feat: Authentik OIDC login (PKCE) + Leitungsteam admin screens

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:24:38 +02:00
co-authored by Claude Sonnet 5
parent a21a9c1cc4
commit 847fed8dad
10 changed files with 37 additions and 29 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# Postgres connection used by Prisma # Postgres connection used by Prisma
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public" DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/ # Authentik OIDC issuer (trailing slash optional — both forms are accepted).
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-app"
# 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`
+13 -3
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
@@ -14,13 +15,22 @@ import { FilesModule } from './files/files.module';
import { ChatModule } from './chat/chat.module'; import { ChatModule } from './chat/chat.module';
import { SyncModule } from './sync/sync.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({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
// Serves the plain static web client from ../client/web; the REST API // Static web client; the REST API lives under /api (see main.ts) so it
// lives under /api (see main.ts) so it never collides with these routes. // never collides. Unmatched non-file paths fall back to index.html so the
// client-side router owns routes like /v1/auth/callback.
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', '..', 'client', 'web'), rootPath: webRoot,
exclude: ['/api*'], exclude: ['/api*'],
}), }),
PrismaModule, PrismaModule,
+5 -3
View File
@@ -32,18 +32,20 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly sync: SyncService, private readonly sync: SyncService,
) { ) {
const issuerUrl = config.getOrThrow<string>('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<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
super({ super({
jwtFromRequest: (req: Request) => jwtFromRequest: (req: Request) =>
req.headers.authorization?.startsWith('Bearer ') req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice('Bearer '.length) ? req.headers.authorization.slice('Bearer '.length)
: null, : null,
secretOrKeyProvider: jwksRsa.passportJwtSecret({ secretOrKeyProvider: jwksRsa.passportJwtSecret({
jwksUri: `${issuerUrl}/jwks/`, jwksUri: `${base}/jwks/`,
cache: true, cache: true,
rateLimit: true, rateLimit: true,
}), }),
issuer: issuerUrl, issuer: [base, `${base}/`],
algorithms: ['RS256'], algorithms: ['RS256'],
}); });
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
+4 -10
View File
@@ -12,6 +12,7 @@ import * as jwt from 'jsonwebtoken';
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 { toAuthenticatedUser } from './provision-user';
export interface TeamJwtPayload { export interface TeamJwtPayload {
sub: string; sub: string;
@@ -146,15 +147,8 @@ export class TeamAuthService {
if (!user) { if (!user) {
throw new UnauthorizedException('Team account no longer exists'); throw new UnauthorizedException('Team account no longer exists');
} }
return { // Same shape as the Authentik path, incl. the synthetic global
userId: user.id, // LEITUNGSTEAM membership when `isLeitungsteam` is set on the row.
authentikSub: user.authentikSub, return toAuthenticatedUser(user);
email: user.email,
memberships: user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
} }
} }
+3 -2
View File
@@ -29,7 +29,8 @@ export class TokenVerificationService {
private readonly teamAuth: TeamAuthService, private readonly teamAuth: TeamAuthService,
private readonly sync: SyncService, private readonly sync: SyncService,
) { ) {
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL'); // See AuthentikStrategy: normalise the trailing slash, accept both forms.
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
} }
@@ -48,7 +49,7 @@ export class TokenVerificationService {
} }
const key = await this.jwks.getSigningKey(kid); const key = await this.jwks.getSigningKey(kid);
const payload = jwt.verify(token, key.getPublicKey(), { const payload = jwt.verify(token, key.getPublicKey(), {
issuer: this.issuerUrl, issuer: [this.issuerUrl, `${this.issuerUrl}/`],
algorithms: ['RS256'], algorithms: ['RS256'],
}) as jwt.JwtPayload & { }) as jwt.JwtPayload & {
email?: string; email?: string;
+1 -1
View File
@@ -21,7 +21,7 @@ import { Role } from '../common/role.enum';
/// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer /// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer
/// learn their own Gemeinde from their Membership, not from this endpoint. /// learn their own Gemeinde from their Membership, not from this endpoint.
@Controller('gemeinde') @Controller('gemeinde')
@UseGuards(AuthGuard('authentik'), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM) @Roles(Role.LEITUNGSTEAM)
export class GemeindeController { export class GemeindeController {
constructor(private readonly gemeinde: GemeindeService) {} constructor(private readonly gemeinde: GemeindeService) {}
+1 -1
View File
@@ -7,7 +7,7 @@ import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum'; import { Role } from '../common/role.enum';
@Controller('kc') @Controller('kc')
@UseGuards(AuthGuard('authentik'), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
export class KcController { export class KcController {
constructor(private readonly kc: KcService) {} constructor(private readonly kc: KcService) {}
+3 -3
View File
@@ -45,21 +45,21 @@ export class OnboardingController {
/// Leitungsteam: review and act on pending self-registrations. /// Leitungsteam: review and act on pending self-registrations.
@Get('requests') @Get('requests')
@UseGuards(AuthGuard('authentik'), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM) @Roles(Role.LEITUNGSTEAM)
listRequests(@Query('kcId') kcId: string) { listRequests(@Query('kcId') kcId: string) {
return this.onboarding.listRequests(kcId); return this.onboarding.listRequests(kcId);
} }
@Post('requests/:membershipId/approve') @Post('requests/:membershipId/approve')
@UseGuards(AuthGuard('authentik'), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM) @Roles(Role.LEITUNGSTEAM)
approve(@Param('membershipId') membershipId: string) { approve(@Param('membershipId') membershipId: string) {
return this.onboarding.approve(membershipId); return this.onboarding.approve(membershipId);
} }
@Post('requests/:membershipId/reject') @Post('requests/:membershipId/reject')
@UseGuards(AuthGuard('authentik'), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM) @Roles(Role.LEITUNGSTEAM)
reject(@Param('membershipId') membershipId: string) { reject(@Param('membershipId') membershipId: string) {
return this.onboarding.reject(membershipId); return this.onboarding.reject(membershipId);
+1 -1
View File
@@ -33,7 +33,7 @@ export class SyncController {
/// Manual on-demand push+pull against the configured peer (Leitungsteam-only). /// Manual on-demand push+pull against the configured peer (Leitungsteam-only).
@Post('trigger') @Post('trigger')
@UseGuards(AuthGuard('authentik'), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM) @Roles(Role.LEITUNGSTEAM)
async trigger() { async trigger() {
const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL'); const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL');
+4 -3
View File
@@ -18,10 +18,11 @@ import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request'; import { AuthenticatedRequest } from '../auth/authenticated-request';
/// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and /// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and
/// Gemeinde Verantwortliche (both Authentik-backed); TeamerService then /// Gemeinde Verantwortliche (Authentik tokens, or a local `isLeitungsteam`
/// checks the caller is actually responsible for `:gemeindeId`. /// account via a team token); TeamerService then checks the caller is
/// actually responsible for `:gemeindeId`.
@Controller('gemeinde/:gemeindeId') @Controller('gemeinde/:gemeindeId')
@UseGuards(AuthGuard('authentik'), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER) @Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
export class TeamerController { export class TeamerController {
constructor(private readonly teamer: TeamerService) {} constructor(private readonly teamer: TeamerService) {}