feat: oauth proxy for mal/anilist auth
This commit is contained in:
@@ -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';
|
||||
}
|
||||
@@ -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 43–128 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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user