Skip to content

feat: SSO/OIDC en el gateway (Okta · Azure AD · Auth0) — epic S-2#20

Merged
dherrero merged 14 commits into
mainfrom
feat/sso-oidc-gateway
Jun 11, 2026
Merged

feat: SSO/OIDC en el gateway (Okta · Azure AD · Auth0) — epic S-2#20
dherrero merged 14 commits into
mainfrom
feat/sso-oidc-gateway

Conversation

@dherrero

Copy link
Copy Markdown
Owner

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)

  • DB (T-23): tabla federated_identity (clave natural (provider, subject) vía índice único parcial; FK ON DELETE CASCADE); user.password nullable + user.auth_source.
  • Contratos (T-24): DTOs SSO en @dto + InternalScope.FEDERATED_IDENTITY.
  • API (T-25): endpoint interno POST /internal/federated/resolve (resolve/provisioning JIT) + guard NULL-password en validateCredentials.
  • Gateway (T-26..T-29): registro multi-proveedor + discovery (openid-client v5); Authorization Code + PKCE + state + nonce; emisión de sesión local + mapeo claims→permisos + redirect allowlist; logout federado RP-initiated.
  • Frontend (T-30): botones de login por proveedor + ruta de aterrizaje del callback + i18n (en/es/ca); token solo en memoria.
  • Docs (T-31): 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:gateway y build:api ✅.

Notas

  • 🔧 Incluye un fix(gateway) aparte: elimina la opción muerta keyGeneratorIpFallback (removida en express-rate-limit 7.5) que rompía el typecheck del build de gateway — preexistente desde S-1, ajeno al SSO.
  • ⚠️ e2e Playwright (happy-path + ataque 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 copiar environment.prod.example.tsenvironment.prod.ts (paso de setup preexistente, como .env).
  • Decisión: openid-client v5 (CJS) en vez de v6 (ESM-only) por compatibilidad con el build CJS del gateway.
  • Setup: configurar SSO_<NAME>_* y SSO_STATE_SECRET (ver .env.example), aplicar migraciones db/30, db/31.

🤖 Generated with Claude Code

dherrero and others added 11 commits June 10, 2026 16:09
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]>
@dherrero

Copy link
Copy Markdown
Owner Author

Actualización T-32 — verificación dinámica e2e completada.

Añadido e2e contra un IdP OIDC mock real (oauth2-mock-server, commit e5ad707): handshake completo (discovery → PKCE S256 → canje de code → validación del ID token vía JWKS/iss/aud/exp/nonce → emisión de sesión local) + casos de ataque (state manipulado y sin cookie de transacción → rechazo, sin sesión). Ejecutar con npm run test:e2e (3/3 ✅; aislado de la suite unitaria).

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 npm test 154 ✅.

dherrero and others added 3 commits June 10, 2026 22:06
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]>
@dherrero dherrero merged commit c9ede95 into main Jun 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant