Files
plezy/lib/services/seerr/seerr_auth_service.dart
T
edde746 595cd9fe24 feat(seerr): sign in with quick connect and accept schemeless urls
Linking a Seerr instance had two rough edges, both on the Jellyfin path.
The URL step prepended https:// to anything without a scheme, so a
plain-HTTP instance on the LAN — 192.168.1.5:5055, or a bare host on
Seerr's default port — failed with "could not reach" unless the user knew
to type the scheme. And the only way to sign in with a Jellyfin account
was typing a username and password, which on a TV remote is misery.

Schemeless input now expands into candidates that are probed together,
but TLS wins by construction: a plaintext success is held while any https
candidate is still in flight and is accepted only once they have all
failed. The sign-in that follows posts a password to whichever URL wins
here, so a slow-but-working https endpoint has to beat a fast plaintext
one, and the wait is already bounded by the probe timeout. When nothing
answers, the failure that reached a server outranks a transport error, so
"this instance has not completed first-run setup" is not masked by "could
not reach https://..." from a candidate the user never typed.

Quick Connect goes through Seerr's own proxy routes (3.4+): initiate,
then check polled at Seerr's own 2s cadence through the shared
pollWithBackoff — a 404 mid-poll means the secret is gone and is terminal
— then authenticate, which mints the session cookie through the existing
sign-in path. The secret rides in a query string, so it is registered for
log redaction the way the Jellyfin flow registers its own. An instance
that predates the routes 404s the initiate and is told apart from a
generic rejection.

SeerrAuthMethod.quickConnect deliberately stores no secret: silent
re-auth is impossible by construction, so an expired cookie lands in the
existing "no stored credentials" arm, unlinks the session, and the
connect flow asks for a fresh code. The affordance is limited to the
Jellyfin credential form, since Seerr rejects Quick Connect for Emby, and
is not auto-started on TV the way the MediaBrowser form is:
/settings/public exposes no "Quick Connect enabled" flag to gate that on.

The waiting panel and its attempt/cancel bookkeeping moved out of
AddJellyfinScreen into QuickConnectCodePanel and QuickConnectFlowMixin so
both screens share one implementation, and the post-frame focus helper
both forms use moved to AsyncFormStateMixin.
2026-08-25 03:07:14 +02:00

452 lines
17 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], 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 or not set up.
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),
);
}
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,
);
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();
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,
);
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 _resolveUser(client, res.data);
return SeerrSession(
baseUrl: client.baseUrl,
method: method,
identifier: identifier,
secret: secret,
cookie: client.cookie!,
userId: user.id,
permissions: user.permissions ?? 0,
displayName: user.displayName ?? identifier,
instanceLabel: '',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
} finally {
client.dispose();
}
}
/// The login endpoints return the [SeerrUser] directly; fall back to
/// `GET /auth/me` with the fresh cookie if that shape ever changes.
Future<SeerrUser> _resolveUser(SeerrHttpClient client, dynamic loginData) async {
if (loginData is Map<String, dynamic>) {
try {
return SeerrUser.fromJson(loginData);
} catch (_) {
// fall through to /auth/me
}
}
final res = await client.send('GET', '/auth/me', timeout: SeerrConstants.authTimeout);
// throwForStatus passes 401 through (it's normally the re-auth signal);
// here it 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>) return SeerrUser.fromJson(data);
throw SeerrAuthException('Seerr did not return user information', display: t.seerr.noUserInformation);
}
}