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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user