feat: SSO/OIDC en el gateway (Okta · Azure AD · Auth0) — epic S-2#20
Merged
Conversation
Adds public.federated_identity (provider, subject natural key via partial unique index, user_id FK ON DELETE CASCADE, soft-delete + audit cols) and relaxes user.password NOT NULL so federated-only users carry NULL. Adds auth_source column for auditability. Migrations idempotent; verified apply + idempotency on ephemeral PG15. Part of S-2 (SSO/OIDC gateway). Co-Authored-By: Claude Opus 4.8 <[email protected]>
…T-24)
Adds FederatedIdentityDTO, SsoProviderPublicDTO (secret-free public metadata),
ResolveFederatedUser{Request,Response}DTO and ClaimPermissionMapping in @dto,
plus InternalScope.FEDERATED_IDENTITY in @internal-auth to guard the federated
resolve/provision endpoint. lint:libs + internal-auth tests green.
Part of S-2 (SSO/OIDC gateway).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
… (T-25) Adds POST /internal/federated/resolve (scope federated.identity) that resolves or JIT-provisions a local user from a gateway-validated federated identity: - existing identity → authoritative stored user (suggested perms ignored) - unverified email → rejected (account-takeover defense) - verified email matching a local user → linked, permissions unchanged - otherwise → provision user with password NULL, auth_source 'federated', ADMIN stripped, least-privilege default - unique-violation race → re-query by natural key (idempotent) Enforces the migration db/31 contract: validateCredentials now rejects login when password is NULL or auth_source != 'local' (closes NULL-password bypass). Email normalised (trim+lowercase) to match local-user storage. Vitest: service (link/provision/unverified/race) + auth NULL-password cases. Security gate: /security-review → no findings >=8. Part of S-2 (SSO/OIDC gateway). Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds the SSO config layer (no routes yet): - provider-registry.ts: parses 0..N IdPs from SSO_<NAME>_* env (issuer, client_id/secret, redirect_uri, scopes, groups_claim, permission_map, display_name/icon). Fail-fast validation at boot; SSRF hardening on the issuer (https-only + blocks loopback/link-local/metadata/RFC1918, with a dev-only SSO_ALLOW_INSECURE_ISSUERS escape hatch). listPublicProviders() exposes only id/displayName/iconKey — never secrets. - discovery.ts: cached openid-client v5 discovery + client per provider with a bounded HTTP timeout; JWKS/ID-token validation delegated to openid-client. Zero providers configured ⇒ gateway behaves exactly as today. Adds openid-client@^5.7.1 (CJS, compatible with the gateway's esbuild/CJS build; v6 is ESM-only and incompatible with moduleResolution:node). Vitest: env parsing, fail-fast, permission-map, SSRF rejections, no-secret leak, discovery caching. Security gate: /security-review → no findings >=8. Part of S-2 (SSO/OIDC gateway). Co-Authored-By: Claude Opus 4.8 <[email protected]>
… (T-27, T-28)
T-27 and T-28 are delivered together (a login handshake without session
emission is dead code). Adds GET /api/v1/auth/sso/:provider/{login,callback}
and /providers:
- login: mints state+nonce+PKCE(S256), stores them in a signed HttpOnly
SameSite=Lax 5-min transaction cookie, redirects to the IdP.
- callback: validates state via the transaction cookie, binds to the provider
that started the flow (mix-up defense), exchanges the code with PKCE and lets
openid-client validate the ID token (sig/JWKS, iss, aud, exp, nonce); maps the
groups claim to suggested permissions; resolves/provisions via the api
(FEDERATED_IDENTITY scope) and issues the SAME local session as a password
login (respondWithTokens). IdP errors are never reflected (generic redirect).
- returnTo / post-login redirect constrained to same-site paths (open-redirect
defense); ApiClient.resolveFederatedUser added; fail-fast validateSsoConfig at
boot. Zero providers ⇒ no behavior change.
Vitest (54 gateway tests): handshake, mix-up, state/nonce failure, no-leak,
success path, transaction round-trip/tamper, open-redirect guard, perm mapping.
Security gate: /security-review → no findings >=8.
Part of S-2 (SSO/OIDC gateway).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds GET /api/v1/auth/sso/logout: ALWAYS revokes the local refresh family and clears cookies first (a down IdP can never keep the local session alive), then — when the session is federated and the IdP advertises end_session_endpoint — redirects the browser to the provider's RP-initiated logout with id_token_hint + optional post_logout_redirect_uri. Best-effort: any IdP failure falls back to a same-site redirect (safeReturnTo). The id_token_hint is carried in a signed HttpOnly SameSite=Lax cookie (sso-logout.service) set at callback; it is an already-consumed assertion, never exposed to JS, and only sent to the IdP. POST /logout also clears it. Adds optional SSO_<NAME>_POST_LOGOUT_REDIRECT_URI to the registry. Back-channel logout is intentionally out of scope (documented for T-31). Vitest (61 gateway tests): always-revoke, federated end_session redirect, IdP failure fallback, hint round-trip/tamper. Security gate → no findings >=8. Part of S-2 (SSO/OIDC gateway). Co-Authored-By: Claude Opus 4.8 <[email protected]>
Configuration-driven federated login alongside email/password: - SsoService: fetches GET /api/v1/auth/sso/providers and starts the flow with a full-page navigation to the gateway login URL (the IdP redirect is cross-site, so not an XHR). No tokens/secrets handled client-side. - Login page: renders one button per provider via @for (none if unconfigured), surfaces a GENERIC error when the gateway flags ?sso_error=1 (never raw IdP data). Provider displayName rendered via interpolation (auto-escaped). - auth/callback route: bootstraps the in-memory access token via AuthService.initialize() (minted by the gateway from the refresh cookie), then navigates home; failure returns to /login?sso_error=1. - i18n (en/es/ca) login.sso.* keys. Token stays in-memory only (no localStorage); zero regression to email/password login when no providers are configured. Vitest (81 front tests): provider rendering, empty state, request failure, sso_error flag, navigation. Part of S-2 (SSO/OIDC gateway). Co-Authored-By: Claude Opus 4.8 <[email protected]>
…p (T-31) - docs/SECURITY.md: new 'Federación OIDC' section (RP flow diagram, security decisions, provider-onboarding guide with exact callback URLs). - .env.example: commented SSO_<NAME>_* block + SSO_STATE_SECRET / SSO_ALLOW_INSECURE_ISSUERS; notes that secrets live only on the gateway. - apps/gateway/AGENTS.md: OIDC RP responsibility, /auth/sso/* routes, new env vars, openid-client v5 note, and the api-trusts-only-internal-JWT rule. - README.md + docs/README_eng.md: roadmap SSO item marked done, linked to SECURITY.md. Back-channel logout documented as out of scope. Part of S-2 (SSO/OIDC gateway). Co-Authored-By: Claude Opus 4.8 <[email protected]>
express-rate-limit 7.5 removed the keyGeneratorIpFallback validation; the option lingered from the dependency bump in S-1 (c062857) as a no-op that broke `npm run build:gateway` typecheck (not in EnabledValidations). The custom keyGenerator already embeds req.ip, so no IP-fallback check is needed — removing the dead option restores the build with zero runtime change. Pre-existing issue surfaced while building the SSO epic; unrelated to SSO. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds a global convention: always use the npm scripts (build/test/lint/dev:*) instead of invoking nx/vitest/tsc/prettier directly; add a script if one is missing rather than documenting a bare command. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds an end-to-end test that boots a real mock IdP (oauth2-mock-server) and drives the full handshake through the gateway: /login mints state+nonce+PKCE S256, the IdP issues a code, /callback exchanges it and openid-client validates the ID token (signature via JWKS, iss, aud, exp, nonce) → the standard local session is issued (access header + refresh cookie). Plus two attack cases: tampered state and missing transaction cookie → rejected to /login?sso_error=1 with no session and no api call. Runs via `npm run test:e2e` (dedicated vitest.e2e.config.ts); excluded from the unit suites (real port binding) in root + gateway configs. Adds oauth2-mock-server dev dependency. Closes the dynamic-verification item of S-2's final security gate (T-32). Co-Authored-By: Claude Opus 4.8 <[email protected]>
Owner
Author
|
✅ Actualización T-32 — verificación dinámica e2e completada. Añadido e2e contra un IdP OIDC mock real ( El epic S-2 queda completo (10/10); veredicto del gate final: PASS (estático sin hallazgos ≥8 + dinámico verde + modelo de amenazas en docs/SECURITY.md). Build de gateway/api ✅ y |
The SSO dependency installs (openid-client, oauth2-mock-server) were run with npm 11 (local node 24), which re-resolved the tree and pruned entries the CI's npm 10.9.0 (node 22.12.0, pinned in .github/workflows/ci.yml) expects, breaking `npm ci` (Missing @rspack/core / @module-federation/* from lock file). Regenerated the lock with `[email protected]` so it is consistent with CI. Verified with the exact CI command `npx [email protected] ci` (passes) plus full unit suite (154), e2e (3), and gateway/api/front builds. Co-Authored-By: Claude Opus 4.8 <[email protected]>
The lock must be generated on the same platform+node as CI (linux, node 22.12.0). Generating it on macOS/node 24 pruned the @rspack/@module-federation subtree, yielding a lock that passed `npm ci` on darwin but failed on the CI linux runner (Missing @rspack/[email protected] from lock file). Regenerated inside a node:22.12.0 linux container (restore main lock → npm install openid-client + oauth2-mock-server), which retains @rspack/[email protected] and adds the SSO deps. Validated with `npm ci` inside the same linux container (in sync, 1781 packages). Supersedes the earlier darwin-generated lock fix. Co-Authored-By: Claude Opus 4.8 <[email protected]>
CI pinned node 22.12.0 (npm 10.9.0) while .nvmrc and package.json engines require node 24.14.1 / npm 11. That mismatch is what let a locally-generated package-lock (node 24) diverge from what CI's npm ci (node 22) accepted. Aligning CI + release to 24.14.1 removes the divergence. Verified the committed lock passes `npm ci` in a node:24.14.1 linux container (in sync, 1781 packages). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resumen
Implementa SSO/OIDC federado con el gateway como Relying Party (RP). El gateway hace todo el handshake OIDC y termina emitiendo la misma sesión local de siempre (access JWT + cookie refresh con familia/rotación). El API nunca habla con el IdP y sigue confiando solo en el JWT interno EdDSA. Sin proveedores configurados, el sistema se comporta exactamente igual que hoy (cero regresión).
Epic S-2 del Kanban, 10 tickets (T-23…T-32), un commit por ticket, cada uno con su gate de seguridad.
Qué incluye (por capa)
federated_identity(clave natural(provider, subject)vía índice único parcial; FKON DELETE CASCADE);user.passwordnullable +user.auth_source.@dto+InternalScope.FEDERATED_IDENTITY.POST /internal/federated/resolve(resolve/provisioning JIT) + guard NULL-password envalidateCredentials.openid-clientv5); Authorization Code + PKCE + state + nonce; emisión de sesión local + mapeo claims→permisos + redirect allowlist; logout federado RP-initiated.docs/SECURITY.md(Federación OIDC + modelo de amenazas),.env.example,apps/gateway/AGENTS.md, roadmap.Seguridad
Cada ticket pasó
/security-review+ revisión adversarial; una auditoría consolidada de superficie completa (T-32) verificó los vectores cross-cutting → sin hallazgos ≥8 de confianza. Cubierto: CSRF (state), replay (nonce), PKCE S256, validación de ID token (firma/JWKS/iss/aud/exp), mix-up multi-IdP, account-takeover (emailVerified + clave natural), escalada de privilegios (ADMIN stripped, perms autoritativos en API), open redirect, SSRF en discovery, fuga de secretos, sesión huérfana tras logout, bypass NULL-password.Verificación
npm test: 154 passed (21 ficheros). Cobertura gateway ~90% stmts.npm run lint: 0 errores.npm run build:gatewayybuild:api✅.Notas
fix(gateway)aparte: elimina la opción muertakeyGeneratorIpFallback(removida enexpress-rate-limit7.5) que rompía el typecheck del build de gateway — preexistente desde S-1, ajeno al SSO.state): pendiente — requiere un IdP OIDC mock + stack levantado. Verificación estática/unitaria y modelo de amenazas completos.npm run build:front(producción) requiere copiarenvironment.prod.example.ts→environment.prod.ts(paso de setup preexistente, como.env).openid-clientv5 (CJS) en vez de v6 (ESM-only) por compatibilidad con el build CJS del gateway.SSO_<NAME>_*ySSO_STATE_SECRET(ver.env.example), aplicar migracionesdb/30,db/31.🤖 Generated with Claude Code