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 @@
-
+
@@ -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"
}