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