Skip to content

Commit

Permalink
Rework authentication.
Browse files Browse the repository at this point in the history
- Rename --token/--token-cmd to --auth/--auth-cmd.
- Add support for basic auth (--auth-type=basic).
- Add support for API key auth (--auth-type=api-key), with
  a configurable header (--api-key-header, defaults to "X-API-Key").

Requires re-adding profiles (or manually updating the config file).
  • Loading branch information
bojanz committed Feb 1, 2022
1 parent 790d33a commit 6d79823
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 46 deletions.
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
56 changes: 49 additions & 7 deletions broom.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package broom

import (
"bytes"
"encoding/base64"
"fmt"
"io"
"net/http"
Expand All @@ -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),
Expand Down Expand Up @@ -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.
Expand Down
71 changes: 71 additions & 0 deletions broom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 64 additions & 16 deletions cmd/broom/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

flag "github.com/spf13/pflag"

Expand All @@ -19,26 +20,35 @@ const addUsage = `Usage: broom add <profile> <spec_file>
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)
Expand All @@ -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))
Expand All @@ -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")
Expand All @@ -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
}
16 changes: 5 additions & 11 deletions cmd/broom/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 6d79823

Please sign in to comment.