import 'dart:convert' show jsonDecode, jsonEncode; import 'dart:developer' as developer; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_config.dart'; import '../providers/annotations.dart'; import 'auth_datasource.dart'; import 'auth_state.dart'; import 'oidc_service.dart'; import 'oidc_service_web.dart'; import 'permissions.dart'; import 'user_preferences.dart'; import 'web_utils.dart' as web_utils; part 'auth_provider.g.dart'; /// Provides authentication state and operations. /// /// Supports OIDC Authorization Code flow with PKCE on all platforms: /// - **Web**: Browser redirect to Authentik, callback via /callback route /// - **Mobile**: flutter_appauth with custom URL scheme /// /// After OIDC authentication, syncs with core-api via POST /auth/sync /// to get user profile, roles, and preferences. @persistentRiverpod class AuthNotifier extends _$AuthNotifier { // Storage keys static const _accessTokenKey = 'auth_access_token'; static const _refreshTokenKey = 'auth_refresh_token'; static const _expiresAtKey = 'auth_expires_at'; static const _userIdKey = 'auth_user_id'; static const _authentikIdKey = 'auth_authentik_id'; static const _userNameKey = 'auth_user_name'; static const _userEmailKey = 'auth_user_email'; static const _avatarUrlKey = 'auth_avatar_url'; static const _rolesKey = 'auth_roles'; static const _preferencesKey = 'auth_preferences'; @override Future build() async { // AuthController.initialize() in main() handles OIDC flow before app starts. // By the time we get here, tokens are already stored (or we're in LAN mode). // Just load the stored auth state. return _loadStoredAuth(); } Future _loadStoredAuth() async { try { final prefs = await SharedPreferences.getInstance(); final accessToken = prefs.getString(_accessTokenKey); if (accessToken == null) { return const AuthState(); } final expiresAtMs = prefs.getInt(_expiresAtKey); final expiresAt = expiresAtMs != null ? DateTime.fromMillisecondsSinceEpoch(expiresAtMs) : null; // Load roles from JSON final rolesJson = prefs.getString(_rolesKey); final roles = rolesJson != null ? _parseRoles(rolesJson) : []; // Load preferences from JSON final prefsJson = prefs.getString(_preferencesKey); final preferences = prefsJson != null ? UserPreferences.fromJson(jsonDecode(prefsJson) as Map) : null; final authState = AuthState( isAuthenticated: true, accessToken: accessToken, refreshToken: prefs.getString(_refreshTokenKey), expiresAt: expiresAt, userId: prefs.getString(_userIdKey), authentikId: prefs.getString(_authentikIdKey), userName: prefs.getString(_userNameKey), userEmail: prefs.getString(_userEmailKey), avatarUrl: prefs.getString(_avatarUrlKey), roles: roles, preferences: preferences, ); // Check if token is expired - try to refresh if (authState.isTokenExpired && authState.refreshToken != null) { developer.log('Token expired, attempting refresh', name: 'auth'); return _tryRefreshToken(authState); } if (authState.isTokenExpired) { developer.log('Stored token expired, clearing auth', name: 'auth'); await _clearStoredAuth(); return const AuthState(); } developer.log('Restored auth for ${authState.userName}', name: 'auth'); return authState; } catch (e) { developer.log('Failed to load stored auth: $e', name: 'auth'); return const AuthState(); } } /// Parse roles from stored JSON. List _parseRoles(String json) { try { final list = jsonDecode(json) as List; return list.map((item) { final map = item as Map; final domain = Domain.fromString(map['domain'] as String); final action = Action.fromString(map['action'] as String); if (domain == null || action == null) { return null; } return Role( id: map['id'] as String, name: map['name'] as String, domain: domain, category: map['category'] as String? ?? 'general', action: action, ); }).whereType().toList(); } catch (e) { developer.log('Failed to parse roles: $e', name: 'auth'); return []; } } /// Try to refresh the access token. Future _tryRefreshToken(AuthState currentState) async { if (currentState.refreshToken == null) { await _clearStoredAuth(); return const AuthState(); } try { final oidcService = ref.read(oidcServiceProvider); final tokens = await oidcService.refreshToken(currentState.refreshToken!); // Update stored tokens final prefs = await SharedPreferences.getInstance(); await prefs.setString(_accessTokenKey, tokens.accessToken); if (tokens.refreshToken != null) { await prefs.setString(_refreshTokenKey, tokens.refreshToken!); } await prefs.setInt(_expiresAtKey, tokens.expiresAt.millisecondsSinceEpoch); developer.log('Token refreshed successfully', name: 'auth'); return currentState.copyWith( accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? currentState.refreshToken, expiresAt: tokens.expiresAt, ); } catch (e) { developer.log('Token refresh failed: $e', name: 'auth'); await _clearStoredAuth(); return const AuthState(); } } /// Sign in with the appropriate method for the platform. /// /// - **Web**: Redirects to Authentik for OIDC authentication /// - **Mobile**: Opens Authentik login via OIDC, then syncs with core-api Future signIn() async { if (!AppConfig.requiresAuth) { developer.log('Auth not required in LAN mode', name: 'auth'); // In LAN mode, set a minimal authenticated state state = const AsyncData(AuthState(isAuthenticated: true)); return; } // Web: Use OIDC flow with browser redirect if (kIsWeb) { developer.log('Web sign-in: starting OIDC flow', name: 'auth'); state = const AsyncLoading(); try { final oidcService = OidcServiceWeb(); final authUrl = await oidcService.getAuthorizationUrl(); developer.log('Redirecting to: $authUrl', name: 'auth'); web_utils.redirectTo(authUrl); // Browser will redirect, so we don't update state here } catch (e, stack) { developer.log('Failed to start OIDC flow: $e', name: 'auth'); state = AsyncError(e, stack); } return; } // Mobile: Use OIDC flow with flutter_appauth state = const AsyncLoading(); try { // Step 1: OIDC authentication with Authentik developer.log('Starting OIDC authentication', name: 'auth'); final oidcService = ref.read(oidcServiceProvider); final tokens = await oidcService.signIn(); // Step 2: Sync with core-api to get user profile and roles developer.log('Syncing with core-api', name: 'auth'); final authDatasource = ref.read(authDatasourceProvider); final syncResponse = await authDatasource.syncUser(tokens.accessToken); // Step 3: Store credentials and user data await _storeAuth( accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, userId: syncResponse.userId, authentikId: syncResponse.authentikId, userName: syncResponse.name, userEmail: syncResponse.email, avatarUrl: syncResponse.avatarUrl, roles: syncResponse.roles, preferences: syncResponse.preferences, ); state = AsyncData(AuthState( isAuthenticated: true, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, userId: syncResponse.userId, authentikId: syncResponse.authentikId, userName: syncResponse.name, userEmail: syncResponse.email, avatarUrl: syncResponse.avatarUrl, roles: syncResponse.roles, preferences: syncResponse.preferences, )); developer.log( 'Authenticated as ${syncResponse.name} with ${syncResponse.roles.length} roles', name: 'auth', ); } on OidcException catch (e) { developer.log('OIDC authentication failed: $e', name: 'auth'); state = AsyncError(e, StackTrace.current); } catch (e, stack) { developer.log('Authentication failed: $e', name: 'auth'); state = AsyncError(e, stack); } } /// Handle OIDC callback after Authentik redirects back (web only). /// /// [code] is the authorization code from the callback URL. /// [state] is the state parameter for CSRF verification. Future handleOidcCallback(String code, String callbackState) async { if (!kIsWeb) { developer.log('handleOidcCallback called on non-web platform', name: 'auth'); return; } developer.log('Handling OIDC callback', name: 'auth'); state = const AsyncLoading(); try { // Step 1: Exchange code for tokens final oidcService = OidcServiceWeb(); final tokens = await oidcService.exchangeCode(code, callbackState); // Step 2: Sync with core-api to get user profile and roles developer.log('Syncing with core-api', name: 'auth'); final authDatasource = ref.read(authDatasourceProvider); final syncResponse = await authDatasource.syncUser(tokens.accessToken); developer.log( 'Synced user: ${syncResponse.name} with ${syncResponse.roles.length} roles', name: 'auth', ); // Step 3: Store credentials and user data from sync response await _storeAuth( accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, userId: syncResponse.userId, authentikId: syncResponse.authentikId, userName: syncResponse.name, userEmail: syncResponse.email, avatarUrl: syncResponse.avatarUrl, roles: syncResponse.roles, preferences: syncResponse.preferences, ); state = AsyncData(AuthState( isAuthenticated: true, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, userId: syncResponse.userId, authentikId: syncResponse.authentikId, userName: syncResponse.name, userEmail: syncResponse.email, avatarUrl: syncResponse.avatarUrl, roles: syncResponse.roles, preferences: syncResponse.preferences, )); developer.log('Authenticated as ${syncResponse.name}', name: 'auth'); // Clean up the URL by removing the query parameters web_utils.replaceUrl('/'); } on OidcException catch (e) { developer.log('OIDC callback failed: $e', name: 'auth'); state = AsyncError(e, StackTrace.current); } catch (e, stack) { developer.log('Callback handling failed: $e', name: 'auth'); state = AsyncError(e, stack); } } /// Sign out and clear stored credentials. /// /// On web, also redirects to Authentik's logout endpoint to end the SSO session. Future signOut() async { // Clear local storage first await _clearStoredAuth(); state = const AsyncData(AuthState()); developer.log('Signed out locally', name: 'auth'); // On web, redirect to Authentik logout to end SSO session if (kIsWeb && AppConfig.requiresAuth) { try { final oidcService = OidcServiceWeb(); final logoutUrl = await oidcService.getLogoutUrl(); developer.log('Redirecting to Authentik logout', name: 'auth'); web_utils.redirectTo(logoutUrl); } catch (e) { developer.log('Failed to get logout URL: $e', name: 'auth'); // Local logout already done, just reload to trigger re-auth web_utils.redirectTo('/'); } } } /// Update user preferences. Future updatePreferences({ String? theme, String? defaultRoom, Map? preferencesJson, }) async { final currentState = state.value; if (currentState == null || !currentState.isAuthenticated) return; try { final authDatasource = ref.read(authDatasourceProvider); final newPrefs = await authDatasource.updatePreferences( theme: theme, defaultRoom: defaultRoom, preferencesJson: preferencesJson, ); // Update stored preferences final prefs = await SharedPreferences.getInstance(); await prefs.setString(_preferencesKey, jsonEncode(newPrefs.toJson())); state = AsyncData(currentState.copyWith(preferences: newPrefs)); developer.log('Preferences updated', name: 'auth'); } catch (e) { developer.log('Failed to update preferences: $e', name: 'auth'); rethrow; } } /// Store authentication data to SharedPreferences. Future _storeAuth({ required String accessToken, String? refreshToken, DateTime? expiresAt, String? userId, String? authentikId, String? userName, String? userEmail, String? avatarUrl, List? roles, UserPreferences? preferences, }) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_accessTokenKey, accessToken); if (refreshToken != null) { await prefs.setString(_refreshTokenKey, refreshToken); } if (expiresAt != null) { await prefs.setInt(_expiresAtKey, expiresAt.millisecondsSinceEpoch); } if (userId != null) await prefs.setString(_userIdKey, userId); if (authentikId != null) await prefs.setString(_authentikIdKey, authentikId); if (userName != null) await prefs.setString(_userNameKey, userName); if (userEmail != null) await prefs.setString(_userEmailKey, userEmail); if (avatarUrl != null) await prefs.setString(_avatarUrlKey, avatarUrl); // Store roles as JSON if (roles != null) { final rolesJson = jsonEncode(roles.map((r) => { 'id': r.id, 'name': r.name, 'domain': r.domain.value, 'category': r.category, 'action': r.action.name, }).toList()); await prefs.setString(_rolesKey, rolesJson); } // Store preferences as JSON if (preferences != null) { await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson())); } } Future _clearStoredAuth() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_accessTokenKey); await prefs.remove(_refreshTokenKey); await prefs.remove(_expiresAtKey); await prefs.remove(_userIdKey); await prefs.remove(_authentikIdKey); await prefs.remove(_userNameKey); await prefs.remove(_userEmailKey); await prefs.remove(_avatarUrlKey); await prefs.remove(_rolesKey); await prefs.remove(_preferencesKey); } }