auth-jwt-templates.ts180 lines · main
| 1 | /** |
| 2 | * JWT Template service — Phase 7.1. |
| 3 | * |
| 4 | * Lets tenants define named claim sets ("templates") and request signed JWTs |
| 5 | * that merge the template claims with user identity claims. The signing keys |
| 6 | * live in `_briven_auth_custom_jwks` — independent from Better Auth's own |
| 7 | * jwks table so we control the lifecycle. |
| 8 | */ |
| 9 | |
| 10 | import { generateKeyPair, SignJWT, importJWK, type JWK } from 'jose'; |
| 11 | |
| 12 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 13 | |
| 14 | // ─── template CRUD ──────────────────────────────────────────────────────── |
| 15 | |
| 16 | export async function listJwtTemplates(projectId: string): Promise<{ name: string; claims: Record<string, unknown> }[]> { |
| 17 | return runInProjectDatabase(projectId, async (tx) => { |
| 18 | const rows = (await tx.unsafe( |
| 19 | `SELECT name, claims FROM "_briven_auth_jwt_templates" ORDER BY name`, |
| 20 | )) as Array<{ name: string; claims: unknown }>; |
| 21 | return rows.map((r) => ({ name: r.name, claims: (r.claims ?? {}) as Record<string, unknown> })); |
| 22 | }); |
| 23 | } |
| 24 | |
| 25 | export async function getJwtTemplate( |
| 26 | projectId: string, |
| 27 | name: string, |
| 28 | ): Promise<{ name: string; claims: Record<string, unknown> } | null> { |
| 29 | return runInProjectDatabase(projectId, async (tx) => { |
| 30 | const rows = (await tx.unsafe( |
| 31 | `SELECT name, claims FROM "_briven_auth_jwt_templates" WHERE name = $1 LIMIT 1`, |
| 32 | [name] as never, |
| 33 | )) as Array<{ name: string; claims: unknown }>; |
| 34 | if (!rows[0]) return null; |
| 35 | return { name: rows[0].name, claims: (rows[0].claims ?? {}) as Record<string, unknown> }; |
| 36 | }); |
| 37 | } |
| 38 | |
| 39 | export async function createJwtTemplate( |
| 40 | projectId: string, |
| 41 | name: string, |
| 42 | claims: Record<string, unknown>, |
| 43 | ): Promise<void> { |
| 44 | const id = crypto.randomUUID(); |
| 45 | await runInProjectDatabase(projectId, async (tx) => { |
| 46 | await tx.unsafe( |
| 47 | `INSERT INTO "_briven_auth_jwt_templates" (id, name, claims) VALUES ($1, $2, $3) ON CONFLICT (name) DO NOTHING`, |
| 48 | [id, name, JSON.stringify(claims)] as never, |
| 49 | ); |
| 50 | }); |
| 51 | } |
| 52 | |
| 53 | export async function deleteJwtTemplate(projectId: string, name: string): Promise<void> { |
| 54 | await runInProjectDatabase(projectId, async (tx) => { |
| 55 | await tx.unsafe( |
| 56 | `DELETE FROM "_briven_auth_jwt_templates" WHERE name = $1`, |
| 57 | [name] as never, |
| 58 | ); |
| 59 | }); |
| 60 | } |
| 61 | |
| 62 | // ─── signing key lifecycle ──────────────────────────────────────────────── |
| 63 | |
| 64 | async function ensureSigningKey(projectId: string): Promise<{ publicKey: JWK; privateKey: JWK }> { |
| 65 | const existing = await runInProjectDatabase(projectId, async (tx) => { |
| 66 | const rows = (await tx.unsafe( |
| 67 | `SELECT public_key, private_key FROM "_briven_auth_custom_jwks" LIMIT 1`, |
| 68 | )) as Array<{ public_key: string; private_key: string }>; |
| 69 | return rows[0] ?? null; |
| 70 | }); |
| 71 | |
| 72 | if (existing) { |
| 73 | return { |
| 74 | publicKey: JSON.parse(existing.public_key) as JWK, |
| 75 | privateKey: JSON.parse(existing.private_key) as JWK, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | const pair = await generateKeyPair('Ed25519', { extractable: true }); |
| 80 | const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey); |
| 81 | const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey); |
| 82 | |
| 83 | const publicJson = JSON.stringify(publicJwk); |
| 84 | const privateJson = JSON.stringify(privateJwk); |
| 85 | |
| 86 | await runInProjectDatabase(projectId, async (tx) => { |
| 87 | await tx.unsafe( |
| 88 | `INSERT INTO "_briven_auth_custom_jwks" (id, public_key, private_key) VALUES ($1, $2, $3)`, |
| 89 | [crypto.randomUUID(), publicJson, privateJson] as never, |
| 90 | ); |
| 91 | }); |
| 92 | |
| 93 | return { publicKey: publicJwk, privateKey: privateJwk }; |
| 94 | } |
| 95 | |
| 96 | export async function getCustomJwks(projectId: string): Promise<{ keys: JWK[] }> { |
| 97 | const rows = await runInProjectDatabase(projectId, async (tx) => { |
| 98 | return (await tx.unsafe( |
| 99 | `SELECT public_key FROM "_briven_auth_custom_jwks"`, |
| 100 | )) as Array<{ public_key: string }>; |
| 101 | }); |
| 102 | return { |
| 103 | keys: rows.map((r) => JSON.parse(r.public_key) as JWK), |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | // ─── JWT generation ─────────────────────────────────────────────────────── |
| 108 | |
| 109 | export interface JwtTokenResult { |
| 110 | token: string; |
| 111 | expiresAt: Date; |
| 112 | } |
| 113 | |
| 114 | export async function generateJwtToken( |
| 115 | projectId: string, |
| 116 | sessionToken: string, |
| 117 | templateName?: string, |
| 118 | ): Promise<JwtTokenResult | { error: string }> { |
| 119 | // 1. Validate session. |
| 120 | const session = await runInProjectDatabase(projectId, async (tx) => { |
| 121 | const rows = (await tx.unsafe( |
| 122 | `SELECT id, user_id, expires_at FROM "_briven_auth_sessions" WHERE token = $1 LIMIT 1`, |
| 123 | [sessionToken] as never, |
| 124 | )) as Array<{ id: string; user_id: string; expires_at: Date }>; |
| 125 | if (!rows[0]) return null; |
| 126 | // Check expiry |
| 127 | if (rows[0].expires_at < new Date()) return null; |
| 128 | return rows[0]; |
| 129 | }); |
| 130 | |
| 131 | if (!session) { |
| 132 | return { error: 'invalid_session' }; |
| 133 | } |
| 134 | |
| 135 | // 2. Fetch user. |
| 136 | const user = await runInProjectDatabase(projectId, async (tx) => { |
| 137 | const rows = (await tx.unsafe( |
| 138 | `SELECT id, email, name, image FROM "_briven_auth_users" WHERE id = $1 LIMIT 1`, |
| 139 | [session.user_id] as never, |
| 140 | )) as Array<{ id: string; email: string; name: string | null; image: string | null }>; |
| 141 | return rows[0] ?? null; |
| 142 | }); |
| 143 | |
| 144 | if (!user) { |
| 145 | return { error: 'user_not_found' }; |
| 146 | } |
| 147 | |
| 148 | // 3. Fetch template claims if requested. |
| 149 | let templateClaims: Record<string, unknown> = {}; |
| 150 | if (templateName) { |
| 151 | const tpl = await getJwtTemplate(projectId, templateName); |
| 152 | if (!tpl) { |
| 153 | return { error: 'template_not_found' }; |
| 154 | } |
| 155 | templateClaims = tpl.claims; |
| 156 | } |
| 157 | |
| 158 | // 4. Ensure signing key. |
| 159 | const { privateKey } = await ensureSigningKey(projectId); |
| 160 | const cryptoKey = await importJWK(privateKey, 'EdDSA'); |
| 161 | |
| 162 | // 5. Build and sign JWT. |
| 163 | const now = Math.floor(Date.now() / 1000); |
| 164 | const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes |
| 165 | |
| 166 | const token = await new SignJWT({ |
| 167 | sub: user.id, |
| 168 | email: user.email, |
| 169 | name: user.name ?? null, |
| 170 | image: user.image ?? null, |
| 171 | ...templateClaims, |
| 172 | }) |
| 173 | .setProtectedHeader({ alg: 'EdDSA', typ: 'JWT' }) |
| 174 | .setIssuedAt(now) |
| 175 | .setExpirationTime(expiresAt) |
| 176 | .setSubject(user.id) |
| 177 | .sign(cryptoKey); |
| 178 | |
| 179 | return { token, expiresAt }; |
| 180 | } |