/** * TASK-0131 — SSO secret storage (at-rest encryption). * * The SAML IdP X.509 certificate (PEM) and the OIDC client secret are stored * AES-256-GCM-encrypted on `Organization.ssoConfig` (a Json column), using the * SAME envelope + key as TASK-0130's webhook secrets (`MASTER_ENCRYPTION_KEY` * via `packages/shared/crypto`). The `SsoProvider.config` Json holds ONLY the * non-secret settings (entityId, ssoUrl, issuer, clientId, …) — the secrets * live on the org row and are re-assembled at use time. * * `ssoConfig` shape (all fields optional, additive): * { * certificateEncrypted?: string // SAML current IdP cert (b64 envelope) * previousCertificateEncrypted?: string // SAML rotation-grace cert (b64) * clientSecretEncrypted?: string // OIDC client secret (b64) * } * * A missing `MASTER_ENCRYPTION_KEY` is a 500 (fail-closed) — mirroring the * webhook-secret behavior — SSO must never store a plaintext secret. */ import { Inject, Injectable } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service.js"; import { ApiError } from "../../common/errors.js"; import { loadEnv } from "../../config/env.js"; import { aes256gcmDecrypt, aes256gcmEncrypt } from "../../lib/crypto.js"; export interface SsoSecrets { certificate?: string; // decrypted IdP X.509 PEM previousCertificate?: string; clientSecret?: string; } @Injectable() export class SsoSecretsService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} private key(): string { const k = loadEnv().MASTER_ENCRYPTION_KEY; if (!k) { throw new ApiError( "INTERNAL_ERROR", 500, "MASTER_ENCRYPTION_KEY is not configured — SSO secrets cannot be stored." ); } return k; } private readSsoConfig(orgId: string): Promise | undefined> { return this.prisma.organization .findUnique({ where: { id: orgId }, select: { ssoConfig: true } }) .then((o) => (o?.ssoConfig as Record | null) ?? undefined); } /** * Persist the SSO secrets for an org. Pass `undefined` for a field to leave * it unchanged; pass `null` to clear it. Returns the decrypted current * secrets (so the caller can act without a second round-trip). */ async saveSecrets( orgId: string, input: { certificate?: string | null; previousCertificate?: string | null; clientSecret?: string | null } ): Promise { const cfg = (await this.readSsoConfig(orgId)) ?? {}; if (input.certificate !== undefined) { cfg.certificateEncrypted = input.certificate === null ? undefined : aes256gcmEncrypt(input.certificate, this.key()); } if (input.previousCertificate !== undefined) { cfg.previousCertificateEncrypted = input.previousCertificate === null ? undefined : aes256gcmEncrypt(input.previousCertificate, this.key()); } if (input.clientSecret !== undefined) { cfg.clientSecretEncrypted = input.clientSecret === null ? undefined : aes256gcmEncrypt(input.clientSecret, this.key()); } // Drop undefined keys so the Json stays compact. const next: Record = {}; for (const [k, v] of Object.entries(cfg)) if (v !== undefined) next[k] = v; await this.prisma.organization.update({ where: { id: orgId }, data: { ssoConfig: next as object } }); return this.getSecrets(orgId); } /** Read + decrypt the org's SSO secrets (empty strings → undefined). */ async getSecrets(orgId: string): Promise { const cfg = (await this.readSsoConfig(orgId)) ?? {}; const k = this.key(); const dec = (v: unknown): string | undefined => { if (typeof v !== "string" || !v) return undefined; try { return aes256gcmDecrypt(v, k); } catch { return undefined; // corrupt/tampered → treat as absent (the validator rejects) } }; return { certificate: dec(cfg.certificateEncrypted), previousCertificate: dec(cfg.previousCertificateEncrypted), clientSecret: dec(cfg.clientSecretEncrypted) }; } /** Whether an OIDC client secret has been configured (for the admin read view). */ async hasClientSecret(orgId: string): Promise { const cfg = (await this.readSsoConfig(orgId)) ?? {}; return typeof cfg.clientSecretEncrypted === "string" && cfg.clientSecretEncrypted.length > 0; } }