feat: verify oidc bearer tokens and jit-provision users

This commit is contained in:
Bastian Wagner
2026-08-17 14:54:42 +02:00
parent 7ca4bd9cf7
commit 5abc7cbe29
14 changed files with 294 additions and 11 deletions

View File

@@ -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;
},
);

View File

@@ -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 {}

View File

@@ -0,0 +1,3 @@
export * from './oidc-discovery.service';
export * from './oidc-auth.guard';
export * from './auth.module';

View File

@@ -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<string, unknown> = 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<string, string>).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<string, unknown>, 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<string, unknown> = {
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',
});
});
});

View File

@@ -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<boolean> {
const request = context.switchToHttp().getRequest<{
headers: Record<string, string | undefined>;
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;
}
}

View File

@@ -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<void> {
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;
}
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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<UserPreference> {
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();

View File

@@ -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<UserPreference, 'userId'>;
export const DEFAULT_USER_PREFERENCE: Omit<UserPreference, 'userId'> = {
preferredPace: null,
preferredBudgetLevel: null,

View File

@@ -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()

View File

@@ -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",

View File

@@ -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';