import 'dart:convert' show jsonDecode, jsonEncode; import 'dart:developer' as developer; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_config.dart'; import 'auth_datasource.dart'; import 'auth_state.dart'; import 'oidc_service_web.dart'; import 'permissions.dart'; import 'user_preferences.dart'; import 'web_utils.dart' as web_utils; /// Standalone auth controller that handles OIDC flow before app starts. /// /// This runs outside of Riverpod to avoid lifecycle issues. Call [initialize] /// in main() before runApp(). The controller will: /// 1. Handle callback if on /callback route (exchange code, sync, store tokens) /// 2. Check for valid stored tokens /// 3. Redirect to silent OIDC if no tokens (app won't continue) /// /// Once auth is complete, [AuthProvider] can simply read the stored tokens. class AuthController { // Storage keys (same as AuthProvider) 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'; /// Initialize auth before app starts. /// /// Returns true if auth is ready (tokens available). /// Returns false if redirecting (app should not continue). /// Throws on error. static Future initialize() async { // Skip auth entirely for LAN mode if (!AppConfig.requiresAuth) { developer.log('Auth not required (LAN mode)', name: 'auth_controller'); return true; } // Only handle web auth here - mobile uses different flow if (!kIsWeb) { developer.log('Non-web platform, skipping controller init', name: 'auth_controller'); return true; } final currentUrl = web_utils.getCurrentUrl(); developer.log('Auth controller init, URL: $currentUrl', name: 'auth_controller'); // Check if we're on the callback route if (currentUrl.contains('/callback')) { return _handleCallback(currentUrl); } // Check for valid stored tokens final prefs = await SharedPreferences.getInstance(); final accessToken = prefs.getString(_accessTokenKey); if (accessToken != null) { final expiresAtMs = prefs.getInt(_expiresAtKey); final expiresAt = expiresAtMs != null ? DateTime.fromMillisecondsSinceEpoch(expiresAtMs) : null; if (expiresAt == null || expiresAt.isAfter(DateTime.now())) { developer.log('Valid tokens found', name: 'auth_controller'); return true; // Auth ready } developer.log('Tokens expired', name: 'auth_controller'); } // No valid tokens - initiate silent OIDC developer.log('No valid tokens, starting silent OIDC', name: 'auth_controller'); await _initiateSilentOidc(); return false; // Redirecting, app should not continue } /// Handle the OIDC callback. static Future _handleCallback(String url) async { final uri = Uri.parse(url); final code = uri.queryParameters['code']; final state = uri.queryParameters['state']; final error = uri.queryParameters['error']; developer.log('Handling callback: code=${code != null}, error=$error', name: 'auth_controller'); // Handle errors if (error != null) { if (error == 'login_required') { // Silent auth failed - no session, start regular OIDC developer.log('Silent auth failed (login_required), starting regular OIDC', name: 'auth_controller'); await _initiateRegularOidc(); return false; } throw Exception('Auth error: $error - ${uri.queryParameters['error_description']}'); } if (code == null || state == null) { throw Exception('Invalid callback - missing code or state'); } // Exchange code for tokens developer.log('Exchanging code for tokens', name: 'auth_controller'); final oidcService = OidcServiceWeb(); final tokens = await oidcService.exchangeCode(code, state); // Sync with core-api developer.log('Syncing with core-api', name: 'auth_controller'); final dio = Dio(BaseOptions( baseUrl: AppConfig.coreApiUrl, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, )); final authDatasource = AuthDatasource(dio); final syncResponse = await authDatasource.syncUser(tokens.accessToken); developer.log('Synced user: ${syncResponse.name}', name: 'auth_controller'); // Store credentials 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, ); // Redirect to home (removes callback params from URL) developer.log('Auth complete, redirecting to home', name: 'auth_controller'); web_utils.redirectTo('/'); return false; // Redirecting } /// Initiate silent OIDC (prompt=none). static Future _initiateSilentOidc() async { final oidcService = OidcServiceWeb(); final authUrl = await oidcService.getAuthorizationUrl(silent: true); developer.log('Redirecting to silent OIDC', name: 'auth_controller'); web_utils.redirectTo(authUrl); } /// Initiate regular OIDC (shows login UI). static Future _initiateRegularOidc() async { final oidcService = OidcServiceWeb(); final authUrl = await oidcService.getAuthorizationUrl(silent: false); developer.log('Redirecting to regular OIDC', name: 'auth_controller'); web_utils.redirectTo(authUrl); } /// Store auth data. static 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); 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); } if (preferences != null) { await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson())); } } /// Load stored auth state (for AuthProvider to use). static 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; final rolesJson = prefs.getString(_rolesKey); final roles = rolesJson != null ? _parseRoles(rolesJson) : []; final prefsJson = prefs.getString(_preferencesKey); final preferences = prefsJson != null ? UserPreferences.fromJson(jsonDecode(prefsJson) as Map) : null; return 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, ); } catch (e) { developer.log('Failed to load stored auth: $e', name: 'auth_controller'); return const AuthState(); } } static 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) { return []; } } /// Clear stored auth (for logout). static Future clearAuth() 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); } }