github_device_auth: configurable endpoints for web builds (v0.2.0)
Some checks are pending
github_device_auth tests / test (push) Waiting to run
palette drift / palette (push) Waiting to run

diet-guard's copy was not cosmetic drift from todo's: it takes the
device-code and token URLs as parameters, because GitHub's device-flow
endpoints send no CORS headers and its desktop **web** build has to route
both through a local proxy (`github_client_factory_web.dart` passes them).
Shipping only todo's hardcoded version would have broken that build the
moment diet-guard adopted the package.

So the package takes the more capable variant: `deviceCodeUrl`/`tokenUrl`
default to GitHub's own URLs, leaving every other consumer unaffected, and
`githubDeviceCodeUrl`/`githubTokenUrl` are exported so callers can
reference the defaults rather than retyping the literals.

23 tests (two new ones pin both the defaults and an override actually
being used for the request), 100% line coverage, clean analyze.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 16:01:21 +02:00
parent 795a8eae62
commit 14a50b44e2
5 changed files with 98 additions and 8 deletions

View File

@ -34,7 +34,7 @@ dependencies:
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.1.0
ref: github_device_auth-v0.2.0
path: github_device_auth
```
@ -48,6 +48,15 @@ final token = await showDialog<String>(
auth.close();
```
## Configurable endpoints (web builds)
`deviceCodeUrl` / `tokenUrl` default to GitHub's own URLs, which is right for
mobile and desktop. A **web** build must point both at a local proxy instead:
GitHub's device-flow endpoints send no CORS headers, so a page cannot call
them at all. diet-guard's desktop web build does exactly that, which is why
this knob exists — it was in diet-guard's local copy and would have been lost
had the package shipped only todo's simpler version.
## One behaviour change from the extracted copies
The dialog now catches `on Object`, not `on Exception`.
@ -63,7 +72,7 @@ pins it with a mock that throws an `Error` rather than an `Exception`.
```bash
flutter pub get
flutter test # 20 tests
flutter test # 23 tests
flutter test --coverage # 100% of lines
flutter analyze # very_good_analysis, clean
```

View File

@ -18,5 +18,10 @@
library;
export 'src/device_auth.dart'
show DeviceAuthException, DeviceCodeResponse, GitHubDeviceAuth;
show
DeviceAuthException,
DeviceCodeResponse,
GitHubDeviceAuth,
githubDeviceCodeUrl,
githubTokenUrl;
export 'src/device_code_dialog.dart' show DeviceCodeDialog;

View File

@ -3,6 +3,12 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
/// GitHub's device-code endpoint. The default for [GitHubDeviceAuth].
const githubDeviceCodeUrl = 'https://github.com/login/device/code';
/// GitHub's device-flow token endpoint. The default for [GitHubDeviceAuth].
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 {
@ -72,6 +78,8 @@ class GitHubDeviceAuth {
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(),
@ -84,17 +92,27 @@ class GitHubDeviceAuth {
/// OAuth scope requested. `repo` is required for private-repo contents.
final String scope;
/// Endpoint asked for a device + user code.
///
/// Defaults to GitHub's own URL, which is right for mobile and desktop.
/// A **web** build must point this (and [tokenUrl]) at a local proxy
/// instead: GitHub's device-flow endpoints send no CORS headers, so a page
/// cannot call them at all. diet-guard's desktop web build does exactly
/// that — see its `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 _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),
Uri.parse(deviceCodeUrl),
headers: const {'Accept': 'application/json'},
body: {'client_id': clientId, 'scope': scope},
);
@ -118,7 +136,7 @@ class GitHubDeviceAuth {
while (DateTime.now().isBefore(deadline)) {
await _delay(Duration(seconds: intervalSeconds));
final res = await _http.post(
Uri.parse(_tokenUrl),
Uri.parse(tokenUrl),
headers: const {'Accept': 'application/json'},
body: {
'client_id': clientId,

View File

@ -4,7 +4,7 @@ description: >-
modal that drives it. Extracted from four apps that each carried a copy,
two of them byte-identical.
publish_to: 'none'
version: 0.1.0
version: 0.2.0
environment:
sdk: ^3.12.2

View File

@ -80,6 +80,39 @@ void main() {
expect(device.userCode, 'UC');
});
test('defaults to GitHub s own endpoints', () {
final auth = GitHubDeviceAuth(clientId: 'cid')..close();
expect(auth.deviceCodeUrl, githubDeviceCodeUrl);
expect(auth.tokenUrl, githubTokenUrl);
});
test('honours an overridden endpoint', () async {
// diet-guard's desktop web build routes both endpoints through a local
// proxy, because GitHub's device-flow endpoints send no CORS headers
// and a page cannot call them at all.
late Uri seen;
final auth = GitHubDeviceAuth(
clientId: 'cid',
deviceCodeUrl: 'http://127.0.0.1:9/gh/auth/device/start',
httpClient: MockClient((req) async {
seen = req.url;
return http.Response(
jsonEncode({
'device_code': 'dc',
'user_code': 'UC',
'verification_uri': 'u',
}),
200,
);
}),
delay: noDelay,
);
await auth.requestDeviceCode();
expect(seen.toString(), 'http://127.0.0.1:9/gh/auth/device/start');
});
test('throws with the status code on a non-200', () async {
final auth = authWith(MockClient((_) async => http.Response('bad', 503)));
await expectLater(
@ -228,4 +261,29 @@ void main() {
final auth = GitHubDeviceAuth(clientId: 'cid')..close();
expect(auth.scope, 'repo');
});
test('polls the overridden token endpoint', () async {
late Uri seen;
final auth = GitHubDeviceAuth(
clientId: 'cid',
tokenUrl: 'http://127.0.0.1:9/gh/auth/device/poll',
httpClient: MockClient((req) async {
seen = req.url;
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
}),
delay: noDelay,
);
await auth.pollForToken(
const DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
expiresIn: 60,
),
);
expect(seen.toString(), 'http://127.0.0.1:9/gh/auth/device/poll');
});
}