- Add prompt=none to silently obtain JWT when Authentik session exists - Flutter sends Bearer token to core-api instead of forward auth cookies - Fixes cross-subdomain cookie issues between home/api.schweitz.net - Callback syncs with /auth/sync for user profile and roles - API interceptor now adds Bearer token on web 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
246 lines
8.3 KiB
Dart
246 lines
8.3 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:developer' as developer;
|
|
import 'dart:math';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:dio/dio.dart';
|
|
|
|
import '../config/app_config.dart';
|
|
import 'oidc_service.dart';
|
|
import 'web_utils.dart' as web_utils;
|
|
|
|
/// Web implementation of OIDC service using browser redirect flow.
|
|
///
|
|
/// Uses Authorization Code flow with PKCE for secure authentication.
|
|
/// On web, we can't use flutter_appauth, so we implement the flow manually
|
|
/// using browser redirects and URL parsing.
|
|
class OidcServiceWeb implements OidcService {
|
|
OidcServiceWeb({Dio? dio}) : _dio = dio ?? Dio();
|
|
|
|
final Dio _dio;
|
|
|
|
/// OIDC scopes to request.
|
|
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
|
|
|
|
/// Redirect URI for web.
|
|
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
|
|
|
|
// SessionStorage keys for PKCE state (persists across redirect)
|
|
static const _codeVerifierKey = 'oidc_code_verifier';
|
|
static const _stateKey = 'oidc_state';
|
|
|
|
/// Get the authorization URL to redirect the browser to.
|
|
///
|
|
/// Returns a URL that the browser should navigate to for authentication.
|
|
/// The [codeVerifier] and [state] are stored for later verification.
|
|
///
|
|
/// If [silent] is true, adds `prompt=none` to skip login UI.
|
|
/// This is used when the user already has an Authentik session (via NPM).
|
|
/// Authentik will instantly redirect back with a code, or return an error
|
|
/// if there's no valid session.
|
|
Future<String> getAuthorizationUrl({bool silent = false}) async {
|
|
// Fetch OIDC discovery document
|
|
final discovery = await _fetchDiscovery();
|
|
final authEndpoint = discovery['authorization_endpoint'] as String;
|
|
|
|
// Generate PKCE code verifier and challenge
|
|
final codeVerifier = _generateCodeVerifier();
|
|
final codeChallenge = _generateCodeChallenge(codeVerifier);
|
|
|
|
// Generate state for CSRF protection
|
|
final state = _generateRandomString(32);
|
|
|
|
// Store PKCE state in sessionStorage (persists across redirect)
|
|
web_utils.setSessionStorage(_codeVerifierKey, codeVerifier);
|
|
web_utils.setSessionStorage(_stateKey, state);
|
|
|
|
// Build authorization URL
|
|
final params = {
|
|
'client_id': AppConfig.authClientId,
|
|
'redirect_uri': _redirectUri,
|
|
'response_type': 'code',
|
|
'scope': _scopes.join(' '),
|
|
'code_challenge': codeChallenge,
|
|
'code_challenge_method': 'S256',
|
|
'state': state,
|
|
if (silent) 'prompt': 'none', // Silent auth - no UI, instant redirect
|
|
};
|
|
|
|
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
|
|
developer.log('Authorization URL (silent=$silent): $uri', name: 'oidc_web');
|
|
return uri.toString();
|
|
}
|
|
|
|
/// Exchange authorization code for tokens.
|
|
///
|
|
/// Call this after the browser redirects back with the authorization code.
|
|
/// [code] is the authorization code from the callback URL.
|
|
/// [state] is the state parameter from the callback URL (verified for CSRF).
|
|
Future<OidcTokens> exchangeCode(String code, String state) async {
|
|
// Retrieve PKCE state from sessionStorage
|
|
final storedState = web_utils.getSessionStorage(_stateKey);
|
|
final codeVerifier = web_utils.getSessionStorage(_codeVerifierKey);
|
|
|
|
developer.log('Stored state: $storedState, received state: $state', name: 'oidc_web');
|
|
developer.log('Code verifier present: ${codeVerifier != null}', name: 'oidc_web');
|
|
|
|
// Verify state matches
|
|
if (storedState == null || state != storedState) {
|
|
_clearPkceState();
|
|
throw OidcException('State mismatch - possible CSRF attack');
|
|
}
|
|
|
|
if (codeVerifier == null) {
|
|
_clearPkceState();
|
|
throw OidcException('No code verifier - flow not started properly');
|
|
}
|
|
|
|
try {
|
|
// Fetch token endpoint from discovery
|
|
final discovery = await _fetchDiscovery();
|
|
final tokenEndpoint = discovery['token_endpoint'] as String;
|
|
|
|
developer.log('Exchanging code at: $tokenEndpoint', name: 'oidc_web');
|
|
|
|
// Exchange code for tokens
|
|
final response = await _dio.post<Map<String, dynamic>>(
|
|
tokenEndpoint,
|
|
data: {
|
|
'grant_type': 'authorization_code',
|
|
'client_id': AppConfig.authClientId,
|
|
'redirect_uri': _redirectUri,
|
|
'code': code,
|
|
'code_verifier': codeVerifier,
|
|
},
|
|
options: Options(
|
|
contentType: Headers.formUrlEncodedContentType,
|
|
),
|
|
);
|
|
|
|
final data = response.data!;
|
|
developer.log('Token exchange successful', name: 'oidc_web');
|
|
|
|
// Clear stored PKCE state
|
|
_clearPkceState();
|
|
|
|
return OidcTokens(
|
|
accessToken: data['access_token'] as String,
|
|
refreshToken: data['refresh_token'] as String?,
|
|
expiresAt: DateTime.now().add(
|
|
Duration(seconds: data['expires_in'] as int? ?? 3600),
|
|
),
|
|
idToken: data['id_token'] as String?,
|
|
);
|
|
} on DioException catch (e) {
|
|
developer.log('Token exchange failed: $e', name: 'oidc_web');
|
|
_clearPkceState();
|
|
throw OidcException('Token exchange failed: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// Clear PKCE state from sessionStorage.
|
|
void _clearPkceState() {
|
|
web_utils.removeSessionStorage(_codeVerifierKey);
|
|
web_utils.removeSessionStorage(_stateKey);
|
|
}
|
|
|
|
/// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead.
|
|
@override
|
|
Future<OidcTokens> signIn() async {
|
|
throw OidcException(
|
|
'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.',
|
|
);
|
|
}
|
|
|
|
/// Refresh the access token using a refresh token.
|
|
@override
|
|
Future<OidcTokens> refreshToken(String refreshToken) async {
|
|
try {
|
|
final discovery = await _fetchDiscovery();
|
|
final tokenEndpoint = discovery['token_endpoint'] as String;
|
|
|
|
final response = await _dio.post<Map<String, dynamic>>(
|
|
tokenEndpoint,
|
|
data: {
|
|
'grant_type': 'refresh_token',
|
|
'client_id': AppConfig.authClientId,
|
|
'refresh_token': refreshToken,
|
|
},
|
|
options: Options(
|
|
contentType: Headers.formUrlEncodedContentType,
|
|
),
|
|
);
|
|
|
|
final data = response.data!;
|
|
|
|
return OidcTokens(
|
|
accessToken: data['access_token'] as String,
|
|
refreshToken: data['refresh_token'] as String? ?? refreshToken,
|
|
expiresAt: DateTime.now().add(
|
|
Duration(seconds: data['expires_in'] as int? ?? 3600),
|
|
),
|
|
idToken: data['id_token'] as String?,
|
|
);
|
|
} on DioException catch (e) {
|
|
throw OidcException('Token refresh failed: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// Fetch OIDC discovery document.
|
|
Future<Map<String, dynamic>> _fetchDiscovery() async {
|
|
final response = await _dio.get<Map<String, dynamic>>(
|
|
AppConfig.authDiscoveryUrl,
|
|
);
|
|
return response.data!;
|
|
}
|
|
|
|
/// Generate a random code verifier for PKCE.
|
|
String _generateCodeVerifier() {
|
|
return _generateRandomString(64);
|
|
}
|
|
|
|
/// Generate code challenge from verifier using S256.
|
|
String _generateCodeChallenge(String verifier) {
|
|
final bytes = utf8.encode(verifier);
|
|
final digest = sha256.convert(bytes);
|
|
return base64Url.encode(digest.bytes).replaceAll('=', '');
|
|
}
|
|
|
|
/// Generate a random string of given length.
|
|
String _generateRandomString(int length) {
|
|
const chars =
|
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
final random = Random.secure();
|
|
return List.generate(length, (_) => chars[random.nextInt(chars.length)])
|
|
.join();
|
|
}
|
|
|
|
/// Get the logout URL to redirect the browser to for SSO logout.
|
|
///
|
|
/// [idToken] is optional but recommended for logout verification.
|
|
/// After logout, Authentik redirects back to [postLogoutRedirectUri].
|
|
Future<String> getLogoutUrl({String? idToken}) async {
|
|
final discovery = await _fetchDiscovery();
|
|
final endSessionEndpoint = discovery['end_session_endpoint'] as String?;
|
|
|
|
if (endSessionEndpoint == null) {
|
|
// Fallback: just redirect to home, local state already cleared
|
|
developer.log('No end_session_endpoint in discovery', name: 'oidc_web');
|
|
return AppConfig.webBaseUrl;
|
|
}
|
|
|
|
final params = <String, String>{
|
|
'post_logout_redirect_uri': AppConfig.webBaseUrl,
|
|
};
|
|
|
|
if (idToken != null) {
|
|
params['id_token_hint'] = idToken;
|
|
}
|
|
|
|
final uri = Uri.parse(endSessionEndpoint).replace(queryParameters: params);
|
|
developer.log('Logout URL: $uri', name: 'oidc_web');
|
|
return uri.toString();
|
|
}
|
|
}
|