feat(auth): Add LDAP and SSO authentication support
This commit is contained in:
@@ -29,10 +29,24 @@ class AuthActions {
|
||||
Future<bool> loginWithApiKey(
|
||||
String apiKey, {
|
||||
bool rememberCredentials = false,
|
||||
String authType = 'token',
|
||||
}) {
|
||||
return _auth.loginWithApiKey(
|
||||
apiKey,
|
||||
rememberCredentials: rememberCredentials,
|
||||
authType: authType,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> ldapLogin(
|
||||
String username,
|
||||
String password, {
|
||||
bool rememberCredentials = false,
|
||||
}) {
|
||||
return _auth.ldapLogin(
|
||||
username,
|
||||
password,
|
||||
rememberCredentials: rememberCredentials,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
_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<AuthenticationPage> {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_apiKeyController.dispose();
|
||||
_ldapUsernameController.dispose();
|
||||
_ldapPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -100,17 +113,27 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
}
|
||||
|
||||
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<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
),
|
||||
);
|
||||
},
|
||||
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<AuthenticationPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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<void> _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
|
||||
|
||||
565
lib/features/auth/views/sso_auth_page.dart
Normal file
565
lib/features/auth/views/sso_auth_page.dart
Normal file
@@ -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<SsoAuthPage> createState() => _SsoAuthPageState();
|
||||
}
|
||||
|
||||
class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
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<void> _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<void> _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<void> _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<bool> _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<void> _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<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user