import 'dart:convert'; import 'dart:math'; import 'dart:io'; import 'package:logging/logging.dart'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:crypto/crypto.dart'; import 'package:floaty/features/api/repositories/fpapi.dart'; import 'package:floaty/features/player/controllers/media_player_service.dart'; import 'package:floaty/features/router/views/root_layout.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:http_cache_hive_store/http_cache_hive_store.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:dio/dio.dart'; import 'package:floaty/settings.dart'; import 'package:floaty/whitelabels.dart'; import 'package:window_manager/window_manager.dart'; /// OpenID Connect Discovery Configuration class OpenIDConfig { final String issuer; final String authorizationEndpoint; final String tokenEndpoint; final String userinfoEndpoint; final String endSessionEndpoint; final String revocationEndpoint; final List scopesSupported; final List responseTypesSupported; final List grantTypesSupported; OpenIDConfig({ required this.issuer, required this.authorizationEndpoint, required this.tokenEndpoint, required this.userinfoEndpoint, required this.endSessionEndpoint, required this.revocationEndpoint, required this.scopesSupported, required this.responseTypesSupported, required this.grantTypesSupported, }); factory OpenIDConfig.fromJson(Map json) { return OpenIDConfig( issuer: json['issuer'] as String, authorizationEndpoint: json['authorization_endpoint'] as String, tokenEndpoint: json['token_endpoint'] as String, userinfoEndpoint: json['userinfo_endpoint'] as String, endSessionEndpoint: json['end_session_endpoint'] as String, revocationEndpoint: json['revocation_endpoint'] as String, scopesSupported: List.from(json['scopes_supported'] ?? []), responseTypesSupported: List.from(json['response_types_supported'] ?? []), grantTypesSupported: List.from(json['grant_types_supported'] ?? []), ); } } class OAuth2Service { final Logger _log = Logger('OAuth2Service'); static final OAuth2Service instance = OAuth2Service(); static const FlutterAppAuth _appAuth = FlutterAppAuth(); HttpServer? server; // Lock mechanism to prevent concurrent token operations per whitelabel final Map> _pendingTokenOperations = {}; final Map> _pendingRefreshOperations = {}; // feat flag for oauth overrides static const bool _useOAuthOverrides = bool.fromEnvironment('USE_OAUTH_OVERRIDES', defaultValue: false); static const String _overrideClientId = String.fromEnvironment('OAUTH_CLIENT_ID'); static const String _overrideAuthEndpoint = String.fromEnvironment('OAUTH_AUTH_ENDPOINT'); static const String _overrideTokenEndpoint = String.fromEnvironment('OAUTH_TOKEN_ENDPOINT'); static const String _overrideUserinfoEndpoint = String.fromEnvironment('OAUTH_USERINFO_ENDPOINT'); static const String _overrideRevocationEndpoint = String.fromEnvironment('OAUTH_REVOCATION_ENDPOINT'); // generic defaults incase something breaks static const String _defaultClientId = 'floaty'; static const String _redirectUrl = 'uk.bw86.floaty://oauth/callback'; static const String _redirectUrlDesktop = 'http://localhost:36479/oauth/callback'; static const List _scopes = [ "openid", // "videos.download", "roles", "profile", // "user.read", // "comments:read", "email", // "videos.watch", "offline_access", // "comments:write", "basic", //UNUSED SCOPES (keep for reference) // "web-origins" // "phone" // "organization" // "acr" // "address" ]; final Dio _dio; OpenIDConfig? _cachedConfig; String? _cachedConfigUrl; OAuth2Service({Dio? dio}) : _dio = dio ?? Dio(BaseOptions(validateStatus: (_) => true)); /// Get the client ID - uses override if flag is enabled, otherwise defaults to floaty Future getClientId() async { if (_useOAuthOverrides) { return _overrideClientId; } return _defaultClientId; } /// Fetches OpenID Connect Discovery configuration from the whitelabel /// Returns null if config URL is not available (will use fallback defaults) Future getOpenIDConfig({WhiteLabel? whiteLabel}) async { // If using overrides, skip dynamic config fetching if (_useOAuthOverrides) { return null; } try { final whitelabel = whiteLabel ?? await whitelabels.getSelectedWhitelabel(); final configUrl = whitelabel.oauthconfigurl; if (configUrl == null || configUrl.isEmpty) { return null; } // Return cached config if available and URL hasn't changed if (_cachedConfig != null && _cachedConfigUrl == configUrl) { return _cachedConfig; } final response = await _dio.get(configUrl); if (response.statusCode == 200) { final config = OpenIDConfig.fromJson(response.data); // Cache the config _cachedConfig = config; _cachedConfigUrl = configUrl; return config; } else { return null; } } catch (e) { return null; } } /// Determines if we're running on a platform that supports flutter_appauth bool get _canUseAppAuth { return !kIsWeb && (Platform.isAndroid || Platform.isIOS); } /// Gets the appropriate redirect URL for the current platform String get _currentRedirectUrl { if (_canUseAppAuth) { return _redirectUrl; } else { return _redirectUrlDesktop; } } /// Main login method that chooses the appropriate flow Future login({WhiteLabel? whiteLabel}) async { try { // Try to fetch OpenID configuration (optional) await getOpenIDConfig(whiteLabel: whiteLabel); if (_canUseAppAuth) { return _performMobileAuthFlow(whiteLabel: whiteLabel); } else { return _performDesktopAuthFlow(whiteLabel: whiteLabel); } } catch (e) { return OAuth2Result.error('Login failed: $e'); } } /// Logout method that clears the appropriate whitelabel(s) tokens. Future logout(WhiteLabelWithUser whitelabel) async { try { final accessToken = await getAccessToken( whitelabel: whitelabel.whitelabel.friendlyName, ); // Try to revoke token on server if (accessToken != null) { await revoke(accessToken); } // Clear OAuth2 client from cache fpApiRequests.clearOAuth2Client(whitelabel.whitelabel.friendlyName); // Clear OAuth2 tokens for this whitelabel await clearStoredTokens( whitelabel: whitelabel.whitelabel.friendlyName, ); // Call logout API endpoint await fpApiRequests.logout(whitelabel.whitelabel.friendlyName); // Clear cookies (try both with and without www) final dir = await getApplicationSupportDirectory(); final cookieJar = PersistCookieJar( storage: FileStorage('${dir.path}/.cookies/'), ); await cookieJar.delete(Uri.parse( 'https://www.${whitelabel.whitelabel.domain}', )); await cookieJar.delete(Uri.parse( 'https://${whitelabel.whitelabel.domain}', )); // Clear cache final hiveStore = HiveCacheStore('${dir.path}/.dio_cache'); await hiveStore.deleteFromPath( RegExp('https://www.${whitelabel.whitelabel.domain}')); // Remove from logged in labels await whitelabels.removeLoggedInLabel(whitelabel.whitelabel.friendlyName); // Update selected whitelabel if needed if ((await whitelabels.getFirstLoggedInLabelOrDefault()).friendlyName == (await settings.getKey('whitelabel'))) { rootLayoutKey.currentState?.ref .read(mediaPlayerServiceProvider.notifier) .changeState(MediaPlayerState.none); } await settings.setKey('whitelabel', (await whitelabels.getFirstLoggedInLabelOrDefault()).friendlyName); return true; } catch (e) { return false; } } /// Mobile OAuth flow using flutter_appauth Future _performMobileAuthFlow({WhiteLabel? whiteLabel}) async { try { final config = await getOpenIDConfig(whiteLabel: whiteLabel); final clientId = await getClientId(); // Use overrides if flag enabled, otherwise use dynamic config or Floatplane defaults final authEndpoint = _useOAuthOverrides ? _overrideAuthEndpoint : (config?.authorizationEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth'); final tokenEndpoint = _useOAuthOverrides ? _overrideTokenEndpoint : (config?.tokenEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token'); final AuthorizationTokenResponse result = await _appAuth.authorizeAndExchangeCode( AuthorizationTokenRequest( clientId, _redirectUrl, serviceConfiguration: AuthorizationServiceConfiguration( authorizationEndpoint: authEndpoint, tokenEndpoint: tokenEndpoint, ), scopes: _scopes, promptValues: ['login'], ), ); Map? userInfo; if (result.idToken != null) { userInfo = JwtDecoder.decode(result.idToken!); } return OAuth2Result.success( accessToken: result.accessToken!, refreshToken: result.refreshToken, idToken: result.idToken, userInfo: userInfo, expirationDateTime: result.accessTokenExpirationDateTime, ); } on PlatformException catch (e) { if (e.code == 'authorize_and_exchange_code_failed') { return OAuth2Result.cancelled(); } else { return OAuth2Result.error('OAuth2 Error: ${e.message}'); } } catch (e) { return OAuth2Result.error('Unexpected error: $e'); } } /// Desktop OAuth flow using manual browser launch and local server Future _performDesktopAuthFlow({WhiteLabel? whiteLabel}) async { // Close any existing server to free the port try { await server?.close(force: true); server = null; } catch (_) { // Ignore errors when closing } try { final config = await getOpenIDConfig(whiteLabel: whiteLabel); final clientId = await getClientId(); _log.fine('OpenID config: ${config.toString()}'); // Use overrides if flag enabled, otherwise use dynamic config or Floatplane defaults final authEndpoint = _useOAuthOverrides ? _overrideAuthEndpoint : (config?.authorizationEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth'); // Generate PKCE parameters final String codeVerifier = _generateCodeVerifier(); final String codeChallenge = _generateCodeChallenge(codeVerifier); final String state = _generateState(); OAuth2Result? result; try { server?.close(); server = await HttpServer.bind('localhost', 36479, shared: true); // Build authorization URL final authUri = Uri.parse(authEndpoint).replace( queryParameters: { 'client_id': clientId, 'redirect_uri': _redirectUrlDesktop, 'response_type': 'code', 'scope': _scopes.join(' '), 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', 'state': state, 'prompt': 'login', }, ); // Launch browser if (await canLaunchUrl(authUri)) { await launchUrl(authUri, mode: LaunchMode.externalApplication); } else { return OAuth2Result.error('Could not launch browser'); } // Listen for callback if (server == null) { return OAuth2Result.error('Server failed to start'); } await for (HttpRequest request in server!) { if (request.uri.path == '/oauth/callback') { final queryParams = request.uri.queryParameters; if (queryParams.containsKey('error')) { result = OAuth2Result.error( queryParams['error'] ?? 'Authorization failed'); } else if (queryParams.containsKey('code')) { final receivedState = queryParams['state']; if (receivedState != state) { result = OAuth2Result.error('Invalid state parameter'); } else { // Exchange code for tokens result = await _exchangeCodeForTokens( queryParams['code']!, codeVerifier, ); } } else { result = OAuth2Result.cancelled(); } // Send response to browser request.response.statusCode = 200; request.response.headers.set('content-type', 'text/html'); request.response.write(''' Floaty Authentication
${result.isSuccess ? ''' ''' : ''' '''}

Authentication ${result.isSuccess ? 'Successful' : 'Failed'}

${result.isSuccess ? 'You have successfully signed in to Floaty. Return to the app to continue.' : 'Authentication could not be completed. Return to the app and try again.'}

You can close this tab now
'''); await windowManager.focus(); await request.response.close(); break; } } } finally { await server?.close(force: true); server = null; } return result ?? OAuth2Result.cancelled(); } catch (e) { return OAuth2Result.error('Desktop OAuth error: $e'); } } /// Exchange authorization code for access tokens Future _exchangeCodeForTokens( String code, String codeVerifier, ) async { try { final config = await getOpenIDConfig(); final clientId = await getClientId(); // Use overrides if flag enabled, otherwise use dynamic config or Floatplane defaults final tokenEndpoint = _useOAuthOverrides ? _overrideTokenEndpoint : (config?.tokenEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token'); final response = await _dio.post( tokenEndpoint, data: { 'grant_type': 'authorization_code', 'client_id': clientId, 'code': code, 'redirect_uri': _currentRedirectUrl, 'code_verifier': codeVerifier, }, options: Options( headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, ), ); if (response.statusCode == 200) { final tokenData = response.data; Map? userInfo; if (tokenData['id_token'] != null) { userInfo = JwtDecoder.decode(tokenData['id_token']); } return OAuth2Result.success( accessToken: tokenData['access_token'], refreshToken: tokenData['refresh_token'], idToken: tokenData['id_token'], userInfo: userInfo, expirationDateTime: tokenData['expires_in'] != null ? DateTime.now().add(Duration(seconds: tokenData['expires_in'])) : null, ); } else { return OAuth2Result.error( 'Token exchange failed: ${response.statusCode}'); } } catch (e) { return OAuth2Result.error('Token exchange error: $e'); } } /// Refreshes the access token using the refresh token /// Uses a lock to prevent concurrent refresh operations Future refreshToken(String refreshToken) async { // Create a key for this specific refresh token final refreshKey = refreshToken.hashCode.toString(); // If there's already a pending refresh operation for this token, wait for it if (_pendingRefreshOperations.containsKey(refreshKey)) { return _pendingRefreshOperations[refreshKey]!; } // Create the refresh operation and store it final operation = _refreshTokenInternal(refreshToken); _pendingRefreshOperations[refreshKey] = operation; try { final result = await operation; return result; } finally { // Clean up the pending operation _pendingRefreshOperations.remove(refreshKey); } } /// Internal implementation of refreshToken without locking Future _refreshTokenInternal(String refreshToken) async { try { if (_canUseAppAuth) { return _refreshTokenMobile(refreshToken); } else { return _refreshTokenDesktop(refreshToken); } } catch (e) { return OAuth2Result.error('Token refresh error: $e'); } } Future _refreshTokenMobile(String refreshToken) async { final config = await getOpenIDConfig(); final clientId = await getClientId(); // Use overrides if flag enabled, otherwise use dynamic config or Floatplane defaults final authEndpoint = _useOAuthOverrides ? _overrideAuthEndpoint : (config?.authorizationEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth'); final tokenEndpoint = _useOAuthOverrides ? _overrideTokenEndpoint : (config?.tokenEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token'); final TokenResponse result = await _appAuth.token( TokenRequest( clientId, _redirectUrl, refreshToken: refreshToken, serviceConfiguration: AuthorizationServiceConfiguration( authorizationEndpoint: authEndpoint, tokenEndpoint: tokenEndpoint, ), ), ); Map? userInfo; if (result.idToken != null) { userInfo = JwtDecoder.decode(result.idToken!); } return OAuth2Result.success( accessToken: result.accessToken!, refreshToken: result.refreshToken, idToken: result.idToken, userInfo: userInfo, expirationDateTime: result.accessTokenExpirationDateTime, ); } Future _refreshTokenDesktop(String refreshToken) async { final config = await getOpenIDConfig(); final clientId = await getClientId(); // Use overrides if flag enabled, otherwise use dynamic config or Floatplane defaults final tokenEndpoint = _useOAuthOverrides ? _overrideTokenEndpoint : (config?.tokenEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token'); final response = await _dio.post( tokenEndpoint, data: { 'grant_type': 'refresh_token', 'client_id': clientId, 'refresh_token': refreshToken, }, options: Options( headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, ), ); if (response.statusCode == 200) { final tokenData = response.data; Map? userInfo; if (tokenData['id_token'] != null) { userInfo = JwtDecoder.decode(tokenData['id_token']); } return OAuth2Result.success( accessToken: tokenData['access_token'], refreshToken: tokenData['refresh_token'] ?? refreshToken, idToken: tokenData['id_token'], userInfo: userInfo, expirationDateTime: tokenData['expires_in'] != null ? DateTime.now().add(Duration(seconds: tokenData['expires_in'])) : null, ); } else if (response.statusCode == 400) { final whitelabel = await whitelabels.getSelectedWhitelabel(); // Clear OAuth2 client from cache fpApiRequests.clearOAuth2Client(whitelabel.friendlyName); // Clear OAuth2 tokens for this whitelabel await clearStoredTokens( whitelabel: whitelabel.friendlyName, ); // Remove from logged in labels await whitelabels.removeLoggedInLabel(whitelabel.friendlyName); // Update selected whitelabel if needed if ((await whitelabels.getFirstLoggedInLabelOrDefault()).friendlyName == (await settings.getKey('whitelabel'))) { rootLayoutKey.currentState?.ref .read(mediaPlayerServiceProvider.notifier) .changeState(MediaPlayerState.none); } await settings.setKey('whitelabel', (await whitelabels.getFirstLoggedInLabelOrDefault()).friendlyName); return OAuth2Result.error('Refresh token is invalid or expired'); } else { return OAuth2Result.error('Token refresh failed: ${response.statusCode}'); } } /// Logs out by revoking tokens and clearing session Future revoke(String? accessToken) async { try { if (accessToken != null) { final config = await getOpenIDConfig(); final clientId = await getClientId(); // Use overrides if flag enabled, otherwise use dynamic config or Floatplane defaults final revocationEndpoint = _useOAuthOverrides ? _overrideRevocationEndpoint : (config?.revocationEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/revoke'); // Try to revoke the token await _dio.post( revocationEndpoint, data: { 'client_id': clientId, 'token': accessToken, }, options: Options( headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, ), ); } // Clear stored tokens await clearStoredTokens(); return true; } catch (e) { if (kDebugMode) { _log.severe('Logout error: $e'); } return false; } } /// Store tokens securely with whitelabel support Future storeTokens(OAuth2Result result, {String? whitelabel}) async { if (result.isSuccess) { final whitelabelPrefix = whitelabel ?? 'default'; await settings.setKey( '${whitelabelPrefix}_oauth2_access_token', result.accessToken ?? ''); await settings.setKey('${whitelabelPrefix}_oauth2_refresh_token', result.refreshToken ?? ''); await settings.setKey( '${whitelabelPrefix}_oauth2_id_token', result.idToken ?? ''); if (result.expirationDateTime != null) { await settings.setKey('${whitelabelPrefix}_oauth2_expiration', result.expirationDateTime!.toIso8601String()); } if (result.userInfo != null) { await settings.setKey('${whitelabelPrefix}_oauth2_user_info', jsonEncode(result.userInfo)); } // Mark this whitelabel as using OAuth2 await settings.setKey('${whitelabelPrefix}_auth_method', 'oauth2'); } } /// Get stored access token for a specific whitelabel /// Automatically refreshes the token if it's expired or about to expire /// Uses a lock to prevent concurrent token operations Future getAccessToken({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; // If there's already a pending token operation for this whitelabel, wait for it if (_pendingTokenOperations.containsKey(whitelabelPrefix)) { return _pendingTokenOperations[whitelabelPrefix]; } // Create the token operation and store it final operation = _getAccessTokenInternal(whitelabel: whitelabel); _pendingTokenOperations[whitelabelPrefix] = operation; try { final result = await operation; return result; } finally { // Clean up the pending operation _pendingTokenOperations.remove(whitelabelPrefix); } } /// Internal implementation of getAccessToken without locking Future _getAccessTokenInternal({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; final key = '${whitelabelPrefix}_oauth2_access_token'; final token = await settings.getKey(key); if (token.isEmpty) { return null; } // Check if token is expired and try to refresh final expired = await isTokenExpired(whitelabel: whitelabel); if (expired) { final refreshTokenStr = await getRefreshToken(whitelabel: whitelabel); if (refreshTokenStr != null) { final result = await refreshToken(refreshTokenStr); if (result.isSuccess) { await storeTokens(result, whitelabel: whitelabel); return result.accessToken; } } // Token expired and refresh failed return null; } return token; } /// Get stored refresh token for a specific whitelabel Future getRefreshToken({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; final token = await settings.getKey('${whitelabelPrefix}_oauth2_refresh_token'); return token.isEmpty ? null : token; } /// Get stored token expiration for a specific whitelabel Future getExpiration({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; final expiration = await settings.getKey('${whitelabelPrefix}_oauth2_expiration'); return expiration.isEmpty ? null : expiration; } /// Get stored user info for a specific whitelabel Future?> getStoredUserInfo({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; final userInfoJson = await settings.getKey('${whitelabelPrefix}_oauth2_user_info'); if (userInfoJson.isEmpty) return null; return jsonDecode(userInfoJson) as Map; } /// Check if token is expired for a specific whitelabel Future isTokenExpired({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; final key = '${whitelabelPrefix}_oauth2_expiration'; final expirationStr = await settings.getKey(key); if (expirationStr.isEmpty) { return false; } final expiration = DateTime.parse(expirationStr); final now = DateTime.now(); final expirationWithBuffer = expiration.subtract(const Duration(minutes: 5)); final isExpired = now.isAfter(expirationWithBuffer); return isExpired; } /// Check if user is authenticated for a specific whitelabel Future isAuthenticated({String? whitelabel}) async { // Use the same locking mechanism through getAccessToken // to prevent duplicate refresh operations final token = await getAccessToken(whitelabel: whitelabel); if (token == null) { return false; } // Token was successfully retrieved (either from cache or after refresh) return true; } /// Clear all stored tokens for a specific whitelabel Future clearStoredTokens({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; await settings.removeKey('${whitelabelPrefix}_oauth2_access_token'); await settings.removeKey('${whitelabelPrefix}_oauth2_refresh_token'); await settings.removeKey('${whitelabelPrefix}_oauth2_id_token'); await settings.removeKey('${whitelabelPrefix}_oauth2_expiration'); await settings.removeKey('${whitelabelPrefix}_oauth2_user_info'); await settings.removeKey('${whitelabelPrefix}_auth_method'); } /// Check if tokens are stored (regardless of whether they're expired) Future hasStoredTokens({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; final accessToken = await settings.getKey('${whitelabelPrefix}_oauth2_access_token'); final refreshToken = await settings.getKey('${whitelabelPrefix}_oauth2_refresh_token'); return accessToken.isNotEmpty || refreshToken.isNotEmpty; } /// Get the auth method for a whitelabel (oauth2, cookie, or null if not set) Future getAuthMethod({String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; final key = '${whitelabelPrefix}_auth_method'; final method = await settings.getKey(key); _log.fine('Auth method for $whitelabelPrefix: $method'); return method.isEmpty ? null : method; } /// Set the auth method for a whitelabel Future setAuthMethod(String method, {String? whitelabel}) async { final whitelabelPrefix = whitelabel ?? 'default'; await settings.setKey('${whitelabelPrefix}_auth_method', method); } /// Gets headers for non fpapi requests Future> getAuthHeaders( String whitelabelFriendlyName) async { final headers = {}; final whitelabel = whitelabels.getWhitelabel(whitelabelFriendlyName); final method = await getAuthMethod(whitelabel: whitelabelFriendlyName); if (method == 'oauth2') { final accessToken = await getAccessToken(whitelabel: whitelabelFriendlyName); if (accessToken != null) { headers['Authorization'] = 'Bearer $accessToken'; } } else if (method == 'cookie') { settings .getAuthTokenFromCookieJar( whitelabelFriendlyName: whitelabelFriendlyName) .then((cookieToken) { if (cookieToken != null) { headers['cookie'] = '${whitelabel.cookieName}=$cookieToken'; } }); } return headers; } /// Generates a cryptographically secure code verifier for PKCE String _generateCodeVerifier() { final random = Random.secure(); final bytes = Uint8List(32); for (int i = 0; i < 32; i++) { bytes[i] = random.nextInt(256); } return base64Url.encode(bytes).replaceAll('=', ''); } /// Generates code challenge from code verifier using SHA256 String _generateCodeChallenge(String codeVerifier) { final bytes = utf8.encode(codeVerifier); final digest = sha256.convert(bytes); return base64Url.encode(digest.bytes).replaceAll('=', ''); } /// Generates a random state parameter for CSRF protection String _generateState() { final random = Random.secure(); final bytes = Uint8List(16); for (int i = 0; i < 16; i++) { bytes[i] = random.nextInt(256); } return base64Url.encode(bytes).replaceAll('=', ''); } /// Fetches additional user information from the userinfo endpoint Future?> fetchUserInfo(String accessToken) async { try { final config = await getOpenIDConfig(); // Use overrides if flag enabled, otherwise use dynamic config or Floatplane defaults final userinfoEndpoint = _useOAuthOverrides ? _overrideUserinfoEndpoint : (config?.userinfoEndpoint ?? 'https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/userinfo'); final response = await _dio.get( userinfoEndpoint, options: Options( headers: { 'Authorization': 'Bearer $accessToken', }, ), ); if (response.statusCode == 200) { return response.data; } return null; } catch (e) { if (kDebugMode) { _log.warning('Failed to fetch user info: $e'); } return null; } } } /// Result class for OAuth2 operations class OAuth2Result { final bool isSuccess; final bool isCancelled; final String? error; final String? accessToken; final String? refreshToken; final String? idToken; final Map? userInfo; final DateTime? expirationDateTime; OAuth2Result._({ required this.isSuccess, required this.isCancelled, this.error, this.accessToken, this.refreshToken, this.idToken, this.userInfo, this.expirationDateTime, }); factory OAuth2Result.success({ required String accessToken, String? refreshToken, String? idToken, Map? userInfo, DateTime? expirationDateTime, }) { return OAuth2Result._( isSuccess: true, isCancelled: false, accessToken: accessToken, refreshToken: refreshToken, idToken: idToken, userInfo: userInfo, expirationDateTime: expirationDateTime, ); } factory OAuth2Result.cancelled() { return OAuth2Result._( isSuccess: false, isCancelled: true, ); } factory OAuth2Result.error(String error) { return OAuth2Result._( isSuccess: false, isCancelled: false, error: error, ); } /// Returns the username from user info (preferred_username or email) String? get username { if (userInfo != null) { return userInfo!['preferred_username'] ?? userInfo!['email']; } return null; } /// Returns the user's email from user info String? get email { return userInfo?['email']; } /// Returns the user's name from user info String? get name { return userInfo?['name'] ?? userInfo?['preferred_username']; } /// Checks if the token is expired or will expire soon bool get isTokenExpired { if (expirationDateTime == null) return false; return DateTime.now() .isAfter(expirationDateTime!.subtract(const Duration(minutes: 5))); } }