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'; /// 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'; // PKCE state stored during auth flow (in-memory for single-page app) static String? _codeVerifier; static String? _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 _codeVerifier = _generateCodeVerifier(); final codeChallenge = _generateCodeChallenge(_codeVerifier!); // Generate state for CSRF protection _state = _generateRandomString(32); // 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 { // Verify state matches if (_state == null || state != _state) { throw OidcException('State mismatch - possible CSRF attack'); } if (_codeVerifier == null) { 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 _codeVerifier = null; _state = null; 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'); throw OidcException('Token exchange failed: ${e.message}'); } finally { // Clear PKCE state on error too _codeVerifier = null; _state = null; } } /// 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(); } }