mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
app: use the shared github_device_auth package
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
parent
b621ccaed0
commit
7a491d9d91
@ -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<GitHubMirrorScreen> {
|
||||
final token = await showDialog<String>(
|
||||
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<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/<id>/food_log.json`
|
||||
|
||||
@ -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/`.
|
||||
|
||||
@ -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<String, dynamic> 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<void> Function(Duration)? delay,
|
||||
}) : _http = httpClient ?? http.Client(),
|
||||
// Indirection so tests can skip real waiting between polls.
|
||||
_delay = delay ?? Future<void>.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<void> Function(Duration) _delay;
|
||||
|
||||
static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
|
||||
/// Step 1: ask GitHub for a device + user code.
|
||||
Future<DeviceCodeResponse> 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<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<String> 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<String, dynamic>;
|
||||
|
||||
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();
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<void>.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<DeviceAuthException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<DeviceAuthException>().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<DeviceAuthException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<DeviceAuthException>().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');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user