feat: oauth proxy for mal/anilist auth

This commit is contained in:
edde746
2026-04-24 09:46:45 +02:00
parent 1582fdbd5b
commit e8729a4e27
49 changed files with 1873 additions and 349 deletions
@@ -1,46 +1,29 @@
import 'dart:async';
import '../loopback_auth_server.dart';
import 'anilist_constants.dart';
import '../oauth_proxy_client.dart';
import 'anilist_session.dart';
/// AniList OAuth 2.0 implicit grant via RFC 8252 loopback redirect.
/// AniList authentication via the Plezy relay's OAuth proxy.
///
/// AniList returns the access token in the URL fragment, which browsers
/// don't send to servers. [LoopbackAuthServer] serves a tiny HTML page that
/// rewrites `location.hash` into a query string and reloads — the second
/// request is then captured normally.
/// We use AniList's authorization-code grant (not implicit), exchanged
/// server-side so the device never sees the fragment. The proxy handles both
/// state + client_secret; the device just gets the bearer token.
class AnilistAuthService {
static const String _callbackPath = '/anilist-oauth';
final OAuthProxyClient _proxy;
/// Drive the full flow. Returns `null` if the user closes the browser
/// before completing.
Future<AnilistSession?> authorize() async {
// AniList's implicit grant rejects the authorize request when
// `redirect_uri` is present — MAL-Sync omits it too. The redirect URL
// registered for the client at anilist.co is used automatically.
final authorizeUri = Uri.parse(AnilistConstants.oauthAuthorizeUrl).replace(
queryParameters: {'client_id': AnilistConstants.clientId, 'response_type': 'token'},
);
AnilistAuthService({OAuthProxyClient? proxy}) : _proxy = proxy ?? OAuthProxyClient();
final callback = await LoopbackAuthServer.launchAndWait(authorizeUri, path: _callbackPath);
if (callback == null) return null;
void dispose() => _proxy.dispose();
final params = callback.queryParameters;
final token = params['access_token'];
if (token == null) {
throw AnilistAuthFlowException('AniList redirect missing access_token: $callback');
}
final expiresIn = int.tryParse(params['expires_in'] ?? '') ?? (365 * 24 * 60 * 60);
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return AnilistSession(accessToken: token, expiresAt: createdAt + expiresIn, createdAt: createdAt);
/// Drive the full flow. Returns null on user cancel.
Future<AnilistSession?> authorize({
required void Function(OAuthProxyStart) onCodeReady,
bool Function()? shouldCancel,
}) async {
final start = await _proxy.start('anilist');
onCodeReady(start);
final result = await _proxy.poll(start.session, shouldCancel: shouldCancel);
if (result == null) return null;
return AnilistSession.fromProxyResult(result);
}
}
class AnilistAuthFlowException implements Exception {
final String message;
const AnilistAuthFlowException(this.message);
@override
String toString() => 'AnilistAuthFlowException: $message';
}
@@ -1,16 +1,12 @@
/// Bundled AniList API credentials and endpoints.
/// Bundled AniList API endpoint.
///
/// Register at https://anilist.co/settings/developer — redirect URL must be
/// `http://127.0.0.1:53682/anilist-oauth` (RFC 8252 loopback). AniList uses
/// OAuth 2.0 Implicit Grant; access tokens are valid for 1 year and have no
/// refresh — the user must re-auth on expiry.
/// Auth is driven entirely by the Plezy relay's OAuth proxy — the device
/// never needs the AniList authorize URL, client ID, or client secret.
/// Tokens are valid for 1 year and have no refresh; users re-auth on expiry.
class AnilistConstants {
AnilistConstants._();
static const String clientId = '39867';
static const String apiBase = 'https://graphql.anilist.co';
static const String oauthAuthorizeUrl = 'https://anilist.co/api/v2/oauth/authorize';
static Map<String, String> headers({String? accessToken}) => {
'Content-Type': 'application/json',
@@ -1,5 +1,7 @@
import 'dart:convert';
import '../oauth_proxy_client.dart';
/// Immutable AniList OAuth session.
///
/// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry
@@ -42,6 +44,19 @@ class AnilistSession {
createdAt: (json['created_at'] as num).toInt(),
);
/// Build a session from the OAuth-proxy result. AniList tokens last 1 year
/// and have no refresh; when the proxy doesn't echo an explicit expiry we
/// default to the documented year.
factory AnilistSession.fromProxyResult(OAuthProxyResult r) {
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expiresIn = r.expiresIn ?? 365 * 24 * 60 * 60;
return AnilistSession(
accessToken: r.accessToken,
expiresAt: createdAt + expiresIn,
createdAt: createdAt,
);
}
String encode() => json.encode(toJson());
static AnilistSession decode(String raw) => AnilistSession.fromJson(json.decode(raw) as Map<String, dynamic>);
}
@@ -1,144 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:url_launcher/url_launcher.dart';
import '../../utils/app_logger.dart';
/// One-shot HTTP server on `127.0.0.1:[port]` that captures an OAuth redirect
/// (RFC 8252 Loopback Interface Redirection).
///
/// Replaces the custom `plezy://` URL scheme used by `flutter_web_auth_2` —
/// no platform manifest registration needed.
///
/// **iOS caveat**: when the app opens an external browser, iOS may suspend
/// the app after ~30 seconds, which silently kills this server. Users who
/// linger in 2FA / password managers may see "localhost refused to connect"
/// on the redirect. If this turns out to be common, fall back to the
/// custom-scheme approach on mobile only.
class LoopbackAuthServer {
/// Fixed port — must be registered as the redirect URI with each OAuth
/// provider. AniList requires exact URI match, so we can't pick dynamically.
static const int port = 53682;
static const String host = '127.0.0.1';
/// Listen for one request at `http://$host:$port$path` and return the
/// captured URI.
///
/// Handles both query-param redirects (RFC 6749 authorization_code grant)
/// and fragment redirects (implicit grant): fragments stay client-side, so
/// we serve a tiny HTML page that rewrites `location.hash` into query
/// params and reloads — the second request is then captured normally.
static Future<Uri> listenOnce({
required String path,
Duration timeout = const Duration(minutes: 5),
}) async {
final HttpServer server;
try {
server = await HttpServer.bind(host, port);
} on SocketException catch (e) {
throw LoopbackBindException('Could not bind $host:$port: ${e.message}');
}
final completer = Completer<Uri>();
late StreamSubscription<HttpRequest> sub;
sub = server.listen((req) async {
if (req.uri.path != path) {
req.response.statusCode = HttpStatus.notFound;
await req.response.close();
return;
}
// Flush the response BEFORE completing the completer — the caller's
// `finally` block forcibly tears down the socket, which would
// otherwise truncate the response body and leave the browser showing
// a broken page despite auth succeeding.
await _respondHtml(req.response, _pageHtml);
if (req.uri.queryParameters.isNotEmpty && !completer.isCompleted) {
completer.complete(req.uri);
}
});
try {
return await completer.future.timeout(timeout);
} finally {
await sub.cancel();
await server.close(force: true);
}
}
/// Build the redirect URI callers should register with the OAuth provider.
static String redirectUri(String path) => 'http://$host:$port$path';
/// Open [authorizeUri] in the external browser and wait for the OAuth
/// provider's redirect to hit the loopback server. Returns `null` if the
/// redirect doesn't arrive within [timeout] (usually because the user
/// closed the browser).
///
/// Starts the listener BEFORE launching the browser so a fast redirect
/// can't race the bind.
static Future<Uri?> launchAndWait(
Uri authorizeUri, {
required String path,
Duration timeout = const Duration(minutes: 5),
}) async {
final callbackFuture = listenOnce(path: path, timeout: timeout);
await launchUrl(authorizeUri, mode: LaunchMode.externalApplication);
try {
return await callbackFuture;
} on TimeoutException {
return null;
}
}
static Future<void> _respondHtml(HttpResponse res, String html) async {
try {
res
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.html
..write(html);
await res.close();
} catch (e) {
appLogger.d('LoopbackAuthServer: response write failed', error: e);
}
}
/// One page for all three flows. Code-grant and polling flows (MAL, Simkl)
/// render it as a plain success page. Implicit-grant flows (AniList) put
/// the access token in the URL fragment — the inline script in `<head>`
/// rewrites that into a query string and redirects before the body paints,
/// so users never see a success page flash before the real capture.
static const String _pageHtml = '<!doctype html>'
'<meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>Signed in</title>'
'<script>'
'if(location.hash&&location.hash.length>1){'
'location.replace(location.pathname+"?"+location.hash.substring(1));'
'}'
'</script>'
'<style>'
'html,body{margin:0;height:100%}'
'body{display:flex;flex-direction:column;align-items:center;justify-content:center;'
'font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}'
'@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}'
'.check{width:72px;height:72px;margin-bottom:20px}'
'h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}'
'p{margin:0;opacity:.7;font-size:.95rem}'
'</style>'
'<body>'
'<svg class="check" viewBox="0 0 24 24">'
'<circle cx="12" cy="12" r="10" fill="#22c55e"/>'
'<path d="M7 12.5l3 3 7-7" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
'</svg>'
'<h2>Signed in to Plezy</h2>'
'<p>You can close this tab and return to the app.</p>'
'</body>';
}
class LoopbackBindException implements Exception {
final String message;
const LoopbackBindException(this.message);
@override
String toString() => 'LoopbackBindException: $message';
}
+24 -64
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
@@ -8,71 +7,39 @@ import '../../../utils/app_logger.dart';
import '../../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../../utils/platform_http_client_io.dart'
as platform;
import '../loopback_auth_server.dart';
import '../oauth_proxy_client.dart';
import 'mal_constants.dart';
import 'mal_session.dart';
/// MyAnimeList OAuth 2.0 PKCE flow via RFC 8252 loopback redirect.
/// MyAnimeList authentication.
///
/// **Quirk**: MAL requires `code_challenge_method=plain` — it rejects `S256`
/// despite RFC 7636.
/// New sessions come from the Plezy relay's OAuth proxy (PKCE is server-side).
/// Refreshes are direct public-client calls against MAL's token endpoint —
/// no proxy needed because refresh requires no redirect.
class MalAuthService {
static const String _callbackPath = '/mal-oauth';
final OAuthProxyClient _proxy;
final http.Client _http;
MalAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
MalAuthService({OAuthProxyClient? proxy, http.Client? httpClient})
: _proxy = proxy ?? OAuthProxyClient(),
_http = httpClient ?? platform.createPlatformClient();
void dispose() => _http.close();
void dispose() {
_proxy.dispose();
_http.close();
}
/// Drive the full flow: build the authorize URL, start a loopback server
/// for the redirect, launch the browser, exchange the code for tokens.
/// Returns `null` if the user closes the browser before completing.
Future<MalSession?> authorize() async {
final verifier = _randomVerifier();
final state = _randomVerifier(length: 16);
final redirectUri = LoopbackAuthServer.redirectUri(_callbackPath);
final authorizeUri = Uri.parse(MalConstants.authorizeUrl).replace(
queryParameters: {
'response_type': 'code',
'client_id': MalConstants.clientId,
'code_challenge': verifier, // plain method → challenge == verifier
'code_challenge_method': 'plain',
'redirect_uri': redirectUri,
'state': state,
},
);
final callback = await LoopbackAuthServer.launchAndWait(authorizeUri, path: _callbackPath);
if (callback == null) return null;
final code = callback.queryParameters['code'];
final returnedState = callback.queryParameters['state'];
if (code == null) {
throw MalAuthFlowException('MAL redirect missing code: $callback');
}
if (returnedState != state) {
throw const MalAuthFlowException('MAL state mismatch (possible CSRF)');
}
final res = await _http
.post(
Uri.parse(MalConstants.tokenUrl),
body: {
'client_id': MalConstants.clientId,
'code': code,
'code_verifier': verifier,
'grant_type': 'authorization_code',
'redirect_uri': redirectUri,
},
)
.timeout(const Duration(seconds: 20));
if (res.statusCode != 200) {
throw MalAuthFlowException('Token exchange failed: HTTP ${res.statusCode}: ${res.body}');
}
return MalSession.fromTokenResponse(json.decode(res.body) as Map<String, dynamic>);
/// Drive the full flow. Invokes [onCodeReady] with the QR URL once the
/// session is created, then long-polls for tokens. Returns null on cancel.
Future<MalSession?> authorize({
required void Function(OAuthProxyStart) onCodeReady,
bool Function()? shouldCancel,
}) async {
final start = await _proxy.start('mal');
onCodeReady(start);
final result = await _proxy.poll(start.session, shouldCancel: shouldCancel);
if (result == null) return null;
return MalSession.fromProxyResult(result);
}
Future<MalSession> refresh(MalSession current) async {
@@ -94,13 +61,6 @@ class MalAuthService {
final fresh = MalSession.fromTokenResponse(json.decode(res.body) as Map<String, dynamic>);
return fresh.copyWith(username: current.username);
}
/// MAL requires 43128 chars from the unreserved URL-safe set.
String _randomVerifier({int length = 64}) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final rand = Random.secure();
return List.generate(length, (_) => alphabet[rand.nextInt(alphabet.length)]).join();
}
}
class MalAuthFlowException implements Exception {
+5 -11
View File
@@ -1,21 +1,15 @@
/// Bundled MyAnimeList API credentials and endpoints.
/// Bundled MyAnimeList API endpoints and public client ID.
///
/// Register at https://myanimelist.net/apiconfig — "App type: Other", redirect
/// URI `http://127.0.0.1:53682/mal-oauth` (RFC 8252 loopback). PKCE-only
/// (no client secret).
///
/// **MAL quirk**: `code_challenge_method` must be `plain` — MAL rejects `S256`
/// despite RFC 7636. See [MalAuthService.authorize].
/// The authorize flow lives in the Plezy relay's OAuth proxy; see
/// `lib/services/trackers/oauth_proxy_client.dart`. Only the refresh path
/// (public-client, no redirect) calls MAL directly from the device.
class MalConstants {
MalConstants._();
static const String clientId = '463b1c92992505e4bdfcef6aab3aedbe';
static const String apiBase = 'https://api.myanimelist.net/v2';
static const String oauthBase = 'https://myanimelist.net/v1/oauth2';
static const String authorizeUrl = '$oauthBase/authorize';
static const String tokenUrl = '$oauthBase/token';
static const String tokenUrl = 'https://myanimelist.net/v1/oauth2/token';
static Map<String, String> headers({String? accessToken}) => {
'Accept': 'application/json',
@@ -1,5 +1,7 @@
import 'dart:convert';
import '../oauth_proxy_client.dart';
/// Immutable MyAnimeList OAuth session.
///
/// Access tokens expire in ~31 days. Refresh token rotates with each refresh
@@ -60,6 +62,19 @@ class MalSession {
);
}
/// Build a session from an OAuth-proxy result. MAL's refresh_token is
/// required for the 31-day refresh loop.
factory MalSession.fromProxyResult(OAuthProxyResult r) {
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expiresIn = r.expiresIn ?? 31 * 24 * 60 * 60;
return MalSession(
accessToken: r.accessToken,
refreshToken: r.refreshToken ?? '',
expiresAt: createdAt + expiresIn,
createdAt: createdAt,
);
}
String encode() => json.encode(toJson());
static MalSession decode(String raw) => MalSession.fromJson(json.decode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,121 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../utils/app_logger.dart';
import '../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../utils/platform_http_client_io.dart'
as platform;
import '../../watch_together/services/watch_together_peer_service.dart';
/// Client for the Plezy relay's `/auth/*` OAuth proxy.
///
/// The proxy drives the full authorization-code flow server-side: device calls
/// [start] to get a QR URL, user scans on a phone to complete auth, device
/// long-polls [poll] until tokens arrive. No local HTTP listener or custom URL
/// scheme is required — works identically on TVs without a browser.
class OAuthProxyClient {
/// Public base URL of the Plezy relay; colocated with Watch Together.
static String get baseUrl => WatchTogetherPeerService.defaultBaseUrl;
final http.Client _http;
OAuthProxyClient({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
void dispose() => _http.close();
/// POST /auth/start — register a new session. Returns a handle including the
/// URL to display as a QR code for the phone scan.
Future<OAuthProxyStart> start(String service) async {
final res = await _http
.post(
Uri.parse('$baseUrl/auth/start'),
headers: {'Content-Type': 'application/json'},
body: json.encode({'service': service}),
)
.timeout(const Duration(seconds: 15));
if (res.statusCode != 200) {
throw OAuthProxyException('start failed: HTTP ${res.statusCode}: ${res.body}');
}
final body = json.decode(res.body) as Map<String, dynamic>;
return OAuthProxyStart(
session: body['session'] as String,
url: body['url'] as String,
expiresIn: (body['expiresIn'] as num).toInt(),
);
}
/// Long-poll /auth/result?session=X until a completion event arrives.
///
/// Returns null if [shouldCancel] flips true before a result arrives. Throws
/// [OAuthProxyException] on unrecoverable errors (session gone, upstream
/// failure). The server holds each request for up to 50 s; 204 responses are
/// retried transparently.
Future<OAuthProxyResult?> poll(String session, {bool Function()? shouldCancel}) async {
final uri = Uri.parse('$baseUrl/auth/result').replace(queryParameters: {'session': session});
while (true) {
if (shouldCancel?.call() ?? false) return null;
final http.Response res;
try {
res = await _http.get(uri).timeout(const Duration(seconds: 65));
} on TimeoutException {
continue;
} catch (e) {
appLogger.d('oauth proxy: poll transient error', error: e);
await Future<void>.delayed(const Duration(seconds: 2));
continue;
}
if (res.statusCode == 204) continue; // server-side timeout, retry
if (res.statusCode == 410) {
throw const OAuthProxyException('Session expired or already used');
}
if (res.statusCode != 200) {
throw OAuthProxyException('poll failed: HTTP ${res.statusCode}: ${res.body}');
}
final body = json.decode(res.body) as Map<String, dynamic>;
if (body['error'] != null) {
final err = body['error'] as String;
if (err == 'access_denied') return null; // user cancelled in browser
throw OAuthProxyException('Upstream auth failed: $err');
}
return OAuthProxyResult(
accessToken: body['accessToken'] as String,
refreshToken: body['refreshToken'] as String?,
expiresIn: (body['expiresIn'] as num?)?.toInt(),
);
}
}
}
class OAuthProxyStart {
/// Opaque session token. Include in subsequent polls.
final String session;
/// URL to render as a QR code and open in a browser. The phone scans it,
/// triggering the upstream OAuth flow.
final String url;
/// Session TTL in seconds. After this, polls will 410 and the user must
/// restart.
final int expiresIn;
const OAuthProxyStart({required this.session, required this.url, required this.expiresIn});
}
class OAuthProxyResult {
final String accessToken;
final String? refreshToken;
final int? expiresIn;
const OAuthProxyResult({required this.accessToken, this.refreshToken, this.expiresIn});
}
class OAuthProxyException implements Exception {
final String message;
const OAuthProxyException(this.message);
@override
String toString() => 'OAuthProxyException: $message';
}
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
@@ -9,21 +8,16 @@ import '../../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../../utils/platform_http_client_io.dart'
as platform;
import '../device_code_poller.dart' as poller;
import '../loopback_auth_server.dart';
import '../oauth_proxy_client.dart';
import 'simkl_constants.dart';
/// Simkl OAuth PIN (device-code) flow.
///
/// `GET /oauth/pin?client_id=...&redirect=http://127.0.0.1:53682/simkl-oauth`
/// returns a PIN the user enters at https://simkl.com/pin. After entry Simkl
/// redirects the browser to our loopback callback (where we show a friendly
/// "close this tab" page). The app polls `/oauth/pin/<user_code>?client_id=...`
/// until `result == "OK"`.
/// `GET /oauth/pin?client_id=...&redirect=<success page>` returns a PIN the
/// user enters at https://simkl.com/pin. After entry Simkl redirects the
/// browser to the relay's static "signed in" page. The app polls
/// `/oauth/pin/<user_code>?client_id=...` until `result == "OK"`.
class SimklAuthService {
/// Redirect path Simkl bounces the browser to after PIN entry. Must match
/// the URL registered at simkl.com/settings/developer for this client.
static const String _callbackPath = '/simkl-oauth';
final http.Client _http;
SimklAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
@@ -34,7 +28,7 @@ class SimklAuthService {
final uri = Uri.parse(SimklConstants.pinUrl).replace(
queryParameters: {
'client_id': SimklConstants.clientId,
'redirect': LoopbackAuthServer.redirectUri(_callbackPath),
'redirect': '${OAuthProxyClient.baseUrl}/auth/done',
},
);
final res = await _http.get(uri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15));
@@ -42,25 +36,13 @@ class SimklAuthService {
throw SimklAuthFlowException('PIN request failed: HTTP ${res.statusCode}: ${res.body}');
}
final body = json.decode(res.body) as Map<String, dynamic>;
final expiresIn = (body['expires_in'] as num?)?.toInt() ?? 900;
// Serve the "close this tab" success page when Simkl redirects the
// browser back after PIN entry. Fire-and-forget — the PIN poll captures
// the token; this listener is purely cosmetic.
unawaited(
LoopbackAuthServer.listenOnce(
path: _callbackPath,
timeout: Duration(seconds: expiresIn),
).catchError((Object _) => Uri()),
);
return DeviceCode(
deviceCode: body['device_code'] as String,
userCode: body['user_code'] as String,
verificationUrl: body['verification_url'] as String? ?? SimklConstants.verificationUrl,
// Simkl doesn't expose a prefilled URL; the user manually enters the code.
verificationUrlComplete: null,
expiresIn: expiresIn,
expiresIn: (body['expires_in'] as num?)?.toInt() ?? 900,
interval: (body['interval'] as num?)?.toInt() ?? 5,
);
}