From 847fed8daddd96879875ee2c2c6758631f38719f Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:24:38 +0200 Subject: [PATCH] feat: Authentik OIDC login (PKCE) + Leitungsteam admin screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - AuthentikStrategy / TokenVerificationService: normalise the issuer's trailing slash and accept both `iss` spellings (Authentik's discovery issuer and token `iss` carry a trailing slash; the JWKS URL must not double it). Wire the real konfi-castle issuer into .env.example. - team token path now goes through toAuthenticatedUser too, so a local account flagged isLeitungsteam gets the synthetic global LT membership regardless of token kind. - LT-admin controllers (kc, gemeinde, onboarding, sync, teamer) accept ['authentik','team'] so such an account can use them. RolesGuard still enforces the actual LT/role check. - app.module serves the Flutter web build from client/app/build/web (SPA fallback covers the OIDC redirect path /v1/auth/callback), falling back to the interim client/web/ if it isn't built. Client (client/app/): - oidc.dart: Authorization-Code + PKCE against Authentik (discovery, S256 challenge, state, token exchange, refresh). Browser bits (sessionStorage, redirect, URL) behind a conditional import so `flutter test` still compiles on the VM. - AppState handles the ?code= callback on bootstrap, stores access + refresh, refreshes an expired token on restart. - Login screen: "Mit Konfi-Castle-ID anmelden" button (Leitungsteam / Verantwortliche) alongside the local Teamer password form. - admin_screen.dart: LT-only "Verwaltung" — list/create KCs, per KC the Gemeinden (list/create) and pending Verantwortlichen requests (approve/reject). Verified end to end against local Postgres with an isLeitungsteam account (create KC/Gemeinde, list + approve a request). flutter analyze/test/build web green; backend npm test 56. Co-Authored-By: Claude Sonnet 5 --- .env.example | 4 ++-- src/app.module.ts | 16 +++++++++++++--- src/auth/authentik.strategy.ts | 8 +++++--- src/auth/team-auth.service.ts | 14 ++++---------- src/auth/token-verification.service.ts | 5 +++-- src/gemeinde/gemeinde.controller.ts | 2 +- src/kc/kc.controller.ts | 2 +- src/onboarding/onboarding.controller.ts | 6 +++--- src/sync/sync.controller.ts | 2 +- src/teamer/teamer.controller.ts | 7 ++++--- 10 files changed, 37 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index de0a625..808ad73 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # Postgres connection used by Prisma DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public" -# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/ -AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" +# Authentik OIDC issuer (trailing slash optional — both forms are accepted). +AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-app" # Name of the Authentik group whose members are Leitungsteam. Mirrored to # User.isLeitungsteam on every login (the access token must carry a `groups` diff --git a/src/app.module.ts b/src/app.module.ts index 2cf76fb..ffaeec6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ServeStaticModule } from '@nestjs/serve-static'; +import { existsSync } from 'fs'; import { join } from 'path'; import { PrismaModule } from './prisma/prisma.module'; import { MailModule } from './mail/mail.module'; @@ -14,13 +15,22 @@ import { FilesModule } from './files/files.module'; import { ChatModule } from './chat/chat.module'; import { SyncModule } from './sync/sync.module'; +// Prefer the Flutter web build (single entry point at :3000, incl. the OIDC +// redirect path /v1/auth/callback via SPA fallback). Falls back to the plain +// interim client if the Flutter build hasn't been produced yet. +const flutterWeb = join(__dirname, '..', '..', 'client', 'app', 'build', 'web'); +const interimWeb = join(__dirname, '..', '..', 'client', 'web'); +const webRoot = + process.env.WEB_CLIENT_DIR ?? (existsSync(flutterWeb) ? flutterWeb : interimWeb); + @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), - // Serves the plain static web client from ../client/web; the REST API - // lives under /api (see main.ts) so it never collides with these routes. + // Static web client; the REST API lives under /api (see main.ts) so it + // never collides. Unmatched non-file paths fall back to index.html so the + // client-side router owns routes like /v1/auth/callback. ServeStaticModule.forRoot({ - rootPath: join(__dirname, '..', '..', 'client', 'web'), + rootPath: webRoot, exclude: ['/api*'], }), PrismaModule, diff --git a/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index 9fca443..474e6e4 100644 --- a/src/auth/authentik.strategy.ts +++ b/src/auth/authentik.strategy.ts @@ -32,18 +32,20 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { private readonly prisma: PrismaClient, private readonly sync: SyncService, ) { - const issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); + // Authentik's discovery `issuer` carries a trailing slash and so does the + // `iss` claim in its tokens; accept both spellings and never emit `//`. + const base = config.getOrThrow('AUTHENTIK_ISSUER_URL').replace(/\/+$/, ''); super({ jwtFromRequest: (req: Request) => req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice('Bearer '.length) : null, secretOrKeyProvider: jwksRsa.passportJwtSecret({ - jwksUri: `${issuerUrl}/jwks/`, + jwksUri: `${base}/jwks/`, cache: true, rateLimit: true, }), - issuer: issuerUrl, + issuer: [base, `${base}/`], algorithms: ['RS256'], }); this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); diff --git a/src/auth/team-auth.service.ts b/src/auth/team-auth.service.ts index 048333e..935f781 100644 --- a/src/auth/team-auth.service.ts +++ b/src/auth/team-auth.service.ts @@ -12,6 +12,7 @@ import * as jwt from 'jsonwebtoken'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; +import { toAuthenticatedUser } from './provision-user'; export interface TeamJwtPayload { sub: string; @@ -146,15 +147,8 @@ export class TeamAuthService { if (!user) { throw new UnauthorizedException('Team account no longer exists'); } - return { - userId: user.id, - authentikSub: user.authentikSub, - email: user.email, - memberships: user.memberships.map((m) => ({ - kcId: m.kcId, - gemeindeId: m.gemeindeId, - role: m.role, - })), - }; + // Same shape as the Authentik path, incl. the synthetic global + // LEITUNGSTEAM membership when `isLeitungsteam` is set on the row. + return toAuthenticatedUser(user); } } diff --git a/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index f3caa60..3d48559 100644 --- a/src/auth/token-verification.service.ts +++ b/src/auth/token-verification.service.ts @@ -29,7 +29,8 @@ export class TokenVerificationService { private readonly teamAuth: TeamAuthService, private readonly sync: SyncService, ) { - this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); + // See AuthentikStrategy: normalise the trailing slash, accept both forms. + this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL').replace(/\/+$/, ''); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); } @@ -48,7 +49,7 @@ export class TokenVerificationService { } const key = await this.jwks.getSigningKey(kid); const payload = jwt.verify(token, key.getPublicKey(), { - issuer: this.issuerUrl, + issuer: [this.issuerUrl, `${this.issuerUrl}/`], algorithms: ['RS256'], }) as jwt.JwtPayload & { email?: string; diff --git a/src/gemeinde/gemeinde.controller.ts b/src/gemeinde/gemeinde.controller.ts index 5f404ef..9b6c06a 100644 --- a/src/gemeinde/gemeinde.controller.ts +++ b/src/gemeinde/gemeinde.controller.ts @@ -21,7 +21,7 @@ import { Role } from '../common/role.enum'; /// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer /// learn their own Gemeinde from their Membership, not from this endpoint. @Controller('gemeinde') -@UseGuards(AuthGuard('authentik'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) export class GemeindeController { constructor(private readonly gemeinde: GemeindeService) {} diff --git a/src/kc/kc.controller.ts b/src/kc/kc.controller.ts index b520137..896cb02 100644 --- a/src/kc/kc.controller.ts +++ b/src/kc/kc.controller.ts @@ -7,7 +7,7 @@ import { RolesGuard } from '../common/roles.guard'; import { Role } from '../common/role.enum'; @Controller('kc') -@UseGuards(AuthGuard('authentik'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) export class KcController { constructor(private readonly kc: KcService) {} diff --git a/src/onboarding/onboarding.controller.ts b/src/onboarding/onboarding.controller.ts index 4fb1861..dabbffe 100644 --- a/src/onboarding/onboarding.controller.ts +++ b/src/onboarding/onboarding.controller.ts @@ -45,21 +45,21 @@ export class OnboardingController { /// Leitungsteam: review and act on pending self-registrations. @Get('requests') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) listRequests(@Query('kcId') kcId: string) { return this.onboarding.listRequests(kcId); } @Post('requests/:membershipId/approve') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) approve(@Param('membershipId') membershipId: string) { return this.onboarding.approve(membershipId); } @Post('requests/:membershipId/reject') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) reject(@Param('membershipId') membershipId: string) { return this.onboarding.reject(membershipId); diff --git a/src/sync/sync.controller.ts b/src/sync/sync.controller.ts index 4a06dfc..c84107f 100644 --- a/src/sync/sync.controller.ts +++ b/src/sync/sync.controller.ts @@ -33,7 +33,7 @@ export class SyncController { /// Manual on-demand push+pull against the configured peer (Leitungsteam-only). @Post('trigger') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) async trigger() { const peerUrl = this.config.getOrThrow('SYNC_PEER_URL'); diff --git a/src/teamer/teamer.controller.ts b/src/teamer/teamer.controller.ts index ba11723..ddc3cde 100644 --- a/src/teamer/teamer.controller.ts +++ b/src/teamer/teamer.controller.ts @@ -18,10 +18,11 @@ import { Role } from '../common/role.enum'; import { AuthenticatedRequest } from '../auth/authenticated-request'; /// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and -/// Gemeinde Verantwortliche (both Authentik-backed); TeamerService then -/// checks the caller is actually responsible for `:gemeindeId`. +/// Gemeinde Verantwortliche (Authentik tokens, or a local `isLeitungsteam` +/// account via a team token); TeamerService then checks the caller is +/// actually responsible for `:gemeindeId`. @Controller('gemeinde/:gemeindeId') -@UseGuards(AuthGuard('authentik'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER) export class TeamerController { constructor(private readonly teamer: TeamerService) {}