diff --git a/README.md b/README.md index e9fc78a..86ca671 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ Each profile has its own server url and authentication settings. ```bash cd my-project/ -broom add prod openapi.json --token=PRODUCTION_KEY -broom add staging openapi.json --token=STAGING_KEY --server-url=htts://staging.my-api.io +broom add prod openapi.json --auth=PRODUCTION_KEY --auth-type=api-key +broom add staging openapi.json --auth=STAGING_KEY --auth-type=api-key --server-url=htts://staging.my-api.io # Proceed as usual. broom prod list-products @@ -70,19 +70,36 @@ broom staging list-products ## Authentication -An access token can be set on the profile via `broom add --token`. This is the usual way of sending API keys. +Broom supports authenticating using an API key, Basic auth, or a Bearer token. -For more advanced use cases, Broom supports fetching an access token through an external command: +Using an API key (X-API-Key header): ``` - broom add api openapi.json --token-cmd="sh get-token.sh" +broom add api openapi.json --auth=MYKEY --auth-type=api-key +``` + +Using an API key (custom header): +``` +broom add api openapi.json --auth=MYKEY --auth-type=api-key --api-key-header="X-MyApp-Key" +``` + +Using Basic auth: +``` +broom add api openapi.json --auth="username:password" --auth-type=basic +``` + +Using a Bearer token: +``` +broom add api openapi.json --auth=MYKEY --auth-type=bearer +``` + +For more advanced use cases, Broom supports fetching credentials through an external command: +``` + broom add api openapi.json --auth-cmd="sh get-token.sh" --auth-type=bearer ``` The external command can do a 2-legged OAuth request via curl, or it can retrieve an API key from a vault. It is run before each request to ensure freshness. -Note: Access tokens are currently always sent in an "Authorization: Bearer" header. The OpenAPI spec allows -specifying which header to use (e.g. "X-API-Key"), Broom should support that at some point. - ## Name Named after a curling broom, with bonus points for resembling the sound a car makes (in certain languages). diff --git a/broom.go b/broom.go index f013322..48dfe54 100644 --- a/broom.go +++ b/broom.go @@ -5,6 +5,7 @@ package broom import ( "bytes" + "encoding/base64" "fmt" "io" "net/http" @@ -25,6 +26,45 @@ type Result struct { Output string } +// Authorize acquires access credentials and sets them on the request. +func Authorize(req *http.Request, cfg AuthConfig) error { + if cfg.Credentials == "" && cfg.Command == "" { + return nil + } + credentials := cfg.Credentials + if cfg.Command != "" { + var err error + credentials, err = RunCommand(cfg.Command) + if err != nil { + return fmt.Errorf("authorize: %w", err) + } + if credentials == "" { + return fmt.Errorf("authorize: run command: no credentials received") + } + } + + switch cfg.Type { + case "bearer": + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", credentials)) + case "basic": + credentials = base64.StdEncoding.EncodeToString([]byte(credentials)) + req.Header.Set("Authorization", fmt.Sprintf("Basic %v", credentials)) + case "api-key": + key := cfg.APIKeyHeader + if key == "" { + key = "X-API-Key" + } + req.Header.Set(key, credentials) + } + + return nil +} + +// AuthTypes returns a list of supported auth types. +func AuthTypes() []string { + return []string{"bearer", "basic", "api-key"} +} + // Execute performs the given HTTP request and returns the result. // // The output consists of the request body (pretty-printed if JSON), @@ -74,20 +114,22 @@ func PrettyJSON(json []byte) []byte { return pretty.Color(pretty.Pretty(json), nil) } -// RetrieveToken retrieves a token by running the given command. -func RetrieveToken(tokenCmd string) (string, error) { +// RunCommand runs the given command and returns its output. +// +// The command has access to environment variables. +func RunCommand(command string) (string, error) { errBuf := &bytes.Buffer{} - cmd := exec.Command("sh", "-c", tokenCmd) + cmd := exec.Command("sh", "-c", command) cmd.Env = os.Environ() cmd.Stderr = errBuf - output, err := cmd.Output() + b, err := cmd.Output() if err != nil { // The error is just a return code, which isn't useful. - return "", fmt.Errorf("retrieve token: %v", errBuf.String()) + return "", fmt.Errorf("run command: %v", errBuf.String()) } - token := strings.TrimSpace(string(output)) + output := bytes.TrimSpace(b) - return token, nil + return string(output), nil } // Sanitize sanitizes the given string, stripping HTML and trailing newlines. diff --git a/broom_test.go b/broom_test.go index 0f57a06..8a1e69f 100644 --- a/broom_test.go +++ b/broom_test.go @@ -4,11 +4,82 @@ package broom_test import ( + "net/http" "testing" "github.com/bojanz/broom" ) +func TestAuthorize(t *testing.T) { + // No credentials. + req, _ := http.NewRequest("GET", "/test", nil) + err := broom.Authorize(req, broom.AuthConfig{}) + if err != nil { + t.Errorf("unexpected error %v", err) + } + + // API key. + req, _ = http.NewRequest("GET", "/test", nil) + err = broom.Authorize(req, broom.AuthConfig{ + Credentials: "MYKEY", + Type: "api-key", + }) + if err != nil { + t.Errorf("unexpected error %v", err) + } + got := req.Header.Get("X-API-Key") + want := "MYKEY" + if got != want { + t.Errorf(`got %q, want %q`, got, want) + } + + // API key, custom header. + req, _ = http.NewRequest("GET", "/test", nil) + err = broom.Authorize(req, broom.AuthConfig{ + Credentials: "MYKEY", + Type: "api-key", + APIKeyHeader: "X-MyApp-Key", + }) + if err != nil { + t.Errorf("unexpected error %v", err) + } + got = req.Header.Get("X-MyApp-Key") + want = "MYKEY" + if got != want { + t.Errorf(`got %q, want %q`, got, want) + } + + // Basic auth. + req, _ = http.NewRequest("GET", "/test", nil) + err = broom.Authorize(req, broom.AuthConfig{ + Credentials: "myuser:mypass", + Type: "basic", + }) + if err != nil { + t.Errorf("unexpected error %v", err) + } + got = req.Header.Get("Authorization") + want = "Basic bXl1c2VyOm15cGFzcw==" + if got != want { + t.Errorf(`got %q, want %q`, got, want) + } + + // Bearer auth. + req, _ = http.NewRequest("GET", "/test", nil) + err = broom.Authorize(req, broom.AuthConfig{ + Credentials: "MYKEY", + Type: "bearer", + }) + if err != nil { + t.Errorf("unexpected error %v", err) + } + got = req.Header.Get("Authorization") + want = "Bearer MYKEY" + if got != want { + t.Errorf(`got %q, want %q`, got, want) + } +} + func TestIsJSON(t *testing.T) { tests := []struct { mediaType string diff --git a/cmd/broom/add.go b/cmd/broom/add.go index 6c66261..5a1d50b 100644 --- a/cmd/broom/add.go +++ b/cmd/broom/add.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" flag "github.com/spf13/pflag" @@ -19,26 +20,35 @@ const addUsage = `Usage: broom add Adds a profile to the .broom.yaml config file in the current directory. +The auth type, API key header, and server url will be auto-detected from +the specification, unless they are provided via options. + Examples: Single profile: broom add api openapi.yaml - Multiple profiles and an API key: - broom add prod openapi.json --token=PRODUCTION_KEY - broom add staging openapi.json --token=STAGING_KEY --server-url=htts://staging.my-api.io + Single profile with Bearer auth via external command: + broom add api openapi.json --auth-cmd="sh get-token.sh" --auth-type=bearer + + Single profile with Basic auth: + broom add api openapi.yaml --auth="myuser:mypass" --auth-type=basic - Authentication through an external command (e.g. for OAuth): - broom add api openapi.json --token-cmd="sh get-token.sh" + Multiple profiles with different API keys: + broom add prod openapi.yaml --auth=PRODUCTION_KEY --auth-type=api-key + broom add staging openapi.yaml --auth=STAGING_KEY --auth-type=api-key --server-url=htts://staging.my-api.io Options:` func addCmd(args []string) { + authTypes := broom.AuthTypes() flags := flag.NewFlagSet("add", flag.ExitOnError) var ( - _ = flags.BoolP("help", "h", false, "Display this help text and exit") - serverURL = flags.String("server-url", "", "Server URL. Overrides the one from the specification file") - token = flags.String("token", "", "Access token. Used to authorize every request") - tokenCmd = flags.String("token-cmd", "", "Access token command. Executed on every request to retrieve a token") + _ = flags.BoolP("help", "h", false, "Display this help text and exit") + authCredentials = flags.String("auth", "", "Auth credentials (e.g. access token or API key). Used to authorize every request") + authCommand = flags.String("auth-cmd", "", "Auth command. Executed on every request to retrieve auth credentials") + authType = flags.String("auth-type", "", fmt.Sprintf("Auth type. One of: %v. Defaults to %v", strings.Join(authTypes, ", "), authTypes[0])) + apiKeyHeader = flags.String("api-key-header", "", "API key header. Defaults to X-API-Key") + serverURL = flags.String("server-url", "", "Server URL") ) flags.Usage = func() { fmt.Println(addUsage) @@ -50,6 +60,10 @@ func addCmd(args []string) { flags.Usage() return } + if *authType != "" && !contains(authTypes, *authType) { + fmt.Fprintf(os.Stderr, "Error: unrecognized auth type %q\n", *authType) + os.Exit(1) + } profile := flags.Arg(1) filename := filepath.Clean(flags.Arg(2)) @@ -65,18 +79,42 @@ func addCmd(args []string) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - var specServerURL string - if len(spec.Servers) > 0 { - specServerURL = spec.Servers[0].URL + specAuthType := authTypes[0] + specAPIKeyHeader := "" + for _, securityScheme := range spec.Components.SecuritySchemes { + if securityScheme.Value == nil { + continue + } + if securityScheme.Value.Type == "http" && securityScheme.Value.Scheme == "bearer" { + specAuthType = "bearer" + break + } else if securityScheme.Value.Type == "http" && securityScheme.Value.Scheme == "basic" { + specAuthType = "basic" + break + } else if securityScheme.Value.Type == "apiKey" && securityScheme.Value.In == "header" { + specAuthType = "api-key" + specAPIKeyHeader = securityScheme.Value.Name + break + } + } + if *serverURL == "" && len(spec.Servers) > 0 { + *serverURL = spec.Servers[0].URL } - if *serverURL == "" { - *serverURL = specServerURL + if *authType == "" { + *authType = specAuthType + } + if *apiKeyHeader == "" { + *apiKeyHeader = specAPIKeyHeader } profileCfg := broom.ProfileConfig{} profileCfg.SpecFile = filename profileCfg.ServerURL = *serverURL - profileCfg.Token = *token - profileCfg.TokenCmd = *tokenCmd + profileCfg.Auth = broom.AuthConfig{ + Credentials: *authCredentials, + Command: *authCommand, + Type: *authType, + APIKeyHeader: *apiKeyHeader, + } // It is okay if the config file doesn't exist yet, so the error is ignored. cfg, _ := broom.ReadConfig(".broom.yaml") @@ -87,3 +125,13 @@ func addCmd(args []string) { } fmt.Fprintf(os.Stdout, "Added the %v profile to .broom.yaml\n", profile) } + +// contains returns whether the sorted slice a contains x. +func contains(a []string, x string) bool { + for _, v := range a { + if v == x { + return true + } + } + return false +} diff --git a/cmd/broom/profile.go b/cmd/broom/profile.go index 44a280c..13a03ae 100644 --- a/cmd/broom/profile.go +++ b/cmd/broom/profile.go @@ -93,31 +93,25 @@ func profileCmd(args []string) { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } - token := profileCfg.Token - if profileCfg.TokenCmd != "" { - token, err = broom.RetrieveToken(profileCfg.TokenCmd) - if err != nil { - fmt.Fprintln(os.Stderr, "Error:", err) - os.Exit(1) - } - } req, err := http.NewRequest(operation.Method, profileCfg.ServerURL+path, bytes.NewReader(bodyBytes)) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } + if err = broom.Authorize(req, profileCfg.Auth); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } if operation.HasBody() { req.Header.Set("Content-Type", operation.BodyFormat) } - if token != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) - } for _, header := range *headers { kv := strings.SplitN(header, ":", 2) req.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])) } req.Header.Set("User-Agent", fmt.Sprintf("broom/%s (%s %s)", broom.Version, runtime.GOOS, runtime.GOARCH)) + result, err := broom.Execute(req, *verbose) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) diff --git a/config.go b/config.go index dc59bb3..aac4e54 100644 --- a/config.go +++ b/config.go @@ -27,10 +27,17 @@ func (c Config) Profiles() []string { // ProfileConfig represents Broom's per-profile configuration. type ProfileConfig struct { - SpecFile string `yaml:"spec_file"` - ServerURL string `yaml:"server_url"` - Token string `yaml:"token"` - TokenCmd string `yaml:"token_cmd"` + SpecFile string `yaml:"spec_file"` + ServerURL string `yaml:"server_url"` + Auth AuthConfig `yaml:"auth"` +} + +// AuthConfig represents a profile's authentication configuration. +type AuthConfig struct { + Credentials string `yaml:"credentials"` + Command string `yaml:"command"` + Type string `yaml:"type"` + APIKeyHeader string `yaml:"api_key_header"` } // ReadConfig reads a config file with the given filename.