mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 14:07:49 +01:00
Use the shared github_device_auth package
todo was the donor for both halves of the GitHub device flow -- its
`lib/sync/github_device_auth.dart` was byte-identical to home_inventory's,
and `lib/ui/device_code_dialog.dart` was the public version of the modal
diet-guard and wake-alarm each carried privately. All of it now lives in
`github_device_auth` in the kuhyx/utils monorepo.
The package is not a straight copy of what left here: it takes the more
capable variant of each divergence found across the four apps, so this app
gains two fixes it never had --
* `pollForToken` retries a transient `ClientException` instead of
failing, because GitHub can close the connection at the moment the
user authorizes (wake-alarm's fix)
* the dialog catches `on Object`, not `on Exception`, so an
`ArgumentError` surfaces as an error message instead of leaving the
dialog spinning forever
The local `test/github_device_auth_test.dart` is deleted; its cases live in
the package now, where they gate all four consumers. 300 tests pass and
`test/github_mirror_device_flow_test.dart` -- which drives the shared
dialog end to end -- is among them.
Deployed to the phone as versionCode 17 via phone_deploy.sh (signer
verified, `adb install -r`, no uninstall): launches with all 109 notes
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
f4272d01b9
commit
f46b65750e
@ -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<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] 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<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;
|
||||
|
||||
final http.Client _http;
|
||||
final Future<void> 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<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;
|
||||
|
||||
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();
|
||||
}
|
||||
@ -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<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,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';
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<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('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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user