├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode └── settings.json ├── .watchmanconfig ├── README.md ├── apps ├── api │ ├── .dev.vars.example │ ├── .gitignore │ ├── drizzle.config.ts │ ├── package.json │ ├── src │ │ ├── auth │ │ │ ├── auth.middleware.ts │ │ │ └── lucia-auth.ts │ │ ├── context.ts │ │ ├── controller │ │ │ ├── auth │ │ │ │ ├── apple.ts │ │ │ │ ├── auth.controller.ts │ │ │ │ ├── github.ts │ │ │ │ └── google.ts │ │ │ └── user │ │ │ │ └── user.controller.ts │ │ ├── database │ │ │ ├── db.ts │ │ │ ├── migrations │ │ │ │ ├── 0000_neat_baron_strucker.sql │ │ │ │ └── meta │ │ │ │ │ ├── 0000_snapshot.json │ │ │ │ │ └── _journal.json │ │ │ ├── oauth.accounts.ts │ │ │ ├── schema.ts │ │ │ ├── sessions.ts │ │ │ └── users.ts │ │ ├── env.ts │ │ └── index.ts │ ├── tsconfig.json │ └── wrangler.toml └── expo │ ├── .env.example │ ├── .gitignore │ ├── .tamagui │ └── css │ │ ├── appappApptsx.css │ │ ├── appapplayouttsx.css │ │ └── appauthsignintsx.css │ ├── app.json │ ├── app │ ├── (app) │ │ ├── App.tsx │ │ ├── _layout.tsx │ │ └── index.tsx │ ├── _layout.tsx │ ├── auth │ │ ├── index.tsx │ │ └── sign-in.tsx │ └── index.tsx │ ├── assets │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash.png │ ├── babel.config.js │ ├── eas.json │ ├── index.js │ ├── lib │ ├── api.client.ts │ ├── auth │ │ ├── AuthProvider.tsx │ │ ├── apple.ios.tsx │ │ ├── apple.tsx │ │ ├── github.tsx │ │ ├── google.tsx │ │ └── google.web.tsx │ ├── storage.native.ts │ └── storage.ts │ ├── metro.config.js │ ├── package.json │ ├── tamagui-web.css │ ├── tamagui.config.ts │ └── tsconfig.json ├── bun.lockb ├── package.json ├── packages ├── eslint-config │ ├── base.js │ ├── package.json │ ├── react.js │ └── tsconfig.json ├── prettier-config │ ├── config.mjs │ ├── package.json │ └── tsconfig.json └── tsconfig │ ├── base.json │ └── package.json ├── tsconfig.base.json ├── tsconfig.json └── turbo.json /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | name: Deploy 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: oven-sh/setup-bun@v1 15 | - run: bun install 16 | - name: Deploy 17 | uses: cloudflare/wrangler-action@v3 18 | with: 19 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 20 | workingDirectory: './apps/api' 21 | packageManager: bun 22 | command: deploy ./src/index.ts --env production 23 | - name: Build Web 24 | env: 25 | EXPO_PUBLIC_API_URL: ${{ vars.EXPO_PUBLIC_API_URL }} 26 | EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID: ${{ vars.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID }} 27 | EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: ${{ vars.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID }} 28 | run: bun run --cwd ./apps/expo build:web 29 | - name: Deploy Pages 30 | uses: cloudflare/wrangler-action@v3 31 | 32 | with: 33 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 34 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 35 | workingDirectory: './apps/expo' 36 | packageManager: bun 37 | command: pages deploy ./dist --project-name=expo-lucia-auth-example-web 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | **/node_modules 4 | .yarn/* 5 | !.yarn/patches 6 | !.yarn/plugins 7 | !.yarn/releases 8 | !.yarn/sdks 9 | !.yarn/versions 10 | 11 | # expo 12 | **/.expo/* 13 | 14 | # next.js 15 | **/.next/* 16 | /out/ 17 | 18 | # tamagui 19 | **/.tamagui/* 20 | 21 | !**/.tamagui/css/ 22 | # production 23 | /build 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | .pnpm-debug.log* 30 | 31 | # local env files 32 | .env.local 33 | .env.development.local 34 | .env.test.local 35 | .env.production.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | 43 | # os 44 | .DS_Store 45 | THUMBS_DB 46 | thumbs.db 47 | 48 | # turbo build log 49 | **/.turbo/* 50 | 51 | .env 52 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Expo doesn't play nice with pnpm by default. 2 | # The symbolic links of pnpm break the rules of Expo monorepos. 3 | # @link https://docs.expo.dev/guides/monorepos/#common-issues 4 | node-linker=hoisted 5 | 6 | # Prevent pnpm from adding the "workspace:"" prefix to local 7 | # packages as it causes issues with manypkg 8 | # @link https://pnpm.io/npmrc#prefer-workspace-packages 9 | save-workspace-protocol=false 10 | prefer-workspace-packages=true -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "arrowParens": "always", 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "files.exclude": { 5 | "**/.git": true, 6 | "**/.svn": true, 7 | "**/.hg": true, 8 | "**/CVS": true, 9 | "**/.DS_Store": true, 10 | "**/Thumbs.db": true, 11 | "**/node_modules": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expo Lucia Auth Example 2 | 3 | This project demonstrates how to use Lucia Auth with Expo, Hono, Drizzle (Cloudflare D1), and Tamagui to create a cross-platform mobile application with authentication capabilities. 4 | 5 | Supports iOS, Android, and Web. 6 | 7 | Feedback appreciated! 8 | 9 | ## Preview 10 | 11 | https://expo-lucia-auth-example-web.pages.dev/ 12 | 13 | ## Features 14 | 15 | - OAuth support for Google, Apple and GitHub - easy extensible to other providers 16 | - Automatic and manual Account Linking with OAuth 17 | 18 | ## Getting Started 19 | 20 | ### Installation 21 | 22 | 1. Install dependencies: 23 | ```sh 24 | bun install 25 | ``` 26 | 27 | ### Running the Project 28 | 29 | 1. Start the development server with Bun: 30 | ```sh 31 | bun dev 32 | ``` 33 | 2. Setup Environment Variables: 34 | ```sh 35 | cp ./apps/expo/.env.example ./apps/expo/.env 36 | cp ./apps/api/.dev.vars.example ./apps/api/.dev.vars 37 | ``` 38 | 3. Hono: 39 | ```sh 40 | bun run api 41 | ``` 42 | 4. Expo: 43 | ```sh 44 | cd apps/expo 45 | bun run ios # or bun run android 46 | ``` 47 | 48 | # Setup OAuth Providers 49 | 50 | ## GitHub Sign In 51 | 52 | https://github.com/settings/applications/new 53 | 54 | Authorized callback URL: /auth/github/callback 55 | Replace `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` in the .dev.vars file. 56 | 57 | ## Google Sign In 58 | 59 | For iOS / Android: [Setup EAS Build and Submit to App Store](#setup-eas-build-and-submit-to-app-store) 60 | 61 | https://console.cloud.google.com/apis/credentials 62 | 63 | iOS: 64 | Create OAuth-Client-ID with the iOS Bundle ID. 65 | Copy iOS-URL-Scheme, go to app.json and replace `iosUrlScheme` with your iOS URL scheme. 66 | Copy the Client ID and set it as the `EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID` in the .env file. 67 | 68 | Web: 69 | Authorized JavaScript origins: 70 | Authorized redirect URIs: /auth/google/callback 71 | Replace `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the .dev.vars file. 72 | 73 | ## Apple Sign In Setup 74 | 75 | For iOS / Android: [Setup EAS Build and Submit to App Store](#setup-eas-build-and-submit-to-app-store) 76 | 77 | Go to https://developer.apple.com/account/resources/authkeys/add 78 | 79 | Set a name for the key and enable Sign in with Apple. 80 | Click on Configure and select your app. 81 | Download the key. 82 | 83 | Go to detail page of the key and copy the Key ID. 84 | Get your Team ID from the Membership page. 85 | Client ID is your App ID. 86 | 87 | Web/Android: 88 | Create a service ID with the App ID and enable Sign in with Apple. 89 | https://developer.apple.com/account/resources/identifiers/list/serviceId 90 | Set the redirect URL to /auth/apple/callback 91 | Get the Identifier from the detail page and set it as the `APPLE_WEB_CLIENT_ID` in the .env file. 92 | 93 | Environment Variables: 94 | 95 | ```sh 96 | APPLE_CLIENT_ID=com.expoluciaauth.app 97 | APPLE_WEB_CLIENT_ID=com.expoluciaauth.web 98 | APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----xxx-----END PRIVATE KEY---- 99 | APPLE_TEAM_ID=YOUR_TEAM_ID 100 | APPLE_KEY_ID=YOUR_KEY_ID 101 | ``` 102 | 103 | ## Setup EAS Build and Submit to App Store 104 | 105 | For Android Google Sign In and Apple Sign In you first need to submit your app to the App Store and Google Play Store. 106 | 107 | ```bash 108 | eas build:configure 109 | ``` 110 | 111 | ```bash 112 | eas build --profile production 113 | ``` 114 | 115 | Play Store: 116 | 117 | ```bash 118 | eas submit --platform android 119 | ``` 120 | 121 | If this is your first time submitting to the Play Store via EAS, you first need to create a Google Service Account Key, see https://github.com/expo/fyi/blob/main/creating-google-service-account.md 122 | 123 | The first time you need also do it manually in the Play Console. 124 | https://github.com/expo/fyi/blob/main/first-android-submission.md 125 | -------------------------------------------------------------------------------- /apps/api/.dev.vars.example: -------------------------------------------------------------------------------- 1 | GITHUB_CLIENT_ID=YOUR_GITHUB_CLIENT_ID 2 | GITHUB_CLIENT_SECRET=YOUR_GITHUB_CLIENT_SECRET 3 | GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID 4 | GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_CLIENT_SECRET 5 | APPLE_CLIENT_ID=com.expoluciaauth.web 6 | APPLE_WEB_CLIENT_ID=com.expoluciaauth.web 7 | APPLE_PRIVATE_KEY=YOUR_APPLE_PRIVATE_KEY 8 | APPLE_TEAM_ID=YOUR_APPLE_TEAM_ID 9 | APPLE_KEY_ID=YOUR_APPLE_KEY_ID 10 | API_DOMAIN=http://localhost:8787 11 | WEB_DOMAIN=http://localhost:8081 -------------------------------------------------------------------------------- /apps/api/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .wrangler 4 | .dev.vars 5 | 6 | # Change them to your taste: 7 | package-lock.json 8 | yarn.lock 9 | pnpm-lock.yaml 10 | bun.lockb -------------------------------------------------------------------------------- /apps/api/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | import { defineConfig } from "drizzle-kit"; 3 | 4 | console.log(process.env.LOCAL_DB_PATH); 5 | export default defineConfig( 6 | process.env.LOCAL_DB_PATH 7 | ? ({ 8 | schema: "./src/database/schema.ts", 9 | driver: "better-sqlite", 10 | dbCredentials: { 11 | url: process.env.LOCAL_DB_PATH, 12 | }, 13 | } satisfies Config) 14 | : ({ 15 | schema: "./src/database/schema.ts", 16 | out: "./src/database/migrations", 17 | driver: "d1", 18 | dbCredentials: { 19 | wranglerConfigPath: __dirname + "/wrangler.toml", 20 | dbName: "demo", 21 | }, 22 | } satisfies Config) 23 | ); 24 | -------------------------------------------------------------------------------- /apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "scripts": { 4 | "lint": "eslint . --fix", 5 | "format": "prettier --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"", 6 | "dev": "wrangler dev src/index.ts", 7 | "deploy": "wrangler deploy --minify src/index.ts", 8 | "db:generate": "drizzle-kit generate:sqlite", 9 | "db:migrate": "wrangler d1 migrations apply demo --local", 10 | "db:migrate:prod": "wrangler d1 migrations apply demo --remote", 11 | "db:studio": "drizzle-kit studio", 12 | "db:studio:local": "cross-env LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio" 13 | }, 14 | "dependencies": { 15 | "@hono/zod-validator": "^0.2.1", 16 | "@lucia-auth/adapter-drizzle": "1.0.7", 17 | "@tsndr/cloudflare-worker-jwt": "^2.5.3", 18 | "arctic": "^1.7.0", 19 | "better-sqlite3": "^9.6.0", 20 | "cross-env": "^7.0.3", 21 | "drizzle-orm": "^0.30.9", 22 | "hono": "^4.2.8", 23 | "lucia": "^3.2.0", 24 | "oslo": "^1.2.0", 25 | "zod": "^3.23.4" 26 | }, 27 | "devDependencies": { 28 | "@acme/eslint-config": "workspace:*", 29 | "@acme/prettier-config": "workspace:*", 30 | "@acme/tsconfig": "workspace:*", 31 | "@cloudflare/workers-types": "^4.20240423.0", 32 | "drizzle-kit": "^0.20.17", 33 | "wrangler": "^3.52.0" 34 | }, 35 | "eslintConfig": { 36 | "root": true, 37 | "extends": [ 38 | "@acme/eslint-config/base", 39 | "@acme/eslint-config/react" 40 | ] 41 | }, 42 | "prettier": "@acme/prettier-config" 43 | } 44 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from "hono"; 2 | import { env } from "hono/adapter"; 3 | import type { User } from "lucia"; 4 | import { verifyRequestOrigin } from "lucia"; 5 | 6 | import type { AppContext } from "../context"; 7 | import type { DatabaseUserAttributes } from "./lucia-auth"; 8 | 9 | export const AuthMiddleware = async (c: Context, next: () => Promise) => { 10 | if (c.req.path.startsWith("/auth")) { 11 | return next(); 12 | } 13 | const lucia = c.get("lucia"); 14 | 15 | const originHeader = c.req.header("Origin") ?? c.req.header("origin"); 16 | const hostHeader = c.req.header("Host") ?? c.req.header("X-Forwarded-Host"); 17 | if ( 18 | (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader, env(c).WEB_DOMAIN])) && 19 | env(c).WORKER_ENV === "production" && 20 | c.req.method !== "GET" 21 | ) { 22 | return new Response(null, { 23 | status: 403, 24 | }); 25 | } 26 | 27 | const authorizationHeader = c.req.header("Authorization"); 28 | const bearerSessionId = lucia.readBearerToken(authorizationHeader ?? ""); 29 | const sessionId = bearerSessionId; 30 | if (!sessionId) { 31 | return new Response("Unauthorized", { status: 401 }); 32 | } 33 | const { session, user } = await lucia.validateSession(sessionId); 34 | if (!session) { 35 | return new Response("Unauthorized", { status: 401 }); 36 | } 37 | if (session?.fresh) { 38 | const sessionCookie = lucia.createSessionCookie(session.id); 39 | c.header("Set-Cookie", sessionCookie.serialize()); 40 | } 41 | c.set("user", user as User & DatabaseUserAttributes); 42 | c.set("session", session); 43 | await next(); 44 | }; 45 | -------------------------------------------------------------------------------- /apps/api/src/auth/lucia-auth.ts: -------------------------------------------------------------------------------- 1 | import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; 2 | import type { InferInsertModel } from "drizzle-orm"; 3 | import type { Context } from "hono"; 4 | import { env } from "hono/adapter"; 5 | import { Lucia } from "lucia"; 6 | 7 | import type { AppContext } from "../context"; 8 | import { sessionTable } from "../database/sessions"; 9 | import { userTable } from "../database/users"; 10 | 11 | export const initializeLucia = (c: Context) => { 12 | let lucia = c.get("lucia"); 13 | if (lucia) { 14 | return lucia; 15 | } 16 | const adapter = new DrizzleSQLiteAdapter(c.get("db") as never, sessionTable, userTable); 17 | 18 | lucia = new Lucia(adapter, { 19 | sessionCookie: { 20 | attributes: { 21 | secure: env(c).WORKER_ENV !== "development", 22 | }, 23 | }, 24 | getUserAttributes: (attributes) => { 25 | return { 26 | id: attributes.id, 27 | username: attributes.username, 28 | email: attributes.email, 29 | emailVerified: attributes.emailVerified, 30 | profilePictureUrl: attributes.profilePictureUrl, 31 | }; 32 | }, 33 | }); 34 | c.set("lucia", lucia); 35 | return lucia; 36 | }; 37 | 38 | export type DatabaseUserAttributes = InferInsertModel; 39 | -------------------------------------------------------------------------------- /apps/api/src/context.ts: -------------------------------------------------------------------------------- 1 | import type { Lucia, Session, User } from "lucia"; 2 | 3 | import type { DatabaseUserAttributes, initializeLucia } from "./auth/lucia-auth"; 4 | import type { Database } from "./database/db"; 5 | import type { Env } from "./env"; 6 | 7 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 8 | type Variables = { 9 | db: Database; 10 | user: (User & DatabaseUserAttributes) | null; 11 | session: Session | null; 12 | lucia: Lucia; 13 | }; 14 | 15 | export interface AppContext { 16 | Bindings: Env; 17 | Variables: Variables; 18 | } 19 | 20 | declare module "lucia" { 21 | interface Register { 22 | Lucia: ReturnType; 23 | DatabaseUserAttributes: DatabaseUserAttributes; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/api/src/controller/auth/apple.ts: -------------------------------------------------------------------------------- 1 | import jwt from "@tsndr/cloudflare-worker-jwt"; 2 | import { Apple } from "arctic"; 3 | import type { Context } from "hono"; 4 | import { env } from "hono/adapter"; 5 | import { generateId } from "lucia"; 6 | 7 | import type { DatabaseUserAttributes } from "../../auth/lucia-auth"; 8 | import type { AppContext } from "../../context"; 9 | import { oauthAccountTable } from "../../database/oauth.accounts"; 10 | import { userTable } from "../../database/users"; 11 | 12 | const appleClient = (c: Context) => 13 | new Apple( 14 | { 15 | clientId: env(c).APPLE_WEB_CLIENT_ID, 16 | teamId: env(c).APPLE_TEAM_ID, 17 | keyId: env(c).APPLE_KEY_ID, 18 | certificate: env(c).APPLE_PRIVATE_KEY, 19 | }, 20 | `${env(c).API_DOMAIN}/auth/apple/callback` 21 | ); 22 | 23 | export const getAppleAuthorizationUrl = async ({ c, state }: { c: Context; state: string }) => { 24 | const apple = appleClient(c); 25 | const url = await apple.createAuthorizationURL(state, { 26 | scopes: ["name", "email"], 27 | }); 28 | url.searchParams.set("response_mode", "form_post"); 29 | return url; 30 | }; 31 | 32 | export const createAppleSession = async ({ 33 | c, 34 | idToken, 35 | code, 36 | user, 37 | sessionToken, 38 | }: { 39 | c: Context; 40 | code?: string; 41 | idToken?: string; 42 | sessionToken?: string; 43 | user?: { 44 | username: string; 45 | }; 46 | }) => { 47 | if (!idToken) { 48 | const apple = appleClient(c); 49 | if (!code) { 50 | return null; 51 | } 52 | const tokens = await apple.validateAuthorizationCode(code); 53 | idToken = tokens.idToken; 54 | } 55 | const { payload, header } = jwt.decode< 56 | { 57 | email: string; 58 | email_verified: string; 59 | sub: string; 60 | }, 61 | { kid: string } 62 | >(idToken); 63 | 64 | const applePublicKey = await fetch("https://appleid.apple.com/auth/keys"); 65 | const applePublicKeyJson: { keys: (JsonWebKey & { kid: string })[] } = await applePublicKey.json(); 66 | const publicKey = applePublicKeyJson.keys.find((key) => key.kid === header?.kid); 67 | if (!publicKey) { 68 | return null; 69 | } 70 | const isValid = await jwt.verify(idToken, publicKey, { algorithm: "RS256" }); 71 | 72 | if ( 73 | !isValid || 74 | !payload || 75 | payload.iss !== "https://appleid.apple.com" || 76 | !(payload?.aud === env(c).APPLE_CLIENT_ID || payload.aud === env(c).APPLE_WEB_CLIENT_ID) || 77 | !payload.exp || 78 | payload?.exp < Date.now() / 1000 79 | ) { 80 | return null; 81 | } 82 | const existingAccount = await c.get("db").query.oauthAccounts.findFirst({ 83 | where: (account, { eq }) => eq(account.providerUserId, payload.sub.toString()), 84 | }); 85 | let existingUser: DatabaseUserAttributes | null = null; 86 | if (sessionToken) { 87 | const sessionUser = await c.get("lucia").validateSession(sessionToken); 88 | if (sessionUser.user) { 89 | existingUser = sessionUser.user as DatabaseUserAttributes; 90 | } 91 | } else { 92 | const response = await c.get("db").query.users.findFirst({ 93 | where: (u, { eq }) => eq(u.email, payload.email), 94 | }); 95 | if (response) { 96 | existingUser = response; 97 | } 98 | } 99 | if (existingUser?.emailVerified && payload.email_verified && !existingAccount) { 100 | await c.get("db").insert(oauthAccountTable).values({ 101 | providerUserId: payload.sub.toString(), 102 | provider: "apple", 103 | userId: existingUser.id, 104 | }); 105 | const session = await c.get("lucia").createSession(existingUser.id, {}); 106 | return session; 107 | } 108 | 109 | if (existingAccount) { 110 | const session = await c.get("lucia").createSession(existingAccount.userId, {}); 111 | return session; 112 | } else { 113 | const userId = generateId(15); 114 | let username = user?.username ?? generateId(10); 115 | const existingUsername = await c.get("db").query.users.findFirst({ 116 | where: (u, { eq }) => eq(u.username, username), 117 | }); 118 | if (existingUsername) { 119 | username = `${username}-${generateId(5)}`; 120 | } 121 | await c 122 | .get("db") 123 | .insert(userTable) 124 | .values({ 125 | id: userId, 126 | username, 127 | email: payload.email, 128 | emailVerified: payload.email_verified ? 1 : 0, 129 | profilePictureUrl: null, 130 | }); 131 | 132 | await c.get("db").insert(oauthAccountTable).values({ 133 | providerUserId: payload.sub, 134 | provider: "apple", 135 | userId, 136 | }); 137 | 138 | const session = await c.get("lucia").createSession(userId, {}); 139 | return session; 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /apps/api/src/controller/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { zValidator } from "@hono/zod-validator"; 2 | import { generateCodeVerifier, generateState } from "arctic"; 3 | import { Hono } from "hono"; 4 | import { env } from "hono/adapter"; 5 | import { getCookie, setCookie } from "hono/cookie"; 6 | import { verifyRequestOrigin } from "lucia"; 7 | import type { Session } from "lucia"; 8 | import { z } from "zod"; 9 | 10 | import type { AppContext } from "../../context"; 11 | import { createAppleSession, getAppleAuthorizationUrl } from "./apple"; 12 | import { createGithubSession, getGithubAuthorizationUrl } from "./github"; 13 | import { createGoogleSession, getGoogleAuthorizationUrl } from "./google"; 14 | 15 | const AuthController = new Hono() 16 | .get( 17 | "/:provider", 18 | zValidator("param", z.object({ provider: z.enum(["github", "google", "apple"]) })), 19 | zValidator( 20 | "query", 21 | z 22 | .object({ 23 | redirect: z.enum([ 24 | "com.expoluciaauth.app://", 25 | "http://localhost:8081", 26 | "https://expo-lucia-auth-example-web.pages.dev", 27 | ]), 28 | sessionToken: z.string().optional(), 29 | }) 30 | .default({ redirect: "http://localhost:8081" }) 31 | ), 32 | async (c) => { 33 | const provider = c.req.valid("param").provider; 34 | const redirect = c.req.valid("query").redirect; 35 | const sessionToken = c.req.valid("query").sessionToken; 36 | setCookie(c, "redirect", redirect, { 37 | httpOnly: true, 38 | maxAge: 60 * 10, 39 | path: "/", 40 | secure: env(c).WORKER_ENV === "production", 41 | }); 42 | if (sessionToken) { 43 | const session = await c.get("lucia").validateSession(sessionToken); 44 | if (session.user) { 45 | // for account linking 46 | setCookie(c, "sessionToken", sessionToken, { 47 | httpOnly: true, 48 | maxAge: 60 * 10, // 10 minutes 49 | path: "/", 50 | secure: env(c).WORKER_ENV === "production", 51 | }); 52 | } 53 | } 54 | const state = generateState(); 55 | if (provider === "github") { 56 | const url = await getGithubAuthorizationUrl({ c, state }); 57 | setCookie(c, "github_oauth_state", state, { 58 | httpOnly: true, 59 | maxAge: 60 * 10, 60 | path: "/", 61 | secure: env(c).WORKER_ENV === "production", 62 | }); 63 | return c.redirect(url.toString()); 64 | } else if (provider === "google") { 65 | const codeVerifier = generateCodeVerifier(); 66 | const url = await getGoogleAuthorizationUrl({ c, state, codeVerifier }); 67 | setCookie(c, "google_oauth_state", state, { 68 | httpOnly: true, 69 | maxAge: 60 * 10, 70 | path: "/", 71 | secure: env(c).WORKER_ENV === "production", 72 | }); 73 | setCookie(c, "google_oauth_code_verifier", codeVerifier, { 74 | httpOnly: true, 75 | maxAge: 60 * 10, 76 | path: "/", 77 | secure: env(c).WORKER_ENV === "production", 78 | }); 79 | return c.redirect(url.toString()); 80 | } else if (provider === "apple") { 81 | const url = await getAppleAuthorizationUrl({ c, state }); 82 | setCookie(c, "apple_oauth_state", state, { 83 | httpOnly: true, 84 | maxAge: 60 * 10, 85 | path: "/", 86 | secure: env(c).WORKER_ENV === "production", 87 | sameSite: "None", 88 | }); 89 | return c.redirect(url.toString()); 90 | } 91 | return c.json({}, 400); 92 | } 93 | ) 94 | .all( 95 | "/:provider/callback", 96 | zValidator("param", z.object({ provider: z.enum(["github", "google", "apple"]) })), 97 | async (c) => { 98 | try { 99 | const provider = c.req.valid("param").provider; 100 | let stateCookie = getCookie(c, `${provider}_oauth_state`); 101 | const codeVerifierCookie = getCookie(c, `${provider}_oauth_code_verifier`); 102 | const sessionTokenCookie = getCookie(c, "sessionToken"); 103 | let redirect = getCookie(c, "redirect"); 104 | 105 | const url = new URL(c.req.url); 106 | let state = url.searchParams.get("state"); 107 | let code = url.searchParams.get("code"); 108 | const codeVerifierRequired = ["google"].includes(provider); 109 | if (c.req.method === "POST") { 110 | const formData = await c.req.formData(); 111 | state = formData.get("state"); 112 | stateCookie = state ?? stateCookie; 113 | code = formData.get("code"); 114 | redirect = env(c).WEB_DOMAIN; 115 | } 116 | if ( 117 | !state || 118 | !stateCookie || 119 | !code || 120 | stateCookie !== state || 121 | !redirect || 122 | (codeVerifierRequired && !codeVerifierCookie) 123 | ) { 124 | return c.json({ error: "Invalid request" }, 400); 125 | } 126 | if (provider === "github") { 127 | const session = await createGithubSession({ c, idToken: code, sessionToken: sessionTokenCookie }); 128 | if (!session) { 129 | return c.json({}, 400); 130 | } 131 | const redirectUrl = new URL(redirect); 132 | redirectUrl.searchParams.append("token", session.id); 133 | return c.redirect(redirectUrl.toString()); 134 | } else if (provider === "google") { 135 | const session = await createGoogleSession({ 136 | c, 137 | idToken: code, 138 | codeVerifier: codeVerifierCookie!, 139 | sessionToken: sessionTokenCookie, 140 | }); 141 | if (!session) { 142 | return c.json({}, 400); 143 | } 144 | const redirectUrl = new URL(redirect); 145 | redirectUrl.searchParams.append("token", session.id); 146 | return c.redirect(redirectUrl.toString()); 147 | } else if (provider === "apple") { 148 | const originHeader = c.req.header("Origin"); 149 | const hostHeader = c.req.header("Host"); 150 | if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader, "appleid.apple.com"])) { 151 | return c.json({}, 403); 152 | } 153 | const formData = await c.req.formData(); 154 | const userJSON = formData.get("user"); // only available first time 155 | let user: { username: string } | undefined; 156 | if (userJSON) { 157 | const reqUser = JSON.parse(userJSON) as { 158 | name: { firstName: string; lastName: string }; 159 | email: string; 160 | }; 161 | user = { 162 | username: `${reqUser.name.firstName} ${reqUser.name.lastName}`, 163 | }; 164 | } 165 | const session = await createAppleSession({ 166 | c, 167 | code, 168 | user, 169 | sessionToken: sessionTokenCookie, 170 | }); 171 | if (!session) { 172 | return c.json({}, 400); 173 | } 174 | // always web 175 | const redirectUrl = new URL(redirect); 176 | redirectUrl.searchParams.append("token", session.id); 177 | return c.redirect(redirectUrl.toString()); 178 | } 179 | return c.json({}, 400); 180 | } catch (error) { 181 | console.error(error); 182 | if (error instanceof Error) { 183 | console.error(error.stack); 184 | } 185 | } 186 | } 187 | ) 188 | .post( 189 | "/login/:provider", 190 | zValidator( 191 | "json", 192 | z.object({ 193 | idToken: z.string(), 194 | user: z 195 | .object({ 196 | username: z.string(), 197 | }) 198 | .optional(), 199 | sessionToken: z.string().optional(), 200 | }) 201 | ), 202 | zValidator( 203 | "param", 204 | z.object({ 205 | provider: z.enum(["github", "google", "apple"]), 206 | }) 207 | ), 208 | async (c) => { 209 | const provider = c.req.param("provider"); 210 | const idToken = c.req.valid("json").idToken; 211 | const sessionToken = c.req.valid("json").sessionToken; 212 | let session: Session | null = null; 213 | if (provider === "github") { 214 | session = await createGithubSession({ c, idToken, sessionToken }); 215 | } else if (provider === "google") { 216 | session = await createGoogleSession({ c, idToken, codeVerifier: "", sessionToken }); 217 | } else if (provider === "apple") { 218 | session = await createAppleSession({ c, idToken, user: c.req.valid("json").user, sessionToken }); 219 | } 220 | if (!session) { 221 | return c.json({}, 400); 222 | } 223 | return c.json({ token: session.id }); 224 | } 225 | ) 226 | .post("/logout", async (c) => { 227 | const authorizationHeader = c.req.header("Authorization"); 228 | const bearerSessionId = c.get("lucia").readBearerToken(authorizationHeader ?? ""); 229 | const sessionId = bearerSessionId; 230 | if (!sessionId) { 231 | return c.json({ error: "Not logged in" }, 400); 232 | } 233 | await c.get("lucia").invalidateSession(sessionId); 234 | return c.json(null, 200); 235 | }); 236 | 237 | export { AuthController }; 238 | -------------------------------------------------------------------------------- /apps/api/src/controller/auth/github.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from "arctic"; 2 | import type { Context } from "hono"; 3 | import { env } from "hono/adapter"; 4 | import { generateId } from "lucia"; 5 | 6 | import type { DatabaseUserAttributes } from "../../auth/lucia-auth"; 7 | import type { AppContext } from "../../context"; 8 | import { oauthAccountTable } from "../../database/oauth.accounts"; 9 | import { userTable } from "../../database/users"; 10 | 11 | const githubClient = (c: Context) => new GitHub(env(c).GITHUB_CLIENT_ID, env(c).GITHUB_CLIENT_SECRET); 12 | 13 | export const getGithubAuthorizationUrl = async ({ c, state }: { c: Context; state: string }) => { 14 | const github = githubClient(c); 15 | return await github.createAuthorizationURL(state, { 16 | scopes: ["read:user", "user:email"], 17 | }); 18 | }; 19 | 20 | export const createGithubSession = async ({ 21 | c, 22 | idToken, 23 | sessionToken, 24 | }: { 25 | c: Context; 26 | idToken: string; 27 | sessionToken?: string; 28 | }) => { 29 | const github = githubClient(c); 30 | const tokens = await github.validateAuthorizationCode(idToken); 31 | const githubUserResponse = await fetch("https://api.github.com/user", { 32 | headers: { 33 | "User-Agent": "hono", 34 | Authorization: `Bearer ${tokens.accessToken}`, 35 | }, 36 | }); 37 | 38 | const githubUserResult: { 39 | id: number; 40 | login: string; // username 41 | name: string; 42 | avatar_url: string; 43 | } = await githubUserResponse.json(); 44 | 45 | const userEmailResponse = await fetch("https://api.github.com/user/emails", { 46 | headers: { 47 | "User-Agent": "hono", 48 | Authorization: `Bearer ${tokens.accessToken}`, 49 | }, 50 | }); 51 | 52 | const userEmailResult: { 53 | email: string; 54 | primary: boolean; 55 | verified: boolean; 56 | }[] = await userEmailResponse.json(); 57 | 58 | const primaryEmail = userEmailResult.find((email) => email.primary); 59 | if (!primaryEmail) { 60 | return null; 61 | } 62 | const existingAccount = await c.get("db").query.oauthAccounts.findFirst({ 63 | where: (account, { eq }) => eq(account.providerUserId, githubUserResult.id.toString()), 64 | }); 65 | let existingUser: DatabaseUserAttributes | null = null; 66 | if (sessionToken) { 67 | const sessionUser = await c.get("lucia").validateSession(sessionToken); 68 | if (sessionUser.user) { 69 | existingUser = sessionUser.user as DatabaseUserAttributes; 70 | } 71 | } else { 72 | const response = await c.get("db").query.users.findFirst({ 73 | where: (u, { eq }) => eq(u.email, primaryEmail.email), 74 | }); 75 | if (response) { 76 | existingUser = response; 77 | } 78 | } 79 | if (existingUser?.emailVerified && primaryEmail.verified && !existingAccount) { 80 | await c.get("db").insert(oauthAccountTable).values({ 81 | providerUserId: githubUserResult.id.toString(), 82 | provider: "github", 83 | userId: existingUser.id, 84 | }); 85 | const session = await c.get("lucia").createSession(existingUser.id, {}); 86 | return session; 87 | } 88 | 89 | if (existingAccount) { 90 | const session = await c.get("lucia").createSession(existingAccount.userId, {}); 91 | return session; 92 | } else { 93 | const userId = generateId(15); 94 | let username = githubUserResult.login; 95 | const existingUsername = await c.get("db").query.users.findFirst({ 96 | where: (u, { eq }) => eq(u.username, username), 97 | }); 98 | if (existingUsername) { 99 | username = `${username}-${generateId(5)}`; 100 | } 101 | await c 102 | .get("db") 103 | .insert(userTable) 104 | .values({ 105 | id: userId, 106 | username, 107 | profilePictureUrl: githubUserResult.avatar_url, 108 | email: primaryEmail.email ?? "", 109 | emailVerified: primaryEmail.verified ? 1 : 0, 110 | }); 111 | await c.get("db").insert(oauthAccountTable).values({ 112 | providerUserId: githubUserResult.id.toString(), 113 | provider: "github", 114 | userId, 115 | }); 116 | const session = await c.get("lucia").createSession(userId, {}); 117 | return session; 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /apps/api/src/controller/auth/google.ts: -------------------------------------------------------------------------------- 1 | import { Google } from "arctic"; 2 | import type { Context } from "hono"; 3 | import { env } from "hono/adapter"; 4 | import { generateId } from "lucia"; 5 | 6 | import type { DatabaseUserAttributes } from "../../auth/lucia-auth"; 7 | import type { AppContext } from "../../context"; 8 | import { oauthAccountTable } from "../../database/oauth.accounts"; 9 | import { userTable } from "../../database/users"; 10 | 11 | const googleClient = (c: Context) => 12 | new Google(env(c).GOOGLE_CLIENT_ID, env(c).GOOGLE_CLIENT_SECRET, `${env(c).API_DOMAIN}/auth/google/callback`); 13 | 14 | export const getGoogleAuthorizationUrl = async ({ 15 | c, 16 | state, 17 | codeVerifier, 18 | }: { 19 | c: Context; 20 | state: string; 21 | codeVerifier: string; 22 | }) => { 23 | const google = googleClient(c); 24 | const url = await google.createAuthorizationURL(state, codeVerifier, { scopes: ["profile", "email"] }); 25 | return url.toString(); 26 | }; 27 | 28 | export const createGoogleSession = async ({ 29 | c, 30 | idToken, 31 | codeVerifier, 32 | sessionToken, 33 | }: { 34 | c: Context; 35 | idToken: string; 36 | codeVerifier: string; 37 | sessionToken?: string; 38 | }) => { 39 | const google = googleClient(c); 40 | 41 | const tokens = await google.validateAuthorizationCode(idToken, codeVerifier); 42 | const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", { 43 | headers: { 44 | Authorization: `Bearer ${tokens.accessToken}`, 45 | }, 46 | }); 47 | const user: { 48 | sub: string; 49 | name: string; 50 | email: string; 51 | email_verified: boolean; 52 | picture: string; 53 | } = await response.json(); 54 | const existingAccount = await c.get("db").query.oauthAccounts.findFirst({ 55 | where: (account, { eq }) => eq(account.providerUserId, user.sub.toString()), 56 | }); 57 | let existingUser: DatabaseUserAttributes | null = null; 58 | if (sessionToken) { 59 | const sessionUser = await c.get("lucia").validateSession(sessionToken); 60 | if (sessionUser.user) { 61 | existingUser = sessionUser.user as DatabaseUserAttributes; 62 | } 63 | } else { 64 | const response = await c.get("db").query.users.findFirst({ 65 | where: (u, { eq }) => eq(u.email, user.email), 66 | }); 67 | if (response) { 68 | existingUser = response; 69 | } 70 | } 71 | if (existingUser?.emailVerified && user.email_verified && !existingAccount) { 72 | await c.get("db").insert(oauthAccountTable).values({ 73 | providerUserId: user.sub, 74 | provider: "google", 75 | userId: existingUser.id, 76 | }); 77 | const session = await c.get("lucia").createSession(existingUser.id, {}); 78 | return session; 79 | } 80 | 81 | if (existingAccount) { 82 | const session = await c.get("lucia").createSession(existingAccount.userId, {}); 83 | return session; 84 | } else { 85 | const userId = generateId(15); 86 | let username = user.name; 87 | const existingUsername = await c.get("db").query.users.findFirst({ 88 | where: (u, { eq }) => eq(u.username, username), 89 | }); 90 | if (existingUsername) { 91 | username = `${username}-${generateId(5)}`; 92 | } 93 | await c 94 | .get("db") 95 | .insert(userTable) 96 | .values({ 97 | id: userId, 98 | username, 99 | email: user.email, 100 | emailVerified: user.email_verified ? 1 : 0, 101 | profilePictureUrl: user.picture, 102 | }); 103 | await c.get("db").insert(oauthAccountTable).values({ 104 | providerUserId: user.sub, 105 | provider: "google", 106 | userId, 107 | }); 108 | const session = await c.get("lucia").createSession(userId, {}); 109 | return session; 110 | } 111 | }; 112 | -------------------------------------------------------------------------------- /apps/api/src/controller/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | 3 | import type { AppContext } from "../../context"; 4 | 5 | const UserController = new Hono() 6 | .get("/", (c) => { 7 | const user = c.get("user"); 8 | return c.json(user); 9 | }) 10 | .get("/oauth-accounts", async (c) => { 11 | const oauthAccounts = await c.get("db").query.oauthAccounts.findMany({ 12 | where: (u, { eq }) => eq(u.userId, c.get("user")?.id ?? ""), 13 | }); 14 | return c.json({ 15 | accounts: oauthAccounts.map((oa) => ({ 16 | provider: oa.provider, 17 | })), 18 | }); 19 | }); 20 | 21 | export { UserController }; 22 | -------------------------------------------------------------------------------- /apps/api/src/database/db.ts: -------------------------------------------------------------------------------- 1 | import type { DrizzleD1Database } from "drizzle-orm/d1"; 2 | import { drizzle } from "drizzle-orm/d1"; 3 | import type { Context } from "hono"; 4 | 5 | import type { AppContext } from "../context"; 6 | import * as schema from "./schema"; 7 | 8 | export const initalizeDB = (c: Context) => { 9 | let db = c.get("db"); 10 | if (!db) { 11 | db = drizzle(c.env.DB, { schema }); 12 | c.set("db", db); 13 | } 14 | return db; 15 | }; 16 | 17 | export type Database = DrizzleD1Database; 18 | -------------------------------------------------------------------------------- /apps/api/src/database/migrations/0000_neat_baron_strucker.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `oauth_account` ( 2 | `provider` text NOT NULL, 3 | `provider_user_id` text NOT NULL, 4 | `user_id` text NOT NULL, 5 | PRIMARY KEY(`provider`, `provider_user_id`), 6 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 7 | ); 8 | --> statement-breakpoint 9 | CREATE TABLE `session` ( 10 | `id` text PRIMARY KEY NOT NULL, 11 | `user_id` text NOT NULL, 12 | `expires_at` integer NOT NULL, 13 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 14 | ); 15 | --> statement-breakpoint 16 | CREATE TABLE `user` ( 17 | `id` text PRIMARY KEY NOT NULL, 18 | `username` text NOT NULL, 19 | `email` text NOT NULL, 20 | `email_verified` integer NOT NULL, 21 | `profile_picture_url` text 22 | ); 23 | --> statement-breakpoint 24 | CREATE UNIQUE INDEX `oauth_account_provider_user_id_unique` ON `oauth_account` (`provider_user_id`);--> statement-breakpoint 25 | CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint 26 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); -------------------------------------------------------------------------------- /apps/api/src/database/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "id": "32403a6d-e0c4-4da9-8436-1a9b2609b8d1", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "oauth_account": { 8 | "name": "oauth_account", 9 | "columns": { 10 | "provider": { 11 | "name": "provider", 12 | "type": "text", 13 | "primaryKey": false, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "provider_user_id": { 18 | "name": "provider_user_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "user_id": { 25 | "name": "user_id", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | } 31 | }, 32 | "indexes": { 33 | "oauth_account_provider_user_id_unique": { 34 | "name": "oauth_account_provider_user_id_unique", 35 | "columns": [ 36 | "provider_user_id" 37 | ], 38 | "isUnique": true 39 | } 40 | }, 41 | "foreignKeys": { 42 | "oauth_account_user_id_user_id_fk": { 43 | "name": "oauth_account_user_id_user_id_fk", 44 | "tableFrom": "oauth_account", 45 | "tableTo": "user", 46 | "columnsFrom": [ 47 | "user_id" 48 | ], 49 | "columnsTo": [ 50 | "id" 51 | ], 52 | "onDelete": "no action", 53 | "onUpdate": "no action" 54 | } 55 | }, 56 | "compositePrimaryKeys": { 57 | "oauth_account_provider_provider_user_id_pk": { 58 | "columns": [ 59 | "provider", 60 | "provider_user_id" 61 | ], 62 | "name": "oauth_account_provider_provider_user_id_pk" 63 | } 64 | }, 65 | "uniqueConstraints": {} 66 | }, 67 | "session": { 68 | "name": "session", 69 | "columns": { 70 | "id": { 71 | "name": "id", 72 | "type": "text", 73 | "primaryKey": true, 74 | "notNull": true, 75 | "autoincrement": false 76 | }, 77 | "user_id": { 78 | "name": "user_id", 79 | "type": "text", 80 | "primaryKey": false, 81 | "notNull": true, 82 | "autoincrement": false 83 | }, 84 | "expires_at": { 85 | "name": "expires_at", 86 | "type": "integer", 87 | "primaryKey": false, 88 | "notNull": true, 89 | "autoincrement": false 90 | } 91 | }, 92 | "indexes": {}, 93 | "foreignKeys": { 94 | "session_user_id_user_id_fk": { 95 | "name": "session_user_id_user_id_fk", 96 | "tableFrom": "session", 97 | "tableTo": "user", 98 | "columnsFrom": [ 99 | "user_id" 100 | ], 101 | "columnsTo": [ 102 | "id" 103 | ], 104 | "onDelete": "no action", 105 | "onUpdate": "no action" 106 | } 107 | }, 108 | "compositePrimaryKeys": {}, 109 | "uniqueConstraints": {} 110 | }, 111 | "user": { 112 | "name": "user", 113 | "columns": { 114 | "id": { 115 | "name": "id", 116 | "type": "text", 117 | "primaryKey": true, 118 | "notNull": true, 119 | "autoincrement": false 120 | }, 121 | "username": { 122 | "name": "username", 123 | "type": "text", 124 | "primaryKey": false, 125 | "notNull": true, 126 | "autoincrement": false 127 | }, 128 | "email": { 129 | "name": "email", 130 | "type": "text", 131 | "primaryKey": false, 132 | "notNull": true, 133 | "autoincrement": false 134 | }, 135 | "email_verified": { 136 | "name": "email_verified", 137 | "type": "integer", 138 | "primaryKey": false, 139 | "notNull": true, 140 | "autoincrement": false 141 | }, 142 | "profile_picture_url": { 143 | "name": "profile_picture_url", 144 | "type": "text", 145 | "primaryKey": false, 146 | "notNull": false, 147 | "autoincrement": false 148 | } 149 | }, 150 | "indexes": { 151 | "user_username_unique": { 152 | "name": "user_username_unique", 153 | "columns": [ 154 | "username" 155 | ], 156 | "isUnique": true 157 | }, 158 | "user_email_unique": { 159 | "name": "user_email_unique", 160 | "columns": [ 161 | "email" 162 | ], 163 | "isUnique": true 164 | } 165 | }, 166 | "foreignKeys": {}, 167 | "compositePrimaryKeys": {}, 168 | "uniqueConstraints": {} 169 | } 170 | }, 171 | "enums": {}, 172 | "_meta": { 173 | "schemas": {}, 174 | "tables": {}, 175 | "columns": {} 176 | } 177 | } -------------------------------------------------------------------------------- /apps/api/src/database/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1711820490724, 9 | "tag": "0000_neat_baron_strucker", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /apps/api/src/database/oauth.accounts.ts: -------------------------------------------------------------------------------- 1 | import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | 3 | import { userTable } from "./users"; 4 | 5 | export const oauthAccountTable = sqliteTable( 6 | "oauth_account", 7 | { 8 | provider: text("provider").notNull(), 9 | providerUserId: text("provider_user_id").notNull().unique(), 10 | userId: text("user_id") 11 | .notNull() 12 | .references(() => userTable.id), 13 | }, 14 | (table) => ({ 15 | pk: primaryKey({ columns: [table.provider, table.providerUserId] }), 16 | }) 17 | ); 18 | -------------------------------------------------------------------------------- /apps/api/src/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { oauthAccountTable } from "./oauth.accounts"; 2 | import { sessionTable } from "./sessions"; 3 | import { userTable } from "./users"; 4 | 5 | export { userTable as users, sessionTable as sessions, oauthAccountTable as oauthAccounts }; 6 | -------------------------------------------------------------------------------- /apps/api/src/database/sessions.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | 3 | import { userTable } from "./users"; 4 | 5 | export const sessionTable = sqliteTable("session", { 6 | id: text("id").notNull().primaryKey(), 7 | userId: text("user_id") 8 | .notNull() 9 | .references(() => userTable.id), 10 | expiresAt: integer("expires_at").notNull(), 11 | }); 12 | -------------------------------------------------------------------------------- /apps/api/src/database/users.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | 3 | export const userTable = sqliteTable("user", { 4 | id: text("id").notNull().primaryKey(), 5 | username: text("username").notNull().unique(), 6 | email: text("email").notNull().unique(), 7 | emailVerified: integer("email_verified").notNull(), 8 | profilePictureUrl: text("profile_picture_url"), 9 | }); 10 | -------------------------------------------------------------------------------- /apps/api/src/env.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 2 | export type Env = { 3 | DB: D1Database; 4 | WORKER_ENV: "production" | "development"; 5 | GITHUB_CLIENT_ID: string; 6 | GITHUB_CLIENT_SECRET: string; 7 | GOOGLE_CLIENT_ID: string; 8 | GOOGLE_CLIENT_SECRET: string; 9 | APPLE_CLIENT_ID: string; 10 | APPLE_PRIVATE_KEY: string; 11 | APPLE_TEAM_ID: string; 12 | APPLE_WEB_CLIENT_ID: string; 13 | APPLE_KEY_ID: string; 14 | API_DOMAIN: string; 15 | WEB_DOMAIN: string; 16 | }; 17 | -------------------------------------------------------------------------------- /apps/api/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { env } from "hono/adapter"; 3 | import { cors } from "hono/cors"; 4 | import { logger } from "hono/logger"; 5 | 6 | import { AuthMiddleware } from "./auth/auth.middleware"; 7 | import { initializeLucia } from "./auth/lucia-auth"; 8 | import type { AppContext } from "./context"; 9 | import { AuthController } from "./controller/auth/auth.controller"; 10 | import { UserController } from "./controller/user/user.controller"; 11 | import { initalizeDB } from "./database/db"; 12 | 13 | const app = new Hono(); 14 | 15 | app 16 | .use(logger()) 17 | .use((c, next) => { 18 | const handler = cors({ origin: env(c).WEB_DOMAIN }); 19 | return handler(c, next); 20 | }) 21 | .use((c, next) => { 22 | initalizeDB(c); 23 | initializeLucia(c); 24 | return next(); 25 | }) 26 | .get("/health", (c) => { 27 | return c.json({ status: "ok" }); 28 | }) 29 | .use(AuthMiddleware); 30 | 31 | const routes = app.route("/auth", AuthController).route("/user", UserController); 32 | 33 | export type AppType = typeof routes; 34 | export default app; 35 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@acme/tsconfig/base", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "strict": true, 8 | "lib": ["ESNext"], 9 | "types": ["@cloudflare/workers-types"], 10 | "jsx": "react-jsx", 11 | "jsxImportSource": "hono/jsx" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "expo-lucia-auth-example" 2 | compatibility_date = "2023-12-04" 3 | 4 | vars = { ENVIRONMENT = "development" } 5 | 6 | [[d1_databases]] 7 | binding = "DB" # i.e. available in your Worker on env.DB 8 | database_name = "demo" 9 | database_id = "b016f510-a7f5-4596-a596-836314b25841" 10 | preview_database_id = "DB" 11 | migrations_dir = "./src/database/migrations" 12 | 13 | [env.production] 14 | vars = { ENVIRONMENT = "production" } 15 | d1_databases = [ 16 | { binding = "DB", database_name = "demo", database_id = "b016f510-a7f5-4596-a596-836314b25841" } 17 | ] 18 | 19 | -------------------------------------------------------------------------------- /apps/expo/.env.example: -------------------------------------------------------------------------------- 1 | EXPO_PUBLIC_API_URL=http://localhost:8787 2 | EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=yours.apps.googleusercontent.com 3 | EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=yours.apps.googleusercontent.com -------------------------------------------------------------------------------- /apps/expo/.gitignore: -------------------------------------------------------------------------------- 1 | .expo 2 | 3 | # OSX 4 | # 5 | .DS_Store 6 | 7 | # Xcode 8 | # 9 | build/ 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata 19 | *.xccheckout 20 | *.moved-aside 21 | DerivedData 22 | *.hmap 23 | *.ipa 24 | *.xcuserstate 25 | project.xcworkspace 26 | 27 | # Android/IntelliJ 28 | # 29 | build/ 30 | .idea 31 | .gradle 32 | local.properties 33 | *.iml 34 | *.hprof 35 | 36 | # node.js 37 | # 38 | node_modules/ 39 | npm-debug.log 40 | yarn-error.log 41 | 42 | # BUCK 43 | buck-out/ 44 | \.buckd/ 45 | *.keystore 46 | 47 | # fastlane 48 | # 49 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 50 | # screenshots whenever they are needed. 51 | # For more information about the recommended setup visit: 52 | # https://docs.fastlane.tools/best-practices/source-control/ 53 | 54 | */fastlane/report.xml 55 | */fastlane/Preview.html 56 | */fastlane/screenshots 57 | 58 | # Bundle artifacts 59 | *.jsbundle 60 | 61 | # CocoaPods 62 | /ios/Pods/ 63 | 64 | # Expo 65 | .expo/* 66 | web-build/ 67 | 68 | # @generated expo-cli sync-e7dcf75f4e856f7b6f3239b3f3a7dd614ee755a8 69 | # The following patterns were generated by expo-cli 70 | 71 | # OSX 72 | # 73 | .DS_Store 74 | 75 | # Xcode 76 | # 77 | build/ 78 | *.pbxuser 79 | !default.pbxuser 80 | *.mode1v3 81 | !default.mode1v3 82 | *.mode2v3 83 | !default.mode2v3 84 | *.perspectivev3 85 | !default.perspectivev3 86 | xcuserdata 87 | *.xccheckout 88 | *.moved-aside 89 | DerivedData 90 | *.hmap 91 | *.ipa 92 | *.xcuserstate 93 | project.xcworkspace 94 | 95 | # Android/IntelliJ 96 | # 97 | build/ 98 | .idea 99 | .gradle 100 | local.properties 101 | *.iml 102 | *.hprof 103 | 104 | # node.js 105 | # 106 | node_modules/ 107 | npm-debug.log 108 | yarn-error.log 109 | 110 | # BUCK 111 | buck-out/ 112 | \.buckd/ 113 | *.keystore 114 | !debug.keystore 115 | 116 | # Bundle artifacts 117 | *.jsbundle 118 | 119 | # CocoaPods 120 | /ios/Pods/ 121 | 122 | # Expo 123 | .expo/ 124 | web-build/ 125 | dist/ 126 | 127 | # @end expo-cli 128 | 129 | .env 130 | android/ 131 | ios/ -------------------------------------------------------------------------------- /apps/expo/.tamagui/css/appappApptsx.css: -------------------------------------------------------------------------------- 1 | :root ._dsp-flex{display:flex;} 2 | :root ._fd-column{flex-direction:column;} 3 | :root ._fb-auto{flex-basis:auto;} 4 | :root ._bxs-border-box{box-sizing:border-box;} 5 | :root ._pos-relative{position:relative;} 6 | :root ._mih-0px{min-height:0px;} 7 | :root ._miw-0px{min-width:0px;} 8 | :root ._fs-1{flex-shrink:1;} 9 | :root ._ai-center{align-items:center;} 10 | :root ._fg-1{flex-grow:1;} 11 | :root ._mt-1481558152{margin-top:var(--space-9);} 12 | :root ._mr-1481558152{margin-right:var(--space-9);} 13 | :root ._mb-1481558152{margin-bottom:var(--space-9);} 14 | :root ._ml-1481558152{margin-left:var(--space-9);} 15 | :root ._ai-stretch{align-items:stretch;} 16 | :root ._gap-1481558152{gap:var(--space-9);} 17 | :root ._w-10037{width:100%;} 18 | :root ._maw-500px{max-width:500px;} 19 | :root ._pt-1316330145{padding-top:var(--space-11);} 20 | :root ._pr-1316330145{padding-right:var(--space-11);} 21 | :root ._pb-1316330145{padding-bottom:var(--space-11);} 22 | :root ._pl-1316330145{padding-left:var(--space-11);} 23 | :root ._btlr-1307609967{border-top-left-radius:var(--radius-4);} 24 | :root ._btrr-1307609967{border-top-right-radius:var(--radius-4);} 25 | :root ._bbrr-1307609967{border-bottom-right-radius:var(--radius-4);} 26 | :root ._bblr-1307609967{border-bottom-left-radius:var(--radius-4);} 27 | :root ._bg-white{background-color:white;} 28 | :root ._fs-0{flex-shrink:0;} 29 | :root ._col-675002279{color:var(--color);} 30 | :root ._tt-230632984{text-transform:var(--f-tr-8);} 31 | :root ._ff-299667014{font-family:var(--f-fa);} 32 | :root ._fow-233016264{font-weight:var(--f-we-8);} 33 | :root ._ls-167744183{letter-spacing:var(--f-21-8);} 34 | :root ._fos-229441344{font-size:var(--f-si-8);} 35 | :root ._lh-222976697{line-height:var(--f-li-8);} 36 | :root ._dsp-inline{display:inline;} 37 | :root ._ww-break-word{word-wrap:break-word;} 38 | :root ._whiteSpace-normal{white-space:normal;} 39 | :root ._mt-0px{margin-top:0px;} 40 | :root ._mr-0px{margin-right:0px;} 41 | :root ._ml-0px{margin-left:0px;} 42 | :root ._ussel-auto{user-select:auto;-webkit-user-select:auto;} 43 | :root ._fd-row{flex-direction:row;} 44 | :root ._gap-1316330145{gap:var(--space-11);} 45 | :root ._w-1611761976{width:var(--size-17);} 46 | :root ._h-1611761976{height:var(--size-17);} 47 | :root ._bg-1218753947{background-color:var(--gray11);} 48 | :root ._btlr-999px{border-top-left-radius:999px;} 49 | :root ._btrr-999px{border-top-right-radius:999px;} 50 | :root ._bbrr-999px{border-bottom-right-radius:999px;} 51 | :root ._bblr-999px{border-bottom-left-radius:999px;} 52 | :root ._tt-230632953{text-transform:var(--f-tr-7);} 53 | :root ._fow-233016233{font-weight:var(--f-we-7);} 54 | :root ._ls-167744152{letter-spacing:var(--f-21-7);} 55 | :root ._fos-229441313{font-size:var(--f-si-7);} 56 | :root ._lh-222976666{line-height:var(--f-li-7);} 57 | :root ._mb-0px{margin-bottom:0px;} 58 | :root ._whiteSpace-pre-wrap{white-space:pre-wrap;} 59 | :root ._gap-6px{gap:6px;} 60 | :root ._col-1218753947{color:var(--gray11);} 61 | :root ._jc-441309761{justify-content:space-between;} 62 | :root ._bg-791969402{background-color:var(--gray3);} 63 | :root ._btlr-1307609998{border-top-left-radius:var(--radius-5);} 64 | :root ._btrr-1307609998{border-top-right-radius:var(--radius-5);} 65 | :root ._bbrr-1307609998{border-bottom-right-radius:var(--radius-5);} 66 | :root ._bblr-1307609998{border-bottom-left-radius:var(--radius-5);} 67 | :root ._pt-1481558152{padding-top:var(--space-9);} 68 | :root ._pr-1481558152{padding-right:var(--space-9);} 69 | :root ._pb-1481558152{padding-bottom:var(--space-9);} 70 | :root ._pl-1481558152{padding-left:var(--space-9);} 71 | :root ._col-971197000{color:var(--green10);} 72 | :root ._col-1218753916{color:var(--gray12);} -------------------------------------------------------------------------------- /apps/expo/.tamagui/css/appapplayouttsx.css: -------------------------------------------------------------------------------- 1 | :root ._ff-299667014{font-family:var(--f-fa);} 2 | :root ._dsp-inline{display:inline;} 3 | :root ._bxs-border-box{box-sizing:border-box;} 4 | :root ._ww-break-word{word-wrap:break-word;} 5 | :root ._whiteSpace-pre-wrap{white-space:pre-wrap;} 6 | :root ._mt-0px{margin-top:0px;} 7 | :root ._mr-0px{margin-right:0px;} 8 | :root ._mb-0px{margin-bottom:0px;} 9 | :root ._ml-0px{margin-left:0px;} 10 | :root ._col-675002279{color:var(--color);} -------------------------------------------------------------------------------- /apps/expo/.tamagui/css/appauthsignintsx.css: -------------------------------------------------------------------------------- 1 | :root ._dsp-flex{display:flex;} 2 | :root ._fd-column{flex-direction:column;} 3 | :root ._fb-auto{flex-basis:auto;} 4 | :root ._bxs-border-box{box-sizing:border-box;} 5 | :root ._pos-relative{position:relative;} 6 | :root ._mih-0px{min-height:0px;} 7 | :root ._miw-0px{min-width:0px;} 8 | :root ._fs-1{flex-shrink:1;} 9 | :root ._ai-center{align-items:center;} 10 | :root ._fg-1{flex-grow:1;} 11 | :root ._mt-8px{margin-top:8px;} 12 | :root ._mr-8px{margin-right:8px;} 13 | :root ._mb-8px{margin-bottom:8px;} 14 | :root ._ml-8px{margin-left:8px;} 15 | :root ._ai-stretch{align-items:stretch;} 16 | :root ._gap-12px{gap:12px;} 17 | :root ._w-10037{width:100%;} 18 | :root ._maw-500px{max-width:500px;} -------------------------------------------------------------------------------- /apps/expo/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-lucia-auth", 4 | "slug": "expo-lucia-auth", 5 | "scheme": "com.expoluciaauth.app", 6 | "version": "1.0.0", 7 | "orientation": "portrait", 8 | "icon": "./assets/icon.png", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "updates": { 16 | "fallbackToCacheTimeout": 0 17 | }, 18 | "assetBundlePatterns": ["**/*"], 19 | "ios": { 20 | "usesAppleSignIn": true, 21 | "supportsTablet": true, 22 | "bundleIdentifier": "com.expoluciaauth.app", 23 | "infoPlist": { 24 | "CFBundleURLTypes": [ 25 | { 26 | "CFBundleURLSchemes": [ 27 | "com.googleusercontent.apps.439381367822-h8hin5n5rondhfeb3p5shj7dkl92kcjb", 28 | "com.expoluciaauth.app" 29 | ] 30 | } 31 | ] 32 | } 33 | }, 34 | "android": { 35 | "adaptiveIcon": { 36 | "foregroundImage": "./assets/adaptive-icon.png", 37 | "backgroundColor": "#FFFFFF" 38 | }, 39 | "package": "com.expoluciaauth.app" 40 | }, 41 | "web": { 42 | "favicon": "./assets/favicon.png", 43 | "bundler": "metro" 44 | }, 45 | "plugins": ["expo-router", "expo-apple-authentication"], 46 | "extra": { 47 | "router": { 48 | "origin": false 49 | }, 50 | "eas": { 51 | "projectId": "be167728-cd4a-495b-997e-06f661b1f67e" 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /apps/expo/app/(app)/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Pressable, useColorScheme } from "react-native"; 3 | import { router } from "expo-router"; 4 | import type { InferResponseType } from "hono"; 5 | import { Button, H3, H4, Image, Text, View, XStack, YStack } from "tamagui"; 6 | 7 | import type { Api } from "../../lib/api.client"; 8 | import { useAuth } from "../../lib/auth/AuthProvider"; 9 | 10 | export function App() { 11 | const scheme = useColorScheme(); 12 | const { user, signOut, getOAuthAccounts, signInWithOAuth } = useAuth(); 13 | const [accounts, setAccounts] = useState< 14 | InferResponseType<(typeof Api.client)["user"]["oauth-accounts"]["$get"]>["accounts"] 15 | >([]); 16 | useEffect(() => { 17 | void getOAuthAccounts().then((response) => setAccounts(response)); 18 | }, [getOAuthAccounts]); 19 | return ( 20 | 21 | 30 | {user && ( 31 | 32 |

User Information

33 | 34 | {user.profilePictureUrl ? ( 35 | 36 | ) : ( 37 | 38 | )} 39 | 40 |

{user.username}

41 | {user.email} 42 |
43 |
44 | 45 | User ID: {user.id} 46 | E-Mail Verified: {user.emailVerified ? "yes" : "no"} 47 | 48 |
49 | )} 50 |

OAuth

51 | {["Google", "Apple", "Github"].map((provider) => ( 52 | 60 | {provider} 61 | {accounts.some((account) => account.provider === provider.toLowerCase()) ? ( 62 | Connected 63 | ) : ( 64 | signInWithOAuth({ provider: provider.toLowerCase() })}> 65 | Connect now 66 | 67 | )} 68 | 69 | ))} 70 | 78 |
79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /apps/expo/app/(app)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect, Stack } from "expo-router"; 2 | import { Text } from "tamagui"; 3 | 4 | import { useAuth } from "../../lib/auth/AuthProvider"; 5 | 6 | export default function AppLayout() { 7 | const { loading, user } = useAuth(); 8 | 9 | if (loading) { 10 | return ( 11 | <> 12 | Loading... 13 | 14 | 15 | ); 16 | } 17 | if (!user) { 18 | return ; 19 | } 20 | 21 | return ( 22 | <> 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/expo/app/(app)/index.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | 3 | import { App } from "./App"; 4 | 5 | export default function Screen() { 6 | return ( 7 | <> 8 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/expo/app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from "react-native"; 2 | import { useFonts } from "expo-font"; 3 | import { Stack } from "expo-router"; 4 | import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"; 5 | import { TamaguiProvider } from "tamagui"; 6 | 7 | import "../tamagui-web.css"; 8 | 9 | import { AuthProvider } from "../lib/auth/AuthProvider"; 10 | import config from "../tamagui.config"; 11 | 12 | export default function HomeLayout() { 13 | const [loaded] = useFonts({ 14 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 15 | Inter: require("@tamagui/font-inter/otf/Inter-Medium.otf"), 16 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 17 | InterBold: require("@tamagui/font-inter/otf/Inter-Bold.otf"), 18 | }); 19 | const scheme = useColorScheme(); 20 | if (!loaded) { 21 | return null; 22 | } 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/expo/app/auth/index.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from "expo-router"; 2 | 3 | export default function Auth() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /apps/expo/app/auth/sign-in.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | import { View, YStack } from "tamagui"; 3 | 4 | import { AppleSignIn } from "../../lib/auth/apple"; 5 | import { GithubSignIn } from "../../lib/auth/github"; 6 | import { GoogleSignIn } from "../../lib/auth/google"; 7 | 8 | export default function SignIn() { 9 | return ( 10 | <> 11 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/expo/app/index.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from "expo-router"; 2 | 3 | export default function Screen() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /apps/expo/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukesthl/expo-lucia-auth-example/b5d664af7f54d080a56d67be249d3be3c1f5e265/apps/expo/assets/adaptive-icon.png -------------------------------------------------------------------------------- /apps/expo/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukesthl/expo-lucia-auth-example/b5d664af7f54d080a56d67be249d3be3c1f5e265/apps/expo/assets/favicon.png -------------------------------------------------------------------------------- /apps/expo/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukesthl/expo-lucia-auth-example/b5d664af7f54d080a56d67be249d3be3c1f5e265/apps/expo/assets/icon.png -------------------------------------------------------------------------------- /apps/expo/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukesthl/expo-lucia-auth-example/b5d664af7f54d080a56d67be249d3be3c1f5e265/apps/expo/assets/splash.png -------------------------------------------------------------------------------- /apps/expo/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | plugins: [ 6 | // NOTE: this is only necessary if you are using reanimated for animations 7 | // "react-native-reanimated/plugin", 8 | ...(process.env.EAS_BUILD_PLATFORM === "android" 9 | ? [] 10 | : [ 11 | [ 12 | "@tamagui/babel-plugin", 13 | { 14 | components: ["tamagui"], 15 | config: "./tamagui.config.ts", 16 | logTimings: true, 17 | disableExtraction: process.env.NODE_ENV === "development", 18 | }, 19 | ], 20 | ]), 21 | ], 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /apps/expo/eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "development": { 4 | "distribution": "internal", 5 | "android": { 6 | "buildType": "apk" 7 | }, 8 | "ios": { 9 | "simulator": true, 10 | "image": "latest" 11 | } 12 | }, 13 | "production": { 14 | "distribution": "store", 15 | "android": { 16 | "buildType": "app-bundle" 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/expo/index.js: -------------------------------------------------------------------------------- 1 | import "expo-router/entry"; 2 | -------------------------------------------------------------------------------- /apps/expo/lib/api.client.ts: -------------------------------------------------------------------------------- 1 | import type { AppType } from "api/src/index"; 2 | import { hc } from "hono/client"; 3 | 4 | const API_URL = process.env.EXPO_PUBLIC_API_URL!; 5 | 6 | class ApiClientSingleton { 7 | public client = hc(API_URL); 8 | 9 | public addSessionToken = (sessionToken: string) => { 10 | this.client = hc(API_URL, { 11 | headers: { 12 | Authorization: `Bearer ${sessionToken}`, 13 | }, 14 | fetch: async (input: RequestInfo | URL, requestInit?: RequestInit> | undefined) => { 15 | const now = new Date(); 16 | console.log("[Request]", String(input).replace(API_URL, "")); 17 | const response = await fetch(input, requestInit); 18 | console.log("[Response]", response.status, response.statusText, `${new Date().getTime() - now.getTime()}ms`); 19 | return response; 20 | }, 21 | }); 22 | }; 23 | } 24 | 25 | export const Api = new ApiClientSingleton(); 26 | -------------------------------------------------------------------------------- /apps/expo/lib/auth/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { createContext, useContext, useEffect, useState } from "react"; 3 | import { makeRedirectUri } from "expo-auth-session"; 4 | import * as Linking from "expo-linking"; 5 | import * as Browser from "expo-web-browser"; 6 | import type { InferRequestType, InferResponseType } from "hono/client"; 7 | 8 | import { Api } from "../api.client"; 9 | import Storage from "../storage"; 10 | 11 | type User = NonNullable>; 12 | 13 | type Provider = NonNullable< 14 | InferRequestType<(typeof Api.client)["auth"]["login"][":provider"]["$post"]> 15 | >["param"]["provider"]; 16 | 17 | interface AuthContextType { 18 | user: User | null; 19 | signOut: () => Promise; 20 | signInWithIdToken: (args: { 21 | idToken: string; 22 | provider: Provider; 23 | user?: { 24 | username: string; 25 | }; 26 | }) => Promise; 27 | getOAuthAccounts: () => Promise["accounts"]>; 28 | signInWithOAuth: (args: { provider: Provider; redirect?: string }) => Promise; 29 | loading: boolean; 30 | } 31 | 32 | const AuthContext = createContext(undefined); 33 | 34 | interface AuthProviderProps { 35 | children: ReactNode; 36 | } 37 | Browser.maybeCompleteAuthSession(); 38 | 39 | export const AuthProvider = ({ children }: AuthProviderProps) => { 40 | const [loading, setLoading] = useState(true); 41 | const [user, setUser] = useState(null); 42 | 43 | const signInWithOAuth = async ({ 44 | provider, 45 | redirect = makeRedirectUri(), 46 | }: { 47 | provider: Provider; 48 | redirect?: string; 49 | }) => { 50 | const oauthUrl = new URL(`${process.env.EXPO_PUBLIC_API_URL!}/auth/${provider}?redirect=${redirect}`); 51 | const sesionToken = await Storage.getItem("session_token"); 52 | if (sesionToken) { 53 | oauthUrl.searchParams.append("sessionToken", sesionToken); 54 | } 55 | console.log(oauthUrl.toString()); 56 | const result = await Browser.openAuthSessionAsync(oauthUrl.toString(), redirect); 57 | if (result.type !== "success") { 58 | return null; 59 | } 60 | const url = Linking.parse(result.url); 61 | const sessionToken = url.queryParams?.token?.toString() ?? null; 62 | if (!sessionToken) { 63 | return null; 64 | } 65 | Api.addSessionToken(sessionToken); 66 | const user = await getUser(); 67 | setUser(user); 68 | await Storage.setItem("session_token", sessionToken); 69 | return user; 70 | }; 71 | 72 | const signInWithIdToken = async ({ 73 | idToken, 74 | provider, 75 | user: createUser, 76 | }: { 77 | idToken: string; 78 | provider: Provider; 79 | user?: { 80 | username: string; 81 | }; 82 | }): Promise => { 83 | const response = await Api.client.auth.login[":provider"].$post({ 84 | param: { provider }, 85 | json: { idToken, user: createUser }, 86 | }); 87 | if (!response.ok) { 88 | return null; 89 | } 90 | const sessionToken = ((await response.json()) as { token: string }).token; 91 | if (!sessionToken) { 92 | return null; 93 | } 94 | Api.addSessionToken(sessionToken); 95 | const user = await getUser(); 96 | setUser(user); 97 | await Storage.setItem("session_token", sessionToken); 98 | return user; 99 | }; 100 | 101 | const getUser = async (): Promise => { 102 | const response = await Api.client.user.$get(); 103 | if (!response.ok) { 104 | return null; 105 | } 106 | const user = await response.json(); 107 | return user; 108 | }; 109 | 110 | const signOut = async () => { 111 | const response = await Api.client.auth.logout.$post(); 112 | if (!response.ok) { 113 | return; 114 | } 115 | setUser(null); 116 | await Storage.deleteItem("session_token"); 117 | }; 118 | 119 | const getOAuthAccounts = async () => { 120 | const response = await Api.client.user["oauth-accounts"].$get(); 121 | if (!response.ok) { 122 | return []; 123 | } 124 | return (await response.json()).accounts; 125 | }; 126 | 127 | useEffect(() => { 128 | const init = async () => { 129 | setLoading(true); 130 | const sessionToken = await Storage.getItem("session_token"); 131 | if (sessionToken) { 132 | Api.addSessionToken(sessionToken); 133 | const user = await getUser(); 134 | setUser(user); 135 | } 136 | setLoading(false); 137 | }; 138 | void init(); 139 | }, []); 140 | 141 | return ( 142 | 143 | {children} 144 | 145 | ); 146 | }; 147 | 148 | export const useAuth = (): AuthContextType => { 149 | const context = useContext(AuthContext); 150 | if (context === undefined) { 151 | throw new Error("useAuth must be used within an AuthProvider"); 152 | } 153 | return context; 154 | }; 155 | -------------------------------------------------------------------------------- /apps/expo/lib/auth/apple.ios.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Platform } from "react-native"; 3 | import { SvgUri } from "react-native-svg"; 4 | import * as AppleAuthentication from "expo-apple-authentication"; 5 | import { router } from "expo-router"; 6 | import { Button, Image } from "tamagui"; 7 | 8 | import { useAuth } from "./AuthProvider"; 9 | 10 | export const AppleSignIn = () => { 11 | const [loading, setLoading] = React.useState(false); 12 | const { signInWithIdToken } = useAuth(); 13 | 14 | return ( 15 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /apps/expo/lib/auth/apple.tsx: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native"; 2 | import { SvgUri } from "react-native-svg"; 3 | import { router } from "expo-router"; 4 | import { Button, Image } from "tamagui"; 5 | 6 | import { useAuth } from "./AuthProvider"; 7 | 8 | export const AppleSignIn = () => { 9 | const { signInWithOAuth } = useAuth(); 10 | 11 | return ( 12 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /apps/expo/lib/auth/github.tsx: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native"; 2 | import { SvgUri } from "react-native-svg"; 3 | import { router } from "expo-router"; 4 | import { Button, Image } from "tamagui"; 5 | 6 | import { useAuth } from "./AuthProvider"; 7 | 8 | export const GithubSignIn = () => { 9 | const { signInWithOAuth } = useAuth(); 10 | return ( 11 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/expo/lib/auth/google.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Platform } from "react-native"; 3 | import { SvgUri } from "react-native-svg"; 4 | import { router } from "expo-router"; 5 | import { GoogleSignin } from "@react-native-google-signin/google-signin"; 6 | import { Button, Image } from "tamagui"; 7 | 8 | import { useAuth } from "./AuthProvider"; 9 | 10 | GoogleSignin.configure({ 11 | iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID!, 12 | offlineAccess: true, 13 | webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID!, 14 | }); 15 | 16 | export const GoogleSignIn = () => { 17 | const { signInWithIdToken } = useAuth(); 18 | 19 | return ( 20 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /apps/expo/lib/auth/google.web.tsx: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native"; 2 | import { SvgUri } from "react-native-svg"; 3 | import { router } from "expo-router"; 4 | import { Button, Image } from "tamagui"; 5 | 6 | import { useAuth } from "./AuthProvider"; 7 | 8 | export const GoogleSignIn = () => { 9 | const { signInWithOAuth } = useAuth(); 10 | return ( 11 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/expo/lib/storage.native.ts: -------------------------------------------------------------------------------- 1 | import * as SecureStore from "expo-secure-store"; 2 | 3 | async function getItem(key: string): Promise { 4 | return SecureStore.getItemAsync(key); 5 | } 6 | 7 | async function setItem(key: string, value: string): Promise { 8 | return SecureStore.setItemAsync(key, value); 9 | } 10 | 11 | async function deleteItem(key: string): Promise { 12 | return SecureStore.deleteItemAsync(key); 13 | } 14 | 15 | export default { deleteItem, getItem, setItem }; 16 | -------------------------------------------------------------------------------- /apps/expo/lib/storage.ts: -------------------------------------------------------------------------------- 1 | async function getItem(key: string): Promise { 2 | await Promise.resolve(); 3 | return localStorage.getItem(key); 4 | } 5 | 6 | async function setItem(key: string, value: string): Promise { 7 | await Promise.resolve(); 8 | localStorage.setItem(key, value); 9 | } 10 | 11 | async function deleteItem(key: string): Promise { 12 | await Promise.resolve(); 13 | localStorage.removeItem(key); 14 | } 15 | 16 | export default { deleteItem, getItem, setItem }; 17 | -------------------------------------------------------------------------------- /apps/expo/metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | /** 3 | * @type {import('expo/metro-config')} 4 | */ 5 | const { getDefaultConfig } = require("@expo/metro-config"); 6 | const path = require("path"); 7 | 8 | const projectRoot = __dirname; 9 | const workspaceRoot = path.resolve(__dirname, "../.."); 10 | 11 | const config = getDefaultConfig(projectRoot, { isCSSEnabled: true }); 12 | // for hono/client to work because unstable_enablePackageExports is not working 13 | config.resolver.resolveRequest = (context, moduleName, platform) => { 14 | if (moduleName === "hono/client") { 15 | return { 16 | type: "sourceFile", 17 | filePath: path.resolve(workspaceRoot, "node_modules/hono/dist/client/index.js"), 18 | }; 19 | } 20 | return context.resolveRequest(context, moduleName, platform); 21 | }; 22 | config.watchFolders = [workspaceRoot]; 23 | config.resolver.nodeModulesPaths = [ 24 | path.resolve(projectRoot, "node_modules"), 25 | path.resolve(workspaceRoot, "node_modules"), 26 | ]; 27 | 28 | config.transformer = { ...config.transformer, unstable_allowRequireContext: true }; 29 | config.transformer.minifierPath = require.resolve("metro-minify-terser"); 30 | const { withTamagui } = require("@tamagui/metro-plugin"); 31 | 32 | module.exports = withTamagui(config, { 33 | components: ["tamagui"], 34 | config: "./tamagui.config.ts", 35 | outputCSS: "./tamagui-web.css", 36 | }); 37 | -------------------------------------------------------------------------------- /apps/expo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-app", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "private": true, 6 | "scripts": { 7 | "lint": "eslint . --fix", 8 | "format": "prettier --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"", 9 | "start": "npx expo start -c", 10 | "android": "npx expo run:android", 11 | "ios": "npx expo run:ios", 12 | "dev:client": "npx expo start --clear --dev-client", 13 | "build:web": "npx expo export --platform web", 14 | "eject": "npx expo eject" 15 | }, 16 | "dependencies": { 17 | "@babel/runtime": "^7.24.4", 18 | "@expo/config-plugins": "~7.9.1", 19 | "@react-native-google-signin/google-signin": "^11.0.1", 20 | "@react-navigation/native": "^6.1.17", 21 | "@tamagui/font-inter": "^1.95.1", 22 | "@tamagui/metro-plugin": "^1.95.1", 23 | "@tamagui/themes": "^1.95.1", 24 | "babel-plugin-module-resolver": "^5.0.2", 25 | "burnt": "^0.12.2", 26 | "expo": "^50.0.17", 27 | "expo-apple-authentication": "^6.3.0", 28 | "expo-auth-session": "^5.4.0", 29 | "expo-constants": "~15.4.6", 30 | "expo-dev-client": "~3.3.11", 31 | "expo-font": "~11.10.3", 32 | "expo-linear-gradient": "~12.7.2", 33 | "expo-linking": "^6.2.2", 34 | "expo-router": "^3.4.8", 35 | "expo-secure-store": "^12.8.1", 36 | "expo-splash-screen": "~0.26.4", 37 | "expo-status-bar": "~1.11.1", 38 | "expo-updates": "~0.24.12", 39 | "expo-web-browser": "^12.8.2", 40 | "hono": "^4.2.8", 41 | "react-native": "0.73.6", 42 | "react-native-gesture-handler": "~2.16.0", 43 | "react-native-safe-area-context": "4.10.1", 44 | "react-native-screens": "~3.31.1", 45 | "react-native-svg": "15.2.0", 46 | "react-native-web": "~0.19.11", 47 | "tamagui": "^1.95.1", 48 | "react": "18.2.0" 49 | }, 50 | "devDependencies": { 51 | "@acme/eslint-config": "workspace:*", 52 | "@acme/prettier-config": "workspace:*", 53 | "@acme/tsconfig": "workspace:*", 54 | "@babel/core": "^7.24.4", 55 | "@expo/metro-config": "~0.17.7", 56 | "@tamagui/babel-plugin": "^1.95.1", 57 | "api": "workspace:*", 58 | "metro-minify-terser": "^0.80.8" 59 | }, 60 | "eslintConfig": { 61 | "root": true, 62 | "extends": [ 63 | "@acme/eslint-config/base", 64 | "@acme/eslint-config/react" 65 | ] 66 | }, 67 | "prettier": "@acme/prettier-config", 68 | "resolutions": { 69 | "metro": "~0.80.4", 70 | "metro-resolver": "~0.80.4" 71 | }, 72 | "overrides": { 73 | "metro": "~0.80.4", 74 | "metro-resolver": "~0.80.4" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /apps/expo/tamagui.config.ts: -------------------------------------------------------------------------------- 1 | import { createAnimations } from "@tamagui/animations-react-native"; 2 | import { createInterFont } from "@tamagui/font-inter"; 3 | import { createMedia } from "@tamagui/react-native-media-driver"; 4 | import { shorthands } from "@tamagui/shorthands"; 5 | import { tokens } from "@tamagui/themes/v2"; 6 | import { themes } from "@tamagui/themes/v2-themes"; 7 | import { createTamagui } from "tamagui"; 8 | 9 | const headingFont = createInterFont({ 10 | size: { 11 | 6: 15, 12 | }, 13 | transform: { 14 | 6: "uppercase", 15 | 7: "none", 16 | }, 17 | weight: { 18 | 6: "400", 19 | 7: "700", 20 | }, 21 | color: { 22 | 6: "$colorFocus", 23 | 7: "$color", 24 | }, 25 | letterSpacing: { 26 | 5: 2, 27 | 6: 1, 28 | 7: 0, 29 | 8: -1, 30 | 9: -2, 31 | 10: -3, 32 | 12: -4, 33 | 14: -5, 34 | 15: -6, 35 | }, 36 | face: { 37 | 700: { normal: "InterBold" }, 38 | }, 39 | }); 40 | 41 | const bodyFont = createInterFont( 42 | { 43 | face: { 44 | 700: { normal: "InterBold" }, 45 | }, 46 | }, 47 | { 48 | sizeSize: (size) => Math.round(size * 1.1), 49 | sizeLineHeight: (size) => Math.round(size * 1.1 + (size > 20 ? 10 : 10)), 50 | } 51 | ); 52 | 53 | const animations = createAnimations({ 54 | bouncy: { 55 | damping: 9, 56 | mass: 0.9, 57 | stiffness: 150, 58 | }, 59 | lazy: { 60 | damping: 18, 61 | stiffness: 50, 62 | }, 63 | }); 64 | 65 | export const config = createTamagui({ 66 | defaultFont: "body", 67 | animations, 68 | shouldAddPrefersColorThemes: true, 69 | themeClassNameOnRoot: true, 70 | 71 | // highly recommended to turn this on if you are using shorthands 72 | // to avoid having multiple valid style keys that do the same thing 73 | // we leave it off by default because it can be confusing as you onboard. 74 | onlyAllowShorthands: false, 75 | shorthands, 76 | 77 | fonts: { 78 | body: bodyFont, 79 | heading: headingFont, 80 | }, 81 | settings: { 82 | allowedStyleValues: "somewhat-strict", 83 | }, 84 | themes: { 85 | ...themes, 86 | light_Button: { 87 | background: "#fff", 88 | backgroundFocus: "#424242", 89 | backgroundHover: "#a5a5a5", 90 | backgroundPress: "#a5a5a5", 91 | backgroundStrong: "#191919", 92 | backgroundTransparent: "#151515", 93 | color: "#000", 94 | colorFocus: "#a5a5a5", 95 | colorHover: "#a5a5a5", 96 | colorPress: "#fff", 97 | colorTransparent: "#a5a5a5", 98 | placeholderColor: "#424242", 99 | }, 100 | }, 101 | tokens, 102 | media: createMedia({ 103 | xs: { maxWidth: 660 }, 104 | sm: { maxWidth: 800 }, 105 | md: { maxWidth: 1020 }, 106 | lg: { maxWidth: 1280 }, 107 | xl: { maxWidth: 1420 }, 108 | xxl: { maxWidth: 1600 }, 109 | gtXs: { minWidth: 660 + 1 }, 110 | gtSm: { minWidth: 800 + 1 }, 111 | gtMd: { minWidth: 1020 + 1 }, 112 | gtLg: { minWidth: 1280 + 1 }, 113 | short: { maxHeight: 820 }, 114 | tall: { minHeight: 820 }, 115 | hoverNone: { hover: "none" }, 116 | pointerCoarse: { pointer: "coarse" }, 117 | }), 118 | }); 119 | 120 | // for the compiler to find it 121 | export default config; 122 | -------------------------------------------------------------------------------- /apps/expo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "expo/tsconfig.base", 4 | "@acme/tsconfig/base" 5 | ], 6 | "compilerOptions": { 7 | "strict": true 8 | }, 9 | "include": [ 10 | "**/*.ts", 11 | "**/*.tsx", 12 | "./index.js" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukesthl/expo-lucia-auth-example/b5d664af7f54d080a56d67be249d3be3c1f5e265/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-lucia-auth-example", 3 | "private": true, 4 | "workspaces": [ 5 | "apps/*", 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "native": "bun run --cwd apps/expo dev:client", 10 | "start": "bun run --cwd apps/expo start", 11 | "api": "bun run --cwd ./apps/api dev", 12 | "check-deps": "check-dependency-version-consistency .", 13 | "lint": "turbo lint", 14 | "format": "turbo format", 15 | "build": "turbo build" 16 | }, 17 | "resolutions": { 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-refresh": "^0.14.0" 21 | }, 22 | "devDependencies": { 23 | "check-dependency-version-consistency": "^4.1.0" 24 | }, 25 | "dependencies": { 26 | "@babel/runtime": "^7.24.4", 27 | "ajv": "^8.12.0", 28 | "node-gyp": "^10.1.0", 29 | "prettier": "^3.2.5", 30 | "turbo": "^1.13.3", 31 | "typescript": "^5.4.5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/eslint-config/base.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | extends: [ 4 | "turbo", 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | "plugin:@typescript-eslint/stylistic-type-checked", 8 | "prettier", 9 | ], 10 | env: { 11 | es2022: true, 12 | node: true, 13 | }, 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | project: true, 17 | }, 18 | plugins: ["@typescript-eslint", "import"], 19 | rules: { 20 | "turbo/no-undeclared-env-vars": "off", 21 | "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], 22 | "@typescript-eslint/consistent-type-imports": [ 23 | "warn", 24 | { prefer: "type-imports", fixStyle: "separate-type-imports" }, 25 | ], 26 | "@typescript-eslint/no-misused-promises": [2, { checksVoidReturn: { attributes: false } }], 27 | "import/consistent-type-specifier-style": ["error", "prefer-top-level"], 28 | }, 29 | ignorePatterns: [ 30 | "**/.eslintrc.cjs", 31 | "**/*.config.js", 32 | "**/*.config.cjs", 33 | ".next", 34 | "dist", 35 | "pnpm-lock.yaml", 36 | "bun.lockb", 37 | "app.plugin.js", 38 | "logger.js", 39 | ], 40 | reportUnusedDisableDirectives: true, 41 | }; 42 | 43 | module.exports = config; 44 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acme/eslint-config", 3 | "version": "0.2.0", 4 | "private": true, 5 | "license": "MIT", 6 | "files": [ 7 | "./base.js", 8 | "./react.js" 9 | ], 10 | "scripts": { 11 | "clean": "rm -rf .turbo node_modules", 12 | "lint": "eslint .", 13 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "devDependencies": { 17 | "@acme/prettier-config": "workspace:*", 18 | "@acme/tsconfig": "workspace:*", 19 | "@types/eslint": "^8.56.10", 20 | "@typescript-eslint/eslint-plugin": "^7.7.1", 21 | "@typescript-eslint/parser": "^7.7.1", 22 | "eslint": "^8.56.0", 23 | "eslint-config-prettier": "^9.1.0", 24 | "eslint-config-turbo": "^1.13.3", 25 | "eslint-plugin-import": "^2.29.1", 26 | "eslint-plugin-jsx-a11y": "^6.8.0", 27 | "eslint-plugin-react": "^7.34.1", 28 | "eslint-plugin-react-hooks": "^4.6.2" 29 | }, 30 | "eslintConfig": { 31 | "root": true, 32 | "extends": [ 33 | "./base.js" 34 | ] 35 | }, 36 | "prettier": "@acme/prettier-config" 37 | } 38 | -------------------------------------------------------------------------------- /packages/eslint-config/react.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | extends: ["plugin:react/recommended", "plugin:react-hooks/recommended", "plugin:jsx-a11y/recommended"], 4 | rules: { 5 | "react/prop-types": "off", 6 | }, 7 | globals: { 8 | React: "writable", 9 | }, 10 | settings: { 11 | react: { 12 | version: "detect", 13 | }, 14 | }, 15 | env: { 16 | browser: true, 17 | }, 18 | }; 19 | 20 | module.exports = config; 21 | -------------------------------------------------------------------------------- /packages/eslint-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@acme/tsconfig/base", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["."], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/prettier-config/config.mjs: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "url"; 2 | 3 | /** @typedef {import("prettier").Config} PrettierConfig */ 4 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */ 5 | 6 | /** @type { PrettierConfig | SortImportsConfig } */ 7 | const config = { 8 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 9 | importOrder: [ 10 | "^(react/(.*)$)|^(react$)|^(react-native(.*)$)", 11 | "^(next/(.*)$)|^(next$)", 12 | "^(expo(.*)$)|^(expo$)", 13 | "", 14 | "", 15 | "^@acme/(.*)$", 16 | "", 17 | "^~/", 18 | "^[../]", 19 | "^[./]", 20 | ], 21 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 22 | importOrderTypeScriptVersion: "4.4.0", 23 | trailingComma: "es5", 24 | printWidth: 120, 25 | }; 26 | 27 | export default config; 28 | -------------------------------------------------------------------------------- /packages/prettier-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acme/prettier-config", 3 | "private": true, 4 | "version": "0.1.0", 5 | "main": "config.mjs", 6 | "scripts": { 7 | "clean": "rm -rf .turbo node_modules", 8 | "format": "prettier --write \"**/*.{mjs,ts,md,json}\"", 9 | "typecheck": "tsc --noEmit" 10 | }, 11 | "devDependencies": { 12 | "@acme/tsconfig": "workspace:*", 13 | "@ianvs/prettier-plugin-sort-imports": "^4.2.1", 14 | "prettier": "^3.2.5" 15 | }, 16 | "prettier": "@acme/prettier-config" 17 | } 18 | -------------------------------------------------------------------------------- /packages/prettier-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@acme/tsconfig/base", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["."], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2017", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "checkJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "noUncheckedIndexedAccess": true 20 | }, 21 | "exclude": ["node_modules", "build", "dist", ".next", ".expo"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acme/tsconfig", 3 | "private": true, 4 | "version": "0.1.0", 5 | "files": [ 6 | "base.json" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "rootDir": ".", 5 | "importHelpers": true, 6 | "allowJs": false, 7 | "allowSyntheticDefaultImports": true, 8 | "downlevelIteration": true, 9 | "esModuleInterop": true, 10 | "preserveSymlinks": true, 11 | "incremental": true, 12 | "jsx": "react-jsx", 13 | "module": "system", 14 | "moduleResolution": "node", 15 | "noEmitOnError": false, 16 | "noImplicitAny": false, 17 | "noImplicitReturns": false, 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": false, 20 | "useUnknownInCatchVariables": false, 21 | "preserveConstEnums": true, 22 | // DONT DO THIS so jsdoc will remain 23 | "removeComments": false, 24 | "skipLibCheck": true, 25 | "sourceMap": false, 26 | "strictNullChecks": true, 27 | "target": "es2020", 28 | "types": ["node"], 29 | "lib": ["dom", "esnext"] 30 | }, 31 | "exclude": ["_"], 32 | "typeAcquisition": { 33 | "enable": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "globalEnv": ["DISABLE_EXTRACTION", "NODE_ENV", "EAS_BUILD_PLATFORM"], 4 | "pipeline": { 5 | "build": { 6 | "env": ["DISABLE_EXTRACTION", "NODE_ENV", "EAS_BUILD_PLATFORM"], 7 | "dependsOn": ["^build"], 8 | "outputs": [".next/**", "!.next/cache/**", "build/**", "node_modules/.cache/metro/**"] 9 | }, 10 | "lint": { 11 | "dependsOn": ["^lint"], 12 | "outputs": ["node_modules/.cache/.eslintcache"] 13 | }, 14 | "format": { 15 | "dependsOn": ["^format"], 16 | "outputs": ["node_modules/.cache/.prettiercache"] 17 | }, 18 | "dev": { 19 | "cache": false, 20 | "persistent": true 21 | } 22 | } 23 | } 24 | --------------------------------------------------------------------------------