kitcn
Version:
kitcn - React Query integration and CLI tools for Convex
503 lines (385 loc) • 15.7 kB
Markdown
# 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 |