feat: updated SSO login flow and enhance cookie handling in LoginRemoteDataSource (#174)

This commit is contained in:
Daniel
2026-07-13 16:38:14 +02:00
parent 545a0d8c3b
commit bd72089ad9
3 changed files with 113 additions and 45 deletions
+66 -23
View File
@@ -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<String, String> 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<String> _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<String, String> queryParams = const {},
bool followRedirects = true,
Map<String, String> 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<String, String> 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<String, String> queryParams = const {},
bool expectBinary = false,
bool connectivityOnly = false,
Map<String, String> 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) {
@@ -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<bool> _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<void> _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<bool> _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<bool> canAccessWebsite() async {
logger.i('Checking if user can access website...');
final prefs = await SharedPreferences.getInstance();