feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1

Merged
linus merged 28 commits from feat/backend-phases-0-6 into main 2026-09-12 11:26:16 +00:00
4 changed files with 37 additions and 11 deletions
Showing only changes of commit 7da9a362e1 - Show all commits
+1 -1
View File
@@ -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 # Name of the Authentik group whose members are Leitungsteam. Mirrored to
# User.isLeitungsteam on every login (the access token must carry a `groups` # User.isLeitungsteam on every login (the access token must carry a `groups`
# claim; add the "groups" scope to the Authentik provider). # 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) # Secret used to sign guest/Konfi session tokens (local accounts only)
GUEST_JWT_SECRET="change-me" GUEST_JWT_SECRET="change-me"
+11 -5
View File
@@ -7,13 +7,19 @@ import { Request } from 'express';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request'; import { AuthenticatedUser } from './authenticated-request';
import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user'; import {
authentikEmail,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
interface AuthentikJwtPayload { interface AuthentikJwtPayload {
sub: string; sub: string;
email?: string; email?: string;
given_name?: string; given_name?: string;
family_name?: string; family_name?: string;
preferred_username?: string;
name?: string;
groups?: string[]; groups?: string[];
} }
@@ -52,8 +58,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
} }
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> { async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
if (!payload.email) { if (!payload.sub) {
throw new UnauthorizedException('Authentik token missing email claim'); throw new UnauthorizedException('Authentik token missing subject');
} }
const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup); const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
const user = await resolveOrProvisionAuthentikUser( const user = await resolveOrProvisionAuthentikUser(
@@ -61,8 +67,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
this.sync, this.sync,
{ {
sub: payload.sub, sub: payload.sub,
email: payload.email, email: authentikEmail(payload),
firstName: payload.given_name ?? '', firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
lastName: payload.family_name ?? '', lastName: payload.family_name ?? '',
}, },
isLeitungsteam, isLeitungsteam,
+12
View File
@@ -10,6 +10,18 @@ export interface AuthentikClaims {
lastName: string; 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 /// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard
/// never compares it (LT short-circuits the KC check), it only needs to exist. /// never compares it (LT short-circuits the KC check), it only needs to exist.
export const GLOBAL_LT_KC_ID = '*'; export const GLOBAL_LT_KC_ID = '*';
+13 -5
View File
@@ -10,6 +10,7 @@ import { GuestJwtPayload } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service'; import { TeamAuthService } from './team-auth.service';
import { import {
AuthentikClaims, AuthentikClaims,
authentikEmail,
resolveOrProvisionAuthentikUser, resolveOrProvisionAuthentikUser,
toAuthenticatedUser, toAuthenticatedUser,
} from './provision-user'; } from './provision-user';
@@ -55,15 +56,22 @@ export class TokenVerificationService {
email?: string; email?: string;
given_name?: string; given_name?: string;
family_name?: string; family_name?: string;
preferred_username?: string;
name?: string;
groups?: string[]; groups?: string[];
}; };
if (!payload.sub || !payload.email) { const sub = payload.sub;
throw new UnauthorizedException('Authentik token missing subject or email'); if (!sub) {
throw new UnauthorizedException('Authentik token missing subject');
} }
return { return {
sub: payload.sub, sub,
email: payload.email, email: authentikEmail({
firstName: payload.given_name ?? '', email: payload.email,
preferred_username: payload.preferred_username,
sub,
}),
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
lastName: payload.family_name ?? '', lastName: payload.family_name ?? '',
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup), isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
}; };