diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e5ea5..db2c586 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.3] - 2026-01-04 + +### Changed +- **Web auth uses silent OIDC with JWT Bearer tokens** + - Uses `prompt=none` to silently obtain JWT when Authentik session exists (via NPM forward auth) + - Flutter sends Bearer token to core-api instead of relying on forward auth cookies + - Fixes cross-subdomain cookie issues between home.schweitz.net and api.schweitz.net + - Callback now syncs with `/auth/sync` to get user profile and roles from core-api + - API interceptor now adds Bearer token on web (previously skipped) + ## [1.1.2] - 2026-01-04 ### Changed diff --git a/lib/core/api/api_interceptors.dart b/lib/core/api/api_interceptors.dart index 8fa2b0e..7be3d50 100644 --- a/lib/core/api/api_interceptors.dart +++ b/lib/core/api/api_interceptors.dart @@ -10,8 +10,7 @@ import 'package:tatlock_ui/core/error/app_exception.dart'; /// Adds authentication token to requests. /// /// - **LAN mode**: Skipped entirely (no auth required) -/// - **Web**: Skipped (cookies handle auth via NPM forward auth) -/// - **Mobile**: Adds Bearer token from OIDC authentication +/// - **Web + Mobile**: Adds Bearer token from OIDC authentication class AuthInterceptor extends Interceptor { AuthInterceptor(this._ref); @@ -25,17 +24,11 @@ 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 + // Add Bearer token for all platforms (web + mobile) final authState = _ref.read(authProvider); authState.whenData((auth) { - if (auth.isAuthenticated && auth.accessToken != null && auth.accessToken != 'web-session') { + if (auth.isAuthenticated && auth.accessToken != null) { options.headers['Authorization'] = 'Bearer ${auth.accessToken}'; } }); diff --git a/lib/core/auth/auth_provider.dart b/lib/core/auth/auth_provider.dart index 14a1320..fdf91a7 100644 --- a/lib/core/auth/auth_provider.dart +++ b/lib/core/auth/auth_provider.dart @@ -1,4 +1,4 @@ -import 'dart:convert' show base64Url, jsonDecode, jsonEncode, utf8; +import 'dart:convert' show jsonDecode, jsonEncode; import 'dart:developer' as developer; import 'package:flutter/foundation.dart' show kIsWeb; @@ -40,10 +40,44 @@ class AuthNotifier extends _$AuthNotifier { @override Future build() async { - // Load stored auth on all platforms + // On web with auth required, use silent OIDC to get JWT + if (kIsWeb && AppConfig.requiresAuth) { + // 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 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(); @@ -265,27 +299,28 @@ class AuthNotifier extends _$AuthNotifier { 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?)?.cast() ?? []; + // 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); - developer.log('JWT claims: name=$userName, email=$userEmail, groups=$groups', name: 'auth'); + developer.log( + 'Synced user: ${syncResponse.name} with ${syncResponse.roles.length} roles', + name: 'auth', + ); - // Step 3: Store credentials and user data from JWT + // Step 3: Store credentials and user data from sync response 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 + userId: syncResponse.userId, + authentikId: syncResponse.authentikId, + userName: syncResponse.name, + userEmail: syncResponse.email, + avatarUrl: syncResponse.avatarUrl, + roles: syncResponse.roles, + preferences: syncResponse.preferences, ); state = AsyncData(AuthState( @@ -293,12 +328,16 @@ class AuthNotifier extends _$AuthNotifier { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, - authentikId: authentikId, - userName: userName, - userEmail: userEmail, + 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 $userName', name: 'auth'); + developer.log('Authenticated as ${syncResponse.name}', name: 'auth'); // Clean up the URL by removing the query parameters web_utils.replaceUrl('/'); @@ -311,35 +350,6 @@ class AuthNotifier extends _$AuthNotifier { } } - /// Decode JWT payload without verification (validation happens server-side). - Map _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; - } 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. diff --git a/lib/core/auth/oidc_service_web.dart b/lib/core/auth/oidc_service_web.dart index d86459e..03818c4 100644 --- a/lib/core/auth/oidc_service_web.dart +++ b/lib/core/auth/oidc_service_web.dart @@ -34,7 +34,12 @@ class OidcServiceWeb implements OidcService { /// /// Returns a URL that the browser should navigate to for authentication. /// The [codeVerifier] and [state] are stored for later verification. - Future getAuthorizationUrl() async { + /// + /// If [silent] is true, adds `prompt=none` to skip login UI. + /// This is used when the user already has an Authentik session (via NPM). + /// Authentik will instantly redirect back with a code, or return an error + /// if there's no valid session. + Future getAuthorizationUrl({bool silent = false}) async { // Fetch OIDC discovery document final discovery = await _fetchDiscovery(); final authEndpoint = discovery['authorization_endpoint'] as String; @@ -59,10 +64,11 @@ class OidcServiceWeb implements OidcService { 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', 'state': state, + if (silent) 'prompt': 'none', // Silent auth - no UI, instant redirect }; final uri = Uri.parse(authEndpoint).replace(queryParameters: params); - developer.log('Authorization URL: $uri', name: 'oidc_web'); + developer.log('Authorization URL (silent=$silent): $uri', name: 'oidc_web'); return uri.toString(); } diff --git a/lib/shared/layouts/app_scaffold.dart b/lib/shared/layouts/app_scaffold.dart index 09f781f..3870700 100644 --- a/lib/shared/layouts/app_scaffold.dart +++ b/lib/shared/layouts/app_scaffold.dart @@ -46,14 +46,7 @@ class _AppScaffoldState extends ConsumerState { return _buildScaffold(context); } - // On web, NPM forward auth handles authentication at the proxy level. - // If we reach this point, the user is already authenticated by NPM. - // No need for Flutter's OIDC flow - just show the app. - if (kIsWeb) { - return _buildScaffold(context); - } - - // Mobile: Use Flutter's OIDC flow + // Watch auth state (works for both web and mobile) final authAsync = ref.watch(authProvider); return authAsync.when( @@ -64,7 +57,13 @@ class _AppScaffoldState extends ConsumerState { return _buildScaffold(context); } - // Not authenticated - auto-initiate OIDC + // On web, NPM handles auth - if we're here without auth, something is wrong + // (NPM should have redirected to Authentik before we loaded) + if (kIsWeb) { + return _buildAuthErrorScreen(context, 'Authentication required'); + } + + // Mobile: Not authenticated - auto-initiate OIDC if (!_authInitiated) { _authInitiated = true; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -75,7 +74,7 @@ class _AppScaffoldState extends ConsumerState { // Show loading while redirecting to Authentik return _buildAuthLoadingScreen(context, 'Redirecting to sign in...'); }, - loading: () => _buildAuthLoadingScreen(context, 'Checking authentication...'), + loading: () => _buildAuthLoadingScreen(context, 'Loading user info...'), error: (error, _) => _buildAuthErrorScreen(context, error), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 9f54dc3..c0c8a49 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.2+1 +version: 1.1.3+1 environment: sdk: ^3.10.4