diff --git a/.cspell.json b/.cspell.json index 4de95cc..6601992 100644 --- a/.cspell.json +++ b/.cspell.json @@ -6,8 +6,10 @@ "artipacked", "binname", "binpath", + "bsdtar", "cimd", "clidocs", + "containedctx", "coverprofile", "cpuprof", "credstore", @@ -28,7 +30,10 @@ "memprof", "mgechev", "mktemp", + "mtimes", + "nolint", "pipefail", + "rundll", "techdocs", "trimpath", "urfave", diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 0000000..07ba83f --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,22 @@ +{ + "threshold": 0, + "reporters": ["console"], + "ignore": [ + "**/.git/**", + "**/megalinter-reports/**", + "**/*cache*/**", + "**/*.json", + "**/*.yaml", + "**/*.yml", + "**/*.md", + "**/*.html", + "**/*.xml", + "**/*.jpg", + "**/*.png", + "**/*.svg", + "**/*.zip", + "**/*.bin", + "**/bin/**", + "**/*_test.go" + ] +} diff --git a/AGENTS.md b/AGENTS.md index c761719..ca03943 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -150,16 +146,17 @@ func NewExampleCommand() *cli.Command { ### Package Comments -Every file in a package must start with the same `// Package ...` -doc comment immediately above the `package` declaration. Revive's -`package-comments` rule itself only requires one such comment per package, -but MegaLinter's `GO_REVIVE` linter defaults to `GO_REVIVE_CLI_LINT_MODE: -list_of_files`, invoking revive with a flat list of files instead of -`./...`. Under that mode revive loses per-package grouping and flags any -file lacking the comment, so duplicating the identical comment across -every file in a package is a required workaround for how MegaLinter calls -revive here, not an inherent revive requirement. Do not vary the wording -between files in the same package. +Every non-test (`*.go`, not `*_test.go`) file in a package must start with +the same `// Package ...` doc comment immediately above the +`package` declaration. Revive's `package-comments` rule itself only +requires one such comment per package, but MegaLinter's `GO_REVIVE` linter +defaults to `GO_REVIVE_CLI_LINT_MODE: list_of_files`, invoking revive with +a flat list of files instead of `./...`. Under that mode revive loses +per-package grouping and flags any non-test file lacking the comment, so +duplicating the identical comment across every non-test file in a package +is a required workaround for how MegaLinter calls revive here, not an +inherent revive requirement. Do not vary the wording between files in the +same package. ## Documentation Generation @@ -230,9 +227,51 @@ release binaries may be missing even though the GitHub Release exists. 1. **Add Commands**: Create new commands in `internal/commands/` following the established pattern -2. **Package Comments**: Every new `*.go` file must include the same - `// Package ...` doc comment as the rest of its package +2. **Package Comments**: Every new non-test `*.go` file must include the + same `// Package ...` doc comment as the rest of its package + (see "Package Comments" above; `*_test.go` files are exempt) 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/@ # " + + # 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//flavors//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 diff --git a/README.md b/README.md index 44f4b9d..45cda85 100644 --- a/README.md +++ b/README.md @@ -44,22 +44,27 @@ lfx api Credentials (refresh token, cached access token) are stored in your operating system's credential store by default (macOS Keychain, Windows -Credential Manager, Linux Secret Service/KWallet/`pass`). Pass -`--insecure-storage` to any `auth` subcommand to instead store credentials -in a plain, unencrypted, owner-only file, at the cost of weaker protection -for the stored tokens. On Windows, this owner-only mode relies on inherited -directory permissions rather than a real ACL, since Go's `Chmod(0600)` maps -to the read-only attribute there rather than restricting access to the -current user. +Credential Manager, Linux Secret Service/KWallet/`pass`). Which of these is +actually used can vary between invocations on the same machine (e.g. Secret +Service reachable in one shell session but not another); pass +`--backend` to pin it to one explicitly (see `lfx auth backends` +for the available names). Once a login has pinned a backend, later commands +must pass the same `--backend` value. Pass `--insecure-storage` to +any `auth` subcommand to instead store credentials in a plain, unencrypted, +owner-only file, at the cost of weaker protection for the stored tokens. On +Windows, this owner-only mode relies on inherited directory permissions +rather than a real ACL, since Go's `Chmod(0600)` maps to the read-only +attribute there rather than restricting access to the current user. ```bash lfx auth login --insecure-storage +lfx auth login --backend=keychain ``` Run `lfx --help` or `lfx --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. diff --git a/go.mod b/go.mod index 7199b68..e66af03 100644 --- a/go.mod +++ b/go.mod @@ -2,19 +2,20 @@ // 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 + github.com/urfave/cli/v3 v3.11.0 + golang.org/x/oauth2 v0.36.0 ) require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/dvsekhvalnov/jose2go v1.8.0 // indirect + github.com/dvsekhvalnov/jose2go v1.10.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/mtibben/percent v0.2.1 // indirect diff --git a/go.sum b/go.sum index 77c7dc3..44c705b 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMF github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= -github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/dvsekhvalnov/jose2go v1.10.0 h1:5RmEnUoQBMBURnk346hX3dKqG60Jkf9qkp6dkLsFx60= +github.com/dvsekhvalnov/jose2go v1.10.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= @@ -31,8 +31,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw= 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= +github.com/urfave/cli/v3 v3.11.0 h1:P/euJp99kb9p0tlVY+iYTLYYTAQlfl0hR2gUO1Img1Q= +github.com/urfave/cli/v3 v3.11.0/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= diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 407629c..948c553 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -5,10 +5,22 @@ package commands import ( + "bufio" "context" + "encoding/base64" + "encoding/json" + "errors" "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "time" + "github.com/linuxfoundation/lfx-cli/internal/credstore" "github.com/urfave/cli/v3" + "golang.org/x/oauth2" ) // insecureStorageFlagName is the auth command group's flag controlling @@ -16,17 +28,36 @@ import ( // unencrypted file. const insecureStorageFlagName = "insecure-storage" +// backendFlagName is the auth command group's flag pinning +// credential storage to a single system keyring backend (see +// `lfx auth backends`), instead of letting keyring.Open silently pick +// whichever backend currently opens. Mutually exclusive with +// --insecure-storage; credStoreFromCommand rejects passing both. +const backendFlagName = "backend" + +// Flag names shared by the login command. +const ( + webFlagName = "web" + withTokenFlagName = "with-token" + envFlagName = "env" + audienceFlagName = "audience" +) + +// scopes requested during the device code flow. offline_access is required +// to receive a refresh token; the rest identify the user for `auth status`. +var loginScopes = []string{"openid", "profile", "email", "offline_access"} + // NewAuthCommand builds the `lfx auth` command group with its subcommands. // -// The --insecure-storage flag is shared by all subcommands and controls -// whether credentials bypass the system keychain in favor of credstore's -// plain (unencrypted) file fallback, e.g. for headless/CI use. -// -// The login, token, status, and logout actions are currently stubs; real -// implementations land in LFXV2-2515 (login flow) and LFXV2-2516 (token -// command), at which point they'll build a credstore.Store via -// credstore.New(credstore.Options{Insecure: -// cmd.Bool(insecureStorageFlagName)}). +// The --insecure-storage and --backend flags are shared by all +// subcommands (they are not declared as "Local" flags, so urfave/cli +// resolves them for subcommand actions via cmd.Bool/cmd.String). +// --insecure-storage controls whether credentials bypass the system +// backend in favor of credstore's plain (unencrypted) file fallback, e.g. +// for headless/CI use. --backend pins credential storage to a +// single system backend rather than letting keyring.Open silently pick +// whichever one currently opens; see credstore.DeviceState.Backend for why +// that matters once a login has pinned one. func NewAuthCommand() *cli.Command { return &cli.Command{ Name: "auth", @@ -34,7 +65,11 @@ func NewAuthCommand() *cli.Command { Flags: []cli.Flag{ &cli.BoolFlag{ Name: insecureStorageFlagName, - Usage: "Store credentials in a plain (unencrypted) file instead of the system keychain", + Usage: "Store credentials in a plain (unencrypted) file instead of the system backend", + }, + &cli.StringFlag{ + Name: backendFlagName, + Usage: "Pin credential storage to a specific system backend (see `lfx auth backends`); mutually exclusive with --insecure-storage", }, }, Commands: []*cli.Command{ @@ -42,27 +77,428 @@ func NewAuthCommand() *cli.Command { newAuthTokenCommand(), newAuthStatusCommand(), newAuthLogoutCommand(), + newAuthBackendsCommand(), }, } } +// credStoreFromCommand builds a credstore.Store using the group-level +// --insecure-storage and --backend flags, however deep in the +// `auth` subcommand tree cmd is. +func credStoreFromCommand(cmd *cli.Command) (credstore.Store, error) { + insecure := cmd.Bool(insecureStorageFlagName) + backend := cmd.String(backendFlagName) + if insecure && backend != "" { + return nil, fmt.Errorf("--%s cannot be combined with --%s", backendFlagName, insecureStorageFlagName) + } + return credstore.New(credstore.Options{Insecure: insecure, Backend: backend}) +} + func newAuthLoginCommand() *cli.Command { return &cli.Command{ Name: "login", Usage: "Log in to the LFX platform via the Auth0 Device Code flow", - Action: func(_ context.Context, _ *cli.Command) error { - fmt.Println("lfx auth login: not yet implemented (see LFXV2-2515)") - return nil + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: webFlagName, + Aliases: []string{"w"}, + Usage: "Automatically open the verification URL in the default browser", + }, + &cli.BoolFlag{ + Name: withTokenFlagName, + Usage: "Read a refresh token from stdin instead of performing the interactive Device Code flow", + }, + &cli.StringFlag{ + Name: envFlagName, + Usage: "Target environment: prod, staging, or development", + Value: string(envProd), + }, + &cli.StringFlag{ + Name: audienceFlagName, + Usage: "Auth0 API audience to request tokens for (independent of --env)", + Value: defaultAudience, + }, + }, + Action: runAuthLogin, + } +} + +func runAuthLogin(ctx context.Context, cmd *cli.Command) error { + store, err := credStoreFromCommand(cmd) + if err != nil { + return err + } + + env := authEnvironment(cmd.String(envFlagName)) + domain, clientID, err := resolveEnvironment(env) + if err != nil { + return err + } + audience := cmd.String(audienceFlagName) + insecure := cmd.Bool(insecureStorageFlagName) + backend := cmd.String(backendFlagName) + + if cmd.Bool(withTokenFlagName) { + return loginWithToken(store, env, domain, audience, insecure, backend) + } + + return loginWithDeviceCode(ctx, cmd, domain, clientID, store, env, audience, insecure, backend) +} + +// loginWithToken implements `--with-token`: it reads a refresh token from +// stdin (one line, trimmed) for headless/CI use, e.g. +// `echo "$REFRESH_TOKEN" | lfx auth login --with-token`. No access token is +// cached; the next `lfx auth token` call exchanges the refresh token for +// one. +func loginWithToken(store credstore.Store, env authEnvironment, domain, audience string, insecure bool, backend string) error { + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("read refresh token from stdin: %w", err) + } + refreshToken := strings.TrimSpace(line) + if refreshToken == "" { + return errors.New("no refresh token provided on stdin") + } + + if err := persistLogin( + store, + credstore.Credentials{RefreshToken: refreshToken}, + credstore.DeviceState{IDPDomain: domain, Environment: string(env), Audience: audience, Insecure: insecure, Backend: backend}, + ); err != nil { + return err + } + + fmt.Println("Logged in with a supplied refresh token.") + return nil +} + +// loginWithDeviceCode performs the interactive Auth0 Device Code flow: +// request a device code, show the user code and verification URL +// (optionally opening it in a browser), then poll until the user completes +// or the flow expires/is denied. +func loginWithDeviceCode( + ctx context.Context, + cmd *cli.Command, + domain, clientID string, + store credstore.Store, + env authEnvironment, + audience string, + insecure bool, + backend string, +) error { + cfg := &oauth2.Config{ + ClientID: clientID, + Scopes: loginScopes, + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: "https://" + domain + "/oauth/device/code", + TokenURL: "https://" + domain + "/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, }, } + var opts []oauth2.AuthCodeOption + if audience != "" { + opts = append(opts, oauth2.SetAuthURLParam("audience", audience)) + } + resp, err := cfg.DeviceAuth(ctx, opts...) + if err != nil { + return fmt.Errorf("request device code: %w", err) + } + + fmt.Printf("First copy your one-time code: %s\n", resp.UserCode) + // VerificationURIComplete is optional per RFC 8628 §3.2; fall back to + // VerificationURI (always present) plus the user code if the IdP + // doesn't supply it. + verificationURI := resp.VerificationURIComplete + if verificationURI == "" { + verificationURI = resp.VerificationURI + } + if cmd.Bool(webFlagName) { + fmt.Printf("Opening %s in your browser...\n", verificationURI) + if err := openBrowser(verificationURI); err != nil { + fmt.Printf("Couldn't open browser automatically: %v\n", err) + fmt.Printf("Please visit: %s\n", verificationURI) + } + } else { + fmt.Printf("Then visit: %s\n", verificationURI) + } + fmt.Println("Waiting for authentication...") + + // DeviceAccessToken blocks until the user completes or rejects the + // flow, polling at the interval Auth0 specified (honoring "slow_down" + // backoff), bounded by the device code's own expiry. + token, err := cfg.DeviceAccessToken(ctx, resp) + if err != nil { + // DeviceAccessToken derives its polling deadline from the device + // code's expiry (RFC 8628 "expires_in") and, once that elapses, + // returns a bare context.DeadlineExceeded instead of waiting for + // one more poll where the server would otherwise answer + // error=expired_token itself. Both mean the same thing -- the + // device code's verification window ran out, not a token or + // network timeout -- so both map to the same message. + var retrieveErr *oauth2.RetrieveError + switch { + case errors.Is(err, context.DeadlineExceeded): + return errors.New("timed out waiting for login") + case errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == "expired_token": + return errors.New("timed out waiting for login") + case errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == "access_denied": + return errors.New("login was denied") + default: + return fmt.Errorf("unexpected error waiting for login: %w", err) + } + } + + // offline_access was requested (see loginScopes), but Auth0 does not + // guarantee a refresh_token is issued -- e.g. a custom API/audience + // can have offline access disabled server-side regardless of the + // scopes requested. Persisting an empty refresh token here would let + // `lfx auth login` report success for a session that silently stops + // working (or worse, is treated as logged-in with no way to refresh) + // as soon as the cached access token expires, so fail loudly instead. + if token.RefreshToken == "" { + return errors.New("login did not receive a refresh token; offline access may be disabled for this API") + } + + if err := persistLogin( + store, + credstore.Credentials{ + RefreshToken: token.RefreshToken, + AccessToken: token.AccessToken, + AccessTokenExpiry: token.Expiry, + }, + credstore.DeviceState{IDPDomain: domain, Environment: string(env), Audience: audience, Insecure: insecure, Backend: backend}, + ); err != nil { + return err + } + + fmt.Println("Login successful.") + if idToken, ok := token.Extra("id_token").(string); ok { + if identity := identityFromIDToken(idToken); identity != "" { + fmt.Printf("Logged in as %s.\n", identity) + } + } + return nil +} + +// loadDeviceStateForBackend loads the persisted device state and validates +// it against the current invocation before returning it: +// +// - state.Insecure must match cmd's --insecure-storage flag. state.json is +// shared by both the keychain and plain-file credential backends (see +// credstore.DeviceState.Insecure), so a mismatch means this invocation's +// credentials were saved under a different backend than the one that +// last wrote state.json -- trusting it here would silently mix a +// refresh token from one backend with IdP/environment metadata written +// for the other. +// - if state.Backend was pinned (non-empty; see +// credstore.DeviceState.Backend), it must match cmd's --backend +// flag. Unlike Insecure, an unpinned state.Backend ("") can't be +// checked at all: keyring.Open can silently land on a different system +// backend across invocations, and once it does there's no recorded +// value to compare against. +// - the IdP domain implied by state.Environment (via resolveEnvironment, +// the source of truth) must match the persisted state.IDPDomain, +// guarding against a tampered or corrupted state.json redirecting a +// refresh request -- and its long-lived refresh token -- to another +// host. +// +// On success it returns the state along with the trusted domain and client +// ID to use for any Auth0 request (always resolveEnvironment's values, +// never the persisted ones). +func loadDeviceStateForBackend(store credstore.Store, cmd *cli.Command) (state credstore.DeviceState, domain, clientID string, err error) { + state, err = store.LoadDeviceState() + if err != nil { + return credstore.DeviceState{}, "", "", err + } + + if state.Insecure != cmd.Bool(insecureStorageFlagName) { + return credstore.DeviceState{}, "", "", fmt.Errorf( + "stored login state belongs to %s; pass %s to match, or run `lfx auth login` again", + backendDescription(state.Insecure), insecureStorageUsageHint(state.Insecure), + ) + } + if state.Backend != "" && state.Backend != cmd.String(backendFlagName) { + return credstore.DeviceState{}, "", "", fmt.Errorf( + "stored login was pinned to keyring backend %q; pass --%s=%s to match, or run `lfx auth login --%s=%s` again", + state.Backend, backendFlagName, state.Backend, backendFlagName, state.Backend, + ) + } + + domain, clientID, err = resolveEnvironment(authEnvironment(state.Environment)) + if err != nil { + return credstore.DeviceState{}, "", "", err + } + if domain != state.IDPDomain { + return credstore.DeviceState{}, "", "", fmt.Errorf( + "stored IdP domain %q does not match %q for environment %q; run `lfx auth login` again", + state.IDPDomain, domain, state.Environment, + ) + } + + return state, domain, clientID, nil +} + +// backendDescription renders a human-readable name for a credential +// backend, for use in error messages. +func backendDescription(insecure bool) string { + if insecure { + return "the plain-file (--insecure-storage) backend" + } + return "the system backend" +} + +// insecureStorageUsageHint renders the flag (or its absence) needed to +// select the given backend, for use in error messages. +func insecureStorageUsageHint(insecure bool) string { + if insecure { + return "--insecure-storage" + } + return "no --insecure-storage" +} + +// stateMatchesInvocation reports whether state was written by an +// invocation using the same --insecure-storage and (if pinned) +// --backend as cmd, i.e. whether it's safe to trust or overwrite +// state.json for this invocation. See loadDeviceStateForBackend for the +// same checks used when actually consuming the state. +func stateMatchesInvocation(state credstore.DeviceState, cmd *cli.Command) bool { + if state.Insecure != cmd.Bool(insecureStorageFlagName) { + return false + } + return state.Backend == "" || state.Backend == cmd.String(backendFlagName) +} + +// stateMismatchReason renders a human-readable explanation of why +// stateMatchesInvocation returned false for state and cmd, for use in the +// advisory notes `status` and `logout` print instead of erroring outright +// (unlike loadDeviceStateForBackend, which does treat this as fatal). +// Distinguishing the two causes matters: backendDescription(state.Insecure) +// alone describes only the Insecure mismatch and, for a --backend pin +// mismatch where Insecure actually matches, would misleadingly reuse the +// current invocation's own backend name and omit the --backend value +// needed to fix it. +func stateMismatchReason(state credstore.DeviceState, cmd *cli.Command) string { + if state.Insecure != cmd.Bool(insecureStorageFlagName) { + return fmt.Sprintf("%s (pass %s to match)", backendDescription(state.Insecure), insecureStorageUsageHint(state.Insecure)) + } + return fmt.Sprintf("keyring backend %q pinned at login (pass --%s=%s to match)", state.Backend, backendFlagName, state.Backend) +} + +// persistLogin saves creds and state as a pair. The two writes are not +// atomic: if SaveDeviceState fails after SaveCredentials succeeds, the +// just-saved credentials are rolled back (deleted) rather than left paired +// with stale or missing environment metadata, which could otherwise send a +// later refresh to the wrong Auth0 tenant. +// +// This rollback is a delete, not a restore: on a re-login (the user was +// already authenticated), SaveCredentials has already overwritten the +// prior working credentials before SaveDeviceState is attempted, so a +// SaveDeviceState failure here logs the user out rather than leaving the +// previous, still-valid session in place. This is considered acceptable: +// the failure mode requires state.json's write to fail (e.g. disk full or +// permissions) immediately after a credentials write succeeded, and the +// fix -- snapshotting and restoring prior credentials instead of deleting +// -- adds meaningful complexity for a narrow, easily-recovered case (rerun +// `lfx auth login`). +func persistLogin(store credstore.Store, creds credstore.Credentials, state credstore.DeviceState) error { + if err := store.SaveCredentials(creds); err != nil { + return fmt.Errorf("save credentials: %w", err) + } + if err := store.SaveDeviceState(state); err != nil { + if delErr := store.DeleteCredentials(); delErr != nil { + return fmt.Errorf("save device state: %w (and rollback of saved credentials also failed: %v)", err, delErr) + } + return fmt.Errorf("save device state: %w", err) + } + return nil +} + +// loadStoredCredentials builds a credstore.Store for cmd and loads its +// credentials, returning (creds, false, nil) when none are stored +// (credstore.ErrNotFound) instead of treating that as an error, since +// callers report "not logged in" differently. +func loadStoredCredentials(cmd *cli.Command) (store credstore.Store, creds credstore.Credentials, found bool, err error) { + store, err = credStoreFromCommand(cmd) + if err != nil { + return nil, credstore.Credentials{}, false, err + } + creds, err = store.LoadCredentials() + if errors.Is(err, credstore.ErrNotFound) { + return store, credstore.Credentials{}, false, nil + } + if err != nil { + return nil, credstore.Credentials{}, false, err + } + return store, creds, true, nil } func newAuthTokenCommand() *cli.Command { return &cli.Command{ Name: "token", Usage: "Print a valid access token for the LFX platform", - Action: func(_ context.Context, _ *cli.Command) error { - fmt.Println("lfx auth token: not yet implemented (see LFXV2-2516)") + Action: func(ctx context.Context, cmd *cli.Command) error { + store, creds, found, err := loadStoredCredentials(cmd) + if err != nil { + return err + } + if !found { + return errors.New("not logged in; run `lfx auth login` first") + } + + // Validate the persisted device state (insecure-storage and + // --backend pinning) before trusting or returning anything + // from creds, including the ValidAccessToken fast path below + // -- otherwise omitting a pinned --backend could still open + // some other auto-detected backend and print its cached + // token, defeating the pin. + _, domain, clientID, err := loadDeviceStateForBackend(store, cmd) + if err != nil { + return fmt.Errorf("load device state: %w", err) + } + + if creds.ValidAccessToken() { + fmt.Println(creds.AccessToken) + return nil + } + + if creds.RefreshToken == "" { + return errors.New("no refresh token available; run `lfx auth login` again") + } + + cfg := &oauth2.Config{ + ClientID: clientID, + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: "https://" + domain + "/oauth/device/code", + TokenURL: "https://" + domain + "/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + } + + token, err := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: creds.RefreshToken}).Token() + var retrieveErr *oauth2.RetrieveError + if errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == "invalid_grant" { + return errors.New("session expired or revoked; run `lfx auth login` to log in again") + } + if err != nil { + return fmt.Errorf("refresh access token: %w", err) + } + + refreshToken := token.RefreshToken + if refreshToken == "" { + // Auth0 may not rotate the refresh token on every + // exchange; keep the existing one in that case. + refreshToken = creds.RefreshToken + } + if err := store.SaveCredentials(credstore.Credentials{ + RefreshToken: refreshToken, + AccessToken: token.AccessToken, + AccessTokenExpiry: token.Expiry, + }); err != nil { + return fmt.Errorf("save refreshed credentials: %w", err) + } + + fmt.Println(token.AccessToken) return nil }, } @@ -72,8 +508,53 @@ func newAuthStatusCommand() *cli.Command { return &cli.Command{ Name: "status", Usage: "Show the current authentication status", - Action: func(_ context.Context, _ *cli.Command) error { - fmt.Println("lfx auth status: not yet implemented (see LFXV2-2515)") + Action: func(_ context.Context, cmd *cli.Command) error { + store, creds, found, err := loadStoredCredentials(cmd) + if err != nil { + return err + } + if !found { + fmt.Println("Not logged in.") + return nil + } + + state, err := store.LoadDeviceState() + if err != nil && !errors.Is(err, credstore.ErrNotFound) { + return fmt.Errorf("load device state: %w", err) + } + + backend := "system backend" + if cmd.Bool(insecureStorageFlagName) { + backend = "plain file (--insecure-storage)" + } + + fmt.Println("Logged in.") + if err == nil && !stateMatchesInvocation(state, cmd) { + fmt.Printf( + " Note: stored login state below belongs to %s, not this credential backend; "+ + "it may not describe these credentials. Run `lfx auth login` to refresh it.\n", + stateMismatchReason(state, cmd), + ) + } + if state.Environment != "" { + fmt.Printf(" %-22s %s\n", "Environment:", state.Environment) + } + if state.IDPDomain != "" { + fmt.Printf(" %-22s %s\n", "IdP domain:", state.IDPDomain) + } + if state.Audience != "" { + fmt.Printf(" %-22s %s\n", "Audience:", state.Audience) + } + fmt.Printf(" %-22s %s\n", "Credential backend:", backend) + if state.Backend != "" { + fmt.Printf(" %-22s %s\n", "Pinned backend:", state.Backend) + } + if creds.ValidAccessToken() { + fmt.Printf(" %-22s %s\n", "Access token expires:", creds.AccessTokenExpiry.Format(time.RFC3339)) + } else { + fmt.Printf(" %-22s %s\n", "Access token:", "expired or not cached (will refresh on next `lfx auth token`)") + } + return nil }, } @@ -83,9 +564,129 @@ func newAuthLogoutCommand() *cli.Command { return &cli.Command{ Name: "logout", Usage: "Remove stored LFX platform credentials", + Action: func(_ context.Context, cmd *cli.Command) error { + store, err := credStoreFromCommand(cmd) + if err != nil { + return err + } + + if err := store.DeleteCredentials(); err != nil { + return fmt.Errorf("delete credentials: %w", err) + } + + // state.json is shared by both credential backends (see + // credstore.DeviceState.Insecure), so only delete it when it + // actually describes this invocation's backend; otherwise + // logging out of one backend would destroy metadata (env, IdP + // domain) still needed by the other backend's credentials. + state, err := store.LoadDeviceState() + switch { + case errors.Is(err, credstore.ErrNotFound): + // Nothing to delete. + case err != nil: + return fmt.Errorf("load device state: %w", err) + case stateMatchesInvocation(state, cmd): + if err := store.DeleteDeviceState(); err != nil { + return fmt.Errorf("delete device state: %w", err) + } + default: + fmt.Printf( + "Note: leaving stored login state in place; it belongs to %s.\n", + stateMismatchReason(state, cmd), + ) + } + + fmt.Println("Logged out.") + return nil + }, + } +} + +// newAuthBackendsCommand builds `lfx auth backends`, which lists the system +// credential-store backends compiled into this binary for the current OS +// (see credstore.AvailableBackends), in the priority order `lfx auth login` +// would try them. It does not attempt to open any backend, so a listed +// backend may still turn out to be unusable at runtime (e.g. no D-Bus +// session for Secret Service, `pass` not initialized). +func newAuthBackendsCommand() *cli.Command { + return &cli.Command{ + Name: "backends", + Usage: "List the system credential-store backends available on this OS", Action: func(_ context.Context, _ *cli.Command) error { - fmt.Println("lfx auth logout: not yet implemented (see LFXV2-2515)") + backends := credstore.AvailableBackends() + if len(backends) == 0 { + fmt.Println("No system credential-store backends are available on this OS; `lfx auth login` requires --insecure-storage.") + return nil + } + + fmt.Println("Available credential-store backends, in the order `lfx auth login` tries them:") + for _, b := range backends { + fmt.Printf(" %-15s %s\n", b.Name, b.DisplayName) + } return nil }, } } + +// lfidClaimsNamespace prefixes the custom LFID claims Auth0 adds to the ID +// token, namely username. Distinct from the shorter "http://lfx.dev/claims" +// LFX claims namespace used elsewhere. +const lfidClaimsNamespace = "https://sso.linuxfoundation.org/claims/" + +// identityFromIDToken extracts a human-readable identity from an unverified +// decode of the ID token's JWT payload, for a friendly "Logged in as ..." +// message; the access token (verified server-side on every API call) is the +// actual credential. LFX usernames (the custom claim above) are the +// conventional identifier; email is included alongside it when present, and +// email or subject alone are used as fallbacks if the custom claim is +// unexpectedly missing. +func identityFromIDToken(idToken string) string { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return "" + } + + payload, err := base64URLDecode(parts[1]) + if err != nil { + return "" + } + + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + + username, _ := claims[lfidClaimsNamespace+"username"].(string) + email, _ := claims["email"].(string) + sub, _ := claims["sub"].(string) + + switch { + case username != "" && email != "": + return fmt.Sprintf("%s (%s)", username, email) + case username != "": + return username + case email != "": + return fmt.Sprintf("%s (no username)", email) + default: + return sub + } +} + +// base64URLDecode decodes a base64url-encoded JWT segment, tolerating the +// missing padding that JWTs conventionally omit. +func base64URLDecode(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +// openBrowser opens url in the user's default browser, following the +// per-OS conventions used by tools like `gh`. +func openBrowser(url string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + return exec.Command("xdg-open", url).Start() + } +} diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go new file mode 100644 index 0000000..1f779c1 --- /dev/null +++ b/internal/commands/auth_test.go @@ -0,0 +1,284 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package commands + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "testing" + + "github.com/linuxfoundation/lfx-cli/internal/credstore" + "github.com/urfave/cli/v3" +) + +// newTestCommand builds a *cli.Command with the --insecure-storage and +// --backend flags registered (as newAuthLoginCommand and friends +// do), parses args against it, and returns the parsed *cli.Command handed +// to fn's Action so tests can read flag values the way the real commands +// do. +func newTestCommand(t *testing.T, args []string, fn func(cmd *cli.Command)) { + t.Helper() + cmd := &cli.Command{ + Name: "test", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: insecureStorageFlagName}, + &cli.StringFlag{Name: backendFlagName}, + }, + Action: func(_ context.Context, cmd *cli.Command) error { + fn(cmd) + return nil + }, + } + if err := cmd.Run(context.Background(), append([]string{"test"}, args...)); err != nil { + t.Fatalf("cmd.Run: %v", err) + } +} + +func newInsecureStore(t *testing.T) credstore.Store { + t.Helper() + store, err := credstore.New(credstore.Options{Insecure: true, StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("credstore.New: %v", err) + } + return store +} + +func TestPersistLoginSuccess(t *testing.T) { + store := newInsecureStore(t) + + creds := credstore.Credentials{RefreshToken: "refresh-token"} + state := credstore.DeviceState{IDPDomain: "linuxfoundation-dev.auth0.com", Environment: "development", Insecure: true} + + if err := persistLogin(store, creds, state); err != nil { + t.Fatalf("persistLogin: %v", err) + } + + gotCreds, err := store.LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if gotCreds != creds { + t.Errorf("LoadCredentials = %+v, want %+v", gotCreds, creds) + } + + gotState, err := store.LoadDeviceState() + if err != nil { + t.Fatalf("LoadDeviceState: %v", err) + } + if gotState != state { + t.Errorf("LoadDeviceState = %+v, want %+v", gotState, state) + } +} + +// failingDeviceStateStore wraps a real Store but always fails +// SaveDeviceState, to exercise persistLogin's rollback path without a real +// disk failure. +type failingDeviceStateStore struct { + credstore.Store +} + +func (f failingDeviceStateStore) SaveDeviceState(credstore.DeviceState) error { + return errors.New("simulated state write failure") +} + +func TestPersistLoginRollsBackCredentialsOnStateFailure(t *testing.T) { + store := failingDeviceStateStore{Store: newInsecureStore(t)} + + err := persistLogin(store, credstore.Credentials{RefreshToken: "refresh-token"}, credstore.DeviceState{}) + if err == nil { + t.Fatal("persistLogin: got nil error, want failure from SaveDeviceState") + } + + // The rollback (documented on persistLogin) deletes the just-saved + // credentials rather than leaving them orphaned without matching + // device state. + if _, err := store.LoadCredentials(); !errors.Is(err, credstore.ErrNotFound) { + t.Fatalf("LoadCredentials after rollback: got err %v, want ErrNotFound", err) + } +} + +func TestLoadDeviceStateForBackendBackendMismatch(t *testing.T) { + store := newInsecureStore(t) + if err := store.SaveDeviceState(credstore.DeviceState{ + IDPDomain: "linuxfoundation-dev.auth0.com", + Environment: "development", + Insecure: true, + }); err != nil { + t.Fatalf("SaveDeviceState: %v", err) + } + + // Parse a command *without* --insecure-storage, so its flag value (false) + // disagrees with the persisted state's Insecure: true. + newTestCommand(t, nil, func(cmd *cli.Command) { + if _, _, _, err := loadDeviceStateForBackend(store, cmd); err == nil { + t.Fatal("loadDeviceStateForBackend: got nil error, want backend-mismatch error") + } + }) +} + +func TestLoadDeviceStateForBackendDomainMismatch(t *testing.T) { + store := newInsecureStore(t) + if err := store.SaveDeviceState(credstore.DeviceState{ + // Environment resolves to a different domain than the one stored + // here, simulating a tampered or corrupted state.json. + IDPDomain: "tampered.example.com", + Environment: "development", + Insecure: true, + }); err != nil { + t.Fatalf("SaveDeviceState: %v", err) + } + + newTestCommand(t, []string{"--insecure-storage"}, func(cmd *cli.Command) { + if _, _, _, err := loadDeviceStateForBackend(store, cmd); err == nil { + t.Fatal("loadDeviceStateForBackend: got nil error, want IdP domain mismatch error") + } + }) +} + +func TestLoadDeviceStateForBackendKeyringBackendMismatch(t *testing.T) { + store := newInsecureStore(t) + if err := store.SaveDeviceState(credstore.DeviceState{ + IDPDomain: "linuxfoundation-dev.auth0.com", + Environment: "development", + Insecure: true, + Backend: "keychain", + }); err != nil { + t.Fatalf("SaveDeviceState: %v", err) + } + + // A pinned state.Backend ("keychain") must match --backend on + // every later command; omitting the flag entirely disagrees with it. + newTestCommand(t, []string{"--insecure-storage"}, func(cmd *cli.Command) { + if _, _, _, err := loadDeviceStateForBackend(store, cmd); err == nil { + t.Fatal("loadDeviceStateForBackend: got nil error, want backend mismatch error") + } + }) + + // A different pinned value also disagrees. + newTestCommand(t, []string{"--insecure-storage", "--" + backendFlagName + "=pass"}, func(cmd *cli.Command) { + if _, _, _, err := loadDeviceStateForBackend(store, cmd); err == nil { + t.Fatal("loadDeviceStateForBackend: got nil error, want backend mismatch error") + } + }) +} + +func TestLoadDeviceStateForBackendUnpinnedAllowsAnyKeyringBackend(t *testing.T) { + store := newInsecureStore(t) + if err := store.SaveDeviceState(credstore.DeviceState{ + IDPDomain: "linuxfoundation-dev.auth0.com", + Environment: "development", + Insecure: true, + // Backend intentionally left unset: an unpinned login can't be + // checked against --backend at all. + }); err != nil { + t.Fatalf("SaveDeviceState: %v", err) + } + + newTestCommand(t, []string{"--insecure-storage", "--" + backendFlagName + "=keychain"}, func(cmd *cli.Command) { + if _, _, _, err := loadDeviceStateForBackend(store, cmd); err != nil { + t.Fatalf("loadDeviceStateForBackend: %v, want nil (unpinned state.Backend can't be checked)", err) + } + }) +} + +func TestLoadDeviceStateForBackendOK(t *testing.T) { + store := newInsecureStore(t) + if err := store.SaveDeviceState(credstore.DeviceState{ + IDPDomain: "linuxfoundation-dev.auth0.com", + Environment: "development", + Insecure: true, + }); err != nil { + t.Fatalf("SaveDeviceState: %v", err) + } + + newTestCommand(t, []string{"--insecure-storage"}, func(cmd *cli.Command) { + _, domain, clientID, err := loadDeviceStateForBackend(store, cmd) + if err != nil { + t.Fatalf("loadDeviceStateForBackend: %v", err) + } + if domain != "linuxfoundation-dev.auth0.com" { + t.Errorf("domain = %q, want linuxfoundation-dev.auth0.com", domain) + } + if clientID == "" { + t.Error("clientID = \"\", want a non-empty compiled-in client ID") + } + }) +} + +// fakeIDToken builds an unsigned JWT with the given claims as its payload, +// matching the shape identityFromIDToken decodes (header/payload/signature +// separated by ".", payload base64url-encoded JSON). The header and +// signature segments are never inspected, so their content is arbitrary. +func fakeIDToken(t *testing.T, claims map[string]any) string { + t.Helper() + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" +} + +func TestIdentityFromIDToken(t *testing.T) { + tests := []struct { + name string + token func(t *testing.T) string + claims map[string]any + want string + }{ + { + name: "username and email", + claims: map[string]any{lfidClaimsNamespace + "username": "jdoe", "email": "jdoe@example.com"}, + want: "jdoe (jdoe@example.com)", + }, + { + name: "username only", + claims: map[string]any{lfidClaimsNamespace + "username": "jdoe"}, + want: "jdoe", + }, + { + name: "email only, no username claim", + claims: map[string]any{"email": "jdoe@example.com"}, + want: "jdoe@example.com (no username)", + }, + { + name: "subject only", + claims: map[string]any{"sub": "auth0|abc123"}, + want: "auth0|abc123", + }, + { + name: "no usable claims", + claims: map[string]any{}, + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + token := fakeIDToken(t, tc.claims) + if got := identityFromIDToken(token); got != tc.want { + t.Errorf("identityFromIDToken(%q) = %q, want %q", token, got, tc.want) + } + }) + } + + t.Run("malformed token", func(t *testing.T) { + if got := identityFromIDToken("not-a-jwt"); got != "" { + t.Errorf("identityFromIDToken(malformed) = %q, want \"\"", got) + } + }) + + t.Run("invalid base64 payload", func(t *testing.T) { + if got := identityFromIDToken("header.not!base64url.signature"); got != "" { + t.Errorf("identityFromIDToken(invalid base64) = %q, want \"\"", got) + } + }) + + t.Run("payload not JSON", func(t *testing.T) { + token := "header." + base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".signature" + if got := identityFromIDToken(token); got != "" { + t.Errorf("identityFromIDToken(non-JSON payload) = %q, want \"\"", got) + } + }) +} diff --git a/internal/commands/environment.go b/internal/commands/environment.go new file mode 100644 index 0000000..04a4667 --- /dev/null +++ b/internal/commands/environment.go @@ -0,0 +1,68 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Package commands implements the lfx CLI subcommands. +package commands + +import ( + "errors" + "fmt" +) + +// authEnvironment identifies which LFX Auth0 tenant/IdP a login targets, +// selected via the `--env` flag. +type authEnvironment string + +// Supported environments. Each maps to a fixed IdP domain and a static, +// pre-provisioned Auth0 native application client ID (device_code + +// refresh_token grants). CIMD was evaluated and abandoned for this flow: +// Auth0 silently drops CIMD client registration for the device_code grant. +const ( + envProd authEnvironment = "prod" + envStaging authEnvironment = "staging" + envDevelopment authEnvironment = "development" +) + +// authDomains maps each authEnvironment 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 CLI 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 authDomains = map[authEnvironment]string{ + envProd: "sso.linuxfoundation.org", + envStaging: "linuxfoundation-staging.auth0.com", + envDevelopment: "linuxfoundation-dev.auth0.com", +} + +// authClientIDs maps each authEnvironment 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 authClientIDs = map[authEnvironment]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`: +// 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 resolveEnvironment for an +// unrecognized authEnvironment value. +var errInvalidEnvironment = errors.New("invalid environment") + +// resolveEnvironment returns the IdP domain and client ID for env. +func resolveEnvironment(env authEnvironment) (domain, clientID string, err error) { + domain, ok := authDomains[env] + if !ok { + return "", "", fmt.Errorf("%w: %q (must be one of prod, staging, development)", errInvalidEnvironment, env) + } + return domain, authClientIDs[env], nil +} diff --git a/internal/credstore/credstore.go b/internal/credstore/credstore.go index 660c015..f69626e 100644 --- a/internal/credstore/credstore.go +++ b/internal/credstore/credstore.go @@ -7,9 +7,13 @@ // Secrets (refresh token, cached access token and its expiry) are stored in // the operating system's credential store via github.com/99designs/keyring: // macOS Keychain, Windows Credential Manager, Linux Secret Service/KWallet, -// or the `pass` password store. keyring's own encrypted-file backend is -// deliberately excluded from this list: it isn't a real system keychain, and -// its Remove behavior doesn't match keyring.ErrKeyNotFound (see +// or the `pass` password store. Which of these keyring.Open actually +// selects can vary between invocations on the same machine (e.g. Secret +// Service reachable in one shell session but not another); --backend +// pins it to one, and once pinned, the DeviceState.Backend it was pinned to +// must match on every later command. keyring's own encrypted-file backend +// is deliberately excluded from this list: it isn't a real system keychain, +// and its Remove behavior doesn't match keyring.ErrKeyNotFound (see // keyringSecrets.Delete). If none of the allowed backends are available, // New returns an error rather than silently falling back to a file. // @@ -22,9 +26,10 @@ // acceptable, and is deliberately less secure than the keyring-backed // storage. // -// Non-sensitive state (device ID, IdP domain used at login) is always stored -// as plain JSON under the XDG state directory (~/.local/state/lfx-cli/ by -// default), per XDG Base Directory conventions for mutable runtime state. +// Non-sensitive state (environment, IdP domain, and audience used at login) +// is always stored as plain JSON under the XDG state directory +// (~/.local/state/lfx-cli/ by default), per XDG Base Directory conventions +// for mutable runtime state. package credstore import ( @@ -33,6 +38,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/99designs/keyring" @@ -76,6 +82,81 @@ var systemBackends = []keyring.BackendType{ keyring.PassBackend, } +// backendDisplayNames maps keyring.BackendType values to the human-readable +// names shown by `lfx auth backends`. +var backendDisplayNames = map[keyring.BackendType]string{ + keyring.SecretServiceBackend: "Secret Service (GNOME Keyring, KeePassXC, etc.)", + keyring.KeychainBackend: "macOS Keychain", + keyring.KWalletBackend: "KDE Wallet (kwallet)", + keyring.WinCredBackend: "Windows Credential Manager", + keyring.PassBackend: "gpg-encrypted vault (passwordstore.org)", +} + +// Backend describes one of the system credential-store backends compiled +// into this binary for the current OS, as reported by `lfx auth backends`. +type Backend struct { + // Name is the keyring.BackendType identifier (e.g. "keychain"). + Name string + // DisplayName is a human-readable label for Name. + DisplayName string +} + +// AvailableBackends reports the system credential-store backends compiled +// into this binary for the current OS (Go build tags determine which +// backends are even possible per-platform; see the per-backend source +// files in github.com/99designs/keyring), in the same priority order (see +// systemBackends) that New passes to keyring.Open as AllowedBackends. It +// does not attempt to open any backend, so a backend listed here may still +// fail at login time if it isn't actually usable at runtime (e.g. no D-Bus +// session for Secret Service, `pass` not initialized, etc.). +func AvailableBackends() []Backend { + available := make(map[keyring.BackendType]bool) + for _, b := range keyring.AvailableBackends() { + available[b] = true + } + + var backends []Backend + for _, b := range systemBackends { + if !available[b] { + continue + } + backends = append(backends, Backend{ + Name: string(b), + DisplayName: backendDisplayNames[b], + }) + } + return backends +} + +// isAvailableBackend reports whether bt is one of the backends actually +// compiled into this binary for the current OS (see AvailableBackends), +// not merely one of the cross-platform systemBackends identifiers. New +// validates --backend against this, not systemBackends: otherwise a value +// like "keychain" would be accepted on Linux (where it's never even +// compiled in, per the darwin-only build tag on +// github.com/99designs/keyring's Keychain backend) only to fail later with +// a generic keyring-open error instead of a clear "unsupported on this OS" +// message. +func isAvailableBackend(bt keyring.BackendType) bool { + for _, b := range AvailableBackends() { + if b.Name == string(bt) { + return true + } + } + return false +} + +// availableBackendNames renders AvailableBackends' Name fields for use in +// error messages about an invalid --backend value. +func availableBackendNames() []string { + backends := AvailableBackends() + names := make([]string, len(backends)) + for i, b := range backends { + names[i] = b.Name + } + return names +} + // Credentials holds the secrets needed to authenticate with the LFX // platform: the long-lived Auth0 refresh token, and an optional cached // access token with its expiry. @@ -94,10 +175,51 @@ func (c Credentials) ValidAccessToken() bool { // DeviceState holds non-sensitive information persisted between CLI // invocations so that commands like `lfx auth token` don't need to -// re-specify the IdP domain used at login. +// re-specify the environment, IdP domain, or audience used at login. +// +// Note: this deliberately does not include a persistent "device ID". One +// was considered (see LFXV2-2515/LFXV2-2509 discussion) on the assumption +// that `gh` uses one as part of its OAuth device flow, but `gh`'s +// `~/.local/state/gh/device-id` is actually just an anonymous telemetry +// identifier (see `internal/telemetry.getOrCreateDeviceID` in +// github.com/cli/cli) -- it plays no role in the OAuth device +// authorization grant and isn't sent to GitHub's API. Since the LFX CLI +// has no telemetry pipeline, and Auth0's device flow has no concept of a +// device ID at all, there's nothing here for one to do. Revisit if/when +// opt-in CLI telemetry is added. type DeviceState struct { - DeviceID string `json:"device_id"` IDPDomain string `json:"idp_domain,omitempty"` + // Environment is the `--env` value used at login (prod, staging, or + // development), determining which compiled-in client ID is used to + // refresh the access token. + Environment string `json:"environment,omitempty"` + // Audience is the `--audience` value used at login. Auth0's + // refresh_token grant automatically ties the refreshed access token + // to the audience it was originally issued for, so this isn't sent + // back on refresh; it's persisted purely for display in + // `lfx auth status`. + Audience string `json:"audience,omitempty"` + // Insecure records whether `--insecure-storage` was passed at login, + // i.e. whether Credentials live in the plain-file backend rather than + // the system keychain. state.json itself is not namespaced by + // backend (both share the same file), so callers must check this + // against the invocation's own --insecure-storage flag before trusting + // the rest of the state: without that check, logging into one backend + // silently overwrites the metadata (env, IdP domain) that the other + // backend's still-present credentials depend on. + Insecure bool `json:"insecure,omitempty"` + // Backend records the keyring.BackendType (e.g. "keychain") pinned via + // --backend at login, or "" if the backend was left to + // keyring.Open's own auto-detection. Unlike Insecure, an empty value + // here is not itself trustworthy: keyring.Open can silently select a + // *different* system backend across invocations (e.g. Secret Service + // is reachable in one shell session but not another, falling back to + // pass), so an unpinned login can't be protected against later landing + // on a different backend with the same state.json. Once a backend has + // been pinned, though, callers must require the same --backend + // value on every later command against this state, the same way + // Insecure is enforced. + Backend string `json:"backend,omitempty"` } // Store is the credential storage abstraction used by the auth commands. @@ -117,6 +239,9 @@ type Store interface { // LoadDeviceState returns the persisted device state, or ErrNotFound if // none has been saved. LoadDeviceState() (DeviceState, error) + // DeleteDeviceState removes any persisted device state. It is a no-op + // if none exists. + DeleteDeviceState() error } // Options configures a Store returned by New. @@ -132,6 +257,13 @@ type Options struct { // leave empty to use $XDG_STATE_HOME/lfx-cli (or ~/.local/state/lfx-cli // if $XDG_STATE_HOME is unset). StateDir string + + // Backend pins keyring.Open to a single system backend (e.g. + // "keychain"; see AvailableBackends for the valid values on this OS), + // instead of letting it probe systemBackends in priority order and + // silently use whichever one currently opens. Ignored when Insecure is + // set. Leave empty to keep the previous auto-detecting behavior. + Backend string } // secretsBackend abstracts over the two ways Credentials can be persisted: @@ -163,6 +295,18 @@ func New(opts Options) (Store, error) { path: filepath.Join(stateDir, insecureCredentialsFileName), } } else { + allowedBackends := systemBackends + if opts.Backend != "" { + bt := keyring.BackendType(opts.Backend) + if !isAvailableBackend(bt) { + return nil, fmt.Errorf( + "credstore: unknown or unsupported --backend %q on this OS (available: %s)", + opts.Backend, strings.Join(availableBackendNames(), ", "), + ) + } + allowedBackends = []keyring.BackendType{bt} + } + kr, err := keyring.Open(keyring.Config{ ServiceName: serviceName, // The pass backend namespaces entries via PassPrefix, not @@ -170,7 +314,7 @@ func New(opts Options) (Store, error) { // stored as a top-level "credentials" entry in the user's // password store, risking collisions with unrelated tools. PassPrefix: serviceName, - AllowedBackends: systemBackends, + AllowedBackends: allowedBackends, KeychainTrustApplication: true, }) if err != nil { @@ -297,6 +441,18 @@ func (s *store) LoadDeviceState() (DeviceState, error) { return state, nil } +// DeleteDeviceState implements Store. +func (s *store) DeleteDeviceState() error { + path := filepath.Join(s.stateDir, stateFileName) + + err := os.Remove(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("credstore: delete device state: %w", err) + } + + return nil +} + // keyringSecrets is a secretsBackend that stores Credentials in a real // system credential store via keyring.Keyring (see systemBackends). type keyringSecrets struct { diff --git a/internal/credstore/credstore_test.go b/internal/credstore/credstore_test.go new file mode 100644 index 0000000..8d7d363 --- /dev/null +++ b/internal/credstore/credstore_test.go @@ -0,0 +1,148 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package credstore + +import ( + "errors" + "testing" + "time" +) + +// newTestStore builds a Store rooted at a temporary directory using the +// insecure (plain-file) backend, so tests never touch the real OS +// keychain. +func newTestStore(t *testing.T) Store { + t.Helper() + store, err := New(Options{Insecure: true, StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("New: %v", err) + } + return store +} + +func TestNewRejectsUnavailableBackend(t *testing.T) { + // Not a real keyring.BackendType on any OS, so this must be rejected + // regardless of which backends this build's OS actually compiles in + // (see isAvailableBackend, which checks AvailableBackends() rather + // than the cross-platform systemBackends list). + _, err := New(Options{Backend: "totally-bogus-backend", StateDir: t.TempDir()}) + if err == nil { + t.Fatal("New: got nil error, want unavailable-backend error") + } +} + +func TestCredentialsRoundTrip(t *testing.T) { + store := newTestStore(t) + + if _, err := store.LoadCredentials(); !errors.Is(err, ErrNotFound) { + t.Fatalf("LoadCredentials before save: got err %v, want ErrNotFound", err) + } + + want := Credentials{ + RefreshToken: "refresh-token", + AccessToken: "access-token", + AccessTokenExpiry: time.Now().Add(time.Hour).Truncate(time.Second), + } + if err := store.SaveCredentials(want); err != nil { + t.Fatalf("SaveCredentials: %v", err) + } + + got, err := store.LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + // AccessTokenExpiry round-trips through JSON, which can change its + // time.Time representation (e.g. Location) without changing the + // instant it represents, so compare it with Equal rather than as + // part of a struct-level != comparison. + if got.RefreshToken != want.RefreshToken || got.AccessToken != want.AccessToken || !got.AccessTokenExpiry.Equal(want.AccessTokenExpiry) { + t.Fatalf("LoadCredentials = %+v, want %+v", got, want) + } + + if err := store.DeleteCredentials(); err != nil { + t.Fatalf("DeleteCredentials: %v", err) + } + if _, err := store.LoadCredentials(); !errors.Is(err, ErrNotFound) { + t.Fatalf("LoadCredentials after delete: got err %v, want ErrNotFound", err) + } + + // DeleteCredentials is documented as a no-op when nothing is stored. + if err := store.DeleteCredentials(); err != nil { + t.Fatalf("DeleteCredentials on empty store: %v", err) + } +} + +func TestDeviceStateRoundTrip(t *testing.T) { + store := newTestStore(t) + + if _, err := store.LoadDeviceState(); !errors.Is(err, ErrNotFound) { + t.Fatalf("LoadDeviceState before save: got err %v, want ErrNotFound", err) + } + + want := DeviceState{ + IDPDomain: "linuxfoundation-dev.auth0.com", + Environment: "development", + Audience: "https://example.test/", + Insecure: true, + } + if err := store.SaveDeviceState(want); err != nil { + t.Fatalf("SaveDeviceState: %v", err) + } + + got, err := store.LoadDeviceState() + if err != nil { + t.Fatalf("LoadDeviceState: %v", err) + } + if got != want { + t.Fatalf("LoadDeviceState = %+v, want %+v", got, want) + } + + if err := store.DeleteDeviceState(); err != nil { + t.Fatalf("DeleteDeviceState: %v", err) + } + if _, err := store.LoadDeviceState(); !errors.Is(err, ErrNotFound) { + t.Fatalf("LoadDeviceState after delete: got err %v, want ErrNotFound", err) + } + + // DeleteDeviceState is documented as a no-op when nothing is stored. + if err := store.DeleteDeviceState(); err != nil { + t.Fatalf("DeleteDeviceState on empty store: %v", err) + } +} + +func TestValidAccessToken(t *testing.T) { + tests := []struct { + name string + creds Credentials + want bool + }{ + { + name: "no access token", + creds: Credentials{AccessTokenExpiry: time.Now().Add(time.Hour)}, + want: false, + }, + { + name: "expired", + creds: Credentials{AccessToken: "tok", AccessTokenExpiry: time.Now().Add(-time.Minute)}, + want: false, + }, + { + name: "within clock-skew buffer of expiry", + creds: Credentials{AccessToken: "tok", AccessTokenExpiry: time.Now().Add(10 * time.Second)}, + want: false, + }, + { + name: "valid", + creds: Credentials{AccessToken: "tok", AccessTokenExpiry: time.Now().Add(time.Hour)}, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.creds.ValidAccessToken(); got != tc.want { + t.Errorf("ValidAccessToken() = %v, want %v", got, tc.want) + } + }) + } +}