From 66d2916aa0692aec3203bd8efb654757b31b445e Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Thu, 11 Jun 2026 10:10:01 +0200 Subject: [PATCH 01/10] feat(dto,gateway): shared SSO protocol contract + internal SAML config types (T-33) - Add strict SsoProtocol union ('oidc' | 'saml') and optional protocol field to SsoProviderPublicDTO (backward-compatible, defaults to oidc) - Document that resolveFederatedUserSchema covers both OIDC sub and SAML persistent NameID (255-char column-bound ceiling, fail-closed) - Add secret-bearing SamlProviderConfig type in the gateway (never exported via @dto): multi-cert rotation, literal-true wantAssertionsSigned, sha1 excluded by type design Co-Authored-By: Claude Fable 5 --- apps/gateway/src/sso/saml-types.ts | 74 +++++++++++++++++++++++++++++ libs/rest-dto/src/lib/sso.ts | 14 +++++- libs/rest-dto/src/lib/validation.ts | 11 ++++- 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 apps/gateway/src/sso/saml-types.ts diff --git a/apps/gateway/src/sso/saml-types.ts b/apps/gateway/src/sso/saml-types.ts new file mode 100644 index 0000000..e22413e --- /dev/null +++ b/apps/gateway/src/sso/saml-types.ts @@ -0,0 +1,74 @@ +import { ClaimPermissionMapping } from '@dto'; + +/** + * Full, secret-bearing configuration of one SAML 2.0 provider. + * Lives ONLY in the gateway — never serialised to the client. + * Use {@link SsoProviderPublicDTO} (from `@dto`) for anything the browser may see. + * + * Mirrors the shape of {@link SsoProviderConfig} (OIDC) in `provider-registry.ts` + * so the two registries remain structurally consistent. + * + * Security notes: + * - `idpCertPems` and `decryptionPvk` are cryptographic material; treat them as + * secrets and keep them out of logs, responses, and error messages. + * - `wantAssertionsSigned` is typed as the literal `true` so the compiler prevents + * any code path from weakening assertion-signing requirements. + * - `sha1` is not an accepted `signatureAlgorithm` value by design (broken algorithm). + */ +export interface SamlProviderConfig { + /** Lowercased provider key, used in `/auth/sso/:id/login`. */ + id: string; + displayName: string; + iconKey?: string; + /** SSO URL of the IdP (HTTP-Redirect binding). */ + entryPoint: string; + /** SP entityID — our service's identifier sent to the IdP. */ + issuer: string; + /** + * EntityID of the IdP. + * Validated against the `Issuer` element in the SAML response to prevent + * mix-up attacks where a response from a different IdP is accepted. + */ + idpIssuer: string; + /** + * One or more X.509 certificate(s) PEM-encoded from the IdP, used to verify + * response signatures. Multiple entries support certificate rotation without + * downtime — validation succeeds if any cert in the list matches. + * Must contain at least one entry. + */ + idpCertPems: string[]; + /** Assertion Consumer Service URL — must exactly match what is registered at the IdP. */ + callbackUrl: string; + /** + * XML digital-signature algorithm for the AuthnRequest. + * `sha1` is intentionally excluded (cryptographically broken). + */ + signatureAlgorithm: 'sha256' | 'sha512'; + /** + * Require the IdP to sign individual assertions (not only the outer Response). + * Typed as the literal `true` — this requirement must never be relaxed. + */ + wantAssertionsSigned: true; + /** + * Optional allowlist of email domains accepted from this IdP. + * When present, users whose email domain is not in the list are rejected + * after assertion validation, before local account resolution. + */ + allowedDomains?: string[]; + /** Name of the SAML attribute carrying the user's group/role memberships. */ + groupsAttribute: string; + /** Name of the SAML attribute carrying the user's email address. */ + emailAttribute: string; + /** + * Mapping from IdP group/role claim values to local permissions. + * Treated as a SUGGESTION by the API — the API may floor it to a minimum. + */ + permissionMap: ClaimPermissionMapping[]; + /** Optional SLO (Single Logout) endpoint of the IdP. */ + logoutUrl?: string; + /** + * Private key (PEM) for decrypting encrypted SAML assertions. + * Secret — must never appear in logs, serialised responses, or error output. + */ + decryptionPvk?: string; +} diff --git a/libs/rest-dto/src/lib/sso.ts b/libs/rest-dto/src/lib/sso.ts index 909239f..403100e 100644 --- a/libs/rest-dto/src/lib/sso.ts +++ b/libs/rest-dto/src/lib/sso.ts @@ -1,5 +1,11 @@ import { CreationOptional, Permission } from './rest-dto'; +/** + * Federation protocol implemented by an SSO provider. + * Strict literal union — never widen to `string`. + */ +export type SsoProtocol = 'oidc' | 'saml'; + /** * Mirrors the `federated_identity` table. * `subject` is the IdP-issued opaque identifier — it is internal and must not @@ -19,14 +25,18 @@ export interface FederatedIdentityDTO { /** * Public metadata the frontend needs to render login buttons. - * MUST NOT contain `clientSecret`, `issuer` internals, raw IdP claims, - * `subject`, or any other secret/internal field. + * MUST NOT contain `clientSecret`, `issuer` internals, IdP URLs, certificates, + * entityIDs, raw IdP claims, `subject`, or any other secret/internal field. * `id` is the provider key used in the login URL `/auth/sso/:id/login`. + * `protocol` indicates the federation protocol; defaults to `'oidc'` when absent + * so existing clients that do not read the field continue to work correctly. */ export interface SsoProviderPublicDTO { id: string; displayName: string; iconKey?: string; + /** Federation protocol of the provider. Defaults to 'oidc' when absent. */ + protocol?: SsoProtocol; } /** diff --git a/libs/rest-dto/src/lib/validation.ts b/libs/rest-dto/src/lib/validation.ts index 86d32b7..54dc5d2 100644 --- a/libs/rest-dto/src/lib/validation.ts +++ b/libs/rest-dto/src/lib/validation.ts @@ -54,8 +54,15 @@ export type PaginationQuery = z.infer; /** * Schema for the internal federated resolve/provision endpoint. - * Called exclusively by the gateway after it has fully validated an OIDC - * ID token. `.strict()` rejects extra keys (mass-assignment defense). + * Called exclusively by the gateway after it has fully validated an IdP + * assertion — either an OIDC ID token or a SAML 2.0 assertion. + * `.strict()` rejects extra keys (mass-assignment defense). + * + * `subject` covers both the OIDC `sub` claim and the SAML NameID + * (persistent format). The 255-char ceiling matches the `federated_identity` + * varchar(255) column; the SAML spec allows persistent NameIDs up to 256 chars, + * so any IdP that emits a 256-char NameID will be rejected at the schema layer — + * this is intentional and the column size should be the source of truth. */ export const resolveFederatedUserSchema = z .object({ From f6650f960eb87195ec0132d271ef68a75fe17ce8 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Thu, 11 Jun 2026 10:25:01 +0200 Subject: [PATCH 02/10] feat(gateway): SAML provider registry + multi-protocol federated registry (T-34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - saml-provider-registry.ts: SAML__* env parsing mirroring the OIDC pattern — fail-fast validation, x509 cert checks (expired -> throw, <2048-bit RSA -> throw, <30-day expiry warn), multi-cert rotation via ';', sha256/sha512 only, deterministic SAML<->OIDC id collision check - federated-registry.ts: unified protocol-tagged public provider list (stable id order) and discriminated getFederatedProvider(id) lookup - Reuse (not duplicate) the OIDC SSRF guard and permission-map parser; harden the guard into two tiers: link-local/cloud-metadata and 0.0.0.0 are blocked unconditionally (even with SSO_ALLOW_INSECURE_ISSUERS), loopback/RFC1918 only allowed behind the explicit dev escape hatch - validateSamlConfig() wired into gateway boot next to validateSsoConfig() - 25 new Vitest cases incl. SSRF tiers, cert fixtures and registry mixing Co-Authored-By: Claude Fable 5 --- .../src/controllers/sso.controller.spec.ts | 8 +- .../gateway/src/controllers/sso.controller.ts | 6 +- apps/gateway/src/main.ts | 2 + apps/gateway/src/sso/federated-registry.ts | 97 +++++ .../gateway/src/sso/provider-registry.spec.ts | 49 ++- apps/gateway/src/sso/provider-registry.ts | 114 +++-- .../src/sso/saml-provider-registry.spec.ts | 404 ++++++++++++++++++ .../gateway/src/sso/saml-provider-registry.ts | 314 ++++++++++++++ 8 files changed, 945 insertions(+), 49 deletions(-) create mode 100644 apps/gateway/src/sso/federated-registry.ts create mode 100644 apps/gateway/src/sso/saml-provider-registry.spec.ts create mode 100644 apps/gateway/src/sso/saml-provider-registry.ts diff --git a/apps/gateway/src/controllers/sso.controller.spec.ts b/apps/gateway/src/controllers/sso.controller.spec.ts index 49c9d3f..00fbd48 100644 --- a/apps/gateway/src/controllers/sso.controller.spec.ts +++ b/apps/gateway/src/controllers/sso.controller.spec.ts @@ -3,6 +3,8 @@ import { Permission } from '@dto'; vi.mock('@gateway/sso/provider-registry', () => ({ getProviderConfig: vi.fn(), +})); +vi.mock('@gateway/sso/federated-registry', () => ({ listPublicProviders: vi.fn(), })); vi.mock('@gateway/sso/discovery', () => ({ getClient: vi.fn() })); @@ -35,10 +37,8 @@ vi.mock('openid-client', () => ({ }, })); -import { - getProviderConfig, - listPublicProviders, -} from '@gateway/sso/provider-registry'; +import { getProviderConfig } from '@gateway/sso/provider-registry'; +import { listPublicProviders } from '@gateway/sso/federated-registry'; import { getClient } from '@gateway/sso/discovery'; import { ApiClient } from '@gateway/clients/api.client'; import { diff --git a/apps/gateway/src/controllers/sso.controller.ts b/apps/gateway/src/controllers/sso.controller.ts index c95552a..a9de07c 100644 --- a/apps/gateway/src/controllers/sso.controller.ts +++ b/apps/gateway/src/controllers/sso.controller.ts @@ -12,10 +12,8 @@ import { readLogoutHint, setLogoutHintCookie, } from '@gateway/sso/sso-logout.service'; -import { - getProviderConfig, - listPublicProviders, -} from '@gateway/sso/provider-registry'; +import { getProviderConfig } from '@gateway/sso/provider-registry'; +import { listPublicProviders } from '@gateway/sso/federated-registry'; import { clearTransactionCookie, readTransaction, diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index 6726911..37b3234 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -6,6 +6,7 @@ import helmet from 'helmet'; import api from './routes'; import { validateSsoConfig } from './sso/provider-registry'; +import { validateSamlConfig } from './sso/saml-provider-registry'; const parseOrigins = (raw?: string): string[] => (raw ?? '') @@ -26,6 +27,7 @@ class Main { // Fail-fast: a declared-but-misconfigured SSO provider must stop boot, not // surface at runtime. No providers configured ⇒ no-op (SSO disabled). validateSsoConfig(); + validateSamlConfig(); this.#config(); this.#setRoutes(); this.#app.listen(this.#port, '0.0.0.0', () => { diff --git a/apps/gateway/src/sso/federated-registry.ts b/apps/gateway/src/sso/federated-registry.ts new file mode 100644 index 0000000..f7c0bc3 --- /dev/null +++ b/apps/gateway/src/sso/federated-registry.ts @@ -0,0 +1,97 @@ +import { SsoProviderPublicDTO } from '@dto'; +import { SsoProviderConfig, getAllProviderConfigs } from './provider-registry'; +import { SamlProviderConfig } from './saml-types'; +import { + getAllSamlProviderConfigs, + isSamlEnabled, + resetSamlRegistryCache, +} from './saml-provider-registry'; +import { isSsoEnabled, resetRegistryCache } from './provider-registry'; + +// Re-export so callers that previously depended on resetRegistryCache from +// provider-registry can also reset the federated cache atomically. +export { resetRegistryCache, resetSamlRegistryCache }; + +/** + * Resets both underlying protocol caches in one call. + * Use in tests that mutate `process.env` or need a clean slate. + */ +export const resetFederatedRegistryCache = (): void => { + resetRegistryCache(); + resetSamlRegistryCache(); +}; + +// --------------------------------------------------------------------------- +// Discriminated-union type for protocol-aware provider lookup +// --------------------------------------------------------------------------- + +/** Result of {@link getFederatedProvider}: protocol-tagged config. */ +export type FederatedProvider = + | { protocol: 'oidc'; config: SsoProviderConfig } + | { protocol: 'saml'; config: SamlProviderConfig }; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Returns the unified public metadata for all configured SSO providers + * (both OIDC and SAML), sorted by provider id for a stable display order. + * + * This is the single source of truth for the `/auth/sso/providers` endpoint. + * It MUST NOT include any secret-bearing fields (clientSecret, cert PEMs, + * issuer URLs, decryptionPvk, etc.) — only `id`, `displayName`, `iconKey`, + * and `protocol`. + */ +export const listPublicProviders = (): SsoProviderPublicDTO[] => { + const oidcProviders: SsoProviderPublicDTO[] = getAllProviderConfigs().map( + ({ id, displayName, iconKey }) => ({ + id, + displayName, + iconKey, + protocol: 'oidc' as const, + }), + ); + + const samlProviders: SsoProviderPublicDTO[] = getAllSamlProviderConfigs().map( + ({ id, displayName, iconKey }) => ({ + id, + displayName, + iconKey, + protocol: 'saml' as const, + }), + ); + + return [...oidcProviders, ...samlProviders].sort((a, b) => + a.id.localeCompare(b.id), + ); +}; + +/** + * Looks up a provider by id across both OIDC and SAML registries. + * Returns a discriminated union so callers can narrow to the correct + * config type without unsafe casts. + * + * Returns `undefined` when no provider with that id is registered. + */ +export const getFederatedProvider = ( + id: string, +): FederatedProvider | undefined => { + const oidcConfigs = getAllProviderConfigs(); + const oidc = oidcConfigs.find((c) => c.id === id); + if (oidc) return { protocol: 'oidc', config: oidc }; + + const samlConfigs = getAllSamlProviderConfigs(); + const saml = samlConfigs.find((c) => c.id === id); + if (saml) return { protocol: 'saml', config: saml }; + + return undefined; +}; + +/** + * Returns `true` when at least one SSO provider (OIDC or SAML) is configured. + * Use this to conditionally show the SSO section in the UI or enable + * federated-login routes. + */ +export const isFederationEnabled = (): boolean => + isSsoEnabled() || isSamlEnabled(); diff --git a/apps/gateway/src/sso/provider-registry.spec.ts b/apps/gateway/src/sso/provider-registry.spec.ts index 9bd0bdc..035afed 100644 --- a/apps/gateway/src/sso/provider-registry.spec.ts +++ b/apps/gateway/src/sso/provider-registry.spec.ts @@ -4,9 +4,12 @@ import { buildRegistryFromEnv, getProviderConfig, isSsoEnabled, - listPublicProviders, resetRegistryCache, } from './provider-registry'; +import { + listPublicProviders, + resetFederatedRegistryCache, +} from './federated-registry'; const okta = { SSO_OKTA_ISSUER: 'https://example.okta.com', @@ -112,14 +115,34 @@ describe('buildRegistryFromEnv', () => { expect(() => buildRegistryFromEnv(okta)).not.toThrow(); }); - it('allows insecure issuers only behind the explicit dev escape hatch', () => { - expect(() => - buildRegistryFromEnv({ - ...okta, - SSO_OKTA_ISSUER: 'http://localhost:8080', - SSO_ALLOW_INSECURE_ISSUERS: 'true', - }), - ).not.toThrow(); + it('allows insecure/local issuers only behind the explicit dev escape hatch', () => { + // Dev setups run the IdP on localhost or a private docker network. + for (const issuer of ['http://localhost:8080', 'https://10.1.2.3']) { + expect(() => + buildRegistryFromEnv({ + ...okta, + SSO_OKTA_ISSUER: issuer, + SSO_ALLOW_INSECURE_ISSUERS: 'true', + }), + ).not.toThrow(); + } + }); + + it('blocks link-local/cloud-metadata even with the dev escape hatch', () => { + // Tier-1 block is unconditional — no escape hatch reaches 169.254.x.x. + for (const issuer of [ + 'https://169.254.169.254/meta', + 'http://169.254.169.254/meta', + 'https://0.0.0.0', + ]) { + expect(() => + buildRegistryFromEnv({ + ...okta, + SSO_OKTA_ISSUER: issuer, + SSO_ALLOW_INSECURE_ISSUERS: 'true', + }), + ).toThrow(/not allowed/); + } }); }); }); @@ -129,6 +152,7 @@ describe('public accessors (process.env-backed)', () => { afterEach(() => { process.env = { ...saved }; resetRegistryCache(); + resetFederatedRegistryCache(); }); it('listPublicProviders exposes only id/displayName/iconKey — no secrets', () => { @@ -141,7 +165,12 @@ describe('public accessors (process.env-backed)', () => { const list = listPublicProviders(); expect(list).toEqual([ - { id: 'okta', displayName: 'Okta', iconKey: 'okta-logo' }, + { + id: 'okta', + displayName: 'Okta', + iconKey: 'okta-logo', + protocol: 'oidc', + }, ]); const serialised = JSON.stringify(list); expect(serialised).not.toContain('secret-okta'); diff --git a/apps/gateway/src/sso/provider-registry.ts b/apps/gateway/src/sso/provider-registry.ts index 3eb5f5a..c58d8b3 100644 --- a/apps/gateway/src/sso/provider-registry.ts +++ b/apps/gateway/src/sso/provider-registry.ts @@ -1,9 +1,11 @@ -import { ClaimPermissionMapping, Permission, SsoProviderPublicDTO } from '@dto'; +import { ClaimPermissionMapping, Permission } from '@dto'; +// SsoProviderPublicDTO intentionally not imported here — the unified public +// surface lives in federated-registry.ts, which owns listPublicProviders(). /** * Full, secret-bearing configuration of one OIDC provider. Lives ONLY in the - * gateway — never serialised to the client. Use {@link listPublicProviders} - * for anything the browser may see. + * gateway — never serialised to the client. Use the federated registry's + * `listPublicProviders` for anything the browser may see. */ export interface SsoProviderConfig { /** Lowercased provider key, used in `/auth/sso/:id/login`. */ @@ -23,47 +25,105 @@ export interface SsoProviderConfig { const ENV_PREFIX = /^SSO_(.+)_ISSUER$/; -// Hosts that must never be reachable as an issuer: loopback, link-local -// (incl. the 169.254.169.254 cloud metadata endpoint), and RFC1918 ranges. -// SSRF defense — an attacker-influenced issuer must not pivot to internal hosts. -const isBlockedHost = (host: string): boolean => { - const h = host.replace(/^\[|\]$/g, '').toLowerCase(); - if (h === 'localhost' || h === '0.0.0.0' || h === '::1') return true; +// SSRF defense, two tiers: +// - ALWAYS blocked, even with the dev escape-hatch: link-local (incl. the +// 169.254.169.254 cloud metadata endpoint) and 0.0.0.0/"this host". There is +// no legitimate federation scenario — dev included — pointing at those. +// - Blocked unless the dev escape-hatch is active: loopback and RFC1918 +// ranges. Dev setups legitimately run the IdP on localhost or a private +// docker network; production must never set SSO_ALLOW_INSECURE_ISSUERS. +const parseIpv4 = (h: string): [number, number] | null => { const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (!ipv4) return false; - const [a, b] = [Number(ipv4[1]), Number(ipv4[2])]; - if (a === 127 || a === 10 || a === 0) return true; // loopback / private / this-host + return ipv4 ? [Number(ipv4[1]), Number(ipv4[2])] : null; +}; + +const isAlwaysBlockedHost = (host: string): boolean => { + const h = host.replace(/^\[|\]$/g, '').toLowerCase(); + if (h === '0.0.0.0') return true; + const ip = parseIpv4(h); + if (!ip) return false; + const [a, b] = ip; + if (a === 0) return true; // "this host" if (a === 169 && b === 254) return true; // link-local + cloud metadata + return false; +}; + +const isPrivateHost = (host: string): boolean => { + const h = host.replace(/^\[|\]$/g, '').toLowerCase(); + if (h === 'localhost' || h === '::1') return true; + const ip = parseIpv4(h); + if (!ip) return false; + const [a, b] = ip; + if (a === 127 || a === 10) return true; // loopback / private if (a === 192 && b === 168) return true; // private if (a === 172 && b >= 16 && b <= 31) return true; // private return false; }; -const assertSafeIssuer = ( +/** + * SSRF guard for any federation URL (OIDC issuer, SAML entryPoint, logoutUrl…). + * Enforces https and blocks loopback/RFC1918 destinations, except when the dev + * escape-hatch (`SSO_ALLOW_INSECURE_ISSUERS=true`) is active — dev setups run + * IdPs on localhost/private networks. Link-local (cloud metadata) and 0.0.0.0 + * are blocked UNCONDITIONALLY: the escape hatch never opens those. + * + * @param providerId Provider id included in error messages (never the URL value). + * @param url The URL to validate. + * @param allowInsecure Set to true only in dev (`SSO_ALLOW_INSECURE_ISSUERS=true`). + * @param label Human-readable field name for the error message (e.g. "issuer", + * "entryPoint", "logoutUrl"). + */ +export const assertSafeFederationUrl = ( providerId: string, - issuer: string, + url: string, allowInsecure: boolean, + label = 'url', ): void => { - let url: URL; + let parsed: URL; try { - url = new URL(issuer); + parsed = new URL(url); } catch { - throw new Error(`SSO provider "${providerId}" has a malformed issuer URL`); + throw new Error( + `SSO provider "${providerId}" has a malformed ${label} URL`, + ); + } + // Tier 1 — unconditional: cloud-metadata/link-local/0.0.0.0 are never a + // legitimate federation target, not even in dev. + if (isAlwaysBlockedHost(parsed.hostname)) { + throw new Error( + `SSO provider "${providerId}" ${label} host is not allowed (link-local/metadata)`, + ); } - if (url.protocol !== 'https:') { - if (allowInsecure && url.protocol === 'http:') return; + if (parsed.protocol !== 'https:') { + if (allowInsecure && parsed.protocol === 'http:') return; throw new Error( - `SSO provider "${providerId}" issuer must use https (got ${url.protocol})`, + `SSO provider "${providerId}" ${label} must use https (got ${parsed.protocol})`, ); } - if (!allowInsecure && isBlockedHost(url.hostname)) { + // Tier 2 — loopback/RFC1918: blocked unless the explicit dev escape hatch. + if (!allowInsecure && isPrivateHost(parsed.hostname)) { throw new Error( - `SSO provider "${providerId}" issuer host is not allowed (loopback/link-local/private)`, + `SSO provider "${providerId}" ${label} host is not allowed (loopback/link-local/private)`, ); } }; -const parsePermissionMap = ( +// Internal alias kept for backward-compatibility within this module. +const assertSafeIssuer = ( + providerId: string, + issuer: string, + allowInsecure: boolean, +): void => assertSafeFederationUrl(providerId, issuer, allowInsecure, 'issuer'); + +/** + * Parses a raw permission-map string (`claim:PERM1,PERM2;…`) into structured + * mappings. Unknown permissions and malformed entries are a fatal misconfiguration + * — this function throws so they are caught at boot, never granted silently. + * + * @param providerId Included in error messages; must not contain secret material. + * @param raw The raw env-var value, or `undefined` when the var is absent. + */ +export const parsePermissionMap = ( providerId: string, raw: string | undefined, ): ClaimPermissionMapping[] => { @@ -182,11 +242,3 @@ export const getProviderConfig = (id: string): SsoProviderConfig | undefined => registry().get(id); export const isSsoEnabled = (): boolean => registry().size > 0; - -/** Secret-free provider metadata for the browser (login buttons). */ -export const listPublicProviders = (): SsoProviderPublicDTO[] => - getAllProviderConfigs().map(({ id, displayName, iconKey }) => ({ - id, - displayName, - iconKey, - })); diff --git a/apps/gateway/src/sso/saml-provider-registry.spec.ts b/apps/gateway/src/sso/saml-provider-registry.spec.ts new file mode 100644 index 0000000..fae3136 --- /dev/null +++ b/apps/gateway/src/sso/saml-provider-registry.spec.ts @@ -0,0 +1,404 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Permission } from '@dto'; +import { + buildSamlRegistryFromEnv, + getSamlProviderConfig, + isSamlEnabled, + resetSamlRegistryCache, +} from './saml-provider-registry'; +import { resetRegistryCache } from './provider-registry'; +import { + getFederatedProvider, + isFederationEnabled, + listPublicProviders, + resetFederatedRegistryCache, +} from './federated-registry'; + +// --------------------------------------------------------------------------- +// Test-only X.509 fixtures. Self-signed throwaway certs generated exclusively +// for this spec — the private keys were discarded and are not reused anywhere. +// --------------------------------------------------------------------------- + +// RSA-2048, CN=test-idp-valid, expires 2100-05-14. +const VALID_CERT = `-----BEGIN CERTIFICATE----- +MIICsDCCAZgCCQDZWwknp3ogYzANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA50 +ZXN0LWlkcC12YWxpZDAgFw0yNjA2MTEwODE2MTRaGA8yMTAwMDUxNDA4MTYxNFow +GTEXMBUGA1UEAwwOdGVzdC1pZHAtdmFsaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQCyVQEMEZSUOzo0xgDLU8EF2I5bFPCzfEPinC/BlYa3RCo0fnWN +r4ZPEfExEMU7nDos+K6i4mApA0vYCdkY+jFA0yB4OKYLMTKd4n29t2PAqkevLu/R +r5sZ+0bVe28+ciDzMNcG/B4JCJncCy+QWn8xdcf8LNg5bCOWLeOMAbZkDepuAIW2 +72grFEtcFH/x3UIBiVRw8Uu8U45SJkL/cQnkJDAteJ1ELoRL+2U2DuH5xn2C4jiJ +D87Sq2MpkLml+YmeXi440g1362iDyROWvRqKI8fGvG4stXKKuqcoKy708JSdHKfs +FvmB4wXGwzBuoy0g2xy63KICNzimbcKCjfJfAgMBAAEwDQYJKoZIhvcNAQELBQAD +ggEBAGrPTol7VQM4SMa1lLsasJwVW9fo7+09g27EZB+nh0B2kd3nNvAJRRM5GJDe +1o/pXAnBxp/tzQZvI8slUPFDDMYXXT4K5MKsrF3emOeT3UIQK5Uff1cRU9OG71mo +7Zukzb03OcJg8bodq0ZNWk7PjhLScyNMUQpWUJHpgDyglFWGplcmLzDNrh8e1r1g +s+qRBG+cf9/8cvuybkIKbnj6h0jNC6mPXfBp3ZVVWLZyNVrTJDENyYl6FLB3hKix +gevtFdPJ/xebLkJaqvRe0ibT4Zt+JtNmvHHI+/uBrLOPv2DCkhmeteR6hyofubhb +5UPyV34+IaicTy7s3W7E1BKe+vQ= +-----END CERTIFICATE-----`; + +// RSA-1024 (intentionally weak), CN=test-idp-weak, expires 2100-05-14. +const WEAK_CERT = `-----BEGIN CERTIFICATE----- +MIIBqTCCARICCQD/4XvlccaiGzANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA10 +ZXN0LWlkcC13ZWFrMCAXDTI2MDYxMTA4MTYxNFoYDzIxMDAwNTE0MDgxNjE0WjAY +MRYwFAYDVQQDDA10ZXN0LWlkcC13ZWFrMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB +iQKBgQDlihgn9fviMnDEU/yJzVKTdZKWZJajp8f2FGVfiziuhkZk0f77WX2GZPn/ +FQ6deO85vxccksirrgU/lNpDgCCZDhGsRMOzMK/KAwpy+bSe2YcrG1QOt0r9WrmT +gkP5h1NHydtpWs+truO5lSMCffJFRFaBjXQsF6GhRDMJXH05zQIDAQABMA0GCSqG +SIb3DQEBCwUAA4GBAN3sAfSDjFw7k8x6q4iC4dkwGZchgNWZLcGwB8JoYuCPUxXM +ZZ994nOU7W1GiUhDi5w3xob8EBt24F1WfUmWAf7IxW1z+xpiMcvnO/SA/0kBPowv +FdFXrjAK84R5ZC5JqboXOy9hX6dl61G8EEv0eMjxmZB8GY/maQ01fWVUnOI+ +-----END CERTIFICATE-----`; + +// RSA-2048, CN=test-idp-expired, expires 2026-07-11. Used with fake timers to +// exercise both the "expires soon" warning and the "expired" rejection paths. +const SHORT_LIVED_CERT = `-----BEGIN CERTIFICATE----- +MIICsjCCAZoCCQDH15vWUmOnXTANBgkqhkiG9w0BAQsFADAbMRkwFwYDVQQDDBB0 +ZXN0LWlkcC1leHBpcmVkMB4XDTI2MDYxMTA4MTYxNFoXDTI2MDcxMTA4MTYxNFow +GzEZMBcGA1UEAwwQdGVzdC1pZHAtZXhwaXJlZDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAMijQeLeLTYq8dUmhVlMdEQcWmVimqaBvvHw8pqIERoybYGJ +kAF0BYkibRSpp5RImwKfDQ8wpJm7zZ2NKjhxUqingv+98CMpee5V9ScEJw2NFpr2 +83pP34ebC9k3WZIUXVSukpMPJ8Exc5nzBxDQB5WO34Z28lG4oSHsXi2zUpgBDyWX +T217rNJGF3+bVrMqq2fK/DTuZHD1gDmzYhPdmiDG38OpP+BmxgqEU/35d4AYYV32 +HbDX9z31wW2SWePJT2BJia0s6MQfx6hphur+uys/gH6hDGOgEsZgJhsV5Q0rBeMw +yWnxiJPqAU6vRa+EvkFxXipeDxIXNEMFGuztsJkCAwEAATANBgkqhkiG9w0BAQsF +AAOCAQEAl9GB9G+okid+Uxsz2ffkYhcCc74OIglgfJW6nj3SndIPz+YdLIypUTUi +R7K4hByEYLSkqsOzW6BPO70/pjSO8/fBMHZAGii3+/C9vO+NtcWGiJma/Q0cMmpo +yomznNgGfQ7cyOk6NU7/XMhUaYg+Imz79AHifpb3ICobtPVbpXJl2wLmKa1V6QBY +3VSwcKj312u5TiuabgeRjNQ88WjMjpvf2LN2asqNh+dE3mI+R/MqpS1Z2BZFG3lp +3CvBa7d8l9QBZPz9vYZF6nGOmHND5Mm3SnAy4bjo5bybYcEZPxgaA2nJlhYF2Nvt +DLWV7xOOxFpksznY/IbJU4aoyPbeCA== +-----END CERTIFICATE-----`; + +const acme = { + SAML_ACME_ENTRY_POINT: 'https://idp.acme.example/sso/saml', + SAML_ACME_IDP_ISSUER: 'https://idp.acme.example/metadata', + SAML_ACME_IDP_CERT: VALID_CERT, + SAML_ACME_CALLBACK_URL: + 'https://app.example.com/api/v1/auth/sso/acme/callback', +}; + +const oktaOidc = { + SSO_OKTA_ISSUER: 'https://example.okta.com', + SSO_OKTA_CLIENT_ID: 'client-okta', + SSO_OKTA_CLIENT_SECRET: 'secret-okta', + SSO_OKTA_REDIRECT_URI: + 'https://app.example.com/api/v1/auth/sso/okta/callback', +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('buildSamlRegistryFromEnv', () => { + it('returns an empty registry when no provider is declared (SAML off)', () => { + expect(buildSamlRegistryFromEnv({}).size).toBe(0); + }); + + it('parses a provider with defaults applied', () => { + const reg = buildSamlRegistryFromEnv(acme); + const cfg = reg.get('acme'); + expect(cfg).toBeDefined(); + expect(cfg?.displayName).toBe('Acme'); + expect(cfg?.entryPoint).toBe(acme.SAML_ACME_ENTRY_POINT); + expect(cfg?.idpIssuer).toBe(acme.SAML_ACME_IDP_ISSUER); + // SP entityID defaults to the origin of the callback URL. + expect(cfg?.issuer).toBe('https://app.example.com'); + expect(cfg?.emailAttribute).toBe('email'); + expect(cfg?.groupsAttribute).toBe('groups'); + expect(cfg?.signatureAlgorithm).toBe('sha256'); + expect(cfg?.wantAssertionsSigned).toBe(true); + expect(cfg?.idpCertPems).toHaveLength(1); + expect(cfg?.allowedDomains).toBeUndefined(); + expect(cfg?.logoutUrl).toBeUndefined(); + expect(cfg?.decryptionPvk).toBeUndefined(); + }); + + it('honours every optional override', () => { + const reg = buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_SP_ISSUER: 'https://app.example.com/saml/metadata', + SAML_ACME_DISPLAY_NAME: 'Acme Corp', + SAML_ACME_ICON_KEY: 'acme-logo', + SAML_ACME_EMAIL_ATTRIBUTE: 'mail', + SAML_ACME_GROUPS_ATTRIBUTE: 'memberOf', + SAML_ACME_SIGNATURE_ALGORITHM: 'sha512', + SAML_ACME_LOGOUT_URL: 'https://idp.acme.example/slo', + SAML_ACME_ALLOWED_DOMAINS: ' @Acme.com , corp.io ', + SAML_ACME_PERMISSION_MAP: 'admins:ADMIN;viewers:READ_SOME_ENTITY', + SAML_ACME_DECRYPTION_PVK: 'test-placeholder-not-a-real-key', + }); + const cfg = reg.get('acme'); + expect(cfg?.issuer).toBe('https://app.example.com/saml/metadata'); + expect(cfg?.displayName).toBe('Acme Corp'); + expect(cfg?.iconKey).toBe('acme-logo'); + expect(cfg?.emailAttribute).toBe('mail'); + expect(cfg?.groupsAttribute).toBe('memberOf'); + expect(cfg?.signatureAlgorithm).toBe('sha512'); + expect(cfg?.logoutUrl).toBe('https://idp.acme.example/slo'); + expect(cfg?.allowedDomains).toEqual(['acme.com', 'corp.io']); + expect(cfg?.permissionMap).toEqual([ + { claim: 'admins', permissions: [Permission.ADMIN] }, + { claim: 'viewers', permissions: [Permission.READ_SOME_ENTITY] }, + ]); + expect(cfg?.decryptionPvk).toBe('test-placeholder-not-a-real-key'); + }); + + it.each(['IDP_ISSUER', 'IDP_CERT', 'CALLBACK_URL'] as const)( + 'fails fast when a declared provider is missing %s', + (key) => { + const env: NodeJS.ProcessEnv = { ...acme }; + delete env[`SAML_ACME_${key}`]; + expect(() => buildSamlRegistryFromEnv(env)).toThrow( + new RegExp(`missing required env SAML_ACME_${key}`), + ); + }, + ); + + it('rejects an invalid signature algorithm (sha1 is disabled)', () => { + expect(() => + buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_SIGNATURE_ALGORITHM: 'sha1', + }), + ).toThrow(/Only "sha256" and "sha512" are accepted/); + }); + + it('fails fast on a malformed permission map (never grants silently)', () => { + expect(() => + buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_PERMISSION_MAP: 'x:SUPERUSER', + }), + ).toThrow(/unknown permission "SUPERUSER"/); + }); + + describe('certificate validation', () => { + it('rejects an unparseable certificate without echoing its content', () => { + let message = ''; + try { + buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_IDP_CERT: 'bm90LWEtY2VydA==', + }); + } catch (e) { + message = (e as Error).message; + } + expect(message).toMatch(/unparseable IdP certificate/); + expect(message).not.toContain('bm90LWEtY2VydA'); + }); + + it('rejects an expired certificate', () => { + vi.useFakeTimers(); + // 2026-08-01: SHORT_LIVED_CERT (validTo 2026-07-11) is expired. + vi.setSystemTime(new Date('2026-08-01T00:00:00Z')); + expect(() => + buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_IDP_CERT: SHORT_LIVED_CERT, + }), + ).toThrow(/certificate has expired/); + }); + + it('warns (but accepts) a certificate expiring in <30 days', () => { + vi.useFakeTimers(); + // 2026-06-15: SHORT_LIVED_CERT expires 2026-07-11 → 26 days left. + vi.setSystemTime(new Date('2026-06-15T00:00:00Z')); + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const reg = buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_IDP_CERT: SHORT_LIVED_CERT, + }); + expect(reg.get('acme')?.idpCertPems).toHaveLength(1); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).not.toContain('BEGIN CERTIFICATE'); + }); + + it('rejects an RSA key smaller than 2048 bits', () => { + expect(() => + buildSamlRegistryFromEnv({ ...acme, SAML_ACME_IDP_CERT: WEAK_CERT }), + ).toThrow(/RSA key smaller than 2048 bits/); + }); + + it('supports multi-cert rotation via the ";" separator', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-15T00:00:00Z')); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const reg = buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_IDP_CERT: `${VALID_CERT};${SHORT_LIVED_CERT}`, + }); + expect(reg.get('acme')?.idpCertPems).toHaveLength(2); + }); + + it('normalises bare base64 (no PEM armour) and literal \\n escapes', () => { + const bare = VALID_CERT.replace( + /-----(BEGIN|END) CERTIFICATE-----/g, + '', + ).replace(/\s/g, ''); + const escaped = VALID_CERT.replace(/\n/g, '\\n'); + for (const variant of [bare, escaped]) { + const reg = buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_IDP_CERT: variant, + }); + const pems = reg.get('acme')?.idpCertPems ?? []; + expect(pems).toHaveLength(1); + expect(pems[0]).toMatch(/^-----BEGIN CERTIFICATE-----\n/); + } + }); + }); + + describe('id collisions across protocols', () => { + it('rejects a SAML provider whose id collides with an OIDC provider', () => { + expect(() => + buildSamlRegistryFromEnv({ + ...oktaOidc, + SAML_OKTA_ENTRY_POINT: 'https://idp.okta.example/sso', + SAML_OKTA_IDP_ISSUER: 'https://idp.okta.example/metadata', + SAML_OKTA_IDP_CERT: VALID_CERT, + SAML_OKTA_CALLBACK_URL: 'https://app.example.com/cb', + }), + ).toThrow(/collides with an existing OIDC provider/); + }); + }); + + describe('SSRF / federation-URL hardening', () => { + it('rejects a non-https entryPoint', () => { + expect(() => + buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_ENTRY_POINT: 'http://idp.acme.example/sso', + }), + ).toThrow(/must use https/); + }); + + it('rejects loopback, link-local and RFC1918 entryPoints', () => { + for (const host of [ + 'https://localhost/sso', + 'https://169.254.169.254/sso', + 'https://10.1.2.3/sso', + 'https://192.168.0.5/sso', + ]) { + expect(() => + buildSamlRegistryFromEnv({ ...acme, SAML_ACME_ENTRY_POINT: host }), + ).toThrow(/not allowed/); + } + }); + + it('applies the same guard to the optional logoutUrl', () => { + expect(() => + buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_LOGOUT_URL: 'https://169.254.169.254/slo', + }), + ).toThrow(/not allowed/); + }); + + it('allows insecure/local entryPoints only behind the explicit dev escape hatch', () => { + // Dev setups run the IdP on localhost or a private docker network. + for (const entryPoint of [ + 'http://localhost:8443/sso', + 'https://10.1.2.3/sso', + ]) { + expect(() => + buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_ENTRY_POINT: entryPoint, + SSO_ALLOW_INSECURE_ISSUERS: 'true', + }), + ).not.toThrow(); + } + }); + + it('blocks link-local/cloud-metadata even with the dev escape hatch', () => { + // Tier-1 block is unconditional — no escape hatch reaches 169.254.x.x. + for (const entryPoint of [ + 'https://169.254.169.254/sso', + 'http://169.254.169.254/sso', + 'https://0.0.0.0/sso', + ]) { + expect(() => + buildSamlRegistryFromEnv({ + ...acme, + SAML_ACME_ENTRY_POINT: entryPoint, + SSO_ALLOW_INSECURE_ISSUERS: 'true', + }), + ).toThrow(/not allowed/); + } + }); + }); +}); + +describe('federated registry (process.env-backed)', () => { + const saved = { ...process.env }; + afterEach(() => { + process.env = { ...saved }; + resetRegistryCache(); + resetSamlRegistryCache(); + resetFederatedRegistryCache(); + }); + + const configureMixedEnv = () => { + Object.assign(process.env, oktaOidc, acme); + resetRegistryCache(); + resetSamlRegistryCache(); + }; + + it('lists OIDC and SAML providers mixed, protocol-tagged and id-sorted', () => { + configureMixedEnv(); + expect(listPublicProviders()).toEqual([ + { id: 'acme', displayName: 'Acme', iconKey: undefined, protocol: 'saml' }, + { + id: 'okta', + displayName: 'Okta', + iconKey: undefined, + protocol: 'oidc', + }, + ]); + }); + + it('never leaks certificates or secrets through the public list', () => { + configureMixedEnv(); + const serialised = JSON.stringify(listPublicProviders()); + expect(serialised).not.toContain('CERTIFICATE'); + expect(serialised).not.toContain('secret-okta'); + expect(serialised).not.toContain('idp.acme.example'); + }); + + it('getFederatedProvider discriminates protocols and misses safely', () => { + configureMixedEnv(); + const saml = getFederatedProvider('acme'); + expect(saml?.protocol).toBe('saml'); + if (saml?.protocol === 'saml') { + expect(saml.config.entryPoint).toBe(acme.SAML_ACME_ENTRY_POINT); + } + const oidc = getFederatedProvider('okta'); + expect(oidc?.protocol).toBe('oidc'); + if (oidc?.protocol === 'oidc') { + expect(oidc.config.clientId).toBe('client-okta'); + } + expect(getFederatedProvider('ghost')).toBeUndefined(); + }); + + it('reports federation enabled when either protocol has providers', () => { + for (const k of Object.keys(process.env)) { + if (k.startsWith('SSO_') || k.startsWith('SAML_')) delete process.env[k]; + } + resetRegistryCache(); + resetSamlRegistryCache(); + expect(isFederationEnabled()).toBe(false); + expect(isSamlEnabled()).toBe(false); + + Object.assign(process.env, acme); + resetRegistryCache(); + resetSamlRegistryCache(); + expect(isFederationEnabled()).toBe(true); + expect(isSamlEnabled()).toBe(true); + expect(getSamlProviderConfig('acme')?.idpCertPems).toHaveLength(1); + }); +}); diff --git a/apps/gateway/src/sso/saml-provider-registry.ts b/apps/gateway/src/sso/saml-provider-registry.ts new file mode 100644 index 0000000..cd97e2f --- /dev/null +++ b/apps/gateway/src/sso/saml-provider-registry.ts @@ -0,0 +1,314 @@ +import { X509Certificate } from 'node:crypto'; +import { ClaimPermissionMapping } from '@dto'; +import { SamlProviderConfig } from './saml-types'; +import { + assertSafeFederationUrl, + buildRegistryFromEnv, + parsePermissionMap, +} from './provider-registry'; + +// Detects a SAML provider declaration by its ENTRY_POINT variable. +// id = lowercased capture group, e.g. SAML_OKTA_ENTRY_POINT → "okta". +const SAML_ENV_PREFIX = /^SAML_(.+)_ENTRY_POINT$/; + +// How many days before expiry to emit a warning about a cert that is +// about to expire. 30 days gives operators time to rotate without downtime. +const CERT_WARN_DAYS = 30; + +const titleCase = (name: string): string => + name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); + +// --------------------------------------------------------------------------- +// Certificate normalisation and validation +// --------------------------------------------------------------------------- + +/** + * Normalises a single raw certificate string into a PEM-encoded DER block: + * - Converts literal `\n` escape sequences to real newlines. + * - If the value is base64 without PEM armour, wraps it in BEGIN/END headers. + * Returns the normalised PEM string. + * + * Security note: this function MUST NOT log or propagate the cert content + * in error messages — it only includes the provider id. + */ +const normaliseCertPem = (raw: string): string => { + // Replace literal backslash-n sequences with real newlines. + const unescaped = raw.replace(/\\n/g, '\n').trim(); + + if (unescaped.startsWith('-----BEGIN')) { + // Already PEM-armoured — return as-is (normalised whitespace). + return unescaped; + } + + // Assume base64 DER without headers; wrap in standard PEM armour. + // Strip any whitespace that might have been present in the env var. + const stripped = unescaped.replace(/\s/g, ''); + const lines = stripped.match(/.{1,64}/g)?.join('\n') ?? stripped; + return `-----BEGIN CERTIFICATE-----\n${lines}\n-----END CERTIFICATE-----`; +}; + +/** + * Parses and validates one X.509 certificate PEM for a SAML provider. + * Throws on parse errors or key weakness. Warns (without logging the cert) + * when the cert is close to expiry. + * + * @param providerId Used in error/warn messages — never the cert content. + * @param pem PEM string (already normalised by {@link normaliseCertPem}). + */ +const validateCert = (providerId: string, pem: string): void => { + let cert: X509Certificate; + try { + cert = new X509Certificate(pem); + } catch { + // Do NOT include pem content — it may contain sensitive material or + // garbage that could be used in a log-injection attack. + throw new Error( + `SAML provider "${providerId}" has an unparseable IdP certificate`, + ); + } + + const now = Date.now(); + const validTo = new Date(cert.validTo).getTime(); + + if (validTo < now) { + throw new Error( + `SAML provider "${providerId}" IdP certificate has expired (validTo: ${cert.validTo})`, + ); + } + + const warnThreshold = now + CERT_WARN_DAYS * 24 * 60 * 60 * 1000; + if (validTo < warnThreshold) { + // Warning only — expired certs above already throw. + console.warn( + `[SAML] Provider "${providerId}": IdP certificate expires in less than ${CERT_WARN_DAYS} days (${cert.validTo}). Rotate it before it expires.`, + ); + } + + // Enforce a minimum RSA key size. EC/Ed25519/Ed448 keys do not have a modulus + // and are accepted unconditionally (their strength is not measured in bits). + const pubKey = cert.publicKey; + if ( + pubKey.asymmetricKeyType === 'rsa' || + pubKey.asymmetricKeyType === 'rsa-pss' + ) { + const modulusLength = pubKey.asymmetricKeyDetails?.modulusLength; + if (modulusLength !== undefined && modulusLength < 2048) { + throw new Error( + `SAML provider "${providerId}" IdP certificate uses an RSA key smaller than 2048 bits`, + ); + } + } +}; + +/** + * Splits a semicolon-separated cert string, normalises each entry, validates + * each cert independently, and returns the array of PEM strings. + * Fails fast on the first invalid cert. + */ +const parseIdpCerts = (providerId: string, rawCerts: string): string[] => { + const entries = rawCerts + .split(';') + .map((s) => s.trim()) + .filter(Boolean); + + if (entries.length === 0) { + throw new Error( + `SAML provider "${providerId}" SAML_${providerId.toUpperCase()}_IDP_CERT is empty`, + ); + } + + return entries.map((raw) => { + const pem = normaliseCertPem(raw); + validateCert(providerId, pem); + return pem; + }); +}; + +// --------------------------------------------------------------------------- +// Required / optional env helpers +// --------------------------------------------------------------------------- + +const requiredSaml = ( + env: NodeJS.ProcessEnv, + providerId: string, + rawName: string, + key: string, +): string => { + const varName = `SAML_${rawName}_${key}`; + const value = env[varName]; + if (!value || !value.trim()) { + throw new Error( + `SAML provider "${providerId}" is missing required env ${varName}`, + ); + } + return value.trim(); +}; + +// --------------------------------------------------------------------------- +// Core builder +// --------------------------------------------------------------------------- + +/** + * Pure builder: parses and validates every SAML provider declared in the given + * env object. Throws (fail-fast) on any misconfiguration. Zero providers is + * valid — SAML simply off. Exported for unit-testing with an explicit env. + * + * Provider detection: any `SAML__ENTRY_POINT` variable declares a + * provider; `NAME` is lowercased to produce the provider id (the primary key + * stored in `federated_identity.provider`). + * + * Id collision: a SAML provider id must not collide with any OIDC provider id + * declared in the SAME env (same id → same `federated_identity.provider` key → + * accounts would be mixed across protocols). The builder derives the OIDC id + * set from the env it receives, so the check is pure and deterministic. + * SAML↔SAML collisions cannot occur because each `SAML__ENTRY_POINT` + * key is unique by construction of a `Map`. + * + * Security notes: + * - Cert PEMs and `_DECRYPTION_PVK` are cryptographic secrets; they MUST NOT + * appear in error messages, logs, or serialised responses. + * - Only variable names and provider ids are included in error messages. + */ +export const buildSamlRegistryFromEnv = ( + env: NodeJS.ProcessEnv, +): Map => { + const allowInsecure = env.SSO_ALLOW_INSECURE_ISSUERS === 'true'; + const samlRegistry = new Map(); + + // OIDC ids declared in the SAME env — pure, deterministic collision source. + // Cheap to rebuild; at boot validateSsoConfig() has already vetted this env. + const oidcIds = new Set(buildRegistryFromEnv(env).keys()); + + for (const key of Object.keys(env)) { + const match = key.match(SAML_ENV_PREFIX); + if (!match) continue; + const rawName = match[1]; // e.g. "OKTA", "AZUREAD" + const id = rawName.toLowerCase(); // e.g. "okta", "azuread" + + // Collision check: the id must not already be claimed by an OIDC provider. + if (oidcIds.has(id)) { + throw new Error( + `SAML provider "${id}" collides with an existing OIDC provider of the same id. ` + + `Provider ids must be globally unique across all SSO protocols.`, + ); + } + + // Parse required fields. + const entryPoint = requiredSaml(env, id, rawName, 'ENTRY_POINT'); + assertSafeFederationUrl(id, entryPoint, allowInsecure, 'entryPoint'); + + const idpIssuer = requiredSaml(env, id, rawName, 'IDP_ISSUER'); + const idpCertRaw = requiredSaml(env, id, rawName, 'IDP_CERT'); + const callbackUrl = requiredSaml(env, id, rawName, 'CALLBACK_URL'); + + const idpCertPems = parseIdpCerts(id, idpCertRaw); + + // SP issuer defaults to the origin of the callbackUrl. This is predictable + // and stable: given `https://app.example.com/saml/acme/acs`, the default + // SP entityID will be `https://app.example.com`. Operators can override + // with `SAML__SP_ISSUER` if their IdP registration requires a + // different entityID (e.g. a full metadata URL). + let defaultSpIssuer: string; + try { + defaultSpIssuer = new URL(callbackUrl).origin; + } catch { + throw new Error( + `SAML provider "${id}" has a malformed SAML_${rawName}_CALLBACK_URL`, + ); + } + const issuer = env[`SAML_${rawName}_SP_ISSUER`]?.trim() || defaultSpIssuer; + + // Optional logout URL — apply the same SSRF guard as entryPoint. + const logoutUrlRaw = env[`SAML_${rawName}_LOGOUT_URL`]?.trim(); + if (logoutUrlRaw) { + assertSafeFederationUrl(id, logoutUrlRaw, allowInsecure, 'logoutUrl'); + } + + // Signature algorithm — only sha256 and sha512 are accepted. + // sha1 is intentionally excluded (cryptographically broken). + const sigAlgRaw = + env[`SAML_${rawName}_SIGNATURE_ALGORITHM`]?.trim() ?? 'sha256'; + if (sigAlgRaw !== 'sha256' && sigAlgRaw !== 'sha512') { + throw new Error( + `SAML provider "${id}" has an invalid SAML_${rawName}_SIGNATURE_ALGORITHM: "${sigAlgRaw}". ` + + `Only "sha256" and "sha512" are accepted (sha1 is disabled).`, + ); + } + const signatureAlgorithm = sigAlgRaw as 'sha256' | 'sha512'; + + // Allowed domains: CSV, lowercased, stripped of any leading `@`. + const allowedDomainsRaw = env[`SAML_${rawName}_ALLOWED_DOMAINS`]?.trim(); + const allowedDomains: string[] | undefined = allowedDomainsRaw + ? allowedDomainsRaw + .split(',') + .map((d) => d.trim().toLowerCase().replace(/^@/, '')) + .filter(Boolean) + : undefined; + + // Permission map — reuse the shared parser (throws on malformed input). + const permissionMap: ClaimPermissionMapping[] = parsePermissionMap( + id, + env[`SAML_${rawName}_PERMISSION_MAP`], + ); + + // Decryption private key — optional; treated as a secret. + // Variable name logged in errors; key material never included. + const decryptionPvk = + env[`SAML_${rawName}_DECRYPTION_PVK`]?.trim() || undefined; + + samlRegistry.set(id, { + id, + displayName: env[`SAML_${rawName}_DISPLAY_NAME`]?.trim() || titleCase(id), + iconKey: env[`SAML_${rawName}_ICON_KEY`]?.trim() || undefined, + entryPoint, + issuer, + idpIssuer, + idpCertPems, + callbackUrl, + signatureAlgorithm, + wantAssertionsSigned: true, + allowedDomains, + groupsAttribute: + env[`SAML_${rawName}_GROUPS_ATTRIBUTE`]?.trim() || 'groups', + emailAttribute: env[`SAML_${rawName}_EMAIL_ATTRIBUTE`]?.trim() || 'email', + permissionMap, + logoutUrl: logoutUrlRaw || undefined, + decryptionPvk, + }); + } + + return samlRegistry; +}; + +// --------------------------------------------------------------------------- +// Memoised registry (process.env-backed) +// --------------------------------------------------------------------------- + +let cache: Map | null = null; + +const registry = (): Map => { + if (!cache) cache = buildSamlRegistryFromEnv(process.env); + return cache; +}; + +/** Clears the memoised SAML registry — for tests that mutate `process.env`. */ +export const resetSamlRegistryCache = (): void => { + cache = null; +}; + +/** + * Validates the SAML config at boot; throws on any misconfiguration (fail-fast). + * Zero providers configured is valid — SAML is simply disabled. + */ +export const validateSamlConfig = (): void => { + cache = buildSamlRegistryFromEnv(process.env); +}; + +export const getSamlProviderConfig = ( + id: string, +): SamlProviderConfig | undefined => registry().get(id); + +export const getAllSamlProviderConfigs = (): SamlProviderConfig[] => + Array.from(registry().values()); + +export const isSamlEnabled = (): boolean => registry().size > 0; From caf954838a0b2e4f6aecf2d6761cb79d5bb954f4 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Thu, 11 Jun 2026 10:42:18 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(gateway):=20SAML=20SP=20core=20?= =?UTF-8?q?=E2=80=94=20AuthnRequest=20login=20+=20SP=20metadata=20(T-35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - saml-client.ts: hardened @node-saml/node-saml@5.1.0 factory — both Response and Assertion signatures required, validateInResponseTo always (IdP-initiated rejected by design), audience pinned to the SP entityID, idpIssuer pinned, 30s clock skew, 10min max assertion age, CSPRNG AuthnRequest ids injected via generateUniqueId, per-provider bounded request-id cache (10k FIFO + TTL, memory-DoS safe) - saml-transaction.service.ts: signed single-use transaction cookie {provider, requestId, returnTo}; SameSite=None+Secure in production (the ACS is a cross-site POST), typ-separated from the OIDC cookie - saml.controller.ts: SP-initiated login (empty RelayState — returnTo only ever travels in the signed cookie) + public SP metadata endpoint (no private material, application/samlmetadata+xml) - sso.controller.ts login now dispatches by protocol via the federated registry; OIDC flow untouched - Registry: optional SAML__FORCE_AUTHN and _DISABLE_REQUESTED_AUTHN_CONTEXT (legacy IdP compat, default omit) - 26 new tests incl. a real deflate+base64 AuthnRequest round-trip Co-Authored-By: Claude Fable 5 --- .../src/controllers/saml.controller.spec.ts | 191 ++++++++ .../src/controllers/saml.controller.ts | 81 +++ .../src/controllers/sso.controller.spec.ts | 29 +- .../gateway/src/controllers/sso.controller.ts | 20 +- apps/gateway/src/routes/sso.routes.ts | 12 +- apps/gateway/src/sso/saml-client.spec.ts | 137 ++++++ apps/gateway/src/sso/saml-client.ts | 151 ++++++ .../src/sso/saml-provider-registry.spec.ts | 6 + .../gateway/src/sso/saml-provider-registry.ts | 13 + .../src/sso/saml-transaction.service.spec.ts | 108 ++++ .../src/sso/saml-transaction.service.ts | 88 ++++ apps/gateway/src/sso/saml-types.ts | 13 + package-lock.json | 463 ++++++++---------- package.json | 1 + 14 files changed, 1041 insertions(+), 272 deletions(-) create mode 100644 apps/gateway/src/controllers/saml.controller.spec.ts create mode 100644 apps/gateway/src/controllers/saml.controller.ts create mode 100644 apps/gateway/src/sso/saml-client.spec.ts create mode 100644 apps/gateway/src/sso/saml-client.ts create mode 100644 apps/gateway/src/sso/saml-transaction.service.spec.ts create mode 100644 apps/gateway/src/sso/saml-transaction.service.ts diff --git a/apps/gateway/src/controllers/saml.controller.spec.ts b/apps/gateway/src/controllers/saml.controller.spec.ts new file mode 100644 index 0000000..8d5c905 --- /dev/null +++ b/apps/gateway/src/controllers/saml.controller.spec.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@gateway/sso/federated-registry', () => ({ + getFederatedProvider: vi.fn(), +})); +vi.mock('@gateway/sso/saml-client', () => ({ + generateSamlRequestId: vi.fn(() => '_feedfacefeedfacefeedfacefeedface'), + getSamlClient: vi.fn(), +})); +vi.mock('@gateway/sso/saml-transaction.service', () => ({ + setSamlTransactionCookie: vi.fn(), +})); + +import { getFederatedProvider } from '@gateway/sso/federated-registry'; +import { getSamlClient } from '@gateway/sso/saml-client'; +import { setSamlTransactionCookie } from '@gateway/sso/saml-transaction.service'; +import samlController from './saml.controller'; + +const samlConfig = { + id: 'acme', + displayName: 'Acme', + entryPoint: 'https://idp.acme.example/sso', + issuer: 'https://app.example.com', + decryptionPvk: 'SUPER-SECRET-KEY-MATERIAL', +}; + +const mkRes = () => { + const res = {} as Record>; + res.status = vi.fn(() => res); + res.json = vi.fn(() => res); + res.redirect = vi.fn(); + res.cookie = vi.fn(); + res.type = vi.fn(() => res); + res.send = vi.fn(() => res); + return res as never; +}; + +const samlInstance = { + getAuthorizeUrlAsync: vi.fn( + async () => 'https://idp.acme.example/sso?SAMLRequest=abc', + ), + generateServiceProviderMetadata: vi.fn( + () => '', + ), +}; + +describe('SamlController', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSamlClient).mockReturnValue(samlInstance as never); + vi.mocked(getFederatedProvider).mockReturnValue({ + protocol: 'saml', + config: samlConfig, + } as never); + }); + + describe('login', () => { + it('404s for an unknown provider without echoing the param', async () => { + vi.mocked(getFederatedProvider).mockReturnValue(undefined); + const res = mkRes() as never as { + status: ReturnType; + json: ReturnType; + }; + await samlController.login( + { + params: { provider: '' }, + query: {}, + } as never, + res as never, + ); + expect(res.status).toHaveBeenCalledWith(404); + expect(JSON.stringify(res.json.mock.calls)).not.toContain('Evil Corp', + protocol: 'saml', + }; + getProviders.mockReturnValue(of([xssProvider])); + const { cmp, fixture } = setup(); + cmp.ngOnInit(); + fixture.detectChanges(); + // No