feat: verify oidc bearer tokens and jit-provision users
This commit is contained in:
11
backend/libs/auth/src/auth.module.ts
Normal file
11
backend/libs/auth/src/auth.module.ts
Normal 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 {}
|
||||
3
backend/libs/auth/src/index.ts
Normal file
3
backend/libs/auth/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './oidc-discovery.service';
|
||||
export * from './oidc-auth.guard';
|
||||
export * from './auth.module';
|
||||
123
backend/libs/auth/src/oidc-auth.guard.spec.ts
Normal file
123
backend/libs/auth/src/oidc-auth.guard.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
66
backend/libs/auth/src/oidc-auth.guard.ts
Normal file
66
backend/libs/auth/src/oidc-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
backend/libs/auth/src/oidc-discovery.service.ts
Normal file
45
backend/libs/auth/src/oidc-discovery.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user