diff --git a/lib/sync/github_device_auth.dart b/lib/sync/github_device_auth.dart deleted file mode 100644 index f22ca5c..0000000 --- a/lib/sync/github_device_auth.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -/// First-stage response of the GitHub OAuth Device Flow: the code the user -/// types on github.com and the URL to type it into. -class DeviceCodeResponse { - /// Creates a [DeviceCodeResponse] from its stored fields. - const DeviceCodeResponse({ - required this.deviceCode, - required this.userCode, - required this.verificationUri, - required this.interval, - required this.expiresIn, - }); - - /// Builds a [DeviceCodeResponse] from GitHub's device-code JSON response. - factory DeviceCodeResponse.fromJson(Map json) { - return DeviceCodeResponse( - deviceCode: json['device_code'] as String, - userCode: json['user_code'] as String, - verificationUri: json['verification_uri'] as String, - interval: (json['interval'] as int?) ?? 5, - expiresIn: (json['expires_in'] as int?) ?? 900, - ); - } - - /// Opaque code the client polls with (not shown to the user). - final String deviceCode; - - /// Short code the user enters on the verification page. - final String userCode; - - /// Page the user opens to enter [userCode] (github.com/login/device). - final String verificationUri; - - /// Minimum seconds to wait between polls. - final int interval; - - /// Seconds until [deviceCode] expires. - final int expiresIn; -} - -/// Raised when the device-flow authorization fails or is declined. -class DeviceAuthException implements Exception { - /// Creates a [DeviceAuthException] from GitHub's [code] and [message]. - DeviceAuthException(this.code, this.message); - - /// GitHub error code, e.g. `access_denied`, `expired_token`. - final String code; - - /// Human-readable description of [code]. - final String message; - - @override - String toString() => 'DeviceAuthException($code): $message'; -} - -/// Implements the GitHub OAuth **Device Flow** so the user can authorize the -/// app by visiting a URL and entering a short code — no token pasting. -/// -/// Device flow needs only a public `client_id` (no client secret), which -/// makes it safe for a distributed app. The resulting access token is then -/// used exactly like a PAT by [GitHubClient]. -/// -/// References: -/// - POST https://github.com/login/device/code -/// - POST https://github.com/login/oauth/access_token -class GitHubDeviceAuth { - /// Creates a [GitHubDeviceAuth] for the given OAuth App [clientId]. - GitHubDeviceAuth({ - required this.clientId, - this.scope = 'repo', - http.Client? httpClient, - Future Function(Duration)? delay, - }) : _http = httpClient ?? http.Client(), - // Indirection so tests can skip real waiting between polls. - _delay = delay ?? Future.delayed; - - /// The GitHub OAuth App's public client id. - final String clientId; - - /// OAuth scope requested. `repo` is required for private-repo contents. - final String scope; - - final http.Client _http; - final Future Function(Duration) _delay; - - static const _deviceCodeUrl = 'https://github.com/login/device/code'; - static const _tokenUrl = 'https://github.com/login/oauth/access_token'; - static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code'; - - /// Step 1: ask GitHub for a device + user code. - Future requestDeviceCode() async { - final res = await _http.post( - Uri.parse(_deviceCodeUrl), - headers: const {'Accept': 'application/json'}, - body: {'client_id': clientId, 'scope': scope}, - ); - if (res.statusCode != 200) { - throw DeviceAuthException('http_${res.statusCode}', res.body); - } - return DeviceCodeResponse.fromJson( - jsonDecode(res.body) as Map, - ); - } - - /// Step 2: poll until the user authorizes, returning the access token. - /// - /// Honors GitHub's pacing protocol: `authorization_pending` keeps polling, - /// `slow_down` increases the interval, and terminal errors throw a - /// [DeviceAuthException]. - Future pollForToken(DeviceCodeResponse device) async { - var intervalSeconds = device.interval; - final deadline = DateTime.now().add(Duration(seconds: device.expiresIn)); - - while (DateTime.now().isBefore(deadline)) { - await _delay(Duration(seconds: intervalSeconds)); - final res = await _http.post( - Uri.parse(_tokenUrl), - headers: const {'Accept': 'application/json'}, - body: { - 'client_id': clientId, - 'device_code': device.deviceCode, - 'grant_type': _grantType, - }, - ); - final json = jsonDecode(res.body) as Map; - - final token = json['access_token'] as String?; - if (token != null) return token; - - switch (json['error'] as String?) { - case 'authorization_pending': - continue; // User has not finished authorizing yet. - case 'slow_down': - // GitHub asks us to back off; obey its new interval. - intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5; - case final String error: - throw DeviceAuthException( - error, - (json['error_description'] as String?) ?? error, - ); - case null: - throw DeviceAuthException('unknown', 'Unexpected response: $json'); - } - } - throw DeviceAuthException('expired_token', 'Device code expired.'); - } - - /// Closes the underlying HTTP client. Call once when done polling. - void close() => _http.close(); -} diff --git a/lib/ui/device_code_dialog.dart b/lib/ui/device_code_dialog.dart deleted file mode 100644 index 0021cee..0000000 --- a/lib/ui/device_code_dialog.dart +++ /dev/null @@ -1,110 +0,0 @@ -/// The GitHub device-flow dialog: shows the user code, opens the page, and -/// polls until the grant is approved. -/// -/// Split out of `github_mirror_screen.dart` for file size; public only -/// because Dart privacy is library-scoped. -library; - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:todo/sync/github_device_auth.dart'; -import 'package:url_launcher/url_launcher.dart'; - -/// Dialog shown during the device flow: displays the user code, opens the -/// verification page, and polls until authorized — popping the token (or -/// null if cancelled / failed). -class DeviceCodeDialog extends StatefulWidget { - /// Creates the dialog for a pending device-flow grant. - const DeviceCodeDialog({ - required this.device, - required this.auth, - super.key, - }); - - /// The pending grant: user code, verification URL, poll interval. - final DeviceCodeResponse device; - - /// Auth client used to poll for the approved token. - final GitHubDeviceAuth auth; - - @override - State createState() => _DeviceCodeDialogState(); -} - -class _DeviceCodeDialogState extends State { - String? _error; - - @override - void initState() { - super.initState(); - unawaited(_poll()); - } - - Future _poll() async { - try { - final token = await widget.auth.pollForToken(widget.device); - if (mounted) Navigator.of(context).pop(token); - } on Exception catch (e) { - if (mounted) setState(() => _error = '$e'); - } - } - - Future _openPage() async { - await Clipboard.setData(ClipboardData(text: widget.device.userCode)); - await launchUrl( - Uri.parse(widget.device.verificationUri), - mode: LaunchMode.externalApplication, - ); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Authorize on GitHub'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Enter this code on GitHub:'), - const SizedBox(height: 8), - SelectableText( - widget.device.userCode, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - if (_error == null) - const Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - SizedBox(width: 12), - Expanded(child: Text('Waiting for authorization…')), - ], - ) - else - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton.icon( - onPressed: _openPage, - icon: const Icon(Icons.open_in_new), - label: const Text('Open GitHub & copy code'), - ), - ], - ); - } -} diff --git a/lib/ui/github_mirror_screen.dart b/lib/ui/github_mirror_screen.dart index cfab411..e23428b 100644 --- a/lib/ui/github_mirror_screen.dart +++ b/lib/ui/github_mirror_screen.dart @@ -2,14 +2,13 @@ import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; +import 'package:github_device_auth/github_device_auth.dart'; import 'package:http/http.dart' as http; import 'package:todo/analytics/analytics_service.dart'; import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note_repository.dart'; -import 'package:todo/sync/github_device_auth.dart'; import 'package:todo/sync/run_sync.dart'; import 'package:todo/sync/sync_settings.dart'; -import 'package:todo/ui/device_code_dialog.dart'; part 'github_mirror_screen_widget.dart'; diff --git a/pubspec.lock b/pubspec.lock index ab8d5c5..e2ac9be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -322,6 +322,15 @@ packages: description: flutter source: sdk version: "0.0.0" + github_device_auth: + dependency: "direct main" + description: + path: github_device_auth + ref: "github_device_auth-v0.3.0" + resolved-ref: "254e3b00c7f41a54c588ec32564017f96f328252" + url: "https://github.com/kuhyx/utils" + source: git + version: "0.3.0" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f9fe5c4..f9eb941 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.24+1 +version: 1.0.25+1 environment: sdk: ^3.12.2 @@ -55,6 +55,11 @@ dependencies: # libsecret) instead of plaintext SharedPreferences. flutter_secure_storage: ^10.3.1 + github_device_auth: + git: + url: https://github.com/kuhyx/utils + ref: github_device_auth-v0.3.0 + path: github_device_auth # One-tap sign-in for the sync account. Lives only in the app: `crdt_sync` # stays pure Dart so it keeps working on Linux desktop and headless, where # this plugin does not exist. See lib/sync/google_sign_in_backend.dart. diff --git a/test/github_device_auth_test.dart b/test/github_device_auth_test.dart deleted file mode 100644 index 88be7a4..0000000 --- a/test/github_device_auth_test.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:todo/sync/github_device_auth.dart'; - -/// Builds an auth instance whose polls resolve instantly (no real waiting). -GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth( - clientId: 'test-client-id', - httpClient: client, - delay: (_) => Future.value(), -); - -const _device = DeviceCodeResponse( - deviceCode: 'dev-123', - userCode: 'WXYZ-1234', - verificationUri: 'https://github.com/login/device', - interval: 1, - expiresIn: 900, -); - -void main() { - test('requestDeviceCode parses the device + user code', () async { - final client = MockClient((req) async { - expect(req.url.toString(), contains('login/device/code')); - expect(req.bodyFields['client_id'], 'test-client-id'); - expect(req.bodyFields['scope'], 'repo'); - return http.Response( - jsonEncode({ - 'device_code': 'dev-123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 5, - 'expires_in': 900, - }), - 200, - ); - }); - - final res = await authWith(client).requestDeviceCode(); - expect(res.deviceCode, 'dev-123'); - expect(res.userCode, 'WXYZ-1234'); - expect(res.verificationUri, 'https://github.com/login/device'); - }); - - test('pollForToken returns the token after authorization_pending', () async { - var calls = 0; - final client = MockClient((req) async { - calls++; - // Pending on the first two polls, then success. - if (calls < 3) { - return http.Response( - jsonEncode({'error': 'authorization_pending'}), - 200, - ); - } - return http.Response( - jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}), - 200, - ); - }); - - final token = await authWith(client).pollForToken(_device); - expect(token, 'gho_abc'); - expect(calls, 3); - }); - - test('pollForToken obeys slow_down and still succeeds', () async { - var calls = 0; - final client = MockClient((req) async { - calls++; - if (calls == 1) { - return http.Response( - jsonEncode({'error': 'slow_down', 'interval': 1}), - 200, - ); - } - return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200); - }); - - final token = await authWith(client).pollForToken(_device); - expect(token, 'gho_xyz'); - }); - - test('pollForToken throws on access_denied', () async { - final client = MockClient( - (req) async => http.Response( - jsonEncode({'error': 'access_denied', 'error_description': 'no'}), - 200, - ), - ); - - expect( - () => authWith(client).pollForToken(_device), - throwsA( - isA().having( - (e) => e.code, - 'code', - 'access_denied', - ), - ), - ); - }); - - test('pollForToken honors slow_down then succeeds', () async { - var calls = 0; - final client = MockClient((req) async { - calls++; - if (calls == 1) { - return http.Response( - jsonEncode({'error': 'slow_down', 'interval': 0}), - 200, - ); - } - return http.Response(jsonEncode({'access_token': 'gho_ok'}), 200); - }); - - expect(await authWith(client).pollForToken(_device), 'gho_ok'); - expect(calls, 2); - }); - - test('pollForToken throws on an unexpected response shape', () async { - final client = MockClient( - (_) async => http.Response(jsonEncode({'foo': 'bar'}), 200), - ); - expect( - () => authWith(client).pollForToken(_device), - throwsA(isA()), - ); - }); - - test('pollForToken throws when the device code has expired', () async { - final client = MockClient( - (_) async => http.Response(jsonEncode({'access_token': 'x'}), 200), - ); - const expired = DeviceCodeResponse( - deviceCode: 'd', - userCode: 'u', - verificationUri: 'v', - interval: 1, - expiresIn: 0, // deadline is now → loop body never runs - ); - expect( - () => authWith(client).pollForToken(expired), - throwsA( - isA().having( - (e) => e.code, - 'code', - 'expired_token', - ), - ), - ); - }); - - test('defaults to a real http client and delay when none are injected', () { - // Omitting httpClient/delay exercises the `?? http.Client()` and - // `?? Future.delayed` constructor fallbacks; no request is made. - final auth = GitHubDeviceAuth(clientId: 'c'); - addTearDown(auth.close); - }); -}