import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as jwt from 'jsonwebtoken'; import * as jwksRsa from 'jwks-rsa'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; import { GuestJwtPayload } from './guest-auth.service'; import { TeamAuthService } from './team-auth.service'; import { AuthentikClaims, authentikEmail, resolveOrProvisionAuthentikUser, toAuthenticatedUser, } from './provision-user'; /// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for /// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. @Injectable() export class TokenVerificationService { private readonly issuerUrl: string; private readonly jwks: jwksRsa.JwksClient; private readonly leitungsteamGroup: string; constructor( private readonly config: ConfigService, private readonly prisma: PrismaClient, private readonly guestJwt: JwtService, private readonly teamAuth: TeamAuthService, private readonly sync: SyncService, ) { // See AuthentikStrategy: normalise the trailing slash, accept both forms. this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL').replace(/\/+$/, ''); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); } /// Verifies an Authentik token's signature and returns its identity claims /// plus whether the caller is in the Leitungsteam group, without requiring /// a local User to exist yet (used by the onboarding self-registration /// path, which provisions that User). async verifyAuthentikClaims( token: string, ): Promise { const decoded = jwt.decode(token, { complete: true }); const kid = decoded?.header.kid; if (!kid) { throw new UnauthorizedException('Malformed Authentik token'); } const key = await this.jwks.getSigningKey(kid); const payload = jwt.verify(token, key.getPublicKey(), { issuer: [this.issuerUrl, `${this.issuerUrl}/`], algorithms: ['RS256'], }) as jwt.JwtPayload & { email?: string; given_name?: string; family_name?: string; preferred_username?: string; name?: string; groups?: string[]; }; const sub = payload.sub; if (!sub) { throw new UnauthorizedException('Authentik token missing subject'); } return { sub, email: authentikEmail({ email: payload.email, preferred_username: payload.preferred_username, sub, }), firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '', lastName: payload.family_name ?? '', isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup), }; } async verifyAuthentik(token: string): Promise { const { isLeitungsteam, ...claims } = await this.verifyAuthentikClaims(token); const user = await resolveOrProvisionAuthentikUser( this.prisma, this.sync, claims, isLeitungsteam, ); return toAuthenticatedUser(user); } async verifyGuest(token: string): Promise { return this.guestJwt.verifyAsync(token); } /// Tries Authentik, then a local team (Teamer) token, then a guest token. async verifyEither(token: string): Promise< { kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload } > { try { return { kind: 'user', user: await this.verifyAuthentik(token) }; } catch { // not an Authentik token } try { return { kind: 'user', user: await this.teamAuth.verify(token) }; } catch { return { kind: 'guest', guest: await this.verifyGuest(token) }; } } }