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 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? _discovery; Future> _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; } /// Kicks off the redirect to Authentik. Does not return (page navigates). Future 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 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); } Future 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); } static String _randomUrlToken(int length) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; final rnd = Random.secure(); return List.generate(length, (_) => chars[rnd.nextInt(chars.length)]).join(); } }