diff --git a/CHANGELOG.md b/CHANGELOG.md index be256ee..31fde78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.5] - 2026-01-04 + +### Changed +- **Web authentication now uses OIDC** instead of NPM forward auth + - Added `OidcServiceWeb` for browser redirect-based Authorization Code flow with PKCE + - Added `/callback` route to handle Authentik redirect after login + - Login page now shows "Sign in with Authentik" button for both web and mobile + - Tokens stored in SharedPreferences and synced with core-api via `/auth/sync` +- Added web utility functions (`web_utils.dart`) with conditional imports for non-web platforms +- Added `crypto` and `web` packages for PKCE SHA-256 and browser API access + +### Fixed +- Removed cross-origin cookie dependency that caused authentication failures on web + ## [1.0.4] - 2026-01-03 ### Added diff --git a/lib/core/auth/auth_provider.dart b/lib/core/auth/auth_provider.dart index 66a5a68..9ebbb5b 100644 --- a/lib/core/auth/auth_provider.dart +++ b/lib/core/auth/auth_provider.dart @@ -1,7 +1,6 @@ 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'; @@ -10,19 +9,21 @@ 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 two authentication flows: -/// - **Web**: NPM forward auth with Authentik (cookies handled by proxy) -/// - **Mobile**: OIDC Authorization Code flow with flutter_appauth +/// 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 /// -/// On web, the app calls GET /auth/users/me to check if user is authenticated -/// via NPM forward auth headers. On mobile, uses OIDC flow then POST /auth/sync. +/// 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 @@ -39,71 +40,10 @@ class AuthNotifier extends _$AuthNotifier { @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 + // Load stored auth on all platforms 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/users/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(); @@ -224,7 +164,7 @@ class AuthNotifier extends _$AuthNotifier { /// Sign in with the appropriate method for the platform. /// - /// - **Web**: Triggers page reload to go through NPM forward auth + /// - **Web**: Redirects to Authentik for OIDC authentication /// - **Mobile**: Opens Authentik login via OIDC, then syncs with core-api Future signIn() async { if (!AppConfig.requiresAuth) { @@ -234,25 +174,25 @@ class AuthNotifier extends _$AuthNotifier { return; } - // On web, auth is handled by NPM forward auth - // User needs to access via the authenticated proxy URL + // Web: Use OIDC flow with browser redirect if (kIsWeb) { - developer.log('Web sign-in: user should access via authenticated proxy', name: 'auth'); - // Try to refresh auth state from /auth/users/me + developer.log('Web sign-in: starting OIDC flow', name: 'auth'); 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, - ); + + 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 + // Mobile: Use OIDC flow with flutter_appauth state = const AsyncLoading(); try { @@ -307,6 +247,73 @@ class AuthNotifier extends _$AuthNotifier { } } + /// 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 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: 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', + ); + + // 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); + } + } + /// Sign out and clear stored credentials. Future signOut() async { await _clearStoredAuth(); diff --git a/lib/core/auth/oidc_service_web.dart b/lib/core/auth/oidc_service_web.dart new file mode 100644 index 0000000..807257e --- /dev/null +++ b/lib/core/auth/oidc_service_web.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer' as developer; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; + +import '../config/app_config.dart'; +import 'oidc_service.dart'; + +/// Web implementation of OIDC service using browser redirect flow. +/// +/// Uses Authorization Code flow with PKCE for secure authentication. +/// On web, we can't use flutter_appauth, so we implement the flow manually +/// using browser redirects and URL parsing. +class OidcServiceWeb implements OidcService { + OidcServiceWeb({Dio? dio}) : _dio = dio ?? Dio(); + + final Dio _dio; + + /// OIDC scopes to request. + static const _scopes = ['openid', 'profile', 'email', 'offline_access']; + + /// Redirect URI for web. + static String get _redirectUri => '${AppConfig.webBaseUrl}/callback'; + + // PKCE state stored during auth flow (in-memory for single-page app) + static String? _codeVerifier; + static String? _state; + + /// Get the authorization URL to redirect the browser to. + /// + /// Returns a URL that the browser should navigate to for authentication. + /// The [codeVerifier] and [state] are stored for later verification. + Future getAuthorizationUrl() async { + // Fetch OIDC discovery document + final discovery = await _fetchDiscovery(); + final authEndpoint = discovery['authorization_endpoint'] as String; + + // Generate PKCE code verifier and challenge + _codeVerifier = _generateCodeVerifier(); + final codeChallenge = _generateCodeChallenge(_codeVerifier!); + + // Generate state for CSRF protection + _state = _generateRandomString(32); + + // Build authorization URL + final params = { + 'client_id': AppConfig.authClientId, + 'redirect_uri': _redirectUri, + 'response_type': 'code', + 'scope': _scopes.join(' '), + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + 'state': _state, + }; + + final uri = Uri.parse(authEndpoint).replace(queryParameters: params); + developer.log('Authorization URL: $uri', name: 'oidc_web'); + return uri.toString(); + } + + /// Exchange authorization code for tokens. + /// + /// Call this after the browser redirects back with the authorization code. + /// [code] is the authorization code from the callback URL. + /// [state] is the state parameter from the callback URL (verified for CSRF). + Future exchangeCode(String code, String state) async { + // Verify state matches + if (_state == null || state != _state) { + throw OidcException('State mismatch - possible CSRF attack'); + } + + if (_codeVerifier == null) { + throw OidcException('No code verifier - flow not started properly'); + } + + try { + // Fetch token endpoint from discovery + final discovery = await _fetchDiscovery(); + final tokenEndpoint = discovery['token_endpoint'] as String; + + developer.log('Exchanging code at: $tokenEndpoint', name: 'oidc_web'); + + // Exchange code for tokens + final response = await _dio.post>( + tokenEndpoint, + data: { + 'grant_type': 'authorization_code', + 'client_id': AppConfig.authClientId, + 'redirect_uri': _redirectUri, + 'code': code, + 'code_verifier': _codeVerifier, + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + ), + ); + + final data = response.data!; + developer.log('Token exchange successful', name: 'oidc_web'); + + // Clear stored PKCE state + _codeVerifier = null; + _state = null; + + return OidcTokens( + accessToken: data['access_token'] as String, + refreshToken: data['refresh_token'] as String?, + expiresAt: DateTime.now().add( + Duration(seconds: data['expires_in'] as int? ?? 3600), + ), + idToken: data['id_token'] as String?, + ); + } on DioException catch (e) { + developer.log('Token exchange failed: $e', name: 'oidc_web'); + throw OidcException('Token exchange failed: ${e.message}'); + } finally { + // Clear PKCE state on error too + _codeVerifier = null; + _state = null; + } + } + + /// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead. + @override + Future signIn() async { + throw OidcException( + 'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.', + ); + } + + /// Refresh the access token using a refresh token. + @override + Future refreshToken(String refreshToken) async { + try { + final discovery = await _fetchDiscovery(); + final tokenEndpoint = discovery['token_endpoint'] as String; + + final response = await _dio.post>( + tokenEndpoint, + data: { + 'grant_type': 'refresh_token', + 'client_id': AppConfig.authClientId, + 'refresh_token': refreshToken, + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + ), + ); + + final data = response.data!; + + return OidcTokens( + accessToken: data['access_token'] as String, + refreshToken: data['refresh_token'] as String? ?? refreshToken, + expiresAt: DateTime.now().add( + Duration(seconds: data['expires_in'] as int? ?? 3600), + ), + idToken: data['id_token'] as String?, + ); + } on DioException catch (e) { + throw OidcException('Token refresh failed: ${e.message}'); + } + } + + /// Fetch OIDC discovery document. + Future> _fetchDiscovery() async { + final response = await _dio.get>( + AppConfig.authDiscoveryUrl, + ); + return response.data!; + } + + /// Generate a random code verifier for PKCE. + String _generateCodeVerifier() { + return _generateRandomString(64); + } + + /// Generate code challenge from verifier using S256. + String _generateCodeChallenge(String verifier) { + final bytes = utf8.encode(verifier); + final digest = sha256.convert(bytes); + return base64Url.encode(digest.bytes).replaceAll('=', ''); + } + + /// Generate a random string of given length. + String _generateRandomString(int length) { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + final random = Random.secure(); + return List.generate(length, (_) => chars[random.nextInt(chars.length)]) + .join(); + } +} diff --git a/lib/core/auth/web_utils.dart b/lib/core/auth/web_utils.dart new file mode 100644 index 0000000..8ea698e --- /dev/null +++ b/lib/core/auth/web_utils.dart @@ -0,0 +1,7 @@ +/// Web utilities with conditional imports. +/// +/// Uses stub implementation on non-web platforms. +library; + +export 'web_utils_stub.dart' + if (dart.library.js_interop) 'web_utils_web.dart'; diff --git a/lib/core/auth/web_utils_stub.dart b/lib/core/auth/web_utils_stub.dart new file mode 100644 index 0000000..647d7ae --- /dev/null +++ b/lib/core/auth/web_utils_stub.dart @@ -0,0 +1,17 @@ +/// Stub for non-web platforms. +library; + +/// Redirect to a URL (no-op on non-web). +void redirectTo(String url) { + throw UnsupportedError('redirectTo is only supported on web'); +} + +/// Get current URL (no-op on non-web). +String getCurrentUrl() { + throw UnsupportedError('getCurrentUrl is only supported on web'); +} + +/// Replace current URL without navigation (no-op on non-web). +void replaceUrl(String url) { + throw UnsupportedError('replaceUrl is only supported on web'); +} diff --git a/lib/core/auth/web_utils_web.dart b/lib/core/auth/web_utils_web.dart new file mode 100644 index 0000000..5abbb5a --- /dev/null +++ b/lib/core/auth/web_utils_web.dart @@ -0,0 +1,19 @@ +/// Web-specific utilities for browser operations. +library; + +import 'package:web/web.dart' as web; + +/// Redirect the browser to a URL. +void redirectTo(String url) { + web.window.location.href = url; +} + +/// Get the current browser URL. +String getCurrentUrl() { + return web.window.location.href; +} + +/// Replace the current URL in history without navigation. +void replaceUrl(String url) { + web.window.history.replaceState(null, '', url); +} diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 59533be..901967c 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -45,12 +45,18 @@ class AppConfig { defaultValue: 'tatlock-ui', ); - /// Authentik redirect URI scheme + /// Authentik redirect URI scheme (for mobile/native) static const authRedirectScheme = String.fromEnvironment( 'AUTH_REDIRECT_SCHEME', defaultValue: 'net.schweitz.tatlock', ); + /// Web app base URL (for OIDC redirect URI on web) + static const webBaseUrl = String.fromEnvironment( + 'WEB_BASE_URL', + defaultValue: 'https://home.schweitz.net', + ); + /// Whether running in debug mode static const isDebug = bool.fromEnvironment('DEBUG', defaultValue: false); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index d0b0a30..0e4c950 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -1,4 +1,3 @@ -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'; @@ -18,6 +17,7 @@ abstract class AppRoutes { static const parlor = '/parlor'; static const settings = '/settings'; static const login = '/login'; + static const callback = '/callback'; } /// Provides the GoRouter instance. @@ -47,6 +47,11 @@ GoRouter appRouter(Ref ref) { return AppRoutes.frontHall; } + // Allow callback route through without auth check + if (state.matchedLocation == AppRoutes.callback) { + return null; + } + return null; }, routes: [ @@ -56,6 +61,17 @@ GoRouter appRouter(Ref ref) { name: 'login', builder: (context, state) => const _LoginPage(), ), + // OIDC callback route (handles auth code exchange) + GoRoute( + path: AppRoutes.callback, + name: 'callback', + builder: (context, state) => _OidcCallbackPage( + code: state.uri.queryParameters['code'], + callbackState: state.uri.queryParameters['state'], + error: state.uri.queryParameters['error'], + errorDescription: state.uri.queryParameters['error_description'], + ), + ), // Main app routes (inside shell with app scaffold) ShellRoute( builder: (context, state, child) => AppScaffold(child: child), @@ -185,32 +201,7 @@ class _LoginPage extends ConsumerWidget { } 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 + // Same sign in button for both web and mobile return FilledButton.icon( onPressed: () => ref.read(authProvider.notifier).signIn(), icon: const Icon(Icons.login), @@ -266,9 +257,136 @@ class _LoginPage extends ConsumerWidget { 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.'; } } + +/// OIDC callback page that handles the authorization code exchange. +class _OidcCallbackPage extends ConsumerStatefulWidget { + const _OidcCallbackPage({ + this.code, + this.callbackState, + this.error, + this.errorDescription, + }); + + final String? code; + final String? callbackState; + final String? error; + final String? errorDescription; + + @override + ConsumerState<_OidcCallbackPage> createState() => _OidcCallbackPageState(); +} + +class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> { + bool _isProcessing = true; + String? _error; + + @override + void initState() { + super.initState(); + _processCallback(); + } + + Future _processCallback() async { + // Check for error from Authentik + if (widget.error != null) { + setState(() { + _isProcessing = false; + _error = widget.errorDescription ?? widget.error; + }); + return; + } + + // Check for required parameters + if (widget.code == null || widget.callbackState == null) { + setState(() { + _isProcessing = false; + _error = 'Invalid callback - missing code or state parameter'; + }); + return; + } + + // Exchange code for tokens + try { + await ref.read(authProvider.notifier).handleOidcCallback( + widget.code!, + widget.callbackState!, + ); + + // Navigate to home on success + if (mounted) { + context.go(AppRoutes.frontHall); + } + } catch (e) { + if (mounted) { + setState(() { + _isProcessing = false; + _error = e.toString(); + }); + } + } + } + + @override + Widget build(BuildContext context) { + 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( + _error != null ? Icons.error_outline : Icons.home_work_outlined, + size: 64, + color: _error != null ? colorScheme.error : colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + _error != null ? 'Authentication Failed' : 'Signing in...', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_isProcessing) + const CircularProgressIndicator() + else if (_error != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _error!, + style: TextStyle(color: colorScheme.onErrorContainer), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => context.go(AppRoutes.login), + icon: const Icon(Icons.arrow_back), + label: const Text('Back to Login'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/version.g.dart b/lib/version.g.dart index 6f36401..f994d82 100644 --- a/lib/version.g.dart +++ b/lib/version.g.dart @@ -7,7 +7,7 @@ class AppVersion { static const String name = 'tatlock_ui'; static const String description = 'Tatlock - a Home Lab AI'; - static const String version = '0.3.3'; + static const String version = '1.0.4'; static const int buildNumber = 1; - static const String fullVersion = '0.3.3+1'; + static const String fullVersion = '1.0.4+1'; } diff --git a/pubspec.yaml b/pubspec.yaml index 56d92ae..e5737cc 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.0.4+1 +version: 1.0.5+1 environment: sdk: ^3.10.4 @@ -52,6 +52,8 @@ dependencies: # Authentication (OIDC/OAuth2) flutter_appauth: ^8.0.0 + crypto: ^3.0.3 + web: ^1.1.0 # UI flex_color_scheme: ^8.1.0