Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions cmd/create_provider_app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package cmd

import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/amp-labs/cli/clerk"
"github.com/amp-labs/cli/flags"
"github.com/amp-labs/cli/logger"
"github.com/amp-labs/cli/request"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
)

const maxClientSecretBytes = 16 * 1024

var (
errClientSecretEmpty = errors.New("client secret cannot be empty")
errClientSecretMultiline = errors.New("client secret must contain exactly one line")
errClientSecretTooLong = errors.New("client secret is too long")
)

type providerAppClient interface {
CreateProviderApp(ctx context.Context, params *request.CreateProviderAppParams) (*request.ProviderApp, error)
}

type providerAppClientFactory func(projectId string, apiKey string) providerAppClient

type projectIdResolver func() string

func readClientSecret(reader io.Reader) (string, error) {
data, err := io.ReadAll(io.LimitReader(reader, maxClientSecretBytes+1))
if err != nil {
return "", fmt.Errorf("read client secret: %w", err)
}

if len(data) > maxClientSecretBytes {
return "", errClientSecretTooLong
}

secret := strings.TrimSuffix(string(data), "\n")
secret = strings.TrimSuffix(secret, "\r")

if strings.TrimSpace(secret) == "" {
return "", errClientSecretEmpty
}

if strings.ContainsAny(secret, "\r\n") {
return "", errClientSecretMultiline
}

return secret, nil
}

func promptClientSecret() (string, error) {
prompt := promptui.Prompt{
Label: "Client secret",
Mask: '*',
Stdin: os.Stdin,
Stdout: os.Stdout,
}

secret, err := prompt.Run()
if err != nil {
return "", fmt.Errorf("read client secret: %w", err)
}

if strings.TrimSpace(secret) == "" {
return "", errClientSecretEmpty
}

return secret, nil
}

func providerAppCreatedMessage(providerApp *request.ProviderApp) string {
scopes := "none"
if len(providerApp.Scopes) > 0 {
scopes = strings.Join(providerApp.Scopes, ", ")
}

return fmt.Sprintf("Provider App ID: %s, Provider: %s, Scopes: %s",
providerApp.Id, providerApp.Provider, scopes)
}

func newCreateProviderAppCmd(
newClient providerAppClientFactory,
getProjectId projectIdResolver,
) *cobra.Command {
var (
clientId string
scopes []string
clientSecretStdin bool
)

cmd := &cobra.Command{
Use: "create:provider-app <provider>",
Short: "Create a provider app",
Long: "Create an OAuth provider app in an Ampersand project. " +
"The client secret is requested in a masked prompt unless --client-secret-stdin is set.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
projectId := getProjectId()
apiKey := flags.GetAPIKey()

if flags.GetDebugMode() {
logger.Fatal("Debug logging is not available when submitting a client secret")
}

var (
clientSecret string
err error
)

if clientSecretStdin {
clientSecret, err = readClientSecret(cmd.InOrStdin())
} else {
clientSecret, err = promptClientSecret()
}

if err != nil {
logger.FatalErr("Unable to read client secret", err)
}

client := newClient(projectId, apiKey)

providerApp, err := client.CreateProviderApp(cmd.Context(), &request.CreateProviderAppParams{
Provider: args[0],
ClientId: clientId,
ClientSecret: clientSecret,
Scopes: scopes,
})
if err != nil {
if errors.Is(err, clerk.ErrNoSessions) {
logger.Fatal("Authenticated session has expired, please log in using amp login")
} else {
logger.Fatal("Unable to create provider app. The API rejected the request")
}
}

logger.Info(providerAppCreatedMessage(providerApp))
},
}

cmd.Flags().StringVar(&clientId, "client-id", "", "OAuth client ID")
cmd.Flags().StringArrayVar(&scopes, "scope", nil, "OAuth scope to register (repeat for multiple scopes)")
cmd.Flags().BoolVar(&clientSecretStdin, "client-secret-stdin", false,
"Read the OAuth client secret from stdin instead of prompting")

err := cmd.MarkFlagRequired("client-id")
if err != nil {
logger.FatalErr("unable to require client-id flag", err)
}

return cmd
}

var createProviderAppCmd = newCreateProviderAppCmd( //nolint:gochecknoglobals
func(projectId string, apiKey string) providerAppClient {
return request.NewAPIClient(projectId, &apiKey)
},
flags.GetProjectOrFail,
)

func init() {
rootCmd.AddCommand(createProviderAppCmd)
}
101 changes: 101 additions & 0 deletions cmd/create_provider_app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package cmd

import (
"context"
"slices"
"strings"
"testing"

"github.com/amp-labs/cli/request"
)

const testClientSecret = "test-client-secret"

type fakeProviderAppClient struct {
createdWith *request.CreateProviderAppParams
}

func (f *fakeProviderAppClient) CreateProviderApp(
_ context.Context,
params *request.CreateProviderAppParams,
) (*request.ProviderApp, error) {
f.createdWith = params

return &request.ProviderApp{
Id: "provider-app-id",
Provider: params.Provider,
ClientId: params.ClientId,
ClientSecret: params.ClientSecret,
Scopes: params.Scopes,
}, nil
}

func TestCreateProviderAppCommandReadsClientSecretFromStdin(t *testing.T) {
t.Parallel()

client := &fakeProviderAppClient{}
cmd := newCreateProviderAppCmd(func(projectId string, _ string) providerAppClient {
if projectId != "test-project" {
t.Fatalf("project ID = %q, want test-project", projectId)
}

return client
}, func() string {
return "test-project"
})
cmd.SetIn(strings.NewReader(testClientSecret + "\n"))
cmd.SetArgs([]string{
"asana",
"--client-id", "test-client-id",
"--scope", "projects:read",
"--scope", "projects:write",
"--client-secret-stdin",
})

err := cmd.Execute()
if err != nil {
t.Fatalf("execute create provider app command: %v", err)
}

want := request.CreateProviderAppParams{
Provider: "asana",
ClientId: "test-client-id",
ClientSecret: testClientSecret,
Scopes: []string{"projects:read", "projects:write"},
}
if client.createdWith == nil || !providerAppParamsEqual(client.createdWith, &want) {
t.Fatalf("create provider app params = %#v, want %#v", client.createdWith, want)
}
}

func TestReadClientSecretRejectsMultipleLines(t *testing.T) {
t.Parallel()

_, err := readClientSecret(strings.NewReader("first\nsecond\n"))
if err == nil {
t.Fatal("read client secret succeeded, want error")
}
}

func TestProviderAppCreatedMessageOmitsCredentials(t *testing.T) {
t.Parallel()

message := providerAppCreatedMessage(&request.ProviderApp{
Id: "provider-app-id",
Provider: "asana",
ClientId: "test-client-id",
ClientSecret: testClientSecret,
Scopes: []string{"projects:read"},
})

if strings.Contains(message, "test-client-id") || strings.Contains(message, testClientSecret) {
t.Fatalf("message contains credentials: %q", message)
}
}

func providerAppParamsEqual(left *request.CreateProviderAppParams, right *request.CreateProviderAppParams) bool {
return left.Provider == right.Provider &&
left.ClientId == right.ClientId &&
left.ClientSecret == right.ClientSecret &&
slices.Equal(left.Scopes, right.Scopes)
}
18 changes: 18 additions & 0 deletions request/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,24 @@ func (c *APIClient) ListProviderApps(ctx context.Context) ([]*ProviderApp, error
return providerApps, nil
}

func (c *APIClient) CreateProviderApp(ctx context.Context, params *CreateProviderAppParams) (*ProviderApp, error) {
createURL := fmt.Sprintf("%s/projects/%s/provider-apps", c.Root, c.ProjectId)

auth, err := c.getAuthHeader(ctx)
if err != nil {
return nil, err
}

var providerApp ProviderApp

_, err = c.Client.Post(ctx, createURL, params, &providerApp, auth) //nolint:bodyclose
if err != nil {
return nil, err
}

return &providerApp, nil
}

func (c *APIClient) ListProjects(ctx context.Context) ([]*Project, error) {
listURL := c.Root + "/projects"

Expand Down
91 changes: 91 additions & 0 deletions request/create_provider_app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package request

import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
)

const providerAppTestAPIKey = "test-api-key"

func newProviderAppTestAPIClient(server *httptest.Server) *APIClient {
apiKey := providerAppTestAPIKey

return &APIClient{
Root: server.URL + "/v1",
ProjectId: "skills-onboarding-cli-dev",
APIKey: &apiKey,
Client: &Client{Client: server.Client()},
}
}

func TestCreateProviderApp(t *testing.T) {
t.Parallel()

wantParams := CreateProviderAppParams{
Provider: "asana",
ClientId: "test-client-id",
ClientSecret: "test-client-secret",
Scopes: []string{"projects:read", "projects:write"},
}

server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost ||
req.URL.Path != "/v1/projects/skills-onboarding-cli-dev/provider-apps" {
t.Errorf("request = %s %s, want POST provider-apps path", req.Method, req.URL.Path)
}

if req.Header.Get("X-Api-Key") != providerAppTestAPIKey {
t.Errorf("X-Api-Key = %q, want test-api-key", req.Header.Get("X-Api-Key"))
}

var params CreateProviderAppParams

err := json.NewDecoder(req.Body).Decode(&params)
if err != nil {
t.Errorf("decode request: %v", err)
}

if !createProviderAppParamsEqual(params, wantParams) {
t.Errorf("request body = %#v, want %#v", params, wantParams)
}

writer.Header().Set("Content-Type", "application/json")

err = json.NewEncoder(writer).Encode(ProviderApp{
Id: "provider-app-id",
Provider: params.Provider,
ClientId: params.ClientId,
Scopes: params.Scopes,
ProjectId: "project-id",
CreateTime: time.Date(2026, time.August, 18, 12, 0, 0, 0, time.UTC),
})
if err != nil {
t.Errorf("encode response: %v", err)
}
}))
defer server.Close()

providerApp, err := newProviderAppTestAPIClient(server).CreateProviderApp(t.Context(), &wantParams)
if err != nil {
t.Fatalf("create provider app: %v", err)
}

if providerApp.Id != "provider-app-id" || providerApp.Provider != "asana" {
t.Fatalf("provider app = %#v", providerApp)
}

if providerApp.ClientSecret != "" {
t.Fatalf("provider app response contains client secret")
}
}

func createProviderAppParamsEqual(left CreateProviderAppParams, right CreateProviderAppParams) bool {
return left.Provider == right.Provider &&
left.ClientId == right.ClientId &&
left.ClientSecret == right.ClientSecret &&
slices.Equal(left.Scopes, right.Scopes)
}
Loading
Loading