/** * TASK-0131 — SSO provider ADMIN service (org-owner gated for now; TASK-0133 * swaps the owner check for the shared `requireOrgAdmin` guard). * * Surface (all under `/api/v1/orgs/:orgId/sso`): * - `create` — one provider per org (SsoProvider.organizationId is * @unique). The plaintext secret (SAML cert PEM / OIDC client secret) * is encrypted (AES-256-GCM) into `Organization.ssoConfig` via * `SsoSecretsService` and NEVER stored in `config` (guarded by * `assertNoPlaintextSecrets`). Audit `sso.provider_configured`. * - `read` — the config + `clientSecretConfigured` / `certificateConfigured` * booleans (secrets are reveal-once; they are never returned). * - `update` — PATCH: config changes + optional new cert / secret * rotation. The SAML `certificate` field, when present, ROTATES: the * current cert moves to `previousCertificate` (rotation grace) and the * new one becomes `certificate`. * - `delete` — remove the provider + its encrypted secrets (the * identities are cascade-deleted by the FK). * - `setEnforce` — write BOTH `SsoProvider.enforce` and * `Organization.policy.ssoEnforced` (the mirror invariant — tested). * Audit `sso.enforce_changed`. * - `test` — SAML: fetch `metadataUrl` (if set) and parse the IdP * entity id + SSO URL + signing cert (green = parsed, red + reason on * failure); OIDC: fetch the issuer's `/.well-known/openid-configuration` * + JWKS and verify a key imports (RS256). Never returns the secret. */ import { Inject, Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; import type { Request } from "express"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { requireUser } from "../../common/types.js"; import { OidcConfigSchema, SamlConfigSchema, SsoProviderAdminInputSchema, assertNoPlaintextSecrets, type SsoProviderAdminInput } from "./schemas.js"; import { SsoSecretsService } from "./sso-secrets.js"; import { assertOrgAdmin } from "../org/guards.js"; @Injectable() export class SsoAdminService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(SsoSecretsService) private readonly secrets: SsoSecretsService ) {} /** * Org-ADMIN gate (TASK-0133): the interim TASK-0131 owner check is swapped * for the shared `requireOrgAdmin` guard (`role ∈ {OWNER, ADMIN}` on * `OrganizationMember` → `403 {reason:"requires_org_admin"}`). The method * name is kept (call sites unchanged); it now ALSO returns the org row for * the existing call sites. */ private async requireOrgOwner(orgId: string, userId: string) { await assertOrgAdmin(this.prisma, orgId, userId); const org = await this.prisma.organization.findUnique({ where: { id: orgId } }); if (!org) throw ApiError.notFound("Organization not found."); return org; } private async providerOf(orgId: string) { return this.prisma.ssoProvider.findUnique({ where: { organizationId: orgId } }); } /** Masked provider view (never returns secrets — booleans only). */ private view( p: { id: string; name: string; type: "SAML" | "OIDC"; enforce: boolean; config: Prisma.JsonValue; createdAt: Date; updatedAt: Date; }) { const cfg = p.config as Record; return { id: p.id, name: p.name, type: p.type, enforce: p.enforce, config: p.type === "SAML" ? SamlConfigSchema.parse(cfg) : OidcConfigSchema.parse(cfg), // Populated by `get()` from the encrypted-secrets store (the create/ // update responses omit them — the UI re-reads via GET after saving). certificateConfigured: undefined as boolean | undefined, clientSecretConfigured: undefined as boolean | undefined, createdAt: p.createdAt, updatedAt: p.updatedAt }; } async get(orgId: string, req: Request) { const userId = requireUser(req); await this.requireOrgOwner(orgId, userId); const p = await this.providerOf(orgId); if (!p) return { provider: null }; const hasCert = Boolean((await this.secrets.getSecrets(orgId)).certificate); const hasSecret = await this.secrets.hasClientSecret(orgId); const cfg = this.view(p); cfg.certificateConfigured = hasCert; cfg.clientSecretConfigured = hasSecret; return { provider: cfg }; } async create(orgId: string, body: SsoProviderAdminInput, req: Request) { const userId = requireUser(req); await this.requireOrgOwner(orgId, userId); const existing = await this.providerOf(orgId); if (existing) { throw new ApiError("CONFLICT", 409, "Organization already has an SSO provider.", [{ reason: "provider_exists" }]); } const parsed = SsoProviderAdminInputSchema.parse(body); assertNoPlaintextSecrets(parsed.config); if (parsed.type === "SAML") { await this.secrets.saveSecrets(orgId, { certificate: parsed.certificate, previousCertificate: parsed.previousCertificate }); } else { await this.secrets.saveSecrets(orgId, { clientSecret: parsed.clientSecret }); } const row = await this.prisma.ssoProvider.create({ data: { organizationId: orgId, type: parsed.type, name: parsed.name, config: parsed.config as Prisma.InputJsonValue } }); await this.audit.audit(req, undefined, { action: "sso.provider_configured", resourceType: "org", resourceId: orgId, metadata: { type: parsed.type, name: parsed.name, enforce: false } }); return { provider: this.view(row) }; } async update( orgId: string, body: { name?: string; config?: Record; certificate?: string; previousCertificate?: string; clientSecret?: string }, req: Request ) { const userId = requireUser(req); await this.requireOrgOwner(orgId, userId); const existing = await this.providerOf(orgId); if (!existing) throw new ApiError("NOT_FOUND", 404, "No SSO provider configured.", [{ reason: "no_provider" }]); const secretsPatch: { certificate?: string | null; previousCertificate?: string | null; clientSecret?: string | null } = {}; if (existing.type === "SAML" && body.certificate) { // Rotation: current → previous, new → current (the grace window). const current = (await this.secrets.getSecrets(orgId)).certificate; secretsPatch.previousCertificate = current ?? null; secretsPatch.certificate = body.certificate; } if (existing.type === "OIDC" && body.clientSecret) { secretsPatch.clientSecret = body.clientSecret; } if (Object.keys(secretsPatch).length) { await this.secrets.saveSecrets(orgId, secretsPatch); } const configPatch: Prisma.InputJsonValue = existing.config as Prisma.InputJsonValue; if (body.config) { const merged = { ...((existing.config as Record) ?? {}), ...body.config }; if (existing.type === "SAML") assertNoPlaintextSecrets(SamlConfigSchema.parse(merged)); else assertNoPlaintextSecrets(OidcConfigSchema.parse(merged)); (configPatch as Record) = merged; } const row = await this.prisma.ssoProvider.update({ where: { organizationId: orgId }, data: { ...(body.name ? { name: body.name } : {}), ...(body.config ? { config: configPatch } : {}) } }); await this.audit.audit(req, undefined, { action: "sso.provider_configured", resourceType: "org", resourceId: orgId, metadata: { type: existing.type, name: row.name, rotated: Boolean(Object.keys(secretsPatch).length) } }); return { provider: this.view(row) }; } async remove(orgId: string, req: Request) { const userId = requireUser(req); await this.requireOrgOwner(orgId, userId); const existing = await this.providerOf(orgId); if (!existing) throw new ApiError("NOT_FOUND", 404, "No SSO provider configured.", [{ reason: "no_provider" }]); await this.secrets.saveSecrets(orgId, { certificate: null, previousCertificate: null, clientSecret: null }); await this.prisma.ssoProvider.delete({ where: { organizationId: orgId } }); // Also clear the mirror policy flag (a deleted provider is not enforced). await this.patchPolicySsoEnforced(orgId, false); await this.audit.audit(req, undefined, { action: "sso.provider_deleted", resourceType: "org", resourceId: orgId, metadata: { type: existing.type, name: existing.name } }); return { deleted: true }; } async setEnforce(orgId: string, body: { enforce: boolean }, req: Request) { const userId = requireUser(req); await this.requireOrgOwner(orgId, userId); const existing = await this.providerOf(orgId); if (!existing) throw new ApiError("NOT_FOUND", 404, "No SSO provider configured.", [{ reason: "no_provider" }]); await this.prisma.ssoProvider.update({ where: { organizationId: orgId }, data: { enforce: body.enforce } }); await this.patchPolicySsoEnforced(orgId, body.enforce); await this.audit.audit(req, undefined, { action: "sso.enforce_changed", resourceType: "org", resourceId: orgId, metadata: { enforce: body.enforce, type: existing.type } }); return { enforce: body.enforce }; } /** The mirror write (spec: the policy editor writes both, tested). */ private async patchPolicySsoEnforced(orgId: string, enforced: boolean): Promise { const org = await this.prisma.organization.findUnique({ where: { id: orgId } }); if (!org) return; const policy = { ...((org.policy as Record | null) ?? {}) }; policy.ssoEnforced = enforced; await this.prisma.organization.update({ where: { id: orgId }, data: { policy: policy as Prisma.InputJsonValue } }); } /** * `POST /orgs/:orgId/sso/test` — the admin "test connection" probe. * SAML: fetch `metadataUrl` → parse `entityID` + SSO location + a cert. * OIDC: fetch discovery + JWKS → verify an RSA key imports. Green/red + * the reason, never a secret. */ async test(orgId: string, req: Request): Promise<{ ok: boolean; reason?: string; details?: Record }> { const userId = requireUser(req); await this.requireOrgOwner(orgId, userId); const p = await this.providerOf(orgId); if (!p) return { ok: false, reason: "no_provider" }; try { if (p.type === "SAML") { const cfg = SamlConfigSchema.parse(p.config as Record); if (!cfg.metadataUrl) return { ok: false, reason: "no_metadata_url" }; const res = await fetch(cfg.metadataUrl, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) return { ok: false, reason: "metadata_fetch_failed", details: { status: res.status } }; const xml = await res.text(); const entityMatch = /entityID="([^"]+)"/.exec(xml); const ssoMatch = /Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"[^>]*Location="([^"]+)"/.exec(xml) ?? /Location="([^"]+)"[^>]*Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"/.exec(xml); const hasCert = //.test(xml); return { ok: Boolean(entityMatch && ssoMatch), reason: entityMatch && ssoMatch ? undefined : "metadata_incomplete", details: { idpEntityId: entityMatch?.[1] ?? null, ssoUrl: ssoMatch?.[1] ?? null, certificateInMetadata: hasCert } }; } const cfg = OidcConfigSchema.parse(p.config as Record); const disc = await fetch(`${cfg.issuer}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(10_000) }); if (!disc.ok) return { ok: false, reason: "discovery_failed", details: { status: disc.status } }; const doc = (await disc.json()) as { issuer?: string; jwks_uri?: string }; if (doc.issuer && doc.issuer !== cfg.issuer) return { ok: false, reason: "issuer_mismatch" }; const jwksRes = await fetch(doc.jwks_uri ?? cfg.jwksUri ?? `${cfg.issuer}/.well-known/jwks.json`, { signal: AbortSignal.timeout(10_000) }); if (!jwksRes.ok) return { ok: false, reason: "jwks_failed", details: { status: jwksRes.status } }; const jwks = (await jwksRes.json()) as { keys?: Array<{ kty?: string }> }; const hasRsa = (jwks.keys ?? []).some((k) => k.kty === "RSA"); return { ok: hasRsa, reason: hasRsa ? undefined : "no_rsa_keys", details: { keyCount: jwks.keys?.length ?? 0 } }; } catch (err) { return { ok: false, reason: "probe_failed", details: { error: (err as Error).message } }; } } }