Skip to content

feat(auth): implement Device Code login flow and token refresh - #4

Open
emsearcy wants to merge 8 commits into
mainfrom
lfxv2-2515-device-code-login
Open

feat(auth): implement Device Code login flow and token refresh#4
emsearcy wants to merge 8 commits into
mainfrom
lfxv2-2515-device-code-login

Conversation

@emsearcy

@emsearcy emsearcy commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

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): resolves --env to its IdP domain and compiled-in client ID, and drives the device code request, poll, and refresh-token exchange via golang.org/x/oauth2.
  • internal/commands/auth.go: real login (interactive device code flow, or --with-token for headless/CI use), token (cached-token fast path, refresh-and-recache on expiry, clear error message on invalid/expired refresh token), status, and logout implementations.
  • internal/credstore: extends DeviceState with environment/audience (needed to rebuild the same IdP client on refresh), insecure (guards against mixing state between the keychain and --insecure-storage backends), and adds DeleteDeviceState for logout.
  • Includes outstanding .cspell.json wordlist 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 assumed gh CLI precedent. I checked gh's actual behavior directly: ~/.local/state/gh/device-id exists, but it's generated by internal/telemetry.getOrCreateDeviceID in github.com/cli/cli — an anonymous telemetry identifier, not something used in gh'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 of credstore.DeviceState with a comment explaining the rationale. See ticket comments on LFXV2-2515/LFXV2-2516 for the full writeup.

Related tickets

  • LFXV2-2515 — Implement Device Code login flow (lfx auth login)
  • LFXV2-2516 — Implement lfx auth token command

Testing

  • go build ./..., go vet ./..., golangci-lint run, revive ./... all pass clean
  • Manually smoke-tested the full login/status/token/logout cycle end-to-end against the dev and prod Auth0 tenants (--env development/--env prod), including the interactive device code flow, cached-token fast path, and refresh-token exchange
  • Found and fixed a real bug during that prod testing: prod's IdP domain must be its custom domain sso.linuxfoundation.org, not linuxfoundation.auth0.com (see auth0-terraform's own auth0_domain variable) — using the wrong domain surfaced as an Auth0 invalid_request: Missing required parameter: response_type error and a login that never completed

🤖 Generated with GitHub Copilot (via OpenCode)

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>
Copilot AI balanced review requested due to automatic review settings August 19, 2026 00:30
@emsearcy
emsearcy requested a review from a team as a code owner August 19, 2026 00:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/commands/auth.go Outdated
Comment thread internal/commands/auth.go
Comment thread internal/commands/auth.go
Comment thread internal/auth0/device.go
- 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>
Copilot AI review requested due to automatic review settings August 19, 2026 16:32
@emsearcy
emsearcy marked this pull request as draft August 19, 2026 16:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; DeviceAccessToken derives its polling context from that parent, so DeadlineExceeded is not necessarily caused by the device-code expiry. This branch misreports an earlier caller timeout as an expired device code. Check dc.ctx.Err() (or otherwise distinguish the parent deadline) before mapping the error to ErrExpiredToken.
	// 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_complete is optional, but both the browser and manual paths use it unconditionally. A valid device response that only supplies verification_uri therefore opens/prints an empty URL and leaves the user unable to complete login. Fall back to dc.VerificationURI when 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>
Copilot AI review requested due to automatic review settings August 19, 2026 17:33
- 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>
@emsearcy

Copy link
Copy Markdown
Contributor Author

AI-assisted: Fixed in d94eda8 — addressed the 2 suppressed Copilot comments from the follow-up review:

  • Poll() now checks dc.ctx.Err() before mapping context.DeadlineExceeded to ErrExpiredToken, so a caller-supplied context deadline (distinct from DeviceAccessToken's own internally-derived one) is no longer misreported as an expired device code.
  • loginWithDeviceCode now falls back to VerificationURI when VerificationURIComplete is empty (it's optional per RFC 8628 §3.2).

@emsearcy
emsearcy marked this pull request as ready for review August 19, 2026 17:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_token when the selected custom API does not allow offline access, even though offline_access was 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 when token.RefreshToken is 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 SaveDeviceState runs. If writing state.json fails, 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-26 and 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 HTTPClient nil, and main supplies context.Background(), so device-code and refresh requests use http.DefaultClient without an overall response timeout. An Auth0 endpoint that accepts a connection but stalls can therefore hang lfx auth login or lfx auth token indefinitely. 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

Copilot AI review requested due to automatic review settings August 19, 2026 17:40
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread internal/commands/auth.go Outdated
Comment on lines +146 to +152
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI review requested due to automatic review settings August 19, 2026 17:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • SaveCredentials has already replaced the selected backend's credentials when SaveDeviceState fails. 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-token accepts 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.RefreshToken before 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 status never 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>
Copilot AI review requested due to automatic review settings August 19, 2026 23:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_access is 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 which auth token can never refresh it. Require a non-empty token.RefreshToken before 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>
Copilot AI review requested due to automatic review settings August 20, 2026 00:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI review requested due to automatic review settings August 20, 2026 00:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.

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.

2 participants