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": "或" }