Applied @persistentRiverpod annotation to AuthNotifier so it persists for app lifetime. Previously, theme changes could trigger AuthProvider rebuild via auto-dispose, causing AsyncLoading state and auth issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
435 lines
15 KiB
Dart
435 lines
15 KiB
Dart
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<AuthState> 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<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;
|
|
|
|
// Load roles from JSON
|
|
final rolesJson = prefs.getString(_rolesKey);
|
|
final roles = rolesJson != null ? _parseRoles(rolesJson) : <Role>[];
|
|
|
|
// Load preferences from JSON
|
|
final prefsJson = prefs.getString(_preferencesKey);
|
|
final preferences = prefsJson != null
|
|
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
|
|
: 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<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) {
|
|
developer.log('Failed to parse roles: $e', name: 'auth');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Try to refresh the access token.
|
|
Future<AuthState> _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<void> 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<void> 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<void> 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<void> updatePreferences({
|
|
String? theme,
|
|
String? defaultRoom,
|
|
Map<String, dynamic>? 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<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);
|
|
|
|
// 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<void> _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);
|
|
}
|
|
}
|