import 'dart:convert'; import 'dart:developer' as developer; import 'package:dio/dio.dart'; 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 'auth_datasource.dart'; import 'auth_state.dart'; import 'oidc_service.dart'; import 'permissions.dart'; import 'user_preferences.dart'; part 'auth_provider.g.dart'; /// Provides authentication state and operations. /// /// Supports two authentication flows: /// - **Web**: NPM forward auth with Authentik (cookies handled by proxy) /// - **Mobile**: OIDC Authorization Code flow with flutter_appauth /// /// On web, the app calls GET /auth/me to check if user is authenticated /// via NPM forward auth headers. On mobile, uses OIDC flow then POST /auth/sync. @riverpod 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 { // On web with auth required, try to get user from NPM forward auth if (kIsWeb && AppConfig.requiresAuth) { final webAuth = await _tryWebAuth(); if (webAuth != null) { return webAuth; } // If web auth failed, user needs to refresh to trigger NPM login developer.log('Web auth not available - user may need to login via proxy', name: 'auth'); return const AuthState(); } // On mobile or LAN, load from stored auth return _loadStoredAuth(); } /// Try to authenticate via NPM forward auth (web only). /// /// Returns AuthState if authenticated, null if not. Future _tryWebAuth() async { try { developer.log('Attempting web auth via /auth/me', name: 'auth'); final authDatasource = ref.read(authDatasourceProvider); final syncResponse = await authDatasource.getCurrentUser(); developer.log( 'Web auth successful: ${syncResponse.name} with ${syncResponse.roles.length} roles', name: 'auth', ); // Store auth data for offline/cached access await _storeAuth( accessToken: 'web-session', // Placeholder - web uses cookies userId: syncResponse.userId, authentikId: syncResponse.authentikId, userName: syncResponse.name, userEmail: syncResponse.email, avatarUrl: syncResponse.avatarUrl, roles: syncResponse.roles, preferences: syncResponse.preferences, ); return AuthState( isAuthenticated: true, accessToken: 'web-session', userId: syncResponse.userId, authentikId: syncResponse.authentikId, userName: syncResponse.name, userEmail: syncResponse.email, avatarUrl: syncResponse.avatarUrl, roles: syncResponse.roles, preferences: syncResponse.preferences, ); } on DioException catch (e) { if (e.response?.statusCode == 401) { developer.log('Web auth: not authenticated via proxy', name: 'auth'); return null; } developer.log('Web auth error: $e', name: 'auth'); return null; } catch (e) { developer.log('Web auth unexpected error: $e', name: 'auth'); return null; } } 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**: Triggers page reload to go through NPM forward auth /// - **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; } // On web, auth is handled by NPM forward auth // User needs to access via the authenticated proxy URL if (kIsWeb) { developer.log('Web sign-in: user should access via authenticated proxy', name: 'auth'); // Try to refresh auth state from /auth/me state = const AsyncLoading(); final webAuth = await _tryWebAuth(); if (webAuth != null) { state = AsyncData(webAuth); } else { state = AsyncError( Exception('Not authenticated - please access via the authenticated URL'), StackTrace.current, ); } return; } // Mobile: Use OIDC flow 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); } } /// Sign out and clear stored credentials. Future signOut() async { await _clearStoredAuth(); state = const AsyncData(AuthState()); developer.log('Signed out', name: 'auth'); } /// 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); } }