feat(auth): implement Device Code login flow and token refresh - #4
feat(auth): implement Device Code login flow and token refresh#4emsearcy wants to merge 8 commits into
Conversation
Implements `lfx auth login`, `lfx auth token`, `lfx auth status`, and `lfx auth logout` on top of golang.org/x/oauth2's device authorization grant support (Config.DeviceAuth/DeviceAccessToken) and refresh-token TokenSource, targeting the static per-environment Auth0 client IDs provisioned in LFXV2-2513. - internal/auth0: new package resolving --env to its IdP domain and compiled-in client ID, and driving the device code request, poll, and refresh-token exchange. - internal/commands/auth.go: real login (interactive device code flow or --with-token for headless use), token (cached-token fast path, refresh-and-recache on expiry, clear error on invalid/expired refresh token), status, and logout implementations. - internal/credstore: extends DeviceState with environment/audience (needed to replay the same IdP/client on refresh) and adds DeleteDeviceState for logout. Deliberately does not persist a device ID: Auth0's device flow has no such concept, and the `gh` CLI precedent cited when this story was written turned out to be an unrelated telemetry identifier, not part of its OAuth flow. - Includes outstanding .cspell.json wordlist additions. LFXV2-2515, LFXV2-2516 Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Implements Auth0 device-code authentication and token refresh for the LFX CLI.
Changes:
- Adds environment-aware Auth0 device authorization and refresh support.
- Implements login, token, status, logout, and credential-state cleanup.
- Adds OAuth dependency and spellcheck terms.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
internal/auth0/device.go |
Adds Auth0 device and refresh flows. |
internal/commands/auth.go |
Implements authentication commands. |
internal/credstore/credstore.go |
Extends and deletes persisted device state. |
go.mod |
Adds OAuth2 dependency. |
go.sum |
Records OAuth2 checksums. |
.cspell.json |
Adds required technical terms. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- auth token: use auth0.Resolve's domain (trusted) for the refresh client instead of the persisted state.json value, while still sanity-checking it against the stored domain to catch a tampered or corrupted state file before it can redirect a refresh request. - credstore: add DeviceState.Insecure, recording which credential backend (keychain vs. --insecure-storage) wrote state.json. auth token/status/logout now validate this against the invocation's own --insecure-storage flag before trusting or deleting state.json, so switching backends no longer silently corrupts or destroys the other backend's metadata. - README.md/AGENTS.md: update the stale "auth commands are stubs" note now that lfx auth is fully implemented; only lfx api remains a stub. - auth0: map DeviceAccessToken's context.DeadlineExceeded (returned once the device code's own expiry passes, ahead of the token endpoint ever reporting expired_token) to ErrExpiredToken, so the documented error and "device code expired" message are reliably produced. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/auth0/device.go:184
- The caller can pass a context with its own deadline to
RequestDeviceCode;DeviceAccessTokenderives its polling context from that parent, soDeadlineExceededis not necessarily caused by the device-code expiry. This branch misreports an earlier caller timeout as an expired device code. Checkdc.ctx.Err()(or otherwise distinguish the parent deadline) before mapping the error toErrExpiredToken.
// passes in (see RequestDeviceCode), any DeadlineExceeded seen here
// can only come from that internal one, so it's safe to always treat
// it as ErrExpiredToken.
if errors.Is(err, context.DeadlineExceeded) {
return nil, ErrExpiredToken
internal/commands/auth.go:186
verification_uri_completeis optional, but both the browser and manual paths use it unconditionally. A valid device response that only suppliesverification_uritherefore opens/prints an empty URL and leaves the user unable to complete login. Fall back todc.VerificationURIwhen the complete URI is empty.
fmt.Printf("Opening %s in your browser...\n", dc.VerificationURIComplete)
if err := openBrowser(dc.VerificationURIComplete); err != nil {
lfx auth login --env prod was sending device code and token requests to linuxfoundation.auth0.com, but end users only ever authenticate at the prod tenant's custom domain, sso.linuxfoundation.org (see auth0-terraform's auth0_domain variable). The mismatch surfaced as an Auth0 invalid_request: Missing required parameter: response_type error completing login, and a login that never resolved when polling. Since this Client only talks to the device code and token endpoints (never the Auth0 Management API), there's no need to separately track each environment's underlying tenant name -- only the IdP domain end users authenticate against, which for prod is the custom domain rather than the *.auth0.com domain. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
- Poll(): distinguish a caller-supplied context deadline from DeviceAccessToken's own internally-derived one before mapping DeadlineExceeded to ErrExpiredToken, so an unrelated caller timeout is no longer misreported as an expired device code. - loginWithDeviceCode: fall back to VerificationURI when VerificationURIComplete is empty (it's optional per RFC 8628 section 3.2), so a login doesn't try to open/print an empty URL. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
|
AI-assisted: Fixed in d94eda8 — addressed the 2 suppressed Copilot comments from the follow-up review:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (5)
internal/commands/auth.go:216
- As in the supplied-token path, credentials are saved before the required device state. A state-file write failure therefore reports login failure while leaving newly issued credentials paired with missing or previous environment metadata. Make the two persistence updates atomic from the caller’s perspective or restore/remove the credential update on failure.
if err := store.SaveCredentials(credstore.Credentials{
RefreshToken: token.RefreshToken,
AccessToken: token.AccessToken,
AccessTokenExpiry: token.Expiry,
}); err != nil {
return fmt.Errorf("save credentials: %w", err)
}
internal/commands/auth.go:209
- A successful Auth0 device exchange can omit
refresh_tokenwhen the selected custom API does not allow offline access, even thoughoffline_accesswas requested. This currently persists an empty refresh token and reports “Login successful,” so the session becomes unusable as soon as the cached access token expires. Reject the login with a clear configuration error whentoken.RefreshTokenis empty before saving credentials.
return errors.New("login was denied")
case errors.Is(err, auth0.ErrExpiredToken):
return errors.New("device code expired before login completed")
default:
return err
internal/commands/auth.go:152
- Credentials have already been overwritten when
SaveDeviceStateruns. If writingstate.jsonfails, the command returns an error but leaves the new refresh token paired with missing or stale environment metadata, so a later refresh cannot safely reconstruct its Auth0 client. Persist these as one consistent operation or roll back the credential update when state persistence fails.
This issue also appears on line 210 of the same file.
if err := store.SaveDeviceState(credstore.DeviceState{
IDPDomain: domain,
Environment: string(env),
Audience: audience,
Insecure: insecure,
}); err != nil {
return fmt.Errorf("save device state: %w", err)
AGENTS.md:50
- This implementation now uses compiled-in static client IDs, but AGENTS.md:177-180 still says the gh-pages CIMD URL is the Device Code client ID. That guidance directly contradicts
internal/auth0/device.go:22-26and can send future maintainers to update an unused client definition. Update the gh-pages section to explain that the CIMD asset is no longer used by this flow.
`lfx auth login` / `status` / `token` / `logout` are fully implemented,
including the Auth0 Device Code flow, refresh-token exchange, and
internal/auth0/device.go:91
- All production callers leave
HTTPClientnil, andmainsuppliescontext.Background(), so device-code and refresh requests usehttp.DefaultClientwithout an overall response timeout. An Auth0 endpoint that accepts a connection but stalls can therefore hanglfx auth loginorlfx auth tokenindefinitely. Provide a bounded default client or add per-request deadlines while retaining the device-flow polling deadline.
// HTTPClient is used for all requests. Defaults to
// http.DefaultClient if nil.
HTTPClient *http.Client
lfx auth login's "Logged in as ..." message now prefers the LFID username (the https://sso.linuxfoundation.org/claims/ custom ID token claim) over email, since username is the conventional LFX identifier. Shows "username (email)" when both are present, falling back to "email (no username)" in the unexpected case the custom claim is missing, and finally the subject claim if neither is present. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/commands/auth.go:223
- This has the same partial-login failure mode as
--with-token: credentials are replaced before device state is saved. If the state write fails, an older same-backend state remains usable and can direct this new refresh token to the previous environment's token endpoint. Roll back/delete the new credentials on failure, or make persistence of credentials and their routing metadata atomic.
if err := store.SaveDeviceState(credstore.DeviceState{
IDPDomain: domain,
Environment: string(env),
Audience: audience,
Insecure: insecure,
}); err != nil {
return fmt.Errorf("save device state: %w", err)
| if err := store.SaveDeviceState(credstore.DeviceState{ | ||
| IDPDomain: domain, | ||
| Environment: string(env), | ||
| Audience: audience, | ||
| Insecure: insecure, | ||
| }); err != nil { | ||
| return fmt.Errorf("save device state: %w", err) |
There was a problem hiding this comment.
AI-assisted: Fixed in 98b10cc. persistLogin now calls store.DeleteCredentials() to roll back the just-saved credentials if SaveDeviceState fails, instead of leaving a mismatched credentials/metadata pair.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (5)
internal/commands/auth.go:146
SaveCredentialshas already replaced the selected backend's credentials whenSaveDeviceStatefails. The command then reports a failed login while leaving the new refresh token paired with stale or missing environment metadata, so a later refresh can target the wrong configured tenant and fail. Persist the credential/state pair transactionally, or restore/delete the newly written credentials when saving the state fails.
if err := store.SaveCredentials(credstore.Credentials{RefreshToken: refreshToken}); err != nil {
return fmt.Errorf("save credentials: %w", err)
}
if err := store.SaveDeviceState(credstore.DeviceState{
internal/commands/auth.go:217
- This second write can fail after the newly issued credentials have already been saved, leaving credentials and device metadata from different login attempts. A later refresh then uses stale metadata with the new refresh token. Make these writes transactional or roll back the credential write when saving state fails.
if err := store.SaveDeviceState(credstore.DeviceState{
internal/commands/auth.go:388
- The presence of a credential record does not establish that the user is logged in:
--with-tokenaccepts any non-empty string without validation, and stored refresh tokens can later be revoked. In both cases this command prints “Logged in.” even though token acquisition fails. Validate the session (for example by refreshing when no valid access token is cached), or report only that credentials are stored rather than asserting an authenticated status.
fmt.Println("Logged in.")
internal/commands/auth.go:214
- A successful OAuth token response is not required to include a refresh token. Saving such a response and printing “Login successful” creates a session that necessarily stops working when the access token expires. Check
token.RefreshTokenbefore persisting and return an actionable error if Auth0 did not issue one.
if err := store.SaveCredentials(credstore.Credentials{
RefreshToken: token.RefreshToken,
AccessToken: token.AccessToken,
AccessTokenExpiry: token.Expiry,
}); err != nil {
internal/commands/auth.go:40
- This comment is inaccurate:
auth statusnever reads identity claims; these scopes are used only for the post-login “Logged in as …” message at lines 227-230. Describe that actual use so future scope changes do not rely on a nonexistent status dependency.
// scopes requested during the device code flow. offline_access is required
// to receive a refresh token; the rest identify the user for `auth status`.
Extract persistLogin and loadStoredCredentials helpers to eliminate the two Go clones MegaLinter's jscpd check flagged in internal/commands/auth.go: the duplicated SaveCredentials + SaveDeviceState pairs in the --with-token and device-code login paths, and the duplicated LoadCredentials/ErrNotFound loading pattern shared by the token and status commands. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/commands/auth.go:299
- If the credential write succeeds but the state write fails (disk full, permissions, or a partial state-file write), the command reports login failure after already replacing the credentials. The new refresh token is then paired with stale environment metadata—or no metadata—so later refreshes fail, and a previously valid login may have been destroyed. Make these writes transactional from the caller’s perspective by restoring/deleting the newly written credentials when state persistence fails (and preserving any prior login as appropriate).
if err := store.SaveCredentials(creds); err != nil {
return fmt.Errorf("save credentials: %w", err)
}
if err := store.SaveDeviceState(state); err != nil {
return fmt.Errorf("save device state: %w", err)
internal/commands/auth.go:209
- A successful OAuth token response is not guaranteed to contain a refresh token, even when
offline_accessis requested (for example, an overridden audience may have offline access disabled). This path still persists the response and prints “Login successful,” so authentication works only until the cached access token expires, after whichauth tokencan never refresh it. Require a non-emptytoken.RefreshTokenbefore persisting the login and return an actionable configuration/login error otherwise.
RefreshToken: token.RefreshToken,
Document a Go toolchain upgrade policy in the Contributing Guidelines: freely bump go.mod's go directive to the latest patch release, but only bump the minor version when the user explicitly asks for it and it has been validated against the Go version MegaLinter itself bundles -- MegaLinter runs several linters (e.g. golangci-lint) against its own bundled Go version, and a go.mod directive newer than that bundled version breaks those checks. Includes the concrete steps to look up MegaLinter's bundled Go version from its pinned flavor tag, and a one-liner using the go.dev/dl JSON feed to find the latest patch release for the minor version currently pinned in go.mod. Downgrade go.mod's go directive from 1.26.5 to 1.25.14 (the latest 1.25.x patch release). Verified against .github/workflows/mega-linter.yml, which pins oxsecurity/megalinter's go flavor to v9.6.0; that flavor's Dockerfile bundles Go 1.26.3 (GO_ALPINE_VERSION=1.26.3-r0), so the previous 1.26.5 directive was already newer than MegaLinter's own Go toolchain -- exactly the failure mode this policy exists to prevent. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
d152fc6 to
5488a60
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (1)
internal/commands/auth.go:299
- If the state-file write fails after the keychain/plain-file write succeeds, this failed login has already replaced any working credentials while leaving old or missing environment metadata. The next refresh can therefore target metadata from a different login or become impossible. Persist credentials and their refresh metadata as one atomic unit, or restore the previous credentials/state when the second write fails instead of returning with a split login.
if err := store.SaveCredentials(creds); err != nil {
return fmt.Errorf("save credentials: %w", err)
}
if err := store.SaveDeviceState(state); err != nil {
return fmt.Errorf("save device state: %w", err)
persistLogin saves credentials and device state in two separate, non-atomic writes. Per Copilot review feedback on PR #4, if SaveDeviceState fails after SaveCredentials has already succeeded, the new refresh token was left paired with stale or missing environment metadata, so a later refresh could target the wrong Auth0 tenant. Delete the newly saved credentials when SaveDeviceState fails, so a partial failure leaves the previous login state undisturbed rather than a mismatched credentials/metadata pair. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Summary
Implements
lfx auth login,lfx auth token,lfx auth status, andlfx auth logouton top ofgolang.org/x/oauth2's device authorization grant support (Config.DeviceAuth/DeviceAccessToken) and refresh-tokenTokenSource, targeting the static per-environment Auth0 client IDs provisioned in LFXV2-2513.internal/auth0(new package): resolves--envto its IdP domain and compiled-in client ID, and drives the device code request, poll, and refresh-token exchange viagolang.org/x/oauth2.internal/commands/auth.go: reallogin(interactive device code flow, or--with-tokenfor headless/CI use),token(cached-token fast path, refresh-and-recache on expiry, clear error message on invalid/expired refresh token),status, andlogoutimplementations.internal/credstore: extendsDeviceStatewithenvironment/audience(needed to rebuild the same IdP client on refresh),insecure(guards against mixing state between the keychain and--insecure-storagebackends), and addsDeleteDeviceStatefor logout..cspell.jsonwordlist additions/fixes (nolint,containedctx,rundll, plus suppressing spellcheck on the opaque Auth0 client ID literals).Notably NOT implemented: persistent "device ID"
The epic's background/design notes called for a "Device ID persistence in
~/.local/state/lfx-cli/," modeled on an assumedghCLI precedent. I checkedgh's actual behavior directly:~/.local/state/gh/device-idexists, but it's generated byinternal/telemetry.getOrCreateDeviceIDingithub.com/cli/cli— an anonymous telemetry identifier, not something used ingh's own OAuth device flow or credential storage. Auth0's device authorization grant has no concept of a device ID either. Since the LFX CLI has no telemetry pipeline in scope here, there's nothing for one to do; left out ofcredstore.DeviceStatewith a comment explaining the rationale. See ticket comments on LFXV2-2515/LFXV2-2516 for the full writeup.Related tickets
lfx auth login)lfx auth tokencommandTesting
go build ./...,go vet ./...,golangci-lint run,revive ./...all pass cleanlogin/status/token/logoutcycle end-to-end against thedevandprodAuth0 tenants (--env development/--env prod), including the interactive device code flow, cached-token fast path, and refresh-token exchangesso.linuxfoundation.org, notlinuxfoundation.auth0.com(seeauth0-terraform's ownauth0_domainvariable) — using the wrong domain surfaced as an Auth0invalid_request: Missing required parameter: response_typeerror and a login that never completed🤖 Generated with GitHub Copilot (via OpenCode)