Add PKCE, token-body extras, AuthURL templating to OAuth2 → Add settings structs for Pattern-2 connector providers

- Add PKCE, token-body extras, AuthURL templating to OAuth2
- Add 13 connector provider enum values
- Add scopes, display names, name resolvers for 13 providers
- Add settings structs for Pattern-2 connector providers

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-05-17 17:22:48 +02:00
parent 3ff66757ad
commit 0147acd9f0
10 changed files with 1334 additions and 5 deletions

View File

@@ -17,10 +17,13 @@ package connector
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"strings"
@@ -49,6 +52,22 @@ type (
ExtraAuthParams map[string]string // Optional: extra params for auth URL (e.g., access_type=offline for Google)
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
SupportsIncrementalAuth bool
// RequiresPKCE enables RFC 7636 PKCE (S256). When true,
// InitiateWithState generates a verifier, persists it in the
// OAuth2State, and adds code_challenge / code_challenge_method
// to the authorize URL; CompleteWithState replays the verifier
// on the token exchange.
RequiresPKCE bool
// TokenExtraParams are merged into the token-exchange request
// body (form-encoded for post-form / basic-form, JSON for
// basic-json). Used for provider-specific extras such as
// Lever's `audience` parameter.
TokenExtraParams map[string]string
// AuthURLParams are operator-supplied placeholders substituted
// into the static provider AuthURL by ApplyProviderDefaults
// (for example Vercel's "{integration_slug}"). Empty for the
// vast majority of providers.
AuthURLParams map[string]string
// HTTPClient is used for the OAuth2 token-exchange request
// issued from CompleteWithState. It must be set by callers;
@@ -63,6 +82,18 @@ type (
ContinueURL string `json:"continue,omitempty"`
ConnectorID string `json:"cid,omitempty"` // Set when reconnecting an existing connector
RequestedScopes []string `json:"scopes,omitempty"`
// CodeVerifier carries the PKCE verifier between Initiate and
// Complete. Set only when the provider requires PKCE
// (RequiresPKCE = true on the OAuth2Connector).
CodeVerifier string `json:"cv,omitempty"`
// ProviderMetadata surfaces provider-specific extras parsed
// from the token-exchange response (e.g. PagerDuty's
// `subdomain`). It is populated by CompleteWithState and is
// NEVER serialized into the state token (the field is for
// in-process plumbing only). Consumers that need to persist
// these values (typically the OAuth callback handler) read
// them off the returned *OAuth2State.
ProviderMetadata map[string]string `json:"-"`
}
OAuth2Connection struct {
@@ -133,6 +164,17 @@ func (c *OAuth2Connector) InitiateWithState(
stateData OAuth2State,
opts InitiateOptions,
) (string, error) {
// PKCE is generated before the state token so the verifier is
// embedded in the signed payload and replayed on the token
// exchange. Providers that do not require PKCE skip this entirely.
if c.RequiresPKCE {
verifier, err := generatePKCEVerifier()
if err != nil {
return "", fmt.Errorf("cannot generate PKCE verifier: %w", err)
}
stateData.CodeVerifier = verifier
}
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
if err != nil {
return "", fmt.Errorf("cannot create state token: %w", err)
@@ -147,6 +189,11 @@ func (c *OAuth2Connector) InitiateWithState(
authCodeQuery.Set("scope", strings.Join(opts.Scopes, " "))
}
if c.RequiresPKCE {
authCodeQuery.Set("code_challenge", pkceChallenge(stateData.CodeVerifier))
authCodeQuery.Set("code_challenge_method", "S256")
}
incrementalAuth := c.SupportsIncrementalAuth && opts.IncludeGrantedScopes
if incrementalAuth {
authCodeQuery.Set("include_granted_scopes", "true")
@@ -172,6 +219,24 @@ func (c *OAuth2Connector) InitiateWithState(
return u.String(), nil
}
// generatePKCEVerifier produces a 32-byte cryptographically random
// PKCE verifier encoded as base64url without padding (RFC 7636 §4.1).
func generatePKCEVerifier() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("cannot read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// pkceChallenge derives the S256 PKCE challenge from a verifier: it is
// the base64url-without-padding encoding of SHA-256(verifier) (RFC 7636
// §4.2).
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, string, error) {
conn, state, err := c.CompleteWithState(ctx, r)
if err != nil {
@@ -209,7 +274,7 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
return nil, nil, fmt.Errorf("cannot parse organization ID: %w", err)
}
tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI)
tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI, payload.Data.CodeVerifier)
if err != nil {
return nil, nil, err
}
@@ -266,17 +331,40 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
return conn, &payload.Data, err
}
// PagerDuty Scoped OAuth includes the customer's subdomain in the
// token response body. We parse it here so the OAuth callback
// handler can write it to PagerDutyConnectorSettings without
// having to issue a second decode against a now-closed body.
if payload.Data.Provider == PagerDutyProvider {
var pd struct {
Subdomain string `json:"subdomain"`
}
if err := json.Unmarshal(body, &pd); err == nil && pd.Subdomain != "" {
if payload.Data.ProviderMetadata == nil {
payload.Data.ProviderMetadata = map[string]string{}
}
payload.Data.ProviderMetadata["subdomain"] = pd.Subdomain
}
}
return &oauth2Conn, &payload.Data, nil
}
// PagerDutyProvider is the canonical string used by PagerDuty in the
// state token's `provider` field.
const PagerDutyProvider = "PAGERDUTY"
func basicAuthHeader(clientID, clientSecret string) string {
credentials := clientID + ":" + clientSecret
return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
}
// buildTokenRequest creates the HTTP request for the token exchange, branching
// on c.TokenEndpointAuth to support different provider requirements.
func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectURI string) (*http.Request, error) {
// on c.TokenEndpointAuth to support different provider requirements. When
// codeVerifier is non-empty (PKCE-enabled providers), it is replayed as
// `code_verifier` in the request body. TokenExtraParams are merged into the
// body in every branch.
func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectURI, codeVerifier string) (*http.Request, error) {
switch c.TokenEndpointAuth {
case "basic-json":
// JSON body with Basic auth header (Notion).
@@ -285,6 +373,10 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
"redirect_uri": redirectURI,
"grant_type": "authorization_code",
}
if codeVerifier != "" {
body["code_verifier"] = codeVerifier
}
maps.Copy(body, c.TokenExtraParams)
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cannot marshal token request body: %w", err)
@@ -312,6 +404,12 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
formData.Set("code", code)
formData.Set("redirect_uri", redirectURI)
formData.Set("grant_type", "authorization_code")
if codeVerifier != "" {
formData.Set("code_verifier", codeVerifier)
}
for k, v := range c.TokenExtraParams {
formData.Set(k, v)
}
req, err := http.NewRequestWithContext(
ctx,
@@ -337,6 +435,12 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
formData.Set("code", code)
formData.Set("redirect_uri", redirectURI)
formData.Set("grant_type", "authorization_code")
if codeVerifier != "" {
formData.Set("code_verifier", codeVerifier)
}
for k, v := range c.TokenExtraParams {
formData.Set(k, v)
}
req, err := http.NewRequestWithContext(
ctx,

View File

@@ -49,6 +49,7 @@ func TestBuildTokenRequest_PostForm(t *testing.T) {
context.Background(),
"test-code",
"https://example.com/callback",
"",
)
require.NoError(t, err)
@@ -84,6 +85,7 @@ func TestBuildTokenRequest_PostForm(t *testing.T) {
context.Background(),
"test-code",
"https://example.com/callback",
"",
)
require.NoError(t, err)
@@ -118,6 +120,7 @@ func TestBuildTokenRequest_BasicForm(t *testing.T) {
context.Background(),
"test-code",
"https://example.com/callback",
"",
)
require.NoError(t, err)
@@ -160,6 +163,7 @@ func TestBuildTokenRequest_BasicJSON(t *testing.T) {
context.Background(),
"test-code",
"https://example.com/callback",
"",
)
require.NoError(t, err)
@@ -553,3 +557,297 @@ func TestCompleteWithState_ScopeFallback(t *testing.T) {
assert.Equal(t, "read:user write:user", oauth2Conn.Scope)
assert.Equal(t, []string{"read:user", "write:user"}, returnedState.RequestedScopes)
}
// TestInitiateWithState_PKCE verifies that connectors with RequiresPKCE=true
// generate a PKCE verifier, embed the S256 challenge in the authorization
// URL (RFC 7636 §4.3), and persist the verifier in the signed state token
// so CompleteWithState can replay it on the token exchange.
func TestInitiateWithState_PKCE(t *testing.T) {
t.Parallel()
t.Run("authorize URL carries S256 code_challenge when PKCE is required", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
RequiresPKCE: true,
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{Scopes: []string{"read:user"}},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
challenge := parsed.Query().Get("code_challenge")
require.NotEmpty(t, challenge, "code_challenge must be present when RequiresPKCE=true")
assert.Equal(t, "S256", parsed.Query().Get("code_challenge_method"))
// The verifier is persisted in the signed state token. Decode
// the payload (without secret-checking — just inspect) and
// verify that re-deriving the challenge from the verifier
// reproduces the URL value.
stateToken := parsed.Query().Get("state")
require.NotEmpty(t, stateToken)
payload, err := DecodeOAuth2StatePayload(stateToken)
require.NoError(t, err)
require.NotEmpty(t, payload.Data.CodeVerifier, "verifier must be persisted in state token")
assert.Equal(t, challenge, pkceChallenge(payload.Data.CodeVerifier),
"code_challenge must equal base64url(sha256(verifier))")
})
t.Run("authorize URL omits PKCE params when PKCE is not required", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
RequiresPKCE: false,
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{Scopes: []string{"read:user"}},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
assert.False(t, parsed.Query().Has("code_challenge"))
assert.False(t, parsed.Query().Has("code_challenge_method"))
})
t.Run("token POST replays code_verifier from state on PKCE flow", func(t *testing.T) {
t.Parallel()
var capturedVerifier string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
form, err := url.ParseQuery(string(body))
assert.NoError(t, err)
capturedVerifier = form.Get("code_verifier")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"live-token","token_type":"Bearer","expires_in":3600}`))
}))
defer server.Close()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
TokenURL: server.URL,
RequiresPKCE: true,
HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()),
}
// Initiate to mint a state token that embeds a fresh PKCE verifier.
orgID := gid.New(gid.NewTenantID(), 0)
authURL, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{Scopes: []string{"read:user"}},
)
require.NoError(t, err)
parsed, err := url.Parse(authURL)
require.NoError(t, err)
stateToken := parsed.Query().Get("state")
require.NotEmpty(t, stateToken)
payload, err := DecodeOAuth2StatePayload(stateToken)
require.NoError(t, err)
expectedVerifier := payload.Data.CodeVerifier
require.NotEmpty(t, expectedVerifier)
// Drive Complete with that same state token + an arbitrary code.
req := httptest.NewRequest(
http.MethodGet,
"https://example.com/cb?code=the-code&state="+stateToken,
nil,
)
_, _, err = c.CompleteWithState(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, expectedVerifier, capturedVerifier,
"token POST body must carry the verifier persisted in the state token")
})
}
// TestBuildTokenRequest_TokenExtraParams verifies that TokenExtraParams are
// merged into the token-exchange body in all three auth branches. This
// powers Lever's required `audience=https://api.lever.co/v1/` parameter
// without any per-provider branching in the OAuth2 core.
func TestBuildTokenRequest_TokenExtraParams(t *testing.T) {
t.Parallel()
t.Run("post-form merges audience into form body", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "lever-client-id",
ClientSecret: "lever-client-secret",
TokenURL: "https://auth.lever.co/oauth/token",
TokenExtraParams: map[string]string{
"audience": "https://api.lever.co/v1/",
},
}
req, err := c.buildTokenRequest(
context.Background(),
"the-code",
"https://example.com/cb",
"",
)
require.NoError(t, err)
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
// Raw body check: the URL-encoded value must be present
// verbatim (catches any double-encoding regressions).
assert.Contains(t, string(body), "audience=https%3A%2F%2Fapi.lever.co%2Fv1%2F")
form, err := url.ParseQuery(string(body))
require.NoError(t, err)
assert.Equal(t, "https://api.lever.co/v1/", form.Get("audience"))
assert.Equal(t, "the-code", form.Get("code"))
assert.Equal(t, "authorization_code", form.Get("grant_type"))
})
t.Run("basic-form merges extra params into form body", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
TokenURL: "https://provider.example.com/oauth/token",
TokenEndpointAuth: "basic-form",
TokenExtraParams: map[string]string{
"audience": "https://api.lever.co/v1/",
},
}
req, err := c.buildTokenRequest(
context.Background(),
"the-code",
"https://example.com/cb",
"",
)
require.NoError(t, err)
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
form, err := url.ParseQuery(string(body))
require.NoError(t, err)
assert.Equal(t, "https://api.lever.co/v1/", form.Get("audience"))
})
t.Run("basic-json merges extra params into JSON body", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
TokenURL: "https://provider.example.com/oauth/token",
TokenEndpointAuth: "basic-json",
TokenExtraParams: map[string]string{
"audience": "https://api.lever.co/v1/",
},
}
req, err := c.buildTokenRequest(
context.Background(),
"the-code",
"https://example.com/cb",
"",
)
require.NoError(t, err)
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
var jsonBody map[string]string
require.NoError(t, json.Unmarshal(body, &jsonBody))
assert.Equal(t, "https://api.lever.co/v1/", jsonBody["audience"])
})
}
// TestApplyProviderDefaults_AuthURLTemplating verifies that operator-supplied
// AuthURLParams (for example Vercel's "{integration_slug}") are substituted
// into the static provider AuthURL when the connector is initialized.
// Providers without placeholders are unaffected.
func TestApplyProviderDefaults_AuthURLTemplating(t *testing.T) {
t.Parallel()
// Register a fake provider definition for the duration of this
// test so we do not have to wait for a real Vercel-style provider
// to land. Restore on teardown.
const fakeProvider = "TEST_TEMPLATED_AUTH_URL"
previous, hadPrevious := providerDefinitions[fakeProvider]
providerDefinitions[fakeProvider] = providerDefinition{
AuthURL: "https://example.com/integrations/{integration_slug}/new",
TokenURL: "https://example.com/oauth/token",
}
t.Cleanup(func() {
if hadPrevious {
providerDefinitions[fakeProvider] = previous
} else {
delete(providerDefinitions, fakeProvider)
}
})
t.Run("placeholder is substituted when AuthURLParams is supplied", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
AuthURLParams: map[string]string{
"integration_slug": "acme",
},
}
ApplyProviderDefaults(fakeProvider, "https://example.com/cb", c)
assert.Equal(t, "https://example.com/integrations/acme/new", c.AuthURL)
assert.Equal(t, "https://example.com/oauth/token", c.TokenURL)
})
t.Run("placeholder remains literal when AuthURLParams is empty", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
}
ApplyProviderDefaults(fakeProvider, "https://example.com/cb", c)
// No substitution requested; the placeholder is preserved
// verbatim so a misconfiguration is visible at the
// authorization step rather than silently masked.
assert.Equal(t, "https://example.com/integrations/{integration_slug}/new", c.AuthURL)
})
}

View File

@@ -14,7 +14,12 @@
package connector
import "go.gearno.de/kit/httpclient"
import (
"maps"
"strings"
"go.gearno.de/kit/httpclient"
)
// CallbackPath is the HTTP path for the OAuth2 callback endpoint.
const CallbackPath = "/api/console/v1/connectors/complete"
@@ -30,6 +35,14 @@ type providerDefinition struct {
ExtraAuthParams map[string]string
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
SupportsIncrementalAuth bool
// RequiresPKCE enables RFC 7636 PKCE (S256) on the authorization
// request and replays the verifier on the token exchange. Default
// false; existing providers are unaffected.
RequiresPKCE bool
// TokenExtraParams are merged into the token-exchange request body
// (form-encoded for "post-form"/"basic-form", JSON for "basic-json").
// Used by providers like Lever that require an `audience` parameter.
TokenExtraParams map[string]string
}
// providerDefinitions maps provider names to their static OAuth2 definitions.
@@ -91,6 +104,83 @@ var (
AuthURL: "https://linear.app/oauth/authorize",
TokenURL: "https://api.linear.app/oauth/token",
},
"GITLAB": {
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
},
// Bitbucket scopes are pinned on the OAuth consumer at registration
// time (`account` for workspace membership). They are not passed in
// the authorize URL and not configured here.
"BITBUCKET": {
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
},
"HEROKU": {
AuthURL: "https://id.heroku.com/oauth/authorize",
TokenURL: "https://id.heroku.com/oauth/token",
},
"PAGERDUTY": {
AuthURL: "https://identity.pagerduty.com/oauth/authorize",
TokenURL: "https://identity.pagerduty.com/oauth/token",
RequiresPKCE: true,
},
"ASANA": {
AuthURL: "https://app.asana.com/-/oauth_authorize",
TokenURL: "https://app.asana.com/-/oauth_token",
},
"SNYK": {
AuthURL: "https://app.snyk.io/oauth2/authorize",
TokenURL: "https://api.snyk.io/oauth2/token",
RequiresPKCE: true,
},
"NETLIFY": {
AuthURL: "https://app.netlify.com/authorize",
TokenURL: "https://api.netlify.com/oauth/token",
},
"RAMP": {
AuthURL: "https://app.ramp.com/v1/authorize",
TokenURL: "https://api.ramp.com/developer/v1/token",
TokenEndpointAuth: "basic-form",
},
"CLICKUP": {
AuthURL: "https://app.clickup.com/api",
TokenURL: "https://api.clickup.com/api/v2/oauth/token",
},
// Vercel uses a templated AuthURL: the operator supplies an
// `integration-slug` config field which is resolved into the
// "{integration_slug}" placeholder by ApplyProviderDefaults.
// Vercel does not use OAuth scopes — capabilities are pinned on
// the integration registration in the Vercel dashboard.
"VERCEL": {
AuthURL: "https://vercel.com/integrations/{integration_slug}/new",
TokenURL: "https://api.vercel.com/v2/oauth/access_token",
},
"MONDAY": {
AuthURL: "https://auth.monday.com/oauth2/authorize",
TokenURL: "https://auth.monday.com/oauth2/token",
},
// Lever runs on Auth0: the `audience` parameter is required in
// BOTH the authorize URL and the token-exchange POST body. The
// trailing slash on the audience value is mandatory.
"LEVER": {
AuthURL: "https://auth.lever.co/authorize",
TokenURL: "https://auth.lever.co/oauth/token",
ExtraAuthParams: map[string]string{
"audience": "https://api.lever.co/v1/",
"prompt": "consent",
},
TokenExtraParams: map[string]string{
"audience": "https://api.lever.co/v1/",
},
},
// Deel: the token endpoint path is "/oauth2/tokens" (plural) —
// Deel's docs are inconsistent on the singular vs plural form.
// The API base host (api.letsdeel.com) differs from the auth host
// (app.deel.com).
"DEEL": {
AuthURL: "https://app.deel.com/oauth2/authorize",
TokenURL: "https://app.deel.com/oauth2/tokens",
},
}
)
@@ -108,5 +198,22 @@ func ApplyProviderDefaults(provider string, redirectURI string, c *OAuth2Connect
c.ExtraAuthParams = def.ExtraAuthParams
c.TokenEndpointAuth = def.TokenEndpointAuth
c.SupportsIncrementalAuth = def.SupportsIncrementalAuth
c.RequiresPKCE = def.RequiresPKCE
// Deep copy TokenExtraParams so per-connector mutations cannot
// alias back into the shared providerDefinitions map.
if len(def.TokenExtraParams) > 0 {
tokenExtra := make(map[string]string, len(def.TokenExtraParams))
maps.Copy(tokenExtra, def.TokenExtraParams)
c.TokenExtraParams = tokenExtra
}
// Resolve operator-supplied placeholders in the static AuthURL
// (for example Vercel's "{integration_slug}"). Providers without
// placeholders are unaffected; the loop is a no-op when
// AuthURLParams is empty.
for k, v := range c.AuthURLParams {
c.AuthURL = strings.ReplaceAll(c.AuthURL, "{"+k+"}", v)
}
}
}

View File

@@ -137,6 +137,22 @@ var (
"RESEND": "https://api.resend.com/domains",
"ONE_PASSWORD": "https://events.1password.com/api/v1/auditevents",
"MICROSOFT_365": "https://graph.microsoft.com/v1.0/organization?$top=1",
"GITLAB": "https://gitlab.com/api/v4/user",
"BITBUCKET": "https://api.bitbucket.org/2.0/user",
"HEROKU": "https://api.heroku.com/account",
"PAGERDUTY": "https://api.pagerduty.com/users/me",
"ASANA": "https://app.asana.com/api/1.0/users/me",
"SNYK": "https://api.snyk.io/rest/self?version=2024-10-15",
"NETLIFY": "https://api.netlify.com/api/v1/user",
"RAMP": "https://api.ramp.com/developer/v1/business",
"CLICKUP": "https://api.clickup.com/api/v2/user",
"VERCEL": "https://api.vercel.com/v2/user",
// Monday's primary API is GraphQL POST, but the probe handler
// is GET-only. Use the OIDC userinfo endpoint as a GET probe
// that returns 200/401 with the same Bearer token.
"MONDAY": "https://auth.monday.com/oauth2/userinfo",
"LEVER": "https://api.lever.co/v1/users?limit=1",
"DEEL": "https://api.letsdeel.com/rest/v2/people?limit=1",
}
)