Add complete authentication system supporting both web (NPM forward auth) and mobile (OIDC) authentication flows. Web flow: - Check /auth/me on startup to detect NPM forward auth session - Cookies handled by proxy, no Bearer tokens needed Mobile flow: - flutter_appauth for OIDC Authorization Code + PKCE - POST /auth/sync to get user profile and roles - Token storage in SharedPreferences Shared: - Permission system with Domain/Action enums and Role class - PermissionGate and AdminGate widgets for UI permission checks - Route guards redirecting unauthenticated users to login - Login page with platform-specific messaging Platform config: - iOS: CFBundleURLTypes for net.schweitz.tatlock:// - Android: appAuthRedirectScheme, minSdk 23 Docs: - Added Freezed 3.x sealed class documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
406 lines
14 KiB
Dart
406 lines
14 KiB
Dart
import 'dart:convert';
|
|
import 'dart:developer' as developer;
|
|
|
|
import 'package:dio/dio.dart';
|
|
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 'permissions.dart';
|
|
import 'user_preferences.dart';
|
|
|
|
part 'auth_provider.g.dart';
|
|
|
|
/// Provides authentication state and operations.
|
|
///
|
|
/// Supports two authentication flows:
|
|
/// - **Web**: NPM forward auth with Authentik (cookies handled by proxy)
|
|
/// - **Mobile**: OIDC Authorization Code flow with flutter_appauth
|
|
///
|
|
/// On web, the app calls GET /auth/me to check if user is authenticated
|
|
/// via NPM forward auth headers. On mobile, uses OIDC flow then POST /auth/sync.
|
|
@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 {
|
|
// On web with auth required, try to get user from NPM forward auth
|
|
if (kIsWeb && AppConfig.requiresAuth) {
|
|
final webAuth = await _tryWebAuth();
|
|
if (webAuth != null) {
|
|
return webAuth;
|
|
}
|
|
// If web auth failed, user needs to refresh to trigger NPM login
|
|
developer.log('Web auth not available - user may need to login via proxy', name: 'auth');
|
|
return const AuthState();
|
|
}
|
|
|
|
// On mobile or LAN, load from stored auth
|
|
return _loadStoredAuth();
|
|
}
|
|
|
|
/// Try to authenticate via NPM forward auth (web only).
|
|
///
|
|
/// Returns AuthState if authenticated, null if not.
|
|
Future<AuthState?> _tryWebAuth() async {
|
|
try {
|
|
developer.log('Attempting web auth via /auth/me', name: 'auth');
|
|
final authDatasource = ref.read(authDatasourceProvider);
|
|
final syncResponse = await authDatasource.getCurrentUser();
|
|
|
|
developer.log(
|
|
'Web auth successful: ${syncResponse.name} with ${syncResponse.roles.length} roles',
|
|
name: 'auth',
|
|
);
|
|
|
|
// Store auth data for offline/cached access
|
|
await _storeAuth(
|
|
accessToken: 'web-session', // Placeholder - web uses cookies
|
|
userId: syncResponse.userId,
|
|
authentikId: syncResponse.authentikId,
|
|
userName: syncResponse.name,
|
|
userEmail: syncResponse.email,
|
|
avatarUrl: syncResponse.avatarUrl,
|
|
roles: syncResponse.roles,
|
|
preferences: syncResponse.preferences,
|
|
);
|
|
|
|
return AuthState(
|
|
isAuthenticated: true,
|
|
accessToken: 'web-session',
|
|
userId: syncResponse.userId,
|
|
authentikId: syncResponse.authentikId,
|
|
userName: syncResponse.name,
|
|
userEmail: syncResponse.email,
|
|
avatarUrl: syncResponse.avatarUrl,
|
|
roles: syncResponse.roles,
|
|
preferences: syncResponse.preferences,
|
|
);
|
|
} on DioException catch (e) {
|
|
if (e.response?.statusCode == 401) {
|
|
developer.log('Web auth: not authenticated via proxy', name: 'auth');
|
|
return null;
|
|
}
|
|
developer.log('Web auth error: $e', name: 'auth');
|
|
return null;
|
|
} catch (e) {
|
|
developer.log('Web auth unexpected error: $e', name: 'auth');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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**: Triggers page reload to go through NPM forward auth
|
|
/// - **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;
|
|
}
|
|
|
|
// On web, auth is handled by NPM forward auth
|
|
// User needs to access via the authenticated proxy URL
|
|
if (kIsWeb) {
|
|
developer.log('Web sign-in: user should access via authenticated proxy', name: 'auth');
|
|
// Try to refresh auth state from /auth/me
|
|
state = const AsyncLoading();
|
|
final webAuth = await _tryWebAuth();
|
|
if (webAuth != null) {
|
|
state = AsyncData(webAuth);
|
|
} else {
|
|
state = AsyncError(
|
|
Exception('Not authenticated - please access via the authenticated URL'),
|
|
StackTrace.current,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Mobile: Use OIDC flow
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// Sign out and clear stored credentials.
|
|
Future<void> signOut() async {
|
|
await _clearStoredAuth();
|
|
state = const AsyncData(AuthState());
|
|
developer.log('Signed out', name: 'auth');
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|