From 5abc7cbe29451e2a1762afa1e79d4f36f6ebe394 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 17 Aug 2026 14:54:42 +0200 Subject: [PATCH] feat: verify oidc bearer tokens and jit-provision users --- .../api/src/auth/current-user.decorator.ts | 11 ++ backend/libs/auth/src/auth.module.ts | 11 ++ backend/libs/auth/src/index.ts | 3 + backend/libs/auth/src/oidc-auth.guard.spec.ts | 123 ++++++++++++++++++ backend/libs/auth/src/oidc-auth.guard.ts | 66 ++++++++++ .../libs/auth/src/oidc-discovery.service.ts | 45 +++++++ .../configuration/src/environment.spec.ts | 4 + backend/libs/configuration/src/environment.ts | 4 + .../users/src/user-preferences.repository.ts | 22 ++-- backend/libs/users/src/user.types.ts | 3 + backend/libs/users/src/users.repository.ts | 2 +- backend/package.json | 1 + backend/test/setup-env.ts | 2 + pnpm-lock.yaml | 8 ++ 14 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 backend/apps/api/src/auth/current-user.decorator.ts create mode 100644 backend/libs/auth/src/auth.module.ts create mode 100644 backend/libs/auth/src/index.ts create mode 100644 backend/libs/auth/src/oidc-auth.guard.spec.ts create mode 100644 backend/libs/auth/src/oidc-auth.guard.ts create mode 100644 backend/libs/auth/src/oidc-discovery.service.ts diff --git a/backend/apps/api/src/auth/current-user.decorator.ts b/backend/apps/api/src/auth/current-user.decorator.ts new file mode 100644 index 0000000..9a5b095 --- /dev/null +++ b/backend/apps/api/src/auth/current-user.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { AuthenticatedUser } from '../../../../libs/auth/src'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthenticatedUser => { + const request = ctx + .switchToHttp() + .getRequest<{ user: AuthenticatedUser }>(); + return request.user; + }, +); diff --git a/backend/libs/auth/src/auth.module.ts b/backend/libs/auth/src/auth.module.ts new file mode 100644 index 0000000..5ea9c2f --- /dev/null +++ b/backend/libs/auth/src/auth.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UsersLibModule } from '../../users/src'; +import { OidcDiscoveryService } from './oidc-discovery.service'; +import { OidcAuthGuard } from './oidc-auth.guard'; + +@Module({ + imports: [UsersLibModule], + providers: [OidcDiscoveryService, OidcAuthGuard], + exports: [OidcDiscoveryService, OidcAuthGuard], +}) +export class AuthModule {} diff --git a/backend/libs/auth/src/index.ts b/backend/libs/auth/src/index.ts new file mode 100644 index 0000000..03eba96 --- /dev/null +++ b/backend/libs/auth/src/index.ts @@ -0,0 +1,3 @@ +export * from './oidc-discovery.service'; +export * from './oidc-auth.guard'; +export * from './auth.module'; diff --git a/backend/libs/auth/src/oidc-auth.guard.spec.ts b/backend/libs/auth/src/oidc-auth.guard.spec.ts new file mode 100644 index 0000000..f58dc5d --- /dev/null +++ b/backend/libs/auth/src/oidc-auth.guard.spec.ts @@ -0,0 +1,123 @@ +import { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } from 'jose'; +import type { KeyLike } from 'jose'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { OidcAuthGuard } from './oidc-auth.guard'; + +const issuer = 'https://idp.example.test/'; +const audience = 'travel-planner-api'; + +function contextWithHeader(authorization?: string): ExecutionContext { + const req: Record = authorization + ? { headers: { authorization } } + : { headers: {} }; + return { + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; +} + +describe('OidcAuthGuard', () => { + let privateKey: KeyLike; + let discovery: { + getVerificationKeySet: jest.Mock; + getIssuer: jest.Mock; + getAudience: jest.Mock; + }; + let usersService: { findOrCreateByExternalSubjectId: jest.Mock }; + + beforeAll(async () => { + const { publicKey, privateKey: pk } = await generateKeyPair('RS256'); + privateKey = pk; + const jwk = await exportJWK(publicKey); + (jwk as Record).kid = 'test-key'; + const jwks = createLocalJWKSet({ keys: [jwk as never] }); + discovery = { + getVerificationKeySet: jest.fn().mockReturnValue(jwks), + getIssuer: jest.fn().mockReturnValue(issuer), + getAudience: jest.fn().mockReturnValue(audience), + }; + }); + + beforeEach(() => { + usersService = { + findOrCreateByExternalSubjectId: jest.fn().mockResolvedValue({ + id: 'local-1', + externalSubjectId: 'idp-sub-1', + displayName: 'A', + email: 'a@example.com', + }), + }; + }); + + async function sign(claims: Record, expires = '5m') { + return new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer(issuer) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime(expires) + .sign(privateKey); + } + + it('rejects a request with no Authorization header', async () => { + const guard = new OidcAuthGuard(discovery as never, usersService as never); + await expect(guard.canActivate(contextWithHeader())).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('rejects an expired token', async () => { + const token = await sign( + { sub: 'idp-sub-1', email: 'a@example.com', name: 'A' }, + '-10s', + ); + const guard = new OidcAuthGuard(discovery as never, usersService as never); + await expect( + guard.canActivate(contextWithHeader(`Bearer ${token}`)), + ).rejects.toThrow(UnauthorizedException); + }); + + it('rejects a token issued for a different audience', async () => { + const token = await new SignJWT({ sub: 'idp-sub-1' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer(issuer) + .setAudience('some-other-api') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + const guard = new OidcAuthGuard(discovery as never, usersService as never); + await expect( + guard.canActivate(contextWithHeader(`Bearer ${token}`)), + ).rejects.toThrow(UnauthorizedException); + }); + + it('provisions the local user and attaches req.user on a valid token', async () => { + const token = await sign({ + sub: 'idp-sub-1', + email: 'a@example.com', + name: 'A', + }); + const req: Record = { + headers: { authorization: `Bearer ${token}` }, + }; + const context = { + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; + + const guard = new OidcAuthGuard(discovery as never, usersService as never); + await expect(guard.canActivate(context)).resolves.toBe(true); + + expect(usersService.findOrCreateByExternalSubjectId).toHaveBeenCalledWith( + 'idp-sub-1', + { + email: 'a@example.com', + displayName: 'A', + }, + ); + expect(req.user).toEqual({ + id: 'local-1', + externalSubjectId: 'idp-sub-1', + displayName: 'A', + email: 'a@example.com', + }); + }); +}); diff --git a/backend/libs/auth/src/oidc-auth.guard.ts b/backend/libs/auth/src/oidc-auth.guard.ts new file mode 100644 index 0000000..afd966a --- /dev/null +++ b/backend/libs/auth/src/oidc-auth.guard.ts @@ -0,0 +1,66 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { jwtVerify } from 'jose'; +import type { JWTPayload } from 'jose'; +import { UsersService } from '../../users/src'; +import { OidcDiscoveryService } from './oidc-discovery.service'; + +export interface AuthenticatedUser { + id: string; + externalSubjectId: string; + displayName: string; + email: string; +} + +@Injectable() +export class OidcAuthGuard implements CanActivate { + constructor( + private readonly discovery: OidcDiscoveryService, + private readonly users: UsersService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ + headers: Record; + user?: AuthenticatedUser; + }>(); + const header = request.headers?.authorization; + const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined; + if (!token) throw new UnauthorizedException('Missing bearer token'); + + let payload: JWTPayload; + try { + const result = await jwtVerify( + token, + this.discovery.getVerificationKeySet(), + { + issuer: this.discovery.getIssuer(), + audience: this.discovery.getAudience(), + }, + ); + payload = result.payload; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + + const sub = payload.sub; + if (!sub) throw new UnauthorizedException('Token has no subject claim'); + + const user = await this.users.findOrCreateByExternalSubjectId(sub, { + email: (payload.email as string) ?? '', + displayName: (payload.name as string) ?? (payload.email as string) ?? sub, + }); + + request.user = { + id: user.id, + externalSubjectId: user.externalSubjectId, + displayName: user.displayName, + email: user.email, + }; + return true; + } +} diff --git a/backend/libs/auth/src/oidc-discovery.service.ts b/backend/libs/auth/src/oidc-discovery.service.ts new file mode 100644 index 0000000..43c66f4 --- /dev/null +++ b/backend/libs/auth/src/oidc-discovery.service.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { createRemoteJWKSet } from 'jose'; +import type { JWTVerifyGetKey } from 'jose'; +import { APP_ENVIRONMENT } from '../../configuration/src'; +import type { AppEnvironment } from '../../configuration/src'; + +interface OidcDiscoveryDocument { + jwks_uri: string; +} + +@Injectable() +export class OidcDiscoveryService implements OnModuleInit { + private verificationKeySet: JWTVerifyGetKey | undefined; + + constructor( + @Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment, + ) {} + + async onModuleInit(): Promise { + const issuer = this.environment.oidcIssuer.replace(/\/$/, ''); + const response = await fetch(`${issuer}/.well-known/openid-configuration`); + if (!response.ok) { + throw new Error( + `Failed to fetch OIDC discovery document: HTTP ${response.status}`, + ); + } + const document = (await response.json()) as OidcDiscoveryDocument; + this.verificationKeySet = createRemoteJWKSet(new URL(document.jwks_uri)); + } + + getIssuer(): string { + return this.environment.oidcIssuer; + } + + getAudience(): string { + return this.environment.oidcAudience; + } + + getVerificationKeySet(): JWTVerifyGetKey { + if (!this.verificationKeySet) { + throw new Error('OIDC discovery has not completed yet'); + } + return this.verificationKeySet; + } +} diff --git a/backend/libs/configuration/src/environment.spec.ts b/backend/libs/configuration/src/environment.spec.ts index 101c5d6..6d38d48 100644 --- a/backend/libs/configuration/src/environment.spec.ts +++ b/backend/libs/configuration/src/environment.spec.ts @@ -10,6 +10,8 @@ describe('loadEnvironment', () => { loadEnvironment({ DATABASE_URL: 'postgresql://u:p@postgres:5432/db', REDIS_URL: 'redis://redis:6379', + OIDC_ISSUER: 'https://idp.example.test/', + OIDC_AUDIENCE: 'travel-planner-api', APP_VERSION: '1.2.3', TEAMCITY_BUILD_NUMBER: '42', SOURCE_REVISION: 'abc123', @@ -17,6 +19,8 @@ describe('loadEnvironment', () => { ).toEqual({ databaseUrl: 'postgresql://u:p@postgres:5432/db', redisUrl: 'redis://redis:6379', + oidcIssuer: 'https://idp.example.test/', + oidcAudience: 'travel-planner-api', appVersion: '1.2.3', teamCityBuildNumber: '42', sourceRevision: 'abc123', diff --git a/backend/libs/configuration/src/environment.ts b/backend/libs/configuration/src/environment.ts index 40b483a..8324a55 100644 --- a/backend/libs/configuration/src/environment.ts +++ b/backend/libs/configuration/src/environment.ts @@ -1,6 +1,8 @@ export interface AppEnvironment { databaseUrl: string; redisUrl: string; + oidcIssuer: string; + oidcAudience: string; appVersion: string; teamCityBuildNumber: string; sourceRevision: string; @@ -16,6 +18,8 @@ export function loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment { return { databaseUrl: required(env, 'DATABASE_URL'), redisUrl: required(env, 'REDIS_URL'), + oidcIssuer: required(env, 'OIDC_ISSUER'), + oidcAudience: required(env, 'OIDC_AUDIENCE'), appVersion: env.APP_VERSION?.trim() || 'dev', teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.trim() || 'local', sourceRevision: env.SOURCE_REVISION?.trim() || 'local', diff --git a/backend/libs/users/src/user-preferences.repository.ts b/backend/libs/users/src/user-preferences.repository.ts index 2cada62..05e9956 100644 --- a/backend/libs/users/src/user-preferences.repository.ts +++ b/backend/libs/users/src/user-preferences.repository.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { Kysely } from 'kysely'; import { KYSELY_DB } from '../../database/src'; import type { Database } from '../../database/src'; -import type { UpdateUserPreferenceDto, UserPreference } from './user.types'; +import type { UserPreference, UserPreferenceFields } from './user.types'; function toUserPreference(row: { user_id: string; @@ -44,23 +44,25 @@ export class UserPreferencesRepository { async upsert( userId: string, - dto: UpdateUserPreferenceDto, + fields: UserPreferenceFields, ): Promise { const values = { - preferred_pace: dto.preferredPace, - preferred_budget_level: dto.preferredBudgetLevel, - max_walking_distance_km: dto.maxWalkingDistanceKm ?? null, - preferred_start_time: dto.preferredStartTime, - child_friendly_preferred: dto.childFriendlyPreferred, - interests: dto.interests, - notes: dto.notes, + preferred_pace: fields.preferredPace, + preferred_budget_level: fields.preferredBudgetLevel, + max_walking_distance_km: fields.maxWalkingDistanceKm, + preferred_start_time: fields.preferredStartTime, + child_friendly_preferred: fields.childFriendlyPreferred, + interests: fields.interests, + notes: fields.notes, }; const row = await this.db .insertInto('user_preferences') .values({ user_id: userId, ...values }) .onConflict((oc) => - oc.column('user_id').doUpdateSet({ ...values, updated_at: new Date() }), + oc + .column('user_id') + .doUpdateSet({ ...values, updated_at: new Date().toISOString() }), ) .returningAll() .executeTakeFirstOrThrow(); diff --git a/backend/libs/users/src/user.types.ts b/backend/libs/users/src/user.types.ts index 1c092a3..d5919f5 100644 --- a/backend/libs/users/src/user.types.ts +++ b/backend/libs/users/src/user.types.ts @@ -33,6 +33,9 @@ export interface UpdateUserPreferenceDto { notes?: string | null; } +/** Fully-resolved preference fields (no optional/undefined members) ready to persist. */ +export type UserPreferenceFields = Omit; + export const DEFAULT_USER_PREFERENCE: Omit = { preferredPace: null, preferredBudgetLevel: null, diff --git a/backend/libs/users/src/users.repository.ts b/backend/libs/users/src/users.repository.ts index d0b2796..6faf4f1 100644 --- a/backend/libs/users/src/users.repository.ts +++ b/backend/libs/users/src/users.repository.ts @@ -41,7 +41,7 @@ export class UsersRepository { oc.column('external_subject_id').doUpdateSet({ display_name: claims.displayName, email: claims.email, - updated_at: new Date(), + updated_at: new Date().toISOString(), }), ) .returningAll() diff --git a/backend/package.json b/backend/package.json index 97bc251..09df5ad 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "ioredis": "^6.0.0", + "jose": "^5.10.0", "kysely": "0.28.17", "node-pg-migrate": "^7.9.1", "pg": "^8.23.0", diff --git a/backend/test/setup-env.ts b/backend/test/setup-env.ts index 4c2b285..560f4ec 100644 --- a/backend/test/setup-env.ts +++ b/backend/test/setup-env.ts @@ -1,2 +1,4 @@ process.env.DATABASE_URL ??= 'postgresql://test:test@localhost:5432/test'; process.env.REDIS_URL ??= 'redis://localhost:6379'; +process.env.OIDC_ISSUER ??= 'https://idp.example.test/'; +process.env.OIDC_AUDIENCE ??= 'travel-planner-api'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a155cb8..69b7404 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: ioredis: specifier: ^6.0.0 version: 6.0.0 + jose: + specifier: ^5.10.0 + version: 5.10.0 kysely: specifier: 0.28.17 version: 0.28.17 @@ -3463,6 +3466,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.9: resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} @@ -8603,6 +8609,8 @@ snapshots: jiti@2.7.0: optional: true + jose@5.10.0: {} + jose@6.2.9: {} js-tokens@4.0.0: {}