Skip to content

Device-flow fallback for browserless logins (SSH, headless) - #33

Closed
jordanalexmeyer wants to merge 1 commit into
jam/cli-workos-connectfrom
jam/cli-device-flow
Closed

Device-flow fallback for browserless logins (SSH, headless)#33
jordanalexmeyer wants to merge 1 commit into
jam/cli-workos-connectfrom
jam/cli-device-flow

Conversation

@jordanalexmeyer

@jordanalexmeyer jordanalexmeyer commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Stacked on #32 (WorkOS Connect login adaptation).

Summary

  • extend login now automatically falls back to the OAuth device authorization flow (RFC 8628) when no usable local browser is detected: in an SSH session (SSH_CONNECTION/SSH_TTY), or on a Linux host with no DISPLAY/WAYLAND_DISPLAY. The loopback flow cannot work there — the browser would open on the wrong machine and the 127.0.0.1 redirect could never come back.
  • New --device flag forces the flow explicitly: the CLI prints a one-time code and a verification URL (using verification_uri_complete when the server provides it, so the code is prefilled), the user approves from a browser on any device, and the CLI polls the token endpoint until approval.
  • This matches what GitHub (gh auth login), AWS SSO, and Docker do for their browserless paths, while keeping loopback + PKCE as the default for desktop use (the gcloud/Azure/Heroku camp).

Implementation

  • internal/oauth/discovery.go: device_authorization_endpoint is now consumed from RFC 8414 metadata, host-pinned like the other endpoints. It is not given a hardcoded default — RFC 8628 defines no conventional path — so servers that don't advertise it simply keep the loopback-only behavior (including the auto-fallback case, which then prints the loopback URL as before).
  • internal/oauth/device.go (new): DeviceAuthorize (sends client_id + RFC 8707 resource) and PollDeviceToken (handles authorization_pending, backs off on slow_down, stops on terminal errors and on expires_in). User-visible strings from the server (user_code, verification URIs) are terminal-sanitized at the source.
  • internal/cli/login.go: loopback and device flows extracted into separate functions sharing the same completion path (store record, sid-aware revocation of the replaced grant, /me personalization). Friendly errors for access_denied / expired_token / timeout. Browser detection is injectable for tests.
  • Both flows produce identical oauth.Records, so refresh, whoami, and logout (/oauth/revoke-current) work unchanged for device-flow logins.

Verification

  • go test ./internal/oauth/ ./internal/cli/ green locally (new coverage: device client unit tests incl. slow_down/expiry/denial/sanitization; CLI tests for --device, auto-fallback, loopback preserved when the server lacks the endpoint, unsupported-server error, detection heuristics).
  • Live E2E against the WorkOS dev environment (2026-08-23): the CLI side works end to end — device authorization request, code + URL display, AuthKit confirmation page, token poll, storage, ✓ Signed in. Also verified: WorkOS enables the device grant only for manually registered (dashboard/API) Connect apps; dynamically registered (DCR) apps get unauthorized_client: Device authorization is not enabled for this application.

⚠️ Blocked on WorkOS for production use (V8 ticket filed)

Live testing surfaced three WorkOS-side gaps that make device-grant tokens unusable against our API today (none are client-side; this PR's code is correct per the RFCs and ready for when WorkOS fixes them):

  1. The device grant ignores RFC 8707 resource: tokens are minted with aud = <environment client id> instead of the requested resource, so the API's audience-lane check rejects them (TOKEN_INVALID, fail-closed as designed). The authorization-code grant honors resource correctly.
  2. Signed-out browsers dead-end: /device redirects signed-out users to the bare external Login URI with no external_auth_id and no return URL, so the login bridge cannot resume the flow. Works only when the browser already holds an AuthKit session.
  3. Scope escalation dead-ends the same way (e.g. adding offline_access, which is also needed because device-grant responses carry no refresh_token and a 300s access token).

The auto-fallback and --device remain safe to merge: against servers that don't advertise the device endpoint the CLI keeps today's loopback behavior, and against WorkOS the failure is a clean error rather than a hang.

Manual step required

WorkOS enables the device grant per application, but only for manually registered Connect apps — there is no toggle. If an environment's CLI app was created via DCR, recreate it in the dashboard or via POST /connect/applications. Documented in MANUAL-STEPS.md (plans repo) along with the V8 support-ticket text.

The loopback flow cannot work over SSH or on displayless hosts: the
browser opens elsewhere (or not at all) and the 127.0.0.1 redirect
never comes back. Detect that and switch to the RFC 8628 device flow —
print a one-time code and URL, approve from a browser on any device,
poll the token endpoint for the result. --device forces the flow
explicitly. The endpoint comes only from discovery metadata (RFC 8628
has no conventional path); servers that do not advertise it keep the
loopback behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant