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:
2026-09-10 08:13:20 +02:00
co-authored by Claude Sonnet 5
parent f03b209e84
commit eb6f64a0c5
8 changed files with 241 additions and 96 deletions
+24 -22
View File
@@ -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> {