Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
"artipacked",
"binname",
"binpath",
"bsdtar",
"cimd",
"clidocs",
"containedctx",
"coverprofile",
"cpuprof",
"credstore",
Expand All @@ -28,7 +30,10 @@
"memprof",
"mgechev",
"mktemp",
"mtimes",
"nolint",
"pipefail",
"rundll",
"techdocs",
"trimpath",
"urfave",
Expand Down
61 changes: 49 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,11 @@ lfx-cli/

### Current State

This repo is under active scaffolding. Auth and API commands are currently
stubs; real implementations land in follow-on work:

- `lfx auth login` / `status` / `logout`
- `lfx auth token`
- `lfx api`

Credential storage (system keychain via `99designs/keyring`) and the Auth0
CIMD client are tracked separately.
`lfx auth login` / `status` / `token` / `logout` are fully implemented,
including the Auth0 Device Code flow, refresh-token exchange, and
credential storage (system keychain via `99designs/keyring`, with a plain
`--insecure-storage` fallback). `lfx api` remains a stub; its
implementation lands in follow-on work.

**No container build**: this project produces binary artifacts only,
distributed via GitHub Releases, the `install.sh` curl-style installer
Expand Down Expand Up @@ -233,6 +229,47 @@ release binaries may be missing even though the GitHub Release exists.
2. **Package Comments**: Every new `*.go` file must include the same
`// Package <name> ...` doc comment as the rest of its package
3. **Dependencies**: Run `go get -u ./... && go mod tidy` before every PR to
keep dependencies current
4. **Code Quality**: Run `make check` before commits
5. **Documentation**: Update README.md for user-facing changes
keep dependencies current. This upgrades module dependencies only, not the
Go toolchain itself (`go.mod`'s `go` directive) -- see the toolchain policy
below before touching that.
4. **Go toolchain version**: Freely bump `go.mod`'s `go` directive to the
latest available *patch* release (e.g. `1.X.Y` → `1.X.{Y+1}`) to pick up
security fixes. Do **not** bump the *minor* version (e.g. `1.X.x` →
`1.{X+1}.x`) unless the user explicitly asks for it, **and** you've
validated it 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.

To find MegaLinter's bundled Go version:

```bash
# 1. Find the MegaLinter flavor and pinned version tag used in CI.
grep -A1 'oxsecurity/megalinter' .github/workflows/*.yml
# e.g. "uses: oxsecurity/megalinter/flavors/<flavor>@<sha> # <tag>"

# 2. Fetch that flavor's Dockerfile and read its GO_ALPINE_VERSION (or
# GO_IMAGE_VERSION) build arg.
curl -s "https://raw.githubusercontent.com/oxsecurity/megalinter/<tag>/flavors/<flavor>/Dockerfile" \
| grep -i 'GO_ALPINE_VERSION\|GO_IMAGE_VERSION'
```

`go.mod`'s `go` directive must never exceed that bundled version. Staying
one minor version behind it (rather than matching its minor *and* patch
exactly) leaves room to always take the latest patch release for security
fixes without ever being blocked by MegaLinter's own bundled patch version
lagging behind a newly disclosed vulnerability.

There's no built-in `go` subcommand to look up the latest patch release
for a given minor version -- query the official `go.dev/dl` JSON feed
instead:

```bash
# Find the latest patch release for the minor version pinned in go.mod.
MINOR=$(grep '^go ' go.mod | awk '{print $2}' | cut -d. -f1,2)
curl -s "https://go.dev/dl/?mode=json&include=all" \
| jq -r --arg m "go${MINOR}." '.[].version | select(startswith($m))' \
| sort -V | tail -1
```
5. **Code Quality**: Run `make check` before commits
6. **Documentation**: Update README.md for user-facing changes
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ lfx auth login --insecure-storage

Run `lfx --help` or `lfx <command> --help` for full details on any command.

> **Note:** This project is under active development. Authentication and API
> commands are currently stubs; see the
> **Note:** This project is under active development. `lfx auth` is fully
> implemented; `lfx api` is currently a stub. See the
> [LFXV2-2509 epic](https://linuxfoundation.atlassian.net/browse/LFXV2-2509)
> for status.

Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
// SPDX-License-Identifier: MIT
module github.com/linuxfoundation/lfx-cli

go 1.26.5
go 1.25.14

require (
github.com/99designs/keyring v1.2.2
github.com/urfave/cli-docs/v3 v3.1.0
github.com/urfave/cli/v3 v3.10.1
golang.org/x/oauth2 v0.36.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7v
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to=
github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY=
github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
Expand Down
212 changes: 212 additions & 0 deletions internal/auth0/device.go
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
}

Comment thread
emsearcy marked this conversation as resolved.
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)
}
Loading
Loading