import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../config/app_config.dart'; part 'oidc_service.g.dart'; /// OIDC token response containing access and refresh tokens. class OidcTokens { const OidcTokens({ required this.accessToken, required this.refreshToken, required this.expiresAt, this.idToken, }); final String accessToken; final String? refreshToken; final DateTime expiresAt; final String? idToken; } /// Service for OIDC authentication using flutter_appauth. /// /// Handles the Authorization Code flow with PKCE for secure authentication /// against Authentik. class OidcService { OidcService({FlutterAppAuth? appAuth}) : _appAuth = appAuth ?? const FlutterAppAuth(); final FlutterAppAuth _appAuth; /// OIDC scopes to request. static const _scopes = ['openid', 'profile', 'email', 'offline_access']; /// Redirect URI for the app. static String get _redirectUri => '${AppConfig.authRedirectScheme}://callback'; /// Start the authorization code flow. /// /// Opens a browser/webview for user to authenticate with Authentik, /// then exchanges the authorization code for tokens. /// /// Throws [OidcException] if authentication fails. Future signIn() async { try { final result = await _appAuth.authorizeAndExchangeCode( AuthorizationTokenRequest( AppConfig.authClientId, _redirectUri, discoveryUrl: AppConfig.authDiscoveryUrl, scopes: _scopes, ), ); if (result.accessToken == null) { throw OidcException('Authorization failed: no access token'); } return OidcTokens( accessToken: result.accessToken!, refreshToken: result.refreshToken, expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)), idToken: result.idToken, ); } on Exception catch (e) { throw OidcException('Authorization failed: $e'); } } /// Refresh the access token using a refresh token. /// /// Throws [OidcException] if refresh fails. Future refreshToken(String refreshToken) async { try { final result = await _appAuth.token( TokenRequest( AppConfig.authClientId, _redirectUri, discoveryUrl: AppConfig.authDiscoveryUrl, refreshToken: refreshToken, scopes: _scopes, ), ); if (result.accessToken == null) { throw OidcException('Token refresh failed: no access token'); } return OidcTokens( accessToken: result.accessToken!, refreshToken: result.refreshToken ?? refreshToken, expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)), idToken: result.idToken, ); } on Exception catch (e) { throw OidcException('Token refresh failed: $e'); } } } /// Exception thrown when OIDC operations fail. class OidcException implements Exception { const OidcException(this.message); final String message; @override String toString() => 'OidcException: $message'; } /// Provider for the OIDC service. @riverpod OidcService oidcService(Ref ref) { return OidcService(); }