idp-clients.ts244 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
183export async function listOidcClients(projectId: string): Promise<OidcClient[]> {
184 const pool = getEnginePool();
185 const res = await pool.query(
186 `SELECT * FROM be_oidc_clients WHERE project_id = $1 ORDER BY created_at DESC`,
187 [projectId],
188 );
189 return (res.rows as Array<Record<string, unknown>>).map(mapRow);
190}
191
192export async function getOidcClientByClientId(
193 clientId: string,
194): Promise<OidcClient | null> {
195 const pool = getEnginePool();
196 const res = await pool.query(
197 `SELECT * FROM be_oidc_clients WHERE client_id = $1 LIMIT 1`,
198 [clientId],
199 );
200 const row = res.rows[0] as Record<string, unknown> | undefined;
201 if (!row) return null;
202 return mapRow(row);
203}
204
205export async function revokeOidcClient(
206 projectId: string,
207 clientId: string,
208): Promise<void> {
209 const pool = getEnginePool();
210 const res = await pool.query(
211 `UPDATE be_oidc_clients SET revoked_at = NOW()
212 WHERE project_id = $1 AND client_id = $2 AND revoked_at IS NULL
213 RETURNING id`,
214 [projectId, clientId],
215 );
216 if (res.rowCount === 0) throw new Error('client not found or already revoked');
217 void recordBrivenEngineAudit({
218 action: 'oidc.client.revoked',
219 projectId,
220 metadata: { clientId },
221 });
222}
223
224/** Verify confidential client secret (constant-time). Public clients: secret ignored. */
225export async function verifyOidcClientSecret(
226 client: OidcClient,
227 secret: string | null | undefined,
228): Promise<boolean> {
229 if (client.isPublic) return true;
230 if (client.revokedAt) return false;
231 if (!secret) return false;
232 const pool = getEnginePool();
233 const res = await pool.query(
234 `SELECT client_secret_hash FROM be_oidc_clients WHERE client_id = $1 LIMIT 1`,
235 [client.clientId],
236 );
237 const row = res.rows[0] as { client_secret_hash?: string } | undefined;
238 const expected = row?.client_secret_hash;
239 if (!expected) return false;
240 const got = hashSecret(secret);
241 const a = Buffer.from(expected, 'utf8');
242 const b = Buffer.from(got, 'utf8');
243 return a.length === b.length && timingSafeEqual(a, b);
244}