diff --git a/CHANGELOG.md b/CHANGELOG.md index 87db7d2..5c4d97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added + +- Add `hb auth login` for OAuth sign-in as an alternative to personal auth + tokens, plus `hb auth status` and `hb auth logout`. Login picks the best + method automatically — the browser-based authorization code flow with PKCE + (RFC 8252/7636) locally, or the device authorization flow (RFC 8628) in + SSH/headless environments — and `--web`/`--device` force a method. Tokens + are stored in `~/.honeybadger-cli-credentials.json` and refreshed + automatically. + ## [0.9.0] - 2026-07-17 ### Added diff --git a/README.md b/README.md index e5710e8..6a02d3e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,45 @@ Run with `--help` for all options including `--version`, `--interval`, and `--in The CLI can be configured using either command-line flags, environment variables, or a configuration file. +### Authentication + +The CLI talks to two Honeybadger APIs with different credentials: + + * **Reporting API** (`deploy`, `agent`, `run`, `check-in`): uses a project API key via `--api-key` or `HONEYBADGER_API_KEY`. + * **Data API** (everything else): uses your personal credentials — either OAuth via `hb auth login`, or a personal auth token. + +The easiest way to authenticate the Data API commands is OAuth: + +```bash +hb auth login # sign in (picks the best method for your environment) +hb auth login --web # force the browser flow on this machine +hb auth login --device # force the device flow (sign in from another device) +hb auth status # show how you're currently authenticated +hb auth logout # revoke and delete the stored credentials +``` + +`hb auth login` picks the sign-in method automatically: on a machine with a +browser it uses the OAuth 2.0 authorization code flow with PKCE (RFC 8252); in +an SSH session or on a headless machine it uses the device authorization flow +(RFC 8628, where the server supports it), showing a one-time code you enter +from any other device. Either way it prints a tip for switching to the other +method. It authenticates against the endpoint you've configured (US by +default, EU with `--endpoint https://eu-api.honeybadger.io`). Tokens are +stored in +`~/.honeybadger-cli-credentials.json` (owner-only permissions; override the +location with `HONEYBADGER_CREDENTIALS_FILE`) and are refreshed automatically +when they expire. + +Alternatively, set a personal auth token with `--auth-token`, +`HONEYBADGER_AUTH_TOKEN`, or `auth_token` in the config file. A personal auth +token always takes precedence over stored OAuth credentials. + +**Note:** OAuth tokens are scoped to the account you choose on the consent +screen. Commands that span accounts — notably `hb accounts list` — return +`403 Insufficient scope` with an OAuth token; use a personal auth token for +those. Account-scoped commands (projects, faults, insights, and so on) work +normally with either. + ### Configuration File By default, the CLI looks for a configuration file at `~/.honeybadger-cli.yaml` in your home directory. You can specify a different configuration file using the `--config` flag. @@ -101,7 +140,7 @@ These commands use `--api-key` or `HONEYBADGER_API_KEY` (project API key): ### Data API Commands -These commands use `--auth-token` or `HONEYBADGER_AUTH_TOKEN` (personal auth token): +These commands authenticate with `hb auth login` (OAuth), or with `--auth-token` / `HONEYBADGER_AUTH_TOKEN` (personal auth token): | Command | Description | |---------|-------------| diff --git a/cmd/accounts.go b/cmd/accounts.go index ea328db..4ef94b9 100644 --- a/cmd/accounts.go +++ b/cmd/accounts.go @@ -9,7 +9,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -35,19 +34,11 @@ var accountsListCmd = &cobra.Command{ Short: "List all accounts", Long: `List all accounts accessible with your auth token.`, RunE: func(_ *cobra.Command, _ []string) error { - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() accounts, err := client.Accounts.List(ctx) if err != nil { @@ -87,19 +78,11 @@ var accountsGetCmd = &cobra.Command{ return fmt.Errorf("account ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() account, err := client.Accounts.Get(ctx, accountID) if err != nil { @@ -143,19 +126,11 @@ var accountsUsersListCmd = &cobra.Command{ return fmt.Errorf("account ID is required. Set it using --account-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() users, err := client.Accounts.ListUsers(ctx, accountID) if err != nil { @@ -199,19 +174,11 @@ var accountsUsersGetCmd = &cobra.Command{ return fmt.Errorf("user ID is required. Set it using --user-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() user, err := client.Accounts.GetUser(ctx, accountID, accountUserID) if err != nil { @@ -253,19 +220,11 @@ var accountsUsersUpdateCmd = &cobra.Command{ return fmt.Errorf("role is required. Set it using --role flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() if err := client.Accounts.UpdateUser( ctx, @@ -312,21 +271,13 @@ var accountsUsersRemoveCmd = &cobra.Command{ return fmt.Errorf("user ID is required. Set it using --user-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Accounts.RemoveUser(ctx, accountID, accountUserID) + err = client.Accounts.RemoveUser(ctx, accountID, accountUserID) if err != nil { return fmt.Errorf("failed to remove user: %w", err) } @@ -353,19 +304,11 @@ var accountsInvitationsListCmd = &cobra.Command{ return fmt.Errorf("account ID is required. Set it using --account-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() invitations, err := client.Accounts.ListInvitations(ctx, accountID) if err != nil { @@ -414,19 +357,11 @@ var accountsInvitationsGetCmd = &cobra.Command{ return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() invitation, err := client.Accounts.GetInvitation(ctx, accountID, accountInvitationID) if err != nil { @@ -483,19 +418,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(accountCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -559,19 +486,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(accountCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -630,21 +549,13 @@ var accountsInvitationsDeleteCmd = &cobra.Command{ return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Accounts.DeleteInvitation(ctx, accountID, accountInvitationID) + err = client.Accounts.DeleteInvitation(ctx, accountID, accountInvitationID) if err != nil { return fmt.Errorf("failed to delete invitation: %w", err) } diff --git a/cmd/alarms.go b/cmd/alarms.go index ec066e4..b2b9dd5 100644 --- a/cmd/alarms.go +++ b/cmd/alarms.go @@ -10,7 +10,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -39,19 +38,11 @@ var alarmsListCmd = &cobra.Command{ return err } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() response, err := client.Alarms.List(ctx, alarmsProjectID) if err != nil { @@ -107,19 +98,11 @@ var alarmsGetCmd = &cobra.Command{ return fmt.Errorf("alarm ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() alarm, err := client.Alarms.Get(ctx, alarmsProjectID, alarmID) if err != nil { @@ -212,19 +195,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(alarmCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -298,19 +273,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(alarmCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -347,19 +314,11 @@ var alarmsDeleteCmd = &cobra.Command{ return fmt.Errorf("alarm ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() result, err := client.Alarms.Delete(ctx, alarmsProjectID, alarmID) if err != nil { @@ -384,19 +343,11 @@ var alarmsHistoryCmd = &cobra.Command{ return fmt.Errorf("alarm ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() response, err := client.Alarms.History(ctx, alarmsProjectID, alarmID, alarmHistoryPage) if err != nil { diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..b175d0b --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,637 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strconv" + "time" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/honeybadger-io/cli/internal/credentials" + "github.com/honeybadger-io/cli/internal/oauth" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + oauthClientName = "Honeybadger CLI" + oauthDefaultScopes = "read write" + tokenExpirySkew = time.Minute +) + +var ( + authLoginDevice bool + authLoginWeb bool + authLoginScopes string +) + +// authCmd represents the auth command +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Authenticate the CLI with your Honeybadger account", + Long: `Log in to Honeybadger with OAuth instead of configuring a personal auth token. + +'hb auth login' signs you in with OAuth 2.0. On a machine with a browser it +opens the authorization page directly (authorization code flow with PKCE); in +an SSH session or on a headless machine it shows a one-time code you enter +from another device instead (device authorization flow). Use --web or +--device to override the automatic choice. + +A personal auth token (--auth-token, HONEYBADGER_AUTH_TOKEN, or auth_token in +the config file) always takes precedence over OAuth credentials when set.`, +} + +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Log in to Honeybadger with OAuth", + Long: `Log in to Honeybadger using OAuth 2.0. + +The CLI picks the best sign-in method for the environment: the browser-based +authorization code flow when a browser is likely available, or the device +authorization flow (a one-time code you enter from another device) in SSH +sessions and on headless machines. Force a method with --web or --device. + +Credentials are stored in ~/.honeybadger-cli-credentials.json (override with +HONEYBADGER_CREDENTIALS_FILE) and refreshed automatically when they expire.`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runAuthLogin(cmd) + }, +} + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Log out and revoke stored OAuth credentials", + RunE: func(cmd *cobra.Command, _ []string) error { + return runAuthLogout(cmd) + }, +} + +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show the current authentication status", + RunE: func(cmd *cobra.Command, _ []string) error { + return runAuthStatus(cmd) + }, +} + +func init() { + authLoginCmd.Flags().BoolVar(&authLoginDevice, "device", false, + "use the device authorization flow (sign in from another device)") + authLoginCmd.Flags().BoolVar(&authLoginWeb, "web", false, + "use the browser-based authorization flow on this machine") + authLoginCmd.Flags().StringVar(&authLoginScopes, "scopes", oauthDefaultScopes, + "space-separated OAuth scopes to request") + + authCmd.AddCommand(authLoginCmd) + authCmd.AddCommand(authLogoutCmd) + authCmd.AddCommand(authStatusCmd) + rootCmd.AddCommand(authCmd) +} + +func runAuthLogin(cmd *cobra.Command) error { + ctx := cmdContext(cmd) + out := cmd.OutOrStdout() + issuer := convertEndpointForDataAPI(viper.GetString("endpoint")) + httpClient := &http.Client{Timeout: 30 * time.Second} + + metadata, err := oauth.Discover(ctx, httpClient, issuer) + if err != nil { + return err + } + + issuerKey, err := oauth.CanonicalIssuer(issuer) + if err != nil { + return err + } + credsPath, err := credentials.Path() + if err != nil { + return err + } + credsFile, err := credentials.Load(credsPath) + if err != nil { + // Preserve the unreadable file instead of overwriting it on save, + // which would silently drop credentials for other issuers. + backup := credsPath + ".corrupt" + if renameErr := os.Rename(credsPath, backup); renameErr != nil { + return fmt.Errorf( + "credentials file is unreadable (%v) and could not be moved aside: %v", + err, renameErr, + ) + } + _, _ = fmt.Fprintf(os.Stderr, "Warning: %v (moved to %s; starting fresh)\n", err, backup) + credsFile = &credentials.File{Version: 1, Credentials: map[string]*credentials.Entry{}} + } + entry := credsFile.Credentials[issuerKey] + if entry == nil { + entry = &credentials.Entry{} + } + + useDevice, err := chooseDeviceFlow(metadata) + if err != nil { + return err + } + printAltFlowHint(out, metadata, useDevice) + + var token *oauth.Token + if useDevice { + token, err = loginWithDeviceFlow(ctx, cmd, httpClient, metadata, entry) + } else { + token, err = loginWithBrowserFlow(ctx, cmd, httpClient, metadata, entry) + } + if err != nil { + return err + } + + entry.TokenEndpoint = metadata.TokenEndpoint + entry.RevocationEndpoint = metadata.RevocationEndpoint + entry.AccessToken = token.AccessToken + entry.RefreshToken = token.RefreshToken + entry.TokenType = token.TokenType + entry.Scope = token.Scope + entry.ExpiresAt = token.ExpiresAt + if _, err := credentials.Update(credsPath, func(f *credentials.File) error { + f.Credentials[issuerKey] = entry + return nil + }); err != nil { + return err + } + + _, _ = fmt.Fprintf(out, "✓ Logged in to %s\n", issuerKey) + return nil +} + +// chooseDeviceFlow picks the login method: an explicit --device or --web flag +// wins; otherwise the device flow is used when the server supports it and a +// local browser is unlikely to reach the user (SSH session, or no graphical +// display on platforms that need one). +func chooseDeviceFlow(metadata *oauth.Metadata) (bool, error) { + if authLoginDevice && authLoginWeb { + return false, fmt.Errorf("--device and --web cannot be combined") + } + if authLoginDevice { + return true, nil + } + if authLoginWeb { + return false, nil + } + if !metadata.SupportsGrant(oauth.DeviceGrantType) { + return false, nil + } + if !metadata.SupportsGrant("authorization_code") { + return true, nil + } + return !browserLikelyAvailable(), nil +} + +// browserLikelyAvailable reports whether opening a browser on this machine +// can plausibly reach the user. +func browserLikelyAvailable() bool { + for _, envVar := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { + if os.Getenv(envVar) != "" { + return false + } + } + switch runtime.GOOS { + case "darwin", "windows": + return true + default: + return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" + } +} + +// printAltFlowHint tells the user how to switch to the sign-in method we +// didn't pick, when the server supports it. +func printAltFlowHint(out io.Writer, metadata *oauth.Metadata, usingDevice bool) { + if usingDevice { + if metadata.SupportsGrant("authorization_code") { + _, _ = fmt.Fprintf( + out, + "Tip: to sign in with a browser on this machine instead, run 'hb auth login --web'.\n\n", + ) + } + return + } + if metadata.SupportsGrant(oauth.DeviceGrantType) { + _, _ = fmt.Fprintf( + out, + "Tip: no browser here? Run 'hb auth login --device' to sign in from another device.\n\n", + ) + } +} + +// loginWithBrowserFlow runs the authorization code + PKCE flow (RFC 8252), +// registering an OAuth client for the loopback redirect when needed. +func loginWithBrowserFlow( + ctx context.Context, + cmd *cobra.Command, + httpClient *http.Client, + metadata *oauth.Metadata, + entry *credentials.Entry, +) (*oauth.Token, error) { + if !metadata.SupportsGrant("authorization_code") { + return nil, fmt.Errorf("the server does not support the authorization code grant") + } + + listener, clientID, err := browserFlowClient(ctx, httpClient, metadata, entry) + if err != nil { + return nil, err + } + + flow := &oauth.AuthCodeFlow{ + HTTPClient: httpClient, + Metadata: metadata, + ClientID: clientID, + Scope: authLoginScopes, + Listener: listener, + OpenBrowser: openBrowser, + Out: cmd.OutOrStdout(), + } + token, err := flow.Run(ctx) + if err != nil { + return nil, err + } + + entry.ClientID = clientID + entry.RedirectURI = oauth.RedirectURIFor(listener) + entry.GrantType = "authorization_code" + return token, nil +} + +// browserFlowClient binds the loopback listener and resolves the OAuth client +// to use with it: a configured oauth_client_id, a previously registered +// client (when its redirect port is still available), or a newly registered +// one (RFC 7591). +func browserFlowClient( + ctx context.Context, + httpClient *http.Client, + metadata *oauth.Metadata, + entry *credentials.Entry, +) (net.Listener, string, error) { + if clientID := viper.GetString("oauth_client_id"); clientID != "" { + listener, err := oauth.ListenLoopback(0) + if err != nil { + return nil, "", err + } + return listener, clientID, nil + } + + // Reuse the cached registered client if we can bind its exact redirect + // port again; loopback redirect URIs are matched exactly by the server. + if entry.ClientID != "" && entry.GrantType == "authorization_code" && entry.RedirectURI != "" { + if port := redirectURIPort(entry.RedirectURI); port > 0 { + if listener, err := oauth.ListenLoopback(port); err == nil { + return listener, entry.ClientID, nil + } + } + } + + if metadata.RegistrationEndpoint == "" { + return nil, "", fmt.Errorf( + "the server does not support dynamic client registration; set oauth_client_id in your config or HONEYBADGER_OAUTH_CLIENT_ID", + ) + } + + listener, err := oauth.ListenLoopback(0) + if err != nil { + return nil, "", err + } + clientID, err := oauth.Register( + ctx, + httpClient, + metadata.RegistrationEndpoint, + oauth.RegistrationRequest{ + ClientName: oauthClientName, + RedirectURIs: []string{oauth.RedirectURIFor(listener)}, + GrantTypes: grantTypesFor("authorization_code", metadata), + ResponseTypes: []string{"code"}, + Scope: authLoginScopes, + }, + ) + if err != nil { + _ = listener.Close() + return nil, "", err + } + return listener, clientID, nil +} + +// loginWithDeviceFlow runs the device authorization flow (RFC 8628). +func loginWithDeviceFlow( + ctx context.Context, + cmd *cobra.Command, + httpClient *http.Client, + metadata *oauth.Metadata, + entry *credentials.Entry, +) (*oauth.Token, error) { + clientID := viper.GetString("oauth_client_id") + if clientID == "" && entry.ClientID != "" && entry.GrantType == oauth.DeviceGrantType { + clientID = entry.ClientID + } + if clientID == "" { + if !metadata.SupportsGrant(oauth.DeviceGrantType) { + return nil, fmt.Errorf( + "the server does not support the device authorization grant; run 'hb auth login' without --device to use the browser flow", + ) + } + if metadata.RegistrationEndpoint == "" { + return nil, fmt.Errorf( + "the server does not support dynamic client registration; set oauth_client_id in your config or HONEYBADGER_OAUTH_CLIENT_ID", + ) + } + var err error + clientID, err = oauth.Register( + ctx, + httpClient, + metadata.RegistrationEndpoint, + oauth.RegistrationRequest{ + ClientName: oauthClientName, + GrantTypes: grantTypesFor(oauth.DeviceGrantType, metadata), + Scope: authLoginScopes, + }, + ) + if err != nil { + return nil, err + } + } + + flow := &oauth.DeviceFlow{ + HTTPClient: httpClient, + Metadata: metadata, + ClientID: clientID, + Scope: authLoginScopes, + Out: cmd.OutOrStdout(), + } + token, err := flow.Run(ctx) + if err != nil { + return nil, err + } + + entry.ClientID = clientID + entry.RedirectURI = "" + entry.GrantType = oauth.DeviceGrantType + return token, nil +} + +func runAuthLogout(cmd *cobra.Command) error { + out := cmd.OutOrStdout() + issuer := convertEndpointForDataAPI(viper.GetString("endpoint")) + issuerKey, err := oauth.CanonicalIssuer(issuer) + if err != nil { + return err + } + credsPath, err := credentials.Path() + if err != nil { + return err + } + credsFile, err := credentials.Load(credsPath) + if err != nil { + return err + } + entry := credsFile.Credentials[issuerKey] + if entry == nil { + _, _ = fmt.Fprintf(out, "Not logged in to %s\n", issuerKey) + return nil + } + + // Revoke tokens best-effort (RFC 7009); log out locally regardless. + if entry.RevocationEndpoint != "" && entry.ClientID != "" { + ctx, cancel := context.WithTimeout(cmdContext(cmd), 30*time.Second) + defer cancel() + httpClient := &http.Client{Timeout: 30 * time.Second} + for _, token := range []string{entry.RefreshToken, entry.AccessToken} { + if token == "" { + continue + } + if err := oauth.Revoke( + ctx, + httpClient, + entry.RevocationEndpoint, + entry.ClientID, + token, + ); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to revoke token: %v\n", err) + } + } + } + + if _, err := credentials.Update(credsPath, func(f *credentials.File) error { + delete(f.Credentials, issuerKey) + return nil + }); err != nil { + return err + } + _, _ = fmt.Fprintf(out, "✓ Logged out of %s\n", issuerKey) + return nil +} + +func runAuthStatus(cmd *cobra.Command) error { + out := cmd.OutOrStdout() + issuer := convertEndpointForDataAPI(viper.GetString("endpoint")) + issuerKey, err := oauth.CanonicalIssuer(issuer) + if err != nil { + return err + } + + if viper.GetString("auth_token") != "" { + _, _ = fmt.Fprintln( + out, + "Using a personal auth token (--auth-token, HONEYBADGER_AUTH_TOKEN, or config file).", + ) + _, _ = fmt.Fprintln(out, "Personal auth tokens take precedence over OAuth credentials.") + return nil + } + + credsPath, err := credentials.Path() + if err != nil { + return err + } + credsFile, err := credentials.Load(credsPath) + if err != nil { + return err + } + entry := credsFile.Credentials[issuerKey] + if entry == nil || entry.AccessToken == "" { + return fmt.Errorf("not logged in to %s. Run 'hb auth login' to authenticate", issuerKey) + } + + _, _ = fmt.Fprintf(out, "Logged in to %s via OAuth\n", issuerKey) + if entry.Scope != "" { + _, _ = fmt.Fprintf(out, "Scopes: %s\n", entry.Scope) + } + switch { + case entry.ExpiresAt.IsZero(): + _, _ = fmt.Fprintln(out, "Token expiry: none reported") + case entry.Expired(time.Now(), 0): + if entry.RefreshToken != "" { + _, _ = fmt.Fprintf(out, "Token expired %s (will refresh automatically on next use)\n", + entry.ExpiresAt.Local().Format(time.RFC3339)) + } else { + _, _ = fmt.Fprintf(out, "Token expired %s (run 'hb auth login' to sign in again)\n", + entry.ExpiresAt.Local().Format(time.RFC3339)) + } + default: + _, _ = fmt.Fprintf(out, "Token expires: %s\n", entry.ExpiresAt.Local().Format(time.RFC3339)) + } + return nil +} + +// newDataAPIClient builds a Data API client authenticated with the personal +// auth token when configured, or stored OAuth credentials otherwise +// (refreshing the access token transparently when it has expired). +func newDataAPIClient() (*hbapi.Client, error) { + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + client := hbapi.NewClient().WithBaseURL(endpoint) + + if authToken := viper.GetString("auth_token"); authToken != "" { + return client.WithAuthToken(authToken), nil + } + + accessToken, err := storedAccessToken(endpoint) + if err != nil { + return nil, err + } + if accessToken == "" { + return nil, fmt.Errorf( + "auth token is required. Run 'hb auth login' to sign in with your browser, or set a personal auth token using the --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + return client.WithBearerToken(accessToken), nil +} + +// storedAccessToken returns a valid stored OAuth access token for the issuer, +// refreshing and persisting it when expired. It returns "" (no error) when +// the user has never logged in. +func storedAccessToken(issuer string) (string, error) { + issuerKey, err := oauth.CanonicalIssuer(issuer) + if err != nil { + return "", nil //nolint:nilerr // an unparsable endpoint just means no stored OAuth creds + } + credsPath, err := credentials.Path() + if err != nil { + return "", nil //nolint:nilerr // no resolvable home dir means no stored OAuth creds + } + credsFile, err := credentials.Load(credsPath) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: ignoring credentials file: %v\n", err) + return "", nil + } + entry := credsFile.Credentials[issuerKey] + if entry == nil || entry.AccessToken == "" { + return "", nil + } + + // Fast path: a still-valid token needs no lock. + if !entry.Expired(time.Now(), tokenExpirySkew) { + return entry.AccessToken, nil + } + if entry.RefreshToken == "" || entry.TokenEndpoint == "" || entry.ClientID == "" { + return "", errSessionExpired + } + + // Refresh under the credentials lock so concurrent commands serialize: + // one process refreshes and persists (including a rotated refresh + // token), and the others see the fresh token when they reload. + var accessToken string + if _, err := credentials.Update(credsPath, func(f *credentials.File) error { + current := f.Credentials[issuerKey] + if current == nil || current.AccessToken == "" { + return errSessionExpired + } + if !current.Expired(time.Now(), tokenExpirySkew) { + accessToken = current.AccessToken // another process already refreshed + return nil + } + if current.RefreshToken == "" || current.TokenEndpoint == "" || current.ClientID == "" { + return errSessionExpired + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + httpClient := &http.Client{Timeout: 30 * time.Second} + token, err := oauth.Refresh( + ctx, + httpClient, + current.TokenEndpoint, + current.ClientID, + current.RefreshToken, + ) + if err != nil { + return fmt.Errorf( + "failed to refresh your Honeybadger session (%v). Run 'hb auth login' to sign in again", + err, + ) + } + + current.AccessToken = token.AccessToken + current.RefreshToken = token.RefreshToken + current.TokenType = token.TokenType + if token.Scope != "" { + current.Scope = token.Scope + } + current.ExpiresAt = token.ExpiresAt + accessToken = token.AccessToken + return nil + }); err != nil { + return "", err + } + return accessToken, nil +} + +var errSessionExpired = fmt.Errorf( + "your Honeybadger session has expired. Run 'hb auth login' to sign in again", +) + +// grantTypesFor returns the grant types to register for a flow, including +// refresh_token when the server supports it. +func grantTypesFor(primary string, metadata *oauth.Metadata) []string { + grants := []string{primary} + if metadata.SupportsGrant("refresh_token") { + grants = append(grants, "refresh_token") + } + return grants +} + +// cmdContext returns the command's context, falling back to Background when +// the command is run outside Execute (as in tests). +func cmdContext(cmd *cobra.Command) context.Context { + if ctx := cmd.Context(); ctx != nil { + return ctx + } + return context.Background() +} + +// redirectURIPort extracts the port from a stored loopback redirect URI, +// returning 0 when it cannot be determined. +func redirectURIPort(redirectURI string) int { + parsed, err := url.Parse(redirectURI) + if err != nil { + return 0 + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + return 0 + } + return port +} + +// openBrowser launches the default browser at the given URL. It is a +// variable so tests can substitute a fake browser. +var openBrowser = func(u string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", u).Start() // #nosec G204 -- u is our authorize URL + case "windows": + // #nosec G204 -- u is our authorize URL + return exec.Command("rundll32", "url.dll,FileProtocolHandler", u).Start() + default: + return exec.Command("xdg-open", u). + Start() + // #nosec G204 - u is the OAuth authorize URL we built + } +} diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..8256337 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,491 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + "time" + + "github.com/honeybadger-io/cli/internal/credentials" + "github.com/honeybadger-io/cli/internal/oauth" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeOAuthServer is a minimal authorization server plus Data API endpoint +// for exercising the auth commands end to end. +type fakeOAuthServer struct { + t *testing.T + server *httptest.Server + + supportsDevice bool + registrations int + revokedTokens []string + refreshCalls int + apiAuthHeaders []string +} + +func newFakeOAuthServer(t *testing.T, supportsDevice bool) *fakeOAuthServer { + f := &fakeOAuthServer{t: t, supportsDevice: supportsDevice} + mux := http.NewServeMux() + + mux.HandleFunc( + "/.well-known/oauth-authorization-server", + func(w http.ResponseWriter, _ *http.Request) { + grants := []string{"authorization_code", "refresh_token"} + if f.supportsDevice { + grants = append(grants, oauth.DeviceGrantType) + } + md := map[string]interface{}{ + "issuer": f.server.URL, + "authorization_endpoint": f.server.URL + "/oauth/authorize", + "token_endpoint": f.server.URL + "/oauth/token", + "registration_endpoint": f.server.URL + "/oauth/register", + "revocation_endpoint": f.server.URL + "/oauth/revoke", + "grant_types_supported": grants, + } + if f.supportsDevice { + md["device_authorization_endpoint"] = f.server.URL + "/oauth/authorize_device" + } + _ = json.NewEncoder(w).Encode(md) + }, + ) + + mux.HandleFunc("/oauth/register", func(w http.ResponseWriter, _ *http.Request) { + f.registrations++ + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"client_id": "registered-client"}`)) + }) + + mux.HandleFunc("/oauth/authorize_device", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{ + "device_code": "dev-1", "user_code": "AAAA-BBBB", + "verification_uri": "https://example.com/device", + "expires_in": 300, "interval": 1 + }`)) + }) + + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + if r.Form.Get("grant_type") == "refresh_token" { + f.refreshCalls++ + } + _, _ = w.Write([]byte(`{ + "access_token": "oauth-access", "refresh_token": "oauth-refresh", + "token_type": "Bearer", "scope": "read write", "expires_in": 3600 + }`)) + }) + + mux.HandleFunc("/oauth/revoke", func(_ http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + f.revokedTokens = append(f.revokedTokens, r.Form.Get("token")) + }) + + mux.HandleFunc("/v2/accounts", func(w http.ResponseWriter, r *http.Request) { + f.apiAuthHeaders = append(f.apiAuthHeaders, r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"results": []}`)) + }) + + f.server = httptest.NewServer(mux) + t.Cleanup(f.server.Close) + return f +} + +// setupAuthTest isolates viper, flags, credentials file, and browser opener. +func setupAuthTest(t *testing.T, f *fakeOAuthServer) { + t.Setenv(credentials.EnvVar, filepath.Join(t.TempDir(), "credentials.json")) + viper.Reset() + viper.Set("endpoint", f.server.URL) + authLoginDevice = false + authLoginWeb = false + authLoginScopes = oauthDefaultScopes + + // Neutralize environment-based flow selection so tests behave the same + // on developer machines, SSH sessions, and CI. + for _, envVar := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "WAYLAND_DISPLAY"} { + t.Setenv(envVar, "") + } + t.Setenv("DISPLAY", ":0") + + // The fake "browser" completes the authorization immediately. + originalOpenBrowser := openBrowser + openBrowser = func(authURL string) error { + u, err := url.Parse(authURL) + if err != nil { + return err + } + q := u.Query() + redirect, err := url.Parse(q.Get("redirect_uri")) + if err != nil { + return err + } + rq := redirect.Query() + rq.Set("code", "test-code") + rq.Set("state", q.Get("state")) + redirect.RawQuery = rq.Encode() + go func() { + resp, err := http.Get(redirect.String()) + if err == nil { + _ = resp.Body.Close() + } + }() + return nil + } + t.Cleanup(func() { + openBrowser = originalOpenBrowser + viper.Reset() + }) +} + +func loadTestCredentials(t *testing.T) *credentials.File { + path, err := credentials.Path() + require.NoError(t, err) + f, err := credentials.Load(path) + require.NoError(t, err) + return f +} + +func saveTestCredentials(t *testing.T, f *fakeOAuthServer, entry *credentials.Entry) { + host, err := oauth.CanonicalIssuer(f.server.URL) + require.NoError(t, err) + path, err := credentials.Path() + require.NoError(t, err) + require.NoError(t, credentials.Save(path, &credentials.File{ + Version: 1, + Credentials: map[string]*credentials.Entry{host: entry}, + })) +} + +func TestAuthLoginBrowserFlow(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + + var out bytes.Buffer + authLoginCmd.SetOut(&out) + defer authLoginCmd.SetOut(nil) + + require.NoError(t, authLoginCmd.RunE(authLoginCmd, []string{})) + assert.Contains(t, out.String(), "Logged in") + assert.Equal(t, 1, f.registrations, "should dynamically register a client") + + creds := loadTestCredentials(t) + host, _ := oauth.CanonicalIssuer(f.server.URL) + entry := creds.Credentials[host] + require.NotNil(t, entry) + assert.Equal(t, "registered-client", entry.ClientID) + assert.Equal(t, "oauth-access", entry.AccessToken) + assert.Equal(t, "oauth-refresh", entry.RefreshToken) + assert.Equal(t, "authorization_code", entry.GrantType) + assert.NotEmpty(t, entry.RedirectURI) + assert.False(t, entry.ExpiresAt.IsZero()) +} + +func TestAuthLoginDeviceFlow(t *testing.T) { + f := newFakeOAuthServer(t, true) + setupAuthTest(t, f) + authLoginDevice = true + + var out bytes.Buffer + authLoginCmd.SetOut(&out) + defer authLoginCmd.SetOut(nil) + + require.NoError(t, authLoginCmd.RunE(authLoginCmd, []string{})) + assert.Contains(t, out.String(), "AAAA-BBBB", "should display the user code") + assert.Contains(t, out.String(), "Logged in") + + creds := loadTestCredentials(t) + host, _ := oauth.CanonicalIssuer(f.server.URL) + entry := creds.Credentials[host] + require.NotNil(t, entry) + assert.Equal(t, oauth.DeviceGrantType, entry.GrantType) + assert.Equal(t, "oauth-access", entry.AccessToken) +} + +func TestAuthLoginDeviceUnsupported(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + authLoginDevice = true + + var out bytes.Buffer + authLoginCmd.SetOut(&out) + defer authLoginCmd.SetOut(nil) + + err := authLoginCmd.RunE(authLoginCmd, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support the device authorization grant") +} + +func TestAuthStatus(t *testing.T) { + t.Run("not logged in", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + + err := authStatusCmd.RunE(authStatusCmd, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not logged in") + }) + + t.Run("personal auth token takes precedence", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + viper.Set("auth_token", "personal-token") + + var out bytes.Buffer + authStatusCmd.SetOut(&out) + defer authStatusCmd.SetOut(nil) + + require.NoError(t, authStatusCmd.RunE(authStatusCmd, []string{})) + assert.Contains(t, out.String(), "personal auth token") + }) + + t.Run("logged in via oauth", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + saveTestCredentials(t, f, &credentials.Entry{ + ClientID: "client-1", + AccessToken: "tok", + Scope: "read write", + ExpiresAt: time.Now().Add(time.Hour), + }) + + var out bytes.Buffer + authStatusCmd.SetOut(&out) + defer authStatusCmd.SetOut(nil) + + require.NoError(t, authStatusCmd.RunE(authStatusCmd, []string{})) + assert.Contains(t, out.String(), "Logged in") + assert.Contains(t, out.String(), "read write") + }) +} + +func TestAuthLogout(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + saveTestCredentials(t, f, &credentials.Entry{ + ClientID: "client-1", + AccessToken: "access-1", + RefreshToken: "refresh-1", + RevocationEndpoint: f.server.URL + "/oauth/revoke", + }) + + var out bytes.Buffer + authLogoutCmd.SetOut(&out) + defer authLogoutCmd.SetOut(nil) + + require.NoError(t, authLogoutCmd.RunE(authLogoutCmd, []string{})) + assert.Contains(t, out.String(), "Logged out") + assert.ElementsMatch(t, []string{"access-1", "refresh-1"}, f.revokedTokens, + "should revoke both tokens") + + creds := loadTestCredentials(t) + host, _ := oauth.CanonicalIssuer(f.server.URL) + assert.Nil(t, creds.Credentials[host]) +} + +func TestNewDataAPIClient(t *testing.T) { + listAccounts := func(t *testing.T) error { + client, err := newDataAPIClient() + if err != nil { + return err + } + _, err = client.Accounts.List(context.Background()) + require.NoError(t, err) + return nil + } + + t.Run("personal auth token uses basic auth", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + viper.Set("auth_token", "personal-token") + + require.NoError(t, listAccounts(t)) + require.Len(t, f.apiAuthHeaders, 1) + expected := "Basic " + base64.StdEncoding.EncodeToString([]byte("personal-token:")) + assert.Equal(t, expected, f.apiAuthHeaders[0]) + }) + + t.Run("oauth credentials use bearer auth", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + saveTestCredentials(t, f, &credentials.Entry{ + ClientID: "client-1", + AccessToken: "stored-access", + ExpiresAt: time.Now().Add(time.Hour), + }) + + require.NoError(t, listAccounts(t)) + require.Len(t, f.apiAuthHeaders, 1) + assert.Equal(t, "Bearer stored-access", f.apiAuthHeaders[0]) + }) + + t.Run("expired token is refreshed and persisted", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + saveTestCredentials(t, f, &credentials.Entry{ + ClientID: "client-1", + TokenEndpoint: f.server.URL + "/oauth/token", + AccessToken: "stale-access", + RefreshToken: "stale-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + }) + + require.NoError(t, listAccounts(t)) + assert.Equal(t, 1, f.refreshCalls) + require.Len(t, f.apiAuthHeaders, 1) + assert.Equal(t, "Bearer oauth-access", f.apiAuthHeaders[0]) + + creds := loadTestCredentials(t) + host, _ := oauth.CanonicalIssuer(f.server.URL) + assert.Equal(t, "oauth-access", creds.Credentials[host].AccessToken) + assert.Equal(t, "oauth-refresh", creds.Credentials[host].RefreshToken) + }) + + t.Run("expired token without refresh token errors", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + saveTestCredentials(t, f, &credentials.Entry{ + ClientID: "client-1", + AccessToken: "stale-access", + ExpiresAt: time.Now().Add(-time.Hour), + }) + + _, err := newDataAPIClient() + require.Error(t, err) + assert.Contains(t, err.Error(), "hb auth login") + }) + + t.Run("no credentials at all errors", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + + _, err := newDataAPIClient() + require.Error(t, err) + assert.Contains(t, err.Error(), "auth token is required") + assert.Contains(t, err.Error(), "hb auth login") + }) +} + +func TestChooseDeviceFlow(t *testing.T) { + withDevice := &oauth.Metadata{ + GrantTypesSupported: []string{"authorization_code", "refresh_token", oauth.DeviceGrantType}, + } + withoutDevice := &oauth.Metadata{ + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + } + deviceOnly := &oauth.Metadata{ + GrantTypesSupported: []string{oauth.DeviceGrantType, "refresh_token"}, + } + + reset := func(t *testing.T) { + authLoginDevice = false + authLoginWeb = false + for _, envVar := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "WAYLAND_DISPLAY"} { + t.Setenv(envVar, "") + } + t.Setenv("DISPLAY", ":0") + } + + t.Run("conflicting flags error", func(t *testing.T) { + reset(t) + authLoginDevice = true + authLoginWeb = true + _, err := chooseDeviceFlow(withDevice) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be combined") + }) + + t.Run("--device wins even without a terminal heuristic", func(t *testing.T) { + reset(t) + authLoginDevice = true + useDevice, err := chooseDeviceFlow(withDevice) + require.NoError(t, err) + assert.True(t, useDevice) + }) + + t.Run("--web wins even over SSH", func(t *testing.T) { + reset(t) + authLoginWeb = true + t.Setenv("SSH_CONNECTION", "10.0.0.1 1234 10.0.0.2 22") + useDevice, err := chooseDeviceFlow(withDevice) + require.NoError(t, err) + assert.False(t, useDevice) + }) + + t.Run("SSH session prefers device flow when supported", func(t *testing.T) { + reset(t) + t.Setenv("SSH_CONNECTION", "10.0.0.1 1234 10.0.0.2 22") + useDevice, err := chooseDeviceFlow(withDevice) + require.NoError(t, err) + assert.True(t, useDevice) + }) + + t.Run("SSH session without server device support stays on browser flow", func(t *testing.T) { + reset(t) + t.Setenv("SSH_TTY", "/dev/pts/0") + useDevice, err := chooseDeviceFlow(withoutDevice) + require.NoError(t, err) + assert.False(t, useDevice) + }) + + t.Run("local machine prefers browser flow", func(t *testing.T) { + reset(t) + useDevice, err := chooseDeviceFlow(withDevice) + require.NoError(t, err) + assert.False(t, useDevice) + }) + + t.Run("device-only server uses device flow", func(t *testing.T) { + reset(t) + useDevice, err := chooseDeviceFlow(deviceOnly) + require.NoError(t, err) + assert.True(t, useDevice) + }) +} + +func TestAuthLoginAutoSelectsDeviceOverSSH(t *testing.T) { + f := newFakeOAuthServer(t, true) + setupAuthTest(t, f) + t.Setenv("SSH_CONNECTION", "10.0.0.1 1234 10.0.0.2 22") + + var out bytes.Buffer + authLoginCmd.SetOut(&out) + defer authLoginCmd.SetOut(nil) + + require.NoError(t, authLoginCmd.RunE(authLoginCmd, []string{})) + assert.Contains(t, out.String(), "AAAA-BBBB", "should have auto-selected the device flow") + assert.Contains(t, out.String(), "--web", "device flow should hint at the browser alternative") +} + +func TestAuthLoginHints(t *testing.T) { + t.Run("browser flow hints at --device when supported", func(t *testing.T) { + f := newFakeOAuthServer(t, true) + setupAuthTest(t, f) + authLoginWeb = true + + var out bytes.Buffer + authLoginCmd.SetOut(&out) + defer authLoginCmd.SetOut(nil) + + require.NoError(t, authLoginCmd.RunE(authLoginCmd, []string{})) + assert.Contains(t, out.String(), "--device") + }) + + t.Run("browser flow shows no hint when the server lacks the device grant", func(t *testing.T) { + f := newFakeOAuthServer(t, false) + setupAuthTest(t, f) + + var out bytes.Buffer + authLoginCmd.SetOut(&out) + defer authLoginCmd.SetOut(nil) + + require.NoError(t, authLoginCmd.RunE(authLoginCmd, []string{})) + assert.NotContains(t, out.String(), "--device") + }) +} diff --git a/cmd/checkins.go b/cmd/checkins.go index 20b0a9e..d8e0973 100644 --- a/cmd/checkins.go +++ b/cmd/checkins.go @@ -9,7 +9,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -37,19 +36,11 @@ var checkinsListCmd = &cobra.Command{ return err } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() checkIns, err := client.CheckIns.List(ctx, checkinsProjectID) if err != nil { @@ -107,19 +98,11 @@ var checkinsGetCmd = &cobra.Command{ return fmt.Errorf("check-in ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() checkIn, err := client.CheckIns.Get(ctx, checkinsProjectID, checkinID) if err != nil { @@ -201,19 +184,11 @@ Example JSON payload for cron schedule: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(checkinCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -276,19 +251,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(checkinCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -347,21 +314,13 @@ var checkinsDeleteCmd = &cobra.Command{ return fmt.Errorf("check-in ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.CheckIns.Delete(ctx, checkinsProjectID, checkinID) + err = client.CheckIns.Delete(ctx, checkinsProjectID, checkinID) if err != nil { return fmt.Errorf("failed to delete check-in: %w", err) } diff --git a/cmd/comments.go b/cmd/comments.go index 1354139..fcd3402 100644 --- a/cmd/comments.go +++ b/cmd/comments.go @@ -7,9 +7,7 @@ import ( "os" "text/tabwriter" - hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -41,19 +39,11 @@ var commentsListCmd = &cobra.Command{ return fmt.Errorf("fault ID is required. Set it using --fault-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() comments, err := client.Comments.List(ctx, commentsProjectID, commentsFaultID) if err != nil { @@ -111,19 +101,11 @@ var commentsGetCmd = &cobra.Command{ return fmt.Errorf("comment ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() comment, err := client.Comments.Get(ctx, commentsProjectID, commentsFaultID, commentID) if err != nil { @@ -170,19 +152,11 @@ var commentsCreateCmd = &cobra.Command{ return fmt.Errorf("comment body is required. Set it using --body flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() comment, err := client.Comments.Create(ctx, commentsProjectID, commentsFaultID, commentBody) if err != nil { @@ -225,19 +199,11 @@ var commentsUpdateCmd = &cobra.Command{ return fmt.Errorf("comment body is required. Set it using --body flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() if err := client.Comments.Update( ctx, @@ -287,21 +253,13 @@ var commentsDeleteCmd = &cobra.Command{ return fmt.Errorf("comment ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Comments.Delete(ctx, commentsProjectID, commentsFaultID, commentID) + err = client.Comments.Delete(ctx, commentsProjectID, commentsFaultID, commentID) if err != nil { return fmt.Errorf("failed to delete comment: %w", err) } diff --git a/cmd/deployments.go b/cmd/deployments.go index 132607b..b3b339b 100644 --- a/cmd/deployments.go +++ b/cmd/deployments.go @@ -9,7 +9,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -41,19 +40,11 @@ var deploymentsListCmd = &cobra.Command{ return err } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - options := hbapi.DeploymentListOptions{ Environment: deploymentsEnvironment, LocalUsername: deploymentsLocalUser, @@ -123,19 +114,11 @@ var deploymentsGetCmd = &cobra.Command{ return fmt.Errorf("deployment ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() deployment, err := client.Deployments.Get(ctx, deploymentsProjectID, deploymentID) if err != nil { @@ -177,21 +160,13 @@ var deploymentsDeleteCmd = &cobra.Command{ return fmt.Errorf("deployment ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Deployments.Delete(ctx, deploymentsProjectID, deploymentID) + err = client.Deployments.Delete(ctx, deploymentsProjectID, deploymentID) if err != nil { return fmt.Errorf("failed to delete deployment: %w", err) } diff --git a/cmd/environments.go b/cmd/environments.go index b65afb2..614ca39 100644 --- a/cmd/environments.go +++ b/cmd/environments.go @@ -9,7 +9,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -37,19 +36,11 @@ var environmentsListCmd = &cobra.Command{ return err } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() environments, err := client.Environments.List(ctx, environmentsProjectID) if err != nil { @@ -98,19 +89,11 @@ var environmentsGetCmd = &cobra.Command{ return fmt.Errorf("environment ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() environment, err := client.Environments.Get(ctx, environmentsProjectID, environmentID) if err != nil { @@ -161,19 +144,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(environmentCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -239,19 +214,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(environmentCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -293,21 +260,13 @@ var environmentsDeleteCmd = &cobra.Command{ return fmt.Errorf("environment ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Environments.Delete(ctx, environmentsProjectID, environmentID) + err = client.Environments.Delete(ctx, environmentsProjectID, environmentID) if err != nil { return fmt.Errorf("failed to delete environment: %w", err) } diff --git a/cmd/faults.go b/cmd/faults.go index 653a81c..bf9272e 100644 --- a/cmd/faults.go +++ b/cmd/faults.go @@ -9,7 +9,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -45,20 +44,11 @@ var faultsListCmd = &cobra.Command{ return err } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - // Build options options := hbapi.FaultListOptions{ Q: faultQuery, @@ -129,20 +119,11 @@ var faultsGetCmd = &cobra.Command{ return fmt.Errorf("fault ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() fault, err := client.Faults.Get(ctx, faultsProjectID, faultID) if err != nil { @@ -211,20 +192,11 @@ var faultsNoticesCmd = &cobra.Command{ return fmt.Errorf("fault ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - // Build options options := hbapi.FaultListNoticesOptions{ Limit: faultLimit, @@ -330,20 +302,11 @@ Examples: ) } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() result, err := client.Faults.Update(ctx, faultsProjectID, faultID, params) if err != nil { @@ -366,20 +329,11 @@ var faultsCountsCmd = &cobra.Command{ return err } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() counts, err := client.Faults.GetCounts(ctx, faultsProjectID, hbapi.FaultListOptions{}) if err != nil { @@ -428,20 +382,11 @@ var faultsAffectedUsersCmd = &cobra.Command{ return fmt.Errorf("fault ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - // Build options options := hbapi.FaultListAffectedUsersOptions{ Q: faultAffectedUserQuery, diff --git a/cmd/insights.go b/cmd/insights.go index 213bdbf..6de6add 100644 --- a/cmd/insights.go +++ b/cmd/insights.go @@ -10,7 +10,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -56,20 +55,11 @@ Examples: return fmt.Errorf("query is required. Set it using --query flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - // Build request request := hbapi.InsightsQueryRequest{ Query: insightsQuery, diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..598e3f5 --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/honeybadger-io/cli/internal/credentials" +) + +// TestMain points the credentials store at a temp file so tests never read or +// write a developer's real ~/.honeybadger-cli-credentials.json. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "hb-cli-test") + if err != nil { + panic(err) + } + if err := os.Setenv(credentials.EnvVar, filepath.Join(dir, "credentials.json")); err != nil { + panic(err) + } + + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/cmd/projects.go b/cmd/projects.go index 608dff1..ac83849 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -10,7 +10,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -40,23 +39,13 @@ var projectsListCmd = &cobra.Command{ Short: "List all projects", Long: `List all projects accessible with your API key.`, RunE: func(_ *cobra.Command, _ []string) error { - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() var response *hbapi.ProjectsResponse - var err error // List projects by account ID if provided if projectAccountID != "" { @@ -106,20 +95,11 @@ var projectsGetCmd = &cobra.Command{ return fmt.Errorf("project ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() project, err := client.Projects.Get(ctx, projectID) if err != nil { @@ -207,20 +187,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - // Parse JSON input jsonData, err := readJSONInput(projectCLIInputJSON) if err != nil { @@ -284,20 +255,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - // Parse JSON input jsonData, err := readJSONInput(projectCLIInputJSON) if err != nil { @@ -333,20 +295,11 @@ var projectsDeleteCmd = &cobra.Command{ return fmt.Errorf("project ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() result, err := client.Projects.Delete(ctx, projectID) if err != nil { @@ -364,20 +317,11 @@ var projectsOccurrencesCmd = &cobra.Command{ Short: "Get occurrence counts for projects", Long: `Get occurrence counts for all projects or a specific project over time.`, RunE: func(_ *cobra.Command, _ []string) error { - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - options := hbapi.ProjectGetOccurrenceCountsOptions{ Period: projectOccurrencesPeriod, Environment: projectOccurrencesEnv, @@ -451,20 +395,11 @@ var projectsIntegrationsCmd = &cobra.Command{ return fmt.Errorf("project ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() integrations, err := client.Projects.GetIntegrations(ctx, projectID) if err != nil { @@ -521,20 +456,11 @@ var projectsReportsCmd = &cobra.Command{ return fmt.Errorf("report type is required. Set it using --type flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - // Create API client - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - options := hbapi.ProjectGetReportOptions{ Environment: projectReportEnv, } diff --git a/cmd/root.go b/cmd/root.go index 064a0a9..74ff607 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,7 +44,8 @@ This tool provides access to two APIs: Authenticate with --api-key or HONEYBADGER_API_KEY Data API - For reading and managing your Honeybadger data - Authenticate with --auth-token or HONEYBADGER_AUTH_TOKEN`, + Authenticate with 'hb auth login' (OAuth), or with + --auth-token or HONEYBADGER_AUTH_TOKEN`, } // Execute adds all child commands to the root command and sets flags appropriately. @@ -68,7 +69,7 @@ func init() { }) rootCmd.AddGroup(&cobra.Group{ ID: GroupDataAPI, - Title: "Data API Commands (use --auth-token):", + Title: "Data API Commands (use 'hb auth login' or --auth-token):", }) rootCmd.PersistentFlags(). @@ -120,6 +121,10 @@ func initConfig() { // to bind, so we use BindEnv to make viper aware of it. _ = viper.BindEnv("project_id") + // Register oauth_client_id for env var lookup (HONEYBADGER_OAUTH_CLIENT_ID), + // used by `hb auth login` to skip dynamic client registration. + _ = viper.BindEnv("oauth_client_id") + if err := viper.ReadInConfig(); err == nil { fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) } diff --git a/cmd/statuspages.go b/cmd/statuspages.go index 85b8f71..e6997b1 100644 --- a/cmd/statuspages.go +++ b/cmd/statuspages.go @@ -9,7 +9,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -37,19 +36,11 @@ var statuspagesListCmd = &cobra.Command{ return fmt.Errorf("account ID is required. Set it using --account-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() statusPages, err := client.StatusPages.List(ctx, statuspagesAccountID) if err != nil { @@ -94,19 +85,11 @@ var statuspagesGetCmd = &cobra.Command{ return fmt.Errorf("status page ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() statusPage, err := client.StatusPages.Get(ctx, statuspagesAccountID, statuspageID) if err != nil { @@ -180,19 +163,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(statuspageCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -255,19 +230,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(statuspageCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -304,21 +271,13 @@ var statuspagesDeleteCmd = &cobra.Command{ return fmt.Errorf("status page ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.StatusPages.Delete(ctx, statuspagesAccountID, statuspageID) + err = client.StatusPages.Delete(ctx, statuspagesAccountID, statuspageID) if err != nil { return fmt.Errorf("failed to delete status page: %w", err) } diff --git a/cmd/streams.go b/cmd/streams.go index ab251ff..63a1950 100644 --- a/cmd/streams.go +++ b/cmd/streams.go @@ -7,9 +7,7 @@ import ( "os" "text/tabwriter" - hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -38,19 +36,11 @@ and alarms to specific streams.`, return err } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() streams, err := client.Streams.List(ctx, streamsProjectID) if err != nil { diff --git a/cmd/teams.go b/cmd/teams.go index 66b748f..96f0b2d 100644 --- a/cmd/teams.go +++ b/cmd/teams.go @@ -9,7 +9,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -41,19 +40,11 @@ var teamsListCmd = &cobra.Command{ return fmt.Errorf("account ID is required. Set it using --account-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() teams, err := client.Teams.List(ctx, teamsAccountID) if err != nil { @@ -93,19 +84,11 @@ var teamsGetCmd = &cobra.Command{ return fmt.Errorf("team ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() team, err := client.Teams.Get(ctx, teamID) if err != nil { @@ -144,19 +127,11 @@ var teamsCreateCmd = &cobra.Command{ return fmt.Errorf("team name is required. Set it using --name flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() team, err := client.Teams.Create(ctx, teamsAccountID, teamName) if err != nil { @@ -193,19 +168,11 @@ var teamsUpdateCmd = &cobra.Command{ return fmt.Errorf("team name is required. Set it using --name flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() if err := client.Teams.Update(ctx, teamID, teamName); err != nil { return fmt.Errorf("failed to update team: %w", err) @@ -243,21 +210,13 @@ var teamsDeleteCmd = &cobra.Command{ return fmt.Errorf("team ID is required. Set it using --id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Teams.Delete(ctx, teamID) + err = client.Teams.Delete(ctx, teamID) if err != nil { return fmt.Errorf("failed to delete team: %w", err) } @@ -284,19 +243,11 @@ var teamsMembersListCmd = &cobra.Command{ return fmt.Errorf("team ID is required. Set it using --team-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() members, err := client.Teams.ListMembers(ctx, teamID) if err != nil { @@ -344,19 +295,11 @@ var teamsMembersUpdateCmd = &cobra.Command{ return fmt.Errorf("member ID is required. Set it using --member-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() if err := client.Teams.UpdateMember( ctx, @@ -414,21 +357,13 @@ var teamsMembersRemoveCmd = &cobra.Command{ return fmt.Errorf("member ID is required. Set it using --member-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Teams.RemoveMember(ctx, teamID, teamMemberID) + err = client.Teams.RemoveMember(ctx, teamID, teamMemberID) if err != nil { return fmt.Errorf("failed to remove team member: %w", err) } @@ -455,19 +390,11 @@ var teamsInvitationsListCmd = &cobra.Command{ return fmt.Errorf("team ID is required. Set it using --team-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() invitations, err := client.Teams.ListInvitations(ctx, teamID) if err != nil { @@ -520,19 +447,11 @@ var teamsInvitationsGetCmd = &cobra.Command{ return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() invitation, err := client.Teams.GetInvitation(ctx, teamID, teamInvitationID) if err != nil { @@ -589,19 +508,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(teamCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -663,19 +574,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(teamCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -734,21 +637,13 @@ var teamsInvitationsDeleteCmd = &cobra.Command{ return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Teams.DeleteInvitation(ctx, teamID, teamInvitationID) + err = client.Teams.DeleteInvitation(ctx, teamID, teamInvitationID) if err != nil { return fmt.Errorf("failed to delete team invitation: %w", err) } diff --git a/cmd/uptime.go b/cmd/uptime.go index 3dfb4b4..2569e5e 100644 --- a/cmd/uptime.go +++ b/cmd/uptime.go @@ -9,7 +9,6 @@ import ( hbapi "github.com/honeybadger-io/api-go" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -47,19 +46,11 @@ var uptimeSitesListCmd = &cobra.Command{ return err } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() sites, err := client.Uptime.List(ctx, uptimeProjectID) if err != nil { @@ -109,19 +100,11 @@ var uptimeSitesGetCmd = &cobra.Command{ return fmt.Errorf("site ID is required. Set it using --site-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() site, err := client.Uptime.Get(ctx, uptimeProjectID, uptimeSiteID) if err != nil { @@ -189,19 +172,11 @@ Available options: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(uptimeCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -265,19 +240,11 @@ Example JSON payload: return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - jsonData, err := readJSONInput(uptimeCLIInputJSON) if err != nil { return fmt.Errorf("failed to read JSON input: %w", err) @@ -326,21 +293,13 @@ var uptimeSitesDeleteCmd = &cobra.Command{ return fmt.Errorf("site ID is required. Set it using --site-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - ctx := context.Background() - err := client.Uptime.Delete(ctx, uptimeProjectID, uptimeSiteID) + err = client.Uptime.Delete(ctx, uptimeProjectID, uptimeSiteID) if err != nil { return fmt.Errorf("failed to delete uptime site: %w", err) } @@ -363,19 +322,11 @@ var uptimeOutagesCmd = &cobra.Command{ return fmt.Errorf("site ID is required. Set it using --site-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - options := hbapi.OutageListOptions{ Limit: uptimeLimit, } @@ -447,19 +398,11 @@ var uptimeChecksCmd = &cobra.Command{ return fmt.Errorf("site ID is required. Set it using --site-id flag") } - authToken := viper.GetString("auth_token") - if authToken == "" { - return fmt.Errorf( - "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", - ) + client, err := newDataAPIClient() + if err != nil { + return err } - endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) - - client := hbapi.NewClient(). - WithBaseURL(endpoint). - WithAuthToken(authToken) - options := hbapi.UptimeCheckListOptions{ Limit: uptimeLimit, } diff --git a/config/honeybadger.yml.example b/config/honeybadger.yml.example index 29464e5..cd95121 100644 --- a/config/honeybadger.yml.example +++ b/config/honeybadger.yml.example @@ -3,3 +3,7 @@ # Your Honeybadger API key api_key: your-api-key-here + +# Personal auth token for Data API commands (optional). Prefer `hb auth login` +# to sign in with OAuth instead; a token set here takes precedence. +# auth_token: your-personal-auth-token diff --git a/docs/superpowers/specs/2026-07-21-oauth-login-design.md b/docs/superpowers/specs/2026-07-21-oauth-login-design.md new file mode 100644 index 0000000..92aa7ae --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-oauth-login-design.md @@ -0,0 +1,158 @@ +# OAuth Login for the Honeybadger CLI + +**Date:** 2026-07-21 (revised same day after code review and E2E testing) +**Status:** Implemented + +## Goal + +Let users authenticate the Data API commands with `hb auth login` (OAuth 2.0) +instead of pasting a personal auth token into a config file or environment +variable. Personal auth tokens remain supported and take precedence when set. + +## Server facts (discovered, not assumed) + +`https://app.honeybadger.io/.well-known/oauth-authorization-server` (RFC 8414) +is live in production and advertises: + +- `authorization_endpoint`, `token_endpoint`, `revocation_endpoint`, + `registration_endpoint` +- `grant_types_supported`: `authorization_code`, `refresh_token` +- `code_challenge_methods_supported`: `S256` (PKCE) +- `token_endpoint_auth_methods_supported`: `none` (public clients) +- `scopes_supported`: `read`, `write` + +Notably the **device grant (`urn:ietf:params:oauth:grant-type:device_code`) is +not advertised yet**. Meanwhile `api-go` v0.8.0 added `WithBearerToken` +("sets an OAuth access token, sent as Authorization: Bearer"), confirming the +Data API accepts OAuth bearer tokens. + +## Approach + +Discovery-driven login supporting **both** flows, selected at runtime: + +1. **Authorization code + PKCE with loopback redirect** (RFC 8252 / RFC 7636) + — the default. Works against production today. Binds `127.0.0.1:`, + opens the browser, exchanges the code with a PKCE verifier. +2. **Device authorization grant** (RFC 8628) — used with `--device` (for SSH / + headless machines) or when the browser flow isn't possible. Activates + automatically once the server advertises the grant; until then it fails with + a clear "server does not support" error. + +Client identity comes from **dynamic client registration** (RFC 7591) against +the advertised `registration_endpoint` — the same mechanism MCP clients use — +with the registered `client_id` cached locally. A pre-provisioned client id can +be supplied via `oauth_client_id` config / `HONEYBADGER_OAUTH_CLIENT_ID` and +skips registration. + +Alternatives considered: + +- *Device flow only* (as the branch name suggests): dead on arrival — production + doesn't support the grant yet. Kept as a first-class flow behind discovery. +- *Hard-coded client id + fixed redirect port*: simpler but requires + out-of-band provisioning and breaks when the port is taken. Dynamic + registration is already an open, supported path on the server. +- *OS keychain storage*: better at-rest security but adds a cgo/keyring + dependency and cross-platform variance; deferred. File is 0600, matching + `gh`'s default behavior. + +## Components + +### `internal/oauth` + +Pure OAuth client, no viper/cobra dependencies: + +- `Discover(ctx, httpClient, issuer)` → `Metadata` (RFC 8414). No fallback: + a missing metadata document is an error. The advertised issuer must exactly + match the requested one (mix-up protection), issuers may not carry a query + or fragment, all endpoints must be HTTPS (loopback hosts excepted), and the + well-known path is built per §3.1 for issuers with a path component. +- `Register(ctx, httpClient, metadata, req)` → registered client (RFC 7591). +- `AuthCodeFlow`: PKCE S256 (43-char base64url verifier from 32 random bytes), + random `state` (validated on callback), loopback HTTP server on + `127.0.0.1:0`, browser opener injected as a func for testability, code + exchange at the token endpoint. 5-minute timeout. +- `DeviceFlow`: POST to `device_authorization_endpoint`; displays + `user_code` + `verification_uri` (and `verification_uri_complete` when + present); polls the token endpoint honoring `interval`, + `authorization_pending`, `slow_down` (+5s per RFC 8628 §3.5), + `access_denied`, `expired_token`. +- `Refresh`: `refresh_token` grant with the public `client_id`; returns rotated + tokens. +- `Revoke`: RFC 7009 revocation, best-effort. +- `Token{AccessToken, RefreshToken, TokenType, Scope, ExpiresAt}`. + +### `internal/credentials` + +JSON file store, default `~/.honeybadger-cli-credentials.json`, written +atomically (0600 temp file + rename) with `0700` parent creation; mutations +go through `Update`, which serializes concurrent CLI processes with an +interprocess lock (flock / LockFileEx on a sidecar file) and reloads before +writing so no process loses another's changes. Keyed by canonical issuer URL +(scheme + host + path) so US and EU logins coexist and an `http://` endpoint +can never receive a token obtained over `https://`. Each +entry: `client_id`, `redirect_uri`, `access_token`, `refresh_token`, +`token_type`, `scope`, `expires_at`. Path overridable via +`HONEYBADGER_CREDENTIALS_FILE` (used by tests, useful for users too). + +### `cmd/auth.go` + +- `hb auth login` — flags: `--device` / `--web` (force a flow), `--scopes` + (default `read write`). Discovers metadata from the Data API endpoint + (after `convertEndpointForDataAPI`), picks the flow automatically (device + flow in SSH/displayless environments when the server supports it, browser + flow otherwise) with a printed hint for the alternate, registers/reuses a + client, runs the flow, saves credentials, prints identity-free success + message. +- `hb auth logout` — revokes access + refresh tokens (best-effort), deletes + the stored entry for the current endpoint. +- `hb auth status` — reports login state, token expiry, scopes, and whether a + personal auth token override is in effect. Offline; no network calls. + +### Token resolution (`cmd/root.go`) + +New helper `newDataAPIClient() (*hbapi.Client, error)` replaces the repeated +per-command block. Precedence: + +1. `auth_token` from flag / env / config → `WithAuthToken` (basic auth), as + today. +2. Stored OAuth credentials for the endpoint's issuer host → + `WithBearerToken`. Tokens within 60s of expiry are refreshed transparently + and persisted (including rotated refresh tokens). Refresh failure → + "run `hb auth login`" error. +3. Neither → error retaining the phrase "auth token is required" (tests match + it) and now also mentioning `hb auth login`. + +All ~76 Data API call sites across 13 command files switch to the helper — a +mechanical replacement of the existing 11-line block. + +## Error handling + +- Discovery/registration/flow errors surface the OAuth `error` + + `error_description` fields when present. +- Browser-flow callback errors (`access_denied`) are reported in the terminal; + the browser tab gets a small self-contained HTML page for both success and + failure. +- Credentials file corruption → on login, the unreadable file is moved aside + to `.corrupt` (never silently overwritten); on reads it is ignored + with a warning to stderr. + +## Testing + +- `internal/oauth`: httptest-backed unit tests. The auth-code flow is tested + end-to-end by injecting a fake "browser" that parses the authorize URL and + invokes the loopback callback; the fake token endpoint validates the PKCE + verifier and state. Device flow tests cover pending → slow_down → success, + denial, and expiry. +- `internal/credentials`: round-trip, permissions, multi-issuer. +- `cmd`: a package-level `TestMain` points `HONEYBADGER_CREDENTIALS_FILE` at a + temp dir so existing tests never read a developer's real credentials; new + tests cover login/logout/status and bearer-vs-basic selection in + `newDataAPIClient`. + +## Out of scope + +- OS keychain storage. +- `hb auth login --with-token` style token stdin (personal tokens already + cover this). +- Server-side device grant work (tracked separately; CLI lights up via + discovery when it ships). diff --git a/go.mod b/go.mod index f18442d..6a711d6 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 + golang.org/x/sys v0.29.0 ) require ( @@ -33,7 +34,6 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go new file mode 100644 index 0000000..3720a8e --- /dev/null +++ b/internal/credentials/credentials.go @@ -0,0 +1,127 @@ +// Package credentials stores OAuth tokens obtained by `hb auth login` in a +// JSON file, keyed by canonical issuer URL so logins to multiple Honeybadger +// instances (e.g. US and EU) can coexist. +// +// On Unix the file is kept owner-only (0600). On Windows the mode bits are +// advisory; the file relies on the user profile directory's ACLs instead. +package credentials + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// EnvVar overrides the credentials file location when set. +const EnvVar = "HONEYBADGER_CREDENTIALS_FILE" + +const defaultFileName = ".honeybadger-cli-credentials.json" + +// Entry holds the OAuth client and tokens for one authorization server. +type Entry struct { + ClientID string `json:"client_id,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + GrantType string `json:"grant_type,omitempty"` + TokenEndpoint string `json:"token_endpoint,omitempty"` + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + AccessToken string `json:"access_token,omitempty"` // #nosec G117 -- stored with 0600 perms; storing tokens is this package's purpose + RefreshToken string `json:"refresh_token,omitempty"` // #nosec G117 + TokenType string `json:"token_type,omitempty"` + Scope string `json:"scope,omitempty"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Expired reports whether the access token is expired (or expires within the +// skew). A zero ExpiresAt means the server reported no expiry. +func (e *Entry) Expired(now time.Time, skew time.Duration) bool { + if e.ExpiresAt.IsZero() { + return false + } + return !now.Add(skew).Before(e.ExpiresAt) +} + +// File is the on-disk credentials document. +type File struct { + Version int `json:"version"` + Credentials map[string]*Entry `json:"credentials"` +} + +// Path returns the credentials file location: $HONEYBADGER_CREDENTIALS_FILE +// when set, otherwise ~/.honeybadger-cli-credentials.json. +func Path() (string, error) { + if p := os.Getenv(EnvVar); p != "" { + return p, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to locate home directory for credentials file: %w", err) + } + return filepath.Join(home, defaultFileName), nil +} + +// Load reads the credentials file. A missing file yields an empty File. +func Load(path string) (*File, error) { + f := &File{Version: 1, Credentials: map[string]*Entry{}} + + data, err := os.ReadFile(path) // #nosec G304 - path is the user's own credentials file + if os.IsNotExist(err) { + return f, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read credentials file: %w", err) + } + if err := json.Unmarshal(data, f); err != nil { + return nil, fmt.Errorf("failed to parse credentials file %s: %w", path, err) + } + if f.Credentials == nil { + f.Credentials = map[string]*Entry{} + } + return f, nil +} + +// Save writes the credentials file with owner-only permissions. The write is +// atomic (temp file + rename) so a crash can't truncate the file, and the +// secrets are never on disk with permissions wider than 0600. +func Save(path string, f *File) error { + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return fmt.Errorf("failed to encode credentials: %w", err) + } + data = append(data, '\n') + + dir := filepath.Dir(path) + if dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create credentials directory: %w", err) + } + } + + tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + defer func() { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) // #nosec G703 -- temp file we created beside the credentials file + }() + + if err := tmp.Chmod(0o600); err != nil { + return fmt.Errorf("failed to set credentials file permissions: %w", err) + } + if _, err := tmp.Write(data); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + // #nosec G703 -- both paths derive from the user's own credentials file location + if err := os.Rename(tmp.Name(), path); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + return nil +} diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go new file mode 100644 index 0000000..c7a3126 --- /dev/null +++ b/internal/credentials/credentials_test.go @@ -0,0 +1,147 @@ +package credentials + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadMissingFile(t *testing.T) { + f, err := Load(filepath.Join(t.TempDir(), "nope.json")) + require.NoError(t, err) + assert.Empty(t, f.Credentials) +} + +func TestSaveAndLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "creds", "credentials.json") + expires := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + + f := &File{Version: 1, Credentials: map[string]*Entry{ + "app.honeybadger.io": { + ClientID: "client-us", + AccessToken: "access-us", + RefreshToken: "refresh-us", + Scope: "read write", + ExpiresAt: expires, + }, + "eu-app.honeybadger.io": { + ClientID: "client-eu", + AccessToken: "access-eu", + }, + }} + require.NoError(t, Save(path, f)) + + if runtime.GOOS != "windows" { // Unix permission bits are advisory on Windows + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal( + t, os.FileMode(0o600), info.Mode().Perm(), "credentials file must be owner-only", + ) + } + + loaded, err := Load(path) + require.NoError(t, err) + require.Len(t, loaded.Credentials, 2) + us := loaded.Credentials["app.honeybadger.io"] + require.NotNil(t, us) + assert.Equal(t, "access-us", us.AccessToken) + assert.True(t, us.ExpiresAt.Equal(expires)) + assert.Equal(t, "access-eu", loaded.Credentials["eu-app.honeybadger.io"].AccessToken) +} + +func TestSaveTightensPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits are advisory on Windows") + } + path := filepath.Join(t.TempDir(), "credentials.json") + // #nosec G306 -- deliberately wide permissions; Save must tighten them + require.NoError(t, os.WriteFile(path, []byte("{}"), 0o644)) + + require.NoError(t, Save(path, &File{Version: 1})) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestLoadCorruptFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + require.NoError(t, os.WriteFile(path, []byte("not json"), 0o600)) + + _, err := Load(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse credentials file") +} + +func TestPathEnvOverride(t *testing.T) { + t.Setenv(EnvVar, "/tmp/custom-creds.json") + p, err := Path() + require.NoError(t, err) + assert.Equal(t, "/tmp/custom-creds.json", p) +} + +func TestExpired(t *testing.T) { + now := time.Now() + skew := time.Minute + + assert.False(t, (&Entry{}).Expired(now, skew), "zero expiry never counts as expired") + assert.False(t, (&Entry{ExpiresAt: now.Add(time.Hour)}).Expired(now, skew)) + assert.True(t, (&Entry{ExpiresAt: now.Add(30 * time.Second)}).Expired(now, skew), + "tokens inside the skew window count as expired") + assert.True(t, (&Entry{ExpiresAt: now.Add(-time.Hour)}).Expired(now, skew)) +} + +func TestUpdateSerializesConcurrentWriters(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + _, err := Update(path, func(f *File) error { + f.Credentials[fmt.Sprintf("issuer-%d.example.com", n)] = &Entry{ + AccessToken: fmt.Sprintf("token-%d", n), + } + return nil + }) + assert.NoError(t, err) + }(i) + } + wg.Wait() + + loaded, err := Load(path) + require.NoError(t, err) + require.Len(t, loaded.Credentials, 8, "no writer's update may be lost") + for i := 0; i < 8; i++ { + key := fmt.Sprintf("issuer-%d.example.com", i) + require.NotNil(t, loaded.Credentials[key]) + assert.Equal(t, fmt.Sprintf("token-%d", i), loaded.Credentials[key].AccessToken) + } +} + +func TestUpdateMutationErrorDoesNotSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + _, err := Update(path, func(f *File) error { + f.Credentials["x"] = &Entry{AccessToken: "t"} + return nil + }) + require.NoError(t, err) + + _, err = Update(path, func(f *File) error { + delete(f.Credentials, "x") + return fmt.Errorf("boom") + }) + require.Error(t, err) + + loaded, err := Load(path) + require.NoError(t, err) + assert.NotNil(t, loaded.Credentials["x"], "a failed mutation must not be persisted") +} diff --git a/internal/credentials/lock_unix.go b/internal/credentials/lock_unix.go new file mode 100644 index 0000000..95eefd0 --- /dev/null +++ b/internal/credentials/lock_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package credentials + +import ( + "os" + "syscall" +) + +func lockFile(f *os.File) error { + // #nosec G115 -- file descriptors fit in an int on all supported platforms + return syscall.Flock(int(f.Fd()), syscall.LOCK_EX) +} + +func unlockFile(f *os.File) error { + // #nosec G115 -- file descriptors fit in an int on all supported platforms + return syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/credentials/lock_windows.go b/internal/credentials/lock_windows.go new file mode 100644 index 0000000..7777368 --- /dev/null +++ b/internal/credentials/lock_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package credentials + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func lockFile(f *os.File) error { + overlapped := new(windows.Overlapped) + return windows.LockFileEx( + windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped, + ) +} + +func unlockFile(f *os.File) error { + overlapped := new(windows.Overlapped) + return windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, overlapped) +} diff --git a/internal/credentials/update.go b/internal/credentials/update.go new file mode 100644 index 0000000..82f2d6a --- /dev/null +++ b/internal/credentials/update.go @@ -0,0 +1,46 @@ +package credentials + +import ( + "fmt" + "os" + "path/filepath" +) + +// Update applies mutate to the credentials file under an exclusive +// interprocess lock: it acquires the lock, reloads the current contents, +// applies the mutation, and saves. This makes concurrent CLI processes +// (login, logout, token refresh) serialize their read-modify-write cycles +// instead of overwriting each other's changes with stale snapshots. +func Update(path string, mutate func(*File) error) (*File, error) { + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("failed to create credentials directory: %w", err) + } + } + + // The lock lives in a sidecar file so locking never interferes with the + // atomic rename in Save. + // #nosec G304 -- sidecar of the user's own credentials file + lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("failed to open credentials lock file: %w", err) + } + defer func() { _ = lock.Close() }() + + if err := lockFile(lock); err != nil { + return nil, fmt.Errorf("failed to lock credentials file: %w", err) + } + defer func() { _ = unlockFile(lock) }() + + f, err := Load(path) + if err != nil { + return nil, err + } + if err := mutate(f); err != nil { + return nil, err + } + if err := Save(path, f); err != nil { + return nil, err + } + return f, nil +} diff --git a/internal/oauth/authcode.go b/internal/oauth/authcode.go new file mode 100644 index 0000000..01e64a3 --- /dev/null +++ b/internal/oauth/authcode.go @@ -0,0 +1,195 @@ +package oauth + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "time" +) + +// AuthCodeFlow runs the authorization code grant with PKCE using a loopback +// redirect, following the OAuth 2.0 for Native Apps BCP (RFC 8252). +type AuthCodeFlow struct { + HTTPClient *http.Client + Metadata *Metadata + ClientID string + Scope string + // Listener is the pre-bound loopback listener the redirect will arrive + // on. Binding before client registration lets the caller register the + // exact redirect URI. + Listener net.Listener + // OpenBrowser launches the user's browser at the authorization URL. When + // nil (or when it fails) the URL is only printed for manual use. + OpenBrowser func(url string) error + Out io.Writer + // Timeout bounds the wait for the user to complete authorization + // (default 5 minutes). + Timeout time.Duration +} + +// ListenLoopback binds a loopback listener. Port 0 picks an ephemeral port. +func ListenLoopback(port int) (net.Listener, error) { + return net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) +} + +// RedirectURIFor returns the loopback redirect URI for a bound listener. +func RedirectURIFor(l net.Listener) string { + return fmt.Sprintf("http://%s/callback", l.Addr().String()) +} + +type callbackResult struct { + code string + err error +} + +// Run executes the flow: it serves the loopback redirect endpoint, directs +// the user's browser to the authorization endpoint, waits for the callback, +// and exchanges the authorization code (with the PKCE verifier) for tokens. +func (f *AuthCodeFlow) Run(ctx context.Context) (*Token, error) { + if f.Metadata.AuthorizationEndpoint == "" { + return nil, fmt.Errorf("authorization server does not advertise an authorization endpoint") + } + + verifier, err := randomURLSafe(32) + if err != nil { + return nil, err + } + state, err := randomURLSafe(16) + if err != nil { + return nil, err + } + redirectURI := RedirectURIFor(f.Listener) + + authURL, err := buildAuthorizeURL(f.Metadata.AuthorizationEndpoint, url.Values{ + "response_type": {"code"}, + "client_id": {f.ClientID}, + "redirect_uri": {redirectURI}, + "scope": {f.Scope}, + "state": {state}, + "code_challenge": {s256Challenge(verifier)}, + "code_challenge_method": {"S256"}, + }) + if err != nil { + return nil, err + } + + results := make(chan callbackResult, 1) + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + res, ok := handleCallback(w, r, state) + if !ok { + return // not a response to this login attempt; keep waiting + } + select { + case results <- res: + default: // a result was already delivered; ignore duplicates + } + }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go func() { _ = server.Serve(f.Listener) }() + defer func() { _ = server.Close() }() + + _, _ = fmt.Fprintf(f.Out, "Opening your browser to log in to Honeybadger.\n\n") + // #nosec G705 -- authURL is written to the user's terminal, not an HTTP response + _, _ = fmt.Fprintf(f.Out, "If the browser doesn't open, visit this URL:\n\n %s\n\n", authURL) + if f.OpenBrowser != nil { + _ = f.OpenBrowser(authURL) + } + + timeout := f.Timeout + if timeout == 0 { + timeout = 5 * time.Minute + } + timer := time.NewTimer(timeout) + defer timer.Stop() + + var res callbackResult + select { + case res = <-results: + case <-timer.C: + return nil, fmt.Errorf("timed out waiting for authorization after %s", timeout) + case <-ctx.Done(): + return nil, ctx.Err() + } + if res.err != nil { + return nil, res.err + } + + return requestToken(ctx, f.HTTPClient, f.Metadata.TokenEndpoint, url.Values{ + "grant_type": {"authorization_code"}, + "code": {res.code}, + "redirect_uri": {redirectURI}, + "client_id": {f.ClientID}, + "code_verifier": {verifier}, + }) +} + +// buildAuthorizeURL appends the authorization parameters to the endpoint, +// preserving any query component the endpoint already carries. +func buildAuthorizeURL(endpoint string, params url.Values) (string, error) { + parsed, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("invalid authorization endpoint %q", endpoint) + } + existing := parsed.Query() + for key, values := range params { + existing[key] = values + } + parsed.RawQuery = existing.Encode() + return parsed.String(), nil +} + +// handleCallback processes one request to the loopback redirect endpoint. +// The second return value reports whether the request belongs to this login +// attempt: requests without a matching state (port scans, stray requests, a +// forged abort) get an error page but must not terminate the flow. +func handleCallback(w http.ResponseWriter, r *http.Request, state string) (callbackResult, bool) { + q := r.URL.Query() + + if q.Get("state") != state { + writeCallbackPage( + w, + http.StatusBadRequest, + "Login failed", + "The response didn't match this login attempt (state mismatch). Return to the terminal and try again.", + ) + return callbackResult{}, false + } + if errCode := q.Get("error"); errCode != "" { + writeCallbackPage(w, http.StatusBadRequest, "Login failed", + "Authorization was not granted. You can close this tab and return to the terminal.") + oauthErr := &Error{Code: errCode, Description: q.Get("error_description")} + return callbackResult{err: fmt.Errorf("authorization failed: %w", oauthErr)}, true + } + code := q.Get("code") + if code == "" { + writeCallbackPage(w, http.StatusBadRequest, "Login failed", + "The authorization response was missing a code. Return to the terminal and try again.") + return callbackResult{err: fmt.Errorf("authorization response missing code")}, true + } + + writeCallbackPage( + w, + http.StatusOK, + "Login successful", + "You're logged in to the Honeybadger CLI. You can close this tab and return to the terminal.", + ) + return callbackResult{code: code}, true +} + +func writeCallbackPage(w http.ResponseWriter, status int, title, message string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, ` + +%s - Honeybadger CLI + +

%s

+

%s

+ + +`, title, title, message) +} diff --git a/internal/oauth/device.go b/internal/oauth/device.go new file mode 100644 index 0000000..58ee59e --- /dev/null +++ b/internal/oauth/device.go @@ -0,0 +1,136 @@ +package oauth + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// DeviceFlow runs the device authorization grant (RFC 8628): it requests a +// user code, tells the user where to enter it, and polls the token endpoint +// until authorization completes. +type DeviceFlow struct { + HTTPClient *http.Client + Metadata *Metadata + ClientID string + Scope string + Out io.Writer + + // sleep is injectable for tests; nil means a context-aware time.Sleep. + sleep func(ctx context.Context, d time.Duration) error +} + +type deviceAuthorization struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// Run executes the device flow and returns the issued token. +func (f *DeviceFlow) Run(ctx context.Context) (*Token, error) { + if f.Metadata.DeviceAuthorizationEndpoint == "" || !f.Metadata.SupportsGrant(DeviceGrantType) { + return nil, fmt.Errorf( + "the server does not support the device authorization grant; run 'hb auth login' without --device to use the browser flow", + ) + } + + var auth deviceAuthorization + form := url.Values{"client_id": {f.ClientID}} + if f.Scope != "" { + form.Set("scope", f.Scope) + } + if err := postForm( + ctx, + f.HTTPClient, + f.Metadata.DeviceAuthorizationEndpoint, + form, + &auth, + ); err != nil { + return nil, fmt.Errorf("device authorization request failed: %w", err) + } + if auth.DeviceCode == "" || auth.UserCode == "" { + return nil, fmt.Errorf("device authorization response was missing device_code or user_code") + } + + _, _ = fmt.Fprintf(f.Out, "First copy your one-time code: %s\n\n", auth.UserCode) + if auth.VerificationURIComplete != "" { + _, _ = fmt.Fprintf( + f.Out, "Then visit (code pre-filled): %s\n", auth.VerificationURIComplete, + ) + } + if auth.VerificationURI != "" { + _, _ = fmt.Fprintf(f.Out, "Or enter the code at: %s\n", auth.VerificationURI) + } + _, _ = fmt.Fprintf(f.Out, "\nWaiting for authorization...\n") + + interval := time.Duration(auth.Interval) * time.Second + if interval <= 0 { + interval = 5 * time.Second // RFC 8628 §3.2 default + } + expiresIn := time.Duration(auth.ExpiresIn) * time.Second + if expiresIn <= 0 { + expiresIn = 15 * time.Minute + } + deadline := time.Now().Add(expiresIn) + + sleep := f.sleep + if sleep == nil { + sleep = sleepContext + } + + for { + if err := sleep(ctx, interval); err != nil { + return nil, err + } + if time.Now().After(deadline) { + return nil, fmt.Errorf( + "the device code expired before authorization completed; run 'hb auth login' again", + ) + } + + tok, err := requestToken(ctx, f.HTTPClient, f.Metadata.TokenEndpoint, url.Values{ + "grant_type": {DeviceGrantType}, + "device_code": {auth.DeviceCode}, + "client_id": {f.ClientID}, + }) + if err == nil { + return tok, nil + } + + oauthErr, ok := err.(*Error) + if !ok { + return nil, err + } + switch oauthErr.Code { + case "authorization_pending": + // keep polling + case "slow_down": + interval += 5 * time.Second // RFC 8628 §3.5 + case "access_denied": + return nil, fmt.Errorf("authorization was denied") + case "expired_token": + return nil, fmt.Errorf( + "the device code expired before authorization completed; run 'hb auth login' again", + ) + default: + return nil, err + } + } +} + +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go new file mode 100644 index 0000000..181add2 --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,335 @@ +// Package oauth implements the OAuth 2.0 flows used by `hb auth login`: +// authorization server metadata discovery (RFC 8414), dynamic client +// registration (RFC 7591), the authorization code grant with PKCE for native +// apps (RFC 8252, RFC 7636), the device authorization grant (RFC 8628), +// refresh token grant, and token revocation (RFC 7009). +package oauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// DeviceGrantType is the RFC 8628 device authorization grant type identifier. +const DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + +// Metadata is the authorization server metadata document (RFC 8414). +type Metadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + RevocationEndpoint string `json:"revocation_endpoint"` + GrantTypesSupported []string `json:"grant_types_supported"` + ScopesSupported []string `json:"scopes_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +// SupportsGrant reports whether the server advertises the given grant type. +// Per RFC 8414, an absent grant_types_supported implies the default of +// authorization_code and implicit. +func (m *Metadata) SupportsGrant(grant string) bool { + if len(m.GrantTypesSupported) == 0 { + return grant == "authorization_code" + } + for _, g := range m.GrantTypesSupported { + if g == grant { + return true + } + } + return false +} + +// CanonicalIssuer normalizes an issuer URL for comparison and storage: +// lowercased scheme and host, no trailing slash, no query or fragment. It +// also enforces the RFC 8414 transport rules: HTTPS is required except for +// loopback hosts (which keep local development and tests working). +func CanonicalIssuer(issuer string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(issuer)) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("invalid issuer URL %q", issuer) + } + // RFC 8414 §2: an issuer identifier has no query or fragment component. + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("invalid issuer URL %q: must not contain a query or fragment", issuer) + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Host = strings.ToLower(parsed.Host) + parsed.Path = strings.TrimRight(parsed.Path, "/") + if err := checkEndpointScheme(parsed); err != nil { + return "", fmt.Errorf("invalid issuer URL %q: %w", issuer, err) + } + return parsed.String(), nil +} + +// checkEndpointScheme requires https, allowing http only for loopback hosts. +func checkEndpointScheme(u *url.URL) error { + switch u.Scheme { + case "https": + return nil + case "http": + host := u.Hostname() + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return nil + } + return fmt.Errorf("http is only allowed for loopback hosts; use https") + default: + return fmt.Errorf("unsupported URL scheme %q", u.Scheme) + } +} + +// wellKnownURL builds the RFC 8414 §3.1 metadata URL: the well-known path is +// inserted between the authority and any issuer path component. +func wellKnownURL(issuer string) (string, error) { + parsed, err := url.Parse(issuer) + if err != nil { + return "", fmt.Errorf("invalid issuer URL %q", issuer) + } + path := parsed.Path + parsed.Path = "/.well-known/oauth-authorization-server" + path + return parsed.String(), nil +} + +// validateEndpoints checks that every advertised endpoint the CLI will send +// credentials to is a well-formed HTTPS (or loopback) URL. +func (m *Metadata) validateEndpoints() error { + endpoints := map[string]string{ + "authorization_endpoint": m.AuthorizationEndpoint, + "token_endpoint": m.TokenEndpoint, + "device_authorization_endpoint": m.DeviceAuthorizationEndpoint, + "registration_endpoint": m.RegistrationEndpoint, + "revocation_endpoint": m.RevocationEndpoint, + } + for name, endpoint := range endpoints { + if endpoint == "" { + continue + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Host == "" { + return fmt.Errorf("invalid OAuth server metadata: malformed %s %q", name, endpoint) + } + if err := checkEndpointScheme(parsed); err != nil { + return fmt.Errorf("invalid OAuth server metadata: %s: %w", name, err) + } + } + return nil +} + +// Discover fetches the authorization server metadata for the given issuer +// from the RFC 8414 well-known location. The returned metadata is validated: +// the advertised issuer must match the requested one (mix-up protection, +// RFC 8414 §3.3) and all endpoints must be HTTPS (or loopback). +func Discover(ctx context.Context, hc *http.Client, issuer string) (*Metadata, error) { + canonical, err := CanonicalIssuer(issuer) + if err != nil { + return nil, err + } + wellKnown, err := wellKnownURL(canonical) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil) + if err != nil { + return nil, fmt.Errorf("failed to create discovery request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := hc.Do(req) // #nosec G704 -- URL is the user-configured endpoint's well-known path + if err != nil { + return nil, fmt.Errorf("OAuth discovery failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "OAuth discovery failed: %s returned HTTP %d (the server may not support OAuth login)", + wellKnown, resp.StatusCode, + ) + } + + var md Metadata + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&md); err != nil { + return nil, fmt.Errorf("failed to parse OAuth server metadata: %w", err) + } + + advertised, err := CanonicalIssuer(md.Issuer) + if err != nil || advertised != canonical { + return nil, fmt.Errorf( + "OAuth server metadata issuer %q does not match the requested issuer %q", + md.Issuer, canonical, + ) + } + if md.TokenEndpoint == "" { + return nil, fmt.Errorf("invalid OAuth server metadata: missing token_endpoint") + } + if err := md.validateEndpoints(); err != nil { + return nil, err + } + return &md, nil +} + +// Token is a set of issued OAuth credentials. +type Token struct { + AccessToken string // #nosec G117 -- this type's purpose is to carry tokens + RefreshToken string // #nosec G117 + TokenType string + Scope string + // ExpiresAt is the absolute expiry of the access token; zero when the + // server did not report expires_in. + ExpiresAt time.Time +} + +// Error is an OAuth 2.0 error response (RFC 6749 §5.2). +type Error struct { + Code string `json:"error"` + Description string `json:"error_description"` +} + +func (e *Error) Error() string { + if e.Description != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Description) + } + return e.Code +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` // #nosec G117 -- OAuth token endpoint response + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` // #nosec G117 + Scope string `json:"scope"` + ExpiresIn int64 `json:"expires_in"` +} + +// postForm sends a form-encoded POST and decodes the JSON response into out. +// Non-2xx responses are returned as *Error when the body carries an OAuth +// error document. +func postForm( + ctx context.Context, + hc *http.Client, + endpoint string, + form url.Values, + out interface{}, +) error { + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()), + ) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := hc.Do(req) // #nosec G704 -- URL comes from the server's OAuth metadata + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + var oauthErr Error + if json.Unmarshal(body, &oauthErr) == nil && oauthErr.Code != "" { + return &oauthErr + } + return fmt.Errorf("%s returned HTTP %d", endpoint, resp.StatusCode) + } + + if out != nil { + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("failed to parse response from %s: %w", endpoint, err) + } + } + return nil +} + +// requestToken posts to the token endpoint and converts the response into a +// Token with an absolute expiry. +func requestToken( + ctx context.Context, + hc *http.Client, + tokenEndpoint string, + form url.Values, +) (*Token, error) { + var tr tokenResponse + if err := postForm(ctx, hc, tokenEndpoint, form, &tr); err != nil { + return nil, err + } + if tr.AccessToken == "" { + return nil, fmt.Errorf("token endpoint returned no access token") + } + tok := &Token{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + TokenType: tr.TokenType, + Scope: tr.Scope, + } + if tr.ExpiresIn > 0 { + tok.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second) + } + return tok, nil +} + +// Refresh exchanges a refresh token for a new token pair (RFC 6749 §6). +func Refresh( + ctx context.Context, + hc *http.Client, + tokenEndpoint, clientID, refreshToken string, +) (*Token, error) { + tok, err := requestToken(ctx, hc, tokenEndpoint, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + "client_id": {clientID}, + }) + if err != nil { + return nil, err + } + // Servers that do not rotate refresh tokens omit them from the response; + // keep using the current one. + if tok.RefreshToken == "" { + tok.RefreshToken = refreshToken + } + return tok, nil +} + +// Revoke revokes a token at the revocation endpoint (RFC 7009). +func Revoke( + ctx context.Context, + hc *http.Client, + revocationEndpoint, clientID, token string, +) error { + return postForm(ctx, hc, revocationEndpoint, url.Values{ + "token": {token}, + "client_id": {clientID}, + }, nil) +} + +// randomURLSafe returns n random bytes base64url-encoded without padding. +func randomURLSafe(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// s256Challenge computes the S256 PKCE code challenge for a verifier +// (RFC 7636 §4.2). +func s256Challenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go new file mode 100644 index 0000000..606a806 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,518 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func metadataHandler(md *Metadata) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + } +} + +func TestDiscover(t *testing.T) { + t.Run("success", func(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + mux.HandleFunc("/.well-known/oauth-authorization-server", metadataHandler(&Metadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/oauth/authorize", + TokenEndpoint: server.URL + "/oauth/token", + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + })) + + md, err := Discover(context.Background(), server.Client(), server.URL) + require.NoError(t, err) + assert.Equal(t, server.URL+"/oauth/token", md.TokenEndpoint) + assert.True(t, md.SupportsGrant("authorization_code")) + assert.False(t, md.SupportsGrant(DeviceGrantType)) + }) + + t.Run("not found", func(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + _, err := Discover(context.Background(), server.Client(), server.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 404") + }) + + t.Run("missing token endpoint", func(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + mux.HandleFunc("/.well-known/oauth-authorization-server", + func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{"issuer": %q}`, server.URL) + }) + + _, err := Discover(context.Background(), server.Client(), server.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing token_endpoint") + }) + + t.Run("issuer mismatch is rejected", func(t *testing.T) { + server := httptest.NewServer(metadataHandler(&Metadata{ + Issuer: "https://evil.example.com", + TokenEndpoint: "https://evil.example.com/oauth/token", + })) + defer server.Close() + + _, err := Discover(context.Background(), server.Client(), server.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match the requested issuer") + }) + + t.Run("non-loopback http issuer is rejected", func(t *testing.T) { + _, err := Discover(context.Background(), http.DefaultClient, "http://example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "http is only allowed for loopback hosts") + }) + + t.Run("path issuers use the RFC 8414 well-known location", func(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + mux.HandleFunc("/.well-known/oauth-authorization-server/tenant", + func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf( + w, `{"issuer": "%s/tenant", "token_endpoint": "%s/tenant/oauth/token"}`, + server.URL, server.URL, + ) + }) + + md, err := Discover(context.Background(), server.Client(), server.URL+"/tenant") + require.NoError(t, err) + assert.Equal(t, server.URL+"/tenant/oauth/token", md.TokenEndpoint) + }) +} + +func TestSupportsGrantDefaults(t *testing.T) { + md := &Metadata{} + assert.True(t, md.SupportsGrant("authorization_code")) + assert.False(t, md.SupportsGrant(DeviceGrantType)) +} + +func TestRegister(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var req RegistrationRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Equal(t, "none", req.TokenEndpointAuthMethod) + assert.Equal(t, "Honeybadger CLI", req.ClientName) + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"client_id": "abc123"}`)) + })) + defer server.Close() + + clientID, err := Register( + context.Background(), + server.Client(), + server.URL, + RegistrationRequest{ + ClientName: "Honeybadger CLI", + RedirectURIs: []string{"http://127.0.0.1:8000/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + }, + ) + require.NoError(t, err) + assert.Equal(t, "abc123", clientID) + }) + + t.Run("oauth error response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write( + []byte(`{"error": "invalid_redirect_uri", "error_description": "bad uri"}`), + ) + })) + defer server.Close() + + _, err := Register(context.Background(), server.Client(), server.URL, RegistrationRequest{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid_redirect_uri") + assert.Contains(t, err.Error(), "bad uri") + }) +} + +// fakeAuthServer simulates the authorization server for the auth-code flow. +// Its "browser" follows the authorize URL and invokes the loopback redirect. +type fakeAuthServer struct { + t *testing.T + server *httptest.Server + authCode string + denyAccess bool + tamperWith string // query param to overwrite in the redirect + + gotVerifier string + gotChallenge string +} + +func newFakeAuthServer(t *testing.T) *fakeAuthServer { + f := &fakeAuthServer{t: t, authCode: "test-auth-code"} + mux := http.NewServeMux() + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.Equal(t, "authorization_code", r.Form.Get("grant_type")) + assert.Equal(t, f.authCode, r.Form.Get("code")) + f.gotVerifier = r.Form.Get("code_verifier") + if s256Challenge(f.gotVerifier) != f.gotChallenge { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write( + []byte( + `{"error": "invalid_grant", "error_description": "PKCE verification failed"}`, + ), + ) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "token_type": "Bearer", + "scope": "read write", + "expires_in": 3600 + }`)) + }) + f.server = httptest.NewServer(mux) + t.Cleanup(f.server.Close) + return f +} + +func (f *fakeAuthServer) metadata() *Metadata { + return &Metadata{ + Issuer: f.server.URL, + AuthorizationEndpoint: f.server.URL + "/oauth/authorize", + TokenEndpoint: f.server.URL + "/oauth/token", + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + } +} + +// browse acts as the user's browser: it parses the authorize URL and hits the +// loopback redirect URI with the resulting code (or error). +func (f *fakeAuthServer) browse(authURL string) error { + u, err := parseURL(authURL) + if err != nil { + return err + } + q := u.Query() + assert.Equal(f.t, "code", q.Get("response_type")) + assert.Equal(f.t, "S256", q.Get("code_challenge_method")) + assert.NotEmpty(f.t, q.Get("state")) + f.gotChallenge = q.Get("code_challenge") + + redirect, err := parseURL(q.Get("redirect_uri")) + if err != nil { + return err + } + rq := redirect.Query() + if f.denyAccess { + rq.Set("error", "access_denied") + rq.Set("error_description", "user said no") + } else { + rq.Set("code", f.authCode) + } + rq.Set("state", q.Get("state")) + if f.tamperWith == "state" { + rq.Set("state", "wrong-state") + } + redirect.RawQuery = rq.Encode() + + go func() { + resp, err := http.Get(redirect.String()) + if err == nil { + _ = resp.Body.Close() + } + }() + return nil +} + +func runAuthCodeFlow(t *testing.T, f *fakeAuthServer) (*Token, error) { + listener, err := ListenLoopback(0) + require.NoError(t, err) + + flow := &AuthCodeFlow{ + HTTPClient: f.server.Client(), + Metadata: f.metadata(), + ClientID: "client-1", + Scope: "read write", + Listener: listener, + OpenBrowser: f.browse, + Out: testWriter{t}, + Timeout: 10 * time.Second, + } + return flow.Run(context.Background()) +} + +func TestAuthCodeFlow(t *testing.T) { + t.Run("success", func(t *testing.T) { + f := newFakeAuthServer(t) + tok, err := runAuthCodeFlow(t, f) + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.Equal(t, "read write", tok.Scope) + assert.WithinDuration(t, time.Now().Add(time.Hour), tok.ExpiresAt, time.Minute) + assert.NotEmpty(t, f.gotVerifier) + assert.GreaterOrEqual( + t, + len(f.gotVerifier), + 43, + "PKCE verifier must be at least 43 chars (RFC 7636)", + ) + }) + + t.Run("access denied", func(t *testing.T) { + f := newFakeAuthServer(t) + f.denyAccess = true + _, err := runAuthCodeFlow(t, f) + require.Error(t, err) + assert.Contains(t, err.Error(), "access_denied") + }) + + t.Run("state mismatch does not abort the flow", func(t *testing.T) { + f := newFakeAuthServer(t) + f.tamperWith = "state" + listener, err := ListenLoopback(0) + require.NoError(t, err) + + flow := &AuthCodeFlow{ + HTTPClient: f.server.Client(), + Metadata: f.metadata(), + ClientID: "client-1", + Scope: "read write", + Listener: listener, + OpenBrowser: f.browse, + Out: testWriter{t}, + Timeout: 500 * time.Millisecond, + } + _, err = flow.Run(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out", + "a mismatched state must be ignored, not delivered as a result") + }) +} + +func TestDeviceFlow(t *testing.T) { + newServer := func(t *testing.T, pollResponses []string) (*fakePollServer, *Metadata) { + f := &fakePollServer{t: t, responses: pollResponses} + mux := http.NewServeMux() + mux.HandleFunc("/oauth/authorize_device", func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.Equal(t, "client-1", r.Form.Get("client_id")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "device_code": "dev-123", + "user_code": "ABCD-EFGH", + "verification_uri": "https://example.com/device", + "verification_uri_complete": "https://example.com/device?user_code=ABCD-EFGH", + "expires_in": 300, + "interval": 1 + }`)) + }) + mux.HandleFunc("/oauth/token", f.handleToken) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + f.server = server + return f, &Metadata{ + Issuer: server.URL, + TokenEndpoint: server.URL + "/oauth/token", + DeviceAuthorizationEndpoint: server.URL + "/oauth/authorize_device", + GrantTypesSupported: []string{DeviceGrantType, "refresh_token"}, + } + } + + run := func(f *fakePollServer, md *Metadata) (*Token, []time.Duration, error) { + var sleeps []time.Duration + flow := &DeviceFlow{ + HTTPClient: f.server.Client(), + Metadata: md, + ClientID: "client-1", + Scope: "read write", + Out: testWriter{f.t}, + sleep: func(_ context.Context, d time.Duration) error { + sleeps = append(sleeps, d) + return nil + }, + } + tok, err := flow.Run(context.Background()) + return tok, sleeps, err + } + + t.Run("pending then slow_down then success", func(t *testing.T) { + f, md := newServer(t, []string{"authorization_pending", "slow_down", "ok"}) + tok, sleeps, err := run(f, md) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + require.Len(t, sleeps, 3) + assert.Equal(t, 1*time.Second, sleeps[0]) + assert.Equal(t, 1*time.Second, sleeps[1]) + assert.Equal(t, 6*time.Second, sleeps[2], "slow_down must add 5 seconds to the interval") + }) + + t.Run("access denied", func(t *testing.T) { + f, md := newServer(t, []string{"access_denied"}) + _, _, err := run(f, md) + require.Error(t, err) + assert.Contains(t, err.Error(), "denied") + }) + + t.Run("expired token", func(t *testing.T) { + f, md := newServer(t, []string{"expired_token"}) + _, _, err := run(f, md) + require.Error(t, err) + assert.Contains(t, err.Error(), "expired") + }) + + t.Run("unsupported by server", func(t *testing.T) { + f, md := newServer(t, nil) + md.GrantTypesSupported = []string{"authorization_code"} + md.DeviceAuthorizationEndpoint = "" + _, _, err := run(f, md) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support the device authorization grant") + }) +} + +type fakePollServer struct { + t *testing.T + server *httptest.Server + responses []string + calls int +} + +func (f *fakePollServer) handleToken(w http.ResponseWriter, r *http.Request) { + require.NoError(f.t, r.ParseForm()) + assert.Equal(f.t, DeviceGrantType, r.Form.Get("grant_type")) + assert.Equal(f.t, "dev-123", r.Form.Get("device_code")) + + require.Less(f.t, f.calls, len(f.responses), "token endpoint polled more times than expected") + response := f.responses[f.calls] + f.calls++ + + w.Header().Set("Content-Type", "application/json") + if response == "ok" { + _, _ = w.Write( + []byte( + `{"access_token": "device-access-token", "token_type": "Bearer", "expires_in": 3600}`, + ), + ) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, `{"error": %q}`, response) +} + +func TestRefresh(t *testing.T) { + t.Run("rotates refresh token", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.Equal(t, "refresh_token", r.Form.Get("grant_type")) + assert.Equal(t, "old-refresh", r.Form.Get("refresh_token")) + assert.Equal(t, "client-1", r.Form.Get("client_id")) + _, _ = w.Write( + []byte( + `{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 3600}`, + ), + ) + })) + defer server.Close() + + tok, err := Refresh( + context.Background(), + server.Client(), + server.URL, + "client-1", + "old-refresh", + ) + require.NoError(t, err) + assert.Equal(t, "new-access", tok.AccessToken) + assert.Equal(t, "new-refresh", tok.RefreshToken) + }) + + t.Run("keeps refresh token when not rotated", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"access_token": "new-access", "expires_in": 3600}`)) + })) + defer server.Close() + + tok, err := Refresh( + context.Background(), + server.Client(), + server.URL, + "client-1", + "old-refresh", + ) + require.NoError(t, err) + assert.Equal(t, "old-refresh", tok.RefreshToken) + }) + + t.Run("invalid grant", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant"}`)) + })) + defer server.Close() + + _, err := Refresh( + context.Background(), + server.Client(), + server.URL, + "client-1", + "old-refresh", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid_grant") + }) +} + +func TestRevoke(t *testing.T) { + var revoked string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + revoked = r.Form.Get("token") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := Revoke(context.Background(), server.Client(), server.URL, "client-1", "tok-1") + require.NoError(t, err) + assert.Equal(t, "tok-1", revoked) +} + +type testWriter struct{ t *testing.T } + +func (w testWriter) Write(p []byte) (int, error) { + w.t.Log(string(p)) + return len(p), nil +} + +func parseURL(s string) (*url.URL, error) { return url.Parse(s) } + +func TestBuildAuthorizeURL(t *testing.T) { + u, err := buildAuthorizeURL( + "https://example.com/oauth/authorize?tenant=acme", + url.Values{"state": {"s1"}, "client_id": {"c1"}}, + ) + require.NoError(t, err) + parsed, err := url.Parse(u) + require.NoError(t, err) + q := parsed.Query() + assert.Equal(t, "acme", q.Get("tenant"), "existing query params must be preserved") + assert.Equal(t, "s1", q.Get("state")) + assert.Equal(t, "c1", q.Get("client_id")) +} diff --git a/internal/oauth/register.go b/internal/oauth/register.go new file mode 100644 index 0000000..f22d5a1 --- /dev/null +++ b/internal/oauth/register.go @@ -0,0 +1,78 @@ +package oauth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// RegistrationRequest is a dynamic client registration request (RFC 7591). +type RegistrationRequest struct { + ClientName string `json:"client_name"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + // TokenEndpointAuthMethod is always "none": the CLI is a public client + // and cannot keep a client secret. + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope,omitempty"` +} + +type registrationResponse struct { + ClientID string `json:"client_id"` + Error +} + +// Register registers a new public client with the authorization server and +// returns the issued client_id. +func Register( + ctx context.Context, + hc *http.Client, + registrationEndpoint string, + reg RegistrationRequest, +) (string, error) { + reg.TokenEndpointAuthMethod = "none" + + body, err := json.Marshal(reg) + if err != nil { + return "", fmt.Errorf("failed to encode registration request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, registrationEndpoint, bytes.NewReader(body), + ) + if err != nil { + return "", fmt.Errorf("failed to create registration request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := hc.Do(req) // #nosec G704 -- URL comes from the server's OAuth metadata + if err != nil { + return "", fmt.Errorf("client registration failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("failed to read registration response: %w", err) + } + + var rr registrationResponse + if resp.StatusCode < 200 || resp.StatusCode > 299 { + if json.Unmarshal(respBody, &rr) == nil && rr.Code != "" { + return "", fmt.Errorf("client registration failed: %w", &rr.Error) + } + return "", fmt.Errorf("client registration failed: HTTP %d", resp.StatusCode) + } + if err := json.Unmarshal(respBody, &rr); err != nil { + return "", fmt.Errorf("failed to parse registration response: %w", err) + } + if rr.ClientID == "" { + return "", fmt.Errorf("client registration succeeded but returned no client_id") + } + return rr.ClientID, nil +}