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 d5ecdcd3c4
commit df11d8492d
20 changed files with 693 additions and 49 deletions
+151
View File
@@ -0,0 +1,151 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'browser.dart' as browser;
/// Authentik OIDC config. Overridable with --dart-define; defaults are the
/// konfi-castle production values (a *public* client — PKCE, no secret).
const kOidcIssuer = String.fromEnvironment(
'OIDC_ISSUER',
defaultValue: 'https://sso.konfi-castle.com/application/o/konfi-castle-app/',
);
const kOidcClientId = String.fromEnvironment(
'OIDC_CLIENT_ID',
defaultValue: 'K7f9mn6bP6jSjZDMYuiZCXMeVmVcqFcYNj0blJk9',
);
const kOidcRedirectUri = String.fromEnvironment(
'OIDC_REDIRECT_URI',
defaultValue: 'http://localhost:3000/v1/auth/callback',
);
const _scope = 'openid profile email groups offline_access';
const _verifierKey = 'oidc_verifier';
const _stateKey = 'oidc_state';
class OidcTokens {
OidcTokens({required this.accessToken, this.refreshToken, this.expiresIn});
final String accessToken;
final String? refreshToken;
final int? expiresIn;
factory OidcTokens.fromJson(Map<String, dynamic> j) => OidcTokens(
accessToken: j['access_token'] as String,
refreshToken: j['refresh_token'] as String?,
expiresIn: (j['expires_in'] as num?)?.toInt(),
);
}
class OidcException implements Exception {
OidcException(this.message);
final String message;
@override
String toString() => 'OidcException: $message';
}
/// Authorization-Code + PKCE against Authentik, for the browser. The backend
/// only validates the resulting access token (resource-server pattern).
class OidcClient {
OidcClient(this._http);
final http.Client _http;
Map<String, dynamic>? _discovery;
Future<Map<String, dynamic>> _disc() async {
if (_discovery != null) return _discovery!;
final base = kOidcIssuer.endsWith('/') ? kOidcIssuer : '$kOidcIssuer/';
final res = await _http.get(Uri.parse('$base.well-known/openid-configuration'));
if (res.statusCode != 200) {
throw OidcException('Discovery failed (${res.statusCode})');
}
return _discovery = jsonDecode(res.body) as Map<String, dynamic>;
}
/// Kicks off the redirect to Authentik. Does not return (page navigates).
Future<void> beginLogin() async {
final d = await _disc();
final verifier = _randomUrlToken(64);
final state = _randomUrlToken(24);
final challenge = base64UrlEncode(sha256.convert(ascii.encode(verifier)).bytes)
.replaceAll('=', '');
browser.setSession(_verifierKey, verifier);
browser.setSession(_stateKey, state);
final authUri = Uri.parse(d['authorization_endpoint'] as String).replace(
queryParameters: {
'response_type': 'code',
'client_id': kOidcClientId,
'redirect_uri': kOidcRedirectUri,
'scope': _scope,
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
},
);
browser.redirect(authUri.toString());
}
/// If the current URL carries `?code=…`, exchanges it for tokens and scrubs
/// the query. Returns null when this isn't a callback load.
Future<OidcTokens?> completeIfCallback() async {
final params = browser.currentQueryParameters();
final code = params['code'];
if (code == null || code.isEmpty) {
if (params['error'] != null) {
browser.clearQuery();
throw OidcException('Authentik: ${params['error_description'] ?? params['error']}');
}
return null;
}
final expectedState = browser.getSession(_stateKey);
final verifier = browser.getSession(_verifierKey);
browser.removeSession(_stateKey);
browser.removeSession(_verifierKey);
browser.clearQuery();
if (verifier == null || params['state'] != expectedState) {
throw OidcException('State mismatch — please retry the login.');
}
final d = await _disc();
final res = await _http.post(
Uri.parse(d['token_endpoint'] as String),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': kOidcRedirectUri,
'client_id': kOidcClientId,
'code_verifier': verifier,
},
);
if (res.statusCode != 200) {
throw OidcException('Token exchange failed (${res.statusCode}): ${res.body}');
}
return OidcTokens.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
}
Future<OidcTokens> refresh(String refreshToken) async {
final d = await _disc();
final res = await _http.post(
Uri.parse(d['token_endpoint'] as String),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': kOidcClientId,
},
);
if (res.statusCode != 200) {
throw OidcException('Refresh failed (${res.statusCode})');
}
return OidcTokens.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
}
static String _randomUrlToken(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final rnd = Random.secure();
return List.generate(length, (_) => chars[rnd.nextInt(chars.length)]).join();
}
}