utils/github_device_auth/README.md
Krzysztof kuhy Rudnicki 254e3b00c7 github_device_auth: retry transient network failures (v0.3.0)
wake-alarm's copy carried a fix the other three lacked: `pollForToken`
swallows a transient socket failure and keeps polling, because GitHub can
close the connection at the exact moment the user authorizes and a phone
can drop its network mid-poll. Without it, an approved grant is lost to a
blip and the user has to start the whole flow again.

Extracting only todo's version would have silently deleted that fix from
wake-alarm. Taking it into the package instead gives it to all four apps.

Catching `http.ClientException` is sufficient, and avoiding `dart:io` here
is deliberate: diet-guard's desktop **web** build imports this package, and
naming `SocketException` would break that compile. Checked rather than
assumed -- a probe against a dead port shows package:http raises
`_ClientSocketException`, which implements *both* `ClientException` and
`SocketException`, so the single catch covers IO and web alike.

24 tests, 100% line coverage, clean analyze.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 16:05:12 +02:00

92 lines
3.5 KiB
Markdown

# `github_device_auth`
GitHub OAuth **Device Flow** for kuhy's Flutter apps: the auth client and the
modal that drives it.
Device flow needs only a public `client_id` (no client secret), which is what
makes it safe for a distributed app. The resulting access token is then used
exactly like a PAT.
## Why this is its own package
Four apps each carried a copy of both halves:
| App | Client | Dialog |
| --- | --- | --- |
| todo | `lib/sync/github_device_auth.dart` (154) | `lib/ui/device_code_dialog.dart` (110) |
| home_inventory | `lib/sync/github_device_auth.dart` (154, **byte-identical** to todo's) | inline |
| diet-guard | `app/lib/services/github_device_auth.dart` (175) | inline (~84, **byte-identical** to wake-alarm's) |
| wake-alarm | `phone_app/lib/services/github_device_auth.dart` (166) | inline (~84) |
~650 lines, two pairs of them byte-for-byte identical.
**It is deliberately not part of `sync_settings_ui`.** That package is
Firebase-only by an explicit decision (2026-08-12), recorded with "do not
re-add a GitHub section without kuhy asking again". Folding a GitHub flow into
it would reverse that. The GitHub mirror in these apps is legacy-but-
load-bearing code, so it gets its own home rather than being merged into the
Firebase path — or deleted, which was never authorised.
## Use
```yaml
dependencies:
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.3.0
path: github_device_auth
```
```dart
final auth = GitHubDeviceAuth(clientId: '<oauth app client id>');
final device = await auth.requestDeviceCode();
final token = await showDialog<String>(
context: context,
builder: (_) => DeviceCodeDialog(device: device, auth: auth),
);
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.
## Transient-network retry
`pollForToken` retries on `ClientException` rather than failing. GitHub can
close the connection at the exact moment the user authorizes, and a phone can
drop its network mid-poll — losing an approved grant to a socket blip is a real
bug wake-alarm had already hit and fixed locally. The other three copies had
not, so they inherit the fix here.
`ClientException` alone is enough and `dart:io` is deliberately *not* imported:
this package is used by a web build, and `package:http` wraps a dead socket as
`_ClientSocketException`, which implements `ClientException` — verified, not
assumed.
## One behaviour change from the extracted copies
The dialog now catches `on Object`, not `on Exception`.
An `Error` is not an `Exception` in Dart, so `on Exception` let `ArgumentError`
(notably from URI parsing) escape `_poll()` — leaving the dialog spinning on
"Waiting for authorization…" forever with nothing shown to the user. This is
the same trap that silently killed the Firebase sync tick across these apps in
2026-08. Fixed once here rather than four times. `test/device_code_dialog_test.dart`
pins it with a mock that throws an `Error` rather than an `Exception`.
## Commands
```bash
flutter pub get
flutter test # 24 tests
flutter test --coverage # 100% of lines
flutter analyze # very_good_analysis, clean
```