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
10import { generateKeyPair, SignJWT, importJWK, type JWK } from 'jose';
11
12import { runInProjectDatabase } from '../db/data-plane.js';
13
14// ─── template CRUD ────────────────────────────────────────────────────────
15
16export 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
25export 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
39export 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
53export 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
64async 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
96export 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
109export interface JwtTokenResult {
110 token: string;
111 expiresAt: Date;
112}
113
114export 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}