diff --git a/ci/integration/statictokens_test.go b/ci/integration/statictokens_test.go new file mode 100644 index 00000000..d38dcd99 --- /dev/null +++ b/ci/integration/statictokens_test.go @@ -0,0 +1,50 @@ +package integration + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + + "cdr.dev/coder-cli/pkg/tcli" +) + +func TestStaticAuth(t *testing.T) { + t.Parallel() + t.Skip() + run(t, "static-auth-test", func(t *testing.T, ctx context.Context, c *tcli.ContainerRunner) { + headlessLogin(ctx, t, c) + + c.Run(ctx, "coder tokens ls").Assert(t, + tcli.Success(), + ) + + var result *tcli.CommandResult + tokenName := randString(5) + c.Run(ctx, "coder tokens create "+tokenName).Assert(t, + tcli.Success(), + tcli.GetResult(&result), + ) + + // remove loging credentials + c.Run(ctx, "rm -rf ~/.config/coder").Assert(t, + tcli.Success(), + ) + + // make requests with token environment variable authentication + cmd := exec.CommandContext(ctx, "sh", "-c", + fmt.Sprintf("export CODER_URL=%s && export CODER_TOKEN=$(cat) && coder envs ls", os.Getenv("CODER_URL")), + ) + cmd.Stdin = strings.NewReader(string(result.Stdout)) + c.RunCmd(cmd).Assert(t, + tcli.Success(), + ) + + // should error when the environment variabels aren't set + c.Run(ctx, "coder envs ls").Assert(t, + tcli.Error(), + ) + }) +} diff --git a/coder-sdk/request.go b/coder-sdk/request.go index 4ae2bdf2..14a59b5e 100644 --- a/coder-sdk/request.go +++ b/coder-sdk/request.go @@ -51,7 +51,7 @@ func (c Client) requestBody(ctx context.Context, method, path string, in, out in // Responses in the 100 are handled by the http lib, in the 200 range, we have a success. // Consider anything at or above 300 to be an error. if resp.StatusCode > 299 { - return fmt.Errorf("unexpected status code: %w", bodyError(resp)) + return fmt.Errorf("unexpected status code %d: %w", resp.StatusCode, bodyError(resp)) } // If we expect a payload, process it as json. diff --git a/coder-sdk/tokens.go b/coder-sdk/tokens.go new file mode 100644 index 00000000..946979c0 --- /dev/null +++ b/coder-sdk/tokens.go @@ -0,0 +1,60 @@ +package coder + +import ( + "context" + "net/http" + "time" +) + +type APIToken struct { + ID string `json:"id"` + Name string `json:"name"` + Application bool `json:"application"` + UserID string `json:"user_id"` + LastUsed time.Time `json:"last_used"` +} + +type CreateAPITokenReq struct { + Name string `json:"name"` +} + +type createAPITokenResp struct { + Key string `json:"key"` +} + +func (c Client) CreateAPIToken(ctx context.Context, userID string, req CreateAPITokenReq) (token string, _ error) { + var resp createAPITokenResp + err := c.requestBody(ctx, http.MethodPost, "/api/api-keys/"+userID, req, &resp) + if err != nil { + return "", err + } + return resp.Key, nil +} + +func (c Client) APITokens(ctx context.Context, userID string) ([]APIToken, error) { + var tokens []APIToken + if err := c.requestBody(ctx, http.MethodGet, "/api/api-keys/"+userID, nil, &tokens); err != nil { + return nil, err + } + return tokens, nil +} + +func (c Client) APITokenByID(ctx context.Context, userID, tokenID string) (*APIToken, error) { + var token APIToken + if err := c.requestBody(ctx, http.MethodGet, "/api/api-keys/"+userID+"/"+tokenID, nil, &token); err != nil { + return nil, err + } + return &token, nil +} + +func (c Client) DeleteAPIToken(ctx context.Context, userID, tokenID string) error { + return c.requestBody(ctx, http.MethodDelete, "/api/api-keys/"+userID+"/"+tokenID, nil, nil) +} + +func (c Client) RegenerateAPIToken(ctx context.Context, userID, tokenID string) (token string, _ error) { + var resp createAPITokenResp + if err := c.requestBody(ctx, http.MethodPost, "/api/api-keys/"+userID+"/"+tokenID+"/regen", nil, &resp); err != nil { + return "", err + } + return resp.Key, nil +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index b517abc6..b4a9b1c8 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "os" "golang.org/x/xerrors" @@ -19,15 +20,26 @@ var errNeedLogin = clog.Fatal( clog.Hintf(`did you run "coder login [https://coder.domain.com]"?`), ) +const tokenEnv = "CODER_TOKEN" +const urlEnv = "CODER_URL" + func newClient(ctx context.Context) (*coder.Client, error) { - sessionToken, err := config.Session.Read() - if err != nil { - return nil, errNeedLogin - } + var ( + err error + sessionToken = os.Getenv(tokenEnv) + rawURL = os.Getenv(urlEnv) + ) - rawURL, err := config.URL.Read() - if err != nil { - return nil, errNeedLogin + if sessionToken == "" || rawURL == "" { + sessionToken, err = config.Session.Read() + if err != nil { + return nil, errNeedLogin + } + + rawURL, err = config.URL.Read() + if err != nil { + return nil, errNeedLogin + } } u, err := url.Parse(rawURL) diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index c629beb3..462c7c3a 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -30,6 +30,7 @@ func Make() *cobra.Command { envsCmd(), syncCmd(), urlCmd(), + tokensCmd(), resourceCmd(), completionCmd(), genDocsCmd(app), diff --git a/internal/cmd/tokens.go b/internal/cmd/tokens.go new file mode 100644 index 00000000..36709d75 --- /dev/null +++ b/internal/cmd/tokens.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "fmt" + + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/pkg/tablewriter" + "github.com/spf13/cobra" +) + +func tokensCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tokens", + Short: "manage Coder API tokens for the active user", + Hidden: true, + Long: "Create and manage API Tokens for authenticating the CLI.\n" + + "Statically authenticate using the token value with the " + "`" + "CODER_TOKEN" + "`" + " and " + "`" + "CODER_URL" + "`" + " environment variables.", + } + cmd.AddCommand( + lsTokensCmd(), + createTokensCmd(), + rmTokenCmd(), + regenTokenCmd(), + ) + return cmd +} + +func lsTokensCmd() *cobra.Command { + return &cobra.Command{ + Use: "ls", + Short: "show the user's active API tokens", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx) + if err != nil { + return err + } + + tokens, err := client.APITokens(ctx, coder.Me) + if err != nil { + return err + } + + err = tablewriter.WriteTable(len(tokens), func(i int) interface{} { return tokens[i] }) + if err != nil { + return err + } + + return nil + }, + } +} + +func createTokensCmd() *cobra.Command { + return &cobra.Command{ + Use: "create [token_name]", + Short: "create generates a new API token and prints it to stdout", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx) + if err != nil { + return err + } + token, err := client.CreateAPIToken(ctx, coder.Me, coder.CreateAPITokenReq{ + Name: args[0], + }) + if err != nil { + return err + } + fmt.Println(token) + return nil + }, + } +} + +func rmTokenCmd() *cobra.Command { + return &cobra.Command{ + Use: "rm [token_id]", + Short: "remove an API token by its unique ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx) + if err != nil { + return err + } + if err = client.DeleteAPIToken(ctx, coder.Me, args[0]); err != nil { + return err + } + return nil + }, + } +} + +func regenTokenCmd() *cobra.Command { + return &cobra.Command{ + Use: "regen [token_id]", + Short: "regenerate an API token by its unique ID and print the new token to stdout", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx) + if err != nil { + return err + } + token, err := client.RegenerateAPIToken(ctx, coder.Me, args[0]) + if err != nil { + return nil + } + fmt.Println(token) + return nil + }, + } +}