diff --git a/internal/custom/custom_test.go b/internal/custom/custom_test.go index 70bcca8..f04cc37 100644 --- a/internal/custom/custom_test.go +++ b/internal/custom/custom_test.go @@ -331,7 +331,7 @@ 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) @@ -339,7 +339,7 @@ func TestExchangeForOrganizationKey(t *testing.T) { })) 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) } @@ -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) + } +} diff --git a/internal/custom/exchange.go b/internal/custom/exchange.go index 3f090cb..44adb97 100644 --- a/internal/custom/exchange.go +++ b/internal/custom/exchange.go @@ -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 } @@ -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)) @@ -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) } } diff --git a/internal/custom/login.go b/internal/custom/login.go index d6f64ba..d1456c7 100644 --- a/internal/custom/login.go +++ b/internal/custom/login.go @@ -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") diff --git a/internal/custom/settings.go b/internal/custom/settings.go index 1925251..d401cd4 100644 --- a/internal/custom/settings.go +++ b/internal/custom/settings.go @@ -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" @@ -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), "/"), } }