feat: expo plugin (#375)

This commit is contained in:
Bereket Engida
2024-11-01 09:24:14 +03:00
committed by GitHub
parent 979923d05e
commit cce28070ee
87 changed files with 10116 additions and 2059 deletions

1
.npmrc
View File

@@ -1 +1,2 @@
link-workspace-packages=true link-workspace-packages=true
node-linker=hoisted

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.10.0

View File

@@ -68,8 +68,8 @@
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"prisma": "^5.19.1", "prisma": "^5.19.1",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
"react": "19.0.0-rc-69d4b800-20241021", "react": "^19.0.0-rc-603e6108-20241029",
"react-dom": "19.0.0-rc-69d4b800-20241021", "react-dom": "^19.0.0-rc-603e6108-20241029",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-qr-code": "^2.0.15", "react-qr-code": "^2.0.15",
"react-resizable-panels": "^2.1.2", "react-resizable-panels": "^2.1.2",
@@ -86,8 +86,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18.3.1",
"@types/react-dom": "^18", "@types/react-dom": "^18.3.1",
"@types/three": "^0.168.0", "@types/three": "^0.168.0",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",

View File

@@ -188,8 +188,9 @@ export default function Features({ stars }: { stars: string | null }) {
"svelteKit", "svelteKit",
"astro", "astro",
"solidStart", "solidStart",
"react", // "react",
"hono", // "hono",
"expo",
"tanstack", "tanstack",
]} ]}
/> />

View File

@@ -392,4 +392,18 @@ export const Icons = {
</g> </g>
</svg> </svg>
), ),
expo: (props?: SVGProps<any>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 32 32"
{...props}
>
<path
fill="currentColor"
d="M24.292 15.547a3.93 3.93 0 0 0 4.115-3.145a2.57 2.57 0 0 0-2.161-1.177c-2.272-.052-3.491 2.651-1.953 4.323zm-9.177-10.85l5.359-3.104L18.766.63l-7.391 4.281l.589.328l1.119.629l2.032-1.176zm6.046-3.39c.089.027.161.1.188.188l2.484 7.593a.285.285 0 0 1-.125.344a5.06 5.06 0 0 0-2.317 5.693a5.066 5.066 0 0 0 5.401 3.703a.3.3 0 0 1 .307.203l2.563 7.803a.3.3 0 0 1-.125.344l-7.859 4.771a.3.3 0 0 1-.131.036a.26.26 0 0 1-.203-.041l-2.765-1.797a.3.3 0 0 1-.109-.129l-5.396-12.896l-8.219 4.875c-.016.011-.037.021-.052.032a.3.3 0 0 1-.261-.021l-1.859-1.093a.283.283 0 0 1-.115-.381l7.953-15.749a.27.27 0 0 1 .135-.131L18.615.045a.29.29 0 0 1 .292-.005zm-8.322 5.1l-1.932-1.089l-7.693 15.229l1.396.823l6.631-9.015a.28.28 0 0 1 .271-.12a.29.29 0 0 1 .235.177l7.228 17.296l1.933 1.251l-8.063-24.552zm13.406 10.557c-2.256 0-3.787-2.292-2.923-4.376c.86-2.083 3.563-2.619 5.156-1.025c.595.593.928 1.396.928 2.235a3.16 3.16 0 0 1-3.161 3.167z"
></path>
</svg>
),
}; };

View File

@@ -632,6 +632,17 @@ export const contents: Content[] = [
icon: Icons.elysia, icon: Icons.elysia,
href: "/docs/integrations/elysia", href: "/docs/integrations/elysia",
}, },
{
group: true,
title: "Mobile & Desktop",
href: "/docs/integrations",
icon: LucideAArrowDown,
},
{
title: "Expo",
icon: Icons.expo,
href: "/docs/integrations/expo",
},
], ],
}, },
{ {

View File

@@ -38,4 +38,8 @@ export const techStackIcons: TechStackIconType = {
name: "TanStack Start", name: "TanStack Start",
icon: <Icons.tanstack className="w-10 h-10" />, icon: <Icons.tanstack className="w-10 h-10" />,
}, },
expo: {
name: "Expo",
icon: <Icons.expo className="w-10 h-10" />,
},
}; };

View File

@@ -236,7 +236,7 @@ Create a new file or route in your framework's designated catch-all route handle
Better Auth supports any backend framework with standard Request and Response objects and offers helper functions for popular frameworks. Better Auth supports any backend framework with standard Request and Response objects and offers helper functions for popular frameworks.
</Callout> </Callout>
<Tabs items={["next-js", "nuxt", "svelte-kit", "remix", "solid-start", "hono", "express", "elysia", "tanstack-start"]} defaultValue="react"> <Tabs items={["next-js", "nuxt", "svelte-kit", "remix", "solid-start", "hono", "express", "elysia", "tanstack", "expo"]} defaultValue="react">
<Tab value="next-js"> <Tab value="next-js">
```ts title="/app/api/auth/[...all]/route.ts" ```ts title="/app/api/auth/[...all]/route.ts"
import { auth } from "@/lib/auth"; // path to your auth file import { auth } from "@/lib/auth"; // path to your auth file
@@ -355,8 +355,7 @@ Better Auth supports any backend framework with standard Request and Response ob
``` ```
</Tab> </Tab>
<Tab value="tanstack-start"> <Tab value="tanstack-start">
```ts ```ts title="app/routes/api/auth/$.ts"
// app/routes/api/auth/$.ts
import { auth } from '~/lib/server/auth' import { auth } from '~/lib/server/auth'
import { createAPIFileRoute } from '@tanstack/start/api' import { createAPIFileRoute } from '@tanstack/start/api'
@@ -370,6 +369,14 @@ Better Auth supports any backend framework with standard Request and Response ob
}); });
``` ```
</Tab> </Tab>
<Tab value="expo">
```ts title="app/api/auth/[..all]+api.ts"
import { auth } from '@/lib/server/auth'; // path to your auth file
const handler = auth.handler;
export { handler as GET, handler as POST };
```
</Tab>
</Tabs> </Tabs>
</Step> </Step>

View File

@@ -0,0 +1,224 @@
---
title: Expo
description: Learn how to use Better Auth with Expo.
---
Expo is a popular framework for building cross-platform apps with React Native. Better Auth supports both Expo native and web apps.
## Installation
<Steps>
<Step>
## Configure A Better Auth Backend
Before using Better Auth with Expo, make sure you have a Better Auth backend set up. You can either use a separate server or leverage Expo's new [API Routes](https://docs.expo.dev/router/reference/api-routes) feature to host your Better Auth instance.
To get started, check out our [installation](/docs/installation) guide for setting up Better Auth on your server.
To use the new API routes feature in Expo to host your Better Auth instance you can create a new API route in your Expo app and mount the Better Auth handler.
```ts title="app/api/auth/[...auth]+api.ts"
import { auth } from "@/lib/auth"; // import Better Auth handler
const handler = auth.handler;
export { handler as GET, handler as POST }; // export handler for both GET and POST requests
```
</Step>
<Step>
## Install Better Auth and Expo Plugin
With your server up and running, install both Better Auth and the Expo plugin in your Expo project.
```package-install
better-auth @better-auth/expo
```
</Step>
<Step>
## Initialize Better Auth Client
To initialize Better Auth in your Expo app, you need to call `createAuthClient` with the base url of your Better Auth backend. Make sure to import the client from `/react`.
```ts title="src/auth-client.ts"
import { createAuthClient } from 'better-auth/react';
const authClient = createAuthClient({
basURL: 'http://localhost:8081', /* base url of your Better Auth backend. */
});
```
<Callout>
Be sure to include the full URL, including the path, if you've changed the default path from `/api/auth`.
</Callout>
</Step>
<Step>
## Configure Metro Bundler
To resolve better auth exports you'll need to enable `unstable_enablePackageExports` in your metro config.
```js title="metro.config.js"
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname)
config.resolver.unstable_enablePackageExports = true; // [!code highlight]
module.exports = config;
```
</Step>
</Steps>
## Usage
### Authenticating Users
With Better Auth initialized, you can now use the `authClient` to authenticate users in your Expo app.
<Tabs items={["sign-in", "sign-up"]}>
<Tab value="sign-in">
```tsx title="app/sign-in.tsx"
import { useState } from 'react';
import { View, TextInput, Button } from 'react-native';
import { authClient } from './auth-client';
export default function App() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async () => {
await authClient.signIn.email({
email,
password,
})
};
return (
<View>
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
/>
<TextInput
placeholder="Password"
value={password}
onChangeText={setPassword}
/>
<Button title="Login" onPress={handleLogin} />
</View>
);
}
```
</Tab>
<Tab value="sign-up">
```tsx title="app/sign-up.tsx"
import { useState } from 'react';
import { View, TextInput, Button } from 'react-native';
import { authClient } from './auth-client';
export default function App() {
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async () => {
await authClient.signUp.email({
email,
password,
name
})
};
return (
<View>
<TextInput
placeholder="Name"
value={name}
onChangeText={setName}
/>
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
/>
<TextInput
placeholder="Password"
value={password}
onChangeText={setPassword}
/>
<Button title="Login" onPress={handleLogin} />
</View>
);
}
```
</Tab>
</Tabs>
For social sign-in, you can use the `authClient.signIn.social` method with the provider name and a callback URL.
```tsx title="app/social-sign-in.tsx"
import { Button } from 'react-native';
export default function App() {
const handleLogin = async () => {
await authClient.signIn.social({
provider: 'google',
callbackURL: "/dashboard" // this will be converted to a deep link (eg. `myapp://dashboard`) on native
})
};
return <Button title="Login with Google" onPress={handleLogin} />;
}
```
### Session
Better Auth provides a `useSession` hook to access the current user's session in your app.
```tsx title="src/App.tsx"
import { authClient } from '@/lib/auth-client';
export default function App() {
const { data: session } = authClient.useSession();
return <Text>Welcome, {data.user.name}</Text>;
}
```
On native, the session data will be cached in SecureStore. This will allow you to remove the need for a loading spinner when the app is reloaded. You can disable this behavior by passing the `disableCache` option to the client.
## Options
**storage**: on native you can pass a storage option to the client to change the storage mechanism. By default, the client will use SecureStore.
```ts title="src/auth-client.ts"
import { createAuthClient } from 'better-auth/react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const authClient = createAuthClient({
basURL: 'http://localhost:8081',
storage: AsyncStorage
});
```
**scheme**: scheme is used to deep link back to your app after a user has authenticated using oAuth providers. By default, Better Auth tries to read the scheme from the `app.json` file. If you need to override this, you can pass the scheme option to the client.
```ts title="src/auth-client.ts"
import { createAuthClient } from 'better-auth/react';
const authClient = createAuthClient({
basURL: 'http://localhost:8081',
scheme: 'myapp'
});
```
**disableCache**: By default, the client will cache the session data in SecureStore. You can disable this behavior by passing the `disableCache` option to the client.
```ts title="src/auth-client.ts"
import { createAuthClient } from 'better-auth/react';
const authClient = createAuthClient({
basURL: 'http://localhost:8081',
disableCache: true
});
```

20
examples/expo-example/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# macOS
.DS_Store
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

View File

@@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

View File

@@ -0,0 +1,54 @@
import type { ConfigContext, ExpoConfig } from "expo/config";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: "Better Auth",
slug: "better-auth",
scheme: "better-auth",
version: "0.1.0",
orientation: "portrait",
icon: "./assets/icon.png",
userInterfaceStyle: "automatic",
splash: {
image: "./assets/icon.png",
resizeMode: "contain",
backgroundColor: "#1F104A",
},
web: {
bundler: "metro",
output: "server",
},
updates: {
fallbackToCacheTimeout: 0,
},
assetBundlePatterns: ["**/*"],
ios: {
bundleIdentifier: "your.bundle.identifier",
supportsTablet: true,
},
android: {
package: "your.bundle.identifier",
adaptiveIcon: {
foregroundImage: "./assets/icon.png",
backgroundColor: "#1F104A",
},
},
// extra: {
// eas: {
// projectId: "your-eas-project-id",
// },
// },
experiments: {
tsconfigPaths: true,
typedRoutes: true,
},
plugins: [
[
"expo-router",
{
origin: "http://localhost:8081",
},
],
"expo-secure-store",
],
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -0,0 +1,6 @@
{
"aliases": {
"components": "@/components",
"lib": "@/lib"
}
}

View File

@@ -0,0 +1 @@
import "expo-router/entry";

View File

@@ -0,0 +1,56 @@
// Learn more: https://docs.expo.dev/guides/monorepos/
const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache");
const { withNativeWind } = require("nativewind/metro");
const path = require("path");
const config = withTurborepoManagedCache(
withMonorepoPaths(
withNativeWind(getDefaultConfig(__dirname), { input: "./src/global.css" }),
),
);
// XXX: Resolve our exports in workspace packages
// https://github.com/expo/expo/issues/26926
config.resolver.unstable_enablePackageExports = true;
module.exports = config;
/**
* Add the monorepo paths to the Metro config.
* This allows Metro to resolve modules from the monorepo.
*
* @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
* @param {import('expo/metro-config').MetroConfig} config
* @returns {import('expo/metro-config').MetroConfig}
*/
function withMonorepoPaths(config) {
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
// #1 - Watch all files in the monorepo
config.watchFolders = [workspaceRoot];
// #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
return config;
}
/**
* Move the Metro cache to the `.cache/metro` folder.
* If you have any environment variables, you can configure Turborepo to invalidate it when needed.
*
* @see https://turbo.build/repo/docs/reference/configuration#env
* @param {import('expo/metro-config').MetroConfig} config
* @returns {import('expo/metro-config').MetroConfig}
*/
function withTurborepoManagedCache(config) {
config.cacheStores = [
new FileStore({ root: path.join(__dirname, ".cache/metro") }),
];
return config;
}

View File

@@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

View File

@@ -0,0 +1,63 @@
{
"name": "expo-example",
"main": "index.ts",
"private": true,
"version": "1.0.0",
"scripts": {
"clean": "git clean -xdf .cache .expo .turbo android ios node_modules",
"start": "expo start",
"dev": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint",
"android": "expo run:android"
},
"dependencies": {
"@better-auth/expo": "workspace:*",
"@expo/vector-icons": "^14.0.2",
"@nanostores/react": "^0.8.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/native": "^6.0.2",
"@rn-primitives/avatar": "^1.1.0",
"@rn-primitives/separator": "^1.1.0",
"@rn-primitives/slot": "^1.1.0",
"@rn-primitives/types": "^1.1.0",
"@types/better-sqlite3": "^7.6.11",
"babel-plugin-transform-import-meta": "^2.2.1",
"better-sqlite3": "^11.5.0",
"expo": "~51.0.38",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.9",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.23",
"expo-secure-store": "~13.0.2",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-web-browser": "~13.0.3",
"nanostores": "^0.11.2",
"nativewind": "^4.1.21",
"pg": "^8.13.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-native": "~0.74.6",
"react-native-gesture-handler": "~2.16.1",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "^15.8.0",
"react-native-web": "~0.19.10",
"tailwindcss": "^3.4.14"
},
"devDependencies": {
"@babel/core": "^7.25.8",
"@babel/preset-env": "^7.25.8",
"@babel/runtime": "^7.25.7",
"@types/babel__core": "^7.20.5",
"@types/jest": "^29.5.12",
"@types/react": "^18.3.1",
"@types/react-test-renderer": "^18.0.7",
"typescript": "~5.3.3"
}
}

View File

@@ -0,0 +1,31 @@
import { Slot, Stack } from "expo-router";
import "../global.css";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { ImageBackground, View } from "react-native";
import { StyleSheet } from "react-native";
export default function RootLayout() {
return (
<SafeAreaProvider>
<ImageBackground
className="z-0 flex items-center justify-center"
source={require("../../assets/bg-image.jpeg")}
resizeMode="cover"
style={StyleSheet.absoluteFill}
>
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
opacity: 0.2,
}}
/>
<Slot />
</ImageBackground>
</SafeAreaProvider>
);
}

View File

@@ -0,0 +1,9 @@
import { auth } from "@/lib/auth";
export const GET = (request: Request) => {
return auth.handler(request);
};
export const POST = (request: Request) => {
return auth.handler(request);
};

View File

@@ -0,0 +1,67 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { View } from "react-native";
import Ionicons from "@expo/vector-icons/AntDesign";
import { router } from "expo-router";
import { useEffect } from "react";
export default function Dashboard() {
const { data: session, error } = authClient.useSession();
useEffect(() => {
if (error) {
router.push("/");
}
}, [error]);
return (
<Card className="w-10/12">
<CardHeader>
<View className="flex-row items-center gap-2">
<Avatar alt="user-image">
<AvatarImage
source={{
uri: session?.user?.image,
}}
/>
<AvatarFallback>
<Text>{session?.user?.name[0]}</Text>
</AvatarFallback>
</Avatar>
<View>
<Text className="font-bold">{session?.user?.name}</Text>
<Text className="text-sm">{session?.user?.email}</Text>
</View>
</View>
</CardHeader>
<CardFooter className="justify-between">
<Button
variant="default"
size="sm"
className="flex-row items-center gap-2 "
>
<Ionicons name="edit" size={16} color="white" />
<Text>Edit User</Text>
</Button>
<Button
variant="secondary"
className="flex-row items-center gap-2"
size="sm"
onPress={async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/");
},
},
});
}}
>
<Ionicons name="logout" size={14} color="black" />
<Text>Sign Out</Text>
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,63 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
import { View } from "react-native";
import Icons from "@expo/vector-icons/AntDesign";
import { router } from "expo-router";
export default function ForgetPassword() {
const [email, setEmail] = useState("");
return (
<Card className="w-10/12 ">
<CardHeader>
<CardTitle>Forget Password</CardTitle>
<CardDescription>
Enter your email to reset your password
</CardDescription>
</CardHeader>
<View className="px-6 mb-2">
<Input
autoCapitalize="none"
placeholder="Email"
value={email}
onChangeText={(text) => setEmail(text)}
/>
</View>
<CardFooter>
<View className="w-full gap-2">
<Button
onPress={() => {
authClient.forgetPassword({
email,
redirectTo: "/reset-password",
});
}}
className="w-full"
variant="default"
>
<Text>Send Email</Text>
</Button>
<Button
onPress={() => {
router.push("/");
}}
className="w-full flex-row gap-4 items-center"
variant="outline"
>
<Icons name="arrowleft" size={18} />
<Text>Back to Sign In</Text>
</Button>
</View>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,132 @@
import Ionicons from "@expo/vector-icons/AntDesign";
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { Image, View } from "react-native";
import { Separator } from "@/components/ui/separator";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react";
import { router, useNavigationContainerRef } from "expo-router";
export default function Index() {
const { data: isAuthenticated } = authClient.useSession();
const navContainerRef = useNavigationContainerRef();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
useEffect(() => {
if (isAuthenticated) {
if (navContainerRef.isReady()) {
router.push("/dashboard");
}
}
}, [isAuthenticated, navContainerRef.isReady()]);
return (
<Card className="z-50 mx-6 backdrop-blur-lg bg-gray-200/70">
<CardHeader className="flex items-center justify-center gap-8">
<Image
source={require("../../assets/images/logo.png")}
style={{
width: 40,
height: 40,
}}
/>
<CardTitle>Sign In to your account</CardTitle>
</CardHeader>
<View className="px-6 flex gap-2">
<Button
onPress={() => {
authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
}}
variant="secondary"
className="flex flex-row gap-2 items-center bg-white/50"
>
<Ionicons name="google" size={16} />
<Text>Sign In with Google</Text>
</Button>
<Button
variant="secondary"
className="flex flex-row gap-2 items-center bg-white/50"
onPress={() => {
authClient.signIn.social({
provider: "github",
callbackURL: "/dashboard",
});
}}
>
<Ionicons name="github" size={16} />
<Text>Sign In with Github</Text>
</Button>
</View>
<View className="flex-row gap-2 w-full items-center px-6 my-4">
<Separator className="flex-grow w-3/12" />
<Text>or continue with</Text>
<Separator className="flex-grow w-3/12" />
</View>
<View className="px-6">
<Input
placeholder="Email Address"
className="rounded-b-none border-b-0"
value={email}
onChangeText={(text) => {
setEmail(text);
}}
/>
<Input
placeholder="Password"
className="rounded-t-none"
secureTextEntry
value={password}
onChangeText={(text) => {
setPassword(text);
}}
/>
</View>
<CardFooter>
<View className="w-full">
<Button
variant="link"
className="w-full"
onPress={() => {
router.push("/forget-password");
}}
>
<Text className="underline text-center">Forget Password?</Text>
</Button>
<Button
onPress={() => {
authClient.signIn.email(
{
email,
password,
},
{
onError: (ctx) => {
alert(ctx.error.message);
},
},
);
}}
>
<Text>Continue</Text>
</Button>
<Text className="text-center mt-2">
Don't have an account?{" "}
<Text
className="underline"
onPress={() => {
router.push("/sign-up");
}}
>
Create Account
</Text>
</Text>
</View>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,102 @@
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { KeyboardAvoidingView, View } from "react-native";
import { Image } from "react-native";
import { useRouter } from "expo-router";
import { useState } from "react";
export default function SignUp() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
return (
<Card className="z-50 mx-6">
<CardHeader className="flex items-center justify-center gap-8">
<Image
source={require("../../assets/images/logo.png")}
style={{
width: 40,
height: 40,
}}
/>
<CardTitle>Create new Account</CardTitle>
</CardHeader>
<View className="px-6">
<KeyboardAvoidingView>
<Input
placeholder="Name"
className="rounded-b-none border-b-0"
value={name}
onChangeText={(text) => {
setName(text);
}}
/>
</KeyboardAvoidingView>
<KeyboardAvoidingView>
<Input
placeholder="Email"
className="rounded-b-none border-b-0"
value={email}
onChangeText={(text) => {
setEmail(text);
}}
autoCapitalize="none"
/>
</KeyboardAvoidingView>
<KeyboardAvoidingView>
<Input
placeholder="Password"
secureTextEntry
className="rounded-t-none"
value={password}
onChangeText={(text) => {
setPassword(text);
}}
/>
</KeyboardAvoidingView>
</View>
<CardFooter>
<View className="w-full mt-2">
<Button
onPress={async () => {
const res = await authClient.signUp.email(
{
email,
password,
name,
},
{
onError: (ctx) => {
alert(ctx.error.message);
},
onSuccess: (ctx) => {
router.push("/dashboard");
},
},
);
console.log(res);
}}
>
<Text>Sign Up</Text>
</Button>
<Text className="text-center mt-2">
Already have an account?{" "}
<Text
className="underline"
onPress={() => {
router.push("/");
}}
>
Sign In
</Text>
</Text>
</View>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,28 @@
import Svg, { Path, SvgProps } from "react-native-svg";
export function GoogleIcon(props: SvgProps) {
return (
<Svg width="1em" height="1em" viewBox="0 0 128 128">
<Path
fill="#fff"
d="M44.59 4.21a63.28 63.28 0 0 0 4.33 120.9a67.6 67.6 0 0 0 32.36.35a57.13 57.13 0 0 0 25.9-13.46a57.44 57.44 0 0 0 16-26.26a74.3 74.3 0 0 0 1.61-33.58H65.27v24.69h34.47a29.72 29.72 0 0 1-12.66 19.52a36.2 36.2 0 0 1-13.93 5.5a41.3 41.3 0 0 1-15.1 0A37.2 37.2 0 0 1 44 95.74a39.3 39.3 0 0 1-14.5-19.42a38.3 38.3 0 0 1 0-24.63a39.25 39.25 0 0 1 9.18-14.91A37.17 37.17 0 0 1 76.13 27a34.3 34.3 0 0 1 13.64 8q5.83-5.8 11.64-11.63c2-2.09 4.18-4.08 6.15-6.22A61.2 61.2 0 0 0 87.2 4.59a64 64 0 0 0-42.61-.38"
></Path>
<Path
fill="#e33629"
d="M44.59 4.21a64 64 0 0 1 42.61.37a61.2 61.2 0 0 1 20.35 12.62c-2 2.14-4.11 4.14-6.15 6.22Q95.58 29.23 89.77 35a34.3 34.3 0 0 0-13.64-8a37.17 37.17 0 0 0-37.46 9.74a39.25 39.25 0 0 0-9.18 14.91L8.76 35.6A63.53 63.53 0 0 1 44.59 4.21"
></Path>
<Path
fill="#f8bd00"
d="M3.26 51.5a63 63 0 0 1 5.5-15.9l20.73 16.09a38.3 38.3 0 0 0 0 24.63q-10.36 8-20.73 16.08a63.33 63.33 0 0 1-5.5-40.9"
></Path>
<Path
fill="#587dbd"
d="M65.27 52.15h59.52a74.3 74.3 0 0 1-1.61 33.58a57.44 57.44 0 0 1-16 26.26c-6.69-5.22-13.41-10.4-20.1-15.62a29.72 29.72 0 0 0 12.66-19.54H65.27c-.01-8.22 0-16.45 0-24.68"
></Path>
<Path
fill="#319f43"
d="M8.75 92.4q10.37-8 20.73-16.08A39.3 39.3 0 0 0 44 95.74a37.2 37.2 0 0 0 14.08 6.08a41.3 41.3 0 0 0 15.1 0a36.2 36.2 0 0 0 13.93-5.5c6.69 5.22 13.41 10.4 20.1 15.62a57.13 57.13 0 0 1-25.9 13.47a67.6 67.6 0 0 1-32.36-.35a63 63 0 0 1-23-11.59A63.7 63.7 0 0 1 8.75 92.4"
></Path>
</Svg>
);
}

View File

@@ -0,0 +1,47 @@
import * as AvatarPrimitive from "@rn-primitives/avatar";
import * as React from "react";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
AvatarPrimitive.RootRef,
AvatarPrimitive.RootProps
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
AvatarPrimitive.ImageRef,
AvatarPrimitive.ImageProps
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
AvatarPrimitive.FallbackRef,
AvatarPrimitive.FallbackProps
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarFallback, AvatarImage };

View File

@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Pressable } from "react-native";
import { cn } from "@/lib/utils";
import { TextClassContext } from "@/components/ui/text";
const buttonVariants = cva(
"group flex items-center justify-center rounded-md web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
{
variants: {
variant: {
default: "bg-primary web:hover:opacity-90 active:opacity-90",
destructive: "bg-destructive web:hover:opacity-90 active:opacity-90",
outline:
"border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
secondary: "bg-secondary web:hover:opacity-80 active:opacity-80",
ghost:
"web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
link: "web:underline-offset-4 web:hover:underline web:focus:underline ",
},
size: {
default: "h-10 px-4 py-2 native:h-12 native:px-5 native:py-3",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8 native:h-14",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const buttonTextVariants = cva(
"web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors",
{
variants: {
variant: {
default: "text-primary-foreground",
destructive: "text-destructive-foreground",
outline: "group-active:text-accent-foreground",
secondary:
"text-secondary-foreground group-active:text-secondary-foreground",
ghost: "group-active:text-accent-foreground",
link: "text-primary group-active:underline",
},
size: {
default: "",
sm: "",
lg: "native:text-lg",
icon: "",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ComponentPropsWithoutRef<typeof Pressable> &
VariantProps<typeof buttonVariants>;
const Button = React.forwardRef<
React.ElementRef<typeof Pressable>,
ButtonProps
>(({ className, variant, size, ...props }, ref) => {
return (
<TextClassContext.Provider
value={buttonTextVariants({
variant,
size,
className: "web:pointer-events-none",
})}
>
<Pressable
className={cn(
props.disabled && "opacity-50 web:pointer-events-none",
buttonVariants({ variant, size, className }),
)}
ref={ref}
role="button"
{...props}
/>
</TextClassContext.Provider>
);
});
Button.displayName = "Button";
export { Button, buttonTextVariants, buttonVariants };
export type { ButtonProps };

View File

@@ -0,0 +1,86 @@
import { TextRef, ViewRef } from "@rn-primitives/types";
import * as React from "react";
import { Text, type TextProps, View, type ViewProps } from "react-native";
import { cn } from "@/lib/utils";
import { TextClassContext } from "@/components/ui/text";
const Card = React.forwardRef<ViewRef, ViewProps>(
({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"rounded-lg border border-border bg-card shadow-sm shadow-foreground/10",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<ViewRef, ViewProps>(
({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<TextRef, TextProps>(
({ className, ...props }, ref) => (
<Text
role="heading"
aria-level={3}
ref={ref}
className={cn(
"text-2xl text-card-foreground font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<TextRef, TextProps>(
({ className, ...props }, ref) => (
<Text
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<ViewRef, ViewProps>(
({ className, ...props }, ref) => (
<TextClassContext.Provider value="text-card-foreground">
<View ref={ref} className={cn("p-6 pt-0", className)} {...props} />
</TextClassContext.Provider>
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<ViewRef, ViewProps>(
({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("flex flex-row items-center p-6 pt-0", className)}
{...props}
/>
),
);
CardFooter.displayName = "CardFooter";
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@@ -0,0 +1,166 @@
import * as DialogPrimitive from "@rn-primitives/dialog";
import * as React from "react";
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { X } from "@/lib/icons/X";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlayWeb = React.forwardRef<
DialogPrimitive.OverlayRef,
DialogPrimitive.OverlayProps
>(({ className, ...props }, ref) => {
const { open } = DialogPrimitive.useRootContext();
return (
<DialogPrimitive.Overlay
className={cn(
"bg-black/80 flex justify-center items-center p-2 absolute top-0 right-0 bottom-0 left-0",
open
? "web:animate-in web:fade-in-0"
: "web:animate-out web:fade-out-0",
className,
)}
{...props}
ref={ref}
/>
);
});
DialogOverlayWeb.displayName = "DialogOverlayWeb";
const DialogOverlayNative = React.forwardRef<
DialogPrimitive.OverlayRef,
DialogPrimitive.OverlayProps
>(({ className, children, ...props }, ref) => {
return (
<DialogPrimitive.Overlay
style={StyleSheet.absoluteFill}
className={cn(
"flex bg-black/80 justify-center items-center p-2",
className,
)}
{...props}
ref={ref}
>
<Animated.View
entering={FadeIn.duration(150)}
exiting={FadeOut.duration(150)}
>
<>{children}</>
</Animated.View>
</DialogPrimitive.Overlay>
);
});
DialogOverlayNative.displayName = "DialogOverlayNative";
const DialogOverlay = Platform.select({
web: DialogOverlayWeb,
default: DialogOverlayNative,
});
const DialogContent = React.forwardRef<
DialogPrimitive.ContentRef,
DialogPrimitive.ContentProps & { portalHost?: string }
>(({ className, children, portalHost, ...props }, ref) => {
const { open } = DialogPrimitive.useRootContext();
return (
<DialogPortal hostName={portalHost}>
<DialogOverlay>
<DialogPrimitive.Content
ref={ref}
className={cn(
"max-w-lg gap-4 border border-border web:cursor-default bg-background p-6 shadow-lg web:duration-200 rounded-lg",
open
? "web:animate-in web:fade-in-0 web:zoom-in-95"
: "web:animate-out web:fade-out-0 web:zoom-out-95",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className={
"absolute right-4 top-4 p-0.5 web:group rounded-sm opacity-70 web:ring-offset-background web:transition-opacity web:hover:opacity-100 web:focus:outline-none web:focus:ring-2 web:focus:ring-ring web:focus:ring-offset-2 web:disabled:pointer-events-none"
}
>
<X
size={Platform.OS === "web" ? 16 : 18}
className={cn(
"text-muted-foreground",
open && "text-accent-foreground",
)}
/>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogOverlay>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: ViewProps) => (
<View
className={cn("flex flex-col gap-1.5 text-center sm:text-left", className)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: ViewProps) => (
<View
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end gap-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
DialogPrimitive.TitleRef,
DialogPrimitive.TitleProps
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg native:text-xl text-foreground font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
DialogPrimitive.DescriptionRef,
DialogPrimitive.DescriptionProps
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm native:text-base text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import { TextInput, type TextInputProps } from "react-native";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<
React.ElementRef<typeof TextInput>,
TextInputProps
>(({ className, placeholderClassName, ...props }, ref) => {
return (
<TextInput
ref={ref}
className={cn(
"web:flex h-10 native:h-12 web:w-full rounded-md border border-input bg-background px-3 web:py-2 text-base lg:text-sm native:text-lg native:leading-[1.25] text-foreground placeholder:text-muted-foreground web:ring-offset-background file:border-0 file:bg-transparent file:font-medium web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
props.editable === false && "opacity-50 web:cursor-not-allowed",
className,
)}
placeholderClassName={cn("text-muted-foreground", placeholderClassName)}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,28 @@
import * as SeparatorPrimitive from "@rn-primitives/separator";
import * as React from "react";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
SeparatorPrimitive.RootRef,
SeparatorPrimitive.RootProps
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,28 @@
import * as Slot from "@rn-primitives/slot";
import { SlottableTextProps, TextRef } from "@rn-primitives/types";
import * as React from "react";
import { Text as RNText } from "react-native";
import { cn } from "@/lib/utils";
const TextClassContext = React.createContext<string | undefined>(undefined);
const Text = React.forwardRef<TextRef, SlottableTextProps>(
({ className, asChild = false, ...props }, ref) => {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
return (
<Component
className={cn(
"text-base text-foreground web:select-text",
textClass,
className,
)}
ref={ref}
{...props}
/>
);
},
);
Text.displayName = "Text";
export { Text, TextClassContext };

View File

@@ -0,0 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,20 @@
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import Constants from "expo-constants";
export const getBaseUrl = () => {
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(":")[0];
return `http://${localhost || "localhost"}:8081`;
};
export const authClient = createAuthClient({
baseURL: getBaseUrl(),
disableDefaultFetchPlugins: true,
plugins: [
expoClient({
scheme: "better-auth",
}),
],
});

View File

@@ -0,0 +1,19 @@
import { betterAuth } from "better-auth";
import { expo } from "@better-auth/expo";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: "postgresql://user:password@localhost:5432/better_auth",
}),
emailAndPassword: {
enabled: true,
},
plugins: [expo()],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
});

View File

@@ -0,0 +1,4 @@
import { X } from "lucide-react-native";
import { iconWithClassName } from "./iconWithClassName";
iconWithClassName(X);
export { X };

View File

@@ -0,0 +1,14 @@
import type { LucideIcon } from "lucide-react-native";
import { cssInterop } from "nativewind";
export function iconWithClassName(icon: LucideIcon) {
cssInterop(icon, {
className: {
target: "style",
nativeStyleToProp: {
color: true,
opacity: true,
},
},
});
}

View File

@@ -0,0 +1,16 @@
import { type ClassValue, clsx } from "clsx";
import { PressableStateCallbackType } from "react-native";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function isTextChildren(
children:
| React.ReactNode
| ((state: PressableStateCallbackType) => React.ReactNode),
) {
return Array.isArray(children)
? children.every((child) => typeof child === "string")
: typeof children === "string";
}

View File

@@ -0,0 +1,75 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
// NOTE: Update this to include the paths to all of your component files.
content: ["./src/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
boxShadow: {
input: `0px 2px 3px -1px rgba(0,0,0,0.1), 0px 1px 0px 0px rgba(25,28,33,0.02), 0px 0px 0px 1px rgba(25,28,33,0.08)`,
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ES2022",
"lib": ["ES2022"],
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"incremental": true,
"disableSourceOfProjectReferenceRedirect": true,
"tsBuildInfoFile": "${configDir}/.cache/tsbuildinfo.json",
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": false,
"types": ["nativewind"],
"module": "es2022",
"moduleResolution": "Bundler",
"noEmit": true,
"jsx": "react-native",
"moduleSuffixes": [".ios", ".android", ".native", ""],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"src",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": ["node_modules", "build", "dist", ".next", ".expo"],
"extends": "expo/tsconfig.base"
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://turborepo.org/schema.json",
"extends": ["//"],
"tasks": {
"dev": {
"persistent": true,
"interactive": true
}
}
}

View File

@@ -35,7 +35,7 @@ export const {
signIn, signIn,
signOut, signOut,
useSession, useSession,
user,
organization, organization,
useListOrganizations, useListOrganizations,
useActiveOrganization, useActiveOrganization,

View File

@@ -12,28 +12,68 @@ import {
import { reactInvitationEmail } from "./email/invitation"; import { reactInvitationEmail } from "./email/invitation";
import { reactResetPasswordEmail } from "./email/rest-password"; import { reactResetPasswordEmail } from "./email/rest-password";
import { resend } from "./email/resend"; import { resend } from "./email/resend";
import { expo } from "@better-auth/expo";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev"; const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const to = process.env.TEST_EMAIL || ""; const to = process.env.TEST_EMAIL || "";
const database = new Database("better-auth.sqlite"); const database = new Database("better-auth.sqlite");
const twoFactorPlugin = twoFactor({
otpOptions: {
async sendOTP(user, otp) {
await resend.emails.send({
from,
to: user.email,
subject: "Your OTP",
html: `Your OTP is ${otp}`,
});
},
},
});
const organizationPlugin = organization({
async sendInvitationEmail(data) {
const res = await resend.emails.send({
from,
to: data.email,
subject: "You've been invited to join an organization",
react: reactInvitationEmail({
username: data.email,
invitedByUsername: data.inviter.user.name,
invitedByEmail: data.inviter.user.email,
teamName: data.organization.name,
inviteLink:
process.env.NODE_ENV === "development"
? `http://localhost:3000/accept-invitation/${data.id}`
: `https://${
process.env.NEXT_PUBLIC_APP_URL ||
process.env.VERCEL_URL ||
process.env.BETTER_AUTH_URL
}/accept-invitation/${data.id}`,
}),
});
console.log(res, data.email);
},
});
export const auth = betterAuth({ export const auth = betterAuth({
database, database,
emailVerification: { emailVerification: {
async sendVerificationEmail(user, url) { async sendVerificationEmail(user, url) {
console.log("Sending verification email to", user.email); console.log("Sending verification email to", user.email);
const res = await resend.emails.send({ // const res = await resend.emails.send({
from, // from,
to: to || user.email, // to: to || user.email,
subject: "Verify your email address", // subject: "Verify your email address",
html: `<a href="${url}">Verify your email address</a>`, // html: `<a href="${url}">Verify your email address</a>`,
}); // });
console.log(res, user.email); // console.log(res, user.email);
}, },
sendOnSignUp: true, sendOnSignUp: true,
}, },
account: { account: {
enabled: true,
accountLinking: { accountLinking: {
trustedProviders: ["google", "github"], trustedProviders: ["google", "github"],
}, },
@@ -60,7 +100,8 @@ export const auth = betterAuth({
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID || "", clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
accessType: "offline", // accessType: "offline",
// prompt: "select_account",
}, },
discord: { discord: {
clientId: process.env.DISCORD_CLIENT_ID || "", clientId: process.env.DISCORD_CLIENT_ID || "",
@@ -76,46 +117,14 @@ export const auth = betterAuth({
}, },
}, },
plugins: [ plugins: [
organization({ organizationPlugin,
async sendInvitationEmail(data) { twoFactorPlugin,
const res = await resend.emails.send({
from,
to: data.email,
subject: "You've been invited to join an organization",
react: reactInvitationEmail({
username: data.email,
invitedByUsername: data.inviter.user.name,
invitedByEmail: data.inviter.user.email,
teamName: data.organization.name,
inviteLink:
process.env.NODE_ENV === "development"
? `http://localhost:3000/accept-invitation/${data.id}`
: `https://${
process.env.NEXT_PUBLIC_APP_URL ||
process.env.VERCEL_URL ||
process.env.BETTER_AUTH_URL
}/accept-invitation/${data.id}`,
}),
});
console.log(res, data.email);
},
}),
twoFactor({
otpOptions: {
async sendOTP(user, otp) {
await resend.emails.send({
from,
to: user.email,
subject: "Your OTP",
html: `Your OTP is ${otp}`,
});
},
},
}),
passkey(), passkey(),
bearer(), bearer(),
admin(), admin(),
multiSession(), multiSession(),
username(), username(),
expo(),
], ],
trustedOrigins: ["better-auth://", "exp://"],
}); });

View File

@@ -1,5 +1,5 @@
{ {
"name": "@better-auth/nextjs", "name": "@example/nextjs",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@libsql/client": "^0.12.0", "@libsql/client": "^0.12.0",
"@better-auth/expo": "workspace:*",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@prisma/adapter-libsql": "^5.19.1", "@prisma/adapter-libsql": "^5.19.1",
"@prisma/client": "^5.19.1", "@prisma/client": "^5.19.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "better-auth", "name": "better-auth",
"version": "0.6.3-beta.3", "version": "0.6.3-beta.4",
"description": "The most comprehensive authentication library for TypeScript.", "description": "The most comprehensive authentication library for TypeScript.",
"type": "module", "type": "module",
"repository": { "repository": {
@@ -9,7 +9,7 @@
"directory": "packages/better-auth" "directory": "packages/better-auth"
}, },
"scripts": { "scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8000 tsup --clean --dts", "build": "cross-env NODE_OPTIONS=--max-old-space-size=8000 tsup --clean --dts --minify",
"dev": "cross-env NODE_OPTIONS='--max-old-space-size=4000' tsup --watch --sourcemap", "dev": "cross-env NODE_OPTIONS='--max-old-space-size=4000' tsup --watch --sourcemap",
"dev:dts": "cross-env NODE_OPTIONS='--max-old-space-size=8192' tsup --watch --dts", "dev:dts": "cross-env NODE_OPTIONS='--max-old-space-size=8192' tsup --watch --dts",
"test": "pnpm prisma:push && vitest", "test": "pnpm prisma:push && vitest",
@@ -63,6 +63,7 @@
}, },
"./react": { "./react": {
"types": "./dist/react.d.ts", "types": "./dist/react.d.ts",
"react-native": "./dist/react.cjs",
"import": "./dist/react.js", "import": "./dist/react.js",
"require": "./dist/react.cjs" "require": "./dist/react.cjs"
}, },
@@ -164,6 +165,9 @@
"client/plugins": [ "client/plugins": [
"./dist/client/plugins.d.ts" "./dist/client/plugins.d.ts"
], ],
"expo": [
"./dist/expo.d.ts"
],
"types": [ "types": [
"./dist/types.d.ts" "./dist/types.d.ts"
], ],
@@ -230,6 +234,7 @@
"pg": "^8.12.0", "pg": "^8.12.0",
"prisma": "^5.19.1", "prisma": "^5.19.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-native": "~0.74.6",
"solid-js": "^1.8.18", "solid-js": "^1.8.18",
"tsup": "^8.2.4", "tsup": "^8.2.4",
"typescript": "5.6.1-rc", "typescript": "5.6.1-rc",
@@ -254,7 +259,6 @@
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nanostores": "^0.11.2", "nanostores": "^0.11.2",
"oslo": "^1.2.1", "oslo": "^1.2.1",
"std-env": "^3.7.0",
"uncrypto": "^0.1.3", "uncrypto": "^0.1.3",
"zod": "^3.22.5" "zod": "^3.22.5"
}, },

View File

@@ -22,26 +22,6 @@ exports[`init > should match config 1`] = `
"secure": false, "secure": false,
}, },
}, },
"nonce": {
"name": "better-auth.nonce",
"options": {
"httpOnly": true,
"maxAge": 900,
"path": "/",
"sameSite": "lax",
"secure": false,
},
},
"pkCodeVerifier": {
"name": "better-auth.pk_code_verifier",
"options": {
"httpOnly": true,
"maxAge": 900,
"path": "/",
"sameSite": "lax",
"secure": false,
},
},
"sessionData": { "sessionData": {
"name": "better-auth.session_data", "name": "better-auth.session_data",
"options": { "options": {
@@ -62,16 +42,6 @@ exports[`init > should match config 1`] = `
"secure": false, "secure": false,
}, },
}, },
"state": {
"name": "better-auth.state",
"options": {
"httpOnly": true,
"maxAge": 900,
"path": "/",
"sameSite": "lax",
"secure": false,
},
},
}, },
"baseURL": "http://localhost:3000/api/auth", "baseURL": "http://localhost:3000/api/auth",
"createAuthCookie": [Function], "createAuthCookie": [Function],

View File

@@ -3,7 +3,7 @@ import { createAuthMiddleware } from "../call";
import { logger } from "../../utils"; import { logger } from "../../utils";
/** /**
* A middleware to validate callbackURL, redirectURL, currentURL and origin against trustedOrigins. * A middleware to validate callbackURL, redirectURL, errorURL, currentURL and origin against trustedOrigins.
*/ */
export const originCheckMiddleware = createAuthMiddleware(async (ctx) => { export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
if (ctx.request?.method !== "POST") { if (ctx.request?.method !== "POST") {

View File

@@ -56,6 +56,7 @@ describe("account", async () => {
}, },
}, },
}); });
const { headers } = await signInWithTestUser(); const { headers } = await signInWithTestUser();
it("should list all accounts", async () => { it("should list all accounts", async () => {
const accounts = await client.listAccounts({ const accounts = await client.listAccounts({
@@ -87,17 +88,14 @@ describe("account", async () => {
); );
expect(linkAccountRes.data).toMatchObject({ expect(linkAccountRes.data).toMatchObject({
url: expect.stringContaining("google.com"), url: expect.stringContaining("google.com"),
codeVerifier: expect.any(String),
state: {
hash: expect.any(String),
raw: expect.any(String),
},
redirect: true, redirect: true,
}); });
const state =
new URL(linkAccountRes.data!.url).searchParams.get("state") || "";
email = "test@test.com"; email = "test@test.com";
await client.$fetch("/callback/google", { await client.$fetch("/callback/google", {
query: { query: {
state: linkAccountRes.data?.state.raw, state,
code: "test", code: "test",
}, },
method: "GET", method: "GET",

View File

@@ -74,44 +74,14 @@ export const linkSocialAccount = createAuthEndpoint(
message: "Provider not found", message: "Provider not found",
}); });
} }
const cookie = c.context.authCookies; const state = await generateState(c);
const currentURL = c.query?.currentURL
? new URL(c.query?.currentURL)
: null;
const callbackURL = c.body.callbackURL?.startsWith("http")
? c.body.callbackURL
: `${currentURL?.origin}${c.body.callbackURL || ""}`;
const state = await generateState(
callbackURL || currentURL?.origin || c.context.options.baseURL,
{
email: session.user.email,
userId: session.user.id,
},
);
await c.setSignedCookie(
cookie.state.name,
state.hash,
c.context.secret,
cookie.state.options,
);
const codeVerifier = generateCodeVerifier();
await c.setSignedCookie(
cookie.pkCodeVerifier.name,
codeVerifier,
c.context.secret,
cookie.pkCodeVerifier.options,
);
const url = await provider.createAuthorizationURL({ const url = await provider.createAuthorizationURL({
state: state.raw, state: state.state,
codeVerifier, codeVerifier: state.codeVerifier,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`, redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
}); });
return c.json({ return c.json({
url: url.toString(), url: url.toString(),
state: state,
codeVerifier,
redirect: true, redirect: true,
}); });
}, },

View File

@@ -7,9 +7,8 @@ import { HIDE_METADATA } from "../../utils/hide-metadata";
import { setSessionCookie } from "../../cookies"; import { setSessionCookie } from "../../cookies";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import type { OAuth2Tokens } from "../../oauth2"; import type { OAuth2Tokens } from "../../oauth2";
import { compareHash } from "../../crypto/hash";
import { createEmailVerificationToken } from "./email-verification"; import { createEmailVerificationToken } from "./email-verification";
import { isDevelopment } from "std-env"; import { isDevelopment } from "../../utils/env";
export const callbackOAuth = createAuthEndpoint( export const callbackOAuth = createAuthEndpoint(
"/callback/:id", "/callback/:id",
@@ -23,20 +22,14 @@ export const callbackOAuth = createAuthEndpoint(
metadata: HIDE_METADATA, metadata: HIDE_METADATA,
}, },
async (c) => { async (c) => {
if (c.query.error || !c.query.code) { if (!c.query.code) {
const parsedState = parseState(c.query.state);
const callbackURL =
parsedState.data?.callbackURL || `${c.context.baseURL}/error`;
c.context.logger.error(c.query.error, c.params.id);
throw c.redirect( throw c.redirect(
`${callbackURL}?error=${c.query.error || "oAuth_code_missing"}`, `${c.context.baseURL}/error?error=${c.query.error || "no_code"}`,
); );
} }
const provider = c.context.socialProviders.find( const provider = c.context.socialProviders.find(
(p) => p.id === c.params.id, (p) => p.id === c.params.id,
); );
if (!provider) { if (!provider) {
c.context.logger.error( c.context.logger.error(
"Oauth provider with id", "Oauth provider with id",
@@ -47,42 +40,7 @@ export const callbackOAuth = createAuthEndpoint(
`${c.context.baseURL}/error?error=oauth_provider_not_found`, `${c.context.baseURL}/error?error=oauth_provider_not_found`,
); );
} }
const { codeVerifier, callbackURL, link, errorURL } = await parseState(c);
const parsedState = parseState(c.query.state);
if (!parsedState.success) {
c.context.logger.error("Unable to parse state");
throw c.redirect(
`${c.context.baseURL}/error?error=please_restart_the_process`,
);
}
const {
data: { callbackURL, currentURL, link },
} = parsedState;
const storedState = await c.getSignedCookie(
c.context.authCookies.state.name,
c.context.secret,
);
if (!storedState) {
logger.error("No stored state found");
throw c.redirect(
`${c.context.baseURL}/error?error=please_restart_the_process`,
);
}
const isValidState = await compareHash(c.query.state, storedState);
if (!isValidState) {
logger.error("OAuth state mismatch");
throw c.redirect(
`${c.context.baseURL}/error?error=please_restart_the_process`,
);
}
const codeVerifier = await c.getSignedCookie(
c.context.authCookies.pkCodeVerifier.name,
c.context.secret,
);
let tokens: OAuth2Tokens; let tokens: OAuth2Tokens;
try { try {
tokens = await provider.validateAuthorizationCode({ tokens = await provider.validateAuthorizationCode({
@@ -130,13 +88,13 @@ export const callbackOAuth = createAuthEndpoint(
if (!newAccount) { if (!newAccount) {
return redirectOnError("unable_to_link_account"); return redirectOnError("unable_to_link_account");
} }
throw c.redirect(callbackURL || currentURL || c.context.options.baseURL!); throw c.redirect(errorURL || callbackURL || c.context.options.baseURL!);
} }
function redirectOnError(error: string) { function redirectOnError(error: string) {
throw c.redirect( throw c.redirect(
`${ `${
currentURL || callbackURL || `${c.context.baseURL}/error` errorURL || callbackURL || `${c.context.baseURL}/error`
}?error=${error}`, }?error=${error}`,
); );
} }
@@ -156,6 +114,7 @@ export const callbackOAuth = createAuthEndpoint(
}); });
let user = dbUser?.user; let user = dbUser?.user;
if (dbUser) { if (dbUser) {
const hasBeenLinked = dbUser.accounts.find( const hasBeenLinked = dbUser.accounts.find(
(a) => a.providerId === provider.id, (a) => a.providerId === provider.id,
@@ -168,7 +127,7 @@ export const callbackOAuth = createAuthEndpoint(
); );
if ( if (
(!isTrustedProvider && !userInfo.emailVerified) || (!isTrustedProvider && !userInfo.emailVerified) ||
!c.context.options.account?.accountLinking?.enabled c.context.options.account?.accountLinking?.enabled === false
) { ) {
if (isDevelopment) { if (isDevelopment) {
logger.warn( logger.warn(
@@ -240,7 +199,6 @@ export const callbackOAuth = createAuthEndpoint(
redirectOnError("unable_to_create_user"); redirectOnError("unable_to_create_user");
} }
} }
if (!user) { if (!user) {
return redirectOnError("unable_to_create_user"); return redirectOnError("unable_to_create_user");
} }
@@ -256,6 +214,8 @@ export const callbackOAuth = createAuthEndpoint(
session, session,
user, user,
}); });
throw c.redirect(callbackURL);
const url = new URL(callbackURL);
throw c.redirect(url.toString());
}, },
); );

View File

@@ -330,7 +330,6 @@ describe("session storage", async () => {
}, },
id: session.data?.session?.id || "", id: session.data?.session?.id || "",
}); });
console.log(res);
const revokedSession = await client.getSession({ const revokedSession = await client.getSession({
fetchOptions: { fetchOptions: {
headers, headers,

View File

@@ -1,12 +1,12 @@
import { APIError } from "better-call"; import { APIError } from "better-call";
import { generateCodeVerifier } from "oslo/oauth2"; import { generateCodeVerifier } from "oslo/oauth2";
import { z } from "zod"; import { z } from "zod";
import { generateState } from "../../oauth2/state";
import { createAuthEndpoint } from "../call"; import { createAuthEndpoint } from "../call";
import { setSessionCookie } from "../../cookies"; import { setSessionCookie } from "../../cookies";
import { socialProviderList } from "../../social-providers"; import { socialProviderList } from "../../social-providers";
import { createEmailVerificationToken } from "./email-verification"; import { createEmailVerificationToken } from "./email-verification";
import { logger } from "../../utils"; import { generateState, logger } from "../../utils";
import { hmac } from "../../crypto/hash";
export const signInOAuth = createAuthEndpoint( export const signInOAuth = createAuthEndpoint(
"/sign-in/social", "/sign-in/social",
@@ -48,40 +48,15 @@ export const signInOAuth = createAuthEndpoint(
message: "Provider not found", message: "Provider not found",
}); });
} }
const cookie = c.context.authCookies; const { codeVerifier, state } = await generateState(c);
const currentURL = c.query?.currentURL
? new URL(c.query?.currentURL)
: null;
const callbackURL = c.body.callbackURL?.startsWith("http")
? c.body.callbackURL
: `${currentURL?.origin}${c.body.callbackURL || ""}`;
const state = await generateState(
callbackURL || currentURL?.origin || c.context.options.baseURL,
);
await c.setSignedCookie(
cookie.state.name,
state.hash,
c.context.secret,
cookie.state.options,
);
const codeVerifier = generateCodeVerifier();
await c.setSignedCookie(
cookie.pkCodeVerifier.name,
codeVerifier,
c.context.secret,
cookie.pkCodeVerifier.options,
);
const url = await provider.createAuthorizationURL({ const url = await provider.createAuthorizationURL({
state: state.raw, state,
codeVerifier, codeVerifier,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`, redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
}); });
return c.json({ return c.json({
url: url.toString(), url: url.toString(),
state: state,
codeVerifier,
redirect: true, redirect: true,
}); });
}, },

View File

@@ -7,9 +7,11 @@ export const redirectPlugin = {
onSuccess(context) { onSuccess(context) {
if (context.data?.url && context.data?.redirect) { if (context.data?.url && context.data?.redirect) {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
if (window.location) {
window.location.href = context.data.url; window.location.href = context.data.url;
} }
} }
}
}, },
}, },
} satisfies BetterFetchPlugin; } satisfies BetterFetchPlugin;
@@ -20,10 +22,12 @@ export const addCurrentURL = {
hooks: { hooks: {
onRequest(context) { onRequest(context) {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
if (window.location) {
const url = new URL(context.url); const url = new URL(context.url);
url.searchParams.set("currentURL", window.location.href); url.searchParams.set("currentURL", window.location.href);
context.url = url; context.url = url;
} }
}
return context; return context;
}, },
}, },

View File

@@ -20,7 +20,7 @@ export type AtomListener = {
signal: "$sessionSignal" | Omit<string, "$sessionSignal">; signal: "$sessionSignal" | Omit<string, "$sessionSignal">;
}; };
interface Store { export interface Store {
notify: (signal: string) => void; notify: (signal: string) => void;
listen: (signal: string, listener: () => void) => void; listen: (signal: string, listener: () => void) => void;
atoms: Record<string, WritableAtom<any>>; atoms: Record<string, WritableAtom<any>>;

View File

@@ -49,12 +49,10 @@ describe("cookies", async () => {
{ {
onResponse(context) { onResponse(context) {
const setCookie = context.response.headers.get("set-cookie"); const setCookie = context.response.headers.get("set-cookie");
console.log(setCookie, context);
expect(setCookie).toContain("Secure"); expect(setCookie).toContain("Secure");
}, },
}, },
); );
console.log(res);
}); });
it("should use secure cookies when the base url is https", async () => { it("should use secure cookies when the base url is https", async () => {

View File

@@ -3,7 +3,7 @@ import { TimeSpan } from "oslo";
import type { BetterAuthOptions } from "../types/options"; import type { BetterAuthOptions } from "../types/options";
import type { GenericEndpointContext } from "../types/context"; import type { GenericEndpointContext } from "../types/context";
import { BetterAuthError } from "../error"; import { BetterAuthError } from "../error";
import { env, isProduction } from "std-env"; import { env, isProduction } from "../utils/env";
import type { Session, User } from "../types"; import type { Session, User } from "../types";
export function getCookies(options: BetterAuthOptions) { export function getCookies(options: BetterAuthOptions) {
@@ -62,28 +62,6 @@ export function getCookies(options: BetterAuthOptions) {
...(crossSubdomainEnabled ? { domain } : {}), ...(crossSubdomainEnabled ? { domain } : {}),
} satisfies CookieOptions, } satisfies CookieOptions,
}, },
state: {
name: `${secureCookiePrefix}${cookiePrefix}.state`,
options: {
httpOnly: true,
sameSite,
path: "/",
secure: !!secureCookiePrefix,
maxAge: 60 * 15,
...(crossSubdomainEnabled ? { domain } : {}),
} satisfies CookieOptions,
},
pkCodeVerifier: {
name: `${secureCookiePrefix}${cookiePrefix}.pk_code_verifier`,
options: {
httpOnly: true,
sameSite,
path: "/",
secure: !!secureCookiePrefix,
maxAge: 60 * 15,
...(crossSubdomainEnabled ? { domain } : {}),
} as CookieOptions,
},
dontRememberToken: { dontRememberToken: {
name: `${secureCookiePrefix}${cookiePrefix}.dont_remember`, name: `${secureCookiePrefix}${cookiePrefix}.dont_remember`,
options: { options: {
@@ -95,17 +73,6 @@ export function getCookies(options: BetterAuthOptions) {
...(crossSubdomainEnabled ? { domain } : {}), ...(crossSubdomainEnabled ? { domain } : {}),
} as CookieOptions, } as CookieOptions,
}, },
nonce: {
name: `${secureCookiePrefix}${cookiePrefix}.nonce`,
options: {
httpOnly: true,
sameSite,
path: "/",
secure: !!secureCookiePrefix,
maxAge: 60 * 15,
...(crossSubdomainEnabled ? { domain } : {}),
} as CookieOptions,
},
}; };
} }
@@ -246,6 +213,7 @@ export function parseSetCookieHeader(
return cookieMap; return cookieMap;
} }
export function parseCookies(cookieHeader: string) { export function parseCookies(cookieHeader: string) {
const cookies = cookieHeader.split("; "); const cookies = cookieHeader.split("; ");
const cookieMap = new Map<string, string>(); const cookieMap = new Map<string, string>();

View File

@@ -1,4 +1,4 @@
import { sha256 } from "oslo/crypto"; import { HMAC, sha256 } from "oslo/crypto";
import { constantTimeEqual } from "./buffer"; import { constantTimeEqual } from "./buffer";
export async function hashToBase64( export async function hashToBase64(
@@ -20,3 +20,28 @@ export async function compareHash(
const hashBuffer = Buffer.from(hash, "base64"); const hashBuffer = Buffer.from(hash, "base64");
return constantTimeEqual(buffer, hashBuffer); return constantTimeEqual(buffer, hashBuffer);
} }
async function signValue({ value, secret }: { value: string; secret: string }) {
const hmac = new HMAC("SHA-256");
return hmac
.sign(new TextEncoder().encode(secret), new TextEncoder().encode(value))
.then((buffer) => Buffer.from(buffer).toString("base64"));
}
function verifyValue({
value,
signature,
secret,
}: { value: string; signature: string; secret: string }) {
const hmac = new HMAC("SHA-256");
return hmac.verify(
new TextEncoder().encode(secret),
Buffer.from(signature, "base64"),
new TextEncoder().encode(value),
);
}
export const hmac = {
sign: signValue,
verify: verifyValue,
};

View File

@@ -555,7 +555,7 @@ export const createInternalAdapter = (
}, },
"verification", "verification",
); );
return verification; return verification as Verification;
}, },
findVerificationValue: async (identifier: string) => { findVerificationValue: async (identifier: string) => {
const verification = await adapter.findOne<Verification>({ const verification = await adapter.findOne<Verification>({

View File

@@ -4,7 +4,7 @@ import { createKyselyAdapter } from "./adapters/kysely-adapter/dialect";
import { getAdapter } from "./db/utils"; import { getAdapter } from "./db/utils";
import { hashPassword, verifyPassword } from "./crypto/password"; import { hashPassword, verifyPassword } from "./crypto/password";
import { createInternalAdapter } from "./db"; import { createInternalAdapter } from "./db";
import { env, isProduction } from "std-env"; import { env, isProduction } from "./utils/env";
import type { import type {
Adapter, Adapter,
BetterAuthOptions, BetterAuthOptions,

View File

@@ -1,37 +1,88 @@
import { generateState as generateStateOAuth } from "oslo/oauth2"; import {
generateCodeVerifier,
generateState as generateSateCode,
} from "oslo/oauth2";
import { z } from "zod"; import { z } from "zod";
import { hashToBase64 } from "../crypto/hash"; import { hmac } from "../crypto/hash";
import type { GenericEndpointContext } from "../types";
import { APIError } from "better-call";
import { logger } from "../utils";
import { checkURLValidity } from "../utils/url";
export async function generateState( export async function generateState(c: GenericEndpointContext) {
callbackURL?: string, const callbackURL = c.body?.callbackURL;
if (!callbackURL) {
throw new APIError("BAD_REQUEST", {
message: "callbackURL is required",
});
}
const codeVerifier = generateCodeVerifier();
const state = generateSateCode();
const data = JSON.stringify({
callbackURL,
codeVerifier,
errorURL: c.query?.currentURL,
});
const verification = await c.context.internalAdapter.createVerificationValue({
value: data,
identifier: state,
expiresAt: new Date(Date.now() + 1000 * 60 * 10),
});
if (!verification) {
logger.error(
"Unable to create verification. Make sure the database adapter is properly working and there is a verification table in the database",
);
throw new APIError("INTERNAL_SERVER_ERROR", {
message: "Unable to create verification",
});
}
return {
state: verification.identifier,
codeVerifier,
};
}
export async function parseState(c: GenericEndpointContext) {
const state = c.query.state;
const data = await c.context.internalAdapter.findVerificationValue(state);
if (!data || data.expiresAt < new Date()) {
if (data) {
await c.context.internalAdapter.deleteVerificationValue(data.id);
logger.error("State expired.", {
state,
});
} else {
logger.error("State Mismatch. Verification not found", {
state,
});
}
throw c.redirect(
`${c.context.baseURL}/error?error=please_restart_the_process`,
);
}
const parsedData = z
.object({
callbackURL: z.string(),
codeVerifier: z.string(),
errorURL: z.string().optional(),
})
.parse(JSON.parse(data.value));
if (!parsedData.errorURL) {
parsedData.errorURL = `${c.context.baseURL}/error`;
}
const isFullURL = checkURLValidity(parsedData.callbackURL);
if (!isFullURL) {
const origin = new URL(c.context.baseURL).origin;
parsedData.callbackURL = `${origin}${parsedData.callbackURL}`;
}
return parsedData as {
callbackURL: string;
codeVerifier: string;
link?: { link?: {
email: string; email: string;
userId: string; userId: string;
}, };
) { errorURL: string;
const code = generateStateOAuth(); };
const raw = JSON.stringify({
code,
callbackURL,
link,
});
const hash = await hashToBase64(raw);
return { raw, hash };
}
export function parseState(state: string) {
const data = z
.object({
code: z.string(),
callbackURL: z.string().optional(),
currentURL: z.string().optional(),
link: z
.object({
email: z.string(),
userId: z.string(),
})
.optional(),
})
.safeParse(JSON.parse(state));
return data;
} }

View File

@@ -201,23 +201,8 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
const callbackURL = ctx.body.callbackURL?.startsWith("http") const callbackURL = ctx.body.callbackURL?.startsWith("http")
? ctx.body.callbackURL ? ctx.body.callbackURL
: `${currentURL?.origin}${ctx.body.callbackURL || ""}`; : `${currentURL?.origin}${ctx.body.callbackURL || ""}`;
const state = await generateState( const { state, codeVerifier } = await generateState(ctx);
callbackURL || currentURL?.origin || ctx.context.options.baseURL,
);
const cookie = ctx.context.authCookies;
await ctx.setSignedCookie(
cookie.state.name,
state.hash,
ctx.context.secret,
cookie.state.options,
);
const codeVerifier = generateCodeVerifier();
await ctx.setSignedCookie(
cookie.pkCodeVerifier.name,
codeVerifier,
ctx.context.secret,
cookie.pkCodeVerifier.options,
);
const authUrl = await createAuthorizationURL({ const authUrl = await createAuthorizationURL({
id: providerId, id: providerId,
options: { options: {
@@ -226,8 +211,8 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
redirectURI, redirectURI,
}, },
authorizationEndpoint: finalAuthUrl, authorizationEndpoint: finalAuthUrl,
state: state.raw, state,
codeVerifier: codeVerifier, codeVerifier,
scopes: scopes || [], scopes: scopes || [],
disablePkce: !pkce, disablePkce: !pkce,
redirectURI: `${ctx.context.baseURL}/oauth2/callback/${providerId}`, redirectURI: `${ctx.context.baseURL}/oauth2/callback/${providerId}`,
@@ -247,8 +232,6 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
return ctx.json({ return ctx.json({
url: authUrl.toString(), url: authUrl.toString(),
state: state,
codeVerifier,
redirect: true, redirect: true,
}); });
}, },
@@ -265,12 +248,10 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
}, },
async (ctx) => { async (ctx) => {
if (ctx.query.error || !ctx.query.code) { if (ctx.query.error || !ctx.query.code) {
const parsedState = parseState(ctx.query.state);
const callbackURL =
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
ctx.context.logger.error(ctx.query.error, ctx.params.providerId);
throw ctx.redirect( throw ctx.redirect(
`${callbackURL}?error=${ctx.query.error || "oAuth_code_missing"}`, `${ctx.context.baseURL}?error=${
ctx.query.error || "oAuth_code_missing"
}`,
); );
} }
const provider = options.config.find( const provider = options.config.find(
@@ -282,39 +263,12 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
message: `No config found for provider ${ctx.params.providerId}`, message: `No config found for provider ${ctx.params.providerId}`,
}); });
} }
const codeVerifier = await ctx.getSignedCookie(
ctx.context.authCookies.pkCodeVerifier.name,
ctx.context.secret,
);
let tokens: OAuth2Tokens | undefined = undefined; let tokens: OAuth2Tokens | undefined = undefined;
const parsedState = parseState(ctx.query.state); const parsedState = await parseState(ctx);
if (!parsedState.success) {
throw ctx.redirect( const { callbackURL, codeVerifier, errorURL } = parsedState;
`${ctx.context.baseURL}/error?error=invalid_state`,
);
}
const state = ctx.query.state;
const {
data: { callbackURL, currentURL },
} = parsedState;
const code = ctx.query.code; const code = ctx.query.code;
const errorURL =
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
const storedState = await ctx.getSignedCookie(
ctx.context.authCookies.state.name,
ctx.context.secret,
);
if (!storedState) {
logger.error("No stored state found");
throw ctx.redirect(`${errorURL}?error=please_restart_the_process`);
}
const isValidState = await compareHash(state, storedState);
if (!isValidState) {
logger.error("OAuth code mismatch");
throw ctx.redirect(`${errorURL}?error=please_restart_the_process`);
}
let finalTokenUrl = provider.tokenUrl; let finalTokenUrl = provider.tokenUrl;
let finalUserInfoUrl = provider.userInfoUrl; let finalUserInfoUrl = provider.userInfoUrl;
if (provider.discoveryUrl) { if (provider.discoveryUrl) {
@@ -406,7 +360,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
) { ) {
let url: URL; let url: URL;
try { try {
url = new URL(errorURL); url = new URL(errorURL!);
url.searchParams.set("error", "account_not_linked"); url.searchParams.set("error", "account_not_linked");
} catch (e) { } catch (e) {
throw ctx.redirect(`${errorURL}?error=account_not_linked`); throw ctx.redirect(`${errorURL}?error=account_not_linked`);
@@ -452,7 +406,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
expiresAt: tokens.accessTokenExpiresAt, expiresAt: tokens.accessTokenExpiresAt,
}); });
} catch (e) { } catch (e) {
const url = new URL(errorURL); const url = new URL(errorURL!);
url.searchParams.set("error", "unable_to_create_user"); url.searchParams.set("error", "unable_to_create_user");
ctx.setHeader("Location", url.toString()); ctx.setHeader("Location", url.toString());
throw ctx.redirect(url.toString()); throw ctx.redirect(url.toString());
@@ -474,7 +428,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
} catch { } catch {
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`); throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
} }
throw ctx.redirect(callbackURL || currentURL || ""); throw ctx.redirect(callbackURL);
}, },
), ),
}, },

View File

@@ -91,54 +91,27 @@ describe("oauth2", async () => {
it("should redirect to the provider and handle the response", async () => { it("should redirect to the provider and handle the response", async () => {
let headers = new Headers(); let headers = new Headers();
const res = await authClient.signIn.oauth2( const signInRes = await authClient.signIn.oauth2({
{
providerId: "test", providerId: "test",
callbackURL: "http://localhost:3000/dashboard", callbackURL: "http://localhost:3000/dashboard",
}, });
{ expect(signInRes.data).toMatchObject({
onSuccess(context) { url: expect.stringContaining("http://localhost:8080/authorize"),
const parsedSetCookie = parseSetCookieHeader( redirect: true,
context.response.headers.get("Set-Cookie") || "", });
const callbackURL = await simulateOAuthFlow(
signInRes.data?.url || "",
headers,
); );
headers.set(
"cookie",
`better-auth.state=${
parsedSetCookie.get("better-auth.state")?.value
}; better-auth.pk_code_verifier=${
parsedSetCookie.get("better-auth.pk_code_verifier")?.value
}`,
);
},
},
);
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
expect(callbackURL).toBe("http://localhost:3000/dashboard"); expect(callbackURL).toBe("http://localhost:3000/dashboard");
}); });
it("should redirect to the provider and handle the response after linked", async () => { it("should redirect to the provider and handle the response after linked", async () => {
let headers = new Headers(); let headers = new Headers();
const res = await authClient.signIn.oauth2( const res = await authClient.signIn.oauth2({
{
providerId: "test", providerId: "test",
callbackURL: "http://localhost:3000/dashboard", callbackURL: "http://localhost:3000/dashboard",
}, });
{
onSuccess(context) {
const parsedSetCookie = parseSetCookieHeader(
context.response.headers.get("Set-Cookie") || "",
);
headers.set(
"cookie",
`better-auth.state=${
parsedSetCookie.get("better-auth.state")?.value
}; better-auth.pk_code_verifier=${
parsedSetCookie.get("better-auth.pk_code_verifier")?.value
}`,
);
},
},
);
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers); const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
expect(callbackURL).toBe("http://localhost:3000/dashboard"); expect(callbackURL).toBe("http://localhost:3000/dashboard");
}); });

View File

@@ -20,7 +20,7 @@ import type { BetterAuthPlugin } from "../../types/plugins";
import { setSessionCookie } from "../../cookies"; import { setSessionCookie } from "../../cookies";
import { BetterAuthError } from "../../error"; import { BetterAuthError } from "../../error";
import { generateId } from "../../utils/id"; import { generateId } from "../../utils/id";
import { env } from "std-env"; import { env } from "../../utils/env";
interface WebAuthnChallengeValue { interface WebAuthnChallengeValue {
expectedChallenge: string; expectedChallenge: string;

View File

@@ -63,34 +63,15 @@ describe("Social Providers", async () => {
const headers = new Headers(); const headers = new Headers();
it("should be able to add social providers", async () => { it("should be able to add social providers", async () => {
const signInRes = await client.signIn.social( const signInRes = await client.signIn.social({
{
provider: "google", provider: "google",
callbackURL: "/callback", callbackURL: "/callback",
}, });
{
onSuccess(context) {
const cookies = parseSetCookieHeader(
context.response.headers.get("set-cookie") || "",
);
headers.set(
"cookie",
`better-auth.state=${cookies.get("better-auth.state")?.value}`,
);
},
},
);
expect(signInRes.data).toMatchObject({ expect(signInRes.data).toMatchObject({
url: expect.stringContaining("google.com"), url: expect.stringContaining("google.com"),
codeVerifier: expect.any(String),
state: {
hash: expect.any(String),
raw: expect.any(String),
},
redirect: true, redirect: true,
}); });
state = signInRes.data?.state.raw || ""; state = new URL(signInRes.data!.url).searchParams.get("state") || "";
}); });
it("should be able to sign in with social providers", async () => { it("should be able to sign in with social providers", async () => {
@@ -100,7 +81,6 @@ describe("Social Providers", async () => {
code: "test", code: "test",
}, },
method: "GET", method: "GET",
headers,
onError(context) { onError(context) {
expect(context.response.status).toBe(302); expect(context.response.status).toBe(302);
const location = context.response.headers.get("location"); const location = context.response.headers.get("location");

View File

@@ -0,0 +1,57 @@
//https://github.com/unjs/std-env/blob/main/src/env.ts
const _envShim = Object.create(null);
export type EnvObject = Record<string, string | undefined>;
const _getEnv = (useShim?: boolean) =>
globalThis.process?.env ||
//@ts-expect-error
globalThis.Deno?.env.toObject() ||
//@ts-expect-error
globalThis.__env__ ||
(useShim ? _envShim : globalThis);
export const env = new Proxy<EnvObject>(_envShim, {
get(_, prop) {
const env = _getEnv();
return env[prop as any] ?? _envShim[prop];
},
has(_, prop) {
const env = _getEnv();
return prop in env || prop in _envShim;
},
set(_, prop, value) {
const env = _getEnv(true);
env[prop as any] = value;
return true;
},
deleteProperty(_, prop) {
if (!prop) {
return false;
}
const env = _getEnv(true);
delete env[prop as any];
return true;
},
ownKeys() {
const env = _getEnv(true);
return Object.keys(env);
},
});
function toBoolean(val: boolean | string | undefined) {
return val ? val !== "false" : false;
}
export const nodeENV =
(typeof process !== "undefined" && process.env && process.env.NODE_ENV) || "";
/** Detect if `NODE_ENV` environment variable is `production` */
export const isProduction = nodeENV === "production";
/** Detect if `NODE_ENV` environment variable is `dev` or `development` */
export const isDevelopment = nodeENV === "dev" || nodeENV === "development";
/** Detect if `NODE_ENV` environment variable is `test` */
export const isTest = nodeENV === "test" || toBoolean(env.TEST);

View File

@@ -1,4 +1,4 @@
import { isTest } from "std-env"; import { isTest } from "../utils/env";
export function getIp(req: Request | Headers): string | null { export function getIp(req: Request | Headers): string | null {
const testIP = "127.0.0.1"; const testIP = "127.0.0.1";

View File

@@ -1,4 +1,4 @@
import { env } from "std-env"; import { env } from "../utils/env";
import { BetterAuthError } from "../error"; import { BetterAuthError } from "../error";
function checkHasPath(url: string): boolean { function checkHasPath(url: string): boolean {
@@ -47,3 +47,8 @@ export function getOrigin(url: string) {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
return parsedUrl.origin.replace("http://", "").replace("https://", ""); return parsedUrl.origin.replace("http://", "").replace("https://", "");
} }
export const checkURLValidity = (url: string) => {
const urlPattern = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;
return urlPattern.test(url);
};

View File

@@ -1,4 +1,5 @@
import { defineConfig } from "tsup"; import { defineConfig } from "tsup";
export default defineConfig((env) => { export default defineConfig((env) => {
return { return {
entry: { entry: {
@@ -28,8 +29,6 @@ export default defineConfig((env) => {
node: "./src/integrations/node.ts", node: "./src/integrations/node.ts",
}, },
format: ["esm", "cjs"], format: ["esm", "cjs"],
minify: true,
splitting: true,
bundle: true, bundle: true,
skipNodeModulesBundle: true, skipNodeModulesBundle: true,
}; };

View File

@@ -1,6 +1,6 @@
{ {
"name": "@better-auth/cli", "name": "@better-auth/cli",
"version": "0.6.3-beta.3", "version": "0.6.3-beta.4",
"description": "The CLI for Better Auth", "description": "The CLI for Better Auth",
"module": "dist/index.mjs", "module": "dist/index.mjs",
"repository": { "repository": {

View File

@@ -0,0 +1,42 @@
{
"name": "@better-auth/expo",
"version": "0.6.3-beta.4",
"description": "",
"main": "dist/index.js",
"module": "dist/index.mjs",
"scripts": {
"test": "vitest",
"build": "tsup --dts --minify --clean",
"dev": "tsup --watch --sourcemap --dts"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.mjs",
"require": "./dist/client.js"
}
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"better-auth": "workspace:*",
"better-sqlite3": "^11.5.0",
"expo-constants": "~16.0.2",
"expo-linking": "~6.3.1",
"expo-secure-store": "~13.0.2",
"expo-web-browser": "~13.0.3",
"vitest": "^1.6.0"
},
"dependencies": {
"nanostores": "^0.11.2"
},
"peerDependencies": {
"better-auth": "workspace:*"
}
}

198
packages/expo/src/client.ts Normal file
View File

@@ -0,0 +1,198 @@
import type { BetterAuthClientPlugin, Store } from "better-auth";
import * as Browser from "expo-web-browser";
import * as Linking from "expo-linking";
import { Platform } from "react-native";
import * as SecureStore from "expo-secure-store";
import Constants from "expo-constants";
interface CookieAttributes {
value: string;
expires?: Date;
"max-age"?: number;
domain?: string;
path?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: "Strict" | "Lax" | "None";
}
function parseSetCookieHeader(header: string): Map<string, CookieAttributes> {
const cookieMap = new Map<string, CookieAttributes>();
const cookies = header.split(", ");
cookies.forEach((cookie) => {
const [nameValue, ...attributes] = cookie.split("; ");
const [name, value] = nameValue.split("=");
const cookieObj: CookieAttributes = { value };
attributes.forEach((attr) => {
const [attrName, attrValue] = attr.split("=");
cookieObj[attrName.toLowerCase() as "value"] = attrValue;
});
cookieMap.set(name, cookieObj);
});
return cookieMap;
}
interface ExpoClientOptions {
scheme?: string;
storage?: {
setItem: (key: string, value: string) => any;
getItem: (key: string) => string | null;
};
storagePrefix?: string;
disableCache?: boolean;
}
interface StoredCookie {
value: string;
expires: Date | null;
}
function getSetCookie(header: string) {
const parsed = parseSetCookieHeader(header);
const toSetCookie: Record<string, StoredCookie> = {};
parsed.forEach((cookie, key) => {
const expiresAt = cookie["expires"];
const maxAge = cookie["max-age"];
const expires = expiresAt
? new Date(String(expiresAt))
: maxAge
? new Date(Date.now() + Number(maxAge))
: null;
toSetCookie[key] = {
value: cookie["value"],
expires,
};
});
return JSON.stringify(toSetCookie);
}
function getCookie(cookie: string) {
let parsed = {} as Record<string, StoredCookie>;
try {
parsed = JSON.parse(cookie) as Record<string, StoredCookie>;
} catch (e) {}
const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
if (value.expires && value.expires < new Date()) {
return acc;
}
return `${acc}; ${key}=${value.value}`;
}, "");
return toSend;
}
function getOrigin(scheme: string) {
const schemeURI = Linking.createURL("", { scheme });
return schemeURI;
}
export const expoClient = (opts: ExpoClientOptions) => {
let store: Store | null = null;
const cookieName = `${opts.storagePrefix || "better-auth"}_cookie`;
const localCacheName = `${opts.storagePrefix || "better-auth"}_session_data`;
const storage = opts.storage || SecureStore;
const scheme = opts.scheme || Constants.platform?.scheme;
const isWeb = Platform.OS === "web";
if (!scheme && !isWeb) {
throw new Error(
"Scheme not found in app.json. Please provide a scheme in the options.",
);
}
return {
id: "expo",
getActions(_, $store) {
if (Platform.OS === "web") return {};
store = $store;
const localSession = storage.getItem(cookieName);
localSession &&
$store.atoms.session.set({
data: JSON.parse(localSession),
error: null,
isPending: false,
});
return {};
},
fetchPlugins: [
{
id: "expo",
name: "Expo",
hooks: {
async onSuccess(context) {
if (isWeb) return;
const setCookie = context.response.headers.get("set-cookie");
if (setCookie) {
const toSetCookie = getSetCookie(setCookie || "");
await storage.setItem(cookieName, toSetCookie);
store?.notify("$sessionSignal");
}
if (
context.request.url.toString().includes("/get-session") &&
!opts.disableCache
) {
const data = context.data;
storage.setItem(localCacheName, JSON.stringify(data));
}
if (
context.data.redirect &&
context.request.url.toString().includes("/sign-in")
) {
const callbackURL = JSON.parse(context.request.body)?.callbackURL;
const to = callbackURL;
const signInURL = context.data?.url;
const result = await Browser.openAuthSessionAsync(signInURL, to);
if (result.type !== "success") return;
const url = new URL(result.url);
const cookie = String(url.searchParams.get("cookie"));
if (!cookie) return;
storage.setItem(cookieName, getSetCookie(cookie));
store?.notify("$sessionSignal");
}
},
},
async init(url, options) {
if (isWeb) {
return {
url,
options,
};
}
options = options || {};
const storedCookie = storage.getItem(cookieName);
const cookie = getCookie(storedCookie || "{}");
options.credentials = "omit";
options.headers = {
...options.headers,
cookie,
origin: getOrigin(scheme!),
};
if (options.body?.callbackURL) {
if (options.body.callbackURL.startsWith("/")) {
const url = Linking.createURL(options.body.callbackURL, {
scheme,
});
options.body.callbackURL = url;
}
}
if (url.includes("/sign-out")) {
await storage.setItem(cookieName, "{}");
store?.atoms.session?.set({
data: null,
error: null,
isPending: false,
});
storage.setItem(localCacheName, "{}");
}
return {
url,
options,
};
},
},
],
} satisfies BetterAuthClientPlugin;
};

View File

@@ -0,0 +1,133 @@
import { createAuthClient } from "better-auth/client";
import Database from "better-sqlite3";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { expo } from ".";
import { expoClient } from "./client";
import { betterAuth } from "better-auth";
import { getMigrations } from "better-auth/db";
vi.mock("expo-web-browser", async () => {
return {
openAuthSessionAsync: vi.fn(async (...args) => {
fn(...args);
return {
type: "success",
url: "better-auth://?cookie=better-auth.session_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYxMzQwZj",
};
}),
};
});
vi.mock("react-native", async () => {
return {
Platform: {
OS: "android",
},
};
});
vi.mock("expo-constants", async () => {
return {
default: {
platform: {
scheme: "better-auth",
},
},
};
});
vi.mock("expo-linking", async () => {
return {
createURL: vi.fn((url) => `better-auth://${url}`),
};
});
vi.mock("expo-secure-store", async () => {
return {
getItemAsync: vi.fn(async (key) => null),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
};
});
const fn = vi.fn();
describe("expo", async () => {
const storage = new Map<string, string>();
const auth = betterAuth({
baseURL: "http://localhost:3000",
database: new Database(":memory:"),
emailAndPassword: {
enabled: true,
},
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
plugins: [expo()],
trustedOrigins: ["better-auth://"],
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl: (url, init) => {
const req = new Request(url.toString(), init);
return auth.handler(req);
},
},
plugins: [
expoClient({
storage: {
getItem: (key) => storage.get(key) || null,
setItem: async (key, value) => storage.set(key, value),
},
}),
],
});
beforeAll(async () => {
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
});
it("should store cookie with expires date", async () => {
const testUser = {
email: "test@test.com",
password: "password",
name: "Test User",
};
await client.signUp.email(testUser);
const storedCookie = storage.get("better-auth_cookie");
expect(storedCookie).toBeDefined();
const parsedCookie = JSON.parse(storedCookie || "");
expect(parsedCookie["better-auth.session_token"]).toMatchObject({
value: expect.any(String),
expires: expect.any(String),
});
});
it("should send cookie and get session", async () => {
const { data } = await client.getSession();
expect(data).toMatchObject({
session: expect.any(Object),
user: expect.any(Object),
});
});
it("should use the scheme to open the browser", async () => {
const { data: res } = await client.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
expect(res).toMatchObject({
url: expect.stringContaining("accounts.google"),
});
expect(fn).toHaveBeenCalledWith(
expect.stringContaining("accounts.google"),
"better-auth:///dashboard",
);
});
});

View File

@@ -0,0 +1,55 @@
import type { BetterAuthPlugin } from "better-auth";
export const expo = () => {
return {
id: "expo",
init: (ctx) => {
const trustedOrigins =
process.env.NODE_ENV === "development"
? [...(ctx.options.trustedOrigins || []), "exp://"]
: ctx.options.trustedOrigins;
return {
options: {
trustedOrigins,
},
};
},
hooks: {
after: [
{
matcher(context) {
return context.path?.startsWith("/callback");
},
handler: async (ctx) => {
const response = ctx.context.returned as Response;
if (response.status === 302) {
const location = response.headers.get("location");
if (!location) {
return;
}
const trustedOrigins = ctx.context.trustedOrigins.filter(
(origin) => !origin.startsWith("http"),
);
const isTrustedOrigin = trustedOrigins.some((origin) =>
location?.startsWith(origin),
);
if (!isTrustedOrigin) {
return;
}
const cookie = response.headers.get("set-cookie");
if (!cookie) {
return;
}
const url = new URL(location);
url.searchParams.set("cookie", cookie);
response.headers.set("location", url.toString());
return {
response,
};
}
},
},
],
},
} satisfies BetterAuthPlugin;
};

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"module": "ESNext",
"noEmit": true,
"moduleResolution": "Bundler",
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
},
"exclude": ["node_modules"],
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from "tsup";
export default defineConfig((env) => {
return {
entry: {
index: "src/index.ts",
client: "src/client.ts",
},
format: ["esm", "cjs"],
bundle: true,
skipNodeModulesBundle: true,
};
});

9287
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff