From 848e180aa4be8ddcdff66e5ecdeba778813d984e Mon Sep 17 00:00:00 2001 From: Charles Pizzato <311327716+modernitconsultants@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:49:05 +1000 Subject: [PATCH 1/2] =?UTF-8?q?feat(auth):=20KEYCLOAK=5FALLOWED=5FAZP=20?= =?UTF-8?q?=E2=80=94=20let=20additional=20realm=20clients=20call=20the=20A?= =?UTF-8?q?PI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both verifiers (the passport strategy and the WebSocket mirror) required azp === KEYCLOAK_CLIENT_ID. azp is immutably the client that obtained the token, so a service account on its own realm client can never pass a single-value equality, no matter what audience mappers it carries — an integration authenticating with client credentials gets 401 "Invalid token audience (azp mismatch)" with no way through except impersonating the browser client. KEYCLOAK_ALLOWED_AZP is a comma-separated list of realm clients allowed to call. Unset, behaviour is byte-for-byte the old one — only the primary client passes — so existing deployments notice nothing. aud stays single-valued and separately validated: the list widens WHO may call, not what audience a token must carry. Both verifiers read the same list, or a caller would work over HTTP and die on the socket. Tests pin all four properties: unset = old behaviour, listed clients pass, unlisted still fail, tokens without azp unaffected. --- apps/api/src/modules/auth/jwt-azp.spec.ts | 61 ++++++++++++++++++++ apps/api/src/modules/auth/jwt.strategy.ts | 15 ++++- apps/api/src/modules/auth/ws-auth.service.ts | 9 ++- 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/modules/auth/jwt-azp.spec.ts diff --git a/apps/api/src/modules/auth/jwt-azp.spec.ts b/apps/api/src/modules/auth/jwt-azp.spec.ts new file mode 100644 index 00000000..fc93bdbd --- /dev/null +++ b/apps/api/src/modules/auth/jwt-azp.spec.ts @@ -0,0 +1,61 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { describe, expect, it } from 'vitest'; + +import { JwtStrategy } from './jwt.strategy'; + +/** + * The azp allow-list. `azp` is immutably the client that obtained the token, + * so a second realm client (a service account integrating over the public + * API) can never satisfy a single-value equality no matter what audience + * mappers it carries. KEYCLOAK_ALLOWED_AZP names the clients allowed to call; + * unset, behaviour is exactly the old one — only the primary client passes. + */ +function strategy(env: Record): JwtStrategy { + const config = new ConfigService(env); + return new JwtStrategy(config as any); +} + +const BASE = { + KEYCLOAK_URL: 'http://keycloak.test', + KEYCLOAK_REALM: 'haip', + KEYCLOAK_CLIENT_ID: 'haip-dashboard', +}; + +const payload = (azp?: string) => ({ + sub: 'user-1', + azp, + realm_access: { roles: ['admin'] }, +}); + +describe('azp allow-list', () => { + it('unset: only the primary client passes (the old behaviour, unchanged)', () => { + const s = strategy(BASE); + expect(s.validate(payload('haip-dashboard')).sub).toBe('user-1'); + expect(() => s.validate(payload('enquiry-service'))).toThrow(UnauthorizedException); + }); + + it('set: every listed client passes, anything else still fails', () => { + const s = strategy({ ...BASE, KEYCLOAK_ALLOWED_AZP: 'haip-dashboard, enquiry-service' }); + expect(s.validate(payload('haip-dashboard')).sub).toBe('user-1'); + expect(s.validate(payload('enquiry-service')).sub).toBe('user-1'); + expect(() => s.validate(payload('some-other-client'))).toThrow(UnauthorizedException); + }); + + it('a token with no azp is not rejected by this check (unchanged)', () => { + const s = strategy({ ...BASE, KEYCLOAK_ALLOWED_AZP: 'haip-dashboard' }); + expect(s.validate(payload(undefined)).sub).toBe('user-1'); + }); + + it('the list does not widen aud: KEYCLOAK_AUDIENCE stays single-valued', () => { + // The allow-list applies to azp only. Passport's audience check above this + // layer still requires the single configured audience, which callers add + // via an audience mapper — the two mechanisms stay separate on purpose. + const s = strategy({ + ...BASE, + KEYCLOAK_AUDIENCE: 'haip-dashboard', + KEYCLOAK_ALLOWED_AZP: 'haip-dashboard,enquiry-service', + }); + expect(s.validate(payload('enquiry-service')).sub).toBe('user-1'); + }); +}); diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts index 142635cf..90cc7e2a 100644 --- a/apps/api/src/modules/auth/jwt.strategy.ts +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -19,7 +19,7 @@ import type { AuthUser } from './current-user.decorator'; */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - private readonly expectedAudience: string; + private readonly allowedAzp: string[]; constructor(configService: ConfigService) { const keycloakUrl = configService.get('KEYCLOAK_URL', 'http://localhost:8080'); @@ -43,7 +43,16 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { }), }); - this.expectedAudience = audience; + // Additional realm clients allowed to CALL this API (service accounts + // integrating over the public API). `azp` is immutably the client that + // obtained the token, so a second client can never satisfy a single-value + // equality no matter what mappers it carries -- while `aud` stays the + // single audience above, which any caller can add via an audience mapper. + // Unset, behaviour is exactly as before: only the primary client passes. + this.allowedAzp = (configService.get('KEYCLOAK_ALLOWED_AZP') || audience) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); } /** @@ -53,7 +62,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { * Returns the user object attached to req.user. */ validate(payload: any): AuthUser { - if (payload.azp && payload.azp !== this.expectedAudience) { + if (payload.azp && !this.allowedAzp.includes(payload.azp)) { throw new UnauthorizedException('Invalid token audience (azp mismatch)'); } return { diff --git a/apps/api/src/modules/auth/ws-auth.service.ts b/apps/api/src/modules/auth/ws-auth.service.ts index b8c4518d..9558e32e 100644 --- a/apps/api/src/modules/auth/ws-auth.service.ts +++ b/apps/api/src/modules/auth/ws-auth.service.ts @@ -15,6 +15,7 @@ export class WsAuthService { private readonly logger = new Logger(WsAuthService.name); private readonly issuer: string; private readonly expectedAudience: string; + private readonly allowedAzp: string[]; private readonly jwks: JwksClient; constructor(configService: ConfigService) { @@ -24,6 +25,12 @@ export class WsAuthService { this.expectedAudience = configService.get('KEYCLOAK_AUDIENCE') || configService.get('KEYCLOAK_CLIENT_ID', 'haip-api'); + // Same allow-list as JwtStrategy -- the two verifiers must agree on who + // may call, or a caller works over HTTP and dies on the socket. + this.allowedAzp = (configService.get('KEYCLOAK_ALLOWED_AZP') || this.expectedAudience) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); this.jwks = jwksClient({ jwksUri: `${this.issuer}/protocol/openid-connect/certs`, cache: true, @@ -65,7 +72,7 @@ export class WsAuthService { ); }); - if (payload.azp && payload.azp !== this.expectedAudience) { + if (payload.azp && !this.allowedAzp.includes(payload.azp)) { throw new Error('Invalid token audience (azp mismatch)'); } From 2e13348d853b883ab76c63674a882ca92afe400c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 04:06:55 +0000 Subject: [PATCH 2/2] chore: sync README test counts after azp allow-list tests Co-authored-by: telivity-otaip --- README.md | 8 ++++---- docs/test-stats.json | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9660ed95..3332e520 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License -1500 Tests Passing 12 AI Agents +1504 Tests Passing 12 AI Agents

@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1500 tests across 213 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1504 tests across 214 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1500 tests across 213 test files) +# All tests (1504 tests across 214 test files) # API tests only pnpm --filter @telivityhaip/api test @@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1500 tests, 213 files) +pnpm test # Run all tests (1504 tests, 214 files) pnpm lint # ESLint ``` diff --git a/docs/test-stats.json b/docs/test-stats.json index 47f7606a..b19a9dc8 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,5 +1,5 @@ { - "tests": 1500, - "files": 213, - "updatedAt": "2026-08-12T16:49:23.658Z" + "tests": 1504, + "files": 214, + "updatedAt": "2026-08-13T04:06:48.382Z" }