Files
KC-APP-Server/src/auth/token-verification.service.ts
T
linusandClaude Sonnet 5 7da9a362e1 feat(backend): tolerate Authentik users without an email + verify against real SSO
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>
2026-09-10 10:00:58 +02:00

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