Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 12 additions & 41 deletions internal/commands/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const (
apiFieldFlagName = "field"
apiRawFieldFlagName = "raw-field"
apiQueryFlagName = "query"
apiHostnameFlagName = "hostname"
)

// apiAllowedMethods enumerates the HTTP methods `lfx api` accepts via
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand All @@ -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)
}
75 changes: 32 additions & 43 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ const (
webFlagName = "web"
withTokenFlagName = "with-token"
envFlagName = "env"
audienceFlagName = "audience"
)

// CredentialStoreFlags are the --insecure-storage and --backend flags
Expand Down Expand Up @@ -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,
}
Expand All @@ -143,29 +138,22 @@ 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
// 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 {
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) {
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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{
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 3 additions & 13 deletions internal/commands/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/",
Expand Down
12 changes: 3 additions & 9 deletions internal/credstore/credstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion internal/credstore/credstore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading