feat(auth): Add OAuth providers and improve authentication flow

This commit is contained in:
cogwheel0
2025-12-11 18:45:18 +05:30
parent ea61168184
commit 8d6c7f5411
19 changed files with 588 additions and 249 deletions

View File

@@ -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()},
);

View File

@@ -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<bool> 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;
}
}

View File

@@ -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<String> 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<String, dynamic>? 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<String, dynamic> 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<String, dynamic>) {
final providers = oauth['providers'];
if (providers is Map<String, dynamic>) {
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<String, dynamic>) {
@@ -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,
);
}
}

View File

@@ -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({

View File

@@ -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<GoRouter>((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,
);
},
),

View File

@@ -232,15 +232,24 @@ class ApiService {
///
/// Returns `true` if the server appears to be a valid OpenWebUI instance.
Future<bool> 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<BackendConfig?> 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<String, dynamic>) {
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;
}
}