From 547ce36c337576f9120624e16d3b30118e7993e2 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 16:09:33 +0200 Subject: [PATCH 01/14] feat(db): federated_identity table + nullable user password (T-23) 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 --- db/30.federated_identity.sql | 48 ++++++++++++++++++++++++++++++++ db/31.user_password_nullable.sql | 27 ++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 db/30.federated_identity.sql create mode 100644 db/31.user_password_nullable.sql diff --git a/db/30.federated_identity.sql b/db/30.federated_identity.sql new file mode 100644 index 0000000..627c769 --- /dev/null +++ b/db/30.federated_identity.sql @@ -0,0 +1,48 @@ +-- Federated identity table: links a local public.user account to an external +-- OIDC/SSO identity provider (IdP). One user may have at most one active link +-- per provider (enforced by uq_federated_identity_user_provider). A given +-- (provider, subject) pair is globally unique across active rows +-- (uq_federated_identity_provider_subject), preventing two local users from +-- claiming the same external identity. +-- +-- Soft-delete is the only supported removal path. ON DELETE CASCADE from +-- public.user is intentional: removing a user removes their federated links +-- with no further cascade chain (federated_identity has no children). +-- +-- email_at_link is PII captured at link time for audit purposes. It is +-- nullable (the IdP may not return an email claim). Soft-delete satisfies +-- retention requirements — hard deletion of PII must go through the data +-- erasure runbook, not a direct DELETE. + +CREATE TABLE IF NOT EXISTS public.federated_identity ( + id bigserial PRIMARY KEY, + user_id bigint NOT NULL REFERENCES public.user(id) ON DELETE CASCADE, + provider varchar(50) NOT NULL, + subject varchar(255) NOT NULL, + email_at_link varchar(150), + deleted boolean DEFAULT false, + createdAt timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updatedAt timestamp without time zone, + deletedAt timestamp without time zone +); + +ALTER TABLE public.federated_identity OWNER TO postgres; + +-- Natural key of a federated identity: (provider, subject) must be unique +-- across active (non-deleted) rows. The partial index allows re-linking after +-- a soft-delete without violating uniqueness — the old soft-deleted row is +-- excluded from the constraint. +CREATE UNIQUE INDEX IF NOT EXISTS uq_federated_identity_provider_subject + ON public.federated_identity (provider, subject) + WHERE deleted = false; + +-- Prevent double-linking the same provider to the same local user while the +-- link is active. A user can re-link the same provider only after the +-- previous link has been soft-deleted. +CREATE UNIQUE INDEX IF NOT EXISTS uq_federated_identity_user_provider + ON public.federated_identity (user_id, provider) + WHERE deleted = false; + +-- FK support index: fast lookup of all federated identities for a given user. +CREATE INDEX IF NOT EXISTS idx_federated_identity_user_id + ON public.federated_identity (user_id); diff --git a/db/31.user_password_nullable.sql b/db/31.user_password_nullable.sql new file mode 100644 index 0000000..9c59094 --- /dev/null +++ b/db/31.user_password_nullable.sql @@ -0,0 +1,27 @@ +-- Migration: allow federated-only users (password = NULL) in public.user +-- +-- SECURITY NOTE — NULL password MUST NOT authenticate via local credentials: +-- Federated-only users have password = NULL. The API's local-credential +-- validation (auth.service.validateCredentials, ticket T-25) MUST explicitly +-- reject any login attempt where the stored password IS NULL before invoking +-- bcrypt.compare. An empty or null password must NEVER successfully +-- authenticate. This migration removes the DB-level NOT NULL constraint but +-- the enforcement responsibility remains entirely in application code. +-- +-- auth_source column: +-- 'local' — user authenticates with email + password (default) +-- 'federated' — user authenticates exclusively through an OIDC/SSO provider; +-- password column will be NULL for these users +-- This column provides an auditable record of how each account was created +-- and prevents accidental local-login attempts on federated-only accounts +-- at the service layer. + +-- Step 1: Drop NOT NULL on password so federated-only users can have NULL. +-- Guard: IF the column is already nullable this is a no-op in PostgreSQL. +ALTER TABLE public.user ALTER COLUMN password DROP NOT NULL; + +-- Step 2: Add auth_source for auditability. +-- IF NOT EXISTS is supported for ADD COLUMN since PostgreSQL 9.6 — safe to +-- re-run. +ALTER TABLE public.user + ADD COLUMN IF NOT EXISTS auth_source varchar(20) NOT NULL DEFAULT 'local'; From 491bcac78afac327cafb8617d3fbc4726cfbe388 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 16:09:43 +0200 Subject: [PATCH 02/14] feat(dto): shared SSO contracts + FEDERATED_IDENTITY internal scope (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 --- .../src/lib/internal-auth.constants.ts | 3 + libs/rest-dto/src/index.ts | 1 + libs/rest-dto/src/lib/sso.ts | 67 +++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 libs/rest-dto/src/lib/sso.ts diff --git a/libs/internal-auth/src/lib/internal-auth.constants.ts b/libs/internal-auth/src/lib/internal-auth.constants.ts index 949d460..1308e8f 100644 --- a/libs/internal-auth/src/lib/internal-auth.constants.ts +++ b/libs/internal-auth/src/lib/internal-auth.constants.ts @@ -14,4 +14,7 @@ export enum InternalScope { USER_REQUEST = 'user.request', AUTH_VALIDATE = 'auth.validate', REFRESH_LIFECYCLE = 'refresh.lifecycle', + /** Guards the federated resolve/provision endpoint only. Least-privilege: + * does not widen or overlap USER_REQUEST, AUTH_VALIDATE, or REFRESH_LIFECYCLE. */ + FEDERATED_IDENTITY = 'federated.identity', } diff --git a/libs/rest-dto/src/index.ts b/libs/rest-dto/src/index.ts index 624052d..b3f8f36 100644 --- a/libs/rest-dto/src/index.ts +++ b/libs/rest-dto/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/rest-dto'; export * from './lib/validation'; +export * from './lib/sso'; diff --git a/libs/rest-dto/src/lib/sso.ts b/libs/rest-dto/src/lib/sso.ts new file mode 100644 index 0000000..909239f --- /dev/null +++ b/libs/rest-dto/src/lib/sso.ts @@ -0,0 +1,67 @@ +import { CreationOptional, Permission } from './rest-dto'; + +/** + * Mirrors the `federated_identity` table. + * `subject` is the IdP-issued opaque identifier — it is internal and must not + * be forwarded to the frontend. + */ +export interface FederatedIdentityDTO { + id: CreationOptional; + userId: number; + provider: string; + subject: string; + emailAtLink?: string; + deleted: boolean; + createdAt: Date; + updatedAt?: Date; + deletedAt?: Date; +} + +/** + * 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. + * `id` is the provider key used in the login URL `/auth/sso/:id/login`. + */ +export interface SsoProviderPublicDTO { + id: string; + displayName: string; + iconKey?: string; +} + +/** + * Input the gateway sends to the API's internal federated resolve/provision + * endpoint after it has cryptographically validated the IdP ID token. + * The gateway only forwards claims it has fully verified; it never forwards + * raw tokens or unverified assertions. + * `suggestedPermissions` is derived from the provider's group→permission map + * and is a SUGGESTION — the API may floor it to a minimum and must not treat + * it as authoritative. + */ +export interface ResolveFederatedUserRequestDTO { + provider: string; + subject: string; + email: string; + emailVerified: boolean; + suggestedPermissions: Permission[]; +} + +/** + * Output of the federated resolve/provision endpoint. + * Never returns `subject` or any secret to layers that do not need them. + * `permissions` are server-side derived by the API and are authoritative. + */ +export interface ResolveFederatedUserResponseDTO { + id: number; + email: string; + permissions: Permission[]; +} + +/** + * Shape for mapping an IdP group/role claim value to local permissions. + * Used by the gateway provider registry configuration. + */ +export type ClaimPermissionMapping = { + claim: string; + permissions: Permission[]; +}; From 9e6a431b09b4383309e801dd9fd70c2b99e9f95c Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 16:34:40 +0200 Subject: [PATCH 03/14] feat(api): federated resolve/provision endpoint + NULL-password guard (T-25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../federated-identity.controller.ts | 31 ++++ apps/api/src/controllers/index.ts | 2 + .../src/models/federated-identity.model.ts | 68 ++++++++ apps/api/src/models/index.ts | 12 +- apps/api/src/models/user.model.ts | 6 + .../src/routes/federated-identity.routes.ts | 21 +++ apps/api/src/routes/index.ts | 4 + apps/api/src/services/auth.service.spec.ts | 28 +++ apps/api/src/services/auth.service.ts | 6 + .../federated-identity.service.spec.ts | 162 ++++++++++++++++++ .../services/federated-identity.service.ts | 142 +++++++++++++++ apps/api/src/services/index.ts | 8 +- libs/rest-dto/src/lib/rest-dto.ts | 5 +- libs/rest-dto/src/lib/validation.ts | 19 ++ 14 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/controllers/federated-identity.controller.ts create mode 100644 apps/api/src/models/federated-identity.model.ts create mode 100644 apps/api/src/routes/federated-identity.routes.ts create mode 100644 apps/api/src/services/federated-identity.service.spec.ts create mode 100644 apps/api/src/services/federated-identity.service.ts diff --git a/apps/api/src/controllers/federated-identity.controller.ts b/apps/api/src/controllers/federated-identity.controller.ts new file mode 100644 index 0000000..96675e8 --- /dev/null +++ b/apps/api/src/controllers/federated-identity.controller.ts @@ -0,0 +1,31 @@ +import HttpResponser from '@api/adapters/http/http.responser'; +import { federatedIdentityService } from '@api/services'; +import type { ResolveFederatedUserRequestDTO } from '@dto'; +import type { Request, Response } from 'express'; + +class FederatedIdentityController { + /** + * Resolves or provisions a local user from a validated federated identity. + * Reachable only through `requireInternalAuth` with the `federated.identity` + * scope, so the gateway is the sole legitimate caller. The request body is + * validated by `resolveFederatedUserSchema` before reaching this handler. + */ + resolve = async (req: Request, res: Response) => { + try { + const input = req.body as ResolveFederatedUserRequestDTO; + const result = await federatedIdentityService.resolveOrProvision(input); + return HttpResponser.successJson(res, result); + } catch { + // Generic message on purpose: never leak which branch failed + // (unverified email vs. collision vs. db error). + return HttpResponser.errorJson( + res, + { message: 'Federated identity could not be resolved.' }, + 400, + ); + } + }; +} + +const federatedIdentityController = new FederatedIdentityController(); +export default federatedIdentityController; diff --git a/apps/api/src/controllers/index.ts b/apps/api/src/controllers/index.ts index 338babd..acbfd10 100644 --- a/apps/api/src/controllers/index.ts +++ b/apps/api/src/controllers/index.ts @@ -1,8 +1,10 @@ +import federatedIdentityController from './federated-identity.controller'; import internalAuthController from './internal-auth.controller'; import refreshLifecycleController from './refresh-lifecycle.controller'; import userCrudController from './user-crud.controller'; export { + federatedIdentityController, internalAuthController, refreshLifecycleController, userCrudController, diff --git a/apps/api/src/models/federated-identity.model.ts b/apps/api/src/models/federated-identity.model.ts new file mode 100644 index 0000000..66523cd --- /dev/null +++ b/apps/api/src/models/federated-identity.model.ts @@ -0,0 +1,68 @@ +import { db } from '@api/adapters/db/pg.connector'; +import { FederatedIdentityDTO } from '@dto'; +import { + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, +} from 'sequelize'; + +export interface FederatedIdentityModel + extends + FederatedIdentityDTO, + Model< + InferAttributes, + InferCreationAttributes + > {} + +const FederatedIdentity = db.define( + 'FederatedIdentity', + { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true, + }, + userId: { + type: DataTypes.BIGINT, + allowNull: false, + field: 'user_id', + }, + provider: { + type: DataTypes.STRING(50), + allowNull: false, + }, + subject: { + type: DataTypes.STRING(255), + allowNull: false, + }, + emailAtLink: { + type: DataTypes.STRING(150), + allowNull: true, + field: 'email_at_link', + }, + deleted: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + field: 'createdat', + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'updatedat', + }, + deletedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'deletedat', + }, + }, + { tableName: 'federated_identity', timestamps: false }, +); + +export default FederatedIdentity; diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 3f9b09b..3ebef5e 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -1,6 +1,16 @@ +import FederatedIdentity, { + FederatedIdentityModel, +} from './federated-identity.model'; import RefreshTokenFamily, { RefreshTokenFamilyModel, } from './refresh-token-family.model'; import User, { UserModel } from './user.model'; -export { RefreshTokenFamily, RefreshTokenFamilyModel, User, UserModel }; +export { + FederatedIdentity, + FederatedIdentityModel, + RefreshTokenFamily, + RefreshTokenFamilyModel, + User, + UserModel, +}; diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts index 7fbd289..0bb8f2d 100644 --- a/apps/api/src/models/user.model.ts +++ b/apps/api/src/models/user.model.ts @@ -41,7 +41,13 @@ const User = db.define( }, password: { type: DataTypes.STRING(250), + allowNull: true, + }, + authSource: { + type: DataTypes.STRING(20), allowNull: false, + defaultValue: 'local', + field: 'auth_source', }, deleted: { type: DataTypes.BOOLEAN, diff --git a/apps/api/src/routes/federated-identity.routes.ts b/apps/api/src/routes/federated-identity.routes.ts new file mode 100644 index 0000000..08b1d78 --- /dev/null +++ b/apps/api/src/routes/federated-identity.routes.ts @@ -0,0 +1,21 @@ +import { federatedIdentityController } from '@api/controllers'; +import { validate } from '@api/middleware'; +import { InternalScope, requireInternalAuth } from '@internal-auth'; +import { resolveFederatedUserSchema } from '@dto'; +import { Router } from 'express'; + +const federatedIdentityRouter = Router(); + +const requireFederatedAuth = requireInternalAuth({ + publicKey: process.env.INTERNAL_JWT_PUBLIC_KEY ?? '', + allowedScopes: [InternalScope.FEDERATED_IDENTITY], +}); + +federatedIdentityRouter.post( + '/resolve', + requireFederatedAuth, + validate(resolveFederatedUserSchema), + federatedIdentityController.resolve, +); + +export default federatedIdentityRouter; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 8844014..143ccf4 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,4 +1,5 @@ import { Router } from 'express'; +import federatedIdentityRouter from './federated-identity.routes'; import healthRouter from './health.routes'; import internalAuthRouter from './internal-auth.routes'; import refreshLifecycleRouter from './refresh-lifecycle.routes'; @@ -12,6 +13,9 @@ api.use('/internal/auth', internalAuthRouter); /** gateway → api refresh-token lifecycle (scope refresh.lifecycle) */ api.use('/internal/refresh', refreshLifecycleRouter); +/** gateway → api federated identity resolve/provision (scope federated.identity) */ +api.use('/internal/federated', federatedIdentityRouter); + /** business endpoints proxied from the gateway (scope user.request) */ api.use('/v1/user', userCrudRouter); diff --git a/apps/api/src/services/auth.service.spec.ts b/apps/api/src/services/auth.service.spec.ts index e78f905..7005552 100644 --- a/apps/api/src/services/auth.service.spec.ts +++ b/apps/api/src/services/auth.service.spec.ts @@ -27,6 +27,7 @@ describe('AuthService (internal)', () => { id: 1, email: 'a@b.com', password: 'hashed', + authSource: 'local', permissions: ['ADMIN'], }; vi.mocked(User.findOne).mockResolvedValue(mockUser as never); @@ -51,6 +52,7 @@ describe('AuthService (internal)', () => { it('throws when password mismatch', async () => { vi.mocked(User.findOne).mockResolvedValue({ password: 'hashed', + authSource: 'local', } as never); vi.mocked(bcrypt.compare).mockResolvedValue(false as never); @@ -58,6 +60,32 @@ describe('AuthService (internal)', () => { authService.validateCredentials('a@b.com', 'bad'), ).rejects.toThrow('Email or password incorrect.'); }); + + it('rejects federated-only accounts (NULL password) without hitting bcrypt', async () => { + vi.mocked(User.findOne).mockResolvedValue({ + email: 'fed@b.com', + password: null, + authSource: 'federated', + } as never); + + await expect( + authService.validateCredentials('fed@b.com', 'anything'), + ).rejects.toThrow('Email or password incorrect.'); + expect(bcrypt.compare).not.toHaveBeenCalled(); + }); + + it('rejects local login when auth_source is not local even if a password exists', async () => { + vi.mocked(User.findOne).mockResolvedValue({ + email: 'fed@b.com', + password: 'leftover-hash', + authSource: 'federated', + } as never); + + await expect( + authService.validateCredentials('fed@b.com', 'anything'), + ).rejects.toThrow('Email or password incorrect.'); + expect(bcrypt.compare).not.toHaveBeenCalled(); + }); }); describe('getUser', () => { diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts index d965674..0cb16b1 100644 --- a/apps/api/src/services/auth.service.ts +++ b/apps/api/src/services/auth.service.ts @@ -14,6 +14,12 @@ class AuthService { where: { email, deleted: false }, }); if (!user) throw new Error('Email or password incorrect.'); + // Federated-only accounts have no local credential (password = NULL, + // auth_source = 'federated'). Reject local login before bcrypt so a + // NULL/empty stored password can never authenticate (auth-bypass defense). + if (!user.password || user.authSource !== 'local') { + throw new Error('Email or password incorrect.'); + } const validPassword = await this.#comparePassword(password, user.password); if (!validPassword) throw new Error('Email or password incorrect.'); return user; diff --git a/apps/api/src/services/federated-identity.service.spec.ts b/apps/api/src/services/federated-identity.service.spec.ts new file mode 100644 index 0000000..963ddc8 --- /dev/null +++ b/apps/api/src/services/federated-identity.service.spec.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UniqueConstraintError } from 'sequelize'; +import { Permission } from '@dto'; +import { FederatedIdentity, User } from '@api/models'; +import federatedIdentityService from './federated-identity.service'; + +vi.mock('@api/adapters/db/pg.connector', () => ({ + db: { + // Run the callback with a dummy transaction; propagate throws like the real one. + transaction: vi.fn(async (cb: (t: unknown) => unknown) => cb({})), + }, +})); + +vi.mock('@api/models', () => ({ + FederatedIdentity: { findOne: vi.fn(), create: vi.fn() }, + User: { findOne: vi.fn(), create: vi.fn() }, +})); + +const baseInput = { + provider: 'okta', + subject: 'okta|123', + email: 'alice@corp.com', + emailVerified: true, + suggestedPermissions: [] as Permission[], +}; + +describe('FederatedIdentityService.resolveOrProvision', () => { + beforeEach(() => vi.clearAllMocks()); + + it('resolves an existing identity to its stored user and ignores suggested permissions', async () => { + vi.mocked(FederatedIdentity.findOne).mockResolvedValue({ + userId: 5, + } as never); + vi.mocked(User.findOne).mockResolvedValue({ + id: 5, + email: 'alice@corp.com', + permissions: [Permission.READ_SOME_ENTITY], + } as never); + + const result = await federatedIdentityService.resolveOrProvision({ + ...baseInput, + suggestedPermissions: [Permission.ADMIN], + }); + + expect(result).toEqual({ + id: 5, + email: 'alice@corp.com', + permissions: [Permission.READ_SOME_ENTITY], + }); + // No write happened — existing identity is read-only here. + expect(User.create).not.toHaveBeenCalled(); + expect(FederatedIdentity.create).not.toHaveBeenCalled(); + }); + + it('rejects an unknown identity when the email is not verified', async () => { + vi.mocked(FederatedIdentity.findOne).mockResolvedValue(null); + + await expect( + federatedIdentityService.resolveOrProvision({ + ...baseInput, + emailVerified: false, + }), + ).rejects.toThrow('Federated identity could not be resolved.'); + expect(User.create).not.toHaveBeenCalled(); + }); + + it('links a new identity to an existing local user without changing its permissions', async () => { + vi.mocked(FederatedIdentity.findOne).mockResolvedValue(null); + vi.mocked(User.findOne).mockResolvedValue({ + id: 7, + email: 'alice@corp.com', + permissions: [Permission.WRITE_SOME_ENTITY], + } as never); + vi.mocked(FederatedIdentity.create).mockResolvedValue({} as never); + + const result = await federatedIdentityService.resolveOrProvision({ + ...baseInput, + suggestedPermissions: [Permission.ADMIN], + }); + + expect(result.id).toBe(7); + expect(result.permissions).toEqual([Permission.WRITE_SOME_ENTITY]); + expect(User.create).not.toHaveBeenCalled(); + expect(FederatedIdentity.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 7, + provider: 'okta', + subject: 'okta|123', + }), + expect.anything(), + ); + }); + + it('provisions a new federated user with no password and strips ADMIN', async () => { + vi.mocked(FederatedIdentity.findOne).mockResolvedValue(null); + vi.mocked(User.findOne).mockResolvedValue(null); + vi.mocked(User.create).mockResolvedValue({ + id: 10, + email: 'alice@corp.com', + permissions: [Permission.WRITE_SOME_ENTITY], + } as never); + vi.mocked(FederatedIdentity.create).mockResolvedValue({} as never); + + const result = await federatedIdentityService.resolveOrProvision({ + ...baseInput, + suggestedPermissions: [Permission.ADMIN, Permission.WRITE_SOME_ENTITY], + }); + + expect(result.id).toBe(10); + const createArgs = vi.mocked(User.create).mock.calls[0][0] as Record< + string, + unknown + >; + expect(createArgs.password).toBeNull(); + expect(createArgs.authSource).toBe('federated'); + expect(createArgs.permissions).toEqual([Permission.WRITE_SOME_ENTITY]); + expect(createArgs.permissions).not.toContain(Permission.ADMIN); + }); + + it('defaults to least privilege when no valid permission is suggested', async () => { + vi.mocked(FederatedIdentity.findOne).mockResolvedValue(null); + vi.mocked(User.findOne).mockResolvedValue(null); + vi.mocked(User.create).mockResolvedValue({ + id: 11, + email: 'alice@corp.com', + permissions: [Permission.READ_SOME_ENTITY], + } as never); + vi.mocked(FederatedIdentity.create).mockResolvedValue({} as never); + + await federatedIdentityService.resolveOrProvision({ + ...baseInput, + suggestedPermissions: [Permission.ADMIN], + }); + + const createArgs = vi.mocked(User.create).mock.calls[0][0] as Record< + string, + unknown + >; + expect(createArgs.permissions).toEqual([Permission.READ_SOME_ENTITY]); + }); + + it('is idempotent under a concurrent create race (unique violation → re-query)', async () => { + vi.mocked(FederatedIdentity.findOne) + .mockResolvedValueOnce(null) // initial lookup: not found + .mockResolvedValueOnce({ userId: 9 } as never); // re-query after the race + vi.mocked(User.findOne) + .mockResolvedValueOnce(null) // no local user by email + .mockResolvedValueOnce({ + id: 9, + email: 'alice@corp.com', + permissions: [Permission.READ_SOME_ENTITY], + } as never); // re-query resolves the winner + vi.mocked(User.create).mockResolvedValue({ id: 9 } as never); + vi.mocked(FederatedIdentity.create).mockRejectedValue( + new UniqueConstraintError({}), + ); + + const result = await federatedIdentityService.resolveOrProvision(baseInput); + + expect(result.id).toBe(9); + }); +}); diff --git a/apps/api/src/services/federated-identity.service.ts b/apps/api/src/services/federated-identity.service.ts new file mode 100644 index 0000000..3391cee --- /dev/null +++ b/apps/api/src/services/federated-identity.service.ts @@ -0,0 +1,142 @@ +import { db } from '@api/adapters/db/pg.connector'; +import { + FederatedIdentity, + FederatedIdentityModel, + User, + UserModel, +} from '@api/models'; +import { + Permission, + ResolveFederatedUserRequestDTO, + ResolveFederatedUserResponseDTO, +} from '@dto'; +import { InferCreationAttributes, UniqueConstraintError } from 'sequelize'; + +/** + * Resolves or just-in-time provisions a local user from a federated identity + * the gateway has ALREADY validated (ID token signature, iss, aud, exp, nonce). + * + * Security model: + * - An existing federated identity always resolves to its stored user, whose + * permissions are authoritative — the gateway's `suggestedPermissions` are + * never applied to an existing account, so a manipulated group claim cannot + * escalate privileges on a live user. + * - A new identity is only accepted when the IdP asserts `emailVerified === true` + * (account-takeover defense against unverified / spoofable email claims). + * - Provisioned accounts never receive ADMIN automatically and carry no local + * password (`auth_source = 'federated'`), so they cannot log in via /login. + */ +class FederatedIdentityService { + resolveOrProvision = async ( + input: ResolveFederatedUserRequestDTO, + ): Promise => { + const { provider, subject, emailVerified, suggestedPermissions } = input; + // Normalise like local users are stored (lowercased + trimmed) so linking + // matches by email reliably and never provisions a case-variant duplicate. + const email = input.email.trim().toLowerCase(); + + // 1. Known identity → authoritative stored user (ignore suggested perms). + const existing = await this.#findUserByIdentity(provider, subject); + if (existing) return this.#toResponse(existing); + + // 2. Unknown identity. Never link/provision on an unverified email. + if (emailVerified !== true) { + throw new Error('Federated identity could not be resolved.'); + } + + try { + return await db.transaction(async (transaction) => { + // 2a. Link to an existing local user sharing the verified email. + const localUser = await User.findOne({ + where: { email, deleted: false }, + transaction, + }); + if (localUser) { + await FederatedIdentity.create( + this.#identityAttrs(localUser.id, provider, subject, email), + { transaction }, + ); + // Permissions stay exactly as stored — no claim-driven change. + return this.#toResponse(localUser); + } + + // 2b. Provision a brand-new federated user (no local credential). + const created = await User.create( + { + email, + name: email.split('@')[0] || email, + lastName: '', + password: null, + authSource: 'federated', + permissions: this.#sanitizePermissions(suggestedPermissions), + deleted: false, + } as InferCreationAttributes, + { transaction }, + ); + await FederatedIdentity.create( + this.#identityAttrs(created.id, provider, subject, email), + { transaction }, + ); + return this.#toResponse(created); + }); + } catch (err) { + // Concurrent request won the race and created the identity first: + // re-query by the natural key and return the winner (idempotency). + if (err instanceof UniqueConstraintError) { + const raced = await this.#findUserByIdentity(provider, subject); + if (raced) return this.#toResponse(raced); + } + throw err; + } + }; + + #findUserByIdentity = async ( + provider: string, + subject: string, + ): Promise => { + const identity = await FederatedIdentity.findOne({ + where: { provider, subject, deleted: false }, + }); + if (!identity) return null; + return await User.findOne({ + where: { id: identity.userId, deleted: false }, + }); + }; + + #identityAttrs = ( + userId: number, + provider: string, + subject: string, + email: string, + ): InferCreationAttributes => + ({ + userId, + provider, + subject, + emailAtLink: email, + deleted: false, + }) as InferCreationAttributes; + + #toResponse = (user: UserModel): ResolveFederatedUserResponseDTO => ({ + id: user.id as number, + email: user.email, + permissions: user.permissions ?? [], + }); + + /** + * Never auto-grant ADMIN through federation and drop unknown values; fall + * back to least privilege when nothing valid remains. + */ + #sanitizePermissions = (suggested: Permission[]): Permission[] => { + const allowed = Object.values(Permission); + const valid = (suggested ?? []).filter( + (p) => allowed.includes(p) && p !== Permission.ADMIN, + ); + return valid.length > 0 + ? Array.from(new Set(valid)) + : [Permission.READ_SOME_ENTITY]; + }; +} + +const federatedIdentityService = new FederatedIdentityService(); +export default federatedIdentityService; diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 8789775..d5d1406 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,5 +1,11 @@ import authService from './auth.service'; +import federatedIdentityService from './federated-identity.service'; import refreshTokenFamilyService from './refresh-token-family.service'; import userCrudService from './user-crud.service'; -export { authService, refreshTokenFamilyService, userCrudService }; +export { + authService, + federatedIdentityService, + refreshTokenFamilyService, + userCrudService, +}; diff --git a/libs/rest-dto/src/lib/rest-dto.ts b/libs/rest-dto/src/lib/rest-dto.ts index 9e2c706..7c3c3ea 100644 --- a/libs/rest-dto/src/lib/rest-dto.ts +++ b/libs/rest-dto/src/lib/rest-dto.ts @@ -41,7 +41,10 @@ export interface UserDTO { name: string; lastName: string; permissions: Permission[]; - password: string; + /** NULL for federated-only accounts (no local credential). */ + password: string | null; + /** 'local' for password-based accounts; 'federated' for OIDC-provisioned accounts. */ + authSource: string; deleted: boolean; createdAt: Date; updatedAt?: Date; diff --git a/libs/rest-dto/src/lib/validation.ts b/libs/rest-dto/src/lib/validation.ts index 7f58fcd..86d32b7 100644 --- a/libs/rest-dto/src/lib/validation.ts +++ b/libs/rest-dto/src/lib/validation.ts @@ -51,3 +51,22 @@ export const idParamSchema = z.object({ export type UserCreateInput = z.infer; export type UserUpdateInput = z.infer; 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). + */ +export const resolveFederatedUserSchema = z + .object({ + provider: z.string().min(1).max(50), + subject: z.string().min(1).max(255), + email: z.string().email(), + emailVerified: z.boolean(), + suggestedPermissions: z.array(z.nativeEnum(Permission)), + }) + .strict(); + +export type ResolveFederatedUserInput = z.infer< + typeof resolveFederatedUserSchema +>; From cfd4cd5637469f785696d40cc3655401227cf340 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 16:35:13 +0200 Subject: [PATCH 04/14] feat(gateway): multi-provider OIDC registry + discovery (T-26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SSO config layer (no routes yet): - provider-registry.ts: parses 0..N IdPs from SSO__* 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 --- apps/gateway/src/sso/discovery.spec.ts | 64 +++ apps/gateway/src/sso/discovery.ts | 37 ++ .../gateway/src/sso/provider-registry.spec.ts | 162 ++++++++ apps/gateway/src/sso/provider-registry.ts | 188 +++++++++ package-lock.json | 391 ++++++------------ package.json | 1 + 6 files changed, 571 insertions(+), 272 deletions(-) create mode 100644 apps/gateway/src/sso/discovery.spec.ts create mode 100644 apps/gateway/src/sso/discovery.ts create mode 100644 apps/gateway/src/sso/provider-registry.spec.ts create mode 100644 apps/gateway/src/sso/provider-registry.ts diff --git a/apps/gateway/src/sso/discovery.spec.ts b/apps/gateway/src/sso/discovery.spec.ts new file mode 100644 index 0000000..f3b350f --- /dev/null +++ b/apps/gateway/src/sso/discovery.spec.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./provider-registry', () => ({ + getProviderConfig: vi.fn(), +})); + +import { getProviderConfig } from './provider-registry'; +import { getClient, resetDiscoveryCache } from './discovery'; + +const config = { + id: 'okta', + displayName: 'Okta', + issuer: 'https://example.okta.com', + clientId: 'client-okta', + clientSecret: 'secret-okta', + redirectUri: 'https://app.example.com/cb', + scopes: 'openid profile email', + groupsClaim: 'groups', + permissionMap: [], +}; + +describe('discovery.getClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetDiscoveryCache(); + }); + afterEach(() => resetDiscoveryCache()); + + it('discovers the issuer once and caches the resulting client', async () => { + vi.mocked(getProviderConfig).mockReturnValue(config as never); + const ClientCtor = vi.fn().mockImplementation(function ( + this: Record, + metadata: unknown, + ) { + this.metadata = metadata; + }); + const fakeIssuer = { Client: ClientCtor } as never; + const discover = vi.fn().mockResolvedValue(fakeIssuer); + + const first = await getClient('okta', discover); + const second = await getClient('okta', discover); + + expect(discover).toHaveBeenCalledTimes(1); + expect(discover).toHaveBeenCalledWith('https://example.okta.com'); + expect(ClientCtor).toHaveBeenCalledTimes(1); + expect(ClientCtor).toHaveBeenCalledWith({ + client_id: 'client-okta', + client_secret: 'secret-okta', + redirect_uris: ['https://app.example.com/cb'], + response_types: ['code'], + }); + expect(first).toBe(second); + }); + + it('throws for an unknown provider', async () => { + vi.mocked(getProviderConfig).mockReturnValue(undefined); + const discover = vi.fn(); + + await expect(getClient('nope', discover)).rejects.toThrow( + /Unknown SSO provider: nope/, + ); + expect(discover).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/sso/discovery.ts b/apps/gateway/src/sso/discovery.ts new file mode 100644 index 0000000..4b2bd6e --- /dev/null +++ b/apps/gateway/src/sso/discovery.ts @@ -0,0 +1,37 @@ +import { BaseClient, custom, Issuer } from 'openid-client'; +import { getProviderConfig } from './provider-registry'; + +// Bound every IdP network call (discovery, token exchange, JWKS) so a slow or +// hostile issuer cannot hang the gateway. +custom.setHttpOptionsDefaults({ timeout: 5000 }); + +export type DiscoverIssuer = (issuer: string) => Promise>; + +// One discovered+configured client per provider; discovery (and the JWKS it +// caches internally) runs once. openid-client validates the ID token signature +// against that JWKS on `client.callback()`, so we never hand-roll key handling. +const clientCache = new Map(); + +export const getClient = async ( + providerId: string, + discover: DiscoverIssuer = (issuer) => Issuer.discover(issuer), +): Promise => { + const cached = clientCache.get(providerId); + if (cached) return cached; + + const config = getProviderConfig(providerId); + if (!config) throw new Error(`Unknown SSO provider: ${providerId}`); + + const issuer = await discover(config.issuer); + const client = new issuer.Client({ + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uris: [config.redirectUri], + response_types: ['code'], + }); + clientCache.set(providerId, client); + return client; +}; + +/** Clears the discovered-client cache — for tests. */ +export const resetDiscoveryCache = (): void => clientCache.clear(); diff --git a/apps/gateway/src/sso/provider-registry.spec.ts b/apps/gateway/src/sso/provider-registry.spec.ts new file mode 100644 index 0000000..9bd0bdc --- /dev/null +++ b/apps/gateway/src/sso/provider-registry.spec.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Permission } from '@dto'; +import { + buildRegistryFromEnv, + getProviderConfig, + isSsoEnabled, + listPublicProviders, + resetRegistryCache, +} from './provider-registry'; + +const okta = { + 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', +}; + +describe('buildRegistryFromEnv', () => { + it('returns an empty registry when no provider is declared (SSO off)', () => { + expect(buildRegistryFromEnv({}).size).toBe(0); + }); + + it('parses multiple providers with defaults applied', () => { + const reg = buildRegistryFromEnv({ + ...okta, + SSO_AZUREAD_ISSUER: 'https://login.microsoftonline.com/tenant/v2.0', + SSO_AZUREAD_CLIENT_ID: 'client-az', + SSO_AZUREAD_CLIENT_SECRET: 'secret-az', + SSO_AZUREAD_REDIRECT_URI: 'https://app.example.com/cb', + SSO_AZUREAD_DISPLAY_NAME: 'Microsoft', + }); + expect([...reg.keys()].sort()).toEqual(['azuread', 'okta']); + expect(reg.get('okta')?.scopes).toBe('openid profile email'); + expect(reg.get('okta')?.groupsClaim).toBe('groups'); + expect(reg.get('okta')?.displayName).toBe('Okta'); + expect(reg.get('azuread')?.displayName).toBe('Microsoft'); + }); + + it('fails fast when a declared provider is missing client_id', () => { + expect(() => + buildRegistryFromEnv({ + SSO_OKTA_ISSUER: 'https://example.okta.com', + SSO_OKTA_CLIENT_SECRET: 'secret', + SSO_OKTA_REDIRECT_URI: 'https://app/cb', + }), + ).toThrow(/missing required env SSO_OKTA_CLIENT_ID/); + }); + + it('parses a permission map into claim→permissions mappings', () => { + const reg = buildRegistryFromEnv({ + ...okta, + SSO_OKTA_PERMISSION_MAP: + 'admins:ADMIN,WRITE_SOME_ENTITY; viewers:READ_SOME_ENTITY', + }); + expect(reg.get('okta')?.permissionMap).toEqual([ + { + claim: 'admins', + permissions: [Permission.ADMIN, Permission.WRITE_SOME_ENTITY], + }, + { claim: 'viewers', permissions: [Permission.READ_SOME_ENTITY] }, + ]); + }); + + it('fails fast on an unknown permission in the map', () => { + expect(() => + buildRegistryFromEnv({ ...okta, SSO_OKTA_PERMISSION_MAP: 'x:SUPERUSER' }), + ).toThrow(/unknown permission "SUPERUSER"/); + }); + + describe('SSRF / issuer hardening', () => { + it('rejects a non-https issuer', () => { + expect(() => + buildRegistryFromEnv({ + ...okta, + SSO_OKTA_ISSUER: 'http://example.com', + }), + ).toThrow(/must use https/); + }); + + it('rejects the cloud metadata / link-local address', () => { + expect(() => + buildRegistryFromEnv({ + ...okta, + SSO_OKTA_ISSUER: 'https://169.254.169.254/meta', + }), + ).toThrow(/not allowed/); + }); + + it('rejects loopback hosts', () => { + expect(() => + buildRegistryFromEnv({ ...okta, SSO_OKTA_ISSUER: 'https://localhost' }), + ).toThrow(/not allowed/); + expect(() => + buildRegistryFromEnv({ ...okta, SSO_OKTA_ISSUER: 'https://127.0.0.1' }), + ).toThrow(/not allowed/); + }); + + it('rejects RFC1918 private hosts', () => { + expect(() => + buildRegistryFromEnv({ ...okta, SSO_OKTA_ISSUER: 'https://10.1.2.3' }), + ).toThrow(/not allowed/); + expect(() => + buildRegistryFromEnv({ + ...okta, + SSO_OKTA_ISSUER: 'https://192.168.0.5', + }), + ).toThrow(/not allowed/); + }); + + it('accepts a public https issuer', () => { + 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(); + }); + }); +}); + +describe('public accessors (process.env-backed)', () => { + const saved = { ...process.env }; + afterEach(() => { + process.env = { ...saved }; + resetRegistryCache(); + }); + + it('listPublicProviders exposes only id/displayName/iconKey — no secrets', () => { + process.env.SSO_OKTA_ISSUER = okta.SSO_OKTA_ISSUER; + process.env.SSO_OKTA_CLIENT_ID = okta.SSO_OKTA_CLIENT_ID; + process.env.SSO_OKTA_CLIENT_SECRET = okta.SSO_OKTA_CLIENT_SECRET; + process.env.SSO_OKTA_REDIRECT_URI = okta.SSO_OKTA_REDIRECT_URI; + process.env.SSO_OKTA_ICON_KEY = 'okta-logo'; + resetRegistryCache(); + + const list = listPublicProviders(); + expect(list).toEqual([ + { id: 'okta', displayName: 'Okta', iconKey: 'okta-logo' }, + ]); + const serialised = JSON.stringify(list); + expect(serialised).not.toContain('secret-okta'); + expect(serialised).not.toContain('client-okta'); + expect(serialised).not.toContain(okta.SSO_OKTA_ISSUER); + expect(isSsoEnabled()).toBe(true); + expect(getProviderConfig('okta')?.clientSecret).toBe('secret-okta'); + }); + + it('reports SSO disabled when no provider is configured', () => { + for (const k of Object.keys(process.env)) { + if (k.startsWith('SSO_')) delete process.env[k]; + } + resetRegistryCache(); + expect(isSsoEnabled()).toBe(false); + expect(listPublicProviders()).toEqual([]); + }); +}); diff --git a/apps/gateway/src/sso/provider-registry.ts b/apps/gateway/src/sso/provider-registry.ts new file mode 100644 index 0000000..749834a --- /dev/null +++ b/apps/gateway/src/sso/provider-registry.ts @@ -0,0 +1,188 @@ +import { ClaimPermissionMapping, Permission, SsoProviderPublicDTO } from '@dto'; + +/** + * 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. + */ +export interface SsoProviderConfig { + /** Lowercased provider key, used in `/auth/sso/:id/login`. */ + id: string; + displayName: string; + iconKey?: string; + issuer: string; + clientId: string; + clientSecret: string; + redirectUri: string; + scopes: string; + groupsClaim: string; + permissionMap: ClaimPermissionMapping[]; +} + +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; + 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 + if (a === 169 && b === 254) return true; // link-local + cloud metadata + if (a === 192 && b === 168) return true; // private + if (a === 172 && b >= 16 && b <= 31) return true; // private + return false; +}; + +const assertSafeIssuer = ( + providerId: string, + issuer: string, + allowInsecure: boolean, +): void => { + let url: URL; + try { + url = new URL(issuer); + } catch { + throw new Error(`SSO provider "${providerId}" has a malformed issuer URL`); + } + if (url.protocol !== 'https:') { + if (allowInsecure && url.protocol === 'http:') return; + throw new Error( + `SSO provider "${providerId}" issuer must use https (got ${url.protocol})`, + ); + } + if (!allowInsecure && isBlockedHost(url.hostname)) { + throw new Error( + `SSO provider "${providerId}" issuer host is not allowed (loopback/link-local/private)`, + ); + } +}; + +const parsePermissionMap = ( + providerId: string, + raw: string | undefined, +): ClaimPermissionMapping[] => { + if (!raw || !raw.trim()) return []; + const allowed = Object.values(Permission) as string[]; + return raw + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const sep = entry.indexOf(':'); + const claim = sep >= 0 ? entry.slice(0, sep).trim() : ''; + const permsRaw = sep >= 0 ? entry.slice(sep + 1).trim() : ''; + if (!claim || !permsRaw) { + throw new Error( + `SSO provider "${providerId}" has an invalid permission map entry: "${entry}"`, + ); + } + const permissions = permsRaw + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => { + if (!allowed.includes(p)) { + throw new Error( + `SSO provider "${providerId}" maps to unknown permission "${p}"`, + ); + } + return p as Permission; + }); + return { claim, permissions }; + }); +}; + +const titleCase = (name: string): string => + name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); + +const required = ( + env: NodeJS.ProcessEnv, + providerId: string, + rawName: string, + key: string, +): string => { + const value = env[`SSO_${rawName}_${key}`]; + if (!value || !value.trim()) { + throw new Error( + `SSO provider "${providerId}" is missing required env SSO_${rawName}_${key}`, + ); + } + return value.trim(); +}; + +/** + * Pure builder: parses + validates every declared provider from the given env. + * Throws (fail-fast) on any misconfiguration. Zero providers is valid → SSO off. + * Exported for testing with an explicit env object. + */ +export const buildRegistryFromEnv = ( + env: NodeJS.ProcessEnv, +): Map => { + const allowInsecure = env.SSO_ALLOW_INSECURE_ISSUERS === 'true'; + const registry = new Map(); + + for (const key of Object.keys(env)) { + const match = key.match(ENV_PREFIX); + if (!match) continue; + const rawName = match[1]; + const id = rawName.toLowerCase(); + + const issuer = required(env, id, rawName, 'ISSUER'); + assertSafeIssuer(id, issuer, allowInsecure); + + registry.set(id, { + id, + displayName: env[`SSO_${rawName}_DISPLAY_NAME`]?.trim() || titleCase(id), + iconKey: env[`SSO_${rawName}_ICON_KEY`]?.trim() || undefined, + issuer, + clientId: required(env, id, rawName, 'CLIENT_ID'), + clientSecret: required(env, id, rawName, 'CLIENT_SECRET'), + redirectUri: required(env, id, rawName, 'REDIRECT_URI'), + scopes: env[`SSO_${rawName}_SCOPES`]?.trim() || 'openid profile email', + groupsClaim: env[`SSO_${rawName}_GROUPS_CLAIM`]?.trim() || 'groups', + permissionMap: parsePermissionMap( + id, + env[`SSO_${rawName}_PERMISSION_MAP`], + ), + }); + } + + return registry; +}; + +let cache: Map | null = null; + +const registry = (): Map => { + if (!cache) cache = buildRegistryFromEnv(process.env); + return cache; +}; + +/** Clears the memoised registry — for tests that mutate `process.env`. */ +export const resetRegistryCache = (): void => { + cache = null; +}; + +/** Validates the SSO config at boot; throws on misconfiguration (fail-fast). */ +export const validateSsoConfig = (): void => { + cache = buildRegistryFromEnv(process.env); +}; + +export const getAllProviderConfigs = (): SsoProviderConfig[] => + Array.from(registry().values()); + +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/package-lock.json b/package-lock.json index e7878b1..6463575 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "jose": "^5.10.0", "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", + "openid-client": "^5.7.1", "pdf-lib": "^1.3.1", "pdf-parse": "^2.4.5", "pdf2pic": "^3.1.1", @@ -2174,6 +2175,7 @@ "version": "21.2.10", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.10.tgz", "integrity": "sha512-FDcnj3ogRmnTca4m2GbKP2khFOCtoVvWDZyfw2ZCPAf+zsQlKTyscKvx4GpTFo+KHrYXpawUpDIWHORFpuqFEA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "7.29.0", @@ -2260,6 +2262,7 @@ "version": "21.2.10", "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-21.2.10.tgz", "integrity": "sha512-pQNL1LwI/6e8SbwiPxGJkXhV5AZxODhS/UJMEeYTx2fRvm4Ob3aMxWTl3ixhFZeYAx9bcU3QpfEJyGr7YDO24w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "7.29.0", @@ -2411,6 +2414,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2420,6 +2424,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -2450,12 +2455,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2465,6 +2472,7 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -2494,6 +2502,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -2510,6 +2519,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2618,6 +2628,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2641,6 +2652,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -2654,6 +2666,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -2757,6 +2770,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2775,6 +2789,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2799,6 +2814,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -2812,6 +2828,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -4397,6 +4414,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -4411,6 +4429,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -4429,6 +4448,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -4445,6 +4465,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -6290,6 +6311,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -6300,6 +6322,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -6310,6 +6333,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -6330,12 +6354,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -11415,277 +11441,6 @@ "win32" ] }, - "node_modules/@rspack/binding": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz", - "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==", - "dev": true, - "license": "MIT", - "peer": true, - "optionalDependencies": { - "@rspack/binding-darwin-arm64": "1.7.11", - "@rspack/binding-darwin-x64": "1.7.11", - "@rspack/binding-linux-arm64-gnu": "1.7.11", - "@rspack/binding-linux-arm64-musl": "1.7.11", - "@rspack/binding-linux-x64-gnu": "1.7.11", - "@rspack/binding-linux-x64-musl": "1.7.11", - "@rspack/binding-wasm32-wasi": "1.7.11", - "@rspack/binding-win32-arm64-msvc": "1.7.11", - "@rspack/binding-win32-ia32-msvc": "1.7.11", - "@rspack/binding-win32-x64-msvc": "1.7.11" - } - }, - "node_modules/@rspack/binding-darwin-arm64": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz", - "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rspack/binding-darwin-x64": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz", - "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rspack/binding-linux-arm64-gnu": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz", - "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rspack/binding-linux-arm64-musl": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz", - "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rspack/binding-linux-x64-gnu": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz", - "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rspack/binding-linux-x64-musl": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz", - "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rspack/binding-wasm32-wasi": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz", - "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@napi-rs/wasm-runtime": "1.0.7" - } - }, - "node_modules/@rspack/binding-win32-arm64-msvc": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz", - "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rspack/binding-win32-ia32-msvc": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz", - "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rspack/binding-win32-x64-msvc": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz", - "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rspack/core": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", - "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@module-federation/runtime-tools": "0.22.0", - "@rspack/binding": "1.7.11", - "@rspack/lite-tapable": "1.1.0" - }, - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.1" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@rspack/core/node_modules/@module-federation/error-codes": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", - "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@rspack/core/node_modules/@module-federation/runtime": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", - "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@module-federation/error-codes": "0.22.0", - "@module-federation/runtime-core": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@rspack/core/node_modules/@module-federation/runtime-core": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", - "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@module-federation/error-codes": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@rspack/core/node_modules/@module-federation/runtime-tools": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", - "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@module-federation/runtime": "0.22.0", - "@module-federation/webpack-bundler-runtime": "0.22.0" - } - }, - "node_modules/@rspack/core/node_modules/@module-federation/sdk": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", - "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@rspack/core/node_modules/@module-federation/webpack-bundler-runtime": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", - "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@module-federation/runtime": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, "node_modules/@rspack/dev-server": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-1.2.1.tgz", @@ -12778,6 +12533,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -12791,6 +12547,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -12800,6 +12557,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -12810,6 +12568,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -14992,6 +14751,7 @@ "version": "2.10.23", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -15196,6 +14956,7 @@ "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -15452,6 +15213,7 @@ "version": "1.0.30001791", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -15515,6 +15277,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -16051,6 +15814,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -17458,6 +17222,7 @@ "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, "license": "ISC" }, "node_modules/emittery": { @@ -18398,6 +18163,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -19034,6 +18800,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -19052,6 +18819,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -21042,6 +20810,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -21092,6 +20861,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -21907,6 +21677,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -22759,6 +22530,7 @@ "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, "license": "MIT" }, "node_modules/node-schedule": { @@ -23432,6 +23204,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -23462,6 +23243,15 @@ ], "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -23552,6 +23342,48 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -24304,6 +24136,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -25071,6 +24904,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -25097,6 +24931,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, "license": "Apache-2.0" }, "node_modules/regenerate": { @@ -27872,6 +27707,7 @@ "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -28365,7 +28201,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -28521,6 +28357,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -30246,6 +30083,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, "license": "ISC" }, "node_modules/yaml": { @@ -30268,6 +30106,7 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -30294,6 +30133,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -30306,6 +30146,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -30318,6 +30159,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -30332,12 +30174,14 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -30355,6 +30199,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -30370,6 +30215,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -30387,6 +30233,7 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" diff --git a/package.json b/package.json index bc7edf8..000fbdd 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "jose": "^5.10.0", "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", + "openid-client": "^5.7.1", "pdf-lib": "^1.3.1", "pdf-parse": "^2.4.5", "pdf2pic": "^3.1.1", From 849f565b55856a462cf24e2c01bcfcd0d5e136d0 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 16:48:16 +0200 Subject: [PATCH 05/14] feat(gateway): OIDC auth-code+PKCE handshake + local session emission (T-27, T-28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/gateway/src/clients/api.client.ts | 23 +- .../src/controllers/sso.controller.spec.ts | 250 ++++++++++++++++++ .../gateway/src/controllers/sso.controller.ts | 140 ++++++++++ apps/gateway/src/main.ts | 4 + apps/gateway/src/routes/auth.routes.ts | 4 + apps/gateway/src/routes/sso.routes.ts | 14 + .../gateway/src/sso/permission-mapper.spec.ts | 36 +++ apps/gateway/src/sso/permission-mapper.ts | 26 ++ .../src/sso/sso-transaction.service.spec.ts | 101 +++++++ .../src/sso/sso-transaction.service.ts | 108 ++++++++ 10 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 apps/gateway/src/controllers/sso.controller.spec.ts create mode 100644 apps/gateway/src/controllers/sso.controller.ts create mode 100644 apps/gateway/src/routes/sso.routes.ts create mode 100644 apps/gateway/src/sso/permission-mapper.spec.ts create mode 100644 apps/gateway/src/sso/permission-mapper.ts create mode 100644 apps/gateway/src/sso/sso-transaction.service.spec.ts create mode 100644 apps/gateway/src/sso/sso-transaction.service.ts diff --git a/apps/gateway/src/clients/api.client.ts b/apps/gateway/src/clients/api.client.ts index 97d98bf..bd7e1af 100644 --- a/apps/gateway/src/clients/api.client.ts +++ b/apps/gateway/src/clients/api.client.ts @@ -1,4 +1,8 @@ -import { Permission } from '@dto'; +import { + Permission, + ResolveFederatedUserRequestDTO, + ResolveFederatedUserResponseDTO, +} from '@dto'; import { INTERNAL_AUTH_HEADER, INTERNAL_REQUEST_ID_HEADER, @@ -121,4 +125,21 @@ export class ApiClient { requestId, 'Failed to revoke refresh token', ); + + /** + * Resolve or JIT-provision a local user from a federated identity the + * gateway has already validated. Carries the dedicated federated scope so + * the api endpoint stays least-privilege. + */ + static resolveFederatedUser = ( + body: ResolveFederatedUserRequestDTO, + requestId: string, + ): Promise => + callApi( + '/internal/federated/resolve', + body, + InternalScope.FEDERATED_IDENTITY, + requestId, + 'Federated identity could not be resolved.', + ); } diff --git a/apps/gateway/src/controllers/sso.controller.spec.ts b/apps/gateway/src/controllers/sso.controller.spec.ts new file mode 100644 index 0000000..d04398d --- /dev/null +++ b/apps/gateway/src/controllers/sso.controller.spec.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Permission } from '@dto'; + +vi.mock('@gateway/sso/provider-registry', () => ({ + getProviderConfig: vi.fn(), + listPublicProviders: vi.fn(), +})); +vi.mock('@gateway/sso/discovery', () => ({ getClient: vi.fn() })); +vi.mock('@gateway/clients/api.client', () => ({ + ApiClient: { resolveFederatedUser: vi.fn() }, +})); +vi.mock('@gateway/middleware/auth.middleware', () => ({ + respondWithTokens: vi.fn(), +})); +vi.mock('@gateway/sso/sso-transaction.service', () => ({ + setTransactionCookie: vi.fn(), + clearTransactionCookie: vi.fn(), + readTransaction: vi.fn(), + safeReturnTo: (v: unknown) => + typeof v === 'string' && v.startsWith('/') && !v.startsWith('//') ? v : '/', +})); +vi.mock('openid-client', () => ({ + generators: { + state: () => 'STATE', + nonce: () => 'NONCE', + codeVerifier: () => 'VERIFIER', + codeChallenge: () => 'CHALLENGE', + }, +})); + +import { + getProviderConfig, + listPublicProviders, +} from '@gateway/sso/provider-registry'; +import { getClient } from '@gateway/sso/discovery'; +import { ApiClient } from '@gateway/clients/api.client'; +import { respondWithTokens } from '@gateway/middleware/auth.middleware'; +import { + clearTransactionCookie, + readTransaction, + setTransactionCookie, +} from '@gateway/sso/sso-transaction.service'; +import ssoController from './sso.controller'; + +const config = { + id: 'okta', + displayName: 'Okta', + issuer: 'https://example.okta.com', + clientId: 'cid', + clientSecret: 'secret', + redirectUri: 'https://app.example.com/api/v1/auth/sso/okta/callback', + scopes: 'openid profile email', + groupsClaim: 'groups', + permissionMap: [ + { claim: 'admins', permissions: [Permission.WRITE_SOME_ENTITY] }, + ], +}; + +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.clearCookie = vi.fn(); + res.setHeader = vi.fn(); + return res as never; +}; + +const client = { + authorizationUrl: vi.fn(() => 'https://example.okta.com/authorize?x=1'), + callbackParams: vi.fn(() => ({ code: 'CODE', state: 'STATE' })), + callback: vi.fn(), +}; + +describe('SsoController', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getClient).mockResolvedValue(client as never); + }); + + it('providers returns the public provider list', async () => { + vi.mocked(listPublicProviders).mockReturnValue([ + { id: 'okta', displayName: 'Okta' }, + ]); + const res = mkRes(); + await ssoController.providers({} as never, res); + expect(res.json).toHaveBeenCalledWith([ + { id: 'okta', displayName: 'Okta' }, + ]); + }); + + describe('login', () => { + it('404s for an unknown provider', async () => { + vi.mocked(getProviderConfig).mockReturnValue(undefined); + const res = mkRes(); + await ssoController.login( + { params: { provider: 'nope' }, query: {} } as never, + res, + ); + expect(res.status).toHaveBeenCalledWith(404); + expect(setTransactionCookie).not.toHaveBeenCalled(); + }); + + it('stores the transaction and redirects to the IdP with PKCE S256', async () => { + vi.mocked(getProviderConfig).mockReturnValue(config as never); + const res = mkRes(); + await ssoController.login( + { + params: { provider: 'okta' }, + query: { returnTo: '/dashboard' }, + } as never, + res, + ); + expect(setTransactionCookie).toHaveBeenCalledWith( + res, + expect.objectContaining({ + provider: 'okta', + state: 'STATE', + nonce: 'NONCE', + codeVerifier: 'VERIFIER', + returnTo: '/dashboard', + }), + ); + expect(client.authorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ + code_challenge: 'CHALLENGE', + code_challenge_method: 'S256', + state: 'STATE', + nonce: 'NONCE', + redirect_uri: config.redirectUri, + }), + ); + expect(res.redirect).toHaveBeenCalledWith( + 'https://example.okta.com/authorize?x=1', + ); + }); + }); + + describe('callback', () => { + const goodTx = { + provider: 'okta', + state: 'STATE', + nonce: 'NONCE', + codeVerifier: 'VERIFIER', + returnTo: '/dashboard', + }; + + it('404s for an unknown provider', async () => { + vi.mocked(getProviderConfig).mockReturnValue(undefined); + const res = mkRes(); + await ssoController.callback( + { params: { provider: 'nope' }, query: {}, cookies: {} } as never, + res, + ); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('redirects to the error page when there is no transaction cookie', async () => { + vi.mocked(getProviderConfig).mockReturnValue(config as never); + vi.mocked(readTransaction).mockReturnValue(null); + const res = mkRes(); + await ssoController.callback( + { params: { provider: 'okta' }, query: {}, cookies: {} } as never, + res, + ); + expect(clearTransactionCookie).toHaveBeenCalledWith(res); + expect(res.redirect).toHaveBeenCalledWith('/login?sso_error=1'); + expect(ApiClient.resolveFederatedUser).not.toHaveBeenCalled(); + }); + + it('rejects a transaction started for a different provider (mix-up defense)', async () => { + vi.mocked(getProviderConfig).mockReturnValue(config as never); + vi.mocked(readTransaction).mockReturnValue({ + ...goodTx, + provider: 'azuread', + }); + const res = mkRes(); + await ssoController.callback( + { params: { provider: 'okta' }, query: {}, cookies: {} } as never, + res, + ); + expect(res.redirect).toHaveBeenCalledWith('/login?sso_error=1'); + expect(ApiClient.resolveFederatedUser).not.toHaveBeenCalled(); + }); + + it('redirects to error (no leak) when ID-token validation fails', async () => { + vi.mocked(getProviderConfig).mockReturnValue(config as never); + vi.mocked(readTransaction).mockReturnValue(goodTx); + client.callback.mockRejectedValueOnce(new Error('nonce mismatch')); + const res = mkRes(); + await ssoController.callback( + { params: { provider: 'okta' }, query: {}, cookies: {} } as never, + res, + ); + expect(res.redirect).toHaveBeenCalledWith('/login?sso_error=1'); + expect(respondWithTokens).not.toHaveBeenCalled(); + }); + + it('issues a local session on success and redirects to the vetted returnTo', async () => { + vi.mocked(getProviderConfig).mockReturnValue(config as never); + vi.mocked(readTransaction).mockReturnValue(goodTx); + client.callback.mockResolvedValueOnce({ + claims: () => ({ + sub: 'okta|1', + email: 'alice@corp.com', + email_verified: true, + groups: ['admins'], + }), + }); + vi.mocked(ApiClient.resolveFederatedUser).mockResolvedValue({ + id: 42, + email: 'alice@corp.com', + permissions: [Permission.WRITE_SOME_ENTITY], + }); + const res = mkRes(); + + await ssoController.callback( + { params: { provider: 'okta' }, query: {}, cookies: {} } as never, + res, + ); + + expect(client.callback).toHaveBeenCalledWith( + config.redirectUri, + { code: 'CODE', state: 'STATE' }, + { state: 'STATE', nonce: 'NONCE', code_verifier: 'VERIFIER' }, + ); + expect(ApiClient.resolveFederatedUser).toHaveBeenCalledWith( + { + provider: 'okta', + subject: 'okta|1', + email: 'alice@corp.com', + emailVerified: true, + suggestedPermissions: [Permission.WRITE_SOME_ENTITY], + }, + expect.any(String), + ); + expect(respondWithTokens).toHaveBeenCalledWith( + res, + { + id: 42, + email: 'alice@corp.com', + permissions: [Permission.WRITE_SOME_ENTITY], + }, + { issueRefreshCookie: true, requestId: expect.any(String) }, + ); + expect(res.redirect).toHaveBeenCalledWith('/dashboard'); + }); + }); +}); diff --git a/apps/gateway/src/controllers/sso.controller.ts b/apps/gateway/src/controllers/sso.controller.ts new file mode 100644 index 0000000..338e03f --- /dev/null +++ b/apps/gateway/src/controllers/sso.controller.ts @@ -0,0 +1,140 @@ +import HttpResponser from '@gateway/adapters/http/http.responser'; +import { ApiClient } from '@gateway/clients/api.client'; +import { respondWithTokens } from '@gateway/middleware/auth.middleware'; +import { getClient } from '@gateway/sso/discovery'; +import { mapGroupsToPermissions } from '@gateway/sso/permission-mapper'; +import { + getProviderConfig, + listPublicProviders, +} from '@gateway/sso/provider-registry'; +import { + clearTransactionCookie, + readTransaction, + safeReturnTo, + setTransactionCookie, +} from '@gateway/sso/sso-transaction.service'; +import { randomUUID } from 'crypto'; +import type { Request, Response } from 'express'; +import { generators } from 'openid-client'; + +// Where the SPA lands when SSO fails. A relative path so it can never be an +// open redirect, and it carries no IdP error detail (avoids leakage). +const SSO_ERROR_REDIRECT = '/login?sso_error=1'; + +class SsoController { + /** Public provider metadata for the SPA to render login buttons. */ + providers = (_req: Request, res: Response) => + HttpResponser.successJson(res, listPublicProviders()); + + /** + * Start the Authorization Code + PKCE flow: mint state/nonce/PKCE, stash + * them in the signed transaction cookie, and redirect to the IdP. + */ + login = async (req: Request, res: Response) => { + const providerId = req.params.provider as string; + const config = getProviderConfig(providerId); + if (!config) { + return HttpResponser.errorJson(res, { message: 'Unknown provider' }, 404); + } + + try { + const client = await getClient(providerId); + const state = generators.state(); + const nonce = generators.nonce(); + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + const returnTo = safeReturnTo(req.query.returnTo); + + setTransactionCookie(res, { + provider: providerId, + state, + nonce, + codeVerifier, + returnTo, + }); + + const url = client.authorizationUrl({ + scope: config.scopes, + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + redirect_uri: config.redirectUri, + }); + return res.redirect(url); + } catch { + return res.redirect(SSO_ERROR_REDIRECT); + } + }; + + /** + * IdP redirect target: validate state (CSRF) against the transaction cookie, + * exchange the code with PKCE, let openid-client validate the ID token + * (signature/iss/aud/exp/nonce), then issue the SAME local session as a + * password login and redirect to a vetted same-site path. + */ + callback = async (req: Request, res: Response) => { + const providerId = req.params.provider as string; + const config = getProviderConfig(providerId); + if (!config) { + return HttpResponser.errorJson(res, { message: 'Unknown provider' }, 404); + } + + // Single-use: read then immediately clear the transaction cookie. + const tx = readTransaction(req); + clearTransactionCookie(res); + + // Mix-up defense: the transaction must exist and belong to the SAME + // provider that started the flow. + if (!tx || tx.provider !== providerId) { + return res.redirect(SSO_ERROR_REDIRECT); + } + + const requestId = randomUUID(); + try { + const client = await getClient(providerId); + const params = client.callbackParams(req); + // openid-client enforces state match, nonce match, ID-token signature + // (via JWKS), iss, aud and exp. A bad/replayed/expired token throws. + const tokenSet = await client.callback(config.redirectUri, params, { + state: tx.state, + nonce: tx.nonce, + code_verifier: tx.codeVerifier, + }); + const claims = tokenSet.claims(); + + const email = typeof claims.email === 'string' ? claims.email : ''; + // Some IdPs send email_verified as a string; accept both forms. + const ev = claims.email_verified as boolean | string | undefined; + const emailVerified = ev === true || ev === 'true'; + const suggestedPermissions = mapGroupsToPermissions( + claims[config.groupsClaim], + config.permissionMap, + ); + + const user = await ApiClient.resolveFederatedUser( + { + provider: providerId, + subject: claims.sub, + email, + emailVerified, + suggestedPermissions, + }, + requestId, + ); + + await respondWithTokens( + res, + { id: user.id, email: user.email, permissions: user.permissions }, + { issueRefreshCookie: true, requestId }, + ); + return res.redirect(safeReturnTo(tx.returnTo)); + } catch { + // Never reflect the IdP error_description (XSS / info-leak); generic only. + return res.redirect(SSO_ERROR_REDIRECT); + } + }; +} + +const ssoController = new SsoController(); +export default ssoController; diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index 3a11763..6726911 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -5,6 +5,7 @@ import express from 'express'; import helmet from 'helmet'; import api from './routes'; +import { validateSsoConfig } from './sso/provider-registry'; const parseOrigins = (raw?: string): string[] => (raw ?? '') @@ -22,6 +23,9 @@ class Main { } start() { + // Fail-fast: a declared-but-misconfigured SSO provider must stop boot, not + // surface at runtime. No providers configured ⇒ no-op (SSO disabled). + validateSsoConfig(); this.#config(); this.#setRoutes(); this.#app.listen(this.#port, '0.0.0.0', () => { diff --git a/apps/gateway/src/routes/auth.routes.ts b/apps/gateway/src/routes/auth.routes.ts index a68bb44..686ed7a 100644 --- a/apps/gateway/src/routes/auth.routes.ts +++ b/apps/gateway/src/routes/auth.routes.ts @@ -1,5 +1,6 @@ import authController from '@gateway/controllers/auth.controller'; import { authRateLimiter, loginRateLimiter } from '@gateway/middleware'; +import ssoRouter from '@gateway/routes/sso.routes'; import { Router } from 'express'; const authRouter = Router(); @@ -10,4 +11,7 @@ authRouter.use(authRateLimiter); authRouter.post('/login', loginRateLimiter, authController.login); authRouter.post('/logout', authController.logout); +// OIDC SSO handshake + public provider list (inherits authRateLimiter). +authRouter.use('/sso', ssoRouter); + export default authRouter; diff --git a/apps/gateway/src/routes/sso.routes.ts b/apps/gateway/src/routes/sso.routes.ts new file mode 100644 index 0000000..3dbcbb6 --- /dev/null +++ b/apps/gateway/src/routes/sso.routes.ts @@ -0,0 +1,14 @@ +import ssoController from '@gateway/controllers/sso.controller'; +import { Router } from 'express'; + +const ssoRouter = Router(); + +// Public list for the SPA login screen. +ssoRouter.get('/providers', ssoController.providers); + +// OIDC Authorization Code + PKCE handshake. `:provider` is validated against +// the registry inside the controller (no arbitrary providers). +ssoRouter.get('/:provider/login', ssoController.login); +ssoRouter.get('/:provider/callback', ssoController.callback); + +export default ssoRouter; diff --git a/apps/gateway/src/sso/permission-mapper.spec.ts b/apps/gateway/src/sso/permission-mapper.spec.ts new file mode 100644 index 0000000..dec3cde --- /dev/null +++ b/apps/gateway/src/sso/permission-mapper.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { Permission } from '@dto'; +import { mapGroupsToPermissions } from './permission-mapper'; + +const map = [ + { + claim: 'admins', + permissions: [Permission.ADMIN, Permission.WRITE_SOME_ENTITY], + }, + { claim: 'viewers', permissions: [Permission.READ_SOME_ENTITY] }, +]; + +describe('mapGroupsToPermissions', () => { + it('unions permissions for every matched group claim', () => { + expect(mapGroupsToPermissions(['admins', 'viewers'], map).sort()).toEqual( + [ + Permission.ADMIN, + Permission.READ_SOME_ENTITY, + Permission.WRITE_SOME_ENTITY, + ].sort(), + ); + }); + + it('accepts a single string claim', () => { + expect(mapGroupsToPermissions('viewers', map)).toEqual([ + Permission.READ_SOME_ENTITY, + ]); + }); + + it('returns empty for unmatched, missing, or non-string groups', () => { + expect(mapGroupsToPermissions(['unknown'], map)).toEqual([]); + expect(mapGroupsToPermissions(undefined, map)).toEqual([]); + expect(mapGroupsToPermissions([123, null], map)).toEqual([]); + expect(mapGroupsToPermissions(['admins'], [])).toEqual([]); + }); +}); diff --git a/apps/gateway/src/sso/permission-mapper.ts b/apps/gateway/src/sso/permission-mapper.ts new file mode 100644 index 0000000..b28d451 --- /dev/null +++ b/apps/gateway/src/sso/permission-mapper.ts @@ -0,0 +1,26 @@ +import { ClaimPermissionMapping, Permission } from '@dto'; + +/** + * Maps the IdP group/role claim to local permissions using the provider's + * configured `permissionMap`. Only claims VALIDATED inside the ID token must + * be passed here. The result is a SUGGESTION — the api floors it (and always + * strips ADMIN on provisioning), so a manipulated claim cannot escalate. + */ +export const mapGroupsToPermissions = ( + groupsClaim: unknown, + permissionMap: ClaimPermissionMapping[], +): Permission[] => { + const groups: string[] = Array.isArray(groupsClaim) + ? groupsClaim.filter((g): g is string => typeof g === 'string') + : typeof groupsClaim === 'string' + ? [groupsClaim] + : []; + + const permissions = new Set(); + for (const mapping of permissionMap) { + if (groups.includes(mapping.claim)) { + mapping.permissions.forEach((p) => permissions.add(p)); + } + } + return Array.from(permissions); +}; diff --git a/apps/gateway/src/sso/sso-transaction.service.spec.ts b/apps/gateway/src/sso/sso-transaction.service.spec.ts new file mode 100644 index 0000000..4ecc9b1 --- /dev/null +++ b/apps/gateway/src/sso/sso-transaction.service.spec.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearTransactionCookie, + readTransaction, + safeReturnTo, + setTransactionCookie, + SsoTransaction, +} from './sso-transaction.service'; + +const tx: SsoTransaction = { + provider: 'okta', + state: 'STATE', + nonce: 'NONCE', + codeVerifier: 'VERIFIER', + returnTo: '/dashboard', +}; + +const mkRes = () => { + const captured: { token?: string; options?: Record } = {}; + const res = { + cookie: vi.fn( + (_name: string, token: string, options: Record) => { + captured.token = token; + captured.options = options; + }, + ), + clearCookie: vi.fn(), + }; + return { res: res as never, captured }; +}; + +describe('sso-transaction.service', () => { + const saved = process.env.SSO_STATE_SECRET; + beforeEach(() => (process.env.SSO_STATE_SECRET = 'test-secret')); + afterEach(() => { + process.env.SSO_STATE_SECRET = saved; + vi.restoreAllMocks(); + }); + + it('round-trips a signed transaction through an HttpOnly, Lax cookie', () => { + const { res, captured } = mkRes(); + setTransactionCookie(res, tx); + + expect(captured.options).toMatchObject({ httpOnly: true, sameSite: 'lax' }); + expect(captured.token).toBeTruthy(); + + const req = { cookies: { sso_tx: captured.token } } as never; + expect(readTransaction(req)).toEqual(tx); + }); + + it('returns null for a tampered token', () => { + const { res, captured } = mkRes(); + setTransactionCookie(res, tx); + const tampered = `${captured.token}tamper`; + const req = { cookies: { sso_tx: tampered } } as never; + expect(readTransaction(req)).toBeNull(); + }); + + it('returns null for a token signed with a different secret', () => { + const { res, captured } = mkRes(); + setTransactionCookie(res, tx); + process.env.SSO_STATE_SECRET = 'other-secret'; + const req = { cookies: { sso_tx: captured.token } } as never; + expect(readTransaction(req)).toBeNull(); + }); + + it('returns null when no cookie is present', () => { + expect(readTransaction({ cookies: {} } as never)).toBeNull(); + }); + + it('clears the cookie with matching attributes', () => { + const { res } = mkRes(); + clearTransactionCookie(res); + expect( + (res as unknown as { clearCookie: ReturnType }).clearCookie, + ).toHaveBeenCalledWith( + 'sso_tx', + expect.objectContaining({ httpOnly: true, sameSite: 'lax' }), + ); + }); + + describe('safeReturnTo (open-redirect defense)', () => { + it('accepts a same-site absolute path', () => { + expect(safeReturnTo('/dashboard')).toBe('/dashboard'); + expect(safeReturnTo('/a/b?c=1')).toBe('/a/b?c=1'); + }); + + it('rejects absolute, protocol-relative, backslash and scheme URLs', () => { + expect(safeReturnTo('https://evil.com')).toBe('/'); + expect(safeReturnTo('//evil.com')).toBe('/'); + expect(safeReturnTo('/\\evil.com')).toBe('/'); + expect(safeReturnTo('javascript:alert(1)')).toBe('/'); + }); + + it('rejects non-strings and control characters', () => { + expect(safeReturnTo(undefined)).toBe('/'); + expect(safeReturnTo(42)).toBe('/'); + expect(safeReturnTo('/a\nb')).toBe('/'); + }); + }); +}); diff --git a/apps/gateway/src/sso/sso-transaction.service.ts b/apps/gateway/src/sso/sso-transaction.service.ts new file mode 100644 index 0000000..87f7726 --- /dev/null +++ b/apps/gateway/src/sso/sso-transaction.service.ts @@ -0,0 +1,108 @@ +import type { CookieOptions, Request, Response } from 'express'; +import jwt, { JwtPayload } from 'jsonwebtoken'; + +/** + * Short-lived, integrity-protected OIDC login transaction. It binds the + * browser that STARTED the flow to the callback, carrying the anti-CSRF + * `state`, the anti-replay `nonce`, the PKCE `codeVerifier` and the + * post-login `returnTo`. + * + * It is stored as a signed JWT inside an HttpOnly cookie (not server memory) + * so it is tamper-proof, stateless across gateway instances, and unreadable by + * JS. SameSite=Lax is REQUIRED: the cookie must survive the cross-site + * top-level redirect back from the IdP (Strict would drop it). + */ +export interface SsoTransaction { + provider: string; + state: string; + nonce: string; + codeVerifier: string; + returnTo: string; +} + +const TX_COOKIE = 'sso_tx'; +const TX_TTL_SECONDS = 5 * 60; // 5 minutes — the handshake is short. +const TX_TYP = 'sso_tx'; + +// Reuse the refresh secret if a dedicated one is not provided. The transaction +// is short-lived and single-use, so this is acceptable; set SSO_STATE_SECRET in +// production to isolate it. +const secret = (): string => + process.env.SSO_STATE_SECRET ?? process.env.JWT_REFRESH_SECRET ?? ''; + +const cookieOptions = (maxAgeMs?: number): CookieOptions => { + const options: CookieOptions = { + httpOnly: true, + path: '/', + // Lax (not Strict): the IdP redirect is a cross-site top-level GET; Strict + // would prevent the browser from sending the transaction cookie back. + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }; + if (maxAgeMs !== undefined) options.maxAge = maxAgeMs; + return options; +}; + +interface SsoTransactionPayload extends JwtPayload, SsoTransaction { + typ: typeof TX_TYP; +} + +export const setTransactionCookie = ( + res: Response, + tx: SsoTransaction, +): void => { + if (!secret()) throw new Error('SSO state secret is not configured'); + const token = jwt.sign({ ...tx, typ: TX_TYP }, secret(), { + algorithm: 'HS256', + expiresIn: TX_TTL_SECONDS, + }); + res.cookie(TX_COOKIE, token, cookieOptions(TX_TTL_SECONDS * 1000)); +}; + +/** Reads and verifies the transaction cookie. Returns null if absent/invalid. */ +export const readTransaction = (req: Request): SsoTransaction | null => { + const token = req.cookies?.[TX_COOKIE]; + if (!token || !secret()) return null; + try { + const decoded = jwt.verify(token, secret(), { + algorithms: ['HS256'], + }) as SsoTransactionPayload; + if (decoded.typ !== TX_TYP) return null; + return { + provider: decoded.provider, + state: decoded.state, + nonce: decoded.nonce, + codeVerifier: decoded.codeVerifier, + returnTo: decoded.returnTo, + }; + } catch { + return null; + } +}; + +/** Clears the transaction cookie — the handshake is single-use. */ +export const clearTransactionCookie = (res: Response): void => { + res.clearCookie(TX_COOKIE, cookieOptions()); +}; + +const hasControlChars = (value: string): boolean => { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +}; + +/** + * Open-redirect defense: only allow a same-site absolute PATH as the + * post-login destination. Rejects absolute URLs, protocol-relative `//evil`, + * back-slash tricks, `javascript:` and control characters → falls back to '/'. + */ +export const safeReturnTo = (raw: unknown): string => { + const fallback = '/'; + if (typeof raw !== 'string' || raw.length === 0) return fallback; + if (!raw.startsWith('/')) return fallback; // must be a relative path + if (raw.startsWith('//') || raw.startsWith('/\\')) return fallback; // not protocol-relative + if (hasControlChars(raw)) return fallback; + return raw; +}; From 977e0e2391f22ddc1c42c4ee2fefa58ee1f9d295 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 16:56:01 +0200 Subject: [PATCH 06/14] feat(gateway): RP-initiated federated logout (T-29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__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 --- .../src/controllers/auth.controller.ts | 3 + .../src/controllers/sso.controller.spec.ts | 92 ++++++++++++++++++- .../gateway/src/controllers/sso.controller.ts | 60 +++++++++++- apps/gateway/src/routes/sso.routes.ts | 4 + apps/gateway/src/sso/provider-registry.ts | 4 + .../src/sso/sso-logout.service.spec.ts | 61 ++++++++++++ apps/gateway/src/sso/sso-logout.service.ts | 67 ++++++++++++++ 7 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 apps/gateway/src/sso/sso-logout.service.spec.ts create mode 100644 apps/gateway/src/sso/sso-logout.service.ts diff --git a/apps/gateway/src/controllers/auth.controller.ts b/apps/gateway/src/controllers/auth.controller.ts index 042a18a..47136df 100644 --- a/apps/gateway/src/controllers/auth.controller.ts +++ b/apps/gateway/src/controllers/auth.controller.ts @@ -5,6 +5,7 @@ import { respondWithTokens, revokeCurrentRefreshFamily, } from '@gateway/middleware/auth.middleware'; +import { clearLogoutHintCookie } from '@gateway/sso/sso-logout.service'; import { randomUUID } from 'crypto'; import type { Request, Response } from 'express'; @@ -56,9 +57,11 @@ class AuthController { try { await revokeCurrentRefreshFamily(req, requestId); clearRefreshCookie(res); + clearLogoutHintCookie(res); return HttpResponser.successEmpty(res); } catch (err) { clearRefreshCookie(res); + clearLogoutHintCookie(res); return HttpResponser.errorJson(res, err as Error); } }; diff --git a/apps/gateway/src/controllers/sso.controller.spec.ts b/apps/gateway/src/controllers/sso.controller.spec.ts index d04398d..49c9d3f 100644 --- a/apps/gateway/src/controllers/sso.controller.spec.ts +++ b/apps/gateway/src/controllers/sso.controller.spec.ts @@ -11,6 +11,13 @@ vi.mock('@gateway/clients/api.client', () => ({ })); vi.mock('@gateway/middleware/auth.middleware', () => ({ respondWithTokens: vi.fn(), + clearRefreshCookie: vi.fn(), + revokeCurrentRefreshFamily: vi.fn(), +})); +vi.mock('@gateway/sso/sso-logout.service', () => ({ + setLogoutHintCookie: vi.fn(), + clearLogoutHintCookie: vi.fn(), + readLogoutHint: vi.fn(), })); vi.mock('@gateway/sso/sso-transaction.service', () => ({ setTransactionCookie: vi.fn(), @@ -34,12 +41,20 @@ import { } from '@gateway/sso/provider-registry'; import { getClient } from '@gateway/sso/discovery'; import { ApiClient } from '@gateway/clients/api.client'; -import { respondWithTokens } from '@gateway/middleware/auth.middleware'; +import { + clearRefreshCookie, + respondWithTokens, + revokeCurrentRefreshFamily, +} from '@gateway/middleware/auth.middleware'; import { clearTransactionCookie, readTransaction, setTransactionCookie, } from '@gateway/sso/sso-transaction.service'; +import { + readLogoutHint, + setLogoutHintCookie, +} from '@gateway/sso/sso-logout.service'; import ssoController from './sso.controller'; const config = { @@ -71,6 +86,10 @@ const client = { authorizationUrl: vi.fn(() => 'https://example.okta.com/authorize?x=1'), callbackParams: vi.fn(() => ({ code: 'CODE', state: 'STATE' })), callback: vi.fn(), + endSessionUrl: vi.fn(() => 'https://example.okta.com/logout?id_token_hint=x'), + issuer: { + metadata: { end_session_endpoint: 'https://example.okta.com/logout' }, + }, }; describe('SsoController', () => { @@ -246,5 +265,76 @@ describe('SsoController', () => { ); expect(res.redirect).toHaveBeenCalledWith('/dashboard'); }); + + it('stores a logout hint when the IdP returns an id_token', async () => { + vi.mocked(getProviderConfig).mockReturnValue(config as never); + vi.mocked(readTransaction).mockReturnValue(goodTx); + client.callback.mockResolvedValueOnce({ + id_token: 'header.payload.sig', + claims: () => ({ + sub: 'okta|1', + email: 'alice@corp.com', + email_verified: true, + groups: [], + }), + }); + vi.mocked(ApiClient.resolveFederatedUser).mockResolvedValue({ + id: 1, + email: 'alice@corp.com', + permissions: [Permission.READ_SOME_ENTITY], + }); + const res = mkRes(); + await ssoController.callback( + { params: { provider: 'okta' }, query: {}, cookies: {} } as never, + res, + ); + expect(setLogoutHintCookie).toHaveBeenCalledWith(res, { + provider: 'okta', + idToken: 'header.payload.sig', + }); + }); + }); + + describe('logout', () => { + it('always revokes the local family and clears cookies', async () => { + vi.mocked(readLogoutHint).mockReturnValue(null); + const res = mkRes(); + await ssoController.logout({ query: {}, cookies: {} } as never, res); + expect(revokeCurrentRefreshFamily).toHaveBeenCalled(); + expect(clearRefreshCookie).toHaveBeenCalledWith(res); + expect(res.redirect).toHaveBeenCalledWith('/'); + }); + + it('redirects to the IdP end_session endpoint for a federated session', async () => { + vi.mocked(readLogoutHint).mockReturnValue({ + provider: 'okta', + idToken: 'header.payload.sig', + }); + vi.mocked(getProviderConfig).mockReturnValue(config as never); + const res = mkRes(); + await ssoController.logout({ query: {}, cookies: {} } as never, res); + expect(client.endSessionUrl).toHaveBeenCalledWith( + expect.objectContaining({ id_token_hint: 'header.payload.sig' }), + ); + expect(res.redirect).toHaveBeenCalledWith( + 'https://example.okta.com/logout?id_token_hint=x', + ); + }); + + it('falls back to a local redirect when the IdP lookup fails', async () => { + vi.mocked(readLogoutHint).mockReturnValue({ + provider: 'okta', + idToken: 'tok', + }); + vi.mocked(getProviderConfig).mockReturnValue(config as never); + vi.mocked(getClient).mockRejectedValueOnce(new Error('idp down')); + const res = mkRes(); + await ssoController.logout( + { query: { returnTo: '/bye' }, cookies: {} } as never, + res, + ); + expect(clearRefreshCookie).toHaveBeenCalledWith(res); + expect(res.redirect).toHaveBeenCalledWith('/bye'); + }); }); }); diff --git a/apps/gateway/src/controllers/sso.controller.ts b/apps/gateway/src/controllers/sso.controller.ts index 338e03f..c95552a 100644 --- a/apps/gateway/src/controllers/sso.controller.ts +++ b/apps/gateway/src/controllers/sso.controller.ts @@ -1,8 +1,17 @@ import HttpResponser from '@gateway/adapters/http/http.responser'; import { ApiClient } from '@gateway/clients/api.client'; -import { respondWithTokens } from '@gateway/middleware/auth.middleware'; +import { + clearRefreshCookie, + respondWithTokens, + revokeCurrentRefreshFamily, +} from '@gateway/middleware/auth.middleware'; import { getClient } from '@gateway/sso/discovery'; import { mapGroupsToPermissions } from '@gateway/sso/permission-mapper'; +import { + clearLogoutHintCookie, + readLogoutHint, + setLogoutHintCookie, +} from '@gateway/sso/sso-logout.service'; import { getProviderConfig, listPublicProviders, @@ -128,12 +137,61 @@ class SsoController { { id: user.id, email: user.email, permissions: user.permissions }, { issueRefreshCookie: true, requestId }, ); + // Keep a best-effort hint so logout can also end the IdP session. + if (typeof tokenSet.id_token === 'string') { + setLogoutHintCookie(res, { + provider: providerId, + idToken: tokenSet.id_token, + }); + } return res.redirect(safeReturnTo(tx.returnTo)); } catch { // Never reflect the IdP error_description (XSS / info-leak); generic only. return res.redirect(SSO_ERROR_REDIRECT); } }; + + /** + * RP-initiated logout. Always revokes the local refresh family and clears + * cookies FIRST (so a down IdP can never keep the local session alive), then + * — if the session is federated and the IdP supports end_session — redirects + * the browser to the provider to terminate the IdP session too. Best-effort: + * any IdP failure falls back to a same-site redirect. + */ + logout = async (req: Request, res: Response) => { + const requestId = randomUUID(); + try { + await revokeCurrentRefreshFamily(req, requestId); + } catch { + /* best-effort: local cookie is cleared regardless */ + } + clearRefreshCookie(res); + + const hint = readLogoutHint(req); + clearLogoutHintCookie(res); + const fallback = safeReturnTo(req.query.returnTo); + + if (hint) { + const config = getProviderConfig(hint.provider); + if (config) { + try { + const client = await getClient(hint.provider); + if (client.issuer.metadata.end_session_endpoint) { + const endSessionUrl = client.endSessionUrl({ + id_token_hint: hint.idToken, + ...(config.postLogoutRedirectUri + ? { post_logout_redirect_uri: config.postLogoutRedirectUri } + : {}), + }); + return res.redirect(endSessionUrl); + } + } catch { + /* IdP unreachable / no end_session — fall through to local redirect */ + } + } + } + return res.redirect(fallback); + }; } const ssoController = new SsoController(); diff --git a/apps/gateway/src/routes/sso.routes.ts b/apps/gateway/src/routes/sso.routes.ts index 3dbcbb6..a51ecc1 100644 --- a/apps/gateway/src/routes/sso.routes.ts +++ b/apps/gateway/src/routes/sso.routes.ts @@ -6,6 +6,10 @@ const ssoRouter = Router(); // Public list for the SPA login screen. ssoRouter.get('/providers', ssoController.providers); +// RP-initiated logout (revokes the local session, then ends the IdP session +// when federated). GET so the SPA can navigate to it (top-level redirect). +ssoRouter.get('/logout', ssoController.logout); + // OIDC Authorization Code + PKCE handshake. `:provider` is validated against // the registry inside the controller (no arbitrary providers). ssoRouter.get('/:provider/login', ssoController.login); diff --git a/apps/gateway/src/sso/provider-registry.ts b/apps/gateway/src/sso/provider-registry.ts index 749834a..3eb5f5a 100644 --- a/apps/gateway/src/sso/provider-registry.ts +++ b/apps/gateway/src/sso/provider-registry.ts @@ -14,6 +14,8 @@ export interface SsoProviderConfig { clientId: string; clientSecret: string; redirectUri: string; + /** Optional RP-initiated-logout return URL (registered at the IdP). */ + postLogoutRedirectUri?: string; scopes: string; groupsClaim: string; permissionMap: ClaimPermissionMapping[]; @@ -142,6 +144,8 @@ export const buildRegistryFromEnv = ( clientId: required(env, id, rawName, 'CLIENT_ID'), clientSecret: required(env, id, rawName, 'CLIENT_SECRET'), redirectUri: required(env, id, rawName, 'REDIRECT_URI'), + postLogoutRedirectUri: + env[`SSO_${rawName}_POST_LOGOUT_REDIRECT_URI`]?.trim() || undefined, scopes: env[`SSO_${rawName}_SCOPES`]?.trim() || 'openid profile email', groupsClaim: env[`SSO_${rawName}_GROUPS_CLAIM`]?.trim() || 'groups', permissionMap: parsePermissionMap( diff --git a/apps/gateway/src/sso/sso-logout.service.spec.ts b/apps/gateway/src/sso/sso-logout.service.spec.ts new file mode 100644 index 0000000..f077080 --- /dev/null +++ b/apps/gateway/src/sso/sso-logout.service.spec.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearLogoutHintCookie, + readLogoutHint, + setLogoutHintCookie, +} from './sso-logout.service'; + +const hint = { provider: 'okta', idToken: 'header.payload.sig' }; + +const mkRes = () => { + const captured: { token?: string; options?: Record } = {}; + const res = { + cookie: vi.fn( + (_n: string, token: string, options: Record) => { + captured.token = token; + captured.options = options; + }, + ), + clearCookie: vi.fn(), + }; + return { res: res as never, captured }; +}; + +describe('sso-logout.service', () => { + const saved = process.env.SSO_STATE_SECRET; + beforeEach(() => (process.env.SSO_STATE_SECRET = 'test-secret')); + afterEach(() => { + process.env.SSO_STATE_SECRET = saved; + vi.restoreAllMocks(); + }); + + it('round-trips the logout hint through an HttpOnly cookie', () => { + const { res, captured } = mkRes(); + setLogoutHintCookie(res, hint); + expect(captured.options).toMatchObject({ httpOnly: true, sameSite: 'lax' }); + const req = { cookies: { sso_logout: captured.token } } as never; + expect(readLogoutHint(req)).toEqual(hint); + }); + + it('returns null for a tampered or absent hint', () => { + const { res, captured } = mkRes(); + setLogoutHintCookie(res, hint); + expect( + readLogoutHint({ + cookies: { sso_logout: `${captured.token}x` }, + } as never), + ).toBeNull(); + expect(readLogoutHint({ cookies: {} } as never)).toBeNull(); + }); + + it('clears the cookie', () => { + const { res } = mkRes(); + clearLogoutHintCookie(res); + expect( + (res as unknown as { clearCookie: ReturnType }).clearCookie, + ).toHaveBeenCalledWith( + 'sso_logout', + expect.objectContaining({ httpOnly: true }), + ); + }); +}); diff --git a/apps/gateway/src/sso/sso-logout.service.ts b/apps/gateway/src/sso/sso-logout.service.ts new file mode 100644 index 0000000..b8e63e8 --- /dev/null +++ b/apps/gateway/src/sso/sso-logout.service.ts @@ -0,0 +1,67 @@ +import type { CookieOptions, Request, Response } from 'express'; +import jwt, { JwtPayload } from 'jsonwebtoken'; + +/** + * Minimal, integrity-protected hint needed to drive RP-initiated logout + * (OIDC end_session): which provider issued the session and the `id_token` + * to pass as `id_token_hint`. Stored in a signed HttpOnly cookie — the ID + * token is an already-consumed assertion (not a credential like the refresh + * token), is never exposed to JS (HttpOnly), and the hint is best-effort: if + * it is missing/expired, logout still revokes the local session. + */ +export interface SsoLogoutHint { + provider: string; + idToken: string; +} + +const COOKIE = 'sso_logout'; +const TYP = 'sso_logout'; +const TTL_SECONDS = 8 * 60 * 60; // align with the default (non-remember) session + +const secret = (): string => + process.env.SSO_STATE_SECRET ?? process.env.JWT_REFRESH_SECRET ?? ''; + +const cookieOptions = (maxAgeMs?: number): CookieOptions => { + const options: CookieOptions = { + httpOnly: true, + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }; + if (maxAgeMs !== undefined) options.maxAge = maxAgeMs; + return options; +}; + +interface SsoLogoutPayload extends JwtPayload, SsoLogoutHint { + typ: typeof TYP; +} + +export const setLogoutHintCookie = ( + res: Response, + hint: SsoLogoutHint, +): void => { + if (!secret()) return; // best-effort; without a secret we simply skip the hint + const token = jwt.sign({ ...hint, typ: TYP }, secret(), { + algorithm: 'HS256', + expiresIn: TTL_SECONDS, + }); + res.cookie(COOKIE, token, cookieOptions(TTL_SECONDS * 1000)); +}; + +export const readLogoutHint = (req: Request): SsoLogoutHint | null => { + const token = req.cookies?.[COOKIE]; + if (!token || !secret()) return null; + try { + const decoded = jwt.verify(token, secret(), { + algorithms: ['HS256'], + }) as SsoLogoutPayload; + if (decoded.typ !== TYP) return null; + return { provider: decoded.provider, idToken: decoded.idToken }; + } catch { + return null; + } +}; + +export const clearLogoutHintCookie = (res: Response): void => { + res.clearCookie(COOKIE, cookieOptions()); +}; From 9c59fd66b2b5dec7201d7ca239bbec5f83044d9c Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 17:06:53 +0200 Subject: [PATCH 07/14] feat(front): SSO login buttons + callback landing (T-30) 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 --- apps/front/src/app/app.routes.ts | 7 ++ .../libs/auth/services/sso.service.spec.ts | 58 ++++++++++++++ .../src/app/libs/auth/services/sso.service.ts | 48 +++++++++++ .../auth-callback.component.html | 4 + .../auth-callback.component.spec.ts | 41 ++++++++++ .../auth-callback/auth-callback.component.ts | 34 ++++++++ .../src/app/pages/login/login.component.html | 19 +++++ .../app/pages/login/login.component.spec.ts | 79 +++++++++++++++++++ .../src/app/pages/login/login.component.ts | 24 +++++- apps/front/src/assets/i18n/ca.json | 5 ++ apps/front/src/assets/i18n/en.json | 5 ++ apps/front/src/assets/i18n/es.json | 5 ++ 12 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 apps/front/src/app/libs/auth/services/sso.service.spec.ts create mode 100644 apps/front/src/app/libs/auth/services/sso.service.ts create mode 100644 apps/front/src/app/pages/auth-callback/auth-callback.component.html create mode 100644 apps/front/src/app/pages/auth-callback/auth-callback.component.spec.ts create mode 100644 apps/front/src/app/pages/auth-callback/auth-callback.component.ts create mode 100644 apps/front/src/app/pages/login/login.component.spec.ts diff --git a/apps/front/src/app/app.routes.ts b/apps/front/src/app/app.routes.ts index b96c6a3..3a57502 100644 --- a/apps/front/src/app/app.routes.ts +++ b/apps/front/src/app/app.routes.ts @@ -10,6 +10,13 @@ export const appRoutes: Route[] = [ path: 'login', loadComponent: () => import('./pages/login/login.component'), }, + // SSO landing route: the gateway redirects here after a successful federated + // callback; the component bootstraps the session and navigates home. + { + path: 'auth/callback', + loadComponent: () => + import('./pages/auth-callback/auth-callback.component'), + }, // Example PROTECTED route — the guard redirects anonymous users to /login. // Use canActivateWithPermission(...) from auth-permission.guard.ts for routes // that also require a specific permission. Guards are UX only; the backend is 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 new file mode 100644 index 0000000..661c6ee --- /dev/null +++ b/apps/front/src/app/libs/auth/services/sso.service.spec.ts @@ -0,0 +1,58 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { firstValueFrom } from 'rxjs'; +import { SsoService, SSO_CALLBACK_PATH } from './sso.service'; +import { AUTH_CONFIGURATION } from '../auth.constants'; + +describe('SsoService', () => { + let service: SsoService; + let httpMock: HttpTestingController; + const cfg = { + idpServer: 'http://localhost:3200/auth', + pingUrl: 'http://localhost:3200/health/secure', + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [SsoService, { provide: AUTH_CONFIGURATION, useValue: cfg }], + }); + service = TestBed.inject(SsoService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('fetches the public provider list with credentials', async () => { + const promise = firstValueFrom(service.getProviders()); + const req = httpMock.expectOne('http://localhost:3200/auth/sso/providers'); + expect(req.request.method).toBe('GET'); + expect(req.request.withCredentials).toBe(true); + req.flush([{ id: 'okta', displayName: 'Okta' }]); + expect(await promise).toEqual([{ id: 'okta', displayName: 'Okta' }]); + }); + + 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( + SSO_CALLBACK_PATH, + )}`, + ); + }); + + it('encodes the provider id (no path injection)', () => { + expect(service.buildLoginUrl('a/b')).toContain('/sso/a%2Fb/login'); + }); + + it('login navigates the browser to the gateway login url', () => { + const spy = vi + .spyOn(window.location, 'assign') + .mockImplementation(() => undefined); + service.login('okta'); + expect(spy).toHaveBeenCalledWith(service.buildLoginUrl('okta')); + spy.mockRestore(); + }); +}); diff --git a/apps/front/src/app/libs/auth/services/sso.service.ts b/apps/front/src/app/libs/auth/services/sso.service.ts new file mode 100644 index 0000000..b2fe15e --- /dev/null +++ b/apps/front/src/app/libs/auth/services/sso.service.ts @@ -0,0 +1,48 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { SsoProviderPublicDTO } from '@dto'; +import { Observable } from 'rxjs'; +import { AUTH_CONFIGURATION } from '../auth.constants'; +import { AuthConfig } from '../auth.interface'; + +/** Landing route the gateway redirects to after a successful SSO callback. */ +export const SSO_CALLBACK_PATH = '/auth/callback'; + +/** + * Drives the gateway-mediated OIDC login. The gateway performs the whole + * handshake; the SPA only (1) lists configured providers to render buttons and + * (2) starts the flow with a FULL-PAGE navigation (the IdP redirect is + * cross-site, so it cannot be an XHR). No tokens or secrets are handled here. + */ +@Injectable({ providedIn: 'root' }) +export class SsoService { + #http = inject(HttpClient); + #config: AuthConfig = inject(AUTH_CONFIGURATION); + + #ssoBase = (): string => `${this.#config.idpServer}/sso`; + + /** Public, secret-free provider metadata for the login buttons. */ + getProviders(): Observable { + return this.#http.get( + `${this.#ssoBase()}/providers`, + { withCredentials: true }, + ); + } + + /** + * Builds the gateway login URL. `returnTo` is a same-site path; the gateway + * re-validates it against its own allowlist, so this is defence-in-depth. + */ + buildLoginUrl( + providerId: string, + returnTo: string = SSO_CALLBACK_PATH, + ): string { + const base = `${this.#ssoBase()}/${encodeURIComponent(providerId)}/login`; + return `${base}?returnTo=${encodeURIComponent(returnTo)}`; + } + + /** Starts the flow via a top-level navigation to the gateway. */ + login(providerId: string, returnTo: string = SSO_CALLBACK_PATH): void { + window.location.assign(this.buildLoginUrl(providerId, returnTo)); + } +} diff --git a/apps/front/src/app/pages/auth-callback/auth-callback.component.html b/apps/front/src/app/pages/auth-callback/auth-callback.component.html new file mode 100644 index 0000000..a5143df --- /dev/null +++ b/apps/front/src/app/pages/auth-callback/auth-callback.component.html @@ -0,0 +1,4 @@ +
+ + {{ 'common.loading' | transloco }} +
diff --git a/apps/front/src/app/pages/auth-callback/auth-callback.component.spec.ts b/apps/front/src/app/pages/auth-callback/auth-callback.component.spec.ts new file mode 100644 index 0000000..d4e5669 --- /dev/null +++ b/apps/front/src/app/pages/auth-callback/auth-callback.component.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { TranslocoService } from '@jsverse/transloco'; +import { of } from 'rxjs'; +import AuthCallbackComponent from './auth-callback.component'; +import { AuthService } from '@front/app/libs/auth/services/auth.service'; + +describe('AuthCallbackComponent', () => { + const initialize = vi.fn(); + const navigateByUrl = vi.fn(); + + const setup = () => { + TestBed.configureTestingModule({ + imports: [AuthCallbackComponent], + providers: [ + { provide: AuthService, useValue: { initialize } }, + { provide: Router, useValue: { navigateByUrl } }, + { provide: TranslocoService, useValue: {} }, + ], + }); + return TestBed.createComponent(AuthCallbackComponent).componentInstance; + }; + + beforeEach(() => { + vi.clearAllMocks(); + TestBed.resetTestingModule(); + }); + + it('navigates home once the session bootstraps', () => { + initialize.mockReturnValue(of(true)); + setup().ngOnInit(); + expect(initialize).toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledWith('/'); + }); + + it('returns to login with a generic error flag on failure', () => { + initialize.mockReturnValue(of(false)); + setup().ngOnInit(); + expect(navigateByUrl).toHaveBeenCalledWith('/login?sso_error=1'); + }); +}); diff --git a/apps/front/src/app/pages/auth-callback/auth-callback.component.ts b/apps/front/src/app/pages/auth-callback/auth-callback.component.ts new file mode 100644 index 0000000..ee0077f --- /dev/null +++ b/apps/front/src/app/pages/auth-callback/auth-callback.component.ts @@ -0,0 +1,34 @@ +import { + ChangeDetectionStrategy, + Component, + OnInit, + inject, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { TranslocoModule } from '@jsverse/transloco'; +import { AuthService } from '@front/app/libs/auth/services/auth.service'; + +/** + * Landing route after the gateway's SSO callback set the refresh cookie and + * redirected here. It bootstraps the in-memory access token via + * `AuthService.initialize()` (which the gateway mints from the refresh cookie) + * and then navigates home. On failure it returns to the login page with a + * generic error flag — never surfacing raw IdP/query data. + */ +@Component({ + selector: 'app-auth-callback', + standalone: true, + imports: [TranslocoModule], + templateUrl: './auth-callback.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class AuthCallbackComponent implements OnInit { + readonly #auth = inject(AuthService); + readonly #router = inject(Router); + + ngOnInit() { + this.#auth.initialize().subscribe((ok) => { + this.#router.navigateByUrl(ok ? '/' : '/login?sso_error=1'); + }); + } +} diff --git a/apps/front/src/app/pages/login/login.component.html b/apps/front/src/app/pages/login/login.component.html index 65271e2..4e5d83d 100644 --- a/apps/front/src/app/pages/login/login.component.html +++ b/apps/front/src/app/pages/login/login.component.html @@ -92,6 +92,25 @@
{{ 'login.title' | transloco }}
{{ 'login.submit' | transloco }} + @if (providers().length > 0) { +
+
+ {{ 'login.sso.or' | transloco }} +
+ @for (provider of providers(); track provider.id) { + + } +
+ } diff --git a/apps/front/src/app/pages/login/login.component.spec.ts b/apps/front/src/app/pages/login/login.component.spec.ts new file mode 100644 index 0000000..83870b8 --- /dev/null +++ b/apps/front/src/app/pages/login/login.component.spec.ts @@ -0,0 +1,79 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslocoService } from '@jsverse/transloco'; +import { of, throwError } from 'rxjs'; +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'; + +describe('LoginComponent (SSO integration)', () => { + const getProviders = vi.fn(); + const ssoLogin = vi.fn(); + + const setup = (hasSsoError = false) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [LoginComponent], + providers: [ + { provide: SsoService, useValue: { getProviders, login: ssoLogin } }, + { provide: AuthService, useValue: { login: vi.fn() } }, + { + provide: Router, + useValue: { currentNavigation: () => null, navigateByUrl: vi.fn() }, + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: { + has: (k: string) => hasSsoError && k === 'sso_error', + }, + }, + }, + }, + { + provide: TranslocoService, + useValue: { translate: (k: string) => k }, + }, + ], + }); + return TestBed.createComponent(LoginComponent).componentInstance; + }; + + beforeEach(() => vi.clearAllMocks()); + + it('renders a button per configured provider', () => { + getProviders.mockReturnValue(of([{ id: 'okta', displayName: 'Okta' }])); + 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(); + 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(); + cmp.ngOnInit(); + expect(cmp.providers()).toEqual([]); + }); + + it('surfaces a generic error when the gateway flags sso_error', () => { + getProviders.mockReturnValue(of([])); + 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(); + cmp.loginWithSso('okta'); + expect(ssoLogin).toHaveBeenCalledWith('okta'); + }); +}); diff --git a/apps/front/src/app/pages/login/login.component.ts b/apps/front/src/app/pages/login/login.component.ts index 932577b..2a0ad1a 100644 --- a/apps/front/src/app/pages/login/login.component.ts +++ b/apps/front/src/app/pages/login/login.component.ts @@ -1,13 +1,15 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { Router, RouterModule } from '@angular/router'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; +import { SsoProviderPublicDTO } from '@dto'; import { Login } from '@front/app/libs/auth/auth.interface'; import { sanitizeRedirect } from '@front/app/libs/auth/safe-redirect'; import { AuthService } from '@front/app/libs/auth/services/auth.service'; +import { SsoService } from '@front/app/libs/auth/services/sso.service'; import { catchError, of, tap } from 'rxjs'; @Component({ @@ -24,9 +26,12 @@ export default class LoginComponent implements OnInit { private fb = inject(FormBuilder); private auth = inject(AuthService); private router = inject(Router); + private route = inject(ActivatedRoute); + private sso = inject(SsoService); error: string[] = []; loading = false; + readonly providers = signal([]); login = this.fb.group({ email: ['', [Validators.required, Validators.email]], @@ -39,6 +44,21 @@ export default class LoginComponent implements OnInit { const state = navigation?.extras.state as { currentRoute: string }; // Only honour safe same-origin paths — never an attacker-controlled URL. this.redirectUrl = sanitizeRedirect(state?.currentRoute); + + // Generic SSO failure flag set by the gateway (never raw IdP detail). + if (this.route.snapshot.queryParamMap.has('sso_error')) { + this.error = [this.translocoService.translate('login.sso.error')]; + } + + // Configuration-driven: render a button per configured provider, or none. + this.sso.getProviders().subscribe({ + next: (providers) => this.providers.set(providers), + error: () => this.providers.set([]), + }); + } + + loginWithSso(providerId: string) { + this.sso.login(providerId); } doLogin() { diff --git a/apps/front/src/assets/i18n/ca.json b/apps/front/src/assets/i18n/ca.json index 5d5e1e3..5f35e14 100644 --- a/apps/front/src/assets/i18n/ca.json +++ b/apps/front/src/assets/i18n/ca.json @@ -22,6 +22,11 @@ "required": "Aquest camp és obligatori", "email": "Si us plau, introdueix un correu electrònic vàlid", "invalid": "Usuari o contrasenya incorrectes" + }, + "sso": { + "or": "o continua amb", + "continueWith": "Continua amb {{provider}}", + "error": "L'inici de sessió únic ha fallat. Torna-ho a provar." } }, "unauthorized": { diff --git a/apps/front/src/assets/i18n/en.json b/apps/front/src/assets/i18n/en.json index bca4f1a..e53f7df 100644 --- a/apps/front/src/assets/i18n/en.json +++ b/apps/front/src/assets/i18n/en.json @@ -22,6 +22,11 @@ "required": "This field is required", "email": "Please enter a valid email address", "invalid": "Invalid username or password" + }, + "sso": { + "or": "or continue with", + "continueWith": "Continue with {{provider}}", + "error": "Single sign-on failed. Please try again." } }, "unauthorized": { diff --git a/apps/front/src/assets/i18n/es.json b/apps/front/src/assets/i18n/es.json index df976a0..112a56d 100644 --- a/apps/front/src/assets/i18n/es.json +++ b/apps/front/src/assets/i18n/es.json @@ -22,6 +22,11 @@ "required": "Este campo es obligatorio", "email": "Por favor, introduce un correo electrónico válido", "invalid": "Usuario o contraseña incorrectos" + }, + "sso": { + "or": "o continúa con", + "continueWith": "Continuar con {{provider}}", + "error": "El inicio de sesión único ha fallado. Inténtalo de nuevo." } }, "unauthorized": { From d1e5bfc4bb2f374535a19f4accceec3f30c0987f Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 17:10:00 +0200 Subject: [PATCH 08/14] =?UTF-8?q?docs:=20SSO/OIDC=20=E2=80=94=20SECURITY.m?= =?UTF-8?q?d,=20.env.example,=20gateway=20AGENTS.md,=20roadmap=20(T-31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/SECURITY.md: new 'Federación OIDC' section (RP flow diagram, security decisions, provider-onboarding guide with exact callback URLs). - .env.example: commented SSO__* 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 --- .env.example | 25 +++++++++++++++ README.md | 2 +- apps/gateway/AGENTS.md | 34 ++++++++++++++++---- docs/README_eng.md | 2 +- docs/SECURITY.md | 73 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 4fa7d49..5a96904 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,31 @@ INTERNAL_JWT_PUBLIC_KEY= GATEWAY_PORT=3100 API_BASE_URL=http://api:3200 +# ── SSO / OIDC (optional) ─────────────────────────────────────────────────── +# Federated login through the gateway acting as an OIDC Relying Party. Leave +# ALL of these unset to keep SSO disabled — the gateway then behaves exactly as +# today (zero regression). Declare a provider by setting _ISSUER; +# becomes the provider id (lowercased) used in /api/v1/auth/sso//login. +# Client secrets live ONLY here on the gateway — never in the api or the browser. +# A declared-but-incomplete provider fails the gateway fast at boot. +# +# SSO_OKTA_ISSUER=https://your-org.okta.com +# SSO_OKTA_CLIENT_ID=replace-me +# SSO_OKTA_CLIENT_SECRET=replace-me +# SSO_OKTA_REDIRECT_URI=https://app.example.com/api/v1/auth/sso/okta/callback +# SSO_OKTA_SCOPES=openid profile email # optional (default) +# SSO_OKTA_GROUPS_CLAIM=groups # optional +# SSO_OKTA_PERMISSION_MAP=admins:ADMIN;staff:WRITE_SOME_ENTITY,READ_SOME_ENTITY +# SSO_OKTA_POST_LOGOUT_REDIRECT_URI=https://app.example.com/login # optional +# 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. +# SSO_STATE_SECRET= +# Dev-only escape hatch to allow http/loopback issuers. NEVER enable in prod. +# SSO_ALLOW_INSECURE_ISSUERS=false + # 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/README.md b/README.md index d41d7c8..609aca2 100644 --- a/README.md +++ b/README.md @@ -750,7 +750,7 @@ En producción, aplicar el SQL manualmente sobre la DB. ### Roadmap -- [ ] SSO/OIDC en el gateway para clientes enterprise (Okta, Azure AD, Auth0) +- [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 - [ ] SCIM 2.0 para aprovisionamiento masivo - [ ] Multi-tenancy en CRUDs diff --git a/apps/gateway/AGENTS.md b/apps/gateway/AGENTS.md index db78355..772f37c 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -19,6 +19,18 @@ the internal API on the same private network. 3. **Internal-token minting**: before forwarding, it signs a short-lived EdDSA token (`signUserContext` from `@internal-auth`) carrying `{ userId, permissions, requestId }` and attaches it as `INTERNAL_AUTH_HEADER`, plus `INTERNAL_REQUEST_ID_HEADER`. +4. **OIDC Relying Party (SSO)** (`src/sso/*`, `src/controllers/sso.controller.ts`, + `src/routes/sso.routes.ts`): optional federated login (Okta/Azure AD/Auth0). + Routes under `/api/v1/auth/sso`: `GET /providers` (public metadata), + `GET /:provider/login`, `GET /:provider/callback`, `GET /logout`. The gateway + runs the whole Authorization Code + PKCE handshake, validates the ID token + (via `openid-client`), resolves/provisions the local user through the api + (`/internal/federated/resolve`, scope `FEDERATED_IDENTITY`) and then issues the + **same** local session via `respondWithTokens` — the api never talks to the IdP. + Uses **`openid-client` v5** (CJS); do NOT upgrade to v6 (ESM-only) without + migrating the monorepo to `moduleResolution: nodenext`. Full design & threat + model: `docs/SECURITY.md` → "Federación OIDC". With zero providers configured + the gateway behaves exactly as before. ## Request flow (must stay intact) @@ -54,13 +66,21 @@ browser ──/api/*──▶ nginx ──proxy_pass──▶ gateway ## Env vars -| Var | Purpose | -| --------------------------- | --------------------------------------------------- | -| `GATEWAY_PORT` | Listen port (default 3100) | -| `API_BASE_URL` | Upstream API base (default `http://api:3200`) | -| `INTERNAL_JWT_PRIVATE_KEY` | EdDSA private key for signing internal tokens (PEM) | -| `CORS_ORIGIN` | Comma-separated allowed origins | -| `JWT_REFRESH_REMEMBER_DAYS` | "remember me" refresh lifetime in days (default 30) | +| Var | Purpose | +| ---------------------------- | --------------------------------------------------------------------------------------------------------- | +| `GATEWAY_PORT` | Listen port (default 3100) | +| `API_BASE_URL` | Upstream API base (default `http://api:3200`) | +| `INTERNAL_JWT_PRIVATE_KEY` | EdDSA private key for signing internal tokens (PEM) | +| `CORS_ORIGIN` | Comma-separated allowed origins | +| `JWT_REFRESH_REMEMBER_DAYS` | "remember me" refresh lifetime in days (default 30) | +| `SSO__*` | OIDC provider config (issuer/client_id/secret/redirect_uri/…); see `.env.example`. Secrets live ONLY here | +| `SSO_STATE_SECRET` | Signs the OIDC transaction & logout-hint cookies (falls back to `JWT_REFRESH_SECRET`) | +| `SSO_ALLOW_INSECURE_ISSUERS` | Dev-only: allow http/loopback issuers. Never enable in production | + +**SSO hard rule:** federated login still ends in the standard local session — the +api keeps trusting only the internal EdDSA JWT, never an IdP token. Client secrets +and ID tokens never leave the gateway; the browser only ever sees public provider +metadata and the normal access/refresh tokens. ## Testing diff --git a/docs/README_eng.md b/docs/README_eng.md index 637d62b..df52823 100644 --- a/docs/README_eng.md +++ b/docs/README_eng.md @@ -737,7 +737,7 @@ In production, apply the SQL manually against the database. ### Roadmap -- [ ] SSO/OIDC in the gateway for enterprise customers (Okta, Azure AD, Auth0) +- [x] SSO/OIDC in the gateway for enterprise customers (Okta, Azure AD, Auth0) — see [docs/SECURITY.md → Federación OIDC](SECURITY.md#federación-oidc-sso-okta--azure-ad--auth0) - [ ] SAML for legacy tenants - [ ] SCIM 2.0 for bulk provisioning - [ ] Multi-tenant CRUDs diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a8840d0..5bfe79d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -196,3 +196,76 @@ clave privada en otros servicios. siguen funcionando hasta que expiren. - Los logs incluyen `requestId` (cabecera `X-Request-Id`) para correlar trazas entre gateway y API. + +## Federación OIDC (SSO: Okta · Azure AD · Auth0) + +El gateway actúa como **Relying Party (RP) OIDC**. Hace todo el handshake y +termina convirtiendo la identidad federada en **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 SSO está desactivado y el sistema se comporta igual que hoy. + +### Flujo + +``` +browser ──/auth/sso/:p/login──▶ gateway (state+nonce+PKCE → cookie tx firmada) + │ │ 302 + ▼ ▼ + IdP ◀── authorization_endpoint ──┘ + │ login del usuario + ▼ 302 con code+state +browser ──/auth/sso/:p/callback──▶ gateway + │ valida state (cookie) + provider (mix-up) + │ canjea code con PKCE; openid-client valida + │ el ID token (firma/JWKS, iss, aud, exp, nonce) + │ mapea grupos→permisos (sugerencia) + │ ApiClient.resolveFederatedUser (scope + │ federated.identity) → usuario local + │ respondWithTokens (sesión local estándar) + ▼ 302 a returnTo (allowlist same-site) +``` + +### Decisiones de seguridad + +- **Authorization Code + PKCE (S256)** — nunca implicit flow. +- **state** anti-CSRF y **nonce** anti-replay viven en una **cookie de + transacción JWT firmada, HttpOnly, SameSite=Lax** (Lax es obligatorio para + sobrevivir el redirect cross-site del IdP), TTL 5 min, single-use. +- **Validación del ID token** delegada a `openid-client` (firma vía JWKS, `iss`, + `aud`, `exp`, `nonce`); algoritmos seguros, `alg:none` rechazado. +- **Mix-up multi-IdP**: el callback se ata al proveedor que inició el flujo. +- **Account takeover**: solo se vincula/aprovisiona con `email_verified === true`. + Usuarios existentes conservan sus permisos almacenados (los claims de grupos + son _sugerencia_, nunca autoritativos sobre una cuenta viva). +- **Escalada de privilegios**: el aprovisionamiento JIT **nunca** concede ADMIN + y aplica mínimo privilegio por defecto. Los permisos son autoritativos en el + API, no en el cliente. +- **Usuarios federados sin contraseña local** (`password = NULL`, + `auth_source = 'federated'`); `validateCredentials` rechaza el login local de + cuentas con password NULL o `auth_source != 'local'` (cierra el bypass). +- **SSRF** en discovery/JWKS: solo issuers `https`, con bloqueo de + loopback/link-local/metadata (169.254.169.254)/RFC1918. Escape hatch + `SSO_ALLOW_INSECURE_ISSUERS` solo dev. +- **Open redirect**: `returnTo` y `post_logout_redirect_uri` restringidos a + rutas same-site (se rechazan absolutas, `//`, `\\`, `javascript:`, control). +- **Sin fuga de secretos/errores**: `client_secret` solo en el gateway, nunca + logueado; los `error_description` del IdP nunca se reflejan (redirect genérico + `/login?sso_error=1`). +- **Logout federado** (RP-initiated `end_session`): siempre revoca la familia de + refresh local primero (IdP caído nunca mantiene viva la sesión), luego redirige + al `end_session_endpoint` con `id_token_hint` (en cookie firmada HttpOnly). + **Back-channel logout** queda fuera de alcance del starter (requiere endpoint + receptor de logout_token con validación de firma/`events`); el RP-initiated + cubre el caso principal sin estado de servidor adicional. + +### Alta de un proveedor + +1. Registrá la app en el IdP. El **callback URL** debe ser **exacto** (sin + comodines): `https:///api/v1/auth/sso//callback`. +2. Definí en el entorno del **gateway** (ver `.env.example`): + `SSO__ISSUER`, `_CLIENT_ID`, `_CLIENT_SECRET`, `_REDIRECT_URI` y, + opcionalmente, `_SCOPES`, `_GROUPS_CLAIM`, `_PERMISSION_MAP`, + `_POST_LOGOUT_REDIRECT_URI`, `_DISPLAY_NAME`, `_ICON_KEY`. +3. Configurá `SSO_STATE_SECRET` (secreto dedicado en producción). +4. `` (en minúsculas) es el id del proveedor. El front pinta un botón por + proveedor desde `GET /api/v1/auth/sso/providers` (solo metadatos públicos). From e7c97dbdff54747d2ba1cd2b9d2f877e5ff5510d Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 19:06:03 +0200 Subject: [PATCH 09/14] fix(gateway): drop removed keyGeneratorIpFallback rate-limit validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/gateway/src/middleware/rate-limit.middleware.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/middleware/rate-limit.middleware.ts b/apps/gateway/src/middleware/rate-limit.middleware.ts index 813970a..c92a1e5 100644 --- a/apps/gateway/src/middleware/rate-limit.middleware.ts +++ b/apps/gateway/src/middleware/rate-limit.middleware.ts @@ -27,8 +27,9 @@ export const loginRateLimiter = rateLimit({ standardHeaders: 'draft-7', legacyHeaders: false, skipSuccessfulRequests: true, - // IP comes from req.ip (trust proxy: 1 → real client behind Nginx). - validate: { keyGeneratorIpFallback: false }, + // IP comes from req.ip (trust proxy: 1 → real client behind Nginx). The + // keyGenerator always embeds the IP, so no IP-fallback validation is needed + // (the `keyGeneratorIpFallback` option was removed in express-rate-limit 7.5). keyGenerator: (req) => { const email = typeof req.body?.email === 'string' From 744622d1f1e10e3a87faad1b3735449dbf492390 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 19:07:27 +0200 Subject: [PATCH 10/14] docs: prefer package.json scripts over external commands (AGENTS.md) 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 --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9180ef1..9efd0e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,5 +194,11 @@ These are the cross-cutting invariants. Layer-specific rules live in each packag - **Soft deletes on every entity**: `deleted`, `createdAt`, `updatedAt`, `deletedAt`. - **Git**: feature branches only (`feat/*`, `fix/*`, `docs/*`, `chore/*`), never commit to `main` or `master`. +- **Always use the `package.json` scripts**, not ad-hoc external commands. Prefer + `npm run build` / `build:`, `npm test` / `test:`, `npm run lint`, + `npm run dev:*` over invoking `nx`, `vitest`, `tsc`, `prettier`, etc. directly. + 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. - When you implement or change something significant, update the relevant `AGENTS.md` in the same change so it stays accurate. From e5ad7074ca3202736e5e305cf00c786604440014 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 19:26:35 +0200 Subject: [PATCH 11/14] test(gateway): e2e SSO flow against a real mock OIDC provider (T-32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/gateway/AGENTS.md | 8 +- apps/gateway/src/sso/sso-flow.e2e.spec.ts | 158 ++++++++++++++++++++++ apps/gateway/vitest.config.ts | 2 + apps/gateway/vitest.e2e.config.ts | 24 ++++ package-lock.json | 44 ++++++ package.json | 2 + vitest.config.ts | 1 + 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 apps/gateway/src/sso/sso-flow.e2e.spec.ts create mode 100644 apps/gateway/vitest.e2e.config.ts diff --git a/apps/gateway/AGENTS.md b/apps/gateway/AGENTS.md index 772f37c..e5ac490 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -85,4 +85,10 @@ metadata and the normal access/refresh tokens. ## Testing Vitest, co-located `*.spec.ts`. Cover the auth middleware and token service — they are -the security-critical units. Run: `npx nx test gateway`. +the security-critical units. Run: `npm run test:gateway`. + +End-to-end specs (`*.e2e.spec.ts`) spin up a **real mock OIDC provider** +(`oauth2-mock-server`) and exercise the full SSO handshake (discovery, PKCE, +state, nonce, ID-token JWKS validation) plus an attack case (tampered state → +rejection). They are excluded from the unit suite (port binding / real HTTP) and +run with `npm run test:e2e`. diff --git a/apps/gateway/src/sso/sso-flow.e2e.spec.ts b/apps/gateway/src/sso/sso-flow.e2e.spec.ts new file mode 100644 index 0000000..6ecdc82 --- /dev/null +++ b/apps/gateway/src/sso/sso-flow.e2e.spec.ts @@ -0,0 +1,158 @@ +import cookieParser from 'cookie-parser'; +import express, { type Express } from 'express'; +import { OAuth2Server } from 'oauth2-mock-server'; +import request from 'supertest'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { Permission } from '@dto'; + +// The api boundary is mocked so this e2e needs no Postgres/api: it exercises the +// REAL OIDC handshake (discovery, PKCE, state, nonce, ID-token JWKS validation) +// of the gateway against a REAL mock IdP, plus the state-tamper attack case. +vi.mock('@gateway/clients/api.client', () => ({ + ApiClient: { + resolveFederatedUser: vi.fn().mockResolvedValue({ + id: 42, + email: 'alice@corp.com', + permissions: [Permission.WRITE_SOME_ENTITY], + }), + recordRefresh: vi.fn().mockResolvedValue({ recorded: true }), + }, +})); + +import { ApiClient } from '@gateway/clients/api.client'; +import authRouter from '@gateway/routes/auth.routes'; +import { resetDiscoveryCache } from '@gateway/sso/discovery'; +import { resetRegistryCache } from '@gateway/sso/provider-registry'; + +const REDIRECT_URI = 'http://localhost:3100/api/v1/auth/sso/test/callback'; + +let idp: OAuth2Server; +let app: Express; + +const buildApp = (): Express => { + const a = express(); + a.use(cookieParser()); + a.use(express.json()); + a.use('/api/v1/auth', authRouter); + return a; +}; + +// Cookie header from a Set-Cookie array (name=value pairs only). +const cookieHeader = (setCookie: string[] | undefined): string => + (setCookie ?? []).map((c) => c.split(';')[0]).join('; '); + +/** Start the flow at the gateway; return the IdP authorize URL + tx cookie. */ +const startLogin = async () => { + const res = await request(app).get( + '/api/v1/auth/sso/test/login?returnTo=/dashboard', + ); + expect(res.status).toBe(302); + const authUrl = new URL(res.headers.location); + return { authUrl, txCookie: cookieHeader(res.headers['set-cookie']) }; +}; + +describe('SSO OIDC flow (e2e against a real mock IdP)', () => { + beforeAll(async () => { + idp = new OAuth2Server(); + await idp.issuer.keys.generate('RS256'); + await idp.start(0, 'localhost'); + + // Claims the IdP asserts for the authenticated user. + idp.service.on('beforeTokenSigning', (token) => { + token.payload.email = 'alice@corp.com'; + token.payload.email_verified = true; + token.payload.groups = ['admins']; + }); + + process.env.SSO_ALLOW_INSECURE_ISSUERS = 'true'; // localhost IdP, dev only + process.env.SSO_TEST_ISSUER = idp.issuer.url as string; + process.env.SSO_TEST_CLIENT_ID = 'gateway-client'; + process.env.SSO_TEST_CLIENT_SECRET = 'gateway-secret'; + process.env.SSO_TEST_REDIRECT_URI = REDIRECT_URI; + process.env.SSO_TEST_PERMISSION_MAP = 'admins:WRITE_SOME_ENTITY'; + process.env.SSO_STATE_SECRET = 'e2e-state-secret'; + process.env.JWT_ACCESS_SECRET = 'e2e-access-secret'; + process.env.JWT_REFRESH_SECRET = 'e2e-refresh-secret'; + + resetRegistryCache(); + resetDiscoveryCache(); + app = buildApp(); + }); + + afterAll(async () => { + await idp.stop(); + }); + + beforeEach(() => vi.clearAllMocks()); + + it('happy path: login → IdP → callback issues the local session', async () => { + const { authUrl, txCookie } = await startLogin(); + + // The gateway must redirect to the IdP with PKCE S256 + state + nonce. + expect(authUrl.origin).toBe(new URL(idp.issuer.url as string).origin); + expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authUrl.searchParams.get('code_challenge')).toBeTruthy(); + expect(authUrl.searchParams.get('state')).toBeTruthy(); + expect(authUrl.searchParams.get('nonce')).toBeTruthy(); + + // Simulate the user authenticating at the IdP (auto-approve → code). + const idpRes = await fetch(authUrl.href, { redirect: 'manual' }); + const cb = new URL(idpRes.headers.get('location') as string); + const code = cb.searchParams.get('code') as string; + const state = cb.searchParams.get('state') as string; + expect(code).toBeTruthy(); + + // Back at the gateway callback with the transaction cookie. + const res = await request(app) + .get(`/api/v1/auth/sso/test/callback?code=${code}&state=${state}`) + .set('Cookie', txCookie); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/dashboard'); + // Standard local session was issued. + expect(res.headers['authorization']).toBeTruthy(); + expect(cookieHeader(res.headers['set-cookie'])).toContain('refreshToken='); + // The gateway forwarded only validated claims + mapped permissions. + expect(ApiClient.resolveFederatedUser).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'test', + email: 'alice@corp.com', + emailVerified: true, + suggestedPermissions: [Permission.WRITE_SOME_ENTITY], + }), + expect.any(String), + ); + }); + + it('attack: a tampered state is rejected — no session issued', async () => { + const { txCookie } = await startLogin(); + + const res = await request(app) + .get('/api/v1/auth/sso/test/callback?code=whatever&state=TAMPERED') + .set('Cookie', txCookie); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/login?sso_error=1'); + expect(cookieHeader(res.headers['set-cookie'])).not.toContain( + 'refreshToken=', + ); + expect(ApiClient.resolveFederatedUser).not.toHaveBeenCalled(); + }); + + it('attack: callback without the transaction cookie is rejected', async () => { + const res = await request(app).get( + '/api/v1/auth/sso/test/callback?code=x&state=y', + ); + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/login?sso_error=1'); + expect(ApiClient.resolveFederatedUser).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/vitest.config.ts b/apps/gateway/vitest.config.ts index 41eeb9f..bd38464 100644 --- a/apps/gateway/vitest.config.ts +++ b/apps/gateway/vitest.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ root: __dirname, environment: 'node', include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + // e2e specs (real mock IdP, port binding) run via `npm run test:e2e`. + exclude: ['**/node_modules/**', '**/*.e2e.{test,spec}.*'], coverage: { reportsDirectory: '../../coverage/apps/gateway', provider: 'v8', diff --git a/apps/gateway/vitest.e2e.config.ts b/apps/gateway/vitest.e2e.config.ts new file mode 100644 index 0000000..f012f20 --- /dev/null +++ b/apps/gateway/vitest.e2e.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +// Dedicated config for end-to-end specs that spin up a real mock OIDC provider +// (port binding, real HTTP). Kept out of the unit suite for determinism; run +// with `npm run test:e2e`. +export default defineConfig({ + test: { + globals: true, + root: __dirname, + environment: 'node', + include: ['src/**/*.e2e.{test,spec}.{js,mjs,cjs,ts,mts,cts}'], + }, + resolve: { + alias: { + '@dto': path.resolve(__dirname, '../../libs/rest-dto/src/index.ts'), + '@internal-auth': path.resolve( + __dirname, + '../../libs/internal-auth/src/index.ts', + ), + '@gateway': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 6463575..10f77ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,7 @@ "jsdom": "^27.4.0", "lint-staged": "^16.4.0", "nx": "22.7.0", + "oauth2-mock-server": "^8.2.3", "prettier": "^3.8.3", "react-refresh": "^0.10.0", "supertest": "^7.2.2", @@ -23195,6 +23196,49 @@ "node": ">=12" } }, + "node_modules/oauth2-mock-server": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/oauth2-mock-server/-/oauth2-mock-server-8.2.3.tgz", + "integrity": "sha512-Lgem1ZjVYG6NuzJ7J6MC2OchMkJWzwb1SFvwuGunQ1VPmQIU+/ydG3LzgBu/cnFWriYD8ZTd2CQpjHtvcVZy3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "cors": "^2.8.6", + "express": "^5.2.1", + "is-plain-obj": "^4.1.0", + "jose": "^6.2.3" + }, + "bin": { + "oauth2-mock-server": "dist/oauth2-mock-server.mjs" + }, + "engines": { + "node": "^20.19 || ^22.12 || ^24" + } + }, + "node_modules/oauth2-mock-server/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oauth2-mock-server/node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 000fbdd..24ee4d4 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "test:api": "vitest run --config apps/api/vitest.config.ts", "test:gateway": "vitest run --config apps/gateway/vitest.config.ts", "test:internal-auth": "vitest run --config libs/internal-auth/vitest.config.ts", + "test:e2e": "vitest run --config apps/gateway/vitest.e2e.config.ts", "test:coverage": "vitest run --coverage", "prepare": "husky" }, @@ -99,6 +100,7 @@ "jsdom": "^27.4.0", "lint-staged": "^16.4.0", "nx": "22.7.0", + "oauth2-mock-server": "^8.2.3", "prettier": "^3.8.3", "react-refresh": "^0.10.0", "supertest": "^7.2.2", diff --git a/vitest.config.ts b/vitest.config.ts index 4a7f299..cde9e8c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ '**/node_modules/**', '**/dist/**', '**/apps/front/**', // Frontend has its own config + '**/*.e2e.{test,spec}.*', // e2e specs run via `npm run test:e2e` ], }, resolve: { From efddc12614d9a326b60d38d4f2dad73f73301406 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 22:06:47 +0200 Subject: [PATCH 12/14] fix: regenerate package-lock with npm 10 to match CI (node 22.12.0) 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 `npm@10.9.0` so it is consistent with CI. Verified with the exact CI command `npx npm@10.9.0 ci` (passes) plus full unit suite (154), e2e (3), and gateway/api/front builds. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 215 ---------------------------------------------- 1 file changed, 215 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10f77ee..d126caa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7590,9 +7590,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7609,9 +7606,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7628,9 +7622,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7647,9 +7638,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7666,9 +7654,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7838,9 +7823,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7858,9 +7840,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7878,9 +7857,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7898,9 +7874,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7918,9 +7891,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7938,9 +7908,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7958,9 +7925,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8838,9 +8802,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8855,9 +8816,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8872,9 +8830,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8889,9 +8844,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9058,9 +9010,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9075,9 +9024,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9092,9 +9038,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9109,9 +9052,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9304,9 +9244,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9321,9 +9258,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9338,9 +9272,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9355,9 +9286,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9940,9 +9868,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9957,9 +9882,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9974,9 +9896,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9991,9 +9910,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10008,9 +9924,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10025,9 +9938,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10042,9 +9952,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10059,9 +9966,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10299,9 +10203,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10323,9 +10224,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10347,9 +10245,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10371,9 +10266,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10395,9 +10287,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10419,9 +10308,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10887,9 +10773,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10907,9 +10790,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10927,9 +10807,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10947,9 +10824,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11145,9 +11019,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -11162,9 +11033,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11179,9 +11047,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -11196,9 +11061,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11213,9 +11075,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -11230,9 +11089,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11247,9 +11103,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -11264,9 +11117,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11281,9 +11131,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -11298,9 +11145,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11315,9 +11159,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -11332,9 +11173,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -11349,9 +11187,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12231,9 +12066,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -12251,9 +12083,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -12271,9 +12100,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -12291,9 +12117,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -12311,9 +12134,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -12331,9 +12151,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -13360,9 +13177,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -13377,9 +13191,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -13394,9 +13205,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -13411,9 +13219,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -13428,9 +13233,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -13445,9 +13247,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -13462,9 +13261,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -13479,9 +13275,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -25817,7 +25610,6 @@ "arm" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -25835,7 +25627,6 @@ "arm64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -25853,7 +25644,6 @@ "arm" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -25871,7 +25661,6 @@ "arm64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -25889,7 +25678,6 @@ "riscv64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -25907,7 +25695,6 @@ "x64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -25925,7 +25712,6 @@ "riscv64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -25943,7 +25729,6 @@ "x64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ From 63d91e9ca9b754656765392a170d0e0965e69844 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Wed, 10 Jun 2026 22:18:28 +0200 Subject: [PATCH 13/14] fix: regenerate package-lock under linux node 22.12.0 to fix CI npm ci MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/core@1.7.11 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/core@1.7.11 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 --- package-lock.json | 318 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 260 insertions(+), 58 deletions(-) diff --git a/package-lock.json b/package-lock.json index d126caa..49bdb7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2176,7 +2176,6 @@ "version": "21.2.10", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.10.tgz", "integrity": "sha512-FDcnj3ogRmnTca4m2GbKP2khFOCtoVvWDZyfw2ZCPAf+zsQlKTyscKvx4GpTFo+KHrYXpawUpDIWHORFpuqFEA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/core": "7.29.0", @@ -2263,7 +2262,6 @@ "version": "21.2.10", "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-21.2.10.tgz", "integrity": "sha512-pQNL1LwI/6e8SbwiPxGJkXhV5AZxODhS/UJMEeYTx2fRvm4Ob3aMxWTl3ixhFZeYAx9bcU3QpfEJyGr7YDO24w==", - "dev": true, "license": "MIT", "dependencies": { "@babel/core": "7.29.0", @@ -2415,7 +2413,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2425,7 +2422,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -2456,14 +2452,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2473,7 +2467,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -2503,7 +2496,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -2520,7 +2512,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2629,7 +2620,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2653,7 +2643,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -2667,7 +2656,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -2771,7 +2759,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2790,7 +2777,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2815,7 +2801,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -2829,7 +2814,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -4415,7 +4399,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -4430,7 +4413,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -4449,7 +4431,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -4466,7 +4447,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -6312,7 +6292,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -6323,7 +6302,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -6334,7 +6312,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -6355,14 +6332,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -11277,6 +11252,265 @@ "win32" ] }, + "node_modules/@rspack/binding": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz", + "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.11", + "@rspack/binding-darwin-x64": "1.7.11", + "@rspack/binding-linux-arm64-gnu": "1.7.11", + "@rspack/binding-linux-arm64-musl": "1.7.11", + "@rspack/binding-linux-x64-gnu": "1.7.11", + "@rspack/binding-linux-x64-musl": "1.7.11", + "@rspack/binding-wasm32-wasi": "1.7.11", + "@rspack/binding-win32-arm64-msvc": "1.7.11", + "@rspack/binding-win32-ia32-msvc": "1.7.11", + "@rspack/binding-win32-x64-msvc": "1.7.11" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz", + "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz", + "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz", + "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz", + "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz", + "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz", + "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz", + "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz", + "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz", + "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz", + "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rspack/core": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", + "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.11", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/core/node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@rspack/core/node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@rspack/core/node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@rspack/core/node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@rspack/core/node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@rspack/core/node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, "node_modules/@rspack/dev-server": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-1.2.1.tgz", @@ -12351,7 +12585,6 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -12365,7 +12598,6 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -12375,7 +12607,6 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -12386,7 +12617,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -14545,7 +14775,6 @@ "version": "2.10.23", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -14750,7 +14979,6 @@ "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -15007,7 +15235,6 @@ "version": "1.0.30001791", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -15071,7 +15298,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -15608,7 +15834,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -17016,7 +17241,6 @@ "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", - "dev": true, "license": "ISC" }, "node_modules/emittery": { @@ -17957,7 +18181,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -18594,7 +18817,6 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -18613,7 +18835,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -20604,7 +20825,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -20655,7 +20875,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -21471,7 +21690,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -22324,7 +22542,6 @@ "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", - "dev": true, "license": "MIT" }, "node_modules/node-schedule": { @@ -23973,7 +24190,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -24741,7 +24957,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -24768,7 +24983,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, "license": "Apache-2.0" }, "node_modules/regenerate": { @@ -27536,7 +27750,6 @@ "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -28030,7 +28243,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -28186,7 +28399,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -29912,7 +30124,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, "license": "ISC" }, "node_modules/yaml": { @@ -29935,7 +30146,6 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -29962,7 +30172,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -29975,7 +30184,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -29988,7 +30196,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -30003,14 +30210,12 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -30028,7 +30233,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -30044,7 +30248,6 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -30062,7 +30265,6 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" From c4bfe34a9f6bebda77808470e69df4d4b573fe98 Mon Sep 17 00:00:00 2001 From: Dani Herrero Date: Thu, 11 Jun 2026 08:11:11 +0200 Subject: [PATCH 14/14] chore(ci): align CI/release Node to 24.14.1 (matches .nvmrc + engines) 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 --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d6dc8d..565353d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: 📦 Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22.12.0' + node-version: '24.14.1' cache: 'npm' - name: 📥 Install dependencies @@ -42,7 +42,7 @@ jobs: - name: 📦 Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22.12.0' + node-version: '24.14.1' cache: 'npm' - name: 📥 Install dependencies @@ -78,7 +78,7 @@ jobs: # - name: 📦 Setup Node.js # uses: actions/setup-node@v4 # with: - # node-version: '22.12.0' + # node-version: '24.14.1' # cache: 'npm' # - name: 📥 Install dependencies @@ -109,7 +109,7 @@ jobs: - name: 📦 Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22.12.0' + node-version: '24.14.1' cache: 'npm' - name: 📥 Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2bb4ea..4197828 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: - name: 📦 Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22.12.0' + node-version: '24.14.1' # --ignore-scripts: never run project/dependency lifecycle scripts on the # privileged runner before we push.