From edff87de5f9814429fd02020e4d293ac085200ef Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:00:58 +0200 Subject: [PATCH] feat(backend): tolerate Authentik users without an email + verify against real SSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `@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 --- backend/.env.example | 2 +- backend/src/auth/authentik.strategy.ts | 16 +++++++++++----- backend/src/auth/provision-user.ts | 12 ++++++++++++ backend/src/auth/token-verification.service.ts | 18 +++++++++++++----- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 808ad73..9fc0c44 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,7 +7,7 @@ AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-ap # Name of the Authentik group whose members are Leitungsteam. Mirrored to # User.isLeitungsteam on every login (the access token must carry a `groups` # claim; add the "groups" scope to the Authentik provider). -AUTHENTIK_LEITUNGSTEAM_GROUP="Leitungsteam" +AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT" # Secret used to sign guest/Konfi session tokens (local accounts only) GUEST_JWT_SECRET="change-me" diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index 474e6e4..b064f7b 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -7,13 +7,19 @@ import { Request } from 'express'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; -import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user'; +import { + authentikEmail, + resolveOrProvisionAuthentikUser, + toAuthenticatedUser, +} from './provision-user'; interface AuthentikJwtPayload { sub: string; email?: string; given_name?: string; family_name?: string; + preferred_username?: string; + name?: string; groups?: string[]; } @@ -52,8 +58,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { } async validate(payload: AuthentikJwtPayload): Promise { - if (!payload.email) { - throw new UnauthorizedException('Authentik token missing email claim'); + if (!payload.sub) { + throw new UnauthorizedException('Authentik token missing subject'); } const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup); const user = await resolveOrProvisionAuthentikUser( @@ -61,8 +67,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { this.sync, { sub: payload.sub, - email: payload.email, - firstName: payload.given_name ?? '', + email: authentikEmail(payload), + firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '', lastName: payload.family_name ?? '', }, isLeitungsteam, diff --git a/backend/src/auth/provision-user.ts b/backend/src/auth/provision-user.ts index be68c4e..e8d9610 100644 --- a/backend/src/auth/provision-user.ts +++ b/backend/src/auth/provision-user.ts @@ -10,6 +10,18 @@ export interface AuthentikClaims { lastName: string; } +/// Authentik users don't necessarily have an email set. Fall back to a stable, +/// per-user placeholder so provisioning still has a unique handle for the row. +export function authentikEmail(p: { + email?: string; + preferred_username?: string; + sub: string; +}): string { + const e = p.email?.trim(); + if (e) return e.toLowerCase(); + return `${p.preferred_username?.trim() || p.sub}@no-email.authentik`.toLowerCase(); +} + /// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard /// never compares it (LT short-circuits the KC check), it only needs to exist. export const GLOBAL_LT_KC_ID = '*'; diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index 3d48559..0a973cf 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -10,6 +10,7 @@ import { GuestJwtPayload } from './guest-auth.service'; import { TeamAuthService } from './team-auth.service'; import { AuthentikClaims, + authentikEmail, resolveOrProvisionAuthentikUser, toAuthenticatedUser, } from './provision-user'; @@ -55,15 +56,22 @@ export class TokenVerificationService { email?: string; given_name?: string; family_name?: string; + preferred_username?: string; + name?: string; groups?: string[]; }; - if (!payload.sub || !payload.email) { - throw new UnauthorizedException('Authentik token missing subject or email'); + const sub = payload.sub; + if (!sub) { + throw new UnauthorizedException('Authentik token missing subject'); } return { - sub: payload.sub, - email: payload.email, - firstName: payload.given_name ?? '', + 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), };