From 54fe2a388b3a9fa0e7b7b6b4bb51ac458742ed39 Mon Sep 17 00:00:00 2001 From: jordan-extend Date: Sun, 23 Aug 2026 08:06:09 -0400 Subject: [PATCH] Fall back to the OAuth device flow when no local browser can sign in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/cli/login.go | 208 +++++++++++++++++++++------- internal/cli/login_test.go | 209 +++++++++++++++++++++++++++- internal/oauth/device.go | 128 +++++++++++++++++ internal/oauth/device_test.go | 227 +++++++++++++++++++++++++++++++ internal/oauth/discovery.go | 20 ++- internal/oauth/discovery_test.go | 12 +- 6 files changed, 743 insertions(+), 61 deletions(-) create mode 100644 internal/oauth/device.go create mode 100644 internal/oauth/device_test.go diff --git a/internal/cli/login.go b/internal/cli/login.go index f6935ef..0b6983a 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -26,6 +27,7 @@ const loginWaitTimeout = 10 * time.Minute func newLoginDoc(app *App) *CommandDoc { var noBrowser bool + var device bool return &CommandDoc{ Use: "login", Summary: "Sign in to Extend through your browser (no API key needed)", @@ -34,17 +36,25 @@ func newLoginDoc(app *App) *CommandDoc { "log in to the extend cli with my browser", "authenticate the cli without creating an api key", "oauth login for the extend cli", + "sign in to extend over ssh or on a headless machine", }, WhenToUse: `Use to authenticate interactively without creating an API key: it opens your browser, you pick one workspace and one environment on the consent screen, and the CLI stores the resulting tokens securely. Prefer an API key (EXTEND_API_KEY or 'extend setup') for CI and unattended use; login -needs a browser once, though the stored session then works headlessly -until it expires or is revoked.`, +needs a browser once — on this machine or, with the device flow, on any +other device — and the stored session then works headlessly until it +expires or is revoked.`, Details: `The flow is a standard native-app OAuth authorization code grant with PKCE: the CLI listens on an ephemeral 127.0.0.1 port, sends you to the Extend consent screen, and exchanges the returned code for tokens. +When no usable local browser is detected (an SSH session, or a Linux +host with no display), login automatically switches to the OAuth device +flow: it prints a short one-time code and a URL, you approve the +sign-in from a browser on any device, and the CLI picks the tokens up. +Pass --device to use that flow explicitly. + Tokens are stored in the OS keychain when one is available, otherwise in a 0600 file next to the config file (~/.config/extend/oauth_tokens.json). Logins are stored per API base URL, so sessions against different @@ -59,6 +69,7 @@ config file always wins over a stored login. Run 'extend config' to see which source is in effect.`, Examples: []Example{ {Label: "Sign in", Cmd: "extend login"}, + {Label: "Sign in from an SSH session or headless machine", Cmd: "extend login --device"}, {Label: "Print the URL instead of opening a browser", Cmd: "extend login --no-browser"}, {Label: "Sign in to the EU region", Cmd: "extend login --region eu"}, }, @@ -67,18 +78,24 @@ which source is in effect.`, "Each login targets exactly one workspace and one environment, chosen on the consent screen; log in again to switch.", "Logins are keyed by API base URL: switching --region or EXTEND_BASE_URL selects a different stored session (or none).", "With --env