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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-1500%20passing-brightgreen" alt="1500 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-1504%20passing-brightgreen" alt="1504 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
61 changes: 61 additions & 0 deletions apps/api/src/modules/auth/jwt-azp.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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');
});
});
15 changes: 12 additions & 3 deletions apps/api/src/modules/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('KEYCLOAK_URL', 'http://localhost:8080');
Expand All @@ -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<string>('KEYCLOAK_ALLOWED_AZP') || audience)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}

/**
Expand All @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/modules/auth/ws-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -24,6 +25,12 @@ export class WsAuthService {
this.expectedAudience =
configService.get<string>('KEYCLOAK_AUDIENCE') ||
configService.get<string>('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<string>('KEYCLOAK_ALLOWED_AZP') || this.expectedAudience)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
this.jwks = jwksClient({
jwksUri: `${this.issuer}/protocol/openid-connect/certs`,
cache: true,
Expand Down Expand Up @@ -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)');
}

Expand Down
6 changes: 3 additions & 3 deletions docs/test-stats.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading