Skip to content
Merged
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
18 changes: 16 additions & 2 deletions internal/custom/custom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,15 @@ func TestExchangeForOrganizationKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
switch r.URL.Path {
case "/api/cli/key":
case "/v1/cli/key":
_, _ = io.WriteString(w, `{"apiKey":"sk_org","organizationId":"org_1","organizationName":"Acme"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

got, err := exchangeForOrganizationKey(context.Background(), srv.Client(), srv.URL, "at-1", "laptop")
got, err := exchangeForOrganizationKey(context.Background(), srv.Client(), srv.URL+"/v1/cli/key", "at-1", "laptop")
if err != nil {
t.Fatal(err)
}
Expand All @@ -352,3 +352,17 @@ func TestExchangeForOrganizationKey(t *testing.T) {
t.Fatalf("404 should mean not available, got %v", err)
}
}

func TestKeyExchangeURLIsTheTokenAudience(t *testing.T) {
t.Setenv("MODAPI_KEY_EXCHANGE_URL", "")
t.Setenv("MODAPI_OAUTH_RESOURCE", "")
cfg := loadSettings()
if cfg.KeyExchangeURL != "https://api.moderationapi.com/v1/cli/key" || cfg.Resource != cfg.KeyExchangeURL {
t.Fatalf("got exchange %q, resource %q", cfg.KeyExchangeURL, cfg.Resource)
}

t.Setenv("MODAPI_KEY_EXCHANGE_URL", "https://staging.example.com/v1/cli/key")
if cfg := loadSettings(); cfg.Resource != "https://staging.example.com/v1/cli/key" {
t.Fatalf("resource %q does not follow the exchange URL", cfg.Resource)
}
}
18 changes: 9 additions & 9 deletions internal/custom/exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,29 @@ import (
"net/http"
)

// exchangeResult is the dashboard's answer: the same shape as the MCP key
// exchange (POST /api/mcp/key).
// exchangeResult is the key exchange's answer: the same shape as the MCP key
// exchange.
type exchangeResult struct {
APIKey string `json:"apiKey"`
OrganizationID string `json:"organizationId"`
OrganizationName string `json:"organizationName"`
}

// errExchangeNotAvailable means the dashboard has no CLI key exchange yet.
// errExchangeNotAvailable means the API has no CLI key exchange yet.
var errExchangeNotAvailable = errors.New("this Moderation API account cannot sign in from the CLI yet")

// exchangeForOrganizationKey trades a Clerk OAuth access token for an
// organization key of the organization the user picked on the consent screen.
// The dashboard makes one key per user and device name.
func exchangeForOrganizationKey(ctx context.Context, client *http.Client, dashboardURL, accessToken, deviceName string) (*exchangeResult, error) {
if err := requireHTTPS(dashboardURL); err != nil {
// The API makes one key per user and device name.
func exchangeForOrganizationKey(ctx context.Context, client *http.Client, keyExchangeURL, accessToken, deviceName string) (*exchangeResult, error) {
if err := requireHTTPS(keyExchangeURL); err != nil {
return nil, err
}
payload, err := json.Marshal(map[string]string{"deviceName": deviceName})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, dashboardURL+"/api/cli/key", bytes.NewReader(payload))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, keyExchangeURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
Expand All @@ -42,7 +42,7 @@ func exchangeForOrganizationKey(ctx context.Context, client *http.Client, dashbo

res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("could not reach the Moderation API dashboard: %w", err)
return nil, fmt.Errorf("could not reach the Moderation API: %w", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
Expand All @@ -61,6 +61,6 @@ func exchangeForOrganizationKey(ctx context.Context, client *http.Client, dashbo
case result.Message != "":
return nil, errors.New(result.Message)
default:
return nil, fmt.Errorf("the dashboard returned %s for the CLI sign-in", res.Status)
return nil, fmt.Errorf("the Moderation API returned %s for the CLI sign-in", res.Status)
}
}
2 changes: 1 addition & 1 deletion internal/custom/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func runLogin(cmd *cobra.Command, _ []string) error {
}

hostname, _ := os.Hostname()
exchanged, err := exchangeForOrganizationKey(ctx, httpClient, cfg.DashboardURL, tokens.AccessToken, hostname)
exchanged, err := exchangeForOrganizationKey(ctx, httpClient, cfg.KeyExchangeURL, tokens.AccessToken, hostname)
if errors.Is(err, errExchangeNotAvailable) {
return withHints(failure(output.ReasonCLIUnavailable, "%v", err),
"Set "+organizationKeyEnv+" to an organization key from the dashboard's API keys page")
Expand Down
26 changes: 15 additions & 11 deletions internal/custom/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ var OAuthClientID = "4Cis1Jh7mS6MINJg"
const (
defaultIssuer = "https://clerk.moderationapi.com"
defaultDashboardURL = "https://dash.moderationapi.com"
// defaultResource is the audience the CLI asks Clerk to put in its access
// token; the dashboard's CLI key exchange accepts only this audience.
defaultResource = "https://dash.moderationapi.com/api/cli/key"
// defaultKeyExchangeURL trades the Clerk access token for an organization
// key. It is also the audience the CLI asks Clerk to put in that token: the
// key exchange accepts only this audience.
defaultKeyExchangeURL = "https://api.moderationapi.com/v1/cli/key"

// projectKeyEnv and webhookSecretEnv are the variables every SDK reads.
projectKeyEnv = "MODAPI_SECRET_KEY"
Expand All @@ -36,19 +37,22 @@ const (
var loginScopes = []string{"openid", "profile", "email", "user:org:read"}

type settings struct {
Issuer string
ClientID string
Resource string
DashboardURL string
Issuer string
ClientID string
Resource string
KeyExchangeURL string
DashboardURL string
}

// loadSettings reads the sign-in settings. The env vars exist for staging and tests.
func loadSettings() settings {
keyExchangeURL := envOr("MODAPI_KEY_EXCHANGE_URL", defaultKeyExchangeURL)
return settings{
Issuer: envOr("MODAPI_OAUTH_ISSUER", defaultIssuer),
ClientID: envOr("MODAPI_OAUTH_CLIENT_ID", OAuthClientID),
Resource: envOr("MODAPI_OAUTH_RESOURCE", defaultResource),
DashboardURL: strings.TrimSuffix(envOr("MODAPI_DASHBOARD_URL", defaultDashboardURL), "/"),
Issuer: envOr("MODAPI_OAUTH_ISSUER", defaultIssuer),
ClientID: envOr("MODAPI_OAUTH_CLIENT_ID", OAuthClientID),
Resource: envOr("MODAPI_OAUTH_RESOURCE", keyExchangeURL),
KeyExchangeURL: keyExchangeURL,
DashboardURL: strings.TrimSuffix(envOr("MODAPI_DASHBOARD_URL", defaultDashboardURL), "/"),
}
}

Expand Down
Loading