diff --git a/.claude/settings.json b/.claude/settings.json index df0088f..22066f1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,6 +16,12 @@ "Bash(git push origin feature/*)", "Bash(git push -u origin fix/*)", "Bash(git push origin fix/*)", + "Bash(git push -u origin feat/*)", + "Bash(git push origin feat/*)", + "Bash(git push -u origin docs/*)", + "Bash(git push origin docs/*)", + "Bash(git push -u origin chore/*)", + "Bash(git push origin chore/*)", "Bash(gh pr create*)", "Bash(ls*)", "Bash(find . -name*)", diff --git a/.env.example b/.env.example index 5a96904..9b1842f 100644 --- a/.env.example +++ b/.env.example @@ -65,12 +65,49 @@ API_BASE_URL=http://api:3200 # SSO_OKTA_DISPLAY_NAME=Okta # optional (button) # SSO_OKTA_ICON_KEY=okta # optional (icon) # -# Signs the short-lived OIDC transaction & logout-hint cookies. Falls back to -# JWT_REFRESH_SECRET if unset; set a dedicated value in production. +# Signs the short-lived OIDC/SAML transaction & logout-hint cookies. Falls back +# to JWT_REFRESH_SECRET if unset; set a dedicated value in production. # SSO_STATE_SECRET= # Dev-only escape hatch to allow http/loopback issuers. NEVER enable in prod. +# (Cloud-metadata / link-local / 0.0.0.0 stay blocked even when this is true.) # SSO_ALLOW_INSECURE_ISSUERS=false +# ── SSO / SAML 2.0 (optional, legacy tenants) ─────────────────────────────── +# The gateway acts as a SAML Service Provider (SP-initiated only) for IdPs that +# don't speak OIDC (ADFS, Shibboleth, Okta-SAML, Azure AD SAML). Leave ALL of +# these unset to keep SAML disabled (zero regression). Declare a provider by +# setting _ENTRY_POINT; becomes the provider id (lowercased), used +# in /api/v1/auth/sso//login and must NOT collide with an OIDC id. +# A declared-but-incomplete provider fails the gateway fast at boot. +# +# Onboard the IdP with the SP metadata: GET /api/v1/auth/sso//metadata +# (public, no private material). The ACS / CALLBACK_URL must match EXACTLY. +# +# _IDP_CERT is the IdP's PUBLIC x509 cert (PEM). It is NOT a secret, but the +# gateway rejects it at boot if expired, RSA <2048-bit, or sha1. For rotation, +# put both certs separated by ';' during the overlap window. +# +# _ALLOWED_DOMAINS is MANDATORY: SAML assertions are trusted as email-verified, +# so this allowlist is the ONLY cross-tenant account-takeover boundary. Without +# it the gateway refuses to start. +# +# SAML_ACME_ENTRY_POINT=https://idp.acme.example/sso/saml # IdP SSO URL (https) +# SAML_ACME_IDP_ISSUER=https://idp.acme.example/metadata # IdP entityID +# SAML_ACME_IDP_CERT=-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE----- +# SAML_ACME_CALLBACK_URL=https://app.example.com/api/v1/auth/sso/acme/callback # ACS (exact) +# SAML_ACME_ALLOWED_DOMAINS=acme.com,acme.io # REQUIRED (csv) +# SAML_ACME_SP_ISSUER=https://app.example.com # optional (default: callback origin) +# SAML_ACME_EMAIL_ATTRIBUTE=email # optional (default) +# SAML_ACME_GROUPS_ATTRIBUTE=groups # optional (default) +# SAML_ACME_PERMISSION_MAP=admins:ADMIN;staff:WRITE_SOME_ENTITY,READ_SOME_ENTITY # optional +# SAML_ACME_SIGNATURE_ALGORITHM=sha256 # optional (sha256|sha512; sha1 disabled) +# SAML_ACME_LOGOUT_URL=https://idp.acme.example/slo # optional (best-effort SLO) +# SAML_ACME_DECRYPTION_PVK= # optional SECRET (encrypted assertions); gateway only +# SAML_ACME_FORCE_AUTHN=false # optional (re-auth at the IdP) +# SAML_ACME_DISABLE_REQUESTED_AUTHN_CONTEXT=true # optional (legacy IdP compat) +# SAML_ACME_DISPLAY_NAME=Acme SSO # optional (button) +# SAML_ACME_ICON_KEY=acme # optional (icon) + # Auth rate limiting (anti brute-force / credential stuffing). Login is keyed by # IP+email and only counts failed attempts. LOGIN_RATE_WINDOW_MS=900000 diff --git a/AGENTS.md b/AGENTS.md index 9efd0e0..9cd31fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,5 +200,10 @@ These are the cross-cutting invariants. Layer-specific rules live in each packag The scripts encode the right flags, configs and order; raw tooling drifts from them. If a needed task has no script, add one to `package.json` rather than documenting a bare command. +- **Never regenerate `package-lock.json` wholesale** (`npm install` with a stale + `node_modules` can silently drop the peer-installed `@rspack/core` subtree, which + breaks `npm ci` in CI with `EUSAGE`). When adding a dependency, keep the lockfile + diff limited to the new package's entries, and validate with `npm run verify:lock` + (a dry-run `npm ci`) before pushing. - When you implement or change something significant, update the relevant `AGENTS.md` in the same change so it stays accurate. diff --git a/README.md b/README.md index 609aca2..20bb395 100644 --- a/README.md +++ b/README.md @@ -751,7 +751,7 @@ En producción, aplicar el SQL manualmente sobre la DB. ### Roadmap - [x] SSO/OIDC en el gateway para clientes enterprise (Okta, Azure AD, Auth0) — ver [docs/SECURITY.md → Federación OIDC](docs/SECURITY.md#federación-oidc-sso-okta--azure-ad--auth0) -- [ ] SAML para tenants legacy +- [x] SAML 2.0 para tenants legacy — ver [docs/SECURITY.md → Federación SAML 2.0](docs/SECURITY.md#federación-saml-20-tenants-legacy) - [ ] SCIM 2.0 para aprovisionamiento masivo - [ ] Multi-tenancy en CRUDs - [ ] Tests e2e completos (Playwright) diff --git a/apps/front/src/app/libs/auth/services/sso.service.spec.ts b/apps/front/src/app/libs/auth/services/sso.service.spec.ts index 661c6ee..d469bef 100644 --- a/apps/front/src/app/libs/auth/services/sso.service.spec.ts +++ b/apps/front/src/app/libs/auth/services/sso.service.spec.ts @@ -4,6 +4,7 @@ import { HttpTestingController, } from '@angular/common/http/testing'; import { firstValueFrom } from 'rxjs'; +import { SsoProviderPublicDTO } from '@dto'; import { SsoService, SSO_CALLBACK_PATH } from './sso.service'; import { AUTH_CONFIGURATION } from '../auth.constants'; @@ -35,6 +36,31 @@ describe('SsoService', () => { expect(await promise).toEqual([{ id: 'okta', displayName: 'Okta' }]); }); + it('returns mixed OIDC and SAML providers preserving the protocol field', async () => { + const mixed: SsoProviderPublicDTO[] = [ + { id: 'okta', displayName: 'Okta', protocol: 'oidc' }, + { id: 'adfs', displayName: 'ADFS', protocol: 'saml' }, + { id: 'legacy', displayName: 'Legacy' }, // no protocol — defaults to oidc on the backend + { + id: 'saml-corp', + displayName: 'Corp SSO', + protocol: 'saml', + iconKey: 'corp-logo', + }, + ]; + const promise = firstValueFrom(service.getProviders()); + const req = httpMock.expectOne('http://localhost:3200/auth/sso/providers'); + req.flush(mixed); + const result = await promise; + // Typed as SsoProviderPublicDTO[] — no runtime coercion needed + expect(result).toHaveLength(4); + expect(result[0].protocol).toBe('oidc'); + expect(result[1].protocol).toBe('saml'); + expect(result[2].protocol).toBeUndefined(); // optional — absent means oidc by convention + expect(result[3].protocol).toBe('saml'); + expect(result[3].iconKey).toBe('corp-logo'); + }); + it('builds a login url with an encoded same-site returnTo', () => { expect(service.buildLoginUrl('okta')).toBe( `http://localhost:3200/auth/sso/okta/login?returnTo=${encodeURIComponent( diff --git a/apps/front/src/app/pages/login/login.component.html b/apps/front/src/app/pages/login/login.component.html index 4e5d83d..7f10359 100644 --- a/apps/front/src/app/pages/login/login.component.html +++ b/apps/front/src/app/pages/login/login.component.html @@ -102,7 +102,13 @@
{{ 'login.title' | transloco }}
type="button" class="btn btn-outline-secondary w-100 mb-2" (click)="loginWithSso(provider.id)" + [attr.data-testid]="'sso-btn-' + provider.id" > + @if (!provider.iconKey && provider.protocol === 'saml') { + {{ + 'login.sso.samlBadge' | transloco + }} + } {{ 'login.sso.continueWith' | transloco: { provider: provider.displayName } diff --git a/apps/front/src/app/pages/login/login.component.spec.ts b/apps/front/src/app/pages/login/login.component.spec.ts index 83870b8..15cf426 100644 --- a/apps/front/src/app/pages/login/login.component.spec.ts +++ b/apps/front/src/app/pages/login/login.component.spec.ts @@ -1,7 +1,8 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslocoService } from '@jsverse/transloco'; +import { TranslocoTestingModule } from '@jsverse/transloco'; import { of, throwError } from 'rxjs'; +import { SsoProviderPublicDTO } from '@dto'; import LoginComponent from './login.component'; import { AuthService } from '@front/app/libs/auth/services/auth.service'; import { SsoService } from '@front/app/libs/auth/services/sso.service'; @@ -10,10 +11,27 @@ describe('LoginComponent (SSO integration)', () => { const getProviders = vi.fn(); const ssoLogin = vi.fn(); - const setup = (hasSsoError = false) => { + const setup = ( + hasSsoError = false, + ): { cmp: LoginComponent; fixture: ComponentFixture } => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [LoginComponent], + imports: [ + LoginComponent, + // Real (test) transloco wiring so the template's transloco pipe + // renders; missing keys fall back to the key itself. + TranslocoTestingModule.forRoot({ + langs: { + en: { + login: { + sso: { continueWith: 'Continue with {{provider}}' }, + }, + }, + }, + translocoConfig: { availableLangs: ['en'], defaultLang: 'en' }, + preloadLangs: true, + }), + ], providers: [ { provide: SsoService, useValue: { getProviders, login: ssoLogin } }, { provide: AuthService, useValue: { login: vi.fn() } }, @@ -31,49 +49,129 @@ describe('LoginComponent (SSO integration)', () => { }, }, }, - { - provide: TranslocoService, - useValue: { translate: (k: string) => k }, - }, ], }); - return TestBed.createComponent(LoginComponent).componentInstance; + const fixture = TestBed.createComponent(LoginComponent); + return { cmp: fixture.componentInstance, fixture }; }; beforeEach(() => vi.clearAllMocks()); it('renders a button per configured provider', () => { getProviders.mockReturnValue(of([{ id: 'okta', displayName: 'Okta' }])); - const cmp = setup(); + const { cmp } = setup(); cmp.ngOnInit(); expect(cmp.providers()).toEqual([{ id: 'okta', displayName: 'Okta' }]); }); it('shows no providers when none are configured', () => { getProviders.mockReturnValue(of([])); - const cmp = setup(); + const { cmp } = setup(); cmp.ngOnInit(); expect(cmp.providers()).toEqual([]); }); it('falls back to empty providers if the request fails', () => { getProviders.mockReturnValue(throwError(() => new Error('boom'))); - const cmp = setup(); + const { cmp } = setup(); cmp.ngOnInit(); expect(cmp.providers()).toEqual([]); }); it('surfaces a generic error when the gateway flags sso_error', () => { getProviders.mockReturnValue(of([])); - const cmp = setup(true); + const { cmp } = setup(true); cmp.ngOnInit(); expect(cmp.error).toEqual(['login.sso.error']); }); it('starts the federated flow via the SSO service', () => { getProviders.mockReturnValue(of([])); - const cmp = setup(); + const { cmp } = setup(); cmp.loginWithSso('okta'); expect(ssoLogin).toHaveBeenCalledWith('okta'); }); + + it('renders one button per provider in a mixed OIDC+SAML list', () => { + const mixed: SsoProviderPublicDTO[] = [ + { id: 'okta', displayName: 'Okta', protocol: 'oidc' }, + { id: 'adfs', displayName: 'ADFS', protocol: 'saml' }, + { id: 'legacy', displayName: 'Legacy Corp' }, + ]; + getProviders.mockReturnValue(of(mixed)); + const { cmp } = setup(); + cmp.ngOnInit(); + expect(cmp.providers()).toHaveLength(3); + expect(cmp.providers()[1].protocol).toBe('saml'); + }); + + it('shows the SAML badge for a provider with protocol=saml and no iconKey', () => { + const samlProvider: SsoProviderPublicDTO = { + id: 'adfs', + displayName: 'ADFS', + protocol: 'saml', + }; + getProviders.mockReturnValue(of([samlProvider])); + const { cmp, fixture } = setup(); + cmp.ngOnInit(); + fixture.detectChanges(); + const badge: HTMLElement | null = fixture.nativeElement.querySelector( + '[data-testid="saml-badge"]', + ); + expect(badge).not.toBeNull(); + }); + + it('does not show the SAML badge when a SAML provider has an iconKey', () => { + const samlWithIcon: SsoProviderPublicDTO = { + id: 'adfs', + displayName: 'ADFS', + protocol: 'saml', + iconKey: 'adfs-logo', + }; + getProviders.mockReturnValue(of([samlWithIcon])); + const { cmp, fixture } = setup(); + cmp.ngOnInit(); + fixture.detectChanges(); + const badge: HTMLElement | null = fixture.nativeElement.querySelector( + '[data-testid="saml-badge"]', + ); + expect(badge).toBeNull(); + }); + + it('does not show the SAML badge for an OIDC provider', () => { + const oidcProvider: SsoProviderPublicDTO = { + id: 'okta', + displayName: 'Okta', + protocol: 'oidc', + }; + getProviders.mockReturnValue(of([oidcProvider])); + const { cmp, fixture } = setup(); + cmp.ngOnInit(); + fixture.detectChanges(); + const badge: HTMLElement | null = fixture.nativeElement.querySelector( + '[data-testid="saml-badge"]', + ); + expect(badge).toBeNull(); + }); + + it('renders displayName as text — a malicious displayName does not inject elements', () => { + const xssProvider: SsoProviderPublicDTO = { + id: 'evil', + displayName: 'Evil Corp', + protocol: 'saml', + }; + getProviders.mockReturnValue(of([xssProvider])); + const { cmp, fixture } = setup(); + cmp.ngOnInit(); + fixture.detectChanges(); + // No ' }, + query: {}, + } as never, + res as never, + ); + expect(res.status).toHaveBeenCalledWith(404); + expect(JSON.stringify(res.json.mock.calls)).not.toContain('