diff --git a/app/lib/screens/github_mirror_screen.dart b/app/lib/screens/github_mirror_screen.dart index cca7a65..b558c3c 100644 --- a/app/lib/screens/github_mirror_screen.dart +++ b/app/lib/screens/github_mirror_screen.dart @@ -10,15 +10,13 @@ import 'dart:async'; import 'package:diet_guard_app/services/firebase_backend.dart'; import 'package:diet_guard_app/services/github_client_factory.dart'; -import 'package:diet_guard_app/services/github_device_auth.dart'; import 'package:diet_guard_app/services/sync_health.dart'; import 'package:diet_guard_app/services/sync_service.dart'; import 'package:diet_guard_app/services/sync_settings.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:github_device_auth/github_device_auth.dart'; import 'package:http/http.dart' as http; -import 'package:url_launcher/url_launcher.dart'; /// Screen for configuring and triggering the GitHub mirror sync. class GitHubMirrorScreen extends StatefulWidget { @@ -132,7 +130,7 @@ class _GitHubMirrorScreenState extends State { final token = await showDialog( context: context, barrierDismissible: false, - builder: (_) => _DeviceCodeDialog(device: device, auth: auth), + builder: (_) => DeviceCodeDialog(device: device, auth: auth), ); if (token != null && token.isNotEmpty) { setState(() { @@ -391,91 +389,3 @@ class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> { ); } } - -/// 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 { - const _DeviceCodeDialog({required this.device, required this.auth}); - - final DeviceCodeResponse device; - final GitHubDeviceAuth auth; - - @override - State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); -} - -class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { - 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/app/lib/services/github_client_factory_io.dart b/app/lib/services/github_client_factory_io.dart index 894aa50..c0bae37 100644 --- a/app/lib/services/github_client_factory_io.dart +++ b/app/lib/services/github_client_factory_io.dart @@ -2,8 +2,8 @@ library; import 'package:crdt_sync/crdt_sync.dart'; -import 'package:diet_guard_app/services/github_device_auth.dart'; import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:github_device_auth/github_device_auth.dart'; import 'package:http/http.dart' as http; /// This device's id in the `diet-guard-sync/devices//food_log.json` diff --git a/app/lib/services/github_client_factory_web.dart b/app/lib/services/github_client_factory_web.dart index 9fee01b..8fe2584 100644 --- a/app/lib/services/github_client_factory_web.dart +++ b/app/lib/services/github_client_factory_web.dart @@ -3,8 +3,8 @@ library; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/services/desktop_wrapper.dart'; -import 'package:diet_guard_app/services/github_device_auth.dart'; import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:github_device_auth/github_device_auth.dart'; import 'package:http/http.dart' as http; /// This device's id under `diet-guard-sync/devices/`. diff --git a/app/lib/services/github_device_auth.dart b/app/lib/services/github_device_auth.dart deleted file mode 100644 index ff980c3..0000000 --- a/app/lib/services/github_device_auth.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:diet_guard_app/services/token_vault.dart'; -import 'package:http/http.dart' as http; - -/// GitHub's device-code endpoint. -const githubDeviceCodeUrl = 'https://github.com/login/device/code'; - -/// GitHub's device-flow token endpoint. -const githubTokenUrl = 'https://github.com/login/oauth/access_token'; - -/// 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]. - const DeviceCodeResponse({ - required this.deviceCode, - required this.userCode, - required this.verificationUri, - required this.interval, - required this.expiresIn, - }); - - /// Parses GitHub's `POST /login/device/code` response body. - 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] for the given GitHub error [code]. - 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', - this.deviceCodeUrl = githubDeviceCodeUrl, - this.tokenUrl = githubTokenUrl, - 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; - - /// Endpoint asked for a device + user code. - /// - /// GitHub's own URL on Android. The desktop web build points both this and - /// [tokenUrl] at the local wrapper instead, because GitHub's device-flow - /// endpoints send no CORS headers and a page cannot call them at all (see - /// `desktop/github_proxy.dart`). - final String deviceCodeUrl; - - /// Endpoint polled for the access token. See [deviceCodeUrl]. - final String tokenUrl; - - final http.Client _http; - final Future Function(Duration) _delay; - - 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; - // The wrapper-proxied flow deliberately keeps the token server-side and - // answers with a bare acknowledgement instead (see [deviceCodeUrl]). - if (json['status'] == 'ok') return wrapperManagedToken; - - 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. - void close() => _http.close(); -} diff --git a/app/pubspec.lock b/app/pubspec.lock index 46662ac..23bfa49 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -257,6 +257,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" google_identity_services_web: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 1ca4013..fd41847 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.10+1 +version: 1.0.11+1 environment: sdk: ^3.12.2 @@ -21,6 +21,11 @@ dependencies: # One-tap sign-in for the sync account. App-side only: crdt_sync stays pure # Dart so it keeps working on Linux desktop and headless. + github_device_auth: + git: + url: https://github.com/kuhyx/utils + ref: github_device_auth-v0.3.0 + path: github_device_auth google_sign_in: ^7.2.0 http: ^1.6.0 idb_shim: ^2.9.6+2 diff --git a/app/test/services/github_device_auth_test.dart b/app/test/services/github_device_auth_test.dart deleted file mode 100644 index 4408030..0000000 --- a/app/test/services/github_device_auth_test.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'dart:convert'; - -import 'package:diet_guard_app/services/github_device_auth.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.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('requestDeviceCode throws on a non-200 response', () async { - final client = MockClient((_) async => http.Response('nope', 422)); - expect( - () => authWith(client).requestDeviceCode(), - throwsA(isA()), - ); - }); - - 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); - }); - - test('DeviceAuthException.toString includes code and message', () { - final e = DeviceAuthException('access_denied', 'no'); - expect(e.toString(), 'DeviceAuthException(access_denied): no'); - }); -}