--- name: JWT Token Exchange overview: Implement a JWT token exchange endpoint that lets customers automatically log their users into ClassroomIO from their own app -- no re-login required. This uses a per-org signing secret and Better Auth's `setSessionCookie` to create sessions from externally-signed JWTs. todos: - id: db-schema content: Add `organization_token_auth` table to schema.ts and create migration 0008 status: pending - id: db-queries content: Create token-auth.ts queries (get, create, rotate, delete, toggle active) status: pending - id: validation content: Add Zod schemas for token exchange (ZTokenExchangeQuery, ZTokenExchangePayload) and token auth admin operations status: pending - id: extract-provisioning content: Extract reusable `ensureOrgMembership(userId, email, orgId, roleId?)` from sso-provisioning.ts status: pending - id: token-exchange-service content: "Create token-exchange.ts service: decode JWT, verify signature, find/create user, ensure membership" status: pending - id: better-auth-plugin content: Create Better Auth plugin with `createAuthEndpoint` for `/token-exchange` using `setSessionCookie` status: pending - id: admin-service content: Create token-auth.ts service (enterprise plan check, secret generation, conflict checks) and route (HTTP concerns only, delegates to service, uses handleError) status: pending - id: admin-routes content: Create token-auth route file, export, and register in app.ts status: pending - id: error-codes content: Add token exchange and token auth error codes to errors.ts status: pending - id: frontend-types content: Add request types for token auth endpoints in org/utils/types.ts (infer from RPC client) status: pending - id: frontend-api content: Create TokenAuthApi class extending BaseApiWithErrors in org/api/token-auth.svelte.ts status: pending - id: frontend-ui content: Create token-auth.svelte settings page, route page, navigation entry, and settings index registration status: pending - id: translations content: Add en.json translation keys for all token auth UI strings (no hardcoded English) status: pending - id: build-verify content: Run build, check types, verify migration status: pending isProject: false --- # JWT Token Exchange for Seamless Auto-Login ## Architecture ```mermaid sequenceDiagram participant CustomerApp as Customer App participant CustomerBackend as Customer Backend participant CIO as ClassroomIO API participant BetterAuth as Better Auth participant DB as Database CustomerApp->>CustomerBackend: User wants to access ClassroomIO CustomerBackend->>CustomerBackend: Sign JWT with org secret CustomerBackend->>CustomerApp: Return signed JWT CustomerApp->>CIO: GET /auth/token-exchange?token=xxx&redirect=/courses CIO->>DB: Look up org signing secret by org_id from JWT CIO->>CIO: Verify JWT signature + expiry with jose CIO->>DB: Find user by email alt User does not exist CIO->>BetterAuth: Create user via auth.api.signUpEmail (internal) BetterAuth->>DB: Insert user + account Note over CIO: createProfileHook fires automatically end CIO->>DB: Ensure org membership (reuse provisioning logic) CIO->>BetterAuth: setSessionCookie(ctx, session/user) CIO->>CustomerApp: 302 Redirect to target page (with session cookie) ``` ## Step 1: Database -- Add `organization_token_auth` table Create a new table rather than overloading `organization_sso_config`, since token exchange is conceptually separate from OIDC SSO and should work independently (an org might want token exchange without configuring Okta/Auth0). **New table: `organization_token_auth`** - `id` (uuid, PK) - `organization_id` (uuid, unique FK to organization, cascade delete) - `signing_secret` (text, not null) -- HMAC secret for HS256 JWT verification - `is_active` (boolean, default false) - `created_by_profile_id` (uuid FK to profile) - `created_at`, `updated_at` (timestamptz) Files to change: - [packages/db/src/schema.ts](packages/db/src/schema.ts) -- add `organizationTokenAuth` table definition after line 1934 - New migration file: `packages/db/src/migrations/0008_add_token_auth_table.sql` - [packages/db/src/types.ts](packages/db/src/types.ts) -- add inferred types ## Step 2: Database Queries **Location**: `packages/db/src/queries/organization/token-auth.ts` (per ARCHITECTURE.md Layer 1 pattern) Create [packages/db/src/queries/organization/token-auth.ts](packages/db/src/queries/organization/token-auth.ts) with pure DB functions -- no business logic, no HTTP calls, no transactions (those belong in the service layer): - `getTokenAuthByOrgId(orgId)` -- look up signing secret for an org - `createTokenAuth(data: TNewOrganizationTokenAuth)` -- insert new record, use `.returning()` - `updateTokenAuth(orgId, data: Partial)` -- update fields, use `.returning()` - `deleteTokenAuth(orgId)` -- remove the record Every `catch` block must log with function name for traceability: `console.error('getTokenAuthByOrgId error:', error)`. ## Step 3: Validation Schemas **Location**: `packages/utils/src/validation/organization/token-auth.ts` (per ARCHITECTURE.md -- shared validation in `@cio/utils/validation`, used by both API and frontend) Create [packages/utils/src/validation/organization/token-auth.ts](packages/utils/src/validation/organization/token-auth.ts): - `ZTokenExchangeQuery` -- validates the `token` (string, min 1) and optional `redirect` (string) query params - `ZTokenExchangePayload` -- validates the decoded JWT payload: `sub` (string), `email` (email), `name` (string, optional), `org_id` (uuid), `iat` (number), `exp` (number) Export `T`* inferred types alongside each schema (`export type TTokenExchangeQuery = z.infer`). ## Step 4: Service -- Token Exchange Logic **Location**: `apps/api/src/services/auth/token-exchange.ts` (per ARCHITECTURE.md Layer 2 -- business logic lives in services, throws `AppError` with error codes and status codes, returns structured data not HTTP responses) Create [apps/api/src/services/auth/token-exchange.ts](apps/api/src/services/auth/token-exchange.ts): ```typescript export async function exchangeToken(token: string): Promise<{ user: User; orgId: string; }> ``` This function: 1. Decode JWT **without** verifying to extract `org_id` from payload 2. Look up signing secret via `getTokenAuthByOrgId(orgId)` -- throw `AppError(ErrorCodes.TOKEN_EXCHANGE_ORG_NOT_FOUND, 404)` if not found or `AppError(ErrorCodes.TOKEN_EXCHANGE_NOT_ENABLED, 403)` if inactive 3. Verify JWT with `jose.jwtVerify(token, secret, { algorithms: ['HS256'] })` -- validates signature + expiry 4. Reject tokens older than 5 minutes (`maxTokenAge: '5m'`) 5. Validate payload against `ZTokenExchangePayload` 6. Find existing user by email in the `user` table (Better Auth's table) 7. If no user exists, create one via `auth.api.signUpEmail` with a random password (the user will never use it -- they always come through token exchange). The `createProfileHook` fires automatically and `ssoProvisioningHook` handles org membership 8. If user exists but is not an org member, run the provisioning logic from [packages/db/src/auth/hooks/sso-provisioning.ts](packages/db/src/auth/hooks/sso-provisioning.ts) (extracted into a reusable function that accepts userId + orgId rather than relying on email domain matching) 9. Return the user object and orgId ## Step 5: Provisioning -- Extract Reusable Function Refactor [packages/db/src/auth/hooks/sso-provisioning.ts](packages/db/src/auth/hooks/sso-provisioning.ts) to extract a reusable `ensureOrgMembership(userId, email, orgId, roleId?)` function that: - Checks if membership already exists - If not, checks for pending invite (accept it) or creates membership with default role The existing `ssoProvisioningHook` continues to call this function (it just adds the domain-lookup step on top). The new token exchange service also calls it directly with the `org_id` from the JWT. ## Step 6: Route -- Public Token Exchange Endpoint Create [apps/api/src/routes/auth/token-exchange.ts](apps/api/src/routes/auth/token-exchange.ts): ``` GET /auth/token-exchange?token=&redirect= ``` This is a **public** endpoint (no `authMiddleware`). Flow: 1. Validate query params with `ZTokenExchangeQuery` 2. Call `exchangeToken(token)` from the service 3. Create a Better Auth session using internal API (see below) 4. Set the session cookie using `setSessionCookie` from `better-auth/cookies` 5. Redirect (302) to the `redirect` param or default to `/` **Session creation approach** -- Based on the [Better Auth discussion #2125](https://github.com/better-auth/better-auth/discussions/2125), use `setSessionCookie` from `better-auth/cookies` combined with creating the session through Better Auth's internal context. The route will be implemented as a Better Auth plugin endpoint using `createAuthEndpoint` from `better-auth/api` to get access to the auth context needed by `setSessionCookie`. Register as a Better Auth plugin in [packages/db/src/auth.ts](packages/db/src/auth.ts) (not as a regular Hono route) so it gets the internal context: ```typescript import { tokenExchange } from './auth/plugins/token-exchange'; // in plugins array: plugins: [admin(), anonymous(), jwt(), sso({}), tokenExchange()] ``` This means the endpoint lives at `/api/auth/token-exchange` (under Better Auth's route namespace). ## Step 7: Admin Routes -- Secret Management **Location**: `apps/api/src/routes/organization/token-auth.ts` (per ARCHITECTURE.md Layer 3 -- routes handle HTTP concerns only: validation, auth, serialization) Create [apps/api/src/routes/organization/token-auth.ts](apps/api/src/routes/organization/token-auth.ts). Routes delegate to the service layer and use `handleError` in catch blocks. Use `authMiddleware` + `orgAdminMiddleware`. Non-null assert `user` (`c.get('user')!`) since `authMiddleware` guarantees it. - `POST /organization/token-auth` -- generate signing secret (requires admin + enterprise plan). Returns `201` with secret shown once - `GET /organization/token-auth` -- get token auth status (active/inactive, created date -- **never** returns the full secret, only last 4 chars). Returns `200` - `POST /organization/token-auth/rotate` -- rotate the secret (returns new secret once). Returns `200` - `DELETE /organization/token-auth` -- delete token auth config. Returns `200` - `PUT /organization/token-auth/activate` -- toggle active/inactive. Returns `200` Create the corresponding service [apps/api/src/services/organization/token-auth.ts](apps/api/src/services/organization/token-auth.ts) for business logic (enterprise plan check, secret generation via `crypto.randomBytes(32).toString('hex')`, conflict checks). Routes must not contain business logic. Export and register in [apps/api/src/app.ts](apps/api/src/app.ts): ```typescript .route('/organization/token-auth', organizationTokenAuthRouter) ``` ## Step 8: Error Codes Add to [apps/api/src/utils/errors.ts](apps/api/src/utils/errors.ts): - `TOKEN_EXCHANGE_INVALID_TOKEN` - `TOKEN_EXCHANGE_EXPIRED` - `TOKEN_EXCHANGE_ORG_NOT_FOUND` - `TOKEN_EXCHANGE_NOT_ENABLED` - `TOKEN_AUTH_ALREADY_EXISTS` ## Step 9: Frontend -- Request Types **Location**: `apps/dashboard/src/lib/features/org/utils/types.ts` (per AGENTS.md -- define request types in `{domain}/utils/types.ts` for reusability) Add request types for all token auth endpoints: ```typescript export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post']; export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get']; export type RotateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['rotate']['$post']; export type DeleteTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$delete']; export type ActivateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['activate']['$put']; ``` Never import types from `@cio/db/queries` into dashboard code -- infer everything from the RPC client. ## Step 10: Frontend API Class **Location**: `apps/dashboard/src/lib/features/org/api/token-auth.svelte.ts` (per AGENTS.md -- API class pattern: extends `BaseApiWithErrors`, handles all logic so components are thin) Create [apps/dashboard/src/lib/features/org/api/token-auth.svelte.ts](apps/dashboard/src/lib/features/org/api/token-auth.svelte.ts): - Class `TokenAuthApi extends BaseApiWithErrors` - Reactive state: `tokenAuth = $state(null)`, `generatedSecret = $state(null)` - Methods using `this.execute()` (never call RPC directly): - `getStatus()` -- fetch token auth status - `generateSecret()` -- create new config, store returned secret in `generatedSecret` - `rotateSecret()` -- rotate, store new secret in `generatedSecret` - `deleteTokenAuth()` -- delete config - `activateTokenAuth()` -- toggle active Use Zod client-side validation where applicable. Use `snackbar.success('translation.key')` with translation keys (not raw English strings). Export singleton: `export const tokenAuthApi = new TokenAuthApi()` ## Step 11: Frontend -- Admin UI Component **Location**: `apps/dashboard/src/lib/features/settings/pages/token-auth.svelte` (kebab-case per AGENTS.md naming convention) Create [apps/dashboard/src/lib/features/settings/pages/token-auth.svelte](apps/dashboard/src/lib/features/settings/pages/token-auth.svelte) as a separate settings page (not inlined into sso.svelte -- keeps components focused): - Import `tokenAuthApi` from `$features/org/api/token-auth.svelte` - Use `$t('key.path')` for all user-facing text (no hardcoded English in markup) - Use `t('key.path')` in script blocks, `t.get('key.path')` outside Svelte components - Use UI components from `@cio/ui`: - `@cio/ui/base/button` for actions (Generate, Rotate, Delete) - `@cio/ui/base/badge` for active/inactive status - `@cio/ui/base/switch` for activate toggle - `@cio/ui/base/field` for form layout (`Field.Group`, `Field.Set`, `Field.Legend`, `Field.Description`) - Do NOT use native ``, `