diff --git a/README.md b/README.md index d4f1040..a646b2f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ Automatic browser opening accepts only absolute HTTPS URLs with a hostname. Other verification URI values are displayed for manual inspection without launching them; control characters in login instructions are escaped. +`lfx api` always sends requests to the compiled-in default API for the +login's environment (see `lfx auth environments`); the base URL is not +configurable per call. + 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`). Which of these is diff --git a/internal/commands/api.go b/internal/commands/api.go index 8aee360..11e382d 100644 --- a/internal/commands/api.go +++ b/internal/commands/api.go @@ -29,7 +29,6 @@ const ( apiFieldFlagName = "field" apiRawFieldFlagName = "raw-field" apiQueryFlagName = "query" - apiHostnameFlagName = "hostname" ) // apiAllowedMethods enumerates the HTTP methods `lfx api` accepts via @@ -89,10 +88,6 @@ func NewAPICommand() *cli.Command { Aliases: []string{"q"}, Usage: "Filter the response body through a gjson expression before output", }, - &cli.StringFlag{ - Name: apiHostnameFlagName, - Usage: "Override the LFX API base URL (advanced; independent of the IdP domain). Requires a development-environment login ('lfx auth login --env=development').", - }, }, Action: runAPI, } @@ -120,37 +115,14 @@ func runAPI(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("invalid --%s %q (must be one of GET, POST, PUT, DELETE)", apiMethodFlagName, method) } - token, audience, env, err := resolveAccessToken(ctx, cmd) + token, env, err := resolveAccessToken(ctx, cmd) if err != nil { return err } - baseURL := cmd.String(apiHostnameFlagName) - if cmd.IsSet(apiHostnameFlagName) { - if baseURL == "" { - return errors.New("--hostname was set to an empty value; omit the flag to use the login audience instead") - } - // --hostname sends the live bearer token to whatever host is - // named. The purpose of this restriction is to keep a prod or - // staging token -- the ones with real authority -- from being - // redirected to a third-party host at all; --hostname's actual - // use case (pointing at a local mock server or alternate - // deployment for advanced troubleshooting) only comes up in - // development anyway. Allowing it there is also independently - // safe: OAuth2 resource servers validate a token's issuer - // (`iss`), not just its audience, against the specific IdP(s) - // they trust, so even in the hypothetical where a development - // login held a token claiming the prod audience, prod would - // still reject it as issued by an untrusted IdP. - if env != envDevelopment { - return fmt.Errorf("--%s requires a development-environment login (run `lfx auth login --%s=%s`); the current login's environment is %q", apiHostnameFlagName, envFlagName, envDevelopment, env) - } - } - if baseURL == "" { - baseURL = audience - } - if baseURL == "" { - return errors.New("no API base URL available; log in with `lfx auth login` or pass --hostname") + baseURL, err := defaultAudienceForEnvironment(env) + if err != nil { + return err } if err := apiRequireHTTPS(baseURL); err != nil { return err @@ -359,11 +331,11 @@ func coerceFieldValue(value string) any { // apiJoinURL joins base and path into a single URL, ensuring exactly one // slash separates them. path is passed through unmodified (aside from a // leading-slash trim) rather than parsed/re-escaped: base is already -// trusted (the login audience, or --hostname, which is gated to -// development-environment logins in runAPI) and path is meant to be sent -// verbatim, including its query string, exactly as the caller wrote it -- -// a URL-parsing round trip risks subtly rewriting it (see the query -// string and escaped-slash regressions caught in review). +// trusted (env's compiled-in default audience; see +// defaultAudienceForEnvironment), and path is meant to be sent verbatim, +// including its query string, exactly as the caller wrote it -- a +// URL-parsing round trip risks subtly rewriting it (see the query string +// and escaped-slash regressions caught in review). func apiJoinURL(base, path string) (string, error) { if base == "" { return "", errors.New("empty base URL") @@ -373,9 +345,8 @@ func apiJoinURL(base, path string) (string, error) { // apiRequireHTTPS rejects base URLs that would send the bearer token over // a non-HTTPS connection, since the request's Authorization header is -// otherwise transmitted in cleartext. Loopback hosts are exempted, since -// http:// there is a common and low-risk way to point --hostname at a -// local mock server for debugging. +// otherwise transmitted in cleartext. Loopback hosts are exempted as a +// low-risk allowance for local mock servers during development. func apiRequireHTTPS(rawURL string) error { parsed, err := url.Parse(rawURL) if err != nil { @@ -388,5 +359,5 @@ func apiRequireHTTPS(rawURL string) error { case "localhost", "127.0.0.1", "::1": return nil } - return fmt.Errorf("refusing to send credentials to non-HTTPS URL %q (use an https:// --hostname, or localhost/127.0.0.1 for local debugging)", rawURL) + return fmt.Errorf("refusing to send credentials to non-HTTPS URL %q (use an https:// URL, or localhost/127.0.0.1 for local debugging)", rawURL) } diff --git a/internal/commands/auth.go b/internal/commands/auth.go index cc478d0..af36be8 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -43,7 +43,6 @@ const ( webFlagName = "web" withTokenFlagName = "with-token" envFlagName = "env" - audienceFlagName = "audience" ) // CredentialStoreFlags are the --insecure-storage and --backend flags @@ -123,10 +122,6 @@ func newAuthLoginCommand() *cli.Command { Usage: "Target environment; see 'lfx auth environments' for accepted values", Value: string(envProduction), }, - &cli.StringFlag{ - Name: audienceFlagName, - Usage: "Auth0 API audience to request tokens for; defaults to --env's audience (see 'lfx auth environments')", - }, }, Action: runAuthLogin, } @@ -143,21 +138,14 @@ func runAuthLogin(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - audience := cmd.String(audienceFlagName) - if !cmd.IsSet(audienceFlagName) { - audience, err = defaultAudienceForEnvironment(env) - if err != nil { - return err - } - } insecure := cmd.Bool(insecureStorageFlagName) backend := cmd.String(backendFlagName) if cmd.Bool(withTokenFlagName) { - return loginWithToken(store, env, domain, audience, insecure, backend) + return loginWithToken(store, env, domain, insecure, backend) } - return loginWithDeviceCode(ctx, cmd, domain, clientID, store, env, audience, insecure, backend) + return loginWithDeviceCode(ctx, cmd, domain, clientID, store, env, insecure, backend) } // loginWithToken implements `--with-token`: it reads a refresh token from @@ -165,7 +153,7 @@ func runAuthLogin(ctx context.Context, cmd *cli.Command) error { // `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 { +func loginWithToken(store credstore.Store, env authEnvironment, domain string, insecure bool, backend string) error { reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { @@ -179,7 +167,7 @@ func loginWithToken(store credstore.Store, env authEnvironment, domain, audience if err := persistLogin( store, credstore.Credentials{RefreshToken: refreshToken}, - credstore.DeviceState{IDPDomain: domain, Environment: string(env), Audience: audience, Insecure: insecure, Backend: backend}, + credstore.DeviceState{IDPDomain: domain, Environment: string(env), Insecure: insecure, Backend: backend}, ); err != nil { return err } @@ -198,10 +186,13 @@ func loginWithDeviceCode( domain, clientID string, store credstore.Store, env authEnvironment, - audience string, insecure bool, backend string, ) error { + audience, err := defaultAudienceForEnvironment(env) + if err != nil { + return err + } cfg := &oauth2.Config{ ClientID: clientID, Scopes: loginScopes, @@ -211,10 +202,7 @@ func loginWithDeviceCode( AuthStyle: oauth2.AuthStyleInParams, }, } - var opts []oauth2.AuthCodeOption - if audience != "" { - opts = append(opts, oauth2.SetAuthURLParam("audience", audience)) - } + opts := []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("audience", audience)} resp, err := cfg.DeviceAuth(ctx, opts...) if err != nil { return fmt.Errorf("request device code: %w", err) @@ -268,7 +256,7 @@ func loginWithDeviceCode( AccessToken: token.AccessToken, AccessTokenExpiry: token.Expiry, }, - credstore.DeviceState{IDPDomain: domain, Environment: string(env), Audience: audience, Insecure: insecure, Backend: backend}, + credstore.DeviceState{IDPDomain: domain, Environment: string(env), Insecure: insecure, Backend: backend}, ); err != nil { return err } @@ -439,7 +427,7 @@ func newAuthTokenCommand() *cli.Command { Name: "token", Usage: "Print a valid access token for the LFX platform", Action: func(ctx context.Context, cmd *cli.Command) error { - token, _, _, err := resolveAccessToken(ctx, cmd) + token, _, err := resolveAccessToken(ctx, cmd) if err != nil { return err } @@ -451,19 +439,19 @@ func newAuthTokenCommand() *cli.Command { // resolveAccessToken returns a valid access token for the current login, // refreshing it (and persisting the refreshed credentials) if the cached -// one is missing or expired. It also returns the audience and login -// environment recorded at login time, so callers (e.g. `lfx api`) can use -// the audience as their default API base URL and the environment to gate -// features like --hostname to non-production logins. Both `lfx auth -// token` and `lfx api` share this single code path so their refresh, -// error, and credential-persistence behavior never drifts apart. -func resolveAccessToken(ctx context.Context, cmd *cli.Command) (token, audience string, env authEnvironment, err error) { +// one is missing or expired. It also returns the login environment, so +// callers (e.g. `lfx api`) can compute the environment's compiled-in +// default audience (see defaultAudienceForEnvironment) themselves, rather +// than trusting anything persisted in state.json. Both `lfx auth token` +// and `lfx api` share this single code path so their refresh, error, and +// credential-persistence behavior never drifts apart. +func resolveAccessToken(ctx context.Context, cmd *cli.Command) (token string, env authEnvironment, err error) { store, creds, found, err := loadStoredCredentials(cmd) if err != nil { - return "", "", "", err + return "", "", err } if !found { - return "", "", "", errors.New("not logged in; run `lfx auth login` first") + return "", "", errors.New("not logged in; run `lfx auth login` first") } // Validate the persisted device state (insecure-storage and @@ -474,15 +462,15 @@ func resolveAccessToken(ctx context.Context, cmd *cli.Command) (token, audience // pin. state, domain, clientID, err := loadDeviceStateForBackend(store, cmd) if err != nil { - return "", "", "", fmt.Errorf("load device state: %w", err) + return "", "", fmt.Errorf("load device state: %w", err) } if creds.ValidAccessToken() { - return creds.AccessToken, state.Audience, normalizeEnvironment(state.Environment), nil + return creds.AccessToken, normalizeEnvironment(state.Environment), nil } if creds.RefreshToken == "" { - return "", "", "", errors.New("no refresh token available; run `lfx auth login` again") + return "", "", errors.New("no refresh token available; run `lfx auth login` again") } cfg := &oauth2.Config{ @@ -497,10 +485,10 @@ func resolveAccessToken(ctx context.Context, cmd *cli.Command) (token, audience refreshed, 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") + 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) + return "", "", fmt.Errorf("refresh access token: %w", err) } refreshToken := refreshed.RefreshToken @@ -514,10 +502,10 @@ func resolveAccessToken(ctx context.Context, cmd *cli.Command) (token, audience AccessToken: refreshed.AccessToken, AccessTokenExpiry: refreshed.Expiry, }); err != nil { - return "", "", "", fmt.Errorf("save refreshed credentials: %w", err) + return "", "", fmt.Errorf("save refreshed credentials: %w", err) } - return refreshed.AccessToken, state.Audience, normalizeEnvironment(state.Environment), nil + return refreshed.AccessToken, normalizeEnvironment(state.Environment), nil } func newAuthStatusCommand() *cli.Command { @@ -553,14 +541,15 @@ func newAuthStatusCommand() *cli.Command { ) } if state.Environment != "" { - fmt.Printf(" %-22s %s\n", "Environment:", normalizeEnvironment(state.Environment)) + env := normalizeEnvironment(state.Environment) + fmt.Printf(" %-22s %s\n", "Environment:", env) + if audience, err := defaultAudienceForEnvironment(env); err == nil { + fmt.Printf(" %-22s %s\n", "Audience:", audience) + } } 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) diff --git a/internal/commands/environment.go b/internal/commands/environment.go index 3d0e7cd..fd94590 100644 --- a/internal/commands/environment.go +++ b/internal/commands/environment.go @@ -31,16 +31,6 @@ const ( // 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. -// -// `runAPI`'s --hostname gate (api.go) restricts redirecting the API base -// URL to development-environment logins: the goal is to keep a prod or -// staging token -- the ones with real authority -- from ever being sent -// to a third-party host, since --hostname's troubleshooting use case only -// comes up in development anyway. That restriction is also independently -// safe, since each tenant here is a distinct OAuth2 issuer and resource -// servers validate a token's issuer (`iss`), not just its audience: even -// a development-issued token claiming the prod audience would still be -// rejected by prod as untrusted. var authDomains = map[authEnvironment]string{ envProduction: "sso.linuxfoundation.org", envStaging: "linuxfoundation-staging.auth0.com", @@ -59,9 +49,9 @@ var authClientIDs = map[authEnvironment]string{ // cspell:enable -// defaultAudiences maps each authEnvironment to the matching LFX v2 API -// audience used when `lfx auth login` is run without an explicit -// `--audience` override. +// defaultAudiences maps each authEnvironment to the compiled-in LFX v2 API +// audience that `lfx auth login` requests and `lfx api` sends requests to +// for that environment. var defaultAudiences = map[authEnvironment]string{ envProduction: "https://lfx-api.v2.cluster.lfx.dev/", envStaging: "https://lfx-api.staging.v2.cluster.linuxfound.info/", diff --git a/internal/credstore/credstore.go b/internal/credstore/credstore.go index 69e317b..e95b298 100644 --- a/internal/credstore/credstore.go +++ b/internal/credstore/credstore.go @@ -26,8 +26,8 @@ // acceptable, and is deliberately less secure than the keyring-backed // storage. // -// Non-sensitive state (environment, IdP domain, and audience used at login) -// is always stored as plain JSON under the XDG state directory +// Non-sensitive state (environment and 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. package credstore @@ -175,7 +175,7 @@ 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 environment, IdP domain, or audience used at login. +// re-specify the environment or IdP domain used at login. // // Note: this deliberately does not include a persistent "device ID". One // was considered on the assumption that `gh` uses one as part of its @@ -193,12 +193,6 @@ type DeviceState struct { // 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 diff --git a/internal/credstore/credstore_test.go b/internal/credstore/credstore_test.go index 8d7d363..e4ff536 100644 --- a/internal/credstore/credstore_test.go +++ b/internal/credstore/credstore_test.go @@ -83,7 +83,6 @@ func TestDeviceStateRoundTrip(t *testing.T) { want := DeviceState{ IDPDomain: "linuxfoundation-dev.auth0.com", Environment: "development", - Audience: "https://example.test/", Insecure: true, } if err := store.SaveDeviceState(want); err != nil {