auth-scim.ts834 lines · main
1/**
2 * SCIM 2.0 provisioning for Briven Auth (Phase 9 / enterprise directory sync).
3 *
4 * Company IdPs (Okta, Entra, Google Workspace) push user/group lifecycle here.
5 * Tokens live in the project data-plane (`_briven_auth_scim_tokens`); user
6 * mapping in `_briven_auth_scim_users`. Protocol routes use Bearer only —
7 * no dashboard session.
8 *
9 * Spec subset: Users + Groups CRUD, ServiceProviderConfig, basic `eq` filter.
10 */
11
12import { createHash, randomBytes } from 'node:crypto';
13
14import { NotFoundError, ValidationError, newId } from '@briven/shared';
15
16import { runInProjectDatabase } from '../db/data-plane.js';
17import { AUTH_SCIM_DDL_SQL } from './auth-provisioning.js';
18import { banUser, unbanUser } from './auth-security.js';
19import { log } from '../lib/logger.js';
20
21const TOKEN_PREFIX = 'scim_briven_';
22const TOKEN_ENTROPY = 32;
23const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
24const SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User';
25const SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group';
26const SCIM_LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse';
27const SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error';
28
29export class ScimError extends Error {
30 constructor(
31 public readonly status: number,
32 public readonly scimType: string | undefined,
33 message: string,
34 ) {
35 super(message);
36 this.name = 'ScimError';
37 }
38
39 toJson() {
40 return {
41 schemas: [SCIM_ERROR_SCHEMA],
42 status: String(this.status),
43 scimType: this.scimType,
44 detail: this.message,
45 };
46 }
47}
48
49/** Ensure SCIM tables exist (idempotent). Call before token or protocol ops. */
50export async function ensureScimTables(projectId: string): Promise<void> {
51 await runInProjectDatabase(projectId, async (tx) => {
52 for (const stmt of AUTH_SCIM_DDL_SQL) {
53 await tx.unsafe(stmt);
54 }
55 });
56}
57
58// ─── Tokens (dashboard admin) ──────────────────────────────────────────────
59
60export interface CreatedScimToken {
61 id: string;
62 name: string;
63 prefix: string;
64 suffix: string;
65 createdAt: string;
66 /** Plaintext — returned once. */
67 plaintext: string;
68}
69
70export interface MaskedScimToken {
71 id: string;
72 name: string;
73 prefix: string;
74 suffix: string;
75 createdAt: string;
76 lastUsedAt: string | null;
77 revokedAt: string | null;
78}
79
80export async function createScimToken(
81 projectId: string,
82 name: string,
83): Promise<CreatedScimToken> {
84 const trimmed = name.trim();
85 if (trimmed.length < 1 || trimmed.length > 64) {
86 throw new ValidationError('name must be 1-64 chars', { name });
87 }
88 await ensureScimTables(projectId);
89 const plaintext = `${TOKEN_PREFIX}${randomBytes(TOKEN_ENTROPY).toString('base64url')}`;
90 const hash = hashToken(plaintext);
91 const id = newId('scimtok');
92 const suffix = plaintext.slice(-4);
93 const createdAt = new Date().toISOString();
94
95 await runInProjectDatabase(projectId, async (tx) => {
96 await tx.unsafe(
97 `INSERT INTO "_briven_auth_scim_tokens"
98 (id, name, hash, prefix, suffix, created_at)
99 VALUES ($1, $2, $3, $4, $5, $6::timestamptz)`,
100 [id, trimmed, hash, TOKEN_PREFIX, suffix, createdAt] as never[],
101 );
102 });
103
104 return { id, name: trimmed, prefix: TOKEN_PREFIX, suffix, createdAt, plaintext };
105}
106
107export async function listScimTokens(projectId: string): Promise<MaskedScimToken[]> {
108 await ensureScimTables(projectId);
109 const rows = await runInProjectDatabase<
110 Array<{
111 id: string;
112 name: string;
113 prefix: string;
114 suffix: string;
115 created_at: Date;
116 last_used_at: Date | null;
117 revoked_at: Date | null;
118 }>
119 >(projectId, async (tx) =>
120 tx.unsafe(
121 `SELECT id, name, prefix, suffix, created_at, last_used_at, revoked_at
122 FROM "_briven_auth_scim_tokens"
123 ORDER BY created_at DESC
124 LIMIT 100`,
125 ),
126 );
127 return rows.map((r) => ({
128 id: r.id,
129 name: r.name,
130 prefix: r.prefix,
131 suffix: r.suffix,
132 createdAt: toIso(r.created_at),
133 lastUsedAt: r.last_used_at ? toIso(r.last_used_at) : null,
134 revokedAt: r.revoked_at ? toIso(r.revoked_at) : null,
135 }));
136}
137
138export async function revokeScimToken(projectId: string, tokenId: string): Promise<void> {
139 await ensureScimTables(projectId);
140 const updated = await runInProjectDatabase<Array<{ id: string }>>(projectId, async (tx) =>
141 tx.unsafe(
142 `UPDATE "_briven_auth_scim_tokens"
143 SET revoked_at = now()
144 WHERE id = $1 AND revoked_at IS NULL
145 RETURNING id`,
146 [tokenId] as never[],
147 ),
148 );
149 if (updated.length === 0) {
150 throw new NotFoundError('scim token not found or already revoked');
151 }
152}
153
154/**
155 * Verify Bearer token for a project. Returns true if valid.
156 * Touches last_used_at on success.
157 */
158export async function verifyScimBearer(projectId: string, bearer: string | null): Promise<boolean> {
159 if (!bearer || !bearer.startsWith(TOKEN_PREFIX)) return false;
160 await ensureScimTables(projectId);
161 const hash = hashToken(bearer);
162 const rows = await runInProjectDatabase<Array<{ id: string }>>(projectId, async (tx) =>
163 tx.unsafe(
164 `UPDATE "_briven_auth_scim_tokens"
165 SET last_used_at = now()
166 WHERE hash = $1 AND revoked_at IS NULL
167 RETURNING id`,
168 [hash] as never[],
169 ),
170 );
171 return rows.length > 0;
172}
173
174// ─── SCIM Users ────────────────────────────────────────────────────────────
175
176export interface ScimUserResource {
177 schemas: string[];
178 id: string;
179 externalId?: string;
180 userName: string;
181 active: boolean;
182 displayName?: string;
183 name?: { formatted?: string; givenName?: string; familyName?: string };
184 emails?: Array<{ value: string; primary?: boolean; type?: string }>;
185 meta: { resourceType: 'User'; created: string; lastModified: string; location: string };
186}
187
188export function scimUserLocation(projectId: string, scimId: string, apiOrigin: string): string {
189 return `${apiOrigin.replace(/\/$/, '')}/v1/projects/${projectId}/scim/v2/Users/${scimId}`;
190}
191
192export function scimGroupLocation(projectId: string, scimId: string, apiOrigin: string): string {
193 return `${apiOrigin.replace(/\/$/, '')}/v1/projects/${projectId}/scim/v2/Groups/${scimId}`;
194}
195
196/** Pure: extract primary email + display name from a SCIM User payload. */
197export function parseScimUserPayload(body: unknown): {
198 userName: string;
199 email: string;
200 displayName: string | null;
201 externalId: string | null;
202 active: boolean;
203 raw: Record<string, unknown>;
204} {
205 if (!body || typeof body !== 'object') {
206 throw new ScimError(400, 'invalidValue', 'body must be a JSON object');
207 }
208 const o = body as Record<string, unknown>;
209 const userName =
210 typeof o.userName === 'string' && o.userName.trim()
211 ? o.userName.trim()
212 : typeof o.user_name === 'string'
213 ? o.user_name.trim()
214 : '';
215 if (!userName) {
216 throw new ScimError(400, 'invalidValue', 'userName is required');
217 }
218
219 let email = userName.includes('@') ? userName : '';
220 if (Array.isArray(o.emails)) {
221 const emails = o.emails as Array<Record<string, unknown>>;
222 const primary = emails.find((e) => e.primary === true) ?? emails[0];
223 if (primary && typeof primary.value === 'string') {
224 email = primary.value.trim();
225 }
226 }
227 if (!email || !EMAIL_RE.test(email)) {
228 throw new ScimError(400, 'invalidValue', 'a valid email is required (userName or emails[0])');
229 }
230
231 let displayName: string | null = null;
232 if (typeof o.displayName === 'string' && o.displayName.trim()) {
233 displayName = o.displayName.trim();
234 } else if (o.name && typeof o.name === 'object') {
235 const n = o.name as Record<string, unknown>;
236 if (typeof n.formatted === 'string') displayName = n.formatted;
237 else {
238 const parts = [n.givenName, n.familyName].filter(
239 (x): x is string => typeof x === 'string' && x.length > 0,
240 );
241 if (parts.length) displayName = parts.join(' ');
242 }
243 }
244
245 const externalId = typeof o.externalId === 'string' ? o.externalId : null;
246 const active = o.active === false ? false : true;
247
248 return {
249 userName,
250 email: email.toLowerCase(),
251 displayName,
252 externalId,
253 active,
254 raw: o,
255 };
256}
257
258/** Pure: very small SCIM filter parser — supports `attr eq "value"`. */
259export function parseScimEqFilter(
260 filter: string | undefined,
261): { attr: string; value: string } | null {
262 if (!filter || !filter.trim()) return null;
263 const m = filter.trim().match(/^(\w+(?:\.\w+)?)\s+eq\s+"([^"]*)"$/i);
264 if (!m) {
265 throw new ScimError(400, 'invalidFilter', `unsupported filter: ${filter}`);
266 }
267 return { attr: m[1]!.toLowerCase(), value: m[2]! };
268}
269
270export async function scimCreateUser(
271 projectId: string,
272 body: unknown,
273 apiOrigin: string,
274): Promise<ScimUserResource> {
275 await ensureScimTables(projectId);
276 const parsed = parseScimUserPayload(body);
277
278 const scimId = newId('scimu');
279 const userId = newId('u');
280 const now = new Date().toISOString();
281
282 try {
283 await runInProjectDatabase(projectId, async (tx) => {
284 const existing = (await tx.unsafe(
285 `SELECT id FROM "_briven_auth_users" WHERE lower(email) = lower($1) LIMIT 1`,
286 [parsed.email] as never[],
287 )) as Array<{ id: string }>;
288 if (existing.length > 0) {
289 throw new ScimError(409, 'uniqueness', `user with email ${parsed.email} already exists`);
290 }
291 const existingScim = (await tx.unsafe(
292 `SELECT id FROM "_briven_auth_scim_users" WHERE lower(user_name) = lower($1) LIMIT 1`,
293 [parsed.userName] as never[],
294 )) as Array<{ id: string }>;
295 if (existingScim.length > 0) {
296 throw new ScimError(409, 'uniqueness', `userName ${parsed.userName} already exists`);
297 }
298
299 await tx.unsafe(
300 `INSERT INTO "_briven_auth_users" (id, email, name, email_verified, created_at, updated_at)
301 VALUES ($1, $2, $3, true, $4::timestamptz, $4::timestamptz)`,
302 [userId, parsed.email, parsed.displayName, now] as never[],
303 );
304
305 // SCIM-provisioned account — no password; sign-in via SSO / magic / invite later.
306 await tx.unsafe(
307 `INSERT INTO "_briven_auth_accounts"
308 (id, user_id, account_id, provider_id, scope, created_at, updated_at)
309 VALUES ($1, $2, $3, 'scim', 'provisioned', $4::timestamptz, $4::timestamptz)`,
310 [newId('a'), userId, userId, now] as never[],
311 );
312
313 await tx.unsafe(
314 `INSERT INTO "_briven_auth_scim_users"
315 (id, user_id, external_id, user_name, active, display_name, raw, created_at, updated_at)
316 VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::timestamptz, $8::timestamptz)`,
317 [
318 scimId,
319 userId,
320 parsed.externalId,
321 parsed.userName,
322 parsed.active,
323 parsed.displayName,
324 JSON.stringify(parsed.raw),
325 now,
326 ] as never[],
327 );
328 });
329 } catch (err) {
330 if (err instanceof ScimError) throw err;
331 log.warn('scim_create_user_failed', {
332 projectId,
333 message: err instanceof Error ? err.message : String(err),
334 });
335 throw new ScimError(500, undefined, 'failed to create user');
336 }
337
338 if (!parsed.active) {
339 await banUser(projectId, userId, { reason: 'scim_inactive' });
340 }
341
342 return toScimUser(
343 {
344 id: scimId,
345 user_id: userId,
346 external_id: parsed.externalId,
347 user_name: parsed.userName,
348 active: parsed.active,
349 display_name: parsed.displayName,
350 email: parsed.email,
351 created_at: now,
352 updated_at: now,
353 },
354 projectId,
355 apiOrigin,
356 );
357}
358
359export async function scimGetUser(
360 projectId: string,
361 scimId: string,
362 apiOrigin: string,
363): Promise<ScimUserResource> {
364 await ensureScimTables(projectId);
365 const row = await loadScimUserRow(projectId, scimId);
366 if (!row) throw new ScimError(404, undefined, 'User not found');
367 return toScimUser(row, projectId, apiOrigin);
368}
369
370export async function scimListUsers(
371 projectId: string,
372 opts: { filter?: string; startIndex?: number; count?: number },
373 apiOrigin: string,
374): Promise<{
375 schemas: string[];
376 totalResults: number;
377 startIndex: number;
378 itemsPerPage: number;
379 Resources: ScimUserResource[];
380}> {
381 await ensureScimTables(projectId);
382 const startIndex = Math.max(1, opts.startIndex ?? 1);
383 const count = Math.min(200, Math.max(1, opts.count ?? 100));
384 const offset = startIndex - 1;
385 const eq = parseScimEqFilter(opts.filter);
386
387 const { rows, total } = await runInProjectDatabase(projectId, async (tx) => {
388 let where = 'WHERE 1=1';
389 const params: unknown[] = [];
390 if (eq) {
391 if (eq.attr === 'username') {
392 params.push(eq.value);
393 where += ` AND lower(s.user_name) = lower($${params.length})`;
394 } else if (eq.attr === 'externalid') {
395 params.push(eq.value);
396 where += ` AND s.external_id = $${params.length}`;
397 } else if (eq.attr === 'emails' || eq.attr === 'emails.value') {
398 params.push(eq.value);
399 where += ` AND lower(u.email) = lower($${params.length})`;
400 } else {
401 throw new ScimError(400, 'invalidFilter', `unsupported filter attribute: ${eq.attr}`);
402 }
403 }
404
405 const countRows = (await tx.unsafe(
406 `SELECT COUNT(*)::int AS c
407 FROM "_briven_auth_scim_users" s
408 JOIN "_briven_auth_users" u ON u.id = s.user_id
409 ${where}`,
410 params as never[],
411 )) as Array<{ c: number | string }>;
412 const totalRaw = countRows[0]?.c ?? 0;
413 const totalN = typeof totalRaw === 'string' ? Number.parseInt(totalRaw, 10) : totalRaw;
414
415 params.push(count, offset);
416 const list = (await tx.unsafe(
417 `SELECT s.id, s.user_id, s.external_id, s.user_name, s.active, s.display_name,
418 s.created_at, s.updated_at, u.email
419 FROM "_briven_auth_scim_users" s
420 JOIN "_briven_auth_users" u ON u.id = s.user_id
421 ${where}
422 ORDER BY s.created_at ASC
423 LIMIT $${params.length - 1} OFFSET $${params.length}`,
424 params as never[],
425 )) as ScimUserRow[];
426
427 return { rows: list, total: totalN };
428 });
429
430 return {
431 schemas: [SCIM_LIST_SCHEMA],
432 totalResults: total,
433 startIndex,
434 itemsPerPage: rows.length,
435 Resources: rows.map((r) => toScimUser(r, projectId, apiOrigin)),
436 };
437}
438
439export async function scimReplaceUser(
440 projectId: string,
441 scimId: string,
442 body: unknown,
443 apiOrigin: string,
444): Promise<ScimUserResource> {
445 await ensureScimTables(projectId);
446 const existing = await loadScimUserRow(projectId, scimId);
447 if (!existing) throw new ScimError(404, undefined, 'User not found');
448 const parsed = parseScimUserPayload(body);
449 const now = new Date().toISOString();
450
451 await runInProjectDatabase(projectId, async (tx) => {
452 await tx.unsafe(
453 `UPDATE "_briven_auth_users"
454 SET email = $2, name = $3, updated_at = $4::timestamptz
455 WHERE id = $1`,
456 [existing.user_id, parsed.email, parsed.displayName, now] as never[],
457 );
458 await tx.unsafe(
459 `UPDATE "_briven_auth_scim_users"
460 SET external_id = $2, user_name = $3, active = $4, display_name = $5,
461 raw = $6::jsonb, updated_at = $7::timestamptz
462 WHERE id = $1`,
463 [
464 scimId,
465 parsed.externalId,
466 parsed.userName,
467 parsed.active,
468 parsed.displayName,
469 JSON.stringify(parsed.raw),
470 now,
471 ] as never[],
472 );
473 });
474
475 if (parsed.active) {
476 await unbanUser(projectId, existing.user_id);
477 } else {
478 await banUser(projectId, existing.user_id, { reason: 'scim_inactive' });
479 }
480
481 return scimGetUser(projectId, scimId, apiOrigin);
482}
483
484export async function scimPatchUser(
485 projectId: string,
486 scimId: string,
487 body: unknown,
488 apiOrigin: string,
489): Promise<ScimUserResource> {
490 await ensureScimTables(projectId);
491 const existing = await loadScimUserRow(projectId, scimId);
492 if (!existing) throw new ScimError(404, undefined, 'User not found');
493
494 // Support common Okta/Entra: { Operations: [{ op: "Replace", path: "active", value: false }] }
495 // and full replace-ish body with userName/emails.
496 if (body && typeof body === 'object' && Array.isArray((body as { Operations?: unknown }).Operations)) {
497 const ops = (body as { Operations: Array<Record<string, unknown>> }).Operations;
498 let active = existing.active;
499 let displayName = existing.display_name;
500 let email = existing.email;
501 let userName = existing.user_name;
502
503 for (const op of ops) {
504 const opName = String(op.op ?? '').toLowerCase();
505 if (opName !== 'replace' && opName !== 'add') continue;
506 const path = typeof op.path === 'string' ? op.path.toLowerCase() : '';
507 if (path === 'active' || (!path && typeof op.value === 'object' && op.value && 'active' in (op.value as object))) {
508 const v =
509 path === 'active'
510 ? op.value
511 : (op.value as { active?: unknown }).active;
512 active = v !== false && v !== 'False' && v !== 'false';
513 }
514 if (path === 'displayname' && typeof op.value === 'string') {
515 displayName = op.value;
516 }
517 if (path === 'username' && typeof op.value === 'string') {
518 userName = op.value;
519 }
520 if ((path === 'emails' || path.startsWith('emails')) && Array.isArray(op.value)) {
521 const first = (op.value as Array<{ value?: string }>)[0];
522 if (first?.value) email = first.value;
523 }
524 if (!path && op.value && typeof op.value === 'object') {
525 const v = op.value as Record<string, unknown>;
526 if (typeof v.userName === 'string') userName = v.userName;
527 if (typeof v.displayName === 'string') displayName = v.displayName;
528 if (typeof v.active === 'boolean') active = v.active;
529 }
530 }
531
532 return scimReplaceUser(
533 projectId,
534 scimId,
535 {
536 userName,
537 displayName,
538 active,
539 emails: [{ value: email, primary: true }],
540 externalId: existing.external_id,
541 },
542 apiOrigin,
543 );
544 }
545
546 return scimReplaceUser(projectId, scimId, body, apiOrigin);
547}
548
549export async function scimDeleteUser(projectId: string, scimId: string): Promise<void> {
550 await ensureScimTables(projectId);
551 const existing = await loadScimUserRow(projectId, scimId);
552 if (!existing) throw new ScimError(404, undefined, 'User not found');
553
554 // Hard delete user (cascade sessions/accounts); SCIM row cascades via FK.
555 await runInProjectDatabase(projectId, async (tx) => {
556 await tx.unsafe(`DELETE FROM "_briven_auth_users" WHERE id = $1`, [existing.user_id] as never[]);
557 });
558}
559
560// ─── SCIM Groups ───────────────────────────────────────────────────────────
561
562export interface ScimGroupResource {
563 schemas: string[];
564 id: string;
565 externalId?: string;
566 displayName: string;
567 members?: Array<{ value: string; display?: string; type?: string }>;
568 meta: { resourceType: 'Group'; created: string; lastModified: string; location: string };
569}
570
571export async function scimCreateGroup(
572 projectId: string,
573 body: unknown,
574 apiOrigin: string,
575): Promise<ScimGroupResource> {
576 await ensureScimTables(projectId);
577 if (!body || typeof body !== 'object') {
578 throw new ScimError(400, 'invalidValue', 'body must be a JSON object');
579 }
580 const o = body as Record<string, unknown>;
581 const displayName = typeof o.displayName === 'string' ? o.displayName.trim() : '';
582 if (!displayName) throw new ScimError(400, 'invalidValue', 'displayName is required');
583 const externalId = typeof o.externalId === 'string' ? o.externalId : null;
584 const members = Array.isArray(o.members)
585 ? (o.members as Array<{ value?: string }>)
586 .map((m) => m.value)
587 .filter((v): v is string => typeof v === 'string' && v.length > 0)
588 : [];
589
590 const id = newId('scimg');
591 const now = new Date().toISOString();
592
593 await runInProjectDatabase(projectId, async (tx) => {
594 await tx.unsafe(
595 `INSERT INTO "_briven_auth_scim_groups"
596 (id, display_name, external_id, active, raw, created_at, updated_at)
597 VALUES ($1, $2, $3, true, $4::jsonb, $5::timestamptz, $5::timestamptz)`,
598 [id, displayName, externalId, JSON.stringify(o), now] as never[],
599 );
600 for (const memberId of members) {
601 await tx.unsafe(
602 `INSERT INTO "_briven_auth_scim_group_members" (group_id, member_id)
603 VALUES ($1, $2) ON CONFLICT DO NOTHING`,
604 [id, memberId] as never[],
605 );
606 }
607 });
608
609 // Phase 9.2: map group → org role when configured.
610 if (members.length > 0) {
611 try {
612 const { applyScimGroupRoleMaps } = await import('./auth-scim-role-maps.js');
613 await applyScimGroupRoleMaps(projectId, displayName, members);
614 } catch (err) {
615 log.warn('scim_group_role_map_failed', {
616 projectId,
617 message: err instanceof Error ? err.message : String(err),
618 });
619 }
620 }
621
622 return scimGetGroup(projectId, id, apiOrigin);
623}
624
625export async function scimGetGroup(
626 projectId: string,
627 groupId: string,
628 apiOrigin: string,
629): Promise<ScimGroupResource> {
630 await ensureScimTables(projectId);
631 const g = await runInProjectDatabase<
632 Array<{
633 id: string;
634 display_name: string;
635 external_id: string | null;
636 created_at: Date | string;
637 updated_at: Date | string;
638 }>
639 >(projectId, async (tx) =>
640 tx.unsafe(
641 `SELECT id, display_name, external_id, created_at, updated_at
642 FROM "_briven_auth_scim_groups" WHERE id = $1 LIMIT 1`,
643 [groupId] as never[],
644 ),
645 );
646 if (!g[0]) throw new ScimError(404, undefined, 'Group not found');
647
648 const members = await runInProjectDatabase<Array<{ member_id: string }>>(projectId, async (tx) =>
649 tx.unsafe(
650 `SELECT member_id FROM "_briven_auth_scim_group_members" WHERE group_id = $1`,
651 [groupId] as never[],
652 ),
653 );
654
655 const row = g[0];
656 return {
657 schemas: [SCIM_GROUP_SCHEMA],
658 id: row.id,
659 externalId: row.external_id ?? undefined,
660 displayName: row.display_name,
661 members: members.map((m) => ({ value: m.member_id, type: 'User' })),
662 meta: {
663 resourceType: 'Group',
664 created: toIso(row.created_at),
665 lastModified: toIso(row.updated_at),
666 location: scimGroupLocation(projectId, row.id, apiOrigin),
667 },
668 };
669}
670
671export async function scimListGroups(
672 projectId: string,
673 opts: { startIndex?: number; count?: number },
674 apiOrigin: string,
675): Promise<{
676 schemas: string[];
677 totalResults: number;
678 startIndex: number;
679 itemsPerPage: number;
680 Resources: ScimGroupResource[];
681}> {
682 await ensureScimTables(projectId);
683 const startIndex = Math.max(1, opts.startIndex ?? 1);
684 const count = Math.min(200, Math.max(1, opts.count ?? 100));
685 const offset = startIndex - 1;
686
687 const { rows, total } = await runInProjectDatabase(projectId, async (tx) => {
688 const countRows = (await tx.unsafe(
689 `SELECT COUNT(*)::int AS c FROM "_briven_auth_scim_groups"`,
690 )) as Array<{ c: number | string }>;
691 const totalRaw = countRows[0]?.c ?? 0;
692 const totalN = typeof totalRaw === 'string' ? Number.parseInt(totalRaw, 10) : totalRaw;
693 const list = (await tx.unsafe(
694 `SELECT id, display_name, external_id, created_at, updated_at
695 FROM "_briven_auth_scim_groups"
696 ORDER BY created_at ASC
697 LIMIT $1 OFFSET $2`,
698 [count, offset] as never[],
699 )) as Array<{
700 id: string;
701 display_name: string;
702 external_id: string | null;
703 created_at: Date | string;
704 updated_at: Date | string;
705 }>;
706 return { rows: list, total: totalN };
707 });
708
709 const Resources: ScimGroupResource[] = [];
710 for (const row of rows) {
711 Resources.push(await scimGetGroup(projectId, row.id, apiOrigin));
712 }
713
714 return {
715 schemas: [SCIM_LIST_SCHEMA],
716 totalResults: total,
717 startIndex,
718 itemsPerPage: Resources.length,
719 Resources,
720 };
721}
722
723export async function scimDeleteGroup(projectId: string, groupId: string): Promise<void> {
724 await ensureScimTables(projectId);
725 const deleted = await runInProjectDatabase<Array<{ id: string }>>(projectId, async (tx) =>
726 tx.unsafe(
727 `DELETE FROM "_briven_auth_scim_groups" WHERE id = $1 RETURNING id`,
728 [groupId] as never[],
729 ),
730 );
731 if (deleted.length === 0) throw new ScimError(404, undefined, 'Group not found');
732}
733
734export function scimServiceProviderConfig() {
735 return {
736 schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'],
737 patch: { supported: true },
738 bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
739 filter: { supported: true, maxResults: 200 },
740 changePassword: { supported: false },
741 sort: { supported: false },
742 etag: { supported: false },
743 authenticationSchemes: [
744 {
745 type: 'oauthbearertoken',
746 name: 'OAuth Bearer Token',
747 description: 'Briven SCIM token (scim_briven_…)',
748 specUri: 'https://www.rfc-editor.org/rfc/rfc6750',
749 primary: true,
750 },
751 ],
752 };
753}
754
755export function scimResourceTypes(projectId: string, apiOrigin: string) {
756 const base = `${apiOrigin.replace(/\/$/, '')}/v1/projects/${projectId}/scim/v2`;
757 return {
758 schemas: [SCIM_LIST_SCHEMA],
759 totalResults: 2,
760 Resources: [
761 {
762 schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
763 id: 'User',
764 name: 'User',
765 endpoint: `${base}/Users`,
766 schema: SCIM_USER_SCHEMA,
767 },
768 {
769 schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
770 id: 'Group',
771 name: 'Group',
772 endpoint: `${base}/Groups`,
773 schema: SCIM_GROUP_SCHEMA,
774 },
775 ],
776 };
777}
778
779// ─── internals ─────────────────────────────────────────────────────────────
780
781function hashToken(plaintext: string): string {
782 return createHash('sha256').update(plaintext).digest('hex');
783}
784
785function toIso(v: Date | string): string {
786 if (v instanceof Date) return v.toISOString();
787 return new Date(v).toISOString();
788}
789
790interface ScimUserRow {
791 id: string;
792 user_id: string;
793 external_id: string | null;
794 user_name: string;
795 active: boolean;
796 display_name: string | null;
797 email: string;
798 created_at: Date | string;
799 updated_at: Date | string;
800}
801
802async function loadScimUserRow(projectId: string, scimId: string): Promise<ScimUserRow | null> {
803 const rows = await runInProjectDatabase<ScimUserRow[]>(projectId, async (tx) =>
804 tx.unsafe(
805 `SELECT s.id, s.user_id, s.external_id, s.user_name, s.active, s.display_name,
806 s.created_at, s.updated_at, u.email
807 FROM "_briven_auth_scim_users" s
808 JOIN "_briven_auth_users" u ON u.id = s.user_id
809 WHERE s.id = $1
810 LIMIT 1`,
811 [scimId] as never[],
812 ),
813 );
814 return rows[0] ?? null;
815}
816
817function toScimUser(row: ScimUserRow, projectId: string, apiOrigin: string): ScimUserResource {
818 return {
819 schemas: [SCIM_USER_SCHEMA],
820 id: row.id,
821 externalId: row.external_id ?? undefined,
822 userName: row.user_name,
823 active: row.active,
824 displayName: row.display_name ?? undefined,
825 name: row.display_name ? { formatted: row.display_name } : undefined,
826 emails: [{ value: row.email, primary: true, type: 'work' }],
827 meta: {
828 resourceType: 'User',
829 created: toIso(row.created_at),
830 lastModified: toIso(row.updated_at),
831 location: scimUserLocation(projectId, row.id, apiOrigin),
832 },
833 };
834}