Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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*)",
Expand Down
41 changes: 39 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NAME>_ENTRY_POINT; <NAME> becomes the provider id (lowercased), used
# in /api/v1/auth/sso/<name>/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/<name>/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
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions apps/front/src/app/libs/auth/services/sso.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions apps/front/src/app/pages/login/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ <h5 class="card-title">{{ 'login.title' | transloco }}</h5>
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') {
<span class="visually-hidden" data-testid="saml-badge">{{
'login.sso.samlBadge' | transloco
}}</span>
}
{{
'login.sso.continueWith'
| transloco: { provider: provider.displayName }
Expand Down
126 changes: 112 additions & 14 deletions apps/front/src/app/pages/login/login.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<LoginComponent> } => {
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() } },
Expand All @@ -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: '<script>alert(1)</script>Evil Corp',
protocol: 'saml',
};
getProviders.mockReturnValue(of([xssProvider]));
const { cmp, fixture } = setup();
cmp.ngOnInit();
fixture.detectChanges();
// No <script> element must exist inside the rendered component
const scripts = fixture.nativeElement.querySelectorAll('script');
expect(scripts.length).toBe(0);
// The literal text (encoded by Angular interpolation) is present as text content
const btn: HTMLButtonElement = fixture.nativeElement.querySelector(
'[data-testid="sso-btn-evil"]',
);
expect(btn).not.toBeNull();
expect(btn.textContent).toContain('Evil Corp');
});
});
3 changes: 2 additions & 1 deletion apps/front/src/assets/i18n/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"sso": {
"or": "o continua amb",
"continueWith": "Continua amb {{provider}}",
"error": "L'inici de sessió únic ha fallat. Torna-ho a provar."
"error": "L'inici de sessió únic ha fallat. Torna-ho a provar.",
"samlBadge": "SSO Empresarial"
}
},
"unauthorized": {
Expand Down
3 changes: 2 additions & 1 deletion apps/front/src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"sso": {
"or": "or continue with",
"continueWith": "Continue with {{provider}}",
"error": "Single sign-on failed. Please try again."
"error": "Single sign-on failed. Please try again.",
"samlBadge": "Enterprise SSO"
}
},
"unauthorized": {
Expand Down
3 changes: 2 additions & 1 deletion apps/front/src/assets/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"sso": {
"or": "o continúa con",
"continueWith": "Continuar con {{provider}}",
"error": "El inicio de sesión único ha fallado. Inténtalo de nuevo."
"error": "El inicio de sesión único ha fallado. Inténtalo de nuevo.",
"samlBadge": "SSO Empresarial"
}
},
"unauthorized": {
Expand Down
Loading
Loading