/** * TASK-0132 — SCIM bearer-token auth. * * `Authorization: Bearer scim_…` on `/scim/v2/*` only. The token is the org: * we resolve it to an `Organization` (actorType `INTEGRATION`, actorId = * org id — audited) by SHA-256 hashing the raw token and looking it up in a * Redis-maintained hash→orgId index. * * Index maintenance (no DB scan per request): * - On enable (admin sets `scimConfig.bearerTokenHash`), the admin service * calls `upsertIndex(hash, orgId)` which does `HSET scim:index ` * + `DEL scim:index:org:` if there was a previous one. * - On invalidate, `removeIndex(hash, orgId)` HDELs the entry. * - On boot / first use (cold cache), `rebuildFromDb()` scans all orgs * with a non-null `scimConfig.bearerTokenHash` and populates the index * (idempotent — the index is a hash map, so re-populating is safe). * * The raw token is NEVER stored (only the SHA-256 hash); it is shown once * at enable time (reveal-once, like the SSO cert/secret). */ import { Inject, Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { sha256hex } from "../../lib/crypto.js"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; const INDEX_KEY = "scim:index"; const ORG_HASH_KEY = (orgId: string) => `scim:index:org:${orgId}`; @Injectable() export class ScimAuthService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService ) {} /** Resolve a raw `scim_…` token to the org it belongs to (or null). */ async resolveOrg(rawToken: string): Promise<{ orgId: string } | null> { const hash = sha256hex(rawToken); const r = this.redis.getRedis(); let orgId = await r.hget(INDEX_KEY, hash); if (!orgId) { // Cold cache (e.g. Redis was flushed) — rebuild from the DB once, // then retry. This is O(orgs-with-scim), not O(all-orgs). await this.rebuildFromDb(); orgId = await r.hget(INDEX_KEY, hash); } if (!orgId) return null; return { orgId }; } /** Maintain the index when the admin sets a token. */ async upsertIndex(sha256Hash: string, orgId: string): Promise { const r = this.redis.getRedis(); // Remove any previous hash for this org (token rotation). const oldHash = await r.get(ORG_HASH_KEY(orgId)); if (oldHash && oldHash !== sha256Hash) await r.hdel(INDEX_KEY, oldHash); await r.hset(INDEX_KEY, sha256Hash, orgId); await r.set(ORG_HASH_KEY(orgId), sha256Hash); } /** Remove the index entry (token invalidated / SCIM disabled). */ async removeIndex(sha256Hash: string, orgId: string): Promise { const r = this.redis.getRedis(); const current = await r.get(ORG_HASH_KEY(orgId)); if (current === sha256Hash) { await r.del(ORG_HASH_KEY(orgId)); await r.hdel(INDEX_KEY, sha256Hash); } } /** Populate the index from the DB (idempotent). */ async rebuildFromDb(): Promise { const r = this.redis.getRedis(); const orgs = await this.prisma.organization.findMany({ where: { scimConfig: { not: Prisma.JsonNull } }, select: { id: true, scimConfig: true } }); for (const org of orgs) { const cfg = org.scimConfig as Record | null; const hash = (cfg?.bearerTokenHash as string | undefined) ?? null; if (!hash) continue; await r.hset(INDEX_KEY, hash, org.id); await r.set(ORG_HASH_KEY(org.id), hash); } } }