(auth): Add proxy authentication WebView for server login

This commit is contained in:
cogwheel
2025-12-18 11:40:16 +05:30
parent 5b91616e35
commit 9da9f9e2b3
60 changed files with 1453 additions and 22 deletions

View 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('; ');
}
}

View File

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

View File

@@ -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,

View File

@@ -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).
///

View File

@@ -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';

View File

@@ -0,0 +1,641 @@
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/native_cookie_manager.dart';
import '../../../core/auth/webview_cookie_helper.dart';
import '../../../core/models/server_config.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';
/// Result of proxy authentication.
class ProxyAuthResult {
/// Whether authentication was successful.
final bool success;
/// Proxy session cookies to be injected into API requests.
final Map<String, String>? cookies;
/// JWT token if user is already authenticated via trusted headers.
/// When oauth2-proxy uses trusted headers, OpenWebUI auto-authenticates
/// the user after proxy auth, so no separate sign-in is needed.
final String? jwtToken;
const ProxyAuthResult({required this.success, this.cookies, this.jwtToken});
/// Creates a failed result.
const ProxyAuthResult.failed()
: success = false,
cookies = null,
jwtToken = null;
/// Creates a successful result with captured cookies.
const ProxyAuthResult.success({this.cookies, this.jwtToken}) : success = true;
/// Whether the user is fully authenticated (has JWT token).
bool get isFullyAuthenticated => jwtToken != null && jwtToken!.isNotEmpty;
}
/// Configuration for the proxy authentication flow.
class ProxyAuthConfig {
/// The server configuration to authenticate against.
final ServerConfig serverConfig;
/// Optional callback when proxy authentication completes successfully.
final VoidCallback? onAuthComplete;
const ProxyAuthConfig({required this.serverConfig, this.onAuthComplete});
}
/// Proxy Authentication page that uses a WebView to handle authentication
/// through reverse proxies like oauth2-proxy or Pangolin.
///
/// This page loads the server URL in a WebView, allowing users to authenticate
/// through the proxy. Once the proxy auth is complete (detected by reaching
/// the actual server), the proxy session cookies are captured and returned.
///
/// The user will then be redirected to the normal sign-in flow, where the
/// proxy cookies will be injected into API requests.
class ProxyAuthPage extends ConsumerStatefulWidget {
final ProxyAuthConfig config;
const ProxyAuthPage({super.key, required this.config});
@override
ConsumerState<ProxyAuthPage> createState() => _ProxyAuthPageState();
}
class _ProxyAuthPageState extends ConsumerState<ProxyAuthPage> {
WebViewController? _controller;
bool _isLoading = true;
bool _cookiesCaptured = false;
String? _error;
bool _isOnTargetServer = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initializeWebView();
});
}
@override
void dispose() {
_controller = null;
super.dispose();
}
Future<void> _initializeWebView() async {
if (!isWebViewSupported) {
if (!mounted) return;
final l10n = AppLocalizations.of(context);
setState(() {
_error =
l10n?.proxyAuthPlatformNotSupported ??
'Proxy authentication requires a mobile device. '
'Please authenticate through a browser first.';
_isLoading = false;
});
return;
}
final serverUrl = widget.config.serverConfig.url;
DebugLogger.auth('Initializing Proxy Auth WebView for $serverUrl');
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: _onPageStarted,
onPageFinished: _onPageFinished,
onWebResourceError: _onWebResourceError,
onNavigationRequest: _onNavigationRequest,
),
)
..setUserAgent(_buildUserAgent());
// Don't clear cookies - preserve any existing proxy session
if (!mounted) return;
// Load the server URL - the proxy will intercept and show its login
await controller.loadRequest(Uri.parse(serverUrl));
if (!mounted) return;
setState(() {
_controller = controller;
});
}
String _buildUserAgent() {
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 {
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) {
if (!mounted) return;
DebugLogger.auth('Proxy auth page started: $url');
setState(() {
_isLoading = true;
_error = null;
});
}
Future<void> _onPageFinished(String url) async {
if (!mounted) return;
DebugLogger.auth('Proxy auth page finished: $url');
setState(() {
_isLoading = false;
});
if (_cookiesCaptured) return;
final uri = Uri.parse(url);
// Check for error parameter
final error = uri.queryParameters['error'];
if (error != null && error.isNotEmpty) {
DebugLogger.auth('Proxy auth error from URL: $error');
setState(() {
_error = error;
});
return;
}
// Check if we're on our target server
final serverUrl = widget.config.serverConfig.url;
final serverUri = Uri.parse(serverUrl);
if (uri.host == serverUri.host) {
// We've reached our server - proxy auth must be complete
_isOnTargetServer = true;
await _checkIfOpenWebUI();
}
}
/// Checks if we're on the OpenWebUI page and captures cookies if so.
Future<void> _checkIfOpenWebUI() async {
if (_cookiesCaptured || !mounted) return;
final controller = _controller;
if (controller == null) return;
try {
// Check if this is an OpenWebUI page by looking for specific elements
// or the /api/config endpoint being accessible
final result = await controller.runJavaScriptReturningResult(
'''
(function() {
// Check for OpenWebUI specific elements or title
var isOpenWebUI =
document.querySelector('div[class*="chat"]') !== null ||
document.querySelector('[data-testid]') !== null ||
document.title.toLowerCase().includes('open webui') ||
document.title.toLowerCase().includes('chat');
return isOpenWebUI ? "true" : "false";
})()
''',
);
if (!mounted) return;
final isOpenWebUI = result.toString().contains('true');
DebugLogger.auth(
'OpenWebUI detection: $isOpenWebUI (on target server: $_isOnTargetServer)',
);
// If we're on the target server, capture cookies
// The user might be on a login page or the main page
if (_isOnTargetServer) {
await _captureProxyCookies();
}
} catch (e) {
DebugLogger.log(
'OpenWebUI detection failed: ${e.toString().split('\n').first}',
scope: 'auth/proxy',
);
// If detection fails but we're on target server, still try to capture
if (_isOnTargetServer) {
try {
await _captureProxyCookies();
} catch (captureError) {
if (!mounted) return;
setState(() {
_error = captureError.toString();
});
}
}
}
}
/// Captures proxy session cookies and checks for JWT token.
///
/// When oauth2-proxy uses trusted headers (like X-Forwarded-Email),
/// OpenWebUI auto-authenticates the user after proxy auth. In this case,
/// we can capture the JWT token and skip the sign-in page entirely.
Future<void> _captureProxyCookies() async {
if (_cookiesCaptured || !mounted) return;
// Set flag immediately to prevent race conditions from rapid taps
// or multiple page finish events triggering concurrent calls
_cookiesCaptured = true;
try {
final serverUrl = widget.config.serverConfig.url;
DebugLogger.auth('Capturing proxy cookies for $serverUrl');
// Get cookies from native cookie store
final cookies = await NativeCookieManager.getCookiesForUrl(serverUrl);
if (!mounted) return;
DebugLogger.auth(
'Captured ${cookies.length} cookies: ${cookies.keys.toList()}',
);
if (cookies.isEmpty) {
DebugLogger.warning(
'No cookies captured - proxy may use HttpOnly cookies not accessible',
scope: 'auth/proxy',
);
}
// Check if OpenWebUI has already authenticated via trusted headers
// This happens when oauth2-proxy sets X-Forwarded-Email and OpenWebUI
// auto-creates/logs in the user
String? jwtToken = await _tryCaptureJwtToken();
// Notify callback if provided
widget.config.onAuthComplete?.call();
// Pop with success result, cookies, and possibly JWT token
if (!mounted) return;
context.pop(ProxyAuthResult.success(cookies: cookies, jwtToken: jwtToken));
} catch (e) {
// Reset flag on failure so user can retry
_cookiesCaptured = false;
DebugLogger.warning(
'Cookie capture failed: $e',
scope: 'auth/proxy',
);
rethrow;
}
}
/// Attempts to capture the JWT token from cookies or localStorage.
///
/// If the proxy uses trusted headers, OpenWebUI will have already
/// authenticated the user and set a JWT token.
Future<String?> _tryCaptureJwtToken() async {
final controller = _controller;
if (controller == null || !mounted) return null;
// Strategy 1: Check token cookie
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 "";
})()
''',
);
if (!mounted) return null;
String tokenValue = _cleanJsString(cookieResult.toString());
if (_isValidJwtFormat(tokenValue)) {
DebugLogger.auth(
'Found JWT token in cookie - user already authenticated via '
'trusted headers',
);
return tokenValue;
}
} catch (e) {
DebugLogger.log(
'Cookie JWT check failed: ${e.toString().split('\n').first}',
scope: 'auth/proxy',
);
}
if (!mounted) return null;
// Strategy 2: Check localStorage
try {
final result = await controller.runJavaScriptReturningResult(
'localStorage.getItem("token")',
);
if (!mounted) return null;
String tokenValue = _cleanJsString(result.toString());
if (_isValidJwtFormat(tokenValue)) {
DebugLogger.auth(
'Found JWT token in localStorage - user already authenticated via '
'trusted headers',
);
return tokenValue;
}
} catch (e) {
DebugLogger.log(
'localStorage JWT check failed: ${e.toString().split('\n').first}',
scope: 'auth/proxy',
);
}
DebugLogger.auth(
'No JWT token found - proxy may not use trusted headers, '
'will proceed to normal sign-in',
);
return null;
}
String _cleanJsString(String value) {
if (value.startsWith('"') && value.endsWith('"')) {
return value.substring(1, value.length - 1);
}
return value;
}
bool _isValidJwtFormat(String value) {
if (value.isEmpty) return false;
final trimmed = value.trim();
if (trimmed == 'null' ||
trimmed == 'undefined' ||
trimmed == 'false' ||
trimmed == 'true') {
return false;
}
final segments = trimmed.split('.');
return segments.length == 3 && trimmed.length >= 50;
}
void _onWebResourceError(WebResourceError error) {
if (!mounted) return;
DebugLogger.error(
'proxy-webview-error',
scope: 'auth/proxy',
data: {
'errorCode': error.errorCode,
'description': error.description,
'errorType': error.errorType?.name,
},
);
if (error.isForMainFrame ?? false) {
setState(() {
_error = error.description;
_isLoading = false;
});
}
}
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final url = request.url;
DebugLogger.auth('Proxy auth navigation request: $url');
return NavigationDecision.navigate;
}
Future<void> _refresh() async {
final controller = _controller;
if (controller == null || !mounted) return;
setState(() {
_isLoading = true;
_error = null;
_cookiesCaptured = false;
_isOnTargetServer = false;
});
if (!mounted) return;
await controller.loadRequest(Uri.parse(widget.config.serverConfig.url));
}
/// Manual completion button for when auto-detection doesn't work.
Future<void> _manualComplete() async {
try {
await _captureProxyCookies();
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ErrorBoundary(
child: Scaffold(
backgroundColor: context.conduitTheme.surfaceBackground,
extendBodyBehindAppBar: true,
appBar: FloatingAppBar(
leading: FloatingAppBarBackButton(
onTap: () => context.pop(const ProxyAuthResult.failed()),
),
title: FloatingAppBarTitle(
text: l10n?.proxyAuthentication ?? 'Proxy Authentication',
),
actions: [
if (_controller != null)
FloatingAppBarAction(
child: FloatingAppBarIconButton(
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
onTap: _refresh,
),
),
],
),
body: SafeArea(child: _buildBody(l10n)),
),
);
}
Widget _buildBody(AppLocalizations? l10n) {
if (_error != null) {
return _buildErrorState(l10n);
}
if (_controller == null || !isWebViewSupported) {
return _buildLoadingState(l10n);
}
return Stack(
children: [
WebViewWidget(controller: _controller!),
if (_isLoading) _buildLoadingOverlay(l10n),
// Help text and manual continue button at the bottom
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildHelpBanner(l10n),
),
],
);
}
Widget _buildHelpBanner(AppLocalizations? l10n) {
return Container(
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.95),
border: Border(
top: BorderSide(
color: context.conduitTheme.dividerColor,
width: BorderWidth.standard,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(
Platform.isIOS ? CupertinoIcons.info : Icons.info_outline,
size: IconSize.small,
color: context.conduitTheme.iconSecondary,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
l10n?.proxyAuthHelpTextSimple ??
'Sign in through your proxy. Once authenticated, '
'tap Continue to proceed to sign in.',
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
),
],
),
const SizedBox(height: Spacing.sm),
SizedBox(
width: double.infinity,
child: ConduitButton(
text: l10n?.continueButton ?? 'Continue',
icon:
Platform.isIOS
? CupertinoIcons.arrow_right
: Icons.arrow_forward,
onPressed: _manualComplete,
),
),
],
),
);
}
Widget _buildLoadingState(AppLocalizations? l10n) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator.adaptive(),
const SizedBox(height: Spacing.lg),
Text(
l10n?.proxyAuthLoading ?? 'Loading authentication 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(
l10n?.proxyAuthLoading ?? '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?.proxyAuthFailed ?? '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(const ProxyAuthResult.failed()),
isSecondary: true,
),
],
),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
import 'package:uuid/uuid.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../../../core/auth/webview_cookie_helper.dart';
import '../../../core/models/server_config.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/services/api_service.dart';
@@ -16,9 +17,11 @@ import '../../../core/services/input_validation_service.dart';
import '../../../core/services/navigation_service.dart';
import '../../../core/utils/debug_logger.dart';
import '../../../core/widgets/error_boundary.dart';
import '../providers/unified_auth_providers.dart';
import '../../../shared/services/brand_service.dart';
import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/conduit_components.dart';
import 'proxy_auth_page.dart';
class ServerConnectionPage extends ConsumerStatefulWidget {
const ServerConnectionPage({super.key});
@@ -39,6 +42,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
bool _isConnecting = false;
bool _showAdvancedSettings = false;
bool _allowSelfSignedCertificates = false;
bool _serverBehindProxy = false;
@override
void initState() {
@@ -104,19 +108,46 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
workerManager: workerManager,
);
// First check basic connectivity
// If user indicated server is behind proxy, go directly to proxy auth
if (_serverBehindProxy) {
DebugLogger.log(
'User indicated server behind proxy, starting proxy auth',
scope: 'auth/connection',
);
await _handleProxyAuth(tempConfig, api, workerManager);
return;
}
// First check connectivity with proxy detection
DebugLogger.log('Checking server health...', scope: 'auth/connection');
final isReachable = await api.checkHealth();
final healthResult = await api.checkHealthWithProxyDetection();
DebugLogger.log(
'Health check result: $isReachable',
'Health check result: $healthResult',
scope: 'auth/connection',
);
if (!isReachable) {
// Handle proxy authentication requirement
if (healthResult == HealthCheckResult.proxyAuthRequired) {
DebugLogger.log(
'Server behind proxy detected, prompting for proxy auth',
scope: 'auth/connection',
);
await _handleProxyAuth(tempConfig, api, workerManager);
return;
}
if (healthResult == HealthCheckResult.unreachable) {
throw Exception(
'Could not reach the server. Please check the address.',
);
}
if (healthResult == HealthCheckResult.unhealthy) {
throw Exception(
'Server responded but may not be healthy. Please try again.',
);
}
// Then verify it's actually an OpenWebUI server and get its config
DebugLogger.log(
'Verifying OpenWebUI server...',
@@ -166,6 +197,204 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
}
}
/// Handles proxy authentication flow.
///
/// Opens the proxy auth page in a WebView where the user authenticates
/// through the proxy (oauth2-proxy, Pangolin, etc.).
///
/// After proxy auth completes, the cookies are captured and added to
/// the server config. Then the normal authentication flow proceeds.
Future<void> _handleProxyAuth(
ServerConfig tempConfig,
ApiService api,
WorkerManager workerManager,
) async {
// Check if WebView is supported
if (!isWebViewSupported) {
throw Exception(
AppLocalizations.of(context)?.proxyAuthPlatformNotSupported ??
'Proxy authentication requires a mobile device.',
);
}
// Show proxy auth page
final proxyConfig = ProxyAuthConfig(serverConfig: tempConfig);
if (!mounted) return;
final result = await context.pushNamed<ProxyAuthResult>(
RouteNames.proxyAuth,
extra: proxyConfig,
);
if (!mounted) return;
// If user cancelled or proxy auth failed, show error
if (result == null || !result.success) {
setState(() {
_connectionError =
AppLocalizations.of(context)?.proxyAuthFailed ??
'Proxy authentication was cancelled or failed.';
_isConnecting = false;
});
return;
}
DebugLogger.log(
'Proxy auth completed, captured ${result.cookies?.length ?? 0} cookies, '
'JWT: ${result.isFullyAuthenticated}',
scope: 'auth/connection',
);
// Build updated headers with proxy cookies
final updatedHeaders = Map<String, String>.from(tempConfig.customHeaders);
if (result.cookies != null && result.cookies!.isNotEmpty) {
// Format cookies as Cookie header
final proxyCookieHeader = result.cookies!.entries
.map((e) => '${e.key}=${e.value}')
.join('; ');
// Merge with existing Cookie header if present (from advanced settings)
final existingCookies = updatedHeaders['Cookie'];
if (existingCookies != null && existingCookies.isNotEmpty) {
updatedHeaders['Cookie'] = '$existingCookies; $proxyCookieHeader';
DebugLogger.log(
'Merged ${result.cookies!.length} proxy cookies with existing Cookie header',
scope: 'auth/connection',
);
} else {
updatedHeaders['Cookie'] = proxyCookieHeader;
DebugLogger.log(
'Added Cookie header with ${result.cookies!.length} cookies',
scope: 'auth/connection',
);
}
}
// Create updated config with proxy cookies (and possibly JWT token)
final configWithCookies = ServerConfig(
id: tempConfig.id,
name: tempConfig.name,
url: tempConfig.url,
customHeaders: updatedHeaders,
isActive: tempConfig.isActive,
allowSelfSignedCertificates: tempConfig.allowSelfSignedCertificates,
// If we got a JWT token, store it as apiKey for API auth
apiKey: result.jwtToken,
);
// Create new API service with updated config
final apiWithCookies = ApiService(
serverConfig: configWithCookies,
workerManager: workerManager,
// If we have a JWT token, use it as auth token
authToken: result.jwtToken,
);
// Now verify it's an OpenWebUI server
DebugLogger.log(
'Verifying OpenWebUI server with proxy cookies...',
scope: 'auth/connection',
);
final backendConfig = await apiWithCookies.verifyAndGetConfig();
if (backendConfig == null) {
if (mounted) {
setState(() {
_connectionError =
'Could not verify OpenWebUI server. The proxy cookies may '
'have expired or be invalid. Please try again.';
_isConnecting = false;
});
}
return;
}
// Check if user is already fully authenticated via trusted headers
// (e.g., oauth2-proxy with X-Forwarded-Email)
if (result.isFullyAuthenticated) {
DebugLogger.log(
'User already authenticated via trusted headers, '
'skipping sign-in page',
scope: 'auth/connection',
);
// Save the server config and go directly to chat
await _completeAuthWithToken(
configWithCookies,
result.jwtToken!,
);
return;
}
DebugLogger.log(
'Server validated with proxy cookies, navigating to auth page',
scope: 'auth/connection',
);
if (mounted) {
final authFlowConfig = AuthFlowConfig(
serverConfig: configWithCookies,
backendConfig: backendConfig,
);
context.pushNamed(RouteNames.authentication, extra: authFlowConfig);
}
}
/// Completes authentication when user is already authenticated via
/// trusted headers (oauth2-proxy with X-Forwarded-Email).
Future<void> _completeAuthWithToken(
ServerConfig serverConfig,
String token,
) async {
try {
// Save the server config first (needed for auth actions)
await _saveServerConfig(serverConfig);
// Use the same auth flow as SSO - loginWithApiKey handles
// saving credentials and updating auth state
final authActions = ref.read(authActionsProvider);
final success = await authActions.loginWithApiKey(
token,
rememberCredentials: true,
authType: 'proxy-sso', // Mark as proxy-obtained token
);
if (!mounted) return;
if (success) {
DebugLogger.auth('Proxy SSO login successful');
// Navigation is handled automatically by the router when auth state
// changes to authenticated. The router redirect will navigate to chat.
} else {
throw Exception('Login failed');
}
} catch (e, stack) {
DebugLogger.error(
'Failed to complete auth with token',
scope: 'auth/connection',
error: e,
stackTrace: stack,
);
if (mounted) {
setState(() {
_connectionError =
'Authentication failed. Please try signing in manually.';
_isConnecting = false;
});
}
}
}
/// Saves server config (extracted from authentication_page.dart)
Future<void> _saveServerConfig(ServerConfig config) async {
final storage = ref.read(optimizedStorageServiceProvider);
await storage.saveServerConfigs([config]);
await storage.setActiveServerId(config.id);
ref.invalidate(serverConfigsProvider);
ref.invalidate(activeServerProvider);
}
String _validateAndFormatUrl(String input) {
if (input.isEmpty) {
throw Exception(AppLocalizations.of(context)!.serverUrlEmpty);
@@ -593,11 +822,13 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
}
Widget _buildAdvancedSettingsContent() {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(Spacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Self-signed certificates toggle
Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.md),
@@ -628,9 +859,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(
context,
)!.allowSelfSignedCertificates,
l10n.allowSelfSignedCertificates,
style: context.conduitTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary,
@@ -638,9 +867,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
),
const SizedBox(height: Spacing.xs),
Text(
AppLocalizations.of(
context,
)!.allowSelfSignedCertificatesDescription,
l10n.allowSelfSignedCertificatesDescription,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
@@ -661,11 +888,69 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
],
),
),
// Server behind proxy toggle
Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.md),
margin: const EdgeInsets.only(bottom: Spacing.md),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceContainer.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: context.conduitTheme.dividerColor.withValues(alpha: 0.4),
width: BorderWidth.thin,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Platform.isIOS ? CupertinoIcons.shield : Icons.security,
color: context.conduitTheme.iconSecondary,
size: IconSize.small,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.serverBehindProxy,
style: context.conduitTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary,
),
),
const SizedBox(height: Spacing.xs),
Text(
l10n.serverBehindProxyDescription,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
],
),
),
const SizedBox(width: Spacing.sm),
Switch.adaptive(
value: _serverBehindProxy,
onChanged: (value) {
setState(() {
_serverBehindProxy = value;
});
},
activeTrackColor: context.conduitTheme.buttonPrimary,
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.customHeaders,
l10n.customHeaders,
style: context.conduitTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
@@ -683,7 +968,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
),
const SizedBox(height: Spacing.xs),
Text(
AppLocalizations.of(context)!.customHeadersDescription,
l10n.customHeadersDescription,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
@@ -695,7 +980,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
Expanded(
flex: 2,
child: AccessibleFormField(
label: AppLocalizations.of(context)!.headerName,
label: l10n.headerName,
hint: 'X-Custom-Header',
controller: _headerKeyController,
validator: (value) => _validateHeaderKey(value ?? ''),
@@ -708,8 +993,8 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
Expanded(
flex: 3,
child: AccessibleFormField(
label: AppLocalizations.of(context)!.headerValue,
hint: AppLocalizations.of(context)!.headerValueHint,
label: l10n.headerValue,
hint: l10n.headerValueHint,
controller: _headerValueController,
validator: (value) => _validateHeaderValue(value ?? ''),
semanticLabel: 'Enter header value',
@@ -724,8 +1009,8 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
? null
: _addCustomHeader,
tooltip: _customHeaders.length >= 10
? AppLocalizations.of(context)!.maximumHeadersReached
: AppLocalizations.of(context)!.addHeader,
? l10n.maximumHeadersReached
: l10n.addHeader,
backgroundColor: _customHeaders.length >= 10
? context.conduitTheme.surfaceContainer
: context.conduitTheme.buttonPrimary,

View File

@@ -1903,6 +1903,54 @@
"@ssoPlatformNotSupported": {
"description": "Error message when SSO is attempted on an unsupported platform (desktop/web)."
},
"proxyAuthentication": "Proxy Authentication",
"@proxyAuthentication": {
"description": "Title for the proxy authentication page."
},
"proxyAuthPlatformNotSupported": "Proxy authentication requires a mobile device. Please authenticate through a browser first.",
"@proxyAuthPlatformNotSupported": {
"description": "Error message when proxy auth is attempted on an unsupported platform."
},
"proxyAuthLoading": "Loading authentication page...",
"@proxyAuthLoading": {
"description": "Loading message while the proxy login page loads."
},
"proxyAuthFailed": "Proxy authentication failed",
"@proxyAuthFailed": {
"description": "Error message when proxy authentication fails."
},
"proxyAuthHelpText": "Complete sign-in through your organization's proxy, then sign in to Open WebUI. You'll be redirected automatically.",
"@proxyAuthHelpText": {
"description": "Help text explaining how to complete proxy authentication."
},
"proxyAuthHelpTextSimple": "Sign in through your proxy. Once authenticated, tap Continue to proceed to sign in.",
"@proxyAuthHelpTextSimple": {
"description": "Simplified help text for proxy-only authentication flow."
},
"continueButton": "Continue",
"@continueButton": {
"description": "Generic continue button text."
},
"proxyAuthRequired": "This server requires proxy authentication",
"@proxyAuthRequired": {
"description": "Message when server is behind an authentication proxy."
},
"proxyAuthRequiredDescription": "Your server appears to be behind an authentication proxy (like oauth2-proxy). You'll need to sign in through the proxy first.",
"@proxyAuthRequiredDescription": {
"description": "Detailed explanation of proxy authentication requirement."
},
"authenticateThroughProxy": "Authenticate",
"@authenticateThroughProxy": {
"description": "Button text to start proxy authentication."
},
"serverBehindProxy": "Server behind proxy",
"@serverBehindProxy": {
"description": "Toggle label for servers behind authentication proxies."
},
"serverBehindProxyDescription": "Enable if your server uses oauth2-proxy or similar authentication.",
"@serverBehindProxyDescription": {
"description": "Description for the proxy server toggle."
},
"continueWithProvider": "Continue with {provider}",
"@continueWithProvider": {
"description": "Button text for OAuth provider sign-in.",