(auth): Add proxy authentication WebView for server login
This commit is contained in:
62
lib/core/auth/native_cookie_manager.dart
Normal file
62
lib/core/auth/native_cookie_manager.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
/// Native cookie manager for accessing cookies from the platform's cookie store.
|
||||
///
|
||||
/// On iOS, this accesses WKHTTPCookieStore (shared with WKWebView).
|
||||
/// On Android, this accesses CookieManager (shared with WebView).
|
||||
///
|
||||
/// This is necessary because dart:io HttpClient has its own isolated cookie
|
||||
/// store that doesn't share with WebView.
|
||||
class NativeCookieManager {
|
||||
static const _channel = MethodChannel('com.conduit.app/cookies');
|
||||
|
||||
/// Gets all cookies for a given URL from the native cookie store.
|
||||
///
|
||||
/// Returns a map of cookie name -> value.
|
||||
/// Returns empty map on web or if native method fails.
|
||||
static Future<Map<String, String>> getCookiesForUrl(String url) async {
|
||||
if (kIsWeb) return {};
|
||||
|
||||
try {
|
||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
||||
'getCookies',
|
||||
{'url': url},
|
||||
);
|
||||
|
||||
if (result == null) return {};
|
||||
|
||||
final cookies = <String, String>{};
|
||||
for (final entry in result.entries) {
|
||||
cookies[entry.key.toString()] = entry.value.toString();
|
||||
}
|
||||
|
||||
DebugLogger.auth('Retrieved ${cookies.length} cookies from native store');
|
||||
return cookies;
|
||||
} on MissingPluginException {
|
||||
// Platform channels not implemented - fall back gracefully
|
||||
DebugLogger.log(
|
||||
'Native cookie manager not available on this platform',
|
||||
scope: 'auth/cookies',
|
||||
);
|
||||
return {};
|
||||
} catch (e) {
|
||||
DebugLogger.warning(
|
||||
'Failed to get native cookies',
|
||||
scope: 'auth/cookies',
|
||||
data: {'error': e.toString()},
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats cookies as a Cookie header string.
|
||||
static String formatCookieHeader(Map<String, String> cookies) {
|
||||
if (cookies.isEmpty) return '';
|
||||
return cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import '../utils/debug_logger.dart';
|
||||
bool get isWebViewSupported =>
|
||||
!kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
||||
|
||||
/// Helper for clearing WebView data on supported platforms.
|
||||
/// Helper for managing WebView data and cookies.
|
||||
///
|
||||
/// This is isolated in its own file to prevent platform coupling issues
|
||||
/// when the webview_flutter package isn't available.
|
||||
@@ -71,4 +71,68 @@ class WebViewCookieHelper {
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Gets cookies from a WebView controller via JavaScript.
|
||||
///
|
||||
/// This can be used to extract session cookies set by proxy authentication
|
||||
/// and pass them to HTTP clients like Dio.
|
||||
///
|
||||
/// Note: Only works for cookies without the HttpOnly flag.
|
||||
/// For HttpOnly cookies, iOS/Android platforms may share cookies
|
||||
/// automatically through the shared cookie store.
|
||||
///
|
||||
/// Returns a map of cookie names to values, or empty map if unavailable.
|
||||
static Future<Map<String, String>> getCookiesFromController(
|
||||
WebViewController controller,
|
||||
) async {
|
||||
if (!isWebViewSupported) return {};
|
||||
|
||||
try {
|
||||
final result = await controller.runJavaScriptReturningResult(
|
||||
'document.cookie',
|
||||
);
|
||||
|
||||
final cookieString = result.toString();
|
||||
// Remove surrounding quotes if present
|
||||
final cleaned =
|
||||
cookieString.startsWith('"') && cookieString.endsWith('"')
|
||||
? cookieString.substring(1, cookieString.length - 1)
|
||||
: cookieString;
|
||||
|
||||
if (cleaned.isEmpty || cleaned == 'null') return {};
|
||||
|
||||
final cookieMap = <String, String>{};
|
||||
final pairs = cleaned.split(';');
|
||||
for (final pair in pairs) {
|
||||
final trimmed = pair.trim();
|
||||
final idx = trimmed.indexOf('=');
|
||||
if (idx > 0) {
|
||||
final name = trimmed.substring(0, idx).trim();
|
||||
final value = trimmed.substring(idx + 1).trim();
|
||||
cookieMap[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
DebugLogger.auth(
|
||||
'Retrieved ${cookieMap.length} cookies from WebView',
|
||||
);
|
||||
return cookieMap;
|
||||
} catch (e) {
|
||||
DebugLogger.warning(
|
||||
'webview-get-cookies-failed',
|
||||
scope: 'auth/webview',
|
||||
data: {'error': e.toString()},
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats cookies as a Cookie header string.
|
||||
///
|
||||
/// This converts a map of cookie names to values into a properly formatted
|
||||
/// Cookie header that can be sent with HTTP requests.
|
||||
static String formatCookieHeader(Map<String, String> cookies) {
|
||||
if (cookies.isEmpty) return '';
|
||||
return cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../features/chat/providers/chat_providers.dart';
|
||||
import '../../features/auth/views/authentication_page.dart';
|
||||
import '../../features/auth/views/connect_signin_page.dart';
|
||||
import '../../features/auth/views/connection_issue_page.dart';
|
||||
import '../../features/auth/views/proxy_auth_page.dart';
|
||||
import '../../features/auth/views/server_connection_page.dart';
|
||||
import '../../features/auth/views/sso_auth_page.dart';
|
||||
import '../../features/chat/views/chat_page.dart';
|
||||
@@ -94,10 +95,13 @@ class RouterNotifier extends ChangeNotifier {
|
||||
final hasActiveServer = activeServer != null;
|
||||
if (!hasActiveServer) {
|
||||
// No server configured - redirect to server connection
|
||||
// Exception: allow staying on server connection or authentication pages
|
||||
// Exception: allow staying on server connection, authentication,
|
||||
// proxy auth, and SSO pages during the connection/auth flow.
|
||||
// But always redirect away from connection issue page (user logged out)
|
||||
if (location == Routes.serverConnection ||
|
||||
location == Routes.authentication ||
|
||||
location == Routes.proxyAuth ||
|
||||
location == Routes.ssoAuth ||
|
||||
location == Routes.login) {
|
||||
return null;
|
||||
}
|
||||
@@ -181,7 +185,8 @@ class RouterNotifier extends ChangeNotifier {
|
||||
location == Routes.login ||
|
||||
location == Routes.authentication ||
|
||||
location == Routes.connectionIssue ||
|
||||
location == Routes.ssoAuth;
|
||||
location == Routes.ssoAuth ||
|
||||
location == Routes.proxyAuth;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -256,6 +261,18 @@ final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.proxyAuth,
|
||||
name: RouteNames.proxyAuth,
|
||||
builder: (context, state) {
|
||||
final config = state.extra;
|
||||
if (config is! ProxyAuthConfig) {
|
||||
// Fallback - should not happen in normal flow
|
||||
return const ServerConnectionPage();
|
||||
}
|
||||
return ProxyAuthPage(config: config);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.profile,
|
||||
name: RouteNames.profile,
|
||||
|
||||
@@ -33,6 +33,28 @@ void _traceApi(String message) {
|
||||
DebugLogger.log(message, scope: 'api/trace');
|
||||
}
|
||||
|
||||
/// Result of a health check with proxy detection.
|
||||
///
|
||||
/// This enum distinguishes between different failure modes:
|
||||
/// - [healthy]: Server is reachable and responding normally
|
||||
/// - [unhealthy]: Server responded but not with expected status
|
||||
/// - [proxyAuthRequired]: Server is behind an auth proxy (oauth2-proxy, etc.)
|
||||
/// - [unreachable]: Server could not be reached at all
|
||||
enum HealthCheckResult {
|
||||
/// Server is healthy and responding normally
|
||||
healthy,
|
||||
|
||||
/// Server responded but not with expected status
|
||||
unhealthy,
|
||||
|
||||
/// Server appears to be behind an authentication proxy
|
||||
/// (detected via redirect or HTML login page response)
|
||||
proxyAuthRequired,
|
||||
|
||||
/// Server could not be reached
|
||||
unreachable,
|
||||
}
|
||||
|
||||
/// Converts ChatSourceReference list back to OpenWebUI's expected format.
|
||||
/// OpenWebUI expects: { source: {...}, document: [...], metadata: [...] }
|
||||
/// But ChatSourceReference stores: { id, title, url, snippet, type, metadata }
|
||||
@@ -114,9 +136,12 @@ List<Map<String, dynamic>> _convertCodeExecutionsToOpenWebUIFormat(
|
||||
// Convert the result if present
|
||||
if (exec.result != null) {
|
||||
final execResult = <String, dynamic>{};
|
||||
if (exec.result!.output != null)
|
||||
if (exec.result!.output != null) {
|
||||
execResult['output'] = exec.result!.output;
|
||||
if (exec.result!.error != null) execResult['error'] = exec.result!.error;
|
||||
}
|
||||
if (exec.result!.error != null) {
|
||||
execResult['error'] = exec.result!.error;
|
||||
}
|
||||
if (exec.result!.files.isNotEmpty) {
|
||||
execResult['files'] = exec.result!.files
|
||||
.map(
|
||||
@@ -330,6 +355,154 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Health check with proxy detection.
|
||||
///
|
||||
/// This method detects when the server is behind an authentication proxy
|
||||
/// (like oauth2-proxy) by checking for:
|
||||
/// - HTTP redirects (302, 307, 308) to login pages
|
||||
/// - HTML responses instead of expected JSON/text
|
||||
///
|
||||
/// When a proxy is detected, returns [HealthCheckResult.proxyAuthRequired]
|
||||
/// so the app can show a WebView for proxy authentication.
|
||||
Future<HealthCheckResult> checkHealthWithProxyDetection() async {
|
||||
try {
|
||||
// Create a temporary Dio instance that doesn't follow redirects
|
||||
// so we can detect proxy redirects
|
||||
final tempDio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: serverConfig.url,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => true, // Accept all status codes
|
||||
headers: serverConfig.customHeaders.isNotEmpty
|
||||
? Map<String, String>.from(serverConfig.customHeaders)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
// Configure self-signed cert support if needed
|
||||
if (!kIsWeb && serverConfig.allowSelfSignedCertificates) {
|
||||
final baseUri = _parseBaseUri(serverConfig.url);
|
||||
if (baseUri != null) {
|
||||
final host = baseUri.host.toLowerCase();
|
||||
final port = baseUri.hasPort ? baseUri.port : null;
|
||||
(tempDio.httpClientAdapter as IOHttpClientAdapter)
|
||||
.createHttpClient = () {
|
||||
final client = HttpClient();
|
||||
client.badCertificateCallback =
|
||||
(X509Certificate cert, String requestHost, int requestPort) {
|
||||
if (requestHost.toLowerCase() != host) return false;
|
||||
if (port == null) return true;
|
||||
return requestPort == port;
|
||||
};
|
||||
return client;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final response = await tempDio.get('/health');
|
||||
final statusCode = response.statusCode ?? 0;
|
||||
|
||||
DebugLogger.log(
|
||||
'Proxy detection health check: status=$statusCode',
|
||||
scope: 'api/proxy-detect',
|
||||
);
|
||||
|
||||
// Check for redirects (proxy authentication pages)
|
||||
if (statusCode == 302 || statusCode == 307 || statusCode == 308) {
|
||||
final location = response.headers.value('location');
|
||||
DebugLogger.log(
|
||||
'Detected redirect to: $location - likely proxy auth required',
|
||||
scope: 'api/proxy-detect',
|
||||
);
|
||||
return HealthCheckResult.proxyAuthRequired;
|
||||
}
|
||||
|
||||
// Check for 401/403 which may indicate proxy auth
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
// Check if the response is HTML (proxy login page)
|
||||
final contentType = response.headers.value('content-type') ?? '';
|
||||
if (contentType.contains('text/html')) {
|
||||
DebugLogger.log(
|
||||
'Detected HTML response on 401/403 - likely proxy auth required',
|
||||
scope: 'api/proxy-detect',
|
||||
);
|
||||
return HealthCheckResult.proxyAuthRequired;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for successful response
|
||||
if (statusCode == 200) {
|
||||
// Verify it's not an HTML login page masquerading as 200
|
||||
final contentType = response.headers.value('content-type') ?? '';
|
||||
final data = response.data;
|
||||
|
||||
// OpenWebUI's /health returns {"status": true} or plain "true"
|
||||
// If we get HTML, it's probably a proxy login page
|
||||
if (contentType.contains('text/html')) {
|
||||
// OpenWebUI's /health returns JSON, not HTML.
|
||||
// Any HTML response indicates a proxy page or misconfiguration.
|
||||
final htmlContent = data?.toString().toLowerCase() ?? '';
|
||||
final hasLoginKeywords = htmlContent.contains('login') ||
|
||||
htmlContent.contains('sign in') ||
|
||||
htmlContent.contains('authenticate') ||
|
||||
htmlContent.contains('oauth');
|
||||
|
||||
DebugLogger.log(
|
||||
'Detected HTML response on /health - '
|
||||
'${hasLoginKeywords ? 'login page detected' : 'unexpected HTML'}',
|
||||
scope: 'api/proxy-detect',
|
||||
);
|
||||
|
||||
// All HTML responses suggest proxy auth is needed
|
||||
// (either login page or custom proxy page)
|
||||
return HealthCheckResult.proxyAuthRequired;
|
||||
}
|
||||
|
||||
return HealthCheckResult.healthy;
|
||||
}
|
||||
|
||||
return HealthCheckResult.unhealthy;
|
||||
} on DioException catch (e) {
|
||||
DebugLogger.log(
|
||||
'Proxy detection failed with DioException: ${e.type}',
|
||||
scope: 'api/proxy-detect',
|
||||
);
|
||||
|
||||
// Connection errors mean unreachable
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.unknown) {
|
||||
return HealthCheckResult.unreachable;
|
||||
}
|
||||
|
||||
// Check if response indicates proxy
|
||||
final response = e.response;
|
||||
if (response != null) {
|
||||
final statusCode = response.statusCode ?? 0;
|
||||
if (statusCode == 302 || statusCode == 307 || statusCode == 308) {
|
||||
return HealthCheckResult.proxyAuthRequired;
|
||||
}
|
||||
|
||||
final contentType = response.headers.value('content-type') ?? '';
|
||||
if (contentType.contains('text/html') &&
|
||||
(statusCode == 401 || statusCode == 403 || statusCode == 200)) {
|
||||
return HealthCheckResult.proxyAuthRequired;
|
||||
}
|
||||
}
|
||||
|
||||
return HealthCheckResult.unreachable;
|
||||
} catch (e) {
|
||||
DebugLogger.error(
|
||||
'proxy-detection-failed',
|
||||
scope: 'api/proxy-detect',
|
||||
error: e,
|
||||
);
|
||||
return HealthCheckResult.unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies this is actually an OpenWebUI server by checking the /api/config
|
||||
/// endpoint for OpenWebUI-specific fields (version, status, features).
|
||||
///
|
||||
|
||||
@@ -102,6 +102,7 @@ class Routes {
|
||||
static const String connectionIssue = '/connection-issue';
|
||||
static const String authentication = '/authentication';
|
||||
static const String ssoAuth = '/sso-auth';
|
||||
static const String proxyAuth = '/proxy-auth';
|
||||
static const String profile = '/profile';
|
||||
static const String appCustomization = '/profile/customization';
|
||||
static const String notes = '/notes';
|
||||
@@ -117,6 +118,7 @@ class RouteNames {
|
||||
static const String connectionIssue = 'connection-issue';
|
||||
static const String authentication = 'authentication';
|
||||
static const String ssoAuth = 'sso-auth';
|
||||
static const String proxyAuth = 'proxy-auth';
|
||||
static const String profile = 'profile';
|
||||
static const String appCustomization = 'app-customization';
|
||||
static const String notes = 'notes';
|
||||
|
||||
Reference in New Issue
Block a user