From 5f3ff7f31a4010a2622460a8ff7c9538dadfb2f8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 4 Jan 2026 17:14:20 +0100 Subject: [PATCH] chore: release v1.1.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 10 + lib/core/auth/auth_controller.dart | 288 +++++++++++++++++++++++++++++ lib/core/auth/auth_provider.dart | 45 +---- lib/main.dart | 12 +- pubspec.yaml | 2 +- 5 files changed, 313 insertions(+), 44 deletions(-) create mode 100644 lib/core/auth/auth_controller.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1909d..63b9e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.6] - 2026-01-04 + +### Changed +- **Auth moved to standalone controller** - Handles OIDC completely outside Riverpod + - New `AuthController` runs in `main()` before `runApp()` - avoids provider lifecycle issues + - 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 from autoDispose providers + ## [1.1.5] - 2026-01-04 ### Fixed diff --git a/lib/core/auth/auth_controller.dart b/lib/core/auth/auth_controller.dart new file mode 100644 index 0000000..6cf95a4 --- /dev/null +++ b/lib/core/auth/auth_controller.dart @@ -0,0 +1,288 @@ +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 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 _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 _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 _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 _storeAuth({ + required String accessToken, + String? refreshToken, + DateTime? expiresAt, + String? userId, + String? authentikId, + String? userName, + String? userEmail, + String? avatarUrl, + List? 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 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) : []; + + final prefsJson = prefs.getString(_preferencesKey); + final preferences = prefsJson != null + ? UserPreferences.fromJson(jsonDecode(prefsJson) as Map) + : 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 _parseRoles(String json) { + try { + final list = jsonDecode(json) as List; + return list.map((item) { + final map = item as Map; + 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().toList(); + } catch (e) { + return []; + } + } + + /// Clear stored auth (for logout). + static Future 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); + } +} diff --git a/lib/core/auth/auth_provider.dart b/lib/core/auth/auth_provider.dart index 51566b2..0545db2 100644 --- a/lib/core/auth/auth_provider.dart +++ b/lib/core/auth/auth_provider.dart @@ -40,51 +40,12 @@ class AuthNotifier extends _$AuthNotifier { @override Future build() async { - // On web with auth required, use silent OIDC to get JWT - if (kIsWeb && AppConfig.requiresAuth) { - // Skip silent OIDC if we're on the callback page (it will handle auth) - final currentUrl = web_utils.getCurrentUrl(); - if (currentUrl.contains('/callback')) { - developer.log('Web: On callback page, skipping silent OIDC', name: 'auth'); - return const AuthState(); - } - - // First check if we have stored tokens - final storedAuth = await _loadStoredAuth(); - if (storedAuth.isAuthenticated && !storedAuth.isTokenExpired) { - developer.log('Web: Using stored tokens for ${storedAuth.userName}', name: 'auth'); - return storedAuth; - } - - // No valid tokens - initiate silent OIDC - // NPM forward auth ensures user has Authentik session - // prompt=none will get us a token instantly without UI - developer.log('Web: No valid tokens, initiating silent OIDC', name: 'auth'); - _initiateSilentOidc(); - - // Return unauthenticated state - will redirect before this matters - return const AuthState(); - } - - // Mobile/LAN: Load stored auth from SharedPreferences + // 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(); } - /// Initiate silent OIDC flow on web. - /// - /// Uses prompt=none to get a token without showing login UI. - /// Relies on existing Authentik session (established via NPM forward auth). - Future _initiateSilentOidc() async { - try { - final oidcService = OidcServiceWeb(); - final authUrl = await oidcService.getAuthorizationUrl(silent: true); - developer.log('Redirecting to silent OIDC: $authUrl', name: 'auth'); - web_utils.redirectTo(authUrl); - } catch (e) { - developer.log('Failed to initiate silent OIDC: $e', name: 'auth'); - } - } - Future _loadStoredAuth() async { try { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/main.dart b/lib/main.dart index 2a90a5d..5749f1e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,10 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app.dart'; +import 'core/auth/auth_controller.dart'; import 'core/config/url_strategy.dart'; import 'version.g.dart'; -void main() { +void main() async { WidgetsFlutterBinding.ensureInitialized(); // Use path-based URLs on web (no-op on mobile/desktop) @@ -18,5 +19,14 @@ void main() { name: 'tatlock_ui', ); + // Initialize auth before starting the app. + // This handles OIDC callback and silent auth on web. + // If it returns false, we're redirecting and shouldn't continue. + final authReady = await AuthController.initialize(); + if (!authReady) { + developer.log('Auth redirecting, not starting app', name: 'tatlock_ui'); + return; // Don't run the app - browser is redirecting + } + runApp(const ProviderScope(child: TatlockApp())); } diff --git a/pubspec.yaml b/pubspec.yaml index 2865a6c..b113021 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.5+1 +version: 1.1.6+1 environment: sdk: ^3.10.4