From ea61168184b241e258151070c82177ffed95a658 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:36:22 +0530 Subject: [PATCH 1/4] feat(auth): Add LDAP and SSO authentication support --- lib/core/auth/auth_state_manager.dart | 160 ++++- lib/core/auth/webview_cookie_helper.dart | 33 + lib/core/router/app_router.dart | 11 + lib/core/services/api_service.dart | 40 ++ lib/core/services/navigation_service.dart | 2 + .../services/optimized_storage_service.dart | 2 + .../services/secure_credential_storage.dart | 20 +- .../providers/unified_auth_providers.dart | 14 + .../auth/views/authentication_page.dart | 380 +++++++++++- lib/features/auth/views/sso_auth_page.dart | 565 ++++++++++++++++++ lib/l10n/app_de.arb | 17 +- lib/l10n/app_en.arb | 60 ++ lib/l10n/app_es.arb | 17 +- lib/l10n/app_fr.arb | 17 +- lib/l10n/app_it.arb | 17 +- lib/l10n/app_ko.arb | 17 +- lib/l10n/app_nl.arb | 17 +- lib/l10n/app_ru.arb | 17 +- lib/l10n/app_zh.arb | 17 +- lib/l10n/app_zh_Hant.arb | 17 +- 20 files changed, 1395 insertions(+), 45 deletions(-) create mode 100644 lib/core/auth/webview_cookie_helper.dart create mode 100644 lib/features/auth/views/sso_auth_page.dart diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index 86bd28c..cb258ec 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -8,6 +8,7 @@ import '../models/user.dart'; import '../services/optimized_storage_service.dart'; import 'token_validator.dart'; import 'auth_cache_manager.dart'; +import 'webview_cookie_helper.dart'; import '../utils/debug_logger.dart'; import '../utils/user_avatar_utils.dart'; import '../../features/tools/providers/tools_providers.dart'; @@ -288,11 +289,17 @@ class AuthStateManager extends _$AuthStateManager { } } - /// Perform login with JWT token + /// Perform login with JWT token. + /// /// Note: API keys (sk-...) are not supported for streaming. + /// + /// [authType] specifies the source of the token for credential storage: + /// - 'token': Manual JWT entry (default) + /// - 'sso': Token obtained via SSO/OAuth flow Future loginWithApiKey( String apiKey, { bool rememberCredentials = false, + String authType = 'token', }) async { _update( (current) => current.copyWith( @@ -347,6 +354,7 @@ class AuthStateManager extends _$AuthStateManager { serverId: activeServer.id, username: 'jwt_user', // Special username to indicate JWT auth password: tokenStr, // Store JWT in password field + authType: authType, // 'token' for manual entry, 'sso' for OAuth ); } } @@ -486,6 +494,117 @@ class AuthStateManager extends _$AuthStateManager { } } + /// Perform login with LDAP credentials. + /// + /// LDAP uses username (not email) for authentication. + /// The server must have LDAP enabled, otherwise this will throw an error. + Future ldapLogin( + String username, + String password, { + bool rememberCredentials = false, + }) async { + _update( + (current) => current.copyWith( + status: AuthStatus.loading, + isLoading: true, + clearError: true, + ), + ); + + try { + // Ensure API service is available + await _ensureApiServiceAvailable(); + final api = ref.read(apiServiceProvider); + if (api == null) { + throw Exception('No server connection available'); + } + + // Perform LDAP login API call + final response = await api.ldapLogin(username, password); + + // Check if notifier is still mounted after async call + if (!ref.mounted) return false; + + // Extract and validate token + final token = response['token'] ?? response['access_token']; + if (token == null || token.toString().trim().isEmpty) { + throw Exception('No authentication token received'); + } + + final tokenStr = token.toString(); + if (!_isValidTokenFormat(tokenStr)) { + throw Exception('Invalid authentication token format'); + } + + // Save token to storage + final storage = ref.read(optimizedStorageServiceProvider); + await storage.saveAuthToken(tokenStr); + + if (!ref.mounted) return false; + + // Save JWT token for re-authentication if requested + // We store the token (not the raw LDAP password) for security: + // - JWT tokens can be revoked server-side + // - Avoids storing the user's directory password + // - Consistent with SSO token storage approach + if (rememberCredentials) { + final activeServer = await ref.read(activeServerProvider.future); + if (!ref.mounted) return false; + if (activeServer != null) { + await storage.saveCredentials( + serverId: activeServer.id, + // Prefix with ldap: to preserve original username for debugging + // while indicating this is token-based auth + username: 'ldap:$username', + password: tokenStr, // Store JWT token, not LDAP password + authType: 'ldap', // Track that this originated from LDAP login + ); + } + } + + if (!ref.mounted) return false; + + // Update state and API service + _update( + (current) => current.copyWith( + status: AuthStatus.authenticated, + token: tokenStr, + isLoading: false, + clearError: true, + ), + cache: true, + ); + + _updateApiServiceToken(tokenStr); + _preloadDefaultModel(); + + // Load user data in background + _loadUserData(); + _prefetchConversations(); + + DebugLogger.auth('LDAP login successful'); + return true; + } catch (e, stack) { + DebugLogger.error( + 'ldap-login-failed', + scope: 'auth/state', + error: e, + stackTrace: stack, + ); + if (ref.mounted) { + _update( + (current) => current.copyWith( + status: AuthStatus.error, + error: e.toString(), + isLoading: false, + clearToken: true, + ), + ); + } + rethrow; + } + } + /// Wait briefly until the API service becomes available Future _ensureApiServiceAvailable({ Duration timeout = const Duration(seconds: 2), @@ -582,12 +701,27 @@ class AuthStateManager extends _$AuthStateManager { return false; } - // Attempt login (detect API key vs normal credentials) - if (username == 'api_key_user' || username == 'jwt_user') { - // This is a saved JWT token (or legacy API key) - return await loginWithApiKey(password, rememberCredentials: false); + // Attempt login based on auth type + final authType = savedCredentials['authType'] ?? 'credentials'; + + // Handle JWT token-based authentication (includes legacy prefixes) + // LDAP now also stores JWT tokens for re-auth (not raw passwords) + if (username == 'api_key_user' || + username == 'jwt_user' || + username.startsWith('ldap:') || + authType == 'token' || + authType == 'sso' || + authType == 'ldap') { + // This is a saved JWT token (manual entry, SSO, or LDAP-obtained) + // For LDAP, we store the JWT token returned by the server, not the + // original password, for security reasons + return await loginWithApiKey( + password, // This is the JWT token + rememberCredentials: false, + authType: authType, + ); } else { - // Normal username/password credentials + // Standard credentials login (default) return await login(username, password, rememberCredentials: false); } } catch (e, stack) { @@ -805,6 +939,20 @@ class AuthStateManager extends _$AuthStateManager { await storage.clearAuthData(); _updateApiServiceToken(null); + // Clear WebView cookies to ensure fresh SSO sessions on next login + try { + final cleared = await WebViewCookieHelper.clearCookies(); + if (cleared) { + DebugLogger.auth('WebView cookies cleared'); + } + } catch (e) { + DebugLogger.warning( + 'webview-cookie-clear-failed', + scope: 'auth/state', + data: {'error': e.toString()}, + ); + } + // Keep active server ID so router redirects to sign-in page, not server // connection page. Users can navigate to server settings if they need to // change server configuration. diff --git a/lib/core/auth/webview_cookie_helper.dart b/lib/core/auth/webview_cookie_helper.dart new file mode 100644 index 0000000..e59ad97 --- /dev/null +++ b/lib/core/auth/webview_cookie_helper.dart @@ -0,0 +1,33 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Check if WebView is supported on the current platform. +/// +/// webview_flutter only supports iOS and Android. +bool get isWebViewSupported => + !kIsWeb && (Platform.isIOS || Platform.isAndroid); + +/// Helper for clearing WebView cookies on supported platforms. +/// +/// This is isolated in its own file to prevent platform coupling issues +/// when the webview_flutter package isn't available. +class WebViewCookieHelper { + /// Clears all WebView cookies. + /// + /// Returns true if cookies were cleared, false if not supported or failed. + /// Checks platform support internally, so safe to call on any platform. + static Future clearCookies() async { + // Only supported on mobile platforms + if (!isWebViewSupported) return false; + + try { + return await WebViewCookieManager().clearCookies(); + } catch (e) { + // Silently fail - WebView may not be available + return false; + } + } +} + diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1ad2d03..d81aba5 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -14,6 +14,7 @@ import '../../features/auth/views/authentication_page.dart'; import '../../features/auth/views/connect_signin_page.dart'; import '../../features/auth/views/connection_issue_page.dart'; import '../../features/auth/views/server_connection_page.dart'; +import '../../features/auth/views/sso_auth_page.dart'; import '../../features/chat/views/chat_page.dart'; import '../../features/navigation/views/splash_launcher_page.dart'; import '../../features/notes/views/notes_list_page.dart'; @@ -237,6 +238,16 @@ final goRouterProvider = Provider((ref) { ); }, ), + GoRoute( + path: Routes.ssoAuth, + name: RouteNames.ssoAuth, + builder: (context, state) { + final config = state.extra; + return SsoAuthPage( + serverConfig: config is ServerConfig ? config : null, + ); + }, + ), GoRoute( path: Routes.profile, name: RouteNames.profile, diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index dc06354..bd38e9a 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -355,6 +355,46 @@ class ApiService { await _dio.get('/api/v1/auths/signout'); } + /// LDAP authentication - uses username instead of email. + /// + /// Returns the same response format as regular login: + /// `{"token": "...", "token_type": "Bearer", "id": "...", ...}` + /// + /// Throws an exception if LDAP is not enabled on the server (400 response). + Future> ldapLogin( + String username, + String password, + ) async { + try { + final response = await _dio.post( + '/api/v1/auths/ldap', + data: {'user': username, 'password': password}, + ); + + return response.data; + } catch (e) { + if (e is DioException) { + // Handle LDAP not enabled + if (e.response?.statusCode == 400) { + final data = e.response?.data; + if (data is Map && data['detail'] != null) { + throw Exception(data['detail']); + } + } + // Handle specific redirect cases + if (e.response?.statusCode == 307 || e.response?.statusCode == 308) { + final location = e.response?.headers.value('location'); + if (location != null) { + throw Exception( + 'Server redirect detected. Please check your server URL configuration. Redirect to: $location', + ); + } + } + } + rethrow; + } + } + // User info Future getCurrentUser() async { final response = await _dio.get('/api/v1/auths/'); diff --git a/lib/core/services/navigation_service.dart b/lib/core/services/navigation_service.dart index d583319..573fd09 100644 --- a/lib/core/services/navigation_service.dart +++ b/lib/core/services/navigation_service.dart @@ -101,6 +101,7 @@ class Routes { static const String serverConnection = '/server-connection'; static const String connectionIssue = '/connection-issue'; static const String authentication = '/authentication'; + static const String ssoAuth = '/sso-auth'; static const String profile = '/profile'; static const String appCustomization = '/profile/customization'; static const String notes = '/notes'; @@ -115,6 +116,7 @@ class RouteNames { static const String serverConnection = 'server-connection'; static const String connectionIssue = 'connection-issue'; static const String authentication = 'authentication'; + static const String ssoAuth = 'sso-auth'; static const String profile = 'profile'; static const String appCustomization = 'app-customization'; static const String notes = 'notes'; diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index bc8f447..1413513 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -132,12 +132,14 @@ class OptimizedStorageService { required String serverId, required String username, required String password, + String authType = 'credentials', }) async { try { await _secureCredentialStorage.saveCredentials( serverId: serverId, username: username, password: password, + authType: authType, ); _cacheManager.write('has_credentials', true, ttl: _credentialsFlagTtl); diff --git a/lib/core/services/secure_credential_storage.dart b/lib/core/services/secure_credential_storage.dart index d0028e0..304ca6d 100644 --- a/lib/core/services/secure_credential_storage.dart +++ b/lib/core/services/secure_credential_storage.dart @@ -40,11 +40,18 @@ class SecureCredentialStorage { ); } - /// Save user credentials securely + /// Save user credentials securely. + /// + /// [authType] identifies the authentication method: + /// - 'credentials': Standard email/password login (default) + /// - 'ldap': LDAP directory authentication + /// - 'token': Manual JWT token entry + /// - 'sso': JWT token obtained via SSO/OAuth flow Future saveCredentials({ required String serverId, required String username, required String password, + String authType = 'credentials', }) async { try { // First check if secure storage is available @@ -57,9 +64,10 @@ class SecureCredentialStorage { 'serverId': serverId, 'username': username, 'password': password, + 'authType': authType, 'savedAt': DateTime.now().toIso8601String(), 'deviceId': await _getDeviceFingerprint(), - 'version': '2.0', // Version for migration purposes + 'version': '2.1', // Version for migration purposes }; final encryptedData = await _encryptData(jsonEncode(credentials)); @@ -76,7 +84,7 @@ class SecureCredentialStorage { DebugLogger.storage( 'save-ok', scope: 'credentials', - data: {'version': '2.0'}, + data: {'version': '2.1'}, ); } catch (e) { DebugLogger.error('save-failed', scope: 'credentials', error: e); @@ -156,6 +164,7 @@ class SecureCredentialStorage { 'username': decoded['username']?.toString() ?? '', 'password': decoded['password']?.toString() ?? '', 'savedAt': decoded['savedAt']?.toString() ?? '', + 'authType': decoded['authType']?.toString() ?? 'credentials', }; } catch (e) { DebugLogger.error('read-failed', scope: 'credentials', error: e); @@ -355,7 +364,9 @@ class SecureCredentialStorage { } } - /// Migrate from old storage format if needed + /// Migrate from old storage format if needed. + /// + /// Preserves the [authType] if present in old credentials. Future migrateFromOldStorage( Map? oldCredentials, ) async { @@ -366,6 +377,7 @@ class SecureCredentialStorage { serverId: oldCredentials['serverId'] ?? '', username: oldCredentials['username'] ?? '', password: oldCredentials['password'] ?? '', + authType: oldCredentials['authType'] ?? 'credentials', ); DebugLogger.storage('migrate-ok', scope: 'credentials'); } catch (e) { diff --git a/lib/features/auth/providers/unified_auth_providers.dart b/lib/features/auth/providers/unified_auth_providers.dart index 37ca30f..ab84f8c 100644 --- a/lib/features/auth/providers/unified_auth_providers.dart +++ b/lib/features/auth/providers/unified_auth_providers.dart @@ -29,10 +29,24 @@ class AuthActions { Future loginWithApiKey( String apiKey, { bool rememberCredentials = false, + String authType = 'token', }) { return _auth.loginWithApiKey( apiKey, rememberCredentials: rememberCredentials, + authType: authType, + ); + } + + Future ldapLogin( + String username, + String password, { + bool rememberCredentials = false, + }) { + return _auth.ldapLogin( + username, + password, + rememberCredentials: rememberCredentials, ); } diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index 9150c77..5a19d9d 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -17,6 +17,15 @@ import '../../../core/auth/auth_state_manager.dart'; import '../../../core/utils/debug_logger.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../providers/unified_auth_providers.dart'; +import '../../../core/auth/webview_cookie_helper.dart' show isWebViewSupported; + +/// Authentication mode options +enum AuthMode { + credentials, // Email/password + token, // JWT token + sso, // OAuth/OIDC via WebView + ldap, // LDAP username/password +} class AuthenticationPage extends ConsumerStatefulWidget { final ServerConfig? serverConfig; @@ -32,9 +41,11 @@ class _AuthenticationPageState extends ConsumerState { final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); final TextEditingController _apiKeyController = TextEditingController(); + final TextEditingController _ldapUsernameController = TextEditingController(); + final TextEditingController _ldapPasswordController = TextEditingController(); bool _obscurePassword = true; - bool _useApiKey = false; + AuthMode _authMode = AuthMode.credentials; String? _loginError; bool _isSigningIn = false; bool _serverConfigSaved = false; @@ -56,7 +67,7 @@ class _AuthenticationPageState extends ConsumerState { _loginError = _formatLoginError(authState.error!); // Switch to token tab if the error is about API keys if (authState.error!.contains('apiKey')) { - _useApiKey = true; + _authMode = AuthMode.token; } }); } @@ -77,6 +88,8 @@ class _AuthenticationPageState extends ConsumerState { _usernameController.dispose(); _passwordController.dispose(); _apiKeyController.dispose(); + _ldapUsernameController.dispose(); + _ldapPasswordController.dispose(); super.dispose(); } @@ -100,17 +113,27 @@ class _AuthenticationPageState extends ConsumerState { final actions = ref.read(authActionsProvider); bool success; - if (_useApiKey) { - success = await actions.loginWithApiKey( - _apiKeyController.text.trim(), - rememberCredentials: true, - ); - } else { - success = await actions.login( - _usernameController.text.trim(), - _passwordController.text, - rememberCredentials: true, - ); + switch (_authMode) { + case AuthMode.credentials: + success = await actions.login( + _usernameController.text.trim(), + _passwordController.text, + rememberCredentials: true, + ); + case AuthMode.token: + success = await actions.loginWithApiKey( + _apiKeyController.text.trim(), + rememberCredentials: true, + ); + case AuthMode.ldap: + success = await actions.ldapLogin( + _ldapUsernameController.text.trim(), + _ldapPasswordController.text, + rememberCredentials: true, + ); + case AuthMode.sso: + // SSO is handled by navigating to SsoAuthPage + return; } if (!success) { @@ -149,6 +172,8 @@ class _AuthenticationPageState extends ConsumerState { return l10n.apiKeyNotSupported; } else if (error.contains('apiKeyNoLongerSupported')) { return l10n.apiKeyNoLongerSupported; + } else if (error.contains('LDAP authentication is not enabled')) { + return l10n.ldapNotEnabled; } else if (error.contains('401') || error.contains('Unauthorized')) { return l10n.invalidCredentials; } else if (error.contains('redirect')) { @@ -374,10 +399,12 @@ class _AuthenticationPageState extends ConsumerState { } Widget _buildAuthForm() { + final l10n = AppLocalizations.of(context)!; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Authentication mode toggle + // Primary authentication mode toggle (Credentials/Token) _buildAuthModeToggle(), const SizedBox(height: Spacing.lg), @@ -389,11 +416,19 @@ class _AuthenticationPageState extends ConsumerState { const SizedBox(height: Spacing.md), _buildErrorMessage(_loginError!), ], + + // More options section (SSO/LDAP) + const SizedBox(height: Spacing.lg), + _buildMoreOptionsSection(l10n), ], ); } Widget _buildAuthModeToggle() { + final l10n = AppLocalizations.of(context)!; + final isPrimaryMode = + _authMode == AuthMode.credentials || _authMode == AuthMode.token; + return Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( @@ -411,9 +446,13 @@ class _AuthenticationPageState extends ConsumerState { icon: Platform.isIOS ? CupertinoIcons.person_circle : Icons.account_circle_outlined, - label: AppLocalizations.of(context)!.credentials, - isSelected: !_useApiKey, - onTap: () => setState(() => _useApiKey = false), + label: l10n.credentials, + isSelected: _authMode == AuthMode.credentials && isPrimaryMode, + onTap: () => setState(() { + _authMode = AuthMode.credentials; + _loginError = null; + _obscurePassword = true; // Reset visibility on mode change + }), ), ), Expanded( @@ -421,9 +460,13 @@ class _AuthenticationPageState extends ConsumerState { icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key_outlined, - label: AppLocalizations.of(context)!.token, - isSelected: _useApiKey, - onTap: () => setState(() => _useApiKey = true), + label: l10n.token, + isSelected: _authMode == AuthMode.token && isPrimaryMode, + onTap: () => setState(() { + _authMode = AuthMode.token; + _loginError = null; + _obscurePassword = true; // Reset visibility on mode change + }), ), ), ], @@ -498,10 +541,23 @@ class _AuthenticationPageState extends ConsumerState { ), ); }, - child: _useApiKey ? _buildApiKeyForm() : _buildCredentialsForm(), + child: _buildCurrentAuthForm(), ); } + Widget _buildCurrentAuthForm() { + switch (_authMode) { + case AuthMode.credentials: + return _buildCredentialsForm(); + case AuthMode.token: + return _buildApiKeyForm(); + case AuthMode.ldap: + return _buildLdapForm(); + case AuthMode.sso: + return _buildSsoPrompt(); + } + } + /// Validates that a token is a JWT and not an API key. /// API keys (sk-, api-, key-) don't work with WebSocket authentication. String? _validateJwtToken(String? value) { @@ -632,15 +688,287 @@ class _AuthenticationPageState extends ConsumerState { ); } + Widget _buildLdapForm() { + final l10n = AppLocalizations.of(context)!; + + return Column( + key: const ValueKey('ldap_form'), + children: [ + AccessibleFormField( + label: l10n.ldapUsername, + hint: l10n.ldapUsernameHint, + controller: _ldapUsernameController, + validator: InputValidationService.validateRequired, + keyboardType: TextInputType.text, + semanticLabel: l10n.ldapUsernameHint, + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.person : Icons.person_outline, + color: context.conduitTheme.iconSecondary, + ), + autofillHints: const [AutofillHints.username], + isRequired: true, + ), + const SizedBox(height: Spacing.lg), + AccessibleFormField( + label: l10n.password, + hint: l10n.passwordHint, + controller: _ldapPasswordController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => InputValidationService.validateMinLength( + value, + 1, + fieldName: l10n.password, + ), + ]), + obscureText: _obscurePassword, + semanticLabel: l10n.passwordHint, + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.lock : Icons.lock_outline, + color: context.conduitTheme.iconSecondary, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? (Platform.isIOS + ? CupertinoIcons.eye_slash + : Icons.visibility_off) + : (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility), + color: context.conduitTheme.iconSecondary, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + onSubmitted: (_) => _signIn(), + autofillHints: const [AutofillHints.password], + isRequired: true, + ), + const SizedBox(height: Spacing.sm), + Text( + l10n.ldapDescription, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ); + } + + Widget _buildSsoPrompt() { + final l10n = AppLocalizations.of(context)!; + + return Column( + key: const ValueKey('sso_form'), + children: [ + Container( + padding: const EdgeInsets.all(Spacing.lg), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.medium), + border: Border.all( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), + width: BorderWidth.standard, + ), + ), + child: Column( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.lock_shield : Icons.security, + size: IconSize.xxl, + color: context.conduitTheme.buttonPrimary, + ), + const SizedBox(height: Spacing.md), + Text(l10n.sso, style: context.conduitTheme.headingMedium), + const SizedBox(height: Spacing.sm), + Text( + l10n.ssoDescription, + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.lg), + ConduitButton( + text: l10n.signInWithSso, + icon: Platform.isIOS + ? CupertinoIcons.arrow_right + : Icons.arrow_forward, + onPressed: _navigateToSso, + isFullWidth: true, + ), + ], + ), + ), + ], + ); + } + + Future _navigateToSso() async { + if (!mounted) return; + + // Save server config first if needed + if (widget.serverConfig != null && !_serverConfigSaved) { + await _saveServerConfig(widget.serverConfig!); + _serverConfigSaved = true; + if (!mounted) return; + } + + context.pushNamed(RouteNames.ssoAuth, extra: widget.serverConfig); + } + + Widget _buildMoreOptionsSection(AppLocalizations l10n) { + return Column( + children: [ + // Divider with "or" text + Row( + children: [ + Expanded( + child: Divider( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: Text( + l10n.moreSignInOptions, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ), + Expanded( + child: Divider( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), + ), + ), + ], + ), + const SizedBox(height: Spacing.md), + + // SSO and LDAP buttons + // SSO is only available on platforms that support WebView (iOS/Android) + Row( + children: [ + if (isWebViewSupported) ...[ + Expanded( + child: _buildOptionButton( + icon: Platform.isIOS + ? CupertinoIcons.lock_shield + : Icons.security, + label: l10n.sso, + isSelected: _authMode == AuthMode.sso, + onTap: () => setState(() { + _authMode = AuthMode.sso; + _loginError = null; + _obscurePassword = true; // Reset visibility on mode change + }), + ), + ), + const SizedBox(width: Spacing.sm), + ], + Expanded( + child: _buildOptionButton( + icon: Platform.isIOS + ? CupertinoIcons.building_2_fill + : Icons.domain, + label: l10n.ldap, + isSelected: _authMode == AuthMode.ldap, + onTap: () => setState(() { + _authMode = AuthMode.ldap; + _loginError = null; + _obscurePassword = true; // Reset visibility on mode change + }), + ), + ), + ], + ), + ], + ); + } + + Widget _buildOptionButton({ + required IconData icon, + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + return Material( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1) + : context.conduitTheme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.small), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.small), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: Spacing.md, + horizontal: Spacing.sm, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.small), + border: Border.all( + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.dividerColor.withValues(alpha: 0.5), + width: BorderWidth.standard, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: IconSize.small, + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.xs), + Text( + label, + style: context.conduitTheme.bodySmall?.copyWith( + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildSignInButton() { + final l10n = AppLocalizations.of(context)!; + + // Don't show sign-in button for SSO mode (it has its own button) + if (_authMode == AuthMode.sso) { + return const SizedBox.shrink(); + } + + String buttonText; + if (_isSigningIn) { + buttonText = l10n.signingIn; + } else { + switch (_authMode) { + case AuthMode.credentials: + buttonText = l10n.signIn; + case AuthMode.token: + buttonText = l10n.signInWithToken; + case AuthMode.ldap: + buttonText = l10n.signInWithLdap; + case AuthMode.sso: + buttonText = l10n.signInWithSso; + } + } + return Padding( padding: const EdgeInsets.only(top: Spacing.lg), child: ConduitButton( - text: _isSigningIn - ? AppLocalizations.of(context)!.signingIn - : _useApiKey - ? AppLocalizations.of(context)!.signInWithToken - : AppLocalizations.of(context)!.signIn, + text: buttonText, icon: _isSigningIn ? null : (Platform.isIOS diff --git a/lib/features/auth/views/sso_auth_page.dart b/lib/features/auth/views/sso_auth_page.dart new file mode 100644 index 0000000..da99682 --- /dev/null +++ b/lib/features/auth/views/sso_auth_page.dart @@ -0,0 +1,565 @@ +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +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:webview_flutter/webview_flutter.dart'; + +import '../../../core/auth/webview_cookie_helper.dart'; +import '../../../core/models/server_config.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/services/navigation_service.dart'; +import '../../../core/utils/debug_logger.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import 'package:conduit/l10n/app_localizations.dart'; +import '../providers/unified_auth_providers.dart'; + +/// SSO Authentication page that uses a WebView to handle OAuth/OIDC flows. +/// +/// This page loads the Open-WebUI `/auth` page in a WebView, allowing users +/// to authenticate via configured OAuth providers (Google, Microsoft, GitHub, +/// OIDC, etc.). After successful authentication, the JWT token is captured +/// from cookies or localStorage and used to authenticate in Conduit. +class SsoAuthPage extends ConsumerStatefulWidget { + final ServerConfig? serverConfig; + + const SsoAuthPage({super.key, this.serverConfig}); + + @override + ConsumerState createState() => _SsoAuthPageState(); +} + +class _SsoAuthPageState extends ConsumerState { + WebViewController? _controller; + bool _isLoading = true; + bool _tokenCaptured = false; + String? _error; + String? _serverUrl; + int _captureAttemptId = 0; // Used to cancel stale retry sequences + + @override + void initState() { + super.initState(); + // Defer initialization to after first frame to ensure context is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initializeWebView(); + }); + } + + @override + void dispose() { + // Increment attempt ID to cancel any in-flight token capture operations + _captureAttemptId++; + // Clear controller reference (WebViewController doesn't have a dispose method, + // but setting to null ensures callbacks check mounted state) + _controller = null; + super.dispose(); + } + + Future _initializeWebView() async { + // Check platform support first - webview_flutter only supports iOS/Android + if (!isWebViewSupported) { + if (!mounted) return; + final l10n = AppLocalizations.of(context); + setState(() { + _error = + l10n?.ssoPlatformNotSupported ?? + 'SSO authentication is not supported on this platform. ' + 'Please use credentials or LDAP authentication instead.'; + _isLoading = false; + }); + return; + } + + // Get server URL from config or active server + final config = widget.serverConfig; + if (config != null) { + _serverUrl = config.url; + } else { + final activeServer = await ref.read(activeServerProvider.future); + if (!mounted) return; + _serverUrl = activeServer?.url; + } + + if (_serverUrl == null) { + if (!mounted) return; + setState(() { + _error = 'No server configured'; + _isLoading = false; + }); + return; + } + + DebugLogger.auth('Initializing SSO WebView for $_serverUrl'); + + final controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: _onPageStarted, + onPageFinished: _onPageFinished, + onWebResourceError: _onWebResourceError, + onNavigationRequest: _onNavigationRequest, + ), + ) + ..setUserAgent(_buildUserAgent()); + + // Clear cookies before loading to ensure fresh session + if (isWebViewSupported) { + await WebViewCookieManager().clearCookies(); + } + + if (!mounted) return; + + // Load the auth page + await controller.loadRequest(Uri.parse('$_serverUrl/auth')); + + if (!mounted) return; + + setState(() { + _controller = controller; + }); + } + + String _buildUserAgent() { + // Use a standard mobile browser user agent to ensure OAuth providers work correctly + // Note: webview_flutter only supports iOS and Android; guard against web to be safe + if (!kIsWeb && Platform.isIOS) { + return 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + } else { + // Android (or fallback) - use mobile Chrome + return 'Mozilla/5.0 (Linux; Android 14) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; + } + } + + void _onPageStarted(String url) { + DebugLogger.auth('SSO page started: $url'); + // Increment attempt ID to cancel any in-progress retry sequences + _captureAttemptId++; + setState(() { + _isLoading = true; + _error = null; + }); + } + + Future _onPageFinished(String url) async { + DebugLogger.auth('SSO page finished: $url'); + + setState(() { + _isLoading = false; + }); + + if (_tokenCaptured) return; + + final uri = Uri.parse(url); + + // Check for error parameter (OAuth failures redirect with ?error=...) + final error = uri.queryParameters['error']; + if (error != null && error.isNotEmpty) { + DebugLogger.auth('SSO error from URL: $error'); + setState(() { + _error = error; + }); + return; + } + + // Only check for token on /auth page (after OAuth callback redirect) + // or on the root page (some configurations) + if (!uri.path.endsWith('/auth') && uri.path != '/') return; + + // Wait a moment for the frontend to persist the token + // The OAuth callback sets the cookie, then redirects to /auth, + // where the frontend reads the cookie and stores it in localStorage + final attemptId = _captureAttemptId; + await _attemptTokenCaptureWithRetry(uri, attemptId: attemptId); + } + + /// Attempt token capture with retries to handle timing issues. + /// + /// The Open-WebUI frontend needs a moment to read the token cookie + /// and store it in localStorage after the OAuth redirect. + /// + /// [attemptId] is used to cancel this retry sequence if a new page load starts. + Future _attemptTokenCaptureWithRetry( + Uri uri, { + required int attemptId, + int maxAttempts = 3, + }) async { + for (int attempt = 0; attempt < maxAttempts; attempt++) { + // Cancel if token captured, widget disposed, or a new page load started + if (_tokenCaptured || !mounted || attemptId != _captureAttemptId) return; + + // Small delay to let frontend persist token (except on first attempt) + if (attempt > 0) { + await Future.delayed(const Duration(milliseconds: 500)); + // Re-check after delay in case state changed + if (_tokenCaptured || !mounted || attemptId != _captureAttemptId) { + return; + } + } + + final found = await _attemptTokenCapture(uri, attemptId: attemptId); + if (found) return; + } + + // After all attempts, token not found - user may still be in auth flow + // Only log if this is still the current attempt sequence + if (attemptId == _captureAttemptId) { + DebugLogger.auth( + 'No token found after $maxAttempts attempts, user may still be authenticating', + ); + } + } + + /// Attempts to capture the authentication token from cookies or localStorage. + /// + /// Returns true if a token was found and handled, false otherwise. + /// [attemptId] is checked to abort if a new page load started. + Future _attemptTokenCapture(Uri uri, {required int attemptId}) async { + final controller = _controller; + if (controller == null || !mounted) return false; + + // Abort if a new page load started + if (attemptId != _captureAttemptId) return false; + + // Strategy 1: Check token cookie via JavaScript + // Open-WebUI sets the token cookie with httponly=False, so it's accessible + try { + final cookieResult = await controller.runJavaScriptReturningResult( + '(function() {' + ' var cookies = document.cookie.split(";");' + ' for (var i = 0; i < cookies.length; i++) {' + ' var cookie = cookies[i].trim();' + ' if (cookie.startsWith("token=")) {' + ' return cookie.substring(6);' + ' }' + ' }' + ' return "";' + '})()', + ); + + // Abort if widget disposed or new page load started + if (!mounted || attemptId != _captureAttemptId) return false; + + String tokenValue = _cleanJsString(cookieResult.toString()); + if (tokenValue.isNotEmpty) { + DebugLogger.auth('Found token in cookie'); + await _handleToken(tokenValue); + return true; + } + } catch (e) { + DebugLogger.warning( + 'sso-cookie-read-failed', + scope: 'auth/sso', + data: {'error': e.toString()}, + ); + } + + // Abort if widget disposed or new page load started + if (!mounted || attemptId != _captureAttemptId) return false; + + // Strategy 2: Check localStorage (fallback - frontend sets this) + try { + final result = await controller.runJavaScriptReturningResult( + 'localStorage.getItem("token")', + ); + + // Abort if widget disposed or new page load started + if (!mounted || attemptId != _captureAttemptId) return false; + + String tokenValue = _cleanJsString(result.toString()); + if (tokenValue.isNotEmpty && tokenValue != 'null') { + DebugLogger.auth('Found token in localStorage'); + await _handleToken(tokenValue); + return true; + } + } catch (e) { + DebugLogger.warning( + 'sso-localstorage-read-failed', + scope: 'auth/sso', + data: {'error': e.toString()}, + ); + } + + return false; + } + + /// Clean JavaScript string result by removing surrounding quotes + String _cleanJsString(String value) { + if (value.startsWith('"') && value.endsWith('"')) { + return value.substring(1, value.length - 1); + } + return value; + } + + Future _handleToken(String token) async { + if (_tokenCaptured || !mounted) return; + + // Basic validation before attempting login + final trimmedToken = token.trim(); + // JWT tokens have 3 dot-separated segments and are typically 100+ chars + final isValidFormat = + trimmedToken.length >= 50 && trimmedToken.split('.').length == 3; + if (trimmedToken.isEmpty || !isValidFormat) { + DebugLogger.warning( + 'sso-token-invalid', + scope: 'auth/sso', + data: { + 'length': trimmedToken.length, + 'segments': trimmedToken.split('.').length, + }, + ); + return; // Invalid token, don't mark as captured - allow retry + } + + DebugLogger.auth('Handling captured SSO token'); + _tokenCaptured = true; + + setState(() { + _isLoading = true; + }); + + // Capture localized error message before async gap + final ssoFailedMessage = + AppLocalizations.of(context)?.ssoAuthFailed ?? + 'SSO authentication failed'; + + try { + final authActions = ref.read(authActionsProvider); + final success = await authActions.loginWithApiKey( + trimmedToken, + rememberCredentials: true, + authType: 'sso', // Mark as SSO-obtained token for traceability + ); + + if (!mounted) return; + + if (success) { + DebugLogger.auth('SSO login successful, navigating to chat'); + context.go(Routes.chat); + } else { + setState(() { + _error = ssoFailedMessage; + _isLoading = false; + _tokenCaptured = false; + }); + } + } catch (e) { + DebugLogger.error( + 'sso-token-handling-failed', + scope: 'auth/sso', + error: e, + ); + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + _tokenCaptured = false; + }); + } + } + + void _onWebResourceError(WebResourceError error) { + DebugLogger.error( + 'sso-webview-error', + scope: 'auth/sso', + data: { + 'errorCode': error.errorCode, + 'description': error.description, + 'errorType': error.errorType?.name, + }, + ); + + // Only show error for main frame failures + if (error.isForMainFrame ?? false) { + setState(() { + _error = error.description; + _isLoading = false; + }); + } + } + + NavigationDecision _onNavigationRequest(NavigationRequest request) { + final url = request.url; + DebugLogger.auth('SSO navigation request: $url'); + + // Allow all navigation - OAuth flows require redirects to external + // identity providers and back. The WebView is sandboxed and the token + // is only captured when the user returns to the Open-WebUI /auth page. + // + // We log the URL for debugging but don't restrict navigation since: + // 1. OAuth providers may use various redirect URLs + // 2. The user initiated this flow intentionally + // 3. Token capture only happens on the configured server's /auth page + return NavigationDecision.navigate; + } + + Future _refresh() async { + final controller = _controller; + if (controller == null || _serverUrl == null) return; + + setState(() { + _isLoading = true; + _error = null; + _tokenCaptured = false; + }); + + // Clear cookies and reload (with platform guard) + if (isWebViewSupported) { + await WebViewCookieManager().clearCookies(); + } + + if (!mounted) return; + + await controller.loadRequest(Uri.parse('$_serverUrl/auth')); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: 0, + leading: ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back, + onPressed: () => context.pop(), + tooltip: l10n?.back ?? 'Back', + ), + title: Text( + l10n?.sso ?? 'SSO', + style: context.conduitTheme.headingMedium, + ), + centerTitle: true, + actions: [ + if (_controller != null) + ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + onPressed: _refresh, + tooltip: l10n?.retry ?? 'Retry', + ), + ], + ), + body: SafeArea(child: _buildBody(l10n)), + ), + ); + } + + Widget _buildBody(AppLocalizations? l10n) { + if (_error != null) { + return _buildErrorState(l10n); + } + + // Guard against rendering WebView on unsupported platforms + if (_controller == null || !isWebViewSupported) { + return _buildLoadingState(l10n); + } + + return Stack( + children: [ + WebViewWidget(controller: _controller!), + if (_isLoading) _buildLoadingOverlay(l10n), + ], + ); + } + + Widget _buildLoadingState(AppLocalizations? l10n) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: Spacing.lg), + Text( + l10n?.ssoLoadingLogin ?? 'Loading login page...', + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildLoadingOverlay(AppLocalizations? l10n) { + return Positioned.fill( + child: Container( + color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.8), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: Spacing.lg), + Text( + _tokenCaptured + ? (l10n?.ssoAuthenticating ?? 'Authenticating...') + : (l10n?.ssoLoadingLogin ?? 'Loading...'), + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildErrorState(AppLocalizations? l10n) { + return Padding( + padding: const EdgeInsets.all(Spacing.pagePadding), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle + : Icons.error_outline, + size: IconSize.xxl, + color: context.conduitTheme.error, + ), + const SizedBox(height: Spacing.lg), + Text( + l10n?.ssoAuthFailed ?? 'SSO authentication failed', + style: context.conduitTheme.headingMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.sm), + Text( + _error ?? '', + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.xl), + ConduitButton( + text: l10n?.retry ?? 'Retry', + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + onPressed: _refresh, + ), + const SizedBox(height: Spacing.md), + ConduitButton( + text: l10n?.back ?? 'Back', + icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back, + onPressed: () => context.pop(), + isSecondary: true, + ), + ], + ), + ), + ); + } +} + diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 49ada17..7497462 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -862,5 +862,20 @@ "widgetCamera": "Kamera", "widgetPhotos": "Fotos", "widgetClipboard": "Zwischenablage", - "widgetDescription": "Schnellzugriff auf den Conduit-Chat mit Kamera, Fotos und Zwischenablage-Verknüpfungen" + "widgetDescription": "Schnellzugriff auf den Conduit-Chat mit Kamera, Fotos und Zwischenablage-Verknüpfungen", + "sso": "SSO", + "ssoDescription": "Mit dem Identitätsanbieter Ihrer Organisation anmelden", + "signInWithSso": "Mit SSO anmelden", + "ssoAuthenticating": "Authentifizierung...", + "ssoAuthFailed": "SSO-Authentifizierung fehlgeschlagen", + "ssoTokenNotFound": "Authentifizierungstoken vom SSO-Anbieter konnte nicht abgerufen werden", + "ssoLoadingLogin": "Anmeldeseite wird geladen...", + "ldap": "LDAP", + "ldapDescription": "Mit Ihren LDAP-Verzeichnis-Anmeldedaten anmelden", + "signInWithLdap": "Mit LDAP anmelden", + "ldapUsername": "Benutzername", + "ldapUsernameHint": "Geben Sie Ihren LDAP-Benutzernamen ein", + "moreSignInOptions": "Weitere Anmeldeoptionen", + "ldapNotEnabled": "LDAP-Authentifizierung ist auf diesem Server nicht aktiviert", + "ssoPlatformNotSupported": "SSO-Authentifizierung wird auf dieser Plattform nicht unterstützt. Bitte verwenden Sie stattdessen Anmeldedaten oder LDAP-Authentifizierung." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 20032f0..1de9113 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1858,5 +1858,65 @@ "example": "100" } } + }, + "sso": "SSO", + "@sso": { + "description": "Label for Single Sign-On authentication option." + }, + "ssoDescription": "Sign in with your organization's identity provider", + "@ssoDescription": { + "description": "Description text explaining SSO authentication." + }, + "signInWithSso": "Sign in with SSO", + "@signInWithSso": { + "description": "Button text for SSO sign-in." + }, + "ssoAuthenticating": "Authenticating...", + "@ssoAuthenticating": { + "description": "Loading message during SSO authentication." + }, + "ssoAuthFailed": "SSO authentication failed", + "@ssoAuthFailed": { + "description": "Error message when SSO authentication fails." + }, + "ssoTokenNotFound": "Could not retrieve authentication token from SSO provider", + "@ssoTokenNotFound": { + "description": "Error message when SSO token cannot be captured." + }, + "ssoLoadingLogin": "Loading login page...", + "@ssoLoadingLogin": { + "description": "Loading message while SSO login page loads." + }, + "ldap": "LDAP", + "@ldap": { + "description": "Label for LDAP authentication option." + }, + "ldapDescription": "Sign in with your LDAP directory credentials", + "@ldapDescription": { + "description": "Description text explaining LDAP authentication." + }, + "signInWithLdap": "Sign in with LDAP", + "@signInWithLdap": { + "description": "Button text for LDAP sign-in." + }, + "ldapUsername": "Username", + "@ldapUsername": { + "description": "Label for LDAP username field." + }, + "ldapUsernameHint": "Enter your LDAP username", + "@ldapUsernameHint": { + "description": "Hint text for LDAP username field." + }, + "moreSignInOptions": "More sign-in options", + "@moreSignInOptions": { + "description": "Section header for additional authentication methods." + }, + "ldapNotEnabled": "LDAP authentication is not enabled on this server", + "@ldapNotEnabled": { + "description": "Error message when LDAP is not configured on the server." + }, + "ssoPlatformNotSupported": "SSO authentication is not supported on this platform. Please use credentials or LDAP authentication instead.", + "@ssoPlatformNotSupported": { + "description": "Error message when SSO is attempted on an unsupported platform (desktop/web)." } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index bd33bfb..1b2cf10 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -862,5 +862,20 @@ "widgetCamera": "Cámara", "widgetPhotos": "Fotos", "widgetClipboard": "Portapapeles", - "widgetDescription": "Acceso rápido al chat de Conduit con cámara, fotos y atajos del portapapeles" + "widgetDescription": "Acceso rápido al chat de Conduit con cámara, fotos y atajos del portapapeles", + "sso": "SSO", + "ssoDescription": "Iniciar sesión con el proveedor de identidad de su organización", + "signInWithSso": "Iniciar sesión con SSO", + "ssoAuthenticating": "Autenticando...", + "ssoAuthFailed": "Error de autenticación SSO", + "ssoTokenNotFound": "No se pudo obtener el token de autenticación del proveedor SSO", + "ssoLoadingLogin": "Cargando página de inicio de sesión...", + "ldap": "LDAP", + "ldapDescription": "Iniciar sesión con sus credenciales de directorio LDAP", + "signInWithLdap": "Iniciar sesión con LDAP", + "ldapUsername": "Nombre de usuario", + "ldapUsernameHint": "Ingrese su nombre de usuario LDAP", + "moreSignInOptions": "Más opciones de inicio de sesión", + "ldapNotEnabled": "La autenticación LDAP no está habilitada en este servidor", + "ssoPlatformNotSupported": "La autenticación SSO no es compatible con esta plataforma. Por favor, use credenciales o autenticación LDAP en su lugar." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e3aa888..082e6dd 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -862,5 +862,20 @@ "widgetCamera": "Appareil photo", "widgetPhotos": "Photos", "widgetClipboard": "Presse-papiers", - "widgetDescription": "Accès rapide au chat Conduit avec appareil photo, photos et raccourcis du presse-papiers" + "widgetDescription": "Accès rapide au chat Conduit avec appareil photo, photos et raccourcis du presse-papiers", + "sso": "SSO", + "ssoDescription": "Connectez-vous avec le fournisseur d'identité de votre organisation", + "signInWithSso": "Se connecter avec SSO", + "ssoAuthenticating": "Authentification...", + "ssoAuthFailed": "Échec de l'authentification SSO", + "ssoTokenNotFound": "Impossible de récupérer le jeton d'authentification du fournisseur SSO", + "ssoLoadingLogin": "Chargement de la page de connexion...", + "ldap": "LDAP", + "ldapDescription": "Connectez-vous avec vos identifiants d'annuaire LDAP", + "signInWithLdap": "Se connecter avec LDAP", + "ldapUsername": "Nom d'utilisateur", + "ldapUsernameHint": "Entrez votre nom d'utilisateur LDAP", + "moreSignInOptions": "Plus d'options de connexion", + "ldapNotEnabled": "L'authentification LDAP n'est pas activée sur ce serveur", + "ssoPlatformNotSupported": "L'authentification SSO n'est pas prise en charge sur cette plateforme. Veuillez utiliser les identifiants ou l'authentification LDAP à la place." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 6d8bedd..18462f0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -862,5 +862,20 @@ "widgetCamera": "Fotocamera", "widgetPhotos": "Foto", "widgetClipboard": "Appunti", - "widgetDescription": "Accesso rapido alla chat di Conduit con fotocamera, foto e scorciatoie degli appunti" + "widgetDescription": "Accesso rapido alla chat di Conduit con fotocamera, foto e scorciatoie degli appunti", + "sso": "SSO", + "ssoDescription": "Accedi con il provider di identità della tua organizzazione", + "signInWithSso": "Accedi con SSO", + "ssoAuthenticating": "Autenticazione...", + "ssoAuthFailed": "Autenticazione SSO fallita", + "ssoTokenNotFound": "Impossibile recuperare il token di autenticazione dal provider SSO", + "ssoLoadingLogin": "Caricamento pagina di accesso...", + "ldap": "LDAP", + "ldapDescription": "Accedi con le credenziali della directory LDAP", + "signInWithLdap": "Accedi con LDAP", + "ldapUsername": "Nome utente", + "ldapUsernameHint": "Inserisci il tuo nome utente LDAP", + "moreSignInOptions": "Altre opzioni di accesso", + "ldapNotEnabled": "L'autenticazione LDAP non è abilitata su questo server", + "ssoPlatformNotSupported": "L'autenticazione SSO non è supportata su questa piattaforma. Usa invece le credenziali o l'autenticazione LDAP." } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9dee5cf..712dd0c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -640,5 +640,20 @@ "widgetCamera": "카메라", "widgetPhotos": "사진", "widgetClipboard": "클립보드", - "widgetDescription": "카메라, 사진 및 클립보드 바로가기로 Conduit 채팅에 빠르게 액세스" + "widgetDescription": "카메라, 사진 및 클립보드 바로가기로 Conduit 채팅에 빠르게 액세스", + "sso": "SSO", + "ssoDescription": "조직의 ID 공급자로 로그인", + "signInWithSso": "SSO로 로그인", + "ssoAuthenticating": "인증 중...", + "ssoAuthFailed": "SSO 인증 실패", + "ssoTokenNotFound": "SSO 공급자에서 인증 토큰을 검색할 수 없습니다", + "ssoLoadingLogin": "로그인 페이지 로딩 중...", + "ldap": "LDAP", + "ldapDescription": "LDAP 디렉토리 자격 증명으로 로그인", + "signInWithLdap": "LDAP으로 로그인", + "ldapUsername": "사용자 이름", + "ldapUsernameHint": "LDAP 사용자 이름을 입력하세요", + "moreSignInOptions": "추가 로그인 옵션", + "ldapNotEnabled": "이 서버에서 LDAP 인증이 활성화되어 있지 않습니다", + "ssoPlatformNotSupported": "이 플랫폼에서는 SSO 인증이 지원되지 않습니다. 대신 자격 증명 또는 LDAP 인증을 사용하세요." } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 9a333ad..3d2db17 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -862,5 +862,20 @@ "widgetCamera": "Camera", "widgetPhotos": "Foto's", "widgetClipboard": "Klembord", - "widgetDescription": "Snelle toegang tot Conduit-chat met camera, foto's en klembordsnelkoppelingen" + "widgetDescription": "Snelle toegang tot Conduit-chat met camera, foto's en klembordsnelkoppelingen", + "sso": "SSO", + "ssoDescription": "Aanmelden met de identiteitsprovider van uw organisatie", + "signInWithSso": "Aanmelden met SSO", + "ssoAuthenticating": "Authenticeren...", + "ssoAuthFailed": "SSO-authenticatie mislukt", + "ssoTokenNotFound": "Kan authenticatietoken niet ophalen van SSO-provider", + "ssoLoadingLogin": "Inlogpagina laden...", + "ldap": "LDAP", + "ldapDescription": "Aanmelden met uw LDAP-directorygegevens", + "signInWithLdap": "Aanmelden met LDAP", + "ldapUsername": "Gebruikersnaam", + "ldapUsernameHint": "Voer uw LDAP-gebruikersnaam in", + "moreSignInOptions": "Meer aanmeldopties", + "ldapNotEnabled": "LDAP-authenticatie is niet ingeschakeld op deze server", + "ssoPlatformNotSupported": "SSO-authenticatie wordt niet ondersteund op dit platform. Gebruik in plaats daarvan inloggegevens of LDAP-authenticatie." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1627f20..02146fe 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -862,5 +862,20 @@ "widgetCamera": "Камера", "widgetPhotos": "Фотографии", "widgetClipboard": "Буфер обмена", - "widgetDescription": "Быстрый доступ к чату Conduit с камерой, фотографиями и буфером обмена" + "widgetDescription": "Быстрый доступ к чату Conduit с камерой, фотографиями и буфером обмена", + "sso": "SSO", + "ssoDescription": "Войдите через провайдера идентификации вашей организации", + "signInWithSso": "Войти через SSO", + "ssoAuthenticating": "Аутентификация...", + "ssoAuthFailed": "Ошибка SSO-аутентификации", + "ssoTokenNotFound": "Не удалось получить токен аутентификации от провайдера SSO", + "ssoLoadingLogin": "Загрузка страницы входа...", + "ldap": "LDAP", + "ldapDescription": "Войдите с учётными данными каталога LDAP", + "signInWithLdap": "Войти через LDAP", + "ldapUsername": "Имя пользователя", + "ldapUsernameHint": "Введите имя пользователя LDAP", + "moreSignInOptions": "Дополнительные способы входа", + "ldapNotEnabled": "LDAP-аутентификация не включена на этом сервере", + "ssoPlatformNotSupported": "SSO-аутентификация не поддерживается на этой платформе. Пожалуйста, используйте учётные данные или LDAP-аутентификацию." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a8e3905..f41467a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -862,5 +862,20 @@ "widgetCamera": "相机", "widgetPhotos": "照片", "widgetClipboard": "剪贴板", - "widgetDescription": "快速访问 Conduit 聊天,支持相机、照片和剪贴板快捷方式" + "widgetDescription": "快速访问 Conduit 聊天,支持相机、照片和剪贴板快捷方式", + "sso": "SSO", + "ssoDescription": "使用组织的身份提供商登录", + "signInWithSso": "使用 SSO 登录", + "ssoAuthenticating": "正在验证...", + "ssoAuthFailed": "SSO 验证失败", + "ssoTokenNotFound": "无法从 SSO 提供商获取验证令牌", + "ssoLoadingLogin": "正在加载登录页面...", + "ldap": "LDAP", + "ldapDescription": "使用 LDAP 目录凭据登录", + "signInWithLdap": "使用 LDAP 登录", + "ldapUsername": "用户名", + "ldapUsernameHint": "输入您的 LDAP 用户名", + "moreSignInOptions": "更多登录选项", + "ldapNotEnabled": "此服务器未启用 LDAP 验证", + "ssoPlatformNotSupported": "此平台不支持 SSO 验证。请改用凭据或 LDAP 验证。" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 38e4632..f5e7ce5 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -862,5 +862,20 @@ "widgetCamera": "相機", "widgetPhotos": "照片", "widgetClipboard": "剪貼簿", - "widgetDescription": "快速存取 Conduit 聊天,支援相機、照片和剪貼簿捷徑" + "widgetDescription": "快速存取 Conduit 聊天,支援相機、照片和剪貼簿捷徑", + "sso": "SSO", + "ssoDescription": "使用組織的身份提供商登入", + "signInWithSso": "使用 SSO 登入", + "ssoAuthenticating": "正在驗證...", + "ssoAuthFailed": "SSO 驗證失敗", + "ssoTokenNotFound": "無法從 SSO 提供商取得驗證權杖", + "ssoLoadingLogin": "正在載入登入頁面...", + "ldap": "LDAP", + "ldapDescription": "使用 LDAP 目錄憑據登入", + "signInWithLdap": "使用 LDAP 登入", + "ldapUsername": "使用者名稱", + "ldapUsernameHint": "輸入您的 LDAP 使用者名稱", + "moreSignInOptions": "更多登入選項", + "ldapNotEnabled": "此伺服器未啟用 LDAP 驗證", + "ssoPlatformNotSupported": "此平台不支援 SSO 驗證。請改用憑據或 LDAP 驗證。" } From 8d6c7f5411bb967fa3182a37ab2499dd5e5ac85d Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:45:18 +0530 Subject: [PATCH 2/4] feat(auth): Add OAuth providers and improve authentication flow --- lib/core/auth/auth_state_manager.dart | 10 +- lib/core/auth/webview_cookie_helper.dart | 45 +- lib/core/models/backend_config.dart | 126 ++++++ lib/core/models/server_config.dart | 16 + lib/core/router/app_router.dart | 14 +- lib/core/services/api_service.dart | 21 +- .../auth/views/authentication_page.dart | 421 ++++++++++-------- .../auth/views/server_connection_page.dart | 16 +- lib/features/auth/views/sso_auth_page.dart | 118 +++-- lib/l10n/app_de.arb | 4 +- lib/l10n/app_en.arb | 14 + lib/l10n/app_es.arb | 4 +- lib/l10n/app_fr.arb | 4 +- lib/l10n/app_it.arb | 4 +- lib/l10n/app_ko.arb | 4 +- lib/l10n/app_nl.arb | 4 +- lib/l10n/app_ru.arb | 4 +- lib/l10n/app_zh.arb | 4 +- lib/l10n/app_zh_Hant.arb | 4 +- 19 files changed, 588 insertions(+), 249 deletions(-) diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index cb258ec..48c036d 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -939,15 +939,13 @@ class AuthStateManager extends _$AuthStateManager { await storage.clearAuthData(); _updateApiServiceToken(null); - // Clear WebView cookies to ensure fresh SSO sessions on next login + // Clear all WebView data (cookies, localStorage, cache) to ensure + // fresh SSO sessions on next login try { - final cleared = await WebViewCookieHelper.clearCookies(); - if (cleared) { - DebugLogger.auth('WebView cookies cleared'); - } + await WebViewCookieHelper.clearAllWebViewData(); } catch (e) { DebugLogger.warning( - 'webview-cookie-clear-failed', + 'webview-data-clear-failed', scope: 'auth/state', data: {'error': e.toString()}, ); diff --git a/lib/core/auth/webview_cookie_helper.dart b/lib/core/auth/webview_cookie_helper.dart index e59ad97..7245e71 100644 --- a/lib/core/auth/webview_cookie_helper.dart +++ b/lib/core/auth/webview_cookie_helper.dart @@ -3,13 +3,15 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import '../utils/debug_logger.dart'; + /// Check if WebView is supported on the current platform. /// /// webview_flutter only supports iOS and Android. bool get isWebViewSupported => !kIsWeb && (Platform.isIOS || Platform.isAndroid); -/// Helper for clearing WebView cookies on supported platforms. +/// Helper for clearing WebView data on supported platforms. /// /// This is isolated in its own file to prevent platform coupling issues /// when the webview_flutter package isn't available. @@ -29,5 +31,44 @@ class WebViewCookieHelper { return false; } } -} + /// Clears all WebView data including cookies, localStorage, and cache. + /// + /// This should be called on logout to ensure SSO sessions are fully cleared. + /// Returns true if all data was cleared successfully. + static Future clearAllWebViewData() async { + if (!isWebViewSupported) return false; + + var success = true; + + // Clear cookies + try { + await WebViewCookieManager().clearCookies(); + DebugLogger.auth('WebView cookies cleared'); + } catch (e) { + DebugLogger.warning( + 'webview-cookie-clear-failed', + scope: 'auth/webview', + data: {'error': e.toString()}, + ); + success = false; + } + + // Clear localStorage and cache using a temporary controller + try { + final controller = WebViewController(); + await controller.clearLocalStorage(); + await controller.clearCache(); + DebugLogger.auth('WebView localStorage and cache cleared'); + } catch (e) { + DebugLogger.warning( + 'webview-storage-clear-failed', + scope: 'auth/webview', + data: {'error': e.toString()}, + ); + success = false; + } + + return success; + } +} diff --git a/lib/core/models/backend_config.dart b/lib/core/models/backend_config.dart index 68cd398..035e046 100644 --- a/lib/core/models/backend_config.dart +++ b/lib/core/models/backend_config.dart @@ -1,5 +1,80 @@ import 'package:flutter/foundation.dart'; +/// Represents the available OAuth providers configured on the server. +@immutable +class OAuthProviders { + const OAuthProviders({ + this.google, + this.microsoft, + this.github, + this.oidc, + this.feishu, + }); + + /// Google OAuth provider name (if enabled). + final String? google; + + /// Microsoft OAuth provider name (if enabled). + final String? microsoft; + + /// GitHub OAuth provider name (if enabled). + final String? github; + + /// Generic OIDC provider name (if enabled). + final String? oidc; + + /// Feishu OAuth provider name (if enabled). + final String? feishu; + + /// Whether any OAuth provider is enabled. + bool get hasAnyProvider => + google != null || + microsoft != null || + github != null || + oidc != null || + feishu != null; + + /// Returns the list of enabled provider keys. + List get enabledProviders => [ + if (google != null) 'google', + if (microsoft != null) 'microsoft', + if (github != null) 'github', + if (oidc != null) 'oidc', + if (feishu != null) 'feishu', + ]; + + /// Returns the display name for a provider. + String getProviderDisplayName(String key) { + return switch (key) { + 'google' => google ?? 'Google', + 'microsoft' => microsoft ?? 'Microsoft', + 'github' => github ?? 'GitHub', + 'oidc' => oidc ?? 'SSO', + 'feishu' => feishu ?? 'Feishu', + _ => key, + }; + } + + factory OAuthProviders.fromJson(Map? json) { + if (json == null) return const OAuthProviders(); + return OAuthProviders( + google: json['google'] as String?, + microsoft: json['microsoft'] as String?, + github: json['github'] as String?, + oidc: json['oidc'] as String?, + feishu: json['feishu'] as String?, + ); + } + + Map toJson() => { + if (google != null) 'google': google, + if (microsoft != null) 'microsoft': microsoft, + if (github != null) 'github': github, + if (oidc != null) 'oidc': oidc, + if (feishu != null) 'feishu': feishu, + }; +} + /// Subset of the backend `/api/config` response the app cares about. @immutable class BackendConfig { @@ -14,6 +89,9 @@ class BackendConfig { this.audioSampleRate, this.audioFrameSize, this.vadEnabled, + this.oauthProviders = const OAuthProviders(), + this.enableLdap = false, + this.enableLoginForm = true, }); /// Mirrors `features.enable_websocket` from OpenWebUI. @@ -28,6 +106,18 @@ class BackendConfig { final int? audioFrameSize; final bool? vadEnabled; + /// OAuth providers configured on the server. + final OAuthProviders oauthProviders; + + /// Whether LDAP authentication is enabled on the server. + final bool enableLdap; + + /// Whether the standard login form (email/password) is enabled. + final bool enableLoginForm; + + /// Whether SSO (OAuth) login is available. + bool get hasSsoEnabled => oauthProviders.hasAnyProvider; + /// Returns a copy with updated fields. BackendConfig copyWith({ bool? enableWebsocket, @@ -40,6 +130,9 @@ class BackendConfig { int? audioSampleRate, int? audioFrameSize, bool? vadEnabled, + OAuthProviders? oauthProviders, + bool? enableLdap, + bool? enableLoginForm, }) { return BackendConfig( enableWebsocket: enableWebsocket ?? this.enableWebsocket, @@ -52,6 +145,9 @@ class BackendConfig { audioSampleRate: audioSampleRate ?? this.audioSampleRate, audioFrameSize: audioFrameSize ?? this.audioFrameSize, vadEnabled: vadEnabled ?? this.vadEnabled, + oauthProviders: oauthProviders ?? this.oauthProviders, + enableLdap: enableLdap ?? this.enableLdap, + enableLoginForm: enableLoginForm ?? this.enableLoginForm, ); } @@ -86,6 +182,9 @@ class BackendConfig { 'audio_sample_rate': audioSampleRate, 'audio_frame_size': audioFrameSize, 'vad_enabled': vadEnabled, + 'oauth': {'providers': oauthProviders.toJson()}, + 'enable_ldap': enableLdap, + 'enable_login_form': enableLoginForm, }; } @@ -100,6 +199,10 @@ class BackendConfig { int? audioSampleRate; int? audioFrameSize; bool? vadEnabled; + OAuthProviders oauthProviders = const OAuthProviders(); + bool enableLdap = false; + bool enableLoginForm = true; + // Try canonical format first final value = json['enable_websocket']; if (value is bool) { @@ -129,6 +232,21 @@ class BackendConfig { final vad = json['vad_enabled']; if (vad is bool) vadEnabled = vad; + // Parse OAuth providers from top-level oauth.providers + final oauth = json['oauth']; + if (oauth is Map) { + final providers = oauth['providers']; + if (providers is Map) { + oauthProviders = OAuthProviders.fromJson(providers); + } + } + + // Parse auth features from top-level + final ldapValue = json['enable_ldap']; + if (ldapValue is bool) enableLdap = ldapValue; + final loginFormValue = json['enable_login_form']; + if (loginFormValue is bool) enableLoginForm = loginFormValue; + // Fallback to nested format for backwards compatibility final features = json['features']; if (features is Map) { @@ -172,6 +290,11 @@ class BackendConfig { if (nestedVad is bool && vadEnabled == null) { vadEnabled = nestedVad; } + // Auth features in nested format + final nestedLdap = features['enable_ldap']; + if (nestedLdap is bool) enableLdap = nestedLdap; + final nestedLoginForm = features['enable_login_form']; + if (nestedLoginForm is bool) enableLoginForm = nestedLoginForm; } return BackendConfig( @@ -185,6 +308,9 @@ class BackendConfig { audioSampleRate: audioSampleRate, audioFrameSize: audioFrameSize, vadEnabled: vadEnabled, + oauthProviders: oauthProviders, + enableLdap: enableLdap, + enableLoginForm: enableLoginForm, ); } } diff --git a/lib/core/models/server_config.dart b/lib/core/models/server_config.dart index ac6a17d..bbb80f3 100644 --- a/lib/core/models/server_config.dart +++ b/lib/core/models/server_config.dart @@ -1,8 +1,24 @@ +import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'backend_config.dart'; + part 'server_config.freezed.dart'; part 'server_config.g.dart'; +/// Container for passing server and backend config during authentication flow. +@immutable +class AuthFlowConfig { + const AuthFlowConfig({required this.serverConfig, this.backendConfig}); + + /// The server configuration (URL, headers, etc.). + final ServerConfig serverConfig; + + /// The backend configuration (auth methods, features, etc.). + /// May be null if not yet fetched. + final BackendConfig? backendConfig; +} + @freezed sealed class ServerConfig with _$ServerConfig { const factory ServerConfig({ diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d81aba5..3261002 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -180,7 +180,8 @@ class RouterNotifier extends ChangeNotifier { return location == Routes.serverConnection || location == Routes.login || location == Routes.authentication || - location == Routes.connectionIssue; + location == Routes.connectionIssue || + location == Routes.ssoAuth; } @override @@ -232,9 +233,16 @@ final goRouterProvider = Provider((ref) { path: Routes.authentication, name: RouteNames.authentication, builder: (context, state) { - final config = state.extra; + final extra = state.extra; + // Support both AuthFlowConfig (new) and ServerConfig (legacy) + if (extra is AuthFlowConfig) { + return AuthenticationPage( + serverConfig: extra.serverConfig, + backendConfig: extra.backendConfig, + ); + } return AuthenticationPage( - serverConfig: config is ServerConfig ? config : null, + serverConfig: extra is ServerConfig ? extra : null, ); }, ), diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index bd38e9a..939824f 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -232,15 +232,24 @@ class ApiService { /// /// Returns `true` if the server appears to be a valid OpenWebUI instance. Future verifyIsOpenWebUIServer() async { + final config = await verifyAndGetConfig(); + return config != null; + } + + /// Verifies this is an OpenWebUI server and returns the backend config. + /// + /// Returns `BackendConfig` if the server is valid, `null` otherwise. + /// This combines server verification and config fetching in a single call. + Future verifyAndGetConfig() async { try { final response = await _dio.get('/api/config'); if (response.statusCode != 200) { - return false; + return null; } final data = response.data; if (data is! Map) { - return false; + return null; } // Check for OpenWebUI-specific fields @@ -250,9 +259,13 @@ class ApiService { data['version'] is String && (data['version'] as String).isNotEmpty; final hasFeatures = data['features'] is Map; - return hasStatus && hasVersion && hasFeatures; + if (!hasStatus || !hasVersion || !hasFeatures) { + return null; + } + + return BackendConfig.fromJson(data); } catch (e) { - return false; + return null; } } diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index 5a19d9d..bb90ec0 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/models/backend_config.dart'; import '../../../core/models/server_config.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/input_validation_service.dart'; @@ -29,8 +30,9 @@ enum AuthMode { class AuthenticationPage extends ConsumerStatefulWidget { final ServerConfig? serverConfig; + final BackendConfig? backendConfig; - const AuthenticationPage({super.key, this.serverConfig}); + const AuthenticationPage({super.key, this.serverConfig, this.backendConfig}); @override ConsumerState createState() => _AuthenticationPageState(); @@ -49,10 +51,27 @@ class _AuthenticationPageState extends ConsumerState { String? _loginError; bool _isSigningIn = false; bool _serverConfigSaved = false; + bool _showMoreOptions = false; + + /// Whether the server has OAuth/SSO providers configured. + bool get _hasSsoEnabled => + widget.backendConfig?.hasSsoEnabled == true && isWebViewSupported; + + /// Whether LDAP authentication is enabled on the server. + bool get _hasLdapEnabled => widget.backendConfig?.enableLdap == true; + + /// Whether the login form (email/password) is enabled on the server. + bool get _hasLoginFormEnabled => + widget.backendConfig?.enableLoginForm ?? true; + + /// OAuth providers available on the server. + OAuthProviders get _oauthProviders => + widget.backendConfig?.oauthProviders ?? const OAuthProviders(); @override void initState() { super.initState(); + _setDefaultAuthMode(); _loadSavedCredentials(); // Check for auth errors (e.g., forced logout due to API key) WidgetsBinding.instance.addPostFrameCallback((_) { @@ -60,6 +79,22 @@ class _AuthenticationPageState extends ConsumerState { }); } + /// Set the default auth mode based on what the server supports. + void _setDefaultAuthMode() { + // Priority: SSO > Credentials > LDAP > Token + if (_hasSsoEnabled && _oauthProviders.enabledProviders.length == 1) { + // If only one SSO provider, that's probably the intended method + _authMode = AuthMode.sso; + } else if (_hasLoginFormEnabled) { + _authMode = AuthMode.credentials; + } else if (_hasLdapEnabled) { + _authMode = AuthMode.ldap; + } else { + // Fallback to token if nothing else is enabled + _authMode = AuthMode.token; + } + } + void _checkAuthStateError() { final authState = ref.read(authStateManagerProvider).asData?.value; if (authState?.error != null && authState!.error!.isNotEmpty) { @@ -404,158 +439,108 @@ class _AuthenticationPageState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Primary authentication mode toggle (Credentials/Token) - _buildAuthModeToggle(), + // Show SSO buttons prominently if OAuth providers are configured + if (_hasSsoEnabled) ...[ + _buildSsoButtons(l10n), + if (_hasLoginFormEnabled || _hasLdapEnabled) ...[ + const SizedBox(height: Spacing.lg), + _buildDividerWithText(l10n.or), + const SizedBox(height: Spacing.lg), + ], + ], - const SizedBox(height: Spacing.lg), - - // Authentication form fields - _buildAuthFields(), + // Show the appropriate form based on auth mode + // Credentials form is shown directly when login form is enabled + // Other modes (LDAP, Token) are shown when selected from "More options" + if (_hasLoginFormEnabled && _authMode == AuthMode.credentials) ...[ + _buildCredentialsForm(), + ] else if (_authMode == AuthMode.ldap && _hasLdapEnabled) ...[ + _buildLdapForm(), + ] else if (_authMode == AuthMode.token) ...[ + _buildApiKeyForm(), + ] else if (_authMode == AuthMode.sso && !_hasSsoEnabled) ...[ + _buildSsoPrompt(), + ], if (_loginError != null) ...[ const SizedBox(height: Spacing.md), _buildErrorMessage(_loginError!), ], - // More options section (SSO/LDAP) + // More options section - always show for additional auth methods const SizedBox(height: Spacing.lg), _buildMoreOptionsSection(l10n), ], ); } - Widget _buildAuthModeToggle() { - final l10n = AppLocalizations.of(context)!; - final isPrimaryMode = - _authMode == AuthMode.credentials || _authMode == AuthMode.token; - - return Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.standard, + Widget _buildDividerWithText(String text) { + return Row( + children: [ + Expanded( + child: Divider( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), + ), ), - ), - child: Row( - children: [ - Expanded( - child: _buildAuthToggleOption( - icon: Platform.isIOS - ? CupertinoIcons.person_circle - : Icons.account_circle_outlined, - label: l10n.credentials, - isSelected: _authMode == AuthMode.credentials && isPrimaryMode, - onTap: () => setState(() { - _authMode = AuthMode.credentials; - _loginError = null; - _obscurePassword = true; // Reset visibility on mode change - }), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: Text( + text, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, ), ), - Expanded( - child: _buildAuthToggleOption( - icon: Platform.isIOS - ? CupertinoIcons.lock_shield - : Icons.vpn_key_outlined, - label: l10n.token, - isSelected: _authMode == AuthMode.token && isPrimaryMode, - onTap: () => setState(() { - _authMode = AuthMode.token; - _loginError = null; - _obscurePassword = true; // Reset visibility on mode change - }), - ), + ), + Expanded( + child: Divider( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), ), + ), + ], + ); + } + + Widget _buildSsoButtons(AppLocalizations l10n) { + final providers = _oauthProviders.enabledProviders; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 0; i < providers.length; i++) ...[ + if (i > 0) const SizedBox(height: Spacing.sm), + _buildOAuthButton(providers[i], l10n), ], - ), + ], ); } - Widget _buildAuthToggleOption({ - required IconData icon, - required String label, - required bool isSelected, - required VoidCallback onTap, - }) { - return AnimatedContainer( - duration: AnimationDuration.microInteraction, - curve: Curves.easeInOutCubic, - child: Material( - color: isSelected - ? context.conduitTheme.buttonPrimary - : Colors.transparent, - borderRadius: BorderRadius.circular(AppBorderRadius.small - 1), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppBorderRadius.small - 1), - child: Container( - padding: const EdgeInsets.symmetric( - vertical: Spacing.sm, - horizontal: Spacing.sm, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: IconSize.small, - color: isSelected - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.iconSecondary, - ), - const SizedBox(width: Spacing.xs), - Text( - label, - style: context.conduitTheme.bodySmall?.copyWith( - color: isSelected - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textSecondary, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ); - } + Widget _buildOAuthButton(String provider, AppLocalizations l10n) { + final displayName = _oauthProviders.getProviderDisplayName(provider); - Widget _buildAuthFields() { - return AnimatedSwitcher( - duration: AnimationDuration.pageTransition, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.1), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: _buildCurrentAuthForm(), - ); - } + IconData icon; - Widget _buildCurrentAuthForm() { - switch (_authMode) { - case AuthMode.credentials: - return _buildCredentialsForm(); - case AuthMode.token: - return _buildApiKeyForm(); - case AuthMode.ldap: - return _buildLdapForm(); - case AuthMode.sso: - return _buildSsoPrompt(); + switch (provider) { + case 'google': + icon = Icons.g_mobiledata; + case 'microsoft': + icon = Icons.window; + case 'github': + icon = Icons.code; + case 'oidc': + icon = Platform.isIOS ? CupertinoIcons.lock_shield : Icons.security; + case 'feishu': + icon = Icons.chat_bubble_outline; + default: + icon = Icons.login; } + + return ConduitButton( + text: l10n.continueWithProvider(displayName), + icon: icon, + onPressed: _navigateToSso, + isSecondary: true, + isFullWidth: true, + ); } /// Validates that a token is a JWT and not an API key. @@ -817,70 +802,136 @@ class _AuthenticationPageState extends ConsumerState { } Widget _buildMoreOptionsSection(AppLocalizations l10n) { + // Build list of available options - always show all available options + // with the current one highlighted for consistency + final options = []; + + // Credentials option (if login form is enabled) + if (_hasLoginFormEnabled) { + options.add( + _buildOptionButton( + icon: Platform.isIOS + ? CupertinoIcons.person_circle + : Icons.account_circle_outlined, + label: l10n.credentials, + isSelected: _authMode == AuthMode.credentials, + onTap: () => setState(() { + _authMode = AuthMode.credentials; + _loginError = null; + _obscurePassword = true; + }), + ), + ); + } + + // SSO option (only if WebView supported and no OAuth buttons shown above) + if (isWebViewSupported && !_hasSsoEnabled) { + options.add( + _buildOptionButton( + icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.security, + label: l10n.sso, + isSelected: _authMode == AuthMode.sso, + onTap: () => setState(() { + _authMode = AuthMode.sso; + _loginError = null; + _obscurePassword = true; + }), + ), + ); + } + + // LDAP option (if enabled on server) + if (_hasLdapEnabled) { + options.add( + _buildOptionButton( + icon: Platform.isIOS ? CupertinoIcons.building_2_fill : Icons.domain, + label: l10n.ldap, + isSelected: _authMode == AuthMode.ldap, + onTap: () => setState(() { + _authMode = AuthMode.ldap; + _loginError = null; + _obscurePassword = true; + }), + ), + ); + } + + // Token option (always available as fallback) + options.add( + _buildOptionButton( + icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key, + label: l10n.token, + isSelected: _authMode == AuthMode.token, + onTap: () => setState(() { + _authMode = AuthMode.token; + _loginError = null; + _obscurePassword = true; + }), + ), + ); + + if (options.isEmpty) return const SizedBox.shrink(); + return Column( children: [ - // Divider with "or" text - Row( - children: [ - Expanded( - child: Divider( - color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), - ), + // Expandable header + InkWell( + onTap: () => setState(() => _showMoreOptions = !_showMoreOptions), + borderRadius: BorderRadius.circular(AppBorderRadius.button), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - child: Text( - l10n.moreSignInOptions, - style: context.conduitTheme.bodySmall?.copyWith( - color: context.conduitTheme.textSecondary, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.moreSignInOptions, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), ), - ), + const SizedBox(width: Spacing.xs), + AnimatedRotation( + duration: AnimationDuration.microInteraction, + turns: _showMoreOptions ? 0.5 : 0, + child: Icon( + Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.expand_more, + color: context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + ), + ], ), - Expanded( - child: Divider( - color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), - ), - ), - ], + ), ), - const SizedBox(height: Spacing.md), - // SSO and LDAP buttons - // SSO is only available on platforms that support WebView (iOS/Android) - Row( - children: [ - if (isWebViewSupported) ...[ - Expanded( - child: _buildOptionButton( - icon: Platform.isIOS - ? CupertinoIcons.lock_shield - : Icons.security, - label: l10n.sso, - isSelected: _authMode == AuthMode.sso, - onTap: () => setState(() { - _authMode = AuthMode.sso; - _loginError = null; - _obscurePassword = true; // Reset visibility on mode change - }), - ), - ), - const SizedBox(width: Spacing.sm), - ], - Expanded( - child: _buildOptionButton( - icon: Platform.isIOS - ? CupertinoIcons.building_2_fill - : Icons.domain, - label: l10n.ldap, - isSelected: _authMode == AuthMode.ldap, - onTap: () => setState(() { - _authMode = AuthMode.ldap; - _loginError = null; - _obscurePassword = true; // Reset visibility on mode change - }), - ), + // Options (collapsed by default unless a secondary option is selected) + AnimatedCrossFade( + duration: AnimationDuration.microInteraction, + sizeCurve: Curves.easeInOutCubic, + crossFadeState: + _showMoreOptions || + _authMode == AuthMode.ldap || + _authMode == AuthMode.token || + (_authMode == AuthMode.sso && !_hasSsoEnabled) + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox.shrink(), + secondChild: Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Row( + children: [ + for (int i = 0; i < options.length; i++) ...[ + if (i > 0) const SizedBox(width: Spacing.sm), + Expanded(child: options[i]), + ], + ], ), - ], + ), ), ], ); diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index 2f36f7e..c612294 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -117,17 +117,17 @@ class _ServerConnectionPageState extends ConsumerState { ); } - // Then verify it's actually an OpenWebUI server + // Then verify it's actually an OpenWebUI server and get its config DebugLogger.log( 'Verifying OpenWebUI server...', scope: 'auth/connection', ); - final isOpenWebUI = await api.verifyIsOpenWebUIServer(); + final backendConfig = await api.verifyAndGetConfig(); DebugLogger.log( - 'OpenWebUI verification result: $isOpenWebUI', + 'OpenWebUI verification result: ${backendConfig != null}', scope: 'auth/connection', ); - if (!isOpenWebUI) { + if (backendConfig == null) { throw Exception('This does not appear to be an Open-WebUI server.'); } @@ -137,9 +137,13 @@ class _ServerConnectionPageState extends ConsumerState { ); // Don't save server config yet - wait until authentication succeeds - // The config is passed to the authentication page + // The config is passed to the authentication page along with backend config if (mounted) { - context.pushNamed(RouteNames.authentication, extra: tempConfig); + final authFlowConfig = AuthFlowConfig( + serverConfig: tempConfig, + backendConfig: backendConfig, + ); + context.pushNamed(RouteNames.authentication, extra: authFlowConfig); } } catch (e, stack) { DebugLogger.error( diff --git a/lib/features/auth/views/sso_auth_page.dart b/lib/features/auth/views/sso_auth_page.dart index da99682..4e08725 100644 --- a/lib/features/auth/views/sso_auth_page.dart +++ b/lib/features/auth/views/sso_auth_page.dart @@ -10,7 +10,6 @@ import 'package:webview_flutter/webview_flutter.dart'; import '../../../core/auth/webview_cookie_helper.dart'; import '../../../core/models/server_config.dart'; import '../../../core/providers/app_providers.dart'; -import '../../../core/services/navigation_service.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/theme/theme_extensions.dart'; @@ -104,6 +103,7 @@ class _SsoAuthPageState extends ConsumerState { onPageFinished: _onPageFinished, onWebResourceError: _onWebResourceError, onNavigationRequest: _onNavigationRequest, + onUrlChange: _onUrlChange, ), ) ..setUserAgent(_buildUserAgent()); @@ -148,6 +148,28 @@ class _SsoAuthPageState extends ConsumerState { }); } + /// Called when URL changes (may catch changes that onPageFinished misses) + Future _onUrlChange(UrlChange change) async { + final url = change.url; + if (url == null) return; + DebugLogger.auth('SSO URL changed: $url'); + + // Try to capture token on URL change as well + if (_tokenCaptured) return; + + final uri = Uri.parse(url); + final serverUrl = _serverUrl; + if (serverUrl == null) return; + + final serverUri = Uri.parse(serverUrl); + if (uri.host != serverUri.host) return; + + // Attempt single token capture (no retry) - onPageFinished will handle retries + // This provides fast capture when URL changes, while onPageFinished + // provides the retry mechanism as a fallback + await _attemptTokenCapture(uri, attemptId: _captureAttemptId); + } + Future _onPageFinished(String url) async { DebugLogger.auth('SSO page finished: $url'); @@ -169,12 +191,35 @@ class _SsoAuthPageState extends ConsumerState { return; } - // Only check for token on /auth page (after OAuth callback redirect) - // or on the root page (some configurations) - if (!uri.path.endsWith('/auth') && uri.path != '/') return; + // Check if this is a page on our server where a token might be present + // After OAuth, Open-WebUI may redirect to: + // - /auth (login page with token in cookie) + // - / (root/chat page after successful auth) + // - /api/v1/auths/callback/* (OAuth callback that sets the token) + // We should check for tokens on any page on our server after OAuth completes + final serverUrl = _serverUrl; + if (serverUrl == null) return; + + final serverUri = Uri.parse(serverUrl); + final isOurServer = uri.host == serverUri.host; + if (!isOurServer) return; + + // Skip external OAuth provider pages (they won't have our token) + // Only check pages that could have the token set + final isAuthRelatedPath = + uri.path == '/' || + uri.path.endsWith('/auth') || + uri.path.contains('/callback') || + uri.path.contains('/oauth'); + + if (!isAuthRelatedPath) { + // For other pages on our server (like /chat), still try to capture + // the token since the user might have been redirected there after auth + DebugLogger.auth('Checking for token on ${uri.path}'); + } // Wait a moment for the frontend to persist the token - // The OAuth callback sets the cookie, then redirects to /auth, + // The OAuth callback sets the cookie, then redirects to /auth or /, // where the frontend reads the cookie and stores it in localStorage final attemptId = _captureAttemptId; await _attemptTokenCaptureWithRetry(uri, attemptId: attemptId); @@ -248,16 +293,16 @@ class _SsoAuthPageState extends ConsumerState { if (!mounted || attemptId != _captureAttemptId) return false; String tokenValue = _cleanJsString(cookieResult.toString()); - if (tokenValue.isNotEmpty) { - DebugLogger.auth('Found token in cookie'); + if (_isValidJwtFormat(tokenValue)) { + DebugLogger.auth('Found valid token in cookie'); await _handleToken(tokenValue); return true; } } catch (e) { - DebugLogger.warning( - 'sso-cookie-read-failed', + // Expected during page load - token may not be accessible yet + DebugLogger.log( + 'Cookie read failed (expected during auth flow): ${e.toString().split('\n').first}', scope: 'auth/sso', - data: {'error': e.toString()}, ); } @@ -274,16 +319,16 @@ class _SsoAuthPageState extends ConsumerState { if (!mounted || attemptId != _captureAttemptId) return false; String tokenValue = _cleanJsString(result.toString()); - if (tokenValue.isNotEmpty && tokenValue != 'null') { - DebugLogger.auth('Found token in localStorage'); + if (_isValidJwtFormat(tokenValue)) { + DebugLogger.auth('Found valid token in localStorage'); await _handleToken(tokenValue); return true; } } catch (e) { - DebugLogger.warning( - 'sso-localstorage-read-failed', + // Expected during page load - token may not be accessible yet + DebugLogger.log( + 'localStorage read failed (expected during auth flow): ${e.toString().split('\n').first}', scope: 'auth/sso', - data: {'error': e.toString()}, ); } @@ -298,26 +343,30 @@ class _SsoAuthPageState extends ConsumerState { return value; } + /// Check if a string looks like a valid JWT token. + /// + /// JWT tokens have 3 dot-separated segments and are typically 100+ chars. + /// This filters out invalid values like 'null', 'undefined', empty strings, + /// or placeholder values that might be in localStorage before OAuth completes. + bool _isValidJwtFormat(String value) { + if (value.isEmpty) return false; + final trimmed = value.trim(); + // Filter out common invalid values + if (trimmed == 'null' || + trimmed == 'undefined' || + trimmed == 'false' || + trimmed == 'true') { + return false; + } + // JWT must have 3 segments and be reasonably long + final segments = trimmed.split('.'); + return segments.length == 3 && trimmed.length >= 50; + } + Future _handleToken(String token) async { if (_tokenCaptured || !mounted) return; - // Basic validation before attempting login final trimmedToken = token.trim(); - // JWT tokens have 3 dot-separated segments and are typically 100+ chars - final isValidFormat = - trimmedToken.length >= 50 && trimmedToken.split('.').length == 3; - if (trimmedToken.isEmpty || !isValidFormat) { - DebugLogger.warning( - 'sso-token-invalid', - scope: 'auth/sso', - data: { - 'length': trimmedToken.length, - 'segments': trimmedToken.split('.').length, - }, - ); - return; // Invalid token, don't mark as captured - allow retry - } - DebugLogger.auth('Handling captured SSO token'); _tokenCaptured = true; @@ -341,8 +390,10 @@ class _SsoAuthPageState extends ConsumerState { if (!mounted) return; if (success) { - DebugLogger.auth('SSO login successful, navigating to chat'); - context.go(Routes.chat); + DebugLogger.auth('SSO login successful'); + // Navigation is handled automatically by the router when auth state + // changes to authenticated. The router redirect will navigate to chat. + // We don't need to call context.go() here - it can cause race conditions. } else { setState(() { _error = ssoFailedMessage; @@ -562,4 +613,3 @@ class _SsoAuthPageState extends ConsumerState { ); } } - diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7497462..e433641 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -877,5 +877,7 @@ "ldapUsernameHint": "Geben Sie Ihren LDAP-Benutzernamen ein", "moreSignInOptions": "Weitere Anmeldeoptionen", "ldapNotEnabled": "LDAP-Authentifizierung ist auf diesem Server nicht aktiviert", - "ssoPlatformNotSupported": "SSO-Authentifizierung wird auf dieser Plattform nicht unterstützt. Bitte verwenden Sie stattdessen Anmeldedaten oder LDAP-Authentifizierung." + "ssoPlatformNotSupported": "SSO-Authentifizierung wird auf dieser Plattform nicht unterstützt. Bitte verwenden Sie stattdessen Anmeldedaten oder LDAP-Authentifizierung.", + "continueWithProvider": "Weiter mit {provider}", + "or": "oder" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1de9113..81a941f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1918,5 +1918,19 @@ "ssoPlatformNotSupported": "SSO authentication is not supported on this platform. Please use credentials or LDAP authentication instead.", "@ssoPlatformNotSupported": { "description": "Error message when SSO is attempted on an unsupported platform (desktop/web)." + }, + "continueWithProvider": "Continue with {provider}", + "@continueWithProvider": { + "description": "Button text for OAuth provider sign-in.", + "placeholders": { + "provider": { + "type": "String", + "example": "Google" + } + } + }, + "or": "or", + "@or": { + "description": "Separator text between authentication options." } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1b2cf10..e1dfc05 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -877,5 +877,7 @@ "ldapUsernameHint": "Ingrese su nombre de usuario LDAP", "moreSignInOptions": "Más opciones de inicio de sesión", "ldapNotEnabled": "La autenticación LDAP no está habilitada en este servidor", - "ssoPlatformNotSupported": "La autenticación SSO no es compatible con esta plataforma. Por favor, use credenciales o autenticación LDAP en su lugar." + "ssoPlatformNotSupported": "La autenticación SSO no es compatible con esta plataforma. Por favor, use credenciales o autenticación LDAP en su lugar.", + "continueWithProvider": "Continuar con {provider}", + "or": "o" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 082e6dd..637aba1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -877,5 +877,7 @@ "ldapUsernameHint": "Entrez votre nom d'utilisateur LDAP", "moreSignInOptions": "Plus d'options de connexion", "ldapNotEnabled": "L'authentification LDAP n'est pas activée sur ce serveur", - "ssoPlatformNotSupported": "L'authentification SSO n'est pas prise en charge sur cette plateforme. Veuillez utiliser les identifiants ou l'authentification LDAP à la place." + "ssoPlatformNotSupported": "L'authentification SSO n'est pas prise en charge sur cette plateforme. Veuillez utiliser les identifiants ou l'authentification LDAP à la place.", + "continueWithProvider": "Continuer avec {provider}", + "or": "ou" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 18462f0..6214e21 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -877,5 +877,7 @@ "ldapUsernameHint": "Inserisci il tuo nome utente LDAP", "moreSignInOptions": "Altre opzioni di accesso", "ldapNotEnabled": "L'autenticazione LDAP non è abilitata su questo server", - "ssoPlatformNotSupported": "L'autenticazione SSO non è supportata su questa piattaforma. Usa invece le credenziali o l'autenticazione LDAP." + "ssoPlatformNotSupported": "L'autenticazione SSO non è supportata su questa piattaforma. Usa invece le credenziali o l'autenticazione LDAP.", + "continueWithProvider": "Continua con {provider}", + "or": "o" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 712dd0c..9e41f1c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -655,5 +655,7 @@ "ldapUsernameHint": "LDAP 사용자 이름을 입력하세요", "moreSignInOptions": "추가 로그인 옵션", "ldapNotEnabled": "이 서버에서 LDAP 인증이 활성화되어 있지 않습니다", - "ssoPlatformNotSupported": "이 플랫폼에서는 SSO 인증이 지원되지 않습니다. 대신 자격 증명 또는 LDAP 인증을 사용하세요." + "ssoPlatformNotSupported": "이 플랫폼에서는 SSO 인증이 지원되지 않습니다. 대신 자격 증명 또는 LDAP 인증을 사용하세요.", + "continueWithProvider": "{provider}(으)로 계속", + "or": "또는" } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 3d2db17..2869cc6 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -877,5 +877,7 @@ "ldapUsernameHint": "Voer uw LDAP-gebruikersnaam in", "moreSignInOptions": "Meer aanmeldopties", "ldapNotEnabled": "LDAP-authenticatie is niet ingeschakeld op deze server", - "ssoPlatformNotSupported": "SSO-authenticatie wordt niet ondersteund op dit platform. Gebruik in plaats daarvan inloggegevens of LDAP-authenticatie." + "ssoPlatformNotSupported": "SSO-authenticatie wordt niet ondersteund op dit platform. Gebruik in plaats daarvan inloggegevens of LDAP-authenticatie.", + "continueWithProvider": "Doorgaan met {provider}", + "or": "of" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 02146fe..a43af60 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -877,5 +877,7 @@ "ldapUsernameHint": "Введите имя пользователя LDAP", "moreSignInOptions": "Дополнительные способы входа", "ldapNotEnabled": "LDAP-аутентификация не включена на этом сервере", - "ssoPlatformNotSupported": "SSO-аутентификация не поддерживается на этой платформе. Пожалуйста, используйте учётные данные или LDAP-аутентификацию." + "ssoPlatformNotSupported": "SSO-аутентификация не поддерживается на этой платформе. Пожалуйста, используйте учётные данные или LDAP-аутентификацию.", + "continueWithProvider": "Продолжить с {provider}", + "or": "или" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f41467a..547e3b8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -877,5 +877,7 @@ "ldapUsernameHint": "输入您的 LDAP 用户名", "moreSignInOptions": "更多登录选项", "ldapNotEnabled": "此服务器未启用 LDAP 验证", - "ssoPlatformNotSupported": "此平台不支持 SSO 验证。请改用凭据或 LDAP 验证。" + "ssoPlatformNotSupported": "此平台不支持 SSO 验证。请改用凭据或 LDAP 验证。", + "continueWithProvider": "使用 {provider} 继续", + "or": "或" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index f5e7ce5..aabaaad 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -877,5 +877,7 @@ "ldapUsernameHint": "輸入您的 LDAP 使用者名稱", "moreSignInOptions": "更多登入選項", "ldapNotEnabled": "此伺服器未啟用 LDAP 驗證", - "ssoPlatformNotSupported": "此平台不支援 SSO 驗證。請改用憑據或 LDAP 驗證。" + "ssoPlatformNotSupported": "此平台不支援 SSO 驗證。請改用憑據或 LDAP 驗證。", + "continueWithProvider": "使用 {provider} 繼續", + "or": "或" } From 4925f7cf18dedc8dd46c92d6634c3125f1944543 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:50:00 +0530 Subject: [PATCH 3/4] feat(auth): Add SSO, LDAP, and dynamic auth options to Conduit --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 54fdd27..1f90fc9 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,13 @@ flutter run -d ios # or: -d android ### Authentication Conduit supports multiple authentication flows when connecting to your Open‑WebUI: - **Username + Password**: Sign in directly against servers that expose a login endpoint. Credentials are stored securely using platform keychains. -- **JWT Token** (for OIDC): Paste a server‑issued JWT token for stateless auth. +- **SSO / OAuth** (iOS & Android): Authenticate via your server's configured OAuth providers (Google, Microsoft, GitHub, OIDC, etc.) using an in-app WebView. The token is automatically captured after the OAuth flow completes. Also supports reverse proxy authentication (Authelia, Authentik, etc.). +- **LDAP**: Sign in using LDAP credentials if enabled on your server. +- **JWT Token**: Paste a server‑issued JWT token for manual token-based auth. - **Custom Headers**: Add headers during login (e.g., `X-API-Key`, `Authorization`, `X-Org`, or self‑hosted SSO headers) that Conduit will include on all HTTP/WebSocket requests (see [Endpoint Allowlist](#endpoint-allowlist-custom-auth) for reverse proxy whitelisting). +The authentication page dynamically displays available options based on your server's configuration. + ## Screenshots | | | | | From 77c79c332517d0197dada529893008031d7dc131 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 11 Dec 2025 19:05:33 +0530 Subject: [PATCH 4/4] refactor(auth): remove unnecessary tools provider invalidation --- lib/core/auth/auth_state_manager.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index 48c036d..ef289f8 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -11,7 +11,6 @@ import 'auth_cache_manager.dart'; import 'webview_cookie_helper.dart'; import '../utils/debug_logger.dart'; import '../utils/user_avatar_utils.dart'; -import '../../features/tools/providers/tools_providers.dart'; part 'auth_state_manager.g.dart'; @@ -955,8 +954,10 @@ class AuthStateManager extends _$AuthStateManager { // connection page. Users can navigate to server settings if they need to // change server configuration. - // Invalidate tools provider to clear cached data - ref.invalidate(toolsListProvider); + // Note: toolsListProvider is NOT invalidated here because: + // 1. clearAuthData() already deletes the tools cache from storage + // 2. The provider has auth checks that prevent API calls when logged out + // 3. When user logs back in, the provider will rebuild with fresh data // Clear auth cache manager _cacheManager.clearAuthCache();