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 | |
| 12 | import { createHash, randomBytes } from 'node:crypto'; |
| 13 | |
| 14 | import { NotFoundError, ValidationError, newId } from '@briven/shared'; |
| 15 | |
| 16 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 17 | import { AUTH_SCIM_DDL_SQL } from './auth-provisioning.js'; |
| 18 | import { banUser, unbanUser } from './auth-security.js'; |
| 19 | import { log } from '../lib/logger.js'; |
| 20 | |
| 21 | const TOKEN_PREFIX = 'scim_briven_'; |
| 22 | const TOKEN_ENTROPY = 32; |
| 23 | const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; |
| 24 | const SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User'; |
| 25 | const SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group'; |
| 26 | const SCIM_LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse'; |
| 27 | const SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error'; |
| 28 | |
| 29 | export 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. */ |
| 50 | export 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 | |
| 60 | export 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 | |
| 70 | export 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 | |
| 80 | export 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 | |
| 107 | export 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 | |
| 138 | export 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 | */ |
| 158 | export 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 | |
| 176 | export 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 | |
| 188 | export function scimUserLocation(projectId: string, scimId: string, apiOrigin: string): string { |
| 189 | return `${apiOrigin.replace(/\/$/, '')}/v1/projects/${projectId}/scim/v2/Users/${scimId}`; |
| 190 | } |
| 191 | |
| 192 | export 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. */ |
| 197 | export 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"`. */ |
| 259 | export 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 | |
| 270 | export 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 | |
| 359 | export 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 | |
| 370 | export 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 | |
| 439 | export 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 | |
| 484 | export 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 | |
| 549 | export 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 | |
| 562 | export 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 | |
| 571 | export 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 | |
| 625 | export 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 | |
| 671 | export 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 | |
| 723 | export 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 | |
| 734 | export 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 | |
| 755 | export 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 | |
| 781 | function hashToken(plaintext: string): string { |
| 782 | return createHash('sha256').update(plaintext).digest('hex'); |
| 783 | } |
| 784 | |
| 785 | function toIso(v: Date | string): string { |
| 786 | if (v instanceof Date) return v.toISOString(); |
| 787 | return new Date(v).toISOString(); |
| 788 | } |
| 789 | |
| 790 | interface 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 | |
| 802 | async 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 | |
| 817 | function 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 | } |