Auth moved to standalone controller outside Riverpod: - New AuthController runs in main() before runApp() - Handles callback, token exchange, and /auth/sync before app starts - If auth not ready (redirecting), app doesn't start at all - AuthProvider now just loads stored tokens (no async OIDC logic) - Fixes "Cannot use Ref after disposed" errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
289 lines
10 KiB
Dart
289 lines
10 KiB
Dart
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<bool> 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<bool> _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<void> _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<void> _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<void> _storeAuth({
|
|
required String accessToken,
|
|
String? refreshToken,
|
|
DateTime? expiresAt,
|
|
String? userId,
|
|
String? authentikId,
|
|
String? userName,
|
|
String? userEmail,
|
|
String? avatarUrl,
|
|
List<Role>? 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<AuthState> 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) : <Role>[];
|
|
|
|
final prefsJson = prefs.getString(_preferencesKey);
|
|
final preferences = prefsJson != null
|
|
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
|
|
: 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<Role> _parseRoles(String json) {
|
|
try {
|
|
final list = jsonDecode(json) as List<dynamic>;
|
|
return list.map((item) {
|
|
final map = item as Map<String, dynamic>;
|
|
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<Role>().toList();
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Clear stored auth (for logout).
|
|
static Future<void> 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);
|
|
}
|
|
}
|