-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): implement Device Code login flow and token refresh #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
emsearcy
wants to merge
8
commits into
main
Choose a base branch
from
lfxv2-2515-device-code-login
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
c94ced7
feat(auth): implement Device Code login flow and token refresh
emsearcy 05b5aff
fix(review): address PR #4 Copilot review feedback
emsearcy 161c203
fix(auth0): use prod custom domain sso.linuxfoundation.org
emsearcy d94eda8
fix(review): address second round of Copilot review comments
emsearcy b6388d1
feat(auth): show LFID username in the login greeting
emsearcy b632b5e
fix(auth): remove code duplication flagged by jscpd
emsearcy 5488a60
docs(agents): add Go toolchain version policy, downgrade go.mod
emsearcy 98b10cc
fix(auth): roll back saved credentials if device-state save fails
emsearcy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| // Copyright The Linux Foundation and each contributor to LFX. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| // Package auth0 configures the Auth0 Device Authorization Grant (RFC 8628) | ||
| // and refresh-token exchange used by `lfx auth login` and `lfx auth token`, | ||
| // on top of golang.org/x/oauth2's device flow support | ||
| // (Config.DeviceAuth/DeviceAccessToken and TokenSource-based refresh). | ||
| package auth0 | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
|
|
||
| "golang.org/x/oauth2" | ||
| ) | ||
|
|
||
| // Environment identifies which LFX Auth0 tenant/IdP a login targets. | ||
| type Environment string | ||
|
|
||
| // Supported environments, selected via the `--env` flag. Each maps to a | ||
| // fixed IdP domain and a static, pre-provisioned Auth0 native application | ||
| // client ID (device_code + refresh_token grants; see LFXV2-2513 and | ||
| // auth0-terraform PR #348). CIMD was evaluated and abandoned for this flow: | ||
| // Auth0 silently drops CIMD client registration for the device_code grant. | ||
| const ( | ||
| EnvProd Environment = "prod" | ||
| EnvStaging Environment = "staging" | ||
| EnvDevelopment Environment = "development" | ||
| ) | ||
|
|
||
| // domains maps each Environment to the Auth0 IdP domain end users | ||
| // authenticate against, matching auth0-terraform's own `auth0_domain` | ||
| // variable. This is deliberately not each tenant's *.auth0.com domain: prod | ||
| // fronts its tenant with the custom domain sso.linuxfoundation.org, and | ||
| // since this Client never calls the Auth0 Management API (only the device | ||
| // code and token endpoints), there's no need to separately track the | ||
| // underlying tenant name. | ||
| var domains = map[Environment]string{ | ||
| EnvProd: "sso.linuxfoundation.org", | ||
| EnvStaging: "linuxfoundation-staging.auth0.com", | ||
| EnvDevelopment: "linuxfoundation-dev.auth0.com", | ||
| } | ||
|
|
||
| // clientIDs maps each Environment to its compiled-in, pre-provisioned | ||
| // Auth0 native application client ID for the device code grant. | ||
| // cspell:disable -- opaque, randomly-generated Auth0 client IDs, not words. | ||
| var clientIDs = map[Environment]string{ | ||
| EnvProd: "kkCpM0c9zJ0vNZZDDOGqcyzocOBircOn", | ||
| EnvStaging: "9XzXgDfAB9O7IoHqhBj5mg4VLvdBM8ci", | ||
| EnvDevelopment: "0TN1OElqQY146vLEPdV5qfejRKpc9IAZ", | ||
| } | ||
|
|
||
| // cspell:enable | ||
|
|
||
| // DefaultAudience is the production LFX v2 API audience used unless | ||
| // overridden via `--audience`. It intentionally does not vary with `--env`: | ||
| // per LFXV2-2515, the audience is independent of the selected environment | ||
| // and must be set explicitly when testing a non-prod API. | ||
| const DefaultAudience = "https://lfx-api.v2.cluster.lfx.dev/" | ||
|
|
||
| // ErrInvalidEnvironment is returned by Resolve for an unrecognized | ||
| // Environment value. | ||
| var ErrInvalidEnvironment = errors.New("auth0: invalid environment") | ||
|
|
||
| // Resolve returns the IdP domain and client ID for env. | ||
| func Resolve(env Environment) (domain, clientID string, err error) { | ||
| domain, ok := domains[env] | ||
| if !ok { | ||
| return "", "", fmt.Errorf("%w: %q (must be one of prod, staging, development)", ErrInvalidEnvironment, env) | ||
| } | ||
| return domain, clientIDs[env], nil | ||
| } | ||
|
|
||
| // Client drives the Auth0 device code and refresh-token exchanges for a | ||
| // single environment, via an underlying oauth2.Config. Auth0's device code | ||
| // application is a public native client (Token Endpoint Authentication | ||
| // Method: None; is_first_party = true), so ClientSecret is deliberately | ||
| // left unset. | ||
| type Client struct { | ||
| // Domain is the Auth0 IdP domain end users authenticate against, e.g. | ||
| // "linuxfoundation-dev.auth0.com" or, for a tenant fronted by a custom | ||
| // domain, "sso.linuxfoundation.org". | ||
| Domain string | ||
| // ClientID is the Auth0 application client ID used for both the | ||
| // device code request and subsequent token/refresh exchanges. | ||
| ClientID string | ||
| // HTTPClient is used for all requests. Defaults to | ||
| // http.DefaultClient if nil. | ||
| HTTPClient *http.Client | ||
| } | ||
|
|
||
| // config builds the oauth2.Config used for a device code flow requesting | ||
| // scopes, pointed at this Client's Auth0 tenant. | ||
| func (c *Client) config(scopes []string) *oauth2.Config { | ||
| return &oauth2.Config{ | ||
| ClientID: c.ClientID, | ||
| Scopes: scopes, | ||
| Endpoint: oauth2.Endpoint{ | ||
| DeviceAuthURL: "https://" + c.Domain + "/oauth/device/code", | ||
| TokenURL: "https://" + c.Domain + "/oauth/token", | ||
| AuthStyle: oauth2.AuthStyleInParams, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // context attaches Client's HTTPClient to ctx, per the convention | ||
| // documented on oauth2.HTTPClient, so all requests made by the resulting | ||
| // oauth2.Config use it. | ||
| func (c *Client) context(ctx context.Context) context.Context { | ||
| if c.HTTPClient == nil { | ||
| return ctx | ||
| } | ||
| return context.WithValue(ctx, oauth2.HTTPClient, c.HTTPClient) | ||
| } | ||
|
|
||
| // DeviceCode wraps the RFC 8628 §3.2 device authorization response, | ||
| // carrying the oauth2.Config needed to complete the flow via Poll. | ||
| type DeviceCode struct { | ||
| *oauth2.DeviceAuthResponse | ||
| cfg *oauth2.Config | ||
| ctx context.Context //nolint:containedctx // ctx is captured for reuse by Poll, matching Client.RequestDeviceCode/Poll's split across a user-facing pause. | ||
| } | ||
|
|
||
| // RequestDeviceCode starts the device authorization flow for the given | ||
| // audience and scopes. | ||
| func (c *Client) RequestDeviceCode(ctx context.Context, audience string, scopes []string) (*DeviceCode, error) { | ||
| cfg := c.config(scopes) | ||
| authCtx := c.context(ctx) | ||
|
|
||
| var opts []oauth2.AuthCodeOption | ||
| if audience != "" { | ||
| opts = append(opts, oauth2.SetAuthURLParam("audience", audience)) | ||
| } | ||
|
|
||
| resp, err := cfg.DeviceAuth(authCtx, opts...) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("auth0: request device code: %w", err) | ||
| } | ||
| return &DeviceCode{DeviceAuthResponse: resp, cfg: cfg, ctx: authCtx}, nil | ||
| } | ||
|
|
||
| // Errors surfaced by Poll when the token endpoint reports the user has | ||
| // explicitly declined (RFC 8628 §3.5's "access_denied") or the device code | ||
| // expired before the flow completed ("expired_token"). Poll otherwise | ||
| // blocks internally on "authorization_pending"/"slow_down" until one of | ||
| // these, success, or ctx's deadline (bounded by the device code's own | ||
| // expiry). | ||
| var ( | ||
| // ErrAccessDenied indicates the user explicitly declined the request. | ||
| ErrAccessDenied = errors.New("auth0: access denied") | ||
| // ErrExpiredToken indicates the device code expired before the user | ||
| // completed the flow. | ||
| ErrExpiredToken = errors.New("auth0: device code expired") | ||
| // ErrInvalidGrant indicates the supplied refresh token is expired, | ||
| // revoked, or otherwise no longer valid (Auth0's "invalid_grant" | ||
| // error from the refresh_token grant). Callers should treat this as | ||
| // requiring the user to run `lfx auth login` again. | ||
| ErrInvalidGrant = errors.New("auth0: invalid or expired refresh token") | ||
| ) | ||
|
|
||
| // Poll blocks until the user completes (or rejects) the device flow, | ||
| // polling the token endpoint at the interval Auth0 specified (respecting | ||
| // "slow_down" backoff) via oauth2.Config.DeviceAccessToken. It returns | ||
| // ErrAccessDenied or ErrExpiredToken for those respective outcomes. | ||
| func (dc *DeviceCode) Poll() (*oauth2.Token, error) { | ||
| tok, err := dc.cfg.DeviceAccessToken(dc.ctx, dc.DeviceAuthResponse) | ||
| if err == nil { | ||
| return tok, nil | ||
| } | ||
|
|
||
| var retrieveErr *oauth2.RetrieveError | ||
| if errors.As(err, &retrieveErr) { | ||
| switch retrieveErr.ErrorCode { | ||
| case "access_denied": | ||
| return nil, ErrAccessDenied | ||
| case "expired_token": | ||
| return nil, ErrExpiredToken | ||
| } | ||
| } | ||
|
|
||
| // DeviceAccessToken derives its own polling deadline from the device | ||
| // code's Expiry and returns the bare context.DeadlineExceeded (not a | ||
| // *oauth2.RetrieveError) once that deadline passes, rather than | ||
| // waiting for the token endpoint to report "expired_token" itself. | ||
| // dc.ctx may also carry a caller-supplied deadline of its own (e.g. if | ||
| // RequestDeviceCode was called with one), so only treat | ||
| // DeadlineExceeded as an expired device code if dc.ctx itself isn't | ||
| // what expired -- otherwise this would misreport an unrelated caller | ||
| // timeout as ErrExpiredToken. | ||
| if errors.Is(err, context.DeadlineExceeded) && dc.ctx.Err() == nil { | ||
| return nil, ErrExpiredToken | ||
| } | ||
|
|
||
| return nil, fmt.Errorf("auth0: poll for token: %w", err) | ||
| } | ||
|
|
||
| // RefreshToken exchanges a refresh token for a new access token. | ||
| func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*oauth2.Token, error) { | ||
| cfg := c.config(nil) | ||
| tok, err := cfg.TokenSource(c.context(ctx), &oauth2.Token{RefreshToken: refreshToken}).Token() | ||
| if err == nil { | ||
| return tok, nil | ||
| } | ||
|
|
||
| var retrieveErr *oauth2.RetrieveError | ||
| if errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == "invalid_grant" { | ||
| return nil, ErrInvalidGrant | ||
| } | ||
| return nil, fmt.Errorf("auth0: refresh token: %w", err) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.