From f1b2b0430f8a8f95a91b1aa34f5960f14617ccd1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 3 Jan 2026 21:56:11 +0100 Subject: [PATCH] feat(auth): implement dual-flow authentication (web + mobile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/app/build.gradle.kts | 5 +- docs/ARCHITECTURE.md | 30 ++- ios/Runner/Info.plist | 13 ++ lib/core/api/api_interceptors.dart | 13 +- lib/core/auth/auth_datasource.dart | 133 ++++++++++++ lib/core/auth/auth_provider.dart | 320 ++++++++++++++++++++++++++-- lib/core/auth/auth_state.dart | 38 +++- lib/core/auth/oidc_service.dart | 115 ++++++++++ lib/core/auth/permission_gate.dart | 110 ++++++++++ lib/core/auth/permissions.dart | 137 ++++++++++++ lib/core/auth/user_preferences.dart | 22 ++ lib/routing/app_router.dart | 188 ++++++++++++++++ pubspec.yaml | 3 + 13 files changed, 1101 insertions(+), 26 deletions(-) create mode 100644 lib/core/auth/auth_datasource.dart create mode 100644 lib/core/auth/oidc_service.dart create mode 100644 lib/core/auth/permission_gate.dart create mode 100644 lib/core/auth/permissions.dart create mode 100644 lib/core/auth/user_preferences.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f8040b1..89f7de2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -24,10 +24,13 @@ android { applicationId = "net.schweitz.tatlock_ui" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 23 // Required for AppAuth targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + // flutter_appauth redirect scheme for OIDC callbacks + manifestPlaceholders["appAuthRedirectScheme"] = "net.schweitz.tatlock" } buildTypes { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7103d4a..c9a742d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -142,7 +142,7 @@ part 'container_model.freezed.dart'; part 'container_model.g.dart'; @freezed -class ContainerModel with _$ContainerModel { +sealed class ContainerModel with _$ContainerModel { const factory ContainerModel({ required String id, required String name, @@ -464,6 +464,34 @@ Generated files: - `*.freezed.dart` - Immutable classes - `*.g.dart` - JSON serialization, Riverpod providers +### Freezed 3.x: Required `sealed class` + +**Freezed 3.x requires the `sealed` keyword** on all classes with generated mixins. Without it, the generated code will fail to compile with errors about missing concrete implementations. + +```dart +// ✅ Correct - Freezed 3.x +@freezed +sealed class UserModel with _$UserModel { + const factory UserModel({ + required String id, + required String name, + }) = _UserModel; + + factory UserModel.fromJson(Map json) => + _$UserModelFromJson(json); +} + +// ❌ Wrong - will fail to compile +@freezed +class UserModel with _$UserModel { // Missing `sealed` + const factory UserModel({...}) = _UserModel; +} +``` + +The `sealed` keyword was introduced in Dart 3.0 and allows the generated mixin `_$UserModel` to have abstract members that are implemented by the private `_UserModel` class. + +**Always use `sealed class` with `@freezed`** - this applies to all models, entities, and state classes using Freezed. + ## Import Rules 1. Never import from `data/` in `domain/` diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9e5b8e6..6033e9e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -45,5 +45,18 @@ UIApplicationSupportsIndirectInputEvents + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + net.schweitz.tatlock + CFBundleURLSchemes + + net.schweitz.tatlock + + + diff --git a/lib/core/api/api_interceptors.dart b/lib/core/api/api_interceptors.dart index deaf99c..8fa2b0e 100644 --- a/lib/core/api/api_interceptors.dart +++ b/lib/core/api/api_interceptors.dart @@ -9,7 +9,9 @@ import 'package:tatlock_ui/core/error/app_exception.dart'; /// Adds authentication token to requests. /// -/// Skipped entirely when [AppConfig.requiresAuth] is false (LAN development). +/// - **LAN mode**: Skipped entirely (no auth required) +/// - **Web**: Skipped (cookies handle auth via NPM forward auth) +/// - **Mobile**: Adds Bearer token from OIDC authentication class AuthInterceptor extends Interceptor { AuthInterceptor(this._ref); @@ -23,10 +25,17 @@ class AuthInterceptor extends Interceptor { return; } + // Skip Bearer token on web - cookies handle auth via NPM forward auth + if (kIsWeb) { + handler.next(options); + return; + } + + // Mobile: Add Bearer token from OIDC authentication final authState = _ref.read(authProvider); authState.whenData((auth) { - if (auth.isAuthenticated && auth.accessToken != null) { + if (auth.isAuthenticated && auth.accessToken != null && auth.accessToken != 'web-session') { options.headers['Authorization'] = 'Bearer ${auth.accessToken}'; } }); diff --git a/lib/core/auth/auth_datasource.dart b/lib/core/auth/auth_datasource.dart new file mode 100644 index 0000000..2129daa --- /dev/null +++ b/lib/core/auth/auth_datasource.dart @@ -0,0 +1,133 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../api/api_client.dart'; +import 'permissions.dart'; +import 'user_preferences.dart'; + +part 'auth_datasource.g.dart'; + +/// Response from POST /auth/sync endpoint. +class AuthSyncResponse { + const AuthSyncResponse({ + required this.userId, + required this.authentikId, + required this.email, + required this.name, + this.avatarUrl, + required this.roles, + required this.preferences, + required this.isNewUser, + }); + + final String userId; + final String authentikId; + final String email; + final String name; + final String? avatarUrl; + final List roles; + final UserPreferences preferences; + final bool isNewUser; + + factory AuthSyncResponse.fromJson(Map json) { + final user = json['user'] as Map; + final rolesJson = json['roles'] as List; + final prefsJson = json['preferences'] as Map; + + return AuthSyncResponse( + userId: user['id'] as String, + authentikId: user['authentik_id'] as String, + email: user['email'] as String, + name: user['name'] as String, + avatarUrl: user['avatar_url'] as String?, + roles: rolesJson.map((r) => _parseRole(r as Map)).toList(), + preferences: UserPreferences.fromJson(prefsJson), + isNewUser: json['is_new_user'] as bool, + ); + } +} + +/// Parse a role from API JSON. +Role _parseRole(Map json) { + final name = json['name'] as String; + final domainStr = json['domain'] as String; + final category = json['category'] as String? ?? 'general'; + final actionStr = json['action'] as String; + + final domain = Domain.fromString(domainStr); + final action = Action.fromString(actionStr); + + if (domain == null || action == null) { + // Return a placeholder role for unknown domains/actions + return Role( + id: json['id'] as String, + name: name, + domain: Domain.admin, // Fallback + category: category, + action: Action.viewer, // Fallback - least privilege + ); + } + + return Role( + id: json['id'] as String, + name: name, + domain: domain, + category: category, + action: action, + ); +} + +/// Datasource for auth API endpoints. +class AuthDatasource { + AuthDatasource(this._dio); + + final Dio _dio; + + /// Sync user with core-api after OIDC authentication. + /// + /// Sends the OIDC access token to core-api, which validates it with Authentik + /// and returns the user profile, roles, and preferences. + Future syncUser(String accessToken) async { + final response = await _dio.post>( + '/auth/sync', + data: {'access_token': accessToken}, + ); + + return AuthSyncResponse.fromJson(response.data!); + } + + /// Get current user profile via NPM forward auth. + /// + /// This endpoint reads X-authentik-* headers set by NPM forward auth. + /// Returns user profile if authenticated via the proxy. + /// Throws 401 if not authenticated or accessing directly. + Future getCurrentUser() async { + final response = await _dio.get>('/auth/me'); + return AuthSyncResponse.fromJson(response.data!); + } + + /// Update user preferences. + Future updatePreferences({ + String? theme, + String? defaultRoom, + Map? preferencesJson, + }) async { + final data = {}; + if (theme != null) data['theme'] = theme; + if (defaultRoom != null) data['default_room'] = defaultRoom; + if (preferencesJson != null) data['preferences_json'] = preferencesJson; + + final response = await _dio.patch>( + '/auth/users/me/preferences', + data: data, + ); + + return UserPreferences.fromJson(response.data!); + } +} + +/// Provider for the auth datasource. +@riverpod +AuthDatasource authDatasource(Ref ref) { + return AuthDatasource(ref.watch(coreApiClientProvider)); +} diff --git a/lib/core/auth/auth_provider.dart b/lib/core/auth/auth_provider.dart index b0ccbc1..9ac66bf 100644 --- a/lib/core/auth/auth_provider.dart +++ b/lib/core/auth/auth_provider.dart @@ -1,31 +1,109 @@ +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. /// -/// Note: Full OIDC implementation with flutter_appauth requires -/// native platform configuration. For now, this provides the -/// state management infrastructure. +/// 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 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 _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 _loadStoredAuth() async { try { final prefs = await SharedPreferences.getInstance(); @@ -40,17 +118,36 @@ class AuthNotifier extends _$AuthNotifier { ? DateTime.fromMillisecondsSinceEpoch(expiresAtMs) : null; + // Load roles from JSON + final rolesJson = prefs.getString(_rolesKey); + final roles = rolesJson != null ? _parseRoles(rolesJson) : []; + + // Load preferences from JSON + final prefsJson = prefs.getString(_preferencesKey); + final preferences = prefsJson != null + ? UserPreferences.fromJson(jsonDecode(prefsJson) as Map) + : 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 + // 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(); @@ -65,12 +162,149 @@ class AuthNotifier extends _$AuthNotifier { } } - /// Sign in with OIDC (placeholder for flutter_appauth integration). + /// Parse roles from stored JSON. + 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) { + developer.log('Failed to parse roles: $e', name: 'auth'); + return []; + } + } + + /// Try to refresh the access token. + Future _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 signIn() async { - // TODO: Implement OIDC flow with flutter_appauth - // For now, this is a placeholder that will be implemented - // when native platform configuration is complete. - developer.log('Sign in requested - OIDC not yet configured', name: 'auth'); + 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. @@ -80,14 +314,47 @@ class AuthNotifier extends _$AuthNotifier { developer.log('Signed out', name: 'auth'); } - /// Update auth state (called after successful OIDC flow). - Future setAuthenticated({ + /// Update user preferences. + Future updatePreferences({ + String? theme, + String? defaultRoom, + Map? 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 _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(); @@ -99,20 +366,27 @@ class AuthNotifier extends _$AuthNotifier { 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); - state = AsyncData(AuthState( - isAuthenticated: true, - accessToken: accessToken, - refreshToken: refreshToken, - expiresAt: expiresAt, - userId: userId, - userName: userName, - userEmail: userEmail, - )); + // 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); + } - developer.log('Authenticated as $userName', name: 'auth'); + // Store preferences as JSON + if (preferences != null) { + await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson())); + } } Future _clearStoredAuth() async { @@ -121,7 +395,11 @@ class AuthNotifier extends _$AuthNotifier { 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_state.dart b/lib/core/auth/auth_state.dart index 26f8086..5eb7087 100644 --- a/lib/core/auth/auth_state.dart +++ b/lib/core/auth/auth_state.dart @@ -1,18 +1,46 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'permissions.dart'; +import 'user_preferences.dart'; + part 'auth_state.freezed.dart'; -/// Authentication state. +/// Authentication state including user profile, roles, and preferences. @freezed sealed class AuthState with _$AuthState { const factory AuthState({ + /// Whether user is authenticated. @Default(false) bool isAuthenticated, + + /// OIDC access token. String? accessToken, + + /// OIDC refresh token. String? refreshToken, + + /// Token expiration time. DateTime? expiresAt, + + /// Internal user ID (from core-api). String? userId, + + /// Authentik user ID. + String? authentikId, + + /// User display name. String? userName, + + /// User email address. String? userEmail, + + /// User avatar URL. + String? avatarUrl, + + /// User's permission roles. + @Default([]) List roles, + + /// User preferences. + UserPreferences? preferences, }) = _AuthState; const AuthState._(); @@ -23,4 +51,12 @@ sealed class AuthState with _$AuthState { // Consider expired if less than 1 minute remaining return DateTime.now().isAfter(expiresAt!.subtract(const Duration(minutes: 1))); } + + /// Check if user has the specified permission. + bool hasPermission(Domain domain, Action action, {String category = 'general'}) { + return roles.hasPermission(domain, action, category: category); + } + + /// Check if user is a global admin. + bool get isGlobalAdmin => roles.isGlobalAdmin; } diff --git a/lib/core/auth/oidc_service.dart b/lib/core/auth/oidc_service.dart new file mode 100644 index 0000000..af42fb7 --- /dev/null +++ b/lib/core/auth/oidc_service.dart @@ -0,0 +1,115 @@ +import 'package:flutter_appauth/flutter_appauth.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../config/app_config.dart'; + +part 'oidc_service.g.dart'; + +/// OIDC token response containing access and refresh tokens. +class OidcTokens { + const OidcTokens({ + required this.accessToken, + required this.refreshToken, + required this.expiresAt, + this.idToken, + }); + + final String accessToken; + final String? refreshToken; + final DateTime expiresAt; + final String? idToken; +} + +/// Service for OIDC authentication using flutter_appauth. +/// +/// Handles the Authorization Code flow with PKCE for secure authentication +/// against Authentik. +class OidcService { + OidcService({FlutterAppAuth? appAuth}) : _appAuth = appAuth ?? const FlutterAppAuth(); + + final FlutterAppAuth _appAuth; + + /// OIDC scopes to request. + static const _scopes = ['openid', 'profile', 'email', 'offline_access']; + + /// Redirect URI for the app. + static String get _redirectUri => '${AppConfig.authRedirectScheme}://callback'; + + /// Start the authorization code flow. + /// + /// Opens a browser/webview for user to authenticate with Authentik, + /// then exchanges the authorization code for tokens. + /// + /// Throws [OidcException] if authentication fails. + Future signIn() async { + try { + final result = await _appAuth.authorizeAndExchangeCode( + AuthorizationTokenRequest( + AppConfig.authClientId, + _redirectUri, + discoveryUrl: AppConfig.authDiscoveryUrl, + scopes: _scopes, + ), + ); + + if (result.accessToken == null) { + throw OidcException('Authorization failed: no access token'); + } + + return OidcTokens( + accessToken: result.accessToken!, + refreshToken: result.refreshToken, + expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)), + idToken: result.idToken, + ); + } on Exception catch (e) { + throw OidcException('Authorization failed: $e'); + } + } + + /// Refresh the access token using a refresh token. + /// + /// Throws [OidcException] if refresh fails. + Future refreshToken(String refreshToken) async { + try { + final result = await _appAuth.token( + TokenRequest( + AppConfig.authClientId, + _redirectUri, + discoveryUrl: AppConfig.authDiscoveryUrl, + refreshToken: refreshToken, + scopes: _scopes, + ), + ); + + if (result.accessToken == null) { + throw OidcException('Token refresh failed: no access token'); + } + + return OidcTokens( + accessToken: result.accessToken!, + refreshToken: result.refreshToken ?? refreshToken, + expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)), + idToken: result.idToken, + ); + } on Exception catch (e) { + throw OidcException('Token refresh failed: $e'); + } + } +} + +/// Exception thrown when OIDC operations fail. +class OidcException implements Exception { + const OidcException(this.message); + + final String message; + + @override + String toString() => 'OidcException: $message'; +} + +/// Provider for the OIDC service. +@riverpod +OidcService oidcService(Ref ref) { + return OidcService(); +} diff --git a/lib/core/auth/permission_gate.dart b/lib/core/auth/permission_gate.dart new file mode 100644 index 0000000..9e8e370 --- /dev/null +++ b/lib/core/auth/permission_gate.dart @@ -0,0 +1,110 @@ +import 'package:flutter/widgets.dart' hide Action; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'auth_provider.dart'; +import 'permissions.dart'; + +/// A widget that conditionally renders its child based on user permissions. +/// +/// Example: +/// ```dart +/// PermissionGate( +/// domain: Domain.controlRoom, +/// action: Action.admin, +/// child: DeleteButton(), +/// fallback: Text('No permission'), +/// ) +/// ``` +class PermissionGate extends ConsumerWidget { + const PermissionGate({ + super.key, + required this.domain, + required this.action, + this.category = 'general', + required this.child, + this.fallback, + }); + + /// The domain required for this permission. + final Domain domain; + + /// The action level required (viewer, user, editor, admin). + final Action action; + + /// Optional category within the domain (defaults to 'general'). + final String category; + + /// Widget to show when user has permission. + final Widget child; + + /// Widget to show when user lacks permission (defaults to empty). + final Widget? fallback; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authProvider); + + final hasPermission = authState.maybeWhen( + data: (state) => state.hasPermission(domain, action, category: category), + orElse: () => false, + ); + + if (hasPermission) { + return child; + } + + return fallback ?? const SizedBox.shrink(); + } +} + +/// A widget that shows its child only if the user is a global admin. +class AdminGate extends ConsumerWidget { + const AdminGate({ + super.key, + required this.child, + this.fallback, + }); + + /// Widget to show when user is admin. + final Widget child; + + /// Widget to show when user is not admin (defaults to empty). + final Widget? fallback; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authProvider); + + final isAdmin = authState.maybeWhen( + data: (state) => state.isGlobalAdmin, + orElse: () => false, + ); + + if (isAdmin) { + return child; + } + + return fallback ?? const SizedBox.shrink(); + } +} + +/// Extension for checking permissions in code. +extension PermissionCheck on WidgetRef { + /// Check if the current user has a specific permission. + bool hasPermission(Domain domain, Action action, {String category = 'general'}) { + final authState = read(authProvider); + return authState.maybeWhen( + data: (state) => state.hasPermission(domain, action, category: category), + orElse: () => false, + ); + } + + /// Check if the current user is a global admin. + bool get isGlobalAdmin { + final authState = read(authProvider); + return authState.maybeWhen( + data: (state) => state.isGlobalAdmin, + orElse: () => false, + ); + } +} diff --git a/lib/core/auth/permissions.dart b/lib/core/auth/permissions.dart new file mode 100644 index 0000000..2a1a0cc --- /dev/null +++ b/lib/core/auth/permissions.dart @@ -0,0 +1,137 @@ +// Permission system for role-based access control. +// +// Roles follow the format: `domain.category:action` +// - Domain: Feature area (control-room, media, etc.) +// - Category: Sub-area within domain (default: general) +// - Action: Permission level (viewer < user < editor < admin) + +/// Permission domains matching feature areas. +enum Domain { + controlRoom('control-room'), + library('library'), + media('media'), + ai('ai'), + housekeeper('housekeeper'), + developer('developer'), + documents('documents'), + gaming('gaming'), + admin('admin'); + + const Domain(this.value); + + /// The API string value for this domain. + final String value; + + /// Parse a domain string from API response. + static Domain? fromString(String value) { + for (final domain in Domain.values) { + if (domain.value == value) return domain; + } + return null; + } +} + +/// Permission actions in hierarchical order. +/// +/// Higher actions imply lower ones: +/// - admin implies editor, user, viewer +/// - editor implies user, viewer +/// - user implies viewer +enum Action { + viewer(1), + user(2), + editor(3), + admin(4); + + const Action(this.level); + + /// Numeric level for comparison (higher = more permissions). + final int level; + + /// Check if this action grants at least the required action. + bool grants(Action required) => level >= required.level; + + /// Parse an action string from API response. + static Action? fromString(String value) { + for (final action in Action.values) { + if (action.name == value) return action; + } + return null; + } +} + +/// A permission role assigned to a user. +/// +/// Roles are parsed from the API format: `domain.category:action` +class Role { + const Role({ + required this.id, + required this.name, + required this.domain, + required this.category, + required this.action, + }); + + /// Unique role ID. + final String id; + + /// Full role name (e.g., "control-room.general:admin"). + final String name; + + /// Permission domain. + final Domain domain; + + /// Permission category (usually "general"). + final String category; + + /// Permission action level. + final Action action; + + /// Check if this role grants access for the given domain and action. + /// + /// Global admin (`admin.general:admin`) grants access to everything. + /// Otherwise, domain and category must match, and action level must be sufficient. + bool grants(Domain domain, Action action, {String category = 'general'}) { + // Global admin override + if (this.domain == Domain.admin && + this.category == 'general' && + this.action == Action.admin) { + return true; + } + + // Check domain and category match + if (this.domain != domain || this.category != category) { + return false; + } + + // Check action hierarchy + return this.action.grants(action); + } + + @override + String toString() => 'Role($name)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Role && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// Extension for checking permissions on a list of roles. +extension RoleListPermissions on List { + /// Check if any role grants the required permission. + bool hasPermission(Domain domain, Action action, {String category = 'general'}) { + return any((role) => role.grants(domain, action, category: category)); + } + + /// Check if any role grants any of the required permissions. + bool hasAnyPermission(List<(Domain, Action)> permissions) { + return permissions.any((p) => hasPermission(p.$1, p.$2)); + } + + /// Check if user is a global admin. + bool get isGlobalAdmin => hasPermission(Domain.admin, Action.admin); +} diff --git a/lib/core/auth/user_preferences.dart b/lib/core/auth/user_preferences.dart new file mode 100644 index 0000000..ff761a4 --- /dev/null +++ b/lib/core/auth/user_preferences.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user_preferences.freezed.dart'; +part 'user_preferences.g.dart'; + +/// User preferences synced from core-api. +@freezed +sealed class UserPreferences with _$UserPreferences { + const factory UserPreferences({ + /// Theme preference: system, light, dark + @Default('system') String theme, + + /// Default room for housekeeping + @Default('front-hall') String defaultRoom, + + /// Extended preferences as JSON + @Default({}) Map preferencesJson, + }) = _UserPreferences; + + factory UserPreferences.fromJson(Map json) => + _$UserPreferencesFromJson(json); +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 154562f..d0b0a30 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -1,6 +1,10 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tatlock_ui/core/auth/auth_provider.dart'; +import 'package:tatlock_ui/core/config/app_config.dart'; import 'package:tatlock_ui/features/control_room/router.dart'; import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart'; import 'package:tatlock_ui/features/security/router.dart'; @@ -13,15 +17,46 @@ abstract class AppRoutes { static const frontHall = '/'; static const parlor = '/parlor'; static const settings = '/settings'; + static const login = '/login'; } /// Provides the GoRouter instance. @riverpod GoRouter appRouter(Ref ref) { + final authState = ref.watch(authProvider); + return GoRouter( initialLocation: AppRoutes.frontHall, debugLogDiagnostics: true, + redirect: (context, state) { + // No auth required in LAN mode + if (!AppConfig.requiresAuth) { + return null; + } + + final isAuthenticated = authState.value?.isAuthenticated ?? false; + final isLoginRoute = state.matchedLocation == AppRoutes.login; + + // If not authenticated, redirect to login (except if already on login) + if (!isAuthenticated && !isLoginRoute) { + return AppRoutes.login; + } + + // If authenticated and on login page, redirect to home + if (isAuthenticated && isLoginRoute) { + return AppRoutes.frontHall; + } + + return null; + }, routes: [ + // Login route (outside shell - no app scaffold) + GoRoute( + path: AppRoutes.login, + name: 'login', + builder: (context, state) => const _LoginPage(), + ), + // Main app routes (inside shell with app scaffold) ShellRoute( builder: (context, state, child) => AppScaffold(child: child), routes: [ @@ -84,3 +119,156 @@ class _PlaceholderPage extends StatelessWidget { ); } } + +/// Login page displayed when user is not authenticated. +class _LoginPage extends ConsumerWidget { + const _LoginPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authAsync = ref.watch(authProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Card( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.home_work_outlined, + size: 64, + color: colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Tatlock Estate', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Sign in to access the estate management system', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + authAsync.when( + data: (_) => _buildSignInContent(context, ref), + loading: () => const Column( + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(height: 16), + Text('Checking authentication...'), + ], + ), + error: (error, _) => _buildErrorContent(context, ref, error), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildSignInContent(BuildContext context, WidgetRef ref) { + if (kIsWeb) { + // Web: User needs to access via authenticated proxy + return Column( + children: [ + Text( + 'Please access Tatlock via the authenticated URL.\n' + 'If you see this page, the proxy authentication may not be configured.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => ref.read(authProvider.notifier).signIn(), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + ], + ); + } + + // Mobile: Show sign in button + return FilledButton.icon( + onPressed: () => ref.read(authProvider.notifier).signIn(), + icon: const Icon(Icons.login), + label: const Text('Sign in with Authentik'), + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ); + } + + Widget _buildErrorContent(BuildContext context, WidgetRef ref, Object error) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + _formatError(error), + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => ref.read(authProvider.notifier).signIn(), + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + ], + ); + } + + String _formatError(Object error) { + final message = error.toString(); + if (message.contains('user_cancelled')) { + return 'Sign in was cancelled'; + } + if (message.contains('network')) { + return 'Network error. Please check your connection.'; + } + if (message.contains('authenticated URL')) { + return 'Not authenticated - please access via the authenticated URL'; + } + return 'Authentication failed. Please try again.'; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index f18f1ce..086e87c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,9 @@ dependencies: # Storage shared_preferences: ^2.3.3 + # Authentication (OIDC/OAuth2) + flutter_appauth: ^8.0.0 + # UI flex_color_scheme: ^8.1.0 flutter_adaptive_scaffold: ^0.3.1