Authentik accounts don't always have an email set (the test account `hermes` doesn't). AuthentikStrategy / verifyAuthentikClaims no longer reject those — `authentikEmail()` falls back to a stable `<preferred_username|sub>@no-email.authentik` handle for the local User row, and first/last name fall back to preferred_username/name. Set AUTHENTIK_LEITUNGSTEAM_GROUP to the real group "KC-APP-LT". Verified end to end against the live https://sso.konfi-castle.com with a password-grant token for a KC-APP-LT member: backend accepts the RS256 token (JWKS + trailing-slash issuer), JIT-provisions the User, maps the `groups` claim to isLeitungsteam=true, and POST /api/kc returns 201. Only the in-browser redirect round-trip remains untested. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
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<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
|
|
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
|
this.leitungsteamGroup = config.get<string>('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<AuthentikClaims & { isLeitungsteam: boolean }> {
|
|
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<AuthenticatedUser> {
|
|
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<GuestJwtPayload> {
|
|
return this.guestJwt.verifyAsync<GuestJwtPayload>(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) };
|
|
}
|
|
}
|
|
}
|