feat(backend): derive LEITUNGSTEAM from the Authentik groups claim
On every Authentik login the token's `groups` claim is compared against AUTHENTIK_LEITUNGSTEAM_GROUP (default "Leitungsteam") and mirrored to the new User.isLeitungsteam column. LT is global, not KC-scoped, so it lives on the User rather than as a per-KC Membership row: toAuthenticatedUser() synthesises a virtual global LEITUNGSTEAM membership from the flag, so RolesGuard / visibility / TeamerService keep working unchanged. - provision helper gains an isLeitungsteam arg and reconciles the flag both ways (grant on join, drop when the group is gone), capturing a User UPDATE to the sync log. - verifyAuthentikClaims() now also returns isLeitungsteam; strategy, WS path and onboarding all funnel through the shared helper + mapper. - new env var AUTHENTIK_LEITUNGSTEAM_GROUP. Tests: provision-user.spec.ts extended (flag up/down, virtual membership); npm test green at 55. Docs updated; ops note added that the Authentik provider must emit the groups claim. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,11 @@ import { SyncService } from '../sync/sync.service';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
import { GuestJwtPayload } from './guest-auth.service';
|
||||
import { TeamAuthService } from './team-auth.service';
|
||||
import { resolveOrProvisionAuthentikUser } from './provision-user';
|
||||
import {
|
||||
AuthentikClaims,
|
||||
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.
|
||||
@@ -16,6 +20,7 @@ import { resolveOrProvisionAuthentikUser } from './provision-user';
|
||||
export class TokenVerificationService {
|
||||
private readonly issuerUrl: string;
|
||||
private readonly jwks: jwksRsa.JwksClient;
|
||||
private readonly leitungsteamGroup: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@@ -26,17 +31,16 @@ export class TokenVerificationService {
|
||||
) {
|
||||
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||
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,
|
||||
/// without requiring a local User to exist yet (used by the onboarding
|
||||
/// self-registration path, which provisions that User).
|
||||
async verifyAuthentikClaims(token: string): Promise<{
|
||||
sub: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}> {
|
||||
/// 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) {
|
||||
@@ -50,6 +54,7 @@ export class TokenVerificationService {
|
||||
email?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
groups?: string[];
|
||||
};
|
||||
if (!payload.sub || !payload.email) {
|
||||
throw new UnauthorizedException('Authentik token missing subject or email');
|
||||
@@ -59,22 +64,19 @@ export class TokenVerificationService {
|
||||
email: payload.email,
|
||||
firstName: payload.given_name ?? '',
|
||||
lastName: payload.family_name ?? '',
|
||||
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
||||
const claims = await this.verifyAuthentikClaims(token);
|
||||
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
|
||||
return {
|
||||
userId: user.id,
|
||||
authentikSub: user.authentikSub,
|
||||
email: user.email,
|
||||
memberships: user.memberships.map((m) => ({
|
||||
kcId: m.kcId,
|
||||
gemeindeId: m.gemeindeId,
|
||||
role: m.role,
|
||||
})),
|
||||
};
|
||||
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> {
|
||||
|
||||
Reference in New Issue
Block a user