From 7a491d9d91f34acdcc36c043cfa88283ca68da95 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 16:15:24 +0200 Subject: [PATCH] app: use the shared github_device_auth package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline `_DeviceCodeDialog` in `github_mirror_screen.dart` (88 lines) was byte-for-byte identical to wake-alarm's, and `services/github_device_auth.dart` was a fourth copy of the auth client. Both now come from `github_device_auth` in the kuhyx/utils monorepo. This app's copy was the one that mattered most to get right: it took the device-code and token URLs as parameters because GitHub's device-flow endpoints send no CORS headers, so the desktop **web** build has to route both through the local wrapper (`github_client_factory_web.dart` passes them). Extracting todo's simpler hardcoded version would have broken that build outright. The package carries the configurable endpoints instead, defaulting to GitHub's own URLs so the other three consumers are unaffected -- and `flutter build web` here is verified green, which is the check that would have caught it. The dialog also gains a real fix: `on Object` rather than `on Exception`, so an `ArgumentError` shows an error instead of leaving the dialog spinning on "Waiting for authorization…" forever. The unused `token_vault` import in the deleted client confirmed it had no app-specific behaviour left worth keeping. 548 tests pass, analyze clean, web build green, and the release APK is installed on the phone with meal history intact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno --- app/lib/screens/github_mirror_screen.dart | 94 +--------- .../services/github_client_factory_io.dart | 2 +- .../services/github_client_factory_web.dart | 2 +- app/lib/services/github_device_auth.dart | 175 ------------------ app/pubspec.lock | 9 + app/pubspec.yaml | 7 +- .../services/github_device_auth_test.dart | 175 ------------------ 7 files changed, 19 insertions(+), 445 deletions(-) delete mode 100644 app/lib/services/github_device_auth.dart delete mode 100644 app/test/services/github_device_auth_test.dart 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'); - }); -}