UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

503 lines (385 loc) 15.7 kB
# Auth Core Reference > Prerequisites: `setup/auth.md` Covers Better Auth integration with Convex: server setup, client hooks, triggers, and auth flow. Assumes Better Auth baseline knowledge. ## Key Concepts **Local approach** auth tables live in your app schema (not a component). Triggers directly access app tables via `ctx.orm`. Single transaction. **Context-aware adapter** generated `getAuth(ctx)` auto-selects: | Context | Adapter | Behavior | |---------|---------|----------| | Query/Mutation (`ctx.db`) | Direct DB | No `runQuery`/`runMutation` wrapper | | Action/HTTP | HTTP adapter | Uses `ctx.run*` APIs | **Entrypoint**: `getAuth(ctx)` everywhere (query, mutation, action, HTTP). ## Auth Flow Two-step validation for every request (SSR or WebSocket): 1. **JWT validation** (cryptographic) decode, verify signature via JWKS, check `exp` 2. **Session lookup** (database) `session.id = sessionId AND expiresAt > now` JWT validity doesn't guarantee access. Session lookup is the source of truth deleting the session immediately invalidates access. | Component | Storage | Invalidatable | Default Lifetime | |-----------|---------|---------------|------------------| | JWT | Cookie (signed) | No (stateless) | 15 min | | Session | Convex DB | Yes (stateful) | 30 days | ### SSR vs Client | | SSR (HTTP) | Client (WebSocket) | |---|---|---| | Transport | HTTP per query | Persistent connection | | Token source | Cookie / fetch from `/api/auth/convex/token` | WebSocket handshake | | Validation | Per request | Once at connection, then cached | | JWKS impact | +100-400ms per request (if dynamic) | +100-400ms blocking handshake (if dynamic) | **Static JWKS** (recommended): instant validation. **Dynamic JWKS**: +100-400ms network calls. ### Auth States | Scenario | JWT | Session | Result | |----------|-----|---------|--------| | Normal | Valid | Valid | 200 OK | | Sign out | Deleted | Deleted | 401 | | Admin revokes session | Valid | Deleted | 401 on next request | | JWT expired, session valid | Expired | Valid | Auto-refresh 200 | | JWT expired, session expired | Expired | Expired | 401 | | User banned | Valid | Valid (banned) | 403 | Client auto-refreshes expired JWTs with 60s leeway. --- ## Server Setup Below is the reference for auth patterns. ### 1. Install auth with CLI Use the CLI-first path: ```bash npx kitcn add auth --yes ``` If kitcn is not bootstrapped yet, start with `npx kitcn@latest init -t next --yes` for a fresh app or `npx kitcn@latest init --yes` for in-place adoption. On local Convex, `add auth --yes` also finishes the first auth bootstrap pass: generated runtime, `BETTER_AUTH_SECRET`, and `JWKS`. ### 2. Auth Config ```ts // convex/functions/auth.config.ts import { getAuthConfigProvider } from 'kitcn/auth/config'; import { getEnv } from '../lib/get-env'; export default { providers: [ getEnv().JWKS ? getAuthConfigProvider({ jwks: getEnv().JWKS }) : getAuthConfigProvider(), ], } satisfies AuthConfig; ``` ### 3. Generate Runtime Start `kitcn dev` for the long-running local runtime. It runs Convex, watches for changes, and regenerates runtime files automatically: ```bash npx kitcn dev ``` ### 4. Define Auth Contract The auth definition lives at `<functionsDir>/auth.ts`. `functionsDir` comes from `convex.json.functions` (default: `convex`), so scaffolded kitcn apps use `convex/functions/auth.ts`. ```ts // convex/functions/auth.ts import { convex } from 'kitcn/auth'; import { getEnv } from '../lib/get-env'; import authConfig from './auth.config'; import { defineAuth } from './generated/auth'; export default defineAuth(() => ({ emailAndPassword: { enabled: true, }, baseURL: getEnv().SITE_URL, plugins: [ convex({ authConfig, jwks: getEnv().JWKS, }), ], session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24 * 15, }, telemetry: { enabled: false }, trustedOrigins: [getEnv().SITE_URL], })); ``` Use runtime exports (`getAuth`, CRUD/JWKS handlers, trigger handlers, static `auth`) from `<functionsDir>/generated/auth`. ### 5. Schema (ORM API) Default kitcn path: ```bash npx kitcn add auth --yes npx kitcn add auth --schema --yes ``` That path patches auth-owned table blocks directly into `<functionsDir>/schema.ts` and records ownership in `<functionsDir>/plugins.lock.json`. Raw Convex path: ```bash npx kitcn add auth --preset convex --yes ``` That path refreshes `<functionsDir>/authSchema.ts` and patches `<functionsDir>/schema.ts`. It assumes the raw Convex app is already initialized and does not support `--schema`. If you want to own the auth tables by hand, use `setup/server.md`. ### 6. Auth HTTP Runtime Import auth route helpers from `kitcn/auth/http`. That entrypoint auto-installs the Convex-safe `MessageChannel` polyfill. `registerRoutes` is lazy by default, so Better Auth does not initialize during `convex/http.ts` registration. If your auth config uses a custom base path, pass the same `basePath` to `registerRoutes`. ### 7. HTTP Routes Three options cRPC (recommended), plain Convex, or Hono: ```ts // convex/functions/http.ts cRPC option import { authMiddleware } from 'kitcn/auth/http'; import { createHttpRouter } from 'kitcn/server'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { getEnv } from '../lib/get-env'; import { getAuth } from './generated/auth'; const app = new Hono(); app.use('/api/*', cors({ origin: getEnv().SITE_URL, allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'], exposeHeaders: ['Set-Better-Auth-Cookie'], credentials: true, })); app.use(authMiddleware(getAuth)); export default createHttpRouter(app, httpRouter); ``` ```ts // convex/functions/http.ts - plain Convex option import { registerRoutes } from 'kitcn/auth/http'; import { httpRouter } from 'convex/server'; import { getAuth } from './generated/auth'; const http = httpRouter(); registerRoutes(http, getAuth, { cors: { allowedOrigins: [process.env.SITE_URL!], }, }); export default http; ``` ### 8. Environment Variables ```bash # convex/.env SITE_URL=http://localhost:3000 GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... # Auto-generated during local auth bootstrap and prod env push BETTER_AUTH_SECRET=... JWKS=... ``` Local Convex: 1. `init --yes`, `dev`, and `add auth --yes` drive the first auth bootstrap when they own the flow. 2. While `kitcn dev` is running on backend `convex`, later edits to `convex/.env` auto-sync. 3. For the normal local path, `SITE_URL` should stay on `http://localhost:3000`. Convex remote / repair: 1. Use `npx kitcn env push` when the target deployment is already active. 2. Use `npx kitcn env push --prod` for production sync. 3. Use `npx kitcn env push --rotate` when you want fresh keys plus fresh `JWKS`. 4. `kitcn env push` writes deployment env for you. No manual copy step. 5. Use `npx kitcn env default set ... --type <dev|preview|prod>` for project defaults that should apply to new deployments. Concave manual lane: 1. Use `npx kitcn --backend concave auth jwks` when you need a manual static `JWKS` payload. 2. Use `npx kitcn --backend concave auth jwks --rotate` when you need rotation plus export. 3. `kitcn auth jwks` only prints `JWKS=...`. Save that value into env yourself. --- ## Server Helpers ```ts import { getAuthUserIdentity, getAuthUserId, getSession, getHeaders } from 'kitcn/auth'; ``` | Helper | Returns | Use case | |--------|---------|----------| | `getAuthUserIdentity(ctx)` | `{ userId, sessionId, subject }` or null | Full identity | | `getAuthUserId(ctx)` | `Id<'user'>` or null | Just user ID | | `getSession(ctx)` | `{ id, userId, activeOrganizationId, expiresAt }` or null | Session doc | | `getHeaders(ctx)` | `Headers` with Authorization + x-forwarded-for | Forward to external APIs | ```ts // Common pattern const userId = await getAuthUserId(ctx); if (!userId) throw new CRPCError({ code: 'UNAUTHORIZED' }); const user = await ctx.orm.query.user.findFirst({ where: { id: userId } }); ``` ### Convex Plugin Options ```ts convex({ authConfig, // required jwks: process.env.JWKS, // static JWKS for fast validation jwt: { expirationSeconds: 60 * 60 * 4, // default 15 min definePayload: ({ user, session }) => ({ name: user.name, email: user.email, role: user.role, sessionId: session.id, // always added automatically }), }, options: { basePath: '/custom/auth/path' }, // if non-default }) ``` Default `definePayload` includes all user fields except `id` and `image`, plus `sessionId` and `iat`. --- ## Client Setup ### Auth Client ```ts // src/lib/convex/auth-client.ts import { inferAdditionalFields } from 'better-auth/client/plugins'; import { createAuthClient } from 'better-auth/react'; import { convexClient } from 'kitcn/auth/client'; import { createAuthMutations } from 'kitcn/react'; import type { Auth } from '@convex/auth-shared'; export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_SITE_URL!, plugins: [inferAdditionalFields<Auth>(), convexClient()], }); export const { useSignInMutationOptions, useSignInSocialMutationOptions, useSignOutMutationOptions, useSignUpMutationOptions, } = createAuthMutations(authClient); ``` ### Sign In Rule: 1. The standard Next local path assumes `NEXT_PUBLIC_SITE_URL=http://localhost:3000`. 2. If the app runs on another port, update `.env.local` `NEXT_PUBLIC_SITE_URL`, `convex/.env` `SITE_URL`, and the app dev script together. **Social:** ```ts const signInSocial = useMutation(useSignInSocialMutationOptions()); signInSocial.mutate({ callbackURL: window.location.origin, provider: 'google' }); ``` **Email/password** (requires `emailAndPassword: { enabled: true }` in server config): ```ts const signIn = useMutation(useSignInMutationOptions({ onSuccess: () => router.push('/') })); signIn.mutate({ callbackURL: window.location.origin, email, password }); const signUp = useMutation(useSignUpMutationOptions({ onSuccess: () => router.push('/') })); signUp.mutate({ callbackURL: window.location.origin, email, name, password }); ``` ### Sign Out ```ts const signOut = useMutation(useSignOutMutationOptions({ onSuccess: () => router.push('/login'), })); signOut.mutate(); ``` `useSignOutMutationOptions` auto-calls `unsubscribeAuthQueries()` before signOut to prevent UNAUTHORIZED errors. `isPending` stays true until token actually cleared. --- ## Client Hooks All from `kitcn/react`: | Hook | Returns | Description | |------|---------|-------------| | `useAuth()` | `{ hasSession, isAuthenticated, isLoading }` | Full auth state | | `useMaybeAuth()` | `boolean` | Has token (optimistic, may not be verified) | | `useIsAuth()` | `boolean` | Server-verified authentication | | `useAuthGuard()` | `() => boolean` | Guard mutations, returns true if blocked | ### useAuthGuard ```ts const guard = useAuthGuard(); const handleClick = () => { if (guard()) return; // blocked not authenticated createPost.mutate({ title: 'New Post' }); }; // Or with callback only runs if authenticated: guard(async () => { await createPost.mutateAsync({ title: 'New Post' }); }); ``` ## Conditional Rendering All from `kitcn/react`: | Component | Renders when | |-----------|-------------| | `MaybeAuthenticated` | Has session token (optimistic) | | `Authenticated` | Server-verified authenticated | | `MaybeUnauthenticated` | No session token (optimistic) | | `Unauthenticated` | Server-verified not authenticated | ```tsx <MaybeAuthenticated><Dashboard /></MaybeAuthenticated> <MaybeUnauthenticated><LoginPage /></MaybeUnauthenticated> ``` ## Provider Config ```tsx <ConvexAuthProvider client={convex} authClient={authClient} initialToken={token} // from SSR (caller.getToken()) onMutationUnauthorized={() => router.push('/login')} onQueryUnauthorized={({ queryName }) => console.log(`Unauth: ${queryName}`)} > ``` For `@convex-dev/auth` (React Native): ```tsx import { ConvexProviderWithAuth } from 'kitcn/react'; <ConvexProviderWithAuth client={convex} useAuth={useAuthFromConvexDev}> ``` --- ## Auth Triggers Define triggers in `auth.ts` via `defineAuth(() => ({ triggers }))`. Triggers run inline in the same CRUD transaction. ### Trigger Shape Nested `{ create, update, delete, change }` per table, matching ORM `defineTriggers` pattern. See [Trigger Shape reference](#trigger-shape-1) below for callback signatures. `before` return contract: `void` (continue unchanged), `{ data }` (shallow merge into payload), `false` (cancel write). `change` receives `{ operation: 'insert' | 'update' | 'delete', id, newDoc, oldDoc }`. ```ts triggers: { user: { create: { before: async (data, triggerCtx) => { const username = await generateUniqueUsername(triggerCtx, data.name); const role = adminEmails.includes(data.email) ? 'admin' : 'user'; return { data: { ...data, username, role } }; }, after: async (user, triggerCtx) => { await triggerCtx.orm.insert(profiles).values({ userId: user.id, bio: '' }); const emailCaller = createEmailsCaller(triggerCtx); await emailCaller.schedule.now.sendWelcome({ userId: user.id }); }, }, update: { after: async (newDoc, triggerCtx) => { // Use `change` handler for old vs new comparisons }, }, delete: { after: async (user, triggerCtx) => { const profiles = await triggerCtx.orm.query.profiles.findMany({ where: { userId: user.id }, limit: 1000 }); for (const p of profiles) await triggerCtx.orm.delete(profilesTable).where(eq(profilesTable.id, p.id)); }, }, change: async (change, triggerCtx) => { switch (change.operation) { case 'update': if (change.newDoc.image !== change.oldDoc.image) { const profile = await triggerCtx.orm.query.profiles.findFirst({ where: { userId: change.id } }); if (profile) await triggerCtx.orm.update(profiles).set({ avatar: change.newDoc.image }).where(eq(profiles.id, profile.id)); } break; } }, }, } ``` ### Session Triggers ```ts triggers: { session: { create: { after: async (session, triggerCtx) => { if (!session.activeOrganizationId) { const user = await triggerCtx.orm.query.user.findFirst({ where: { id: session.userId } }); if (user?.lastActiveOrganizationId) { await triggerCtx.orm.update(sessionTable).set({ activeOrganizationId: user.lastActiveOrganizationId }) .where(eq(sessionTable.id, session.id)); } } }, }, }, } ``` ### Type Safety Triggers are typed from schema: `data` is `Infer<Schema['tables']['user']['validator']>`, `doc` includes `id` and `_creationTime`, `update` is `Partial`. --- ## Auth vs DB Triggers Auth triggers (`defineAuth(...).triggers`) handle auth lifecycle events. DB triggers (`defineTriggers`) handle database-level side effects (aggregates, cascades, counters). --- ## API Reference ### Trigger Shape Nested `{ create, update, delete, change }` per table, matching ORM `defineTriggers` pattern: | Hook | Signature | Return | |------|-----------|--------| | `create.before` | `(data, ctx) => void \| { data } \| false` | Merge / cancel | | `create.after` | `(doc, ctx) => void` | Side effects | | `update.before` | `(update, ctx) => void \| { data } \| false` | Merge / cancel | | `update.after` | `(newDoc, ctx) => void` | Sync changes | | `delete.before` | `(doc, ctx) => void \| { data } \| false` | Guard / cancel | | `delete.after` | `(doc, ctx) => void` | Cleanup | | `change` | `(change, ctx) => void` | Cross-operation |