- Fix "Cannot use Ref after disposed" error in OIDC callback page - Store notifier reference before async gap - Add mounted check at start of processing - Add proper SSO logout via Authentik end_session_endpoint - Clears local tokens AND redirects to Authentik logout - Returns to app after Authentik session ends 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
456 lines
15 KiB
Dart
456 lines
15 KiB
Dart
import 'dart:convert' show base64Url, jsonDecode, jsonEncode, utf8;
|
|
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 '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.
|
|
@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<AuthState> build() async {
|
|
// Load stored auth on all platforms
|
|
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: Decode JWT to extract user info (skip core-api sync)
|
|
final claims = _decodeJwtClaims(tokens.accessToken);
|
|
final userName = claims['name'] as String? ??
|
|
claims['preferred_username'] as String? ??
|
|
'User';
|
|
final userEmail = claims['email'] as String? ?? '';
|
|
final authentikId = claims['sub'] as String?;
|
|
final groups = (claims['groups'] as List<dynamic>?)?.cast<String>() ?? [];
|
|
|
|
developer.log('JWT claims: name=$userName, email=$userEmail, groups=$groups', name: 'auth');
|
|
|
|
// Step 3: Store credentials and user data from JWT
|
|
await _storeAuth(
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
expiresAt: tokens.expiresAt,
|
|
authentikId: authentikId,
|
|
userName: userName,
|
|
userEmail: userEmail,
|
|
// Roles from groups - for now just store group names
|
|
// Full role parsing can be done later if needed
|
|
);
|
|
|
|
state = AsyncData(AuthState(
|
|
isAuthenticated: true,
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
expiresAt: tokens.expiresAt,
|
|
authentikId: authentikId,
|
|
userName: userName,
|
|
userEmail: userEmail,
|
|
));
|
|
|
|
developer.log('Authenticated as $userName', 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);
|
|
}
|
|
}
|
|
|
|
/// Decode JWT payload without verification (validation happens server-side).
|
|
Map<String, dynamic> _decodeJwtClaims(String jwt) {
|
|
try {
|
|
final parts = jwt.split('.');
|
|
if (parts.length != 3) {
|
|
developer.log('Invalid JWT format', name: 'auth');
|
|
return {};
|
|
}
|
|
|
|
// Decode the payload (second part)
|
|
String payload = parts[1];
|
|
// Add padding if needed for base64
|
|
switch (payload.length % 4) {
|
|
case 2:
|
|
payload += '==';
|
|
break;
|
|
case 3:
|
|
payload += '=';
|
|
break;
|
|
}
|
|
|
|
final decoded = utf8.decode(base64Url.decode(payload));
|
|
return jsonDecode(decoded) as Map<String, dynamic>;
|
|
} catch (e) {
|
|
developer.log('Failed to decode JWT: $e', name: 'auth');
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|