idp-clients.ts391 lines · main
1/**
2 * OIDC client registry (per Briven project) — production IdP apps.
3 */
4
5import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
6
7import { getEnginePool } from './db.js';
8import { mapProjectToAuthCore } from './project-map.js';
9import { recordBrivenEngineAudit } from './audit.js';
10
11export type OidcClient = {
12 id: string;
13 clientId: string;
14 projectId: string;
15 tenantId: string;
16 name: string;
17 logoUrl: string | null;
18 isPublic: boolean;
19 redirectUris: string[];
20 postLogoutUris: string[];
21 grantTypes: string[];
22 scopes: string[];
23 tokenEndpointAuthMethod: string;
24 secretSuffix: string | null;
25 revokedAt: string | null;
26 createdAt: string;
27};
28
29function hashSecret(secret: string): string {
30 return createHash('sha256').update(`briven-oidc-client:${secret}`).digest('hex');
31}
32
33function parseJsonArray(raw: unknown, fallback: string[]): string[] {
34 try {
35 const v = typeof raw === 'string' ? JSON.parse(raw) : raw;
36 if (!Array.isArray(v)) return fallback;
37 return v.map(String).filter(Boolean);
38 } catch {
39 return fallback;
40 }
41}
42
43function mapRow(r: Record<string, unknown>): OidcClient {
44 return {
45 id: String(r.id),
46 clientId: String(r.client_id),
47 projectId: String(r.project_id),
48 tenantId: String(r.tenant_id),
49 name: String(r.name),
50 logoUrl: r.logo_url ? String(r.logo_url) : null,
51 isPublic: Boolean(r.is_public),
52 redirectUris: parseJsonArray(r.redirect_uris_json, []),
53 postLogoutUris: parseJsonArray(r.post_logout_uris_json, []),
54 grantTypes: parseJsonArray(r.grant_types_json, [
55 'authorization_code',
56 'refresh_token',
57 ]),
58 scopes: parseJsonArray(r.scopes_json, [
59 'openid',
60 'profile',
61 'email',
62 'offline_access',
63 ]),
64 tokenEndpointAuthMethod: String(
65 r.token_endpoint_auth_method ?? 'client_secret_post',
66 ),
67 secretSuffix: r.client_secret_suffix ? String(r.client_secret_suffix) : null,
68 revokedAt: r.revoked_at
69 ? r.revoked_at instanceof Date
70 ? r.revoked_at.toISOString()
71 : String(r.revoked_at)
72 : null,
73 createdAt:
74 r.created_at instanceof Date
75 ? r.created_at.toISOString()
76 : String(r.created_at ?? new Date().toISOString()),
77 };
78}
79
80export function redirectUriAllowed(client: OidcClient, uri: string): boolean {
81 return client.redirectUris.includes(uri);
82}
83
84export async function createOidcClient(input: {
85 projectId: string;
86 name: string;
87 redirectUris: string[];
88 logoUrl?: string | null;
89 isPublic?: boolean;
90 postLogoutUris?: string[];
91 scopes?: string[];
92 createdBy?: string | null;
93}): Promise<{ client: OidcClient; clientSecret: string | null }> {
94 const name = input.name.trim();
95 if (!name || name.length > 120) throw new Error('name must be 1–120 characters');
96 const redirectUris = (input.redirectUris ?? [])
97 .map((u) => u.trim())
98 .filter(Boolean);
99 if (redirectUris.length === 0) throw new Error('at least one redirect_uri required');
100 for (const u of redirectUris) {
101 let parsed: URL;
102 try {
103 parsed = new URL(u);
104 } catch {
105 throw new Error(`invalid redirect_uri: ${u}`);
106 }
107 if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
108 throw new Error(`redirect_uri must be http(s): ${u}`);
109 }
110 if (
111 parsed.protocol === 'http:' &&
112 parsed.hostname !== 'localhost' &&
113 parsed.hostname !== '127.0.0.1'
114 ) {
115 throw new Error(`http redirect_uri only allowed on localhost: ${u}`);
116 }
117 }
118
119 const isPublic = Boolean(input.isPublic);
120 const map = mapProjectToAuthCore(input.projectId);
121 const id = `oidc_${randomBytes(10).toString('hex')}`;
122 const clientId = `oidc_app_${randomBytes(12).toString('base64url')}`;
123 let clientSecret: string | null = null;
124 let secretHash: string | null = null;
125 let secretSuffix: string | null = null;
126 if (!isPublic) {
127 clientSecret = `oidc_sec_${randomBytes(24).toString('base64url')}`;
128 secretHash = hashSecret(clientSecret);
129 secretSuffix = clientSecret.slice(-4);
130 }
131
132 const postLogout = (input.postLogoutUris ?? []).map((u) => u.trim()).filter(Boolean);
133 const scopes = input.scopes?.length
134 ? input.scopes
135 : ['openid', 'profile', 'email', 'offline_access'];
136 let logoUrl: string | null = null;
137 if (input.logoUrl?.trim()) {
138 const lu = input.logoUrl.trim();
139 if (lu.startsWith('https://') || lu.startsWith('http://localhost')) {
140 logoUrl = lu.slice(0, 500);
141 }
142 }
143
144 const pool = getEnginePool();
145 await pool.query(
146 `INSERT INTO be_oidc_clients
147 (id, client_id, project_id, tenant_id, name, logo_url, client_secret_hash,
148 client_secret_suffix, is_public, redirect_uris_json, post_logout_uris_json,
149 grant_types_json, scopes_json, token_endpoint_auth_method, created_by, created_at)
150 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW())`,
151 [
152 id,
153 clientId,
154 input.projectId,
155 map.tenantId,
156 name,
157 logoUrl,
158 secretHash,
159 secretSuffix,
160 isPublic,
161 JSON.stringify(redirectUris),
162 JSON.stringify(postLogout),
163 JSON.stringify(['authorization_code', 'refresh_token']),
164 JSON.stringify(scopes),
165 isPublic ? 'none' : 'client_secret_post',
166 input.createdBy ?? null,
167 ],
168 );
169
170 void recordBrivenEngineAudit({
171 action: 'oidc.client.created',
172 projectId: input.projectId,
173 tenantId: map.tenantId,
174 userId: input.createdBy ?? null,
175 metadata: { clientId, name, isPublic },
176 });
177
178 const client = await getOidcClientByClientId(clientId);
179 if (!client) throw new Error('client create failed');
180 return { client, clientSecret };
181}
182
183/**
184 * Kill live credentials for a client: refresh tokens, unused auth codes,
185 * pending auth requests. SuperTokens-class: revoke means the old secret/tokens
186 * cannot be used again.
187 */
188export async function purgeOidcClientSessions(clientId: string): Promise<{
189 refreshRevoked: number;
190 codesDeleted: number;
191 requestsDeleted: number;
192}> {
193 const pool = getEnginePool();
194 const refresh = await pool.query(
195 `UPDATE be_oidc_refresh_tokens SET revoked_at = NOW()
196 WHERE client_id = $1 AND revoked_at IS NULL`,
197 [clientId],
198 );
199 const codes = await pool.query(
200 `DELETE FROM be_oidc_auth_codes
201 WHERE client_id = $1 AND used_at IS NULL`,
202 [clientId],
203 );
204 const requests = await pool.query(
205 `DELETE FROM be_oidc_auth_requests WHERE client_id = $1`,
206 [clientId],
207 );
208 return {
209 refreshRevoked: refresh.rowCount ?? 0,
210 codesDeleted: codes.rowCount ?? 0,
211 requestsDeleted: requests.rowCount ?? 0,
212 };
213}
214
215export async function listOidcClients(
216 projectId: string,
217 opts?: { includeRevoked?: boolean },
218): Promise<OidcClient[]> {
219 const pool = getEnginePool();
220 const includeRevoked = Boolean(opts?.includeRevoked);
221 const res = await pool.query(
222 includeRevoked
223 ? `SELECT * FROM be_oidc_clients WHERE project_id = $1 ORDER BY created_at DESC`
224 : `SELECT * FROM be_oidc_clients
225 WHERE project_id = $1 AND revoked_at IS NULL
226 ORDER BY created_at DESC`,
227 [projectId],
228 );
229 return (res.rows as Array<Record<string, unknown>>).map(mapRow);
230}
231
232export async function getOidcClientByClientId(
233 clientId: string,
234): Promise<OidcClient | null> {
235 const pool = getEnginePool();
236 const res = await pool.query(
237 `SELECT * FROM be_oidc_clients WHERE client_id = $1 LIMIT 1`,
238 [clientId],
239 );
240 const row = res.rows[0] as Record<string, unknown> | undefined;
241 if (!row) return null;
242 return mapRow(row);
243}
244
245/**
246 * Rotate confidential client secret. Old secret is overwritten immediately;
247 * live refresh tokens for this app are revoked.
248 */
249export async function rotateOidcClientSecret(
250 projectId: string,
251 clientId: string,
252): Promise<{ client: OidcClient; clientSecret: string }> {
253 const client = await getOidcClientByClientId(clientId);
254 if (!client || client.projectId !== projectId) {
255 throw new Error('client not found');
256 }
257 if (client.revokedAt) throw new Error('client is revoked — cannot rotate');
258 if (client.isPublic) {
259 throw new Error('public clients have no secret — use PKCE only');
260 }
261
262 const clientSecret = `oidc_sec_${randomBytes(24).toString('base64url')}`;
263 const secretHash = hashSecret(clientSecret);
264 const secretSuffix = clientSecret.slice(-4);
265 const pool = getEnginePool();
266 const res = await pool.query(
267 `UPDATE be_oidc_clients
268 SET client_secret_hash = $1,
269 client_secret_suffix = $2
270 WHERE project_id = $3 AND client_id = $4 AND revoked_at IS NULL
271 RETURNING id`,
272 [secretHash, secretSuffix, projectId, clientId],
273 );
274 if (res.rowCount === 0) throw new Error('client not found or already revoked');
275
276 const purged = await purgeOidcClientSessions(clientId);
277 void recordBrivenEngineAudit({
278 action: 'oidc.client.secret_rotated',
279 projectId,
280 metadata: {
281 clientId,
282 refreshRevoked: purged.refreshRevoked,
283 codesDeleted: purged.codesDeleted,
284 },
285 });
286
287 const updated = await getOidcClientByClientId(clientId);
288 if (!updated) throw new Error('client missing after rotate');
289 return { client: updated, clientSecret };
290}
291
292/**
293 * Soft-revoke: client cannot authenticate; secret wiped; sessions purged.
294 * Row stays for audit until hard-deleted.
295 */
296export async function revokeOidcClient(
297 projectId: string,
298 clientId: string,
299): Promise<void> {
300 const pool = getEnginePool();
301 const res = await pool.query(
302 `UPDATE be_oidc_clients
303 SET revoked_at = NOW(),
304 client_secret_hash = NULL,
305 client_secret_suffix = NULL
306 WHERE project_id = $1 AND client_id = $2 AND revoked_at IS NULL
307 RETURNING id`,
308 [projectId, clientId],
309 );
310 if (res.rowCount === 0) throw new Error('client not found or already revoked');
311 const purged = await purgeOidcClientSessions(clientId);
312 // Drop consents so re-register feels clean if a new client is created later
313 await pool.query(`DELETE FROM be_oidc_consents WHERE client_id = $1`, [
314 clientId,
315 ]);
316 void recordBrivenEngineAudit({
317 action: 'oidc.client.revoked',
318 projectId,
319 metadata: {
320 clientId,
321 refreshRevoked: purged.refreshRevoked,
322 codesDeleted: purged.codesDeleted,
323 },
324 });
325}
326
327/**
328 * Permanently remove a revoked (or force-active) client and leftover rows.
329 */
330export async function deleteOidcClient(
331 projectId: string,
332 clientId: string,
333 opts?: { force?: boolean },
334): Promise<void> {
335 const client = await getOidcClientByClientId(clientId);
336 if (!client || client.projectId !== projectId) {
337 throw new Error('client not found');
338 }
339 if (!client.revokedAt && !opts?.force) {
340 throw new Error('revoke the client first, then delete — or pass force');
341 }
342 await purgeOidcClientSessions(clientId);
343 const pool = getEnginePool();
344 await pool.query(`DELETE FROM be_oidc_consents WHERE client_id = $1`, [
345 clientId,
346 ]);
347 await pool.query(
348 `DELETE FROM be_oidc_auth_codes WHERE client_id = $1`,
349 [clientId],
350 );
351 await pool.query(
352 `DELETE FROM be_oidc_refresh_tokens WHERE client_id = $1`,
353 [clientId],
354 );
355 await pool.query(
356 `DELETE FROM be_oidc_auth_requests WHERE client_id = $1`,
357 [clientId],
358 );
359 const res = await pool.query(
360 `DELETE FROM be_oidc_clients WHERE project_id = $1 AND client_id = $2`,
361 [projectId, clientId],
362 );
363 if (res.rowCount === 0) throw new Error('client not found');
364 void recordBrivenEngineAudit({
365 action: 'oidc.client.deleted',
366 projectId,
367 metadata: { clientId, force: Boolean(opts?.force) },
368 });
369}
370
371/** Verify confidential client secret (constant-time). Public clients: secret ignored. */
372export async function verifyOidcClientSecret(
373 client: OidcClient,
374 secret: string | null | undefined,
375): Promise<boolean> {
376 if (client.isPublic) return true;
377 if (client.revokedAt) return false;
378 if (!secret) return false;
379 const pool = getEnginePool();
380 const res = await pool.query(
381 `SELECT client_secret_hash FROM be_oidc_clients WHERE client_id = $1 LIMIT 1`,
382 [client.clientId],
383 );
384 const row = res.rows[0] as { client_secret_hash?: string } | undefined;
385 const expected = row?.client_secret_hash;
386 if (!expected) return false;
387 const got = hashSecret(secret);
388 const a = Buffer.from(expected, 'utf8');
389 const b = Buffer.from(got, 'utf8');
390 return a.length === b.length && timingSafeEqual(a, b);
391}