Files
jellyfin-plugin-floatplane/doc/research/reference-sources/floaty_oauth2_service.dart
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

1138 lines
38 KiB
Dart

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<String> scopesSupported;
final List<String> responseTypesSupported;
final List<String> 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<String, dynamic> 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<String>.from(json['scopes_supported'] ?? []),
responseTypesSupported:
List<String>.from(json['response_types_supported'] ?? []),
grantTypesSupported:
List<String>.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<String, Future<String?>> _pendingTokenOperations = {};
final Map<String, Future<OAuth2Result>> _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<String> _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<String> 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<OpenIDConfig?> 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<OAuth2Result> 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<bool> 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<OAuth2Result> _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<String, dynamic>? 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<OAuth2Result> _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('''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Floaty Authentication</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #121212;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.container {
background: #1E1E1E;
border-radius: 12px;
padding: 48px 40px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
text-align: center;
max-width: 420px;
animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: ${result.isSuccess ? '#4CAF50' : '#F44336'};
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s both;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.icon svg {
width: 32px;
height: 32px;
stroke: white;
stroke-width: 3;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
h1 {
color: #FFFFFF;
font-size: 24px;
font-weight: 500;
margin-bottom: 12px;
letter-spacing: 0.25px;
}
p {
color: #B3B3B3;
font-size: 14px;
line-height: 1.5;
margin-bottom: 32px;
letter-spacing: 0.25px;
}
.button {
background-color: ${result.isSuccess ? '#4CAF50' : '#F44336'};
color: #FFFFFF;
border: none;
padding: 12px 32px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1.25px;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.button:hover {
background-color: ${result.isSuccess ? '#45A049' : '#E53935'};
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
.button:active {
background-color: ${result.isSuccess ? '#388E3C' : '#D32F2F'};
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.countdown {
color: #757575;
font-size: 12px;
margin-top: 20px;
letter-spacing: 0.4px;
}
.countdown-timer {
color: #9E9E9E;
font-weight: 500;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
${result.isSuccess ? '''
<svg viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
''' : '''
<svg viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
'''}
</div>
<h1>Authentication ${result.isSuccess ? 'Successful' : 'Failed'}</h1>
<p>
${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.'}
</p>
<div class="countdown">You can close this tab now</div>
</div>
</body>
</html>
''');
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<OAuth2Result> _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<String, dynamic>? 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<OAuth2Result> 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<OAuth2Result> _refreshTokenInternal(String refreshToken) async {
try {
if (_canUseAppAuth) {
return _refreshTokenMobile(refreshToken);
} else {
return _refreshTokenDesktop(refreshToken);
}
} catch (e) {
return OAuth2Result.error('Token refresh error: $e');
}
}
Future<OAuth2Result> _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<String, dynamic>? 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<OAuth2Result> _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<String, dynamic>? 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<bool> 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<void> 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<String?> 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<String?> _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<String?> 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<String?> 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<Map<String, dynamic>?> 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<String, dynamic>;
}
/// Check if token is expired for a specific whitelabel
Future<bool> 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<bool> 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<void> 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<bool> 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<String?> 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<void> setAuthMethod(String method, {String? whitelabel}) async {
final whitelabelPrefix = whitelabel ?? 'default';
await settings.setKey('${whitelabelPrefix}_auth_method', method);
}
/// Gets headers for non fpapi requests
Future<Map<String, String>> getAuthHeaders(
String whitelabelFriendlyName) async {
final headers = <String, String>{};
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<Map<String, dynamic>?> 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<String, dynamic>? 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<String, dynamic>? 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)));
}
}