utils/github_device_auth
Krzysztof kuhy Rudnicki 922fb36208
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
design_system tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
crdt_sync_dart: latest stable + exact pins, 100% coverage, CI for both crdt_sync packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:32:24 +02:00
..
lib github_device_auth: retry transient network failures (v0.3.0) 2026-08-15 16:05:12 +02:00
scripts crdt_sync_dart: latest stable + exact pins, 100% coverage, CI for both crdt_sync packages 2026-08-28 12:32:24 +02:00
test dart: split ten oversized test files by group 2026-08-22 14:54:35 +02:00
.gitignore github_device_auth: extract the GitHub device flow from four apps 2026-08-15 15:57:19 +02:00
analysis_options.yaml github_device_auth: extract the GitHub device flow from four apps 2026-08-15 15:57:19 +02:00
pubspec.lock github_device_auth: extract the GitHub device flow from four apps 2026-08-15 15:57:19 +02:00
pubspec.yaml github_device_auth: retry transient network failures (v0.3.0) 2026-08-15 16:05:12 +02:00
README.md github_device_auth: retry transient network failures (v0.3.0) 2026-08-15 16:05:12 +02:00

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

dependencies:
  github_device_auth:
    git:
      url: https://github.com/kuhyx/utils
      ref: github_device_auth-v0.3.0
      path: github_device_auth
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

flutter pub get
flutter test              # 24 tests
flutter test --coverage   # 100% of lines
flutter analyze           # very_good_analysis, clean