Files
plezy/lib/services/seerr/seerr_auth_service.dart
T
edde746 e46b3dab7c fix(seerr): diagnose an auth proxy in front of Seerr instead of misreporting it
A Seerr instance behind forward-auth (Authelia, Authentik, Cloudflare
Access) or HTTP Basic failed setup with "No Seerr instance at … (HTTP
200)": the probe followed the proxy's redirect to its login page and
decoded the HTML as "not JSON". A live session that later hit the same
wall took the proxy's 401 as Seerr's, re-authed through the wall, failed,
and unlinked a perfectly good stored session.

Seerr's API never redirects and always answers with JSON, so a 3xx or a
non-JSON 401/403 is the proxy talking. Seerr requests no longer follow
redirects, that shape maps to a SeerrProxyException with a message that
says what to do, the probe reports it in place of "no instance", and the
client neither re-auths nor unlinks on it.

close #1877
2026-09-03 21:42:47 +02:00

469 lines
18 KiB
Dart

import 'dart:async';
import 'package:http/http.dart' as http;
import '../../i18n/strings.g.dart';
import '../../models/seerr/seerr_public_settings.dart';
import '../../models/seerr/seerr_session.dart';
import '../../models/seerr/seerr_user.dart';
import '../../utils/app_logger.dart';
import '../../utils/log_redaction_manager.dart';
import '../../utils/poll_with_backoff.dart';
import '../../utils/url_utils.dart';
import 'seerr_constants.dart';
import 'seerr_exceptions.dart';
import 'seerr_http_client.dart';
/// A reachable, initialized Seerr instance: the URL that answered and the
/// public settings it reported.
typedef SeerrReachableInstance = ({String baseUrl, SeerrPublicSettings settings});
/// Result of `POST /auth/jellyfin/quickconnect/initiate`: [code] is shown to
/// the user to approve inside Jellyfin, [secret] drives the poll and the final
/// exchange.
class SeerrQuickConnectInitiation {
final String code;
final String secret;
const SeerrQuickConnectInitiation({required this.code, required this.secret});
}
/// Sign-in flows against a Seerr instance. Every flow ends with a captured
/// `connect.sid` cookie and the Seerr-side [SeerrUser] read back through
/// `GET /auth/me`, packed into a [SeerrSession].
class SeerrAuthService {
final http.Client Function()? httpClientFactory;
SeerrAuthService({this.httpClientFactory});
SeerrHttpClient _client(String baseUrl, {String? cookie}) =>
SeerrHttpClient(baseUrl: baseUrl, httpClient: httpClientFactory?.call(), cookie: cookie);
/// Schemeless-input guesses, TLS first: a bare host is usually a public
/// instance behind a reverse proxy, and [probeFirstReachable] must be able
/// to prefer the TLS candidate before a plaintext one wins the race.
static const List<BaseUrlGuess> _schemelessGuesses = [
(scheme: 'https', port: null),
(scheme: 'http', port: null),
(scheme: 'http', port: SeerrConstants.defaultPort),
];
/// Expands a user-typed instance address into probe candidates: an explicit
/// scheme is authoritative, otherwise TLS, plain HTTP, and the default
/// install port are all tried. Guesses are for discovery only — only the
/// candidate that answered is persisted.
static List<String> expandUrlCandidates(String input) => expandBaseUrlCandidates(input, guesses: _schemelessGuesses);
/// Probes every [expandUrlCandidates] guess for [input] and returns the
/// first reachable instance.
///
/// TLS wins by construction: probes all start together, but a plaintext
/// success is held while any `https` candidate is still in flight and is
/// only accepted once they have all failed. The sign-in that follows posts a
/// password to whichever URL wins here, so a slow-but-working TLS endpoint
/// must beat a fast plaintext one; each probe already bounds the hold at
/// [SeerrConstants.probeTimeout].
///
/// When nothing answers, the failure that reached a *server* is reported in
/// preference to a transport error, so "finish Seerr's setup" isn't masked by
/// "could not reach https://…" from a candidate the user never meant.
Future<SeerrReachableInstance> probeFirstReachable(String input) async {
final candidates = expandUrlCandidates(input);
if (candidates.isEmpty) {
throw SeerrUrlException('Not a usable Seerr instance URL: "$input"', display: t.seerr.invalidUrl);
}
if (candidates.length == 1) {
final only = candidates.single;
return (baseUrl: only, settings: await probe(only));
}
final completer = Completer<SeerrReachableInstance>();
final failures = List<(Object, StackTrace)?>.filled(candidates.length, null);
var pending = candidates.length;
var pendingSecure = candidates.where(_isSecure).length;
SeerrReachableInstance? heldPlaintext;
void settle() {
if (completer.isCompleted) return;
final held = heldPlaintext;
if (held != null && pendingSecure == 0) {
appLogger.d('Seerr: no TLS candidate answered, accepting the plaintext instance URL');
completer.complete(held);
return;
}
if (pending == 0) {
final (error, stackTrace) = _mostInformativeFailure(failures);
completer.completeError(error, stackTrace);
}
}
for (final (index, candidate) in candidates.indexed) {
final secure = _isSecure(candidate);
unawaited(
probe(candidate).then(
(settings) {
pending -= 1;
if (secure) pendingSecure -= 1;
if (completer.isCompleted) return;
if (secure) {
completer.complete((baseUrl: candidate, settings: settings));
return;
}
heldPlaintext ??= (baseUrl: candidate, settings: settings);
settle();
},
onError: (Object error, StackTrace stackTrace) {
failures[index] = (error, stackTrace);
pending -= 1;
if (secure) pendingSecure -= 1;
settle();
},
),
);
}
return completer.future;
}
static bool _isSecure(String baseUrl) => baseUrl.startsWith('https://');
/// The failure worth showing: one that came back from a server outranks a
/// transport error, and the first candidate — the URL the user most likely
/// meant — breaks the tie.
static (Object, StackTrace) _mostInformativeFailure(List<(Object, StackTrace)?> failures) {
for (final failure in failures) {
if (failure != null && failure.$1 is SeerrUrlException && (failure.$1 as SeerrUrlException).statusCode != null) {
return failure;
}
}
return failures.firstWhere((failure) => failure != null)!;
}
/// Validate that [baseUrl] points at a running, initialized Seerr and
/// collect the metadata the connect flow needs. Throws [SeerrUrlException]
/// when unreachable, not set up, or answered by an auth proxy instead.
Future<SeerrPublicSettings> probe(String baseUrl) async {
final client = _client(baseUrl);
try {
final SeerrResponse res;
try {
res = await client.send('GET', '/settings/public', timeout: SeerrConstants.probeTimeout, authenticated: false);
} catch (e) {
throw SeerrUrlException(
'Could not reach $baseUrl: $e',
display: t.seerr.couldNotReach(url: baseUrl, error: e),
);
}
if (SeerrHttpClient.isProxyInterception(res)) {
throw SeerrUrlException(
'Auth proxy in front of $baseUrl (HTTP ${res.statusCode})',
display: t.seerr.behindAuthProxy,
statusCode: res.statusCode,
);
}
final data = res.data;
if (res.statusCode >= 400 || data is! Map<String, dynamic>) {
throw SeerrUrlException(
'No Seerr instance at $baseUrl (HTTP ${res.statusCode})',
display: t.seerr.noInstanceAtUrl(url: baseUrl, status: res.statusCode),
statusCode: res.statusCode,
);
}
final settings = SeerrPublicSettings.fromJson(data);
if (!settings.initialized) {
throw SeerrUrlException(
'Seerr instance has not completed first-run setup',
display: t.seerr.notInitialized,
statusCode: res.statusCode,
);
}
return settings;
} finally {
client.dispose();
}
}
/// `POST /auth/plex` with a Plex account token.
Future<SeerrSession> signInWithPlex({required String baseUrl, required String plexToken}) => _signIn(
baseUrl: baseUrl,
method: SeerrAuthMethod.plex,
path: '/auth/plex',
body: {'authToken': plexToken},
identifier: '',
secret: '',
);
/// `POST /auth/jellyfin` with Jellyfin or Emby credentials.
Future<SeerrSession> signInWithJellyfin({
required String baseUrl,
required String username,
required String password,
bool emby = false,
}) => _signIn(
baseUrl: baseUrl,
method: emby ? SeerrAuthMethod.emby : SeerrAuthMethod.jellyfin,
path: '/auth/jellyfin',
body: {
'username': username,
'password': password,
'serverType': emby ? SeerrMediaServerType.emby : SeerrMediaServerType.jellyfin,
},
identifier: username,
secret: password,
);
/// `POST /auth/local` with a Seerr local account.
Future<SeerrSession> signInWithLocal({required String baseUrl, required String email, required String password}) =>
_signIn(
baseUrl: baseUrl,
method: SeerrAuthMethod.local,
path: '/auth/local',
body: {'email': email, 'password': password},
identifier: email,
secret: password,
);
/// `POST /auth/jellyfin/quickconnect/initiate` (Seerr 3.4+): starts a
/// Jellyfin Quick Connect session proxied by the instance. A 404 means the
/// instance predates the proxy routes; anything else 4xx/5xx is the
/// instance's own rejection (Quick Connect disabled on its Jellyfin, Emby
/// backends, …).
Future<SeerrQuickConnectInitiation> initiateQuickConnect(String baseUrl) async {
final client = _client(baseUrl);
try {
final res = await client.send(
'POST',
'/auth/jellyfin/quickconnect/initiate',
timeout: SeerrConstants.authTimeout,
authenticated: false,
);
SeerrHttpClient.throwIfProxied(res);
final data = res.data;
final message = data is Map<String, dynamic> ? data['message'] as String? : null;
if (res.statusCode == 404) {
throw SeerrAuthException(
'Seerr has no Quick Connect proxy routes (needs 3.4+)',
statusCode: res.statusCode,
display: t.seerr.quickConnectUnsupported,
);
}
if (res.statusCode >= 400) {
throw SeerrAuthException(
message ?? 'Quick Connect rejected (HTTP ${res.statusCode})',
statusCode: res.statusCode,
display: t.addServer.quickConnectFailed(error: message ?? 'HTTP ${res.statusCode}'),
);
}
final code = data is Map<String, dynamic> ? data['code'] : null;
final secret = data is Map<String, dynamic> ? data['secret'] : null;
if (code is! String || code.isEmpty || secret is! String || secret.isEmpty) {
throw SeerrAuthException(
'Quick Connect response is missing a code or secret',
display: t.addServer.quickConnectMissingFields,
);
}
// The secret rides in a query string, so a poll failure would otherwise
// put it in a log line via ClientException's URI.
LogRedactionManager.registerCustomValue(secret);
return SeerrQuickConnectInitiation(code: code, secret: secret);
} finally {
client.dispose();
}
}
/// Polls `GET /auth/jellyfin/quickconnect/check` until the code is approved
/// inside Jellyfin, then exchanges the secret through
/// `POST …/authenticate` for a Seerr session.
///
/// Returns null when [shouldCancel] fires, when [timeout] elapses, or when
/// the instance drops the secret mid-poll (404) — the caller decides which of
/// those is worth an error message. Transient poll failures keep retrying.
Future<SeerrSession?> signInWithQuickConnect({
required String baseUrl,
required String secret,
bool Function()? shouldCancel,
Duration timeout = const Duration(minutes: 5),
}) async {
// One client for the whole window: no TCP churn across minutes of polling.
final pollClient = _client(baseUrl);
final bool? approved;
try {
approved = await pollWithBackoff<bool>(
endTime: DateTime.now().add(timeout),
shouldCancel: shouldCancel,
initial: SeerrConstants.quickConnectPollInterval,
maxBackoff: SeerrConstants.quickConnectPollInterval,
probe: () async {
final SeerrResponse res;
try {
res = await pollClient.send(
'GET',
'/auth/jellyfin/quickconnect/check',
query: {'secret': secret},
timeout: SeerrConstants.authTimeout,
authenticated: false,
);
} catch (e) {
appLogger.d('Seerr: Quick Connect poll blip', error: e);
return null;
}
// 404 mid-poll = secret expired or revoked server-side. Terminal.
if (res.statusCode == 404) throw const PollTerminatedSignal();
SeerrHttpClient.throwIfProxied(res);
if (res.statusCode == 401 || res.statusCode == 403) {
final data = res.data;
throw SeerrAuthException(
(data is Map<String, dynamic> ? data['message'] as String? : null) ?? 'Quick Connect poll rejected',
statusCode: res.statusCode,
display: t.addServer.quickConnectPollRejected,
);
}
SeerrHttpClient.throwForStatus(res);
final data = res.data;
return data is Map<String, dynamic> && data['authenticated'] == true ? true : null;
},
);
} finally {
pollClient.dispose();
}
if (approved != true) return null;
return _signIn(
baseUrl: baseUrl,
method: SeerrAuthMethod.quickConnect,
path: '/auth/jellyfin/quickconnect/authenticate',
body: {'secret': secret},
identifier: '',
secret: '',
);
}
/// Silent re-login using the credentials carried by [session]
/// ([plexToken] for plex-method sessions). Returns the refreshed session.
Future<SeerrSession> reauth(SeerrSession session, {String? plexToken}) async {
final fresh = await switch (session.method) {
SeerrAuthMethod.plex when plexToken != null && plexToken.isNotEmpty => signInWithPlex(
baseUrl: session.baseUrl,
plexToken: plexToken,
),
// No token RIGHT NOW is a degraded state (identity not hydrated yet,
// vault decrypt hiccup), not a server rejection — retryable, so it
// must not unlink the session. An empty stored secret below is the
// opposite: those credentials are gone for good, so re-linking is the
// only way forward and unlinking is honest.
SeerrAuthMethod.plex => throw SeerrReauthUnavailableException(
'No Plex token available for silent re-auth',
display: t.seerr.noPlexTokenForReauth,
),
SeerrAuthMethod.jellyfin || SeerrAuthMethod.emby when session.secret.isNotEmpty => signInWithJellyfin(
baseUrl: session.baseUrl,
username: session.identifier,
password: session.secret,
emby: session.method == SeerrAuthMethod.emby,
),
SeerrAuthMethod.local when session.secret.isNotEmpty => signInWithLocal(
baseUrl: session.baseUrl,
email: session.identifier,
password: session.secret,
),
_ => throw SeerrAuthException('No stored credentials for silent re-auth', display: t.seerr.noStoredCredentials),
};
return session.copyWith(cookie: fresh.cookie, permissions: fresh.permissions, displayName: fresh.displayName);
}
/// Best-effort server-side sign-out; local cleanup must not depend on it.
Future<void> signOut(SeerrSession session) async {
final client = _client(session.baseUrl, cookie: session.cookie);
try {
await client.send('POST', '/auth/logout', timeout: SeerrConstants.authTimeout);
} catch (e) {
appLogger.d('Seerr: sign-out best-effort failed', error: e);
} finally {
client.dispose();
}
}
Future<SeerrSession> _signIn({
required String baseUrl,
required SeerrAuthMethod method,
required String path,
required Map<String, Object?> body,
required String identifier,
required String secret,
}) async {
final client = _client(baseUrl);
try {
final res = await client.send(
'POST',
path,
body: body,
timeout: SeerrConstants.authTimeout,
authenticated: false,
);
SeerrHttpClient.throwIfProxied(res);
if (res.statusCode == 401 || res.statusCode == 403) {
final message = res.data is Map<String, dynamic>
? (res.data as Map<String, dynamic>)['message'] as String?
: null;
throw SeerrAuthException(
message ?? 'Sign-in rejected',
statusCode: res.statusCode,
display: t.seerr.signInRejected,
);
}
SeerrHttpClient.throwForStatus(res);
if (!client.captureSessionCookie(res.response)) {
throw SeerrAuthException('Seerr did not issue a session cookie', display: t.seerr.noSessionCookie);
}
final user = await _fetchUser(client);
return SeerrSession(
baseUrl: client.baseUrl,
method: method,
identifier: identifier,
secret: secret,
cookie: client.cookie!,
userId: user.id,
permissions: user.permissions,
displayName: user.displayName ?? identifier,
instanceLabel: '',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
} finally {
client.dispose();
}
}
/// The user behind the fresh cookie, via `GET /auth/me`.
///
/// The login response body is ignored on purpose. Each handler returns
/// whatever `User` instance it happened to build: `POST /auth/local` loads
/// only id, email, password, and plexId to check the password, so its body
/// carries the entity's `permissions` default of 0 rather than the stored
/// mask (#2213), and a first sign-in through Plex or Jellyfin returns the
/// just-saved entity before its display name is derived. `/auth/me` reloads
/// the full row, which is what the Seerr web UI itself reads after login.
Future<SeerrUser> _fetchUser(SeerrHttpClient client) async {
final res = await client.send('GET', '/auth/me', timeout: SeerrConstants.authTimeout);
SeerrHttpClient.throwIfProxied(res);
// throwForStatus passes 401 through (it's normally the re-auth signal);
// here it, like Seerr's own 403, means the fresh cookie was rejected — an
// auth failure, not a malformed-user-payload crash further down.
if (res.statusCode == 401 || res.statusCode == 403) {
throw SeerrAuthException(
'Seerr rejected the fresh session cookie',
statusCode: res.statusCode,
display: t.seerr.freshCookieRejected,
);
}
SeerrHttpClient.throwForStatus(res);
final data = res.data;
if (data is Map<String, dynamic>) {
try {
return SeerrUser.fromJson(data);
} on TypeError catch (e) {
appLogger.w('Seerr: /auth/me returned an unusable user', error: e);
}
}
throw SeerrAuthException('Seerr did not return user information', display: t.seerr.noUserInformation);
}
}