Files
tatlock-ui/lib/core/auth/auth_provider.dart
T
Jeroen SchweitzerandClaude Opus 4.5 790ae41171
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m2s
fix(auth): Riverpod lifecycle error + Authentik logout
- 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>
2026-01-04 14:50:32 +01:00

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);
}
}