diff --git a/README.md b/README.md
index 9660ed9..3332e52 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/apps/api/src/modules/auth/jwt-azp.spec.ts b/apps/api/src/modules/auth/jwt-azp.spec.ts
new file mode 100644
index 0000000..fc93bdb
--- /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 142635c..90cc7e2 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 b8c4518..9558e32 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)');
}
diff --git a/docs/test-stats.json b/docs/test-stats.json
index 47f7606..b19a9dc 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"
}