feat(auth): Add OAuth providers and improve authentication flow
This commit is contained in:
@@ -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()},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user