From bd72089ad96f203c00e07f29bf4ab2fbf274a50a Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 13 Jul 2026 16:38:14 +0200 Subject: [PATCH] feat: updated SSO login flow and enhance cookie handling in LoginRemoteDataSource (#174) --- .../metadata/android/en-US/changelogs/39.txt | 1 + lib/core/services/api_service.dart | 89 ++++++++++++++----- .../datasources/login_remote_datasource.dart | 68 +++++++++----- 3 files changed, 113 insertions(+), 45 deletions(-) diff --git a/fastlane/metadata/android/en-US/changelogs/39.txt b/fastlane/metadata/android/en-US/changelogs/39.txt index 57e6b87..7c7d4f8 100644 --- a/fastlane/metadata/android/en-US/changelogs/39.txt +++ b/fastlane/metadata/android/en-US/changelogs/39.txt @@ -1,3 +1,4 @@ - Feature: Homescreen widgets added (Quick Actions, Stats, Currently Reading Book, Shelf Grid). - Fix: Fixed an issue where FileOpener was not using the correct MIME type for EPUB files, causing some apps to fail to open them correctly. +- Fix: Updated SSO login flow and enhance cookie handling in LoginRemoteDataSource - Language: Updated translations: Spanish (@Libre), Italian (@Michele Abbondanza), Hungarian (@Márton Kónya) and Chinese (Simplified Han script) (@Mas Wang). \ No newline at end of file diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 95995f1..119061a 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -17,6 +17,10 @@ import 'package:calibre_web_companion/features/book_view/data/datasources/book_v enum AuthMethod { none, cookie, basic, auto } class ApiService { + static const Map browserAcceptHeaders = { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }; + final Logger _logger = Logger(); HttpClient? _httpClient; http.Client? _client; @@ -78,18 +82,23 @@ class ApiService { final prefs = await SharedPreferences.getInstance(); _baseUrl = prefs.getString('base_url'); - final storedCookie = - prefs.getString('calibre_web_cookie') ?? - prefs.getString('calibre_web_session'); + final storedCookieHeader = prefs.getString('calibre_web_cookie'); + final storedSetCookie = prefs.getString('calibre_web_session'); - if (storedCookie != null) { - final normalized = buildCookieHeaderFromSetCookie(storedCookie); - _cookie = normalized.isEmpty ? storedCookie : normalized; - await prefs.setString('calibre_web_cookie', _cookie!); + if (storedCookieHeader != null && storedCookieHeader.isNotEmpty) { + final sanitized = sanitizeCookieHeader(storedCookieHeader); + _cookie = sanitized.isEmpty ? storedCookieHeader : sanitized; + } else if (storedSetCookie != null && storedSetCookie.isNotEmpty) { + final normalized = buildCookieHeaderFromSetCookie(storedSetCookie); + _cookie = normalized.isEmpty ? storedSetCookie : normalized; } else { _cookie = null; } + if (_cookie != null) { + await prefs.setString('calibre_web_cookie', _cookie!); + } + _username = prefs.getString('username'); _password = prefs.getString('password'); _basePath = prefs.getString('base_path') ?? ''; @@ -135,6 +144,17 @@ class ApiService { await initialize(); } + static const Set _cookieAttributes = { + 'path', + 'expires', + 'max-age', + 'domain', + 'secure', + 'httponly', + 'samesite', + 'partitioned', + }; + /// Build a Cookie header value from a Set-Cookie header string. /// Extracts all cookie-name=cookie-value pairs and joins them with '; '. String buildCookieHeaderFromSetCookie(String? setCookieHeader) { @@ -145,14 +165,7 @@ class ApiService { final name = match.group(1); final value = match.group(2); if (name != null && value != null) { - final lower = name.toLowerCase(); - if (lower == 'path' || - lower == 'expires' || - lower == 'max-age' || - lower == 'domain' || - lower == 'secure' || - lower == 'httponly' || - lower == 'samesite') { + if (_cookieAttributes.contains(name.toLowerCase())) { continue; } cookiePairs.add('$name=$value'); @@ -161,6 +174,19 @@ class ApiService { return cookiePairs.join('; '); } + /// Drop attributes from an existing Cookie header, keeping every cookie. + /// + /// A Cookie header separates cookies with ';', a Set-Cookie header separates + /// them with ',' and uses ';' for the attributes of a single cookie. Running + /// one through the parser of the other keeps only the first cookie, which is + /// fatal for SSO sessions that carry both a proxy and a Calibre-Web cookie. + String sanitizeCookieHeader(String cookieHeader) { + final cookies = _parseCookieHeader( + cookieHeader, + )..removeWhere((name, _) => _cookieAttributes.contains(name.toLowerCase())); + return cookies.entries.map((e) => '${e.key}=${e.value}').join('; '); + } + /// Merge two Cookie header strings, deduplicating by cookie name String _mergeCookieHeaders(String existingCookie, String newCookie) { if ((existingCookie).trim().isEmpty) return newCookie.trim(); @@ -416,6 +442,7 @@ class ApiService { AuthMethod authMethod = AuthMethod.basic, Map queryParams = const {}, bool followRedirects = true, + Map extraHeaders = const {}, }) async { await _ensureInitialized(); final uri = _buildUri(endpoint: endpoint, queryParams: queryParams); @@ -427,6 +454,7 @@ class ApiService { final customHeaders = await _processCustomHeaders(); headers.addAll(customHeaders); + headers.addAll(extraHeaders); if (followRedirects) { try { @@ -1211,25 +1239,36 @@ class ApiService { AuthMethod authMethod = AuthMethod.auto, }) { Map headers = {}; + + final hasBasicCredentials = + _username != null && _username!.isNotEmpty && _password != null; + final hasCookie = _cookie != null && _cookie!.isNotEmpty; + AuthMethod resolvedAuthMethod = authMethod; if (resolvedAuthMethod == AuthMethod.auto) { - if (_username != null && _username!.isNotEmpty && _password != null) { + if (hasBasicCredentials) { resolvedAuthMethod = AuthMethod.basic; - } else if (_cookie != null && _cookie!.isNotEmpty) { + } else if (hasCookie) { resolvedAuthMethod = AuthMethod.cookie; } else { resolvedAuthMethod = AuthMethod.none; } } - if (resolvedAuthMethod == AuthMethod.cookie && _cookie != null) { + if (resolvedAuthMethod == AuthMethod.cookie && hasCookie) { headers['Cookie'] = _cookie!; - } else if (resolvedAuthMethod == AuthMethod.basic && - _username != null && - _password != null) { - headers['Authorization'] = - 'Basic ${base64.encode(utf8.encode('$_username:$_password'))}'; + } else if (resolvedAuthMethod == AuthMethod.basic) { + if (hasBasicCredentials) { + headers['Authorization'] = + 'Basic ${base64.encode(utf8.encode('$_username:$_password'))}'; + } + // Calibre-Web's OPDS endpoints only accept Basic auth, while a + // forward-auth proxy in front of it (Authelia, Authentik, …) only accepts + // its session cookie. Such a request has to carry both to get through. + if (hasCookie) { + headers['Cookie'] = _cookie!; + } } return headers; @@ -1289,6 +1328,7 @@ class ApiService { '/', AuthMethod.auto, connectivityOnly: true, + extraHeaders: browserAcceptHeaders, ), await _probe( DiagnosticProbeId.bookList, @@ -1313,6 +1353,7 @@ class ApiService { '/', AuthMethod.auto, connectivityOnly: true, + extraHeaders: browserAcceptHeaders, ), await _probe( DiagnosticProbeId.bookList, @@ -1351,6 +1392,7 @@ class ApiService { Map queryParams = const {}, bool expectBinary = false, bool connectivityOnly = false, + Map extraHeaders = const {}, }) async { final uri = _buildUri(endpoint: endpoint, queryParams: queryParams); final httpClient = HttpClient(); @@ -1370,6 +1412,7 @@ class ApiService { final headers = getAuthHeaders(authMethod: authMethod); if (_userAgent != null) headers['User-Agent'] = _userAgent!; headers.addAll(await _processCustomHeaders()); + headers.addAll(extraHeaders); final preDigest = _preemptiveDigestHeader(authMethod, 'GET', uri); if (preDigest != null) { diff --git a/lib/features/login/data/datasources/login_remote_datasource.dart b/lib/features/login/data/datasources/login_remote_datasource.dart index 0029fe6..b996266 100644 --- a/lib/features/login/data/datasources/login_remote_datasource.dart +++ b/lib/features/login/data/datasources/login_remote_datasource.dart @@ -1,7 +1,5 @@ import 'dart:convert'; import 'dart:io'; -// ignore: implementation_imports -import 'package:http/src/response.dart'; import 'package:logger/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -152,29 +150,19 @@ class LoginRemoteDataSource { Future _loginCalibreWeb(LoginCredentials credentials) async { final prefs = await SharedPreferences.getInstance(); if (credentials.username.isEmpty && credentials.password.isEmpty) { - logger.i('Attempting SSO login by triggering a redirect...'); - await apiService.get(endpoint: '/', followRedirects: false); - logger.i('User is already logged in.'); + logger.i('Attempting SSO login...'); + await _startSsoLogin(credentials.baseUrl); + logger.i('Existing session is still valid, no SSO web view needed.'); return true; } - Response response; - - if (credentials.username.isEmpty && credentials.password.isEmpty) { - response = await apiService.post( - endpoint: '/login', - body: credentials.toFormData(), - followRedirects: false, - ); - } else { - response = await apiService.post( - endpoint: '/login', - body: credentials.toFormData(), - authMethod: AuthMethod.none, - contentType: 'application/x-www-form-urlencoded', - useCsrf: true, - ); - } + final response = await apiService.post( + endpoint: '/login', + body: credentials.toFormData(), + authMethod: AuthMethod.none, + contentType: 'application/x-www-form-urlencoded', + useCsrf: true, + ); if (response.statusCode == 200 || response.statusCode == 302) { final isSuccess = !response.body.contains('flash_danger'); @@ -202,6 +190,42 @@ class LoginRemoteDataSource { throw Exception(response.reasonPhrase ?? response.body); } + Future _startSsoLogin(String baseUrl) async { + if (await _hasValidCalibreWebSession()) return; + + try { + await apiService.get( + endpoint: '/', + authMethod: AuthMethod.cookie, + followRedirects: false, + extraHeaders: ApiService.browserAcceptHeaders, + ); + } on RedirectException { + rethrow; + } catch (e) { + logger.w('SSO probe did not redirect: $e'); + } + + logger.i('No redirect received, opening the web view at the base URL.'); + throw RedirectException(baseUrl); + } + + Future _hasValidCalibreWebSession() async { + try { + final response = await apiService.get( + endpoint: '/ajax/listbooks', + authMethod: AuthMethod.cookie, + queryParams: const {'limit': '1'}, + ); + + return response.statusCode == 200 && + !response.body.trimLeft().startsWith('<'); + } catch (e) { + logger.i('No usable session yet: $e'); + return false; + } + } + Future canAccessWebsite() async { logger.i('Checking if user can access website...'); final prefs = await SharedPreferences.getInstance();