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. Future getAuthorizationUrl() 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, }; final uri = Uri.parse(authEndpoint).replace(queryParameters: params); developer.log('Authorization URL: $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 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>( 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 signIn() async { throw OidcException( 'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.', ); } /// Refresh the access token using a refresh token. @override Future refreshToken(String refreshToken) async { try { final discovery = await _fetchDiscovery(); final tokenEndpoint = discovery['token_endpoint'] as String; final response = await _dio.post>( 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> _fetchDiscovery() async { final response = await _dio.get>( 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(); } }