Files
probo/pkg/connector/oauth2_test.go
Émile Ré 9156d6a16a Add wsl linter and fix
Signed-off-by: Émile Ré <emile@probo.com>
2026-05-20 09:27:28 +04:00

853 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connector
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/httpclient"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/statelesstoken"
)
func TestBuildTokenRequest_PostForm(t *testing.T) {
t.Parallel()
t.Run("empty token endpoint auth", func(t *testing.T) {
t.Parallel()
connector := &OAuth2Connector{
ClientID: "my-client-id",
ClientSecret: "my-client-secret",
TokenURL: "https://provider.example.com/oauth/token",
TokenEndpointAuth: "",
}
req, err := connector.buildTokenRequest(
context.Background(),
"test-code",
"https://example.com/callback",
"",
)
require.NoError(t, err)
assert.Equal(t, http.MethodPost, req.Method)
assert.Equal(t, "https://provider.example.com/oauth/token", req.URL.String())
assert.Equal(t, "application/x-www-form-urlencoded; charset=utf-8", req.Header.Get("Content-Type"))
assert.Empty(t, req.Header.Get("Authorization"))
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
formValues, err := url.ParseQuery(string(body))
require.NoError(t, err)
assert.Equal(t, "my-client-id", formValues.Get("client_id"))
assert.Equal(t, "my-client-secret", formValues.Get("client_secret"))
assert.Equal(t, "test-code", formValues.Get("code"))
assert.Equal(t, "https://example.com/callback", formValues.Get("redirect_uri"))
assert.Equal(t, "authorization_code", formValues.Get("grant_type"))
})
t.Run("explicit post-form token endpoint auth", func(t *testing.T) {
t.Parallel()
connector := &OAuth2Connector{
ClientID: "my-client-id",
ClientSecret: "my-client-secret",
TokenURL: "https://provider.example.com/oauth/token",
TokenEndpointAuth: "post-form",
}
req, err := connector.buildTokenRequest(
context.Background(),
"test-code",
"https://example.com/callback",
"",
)
require.NoError(t, err)
assert.Equal(t, http.MethodPost, req.Method)
assert.Empty(t, req.Header.Get("Authorization"))
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
formValues, err := url.ParseQuery(string(body))
require.NoError(t, err)
assert.Equal(t, "my-client-id", formValues.Get("client_id"))
assert.Equal(t, "my-client-secret", formValues.Get("client_secret"))
assert.Equal(t, "test-code", formValues.Get("code"))
assert.Equal(t, "https://example.com/callback", formValues.Get("redirect_uri"))
assert.Equal(t, "authorization_code", formValues.Get("grant_type"))
})
}
func TestBuildTokenRequest_BasicForm(t *testing.T) {
t.Parallel()
connector := &OAuth2Connector{
ClientID: "my-client-id",
ClientSecret: "my-client-secret",
TokenURL: "https://provider.example.com/oauth/token",
TokenEndpointAuth: "basic-form",
}
req, err := connector.buildTokenRequest(
context.Background(),
"test-code",
"https://example.com/callback",
"",
)
require.NoError(t, err)
assert.Equal(t, http.MethodPost, req.Method)
assert.Equal(t, "https://provider.example.com/oauth/token", req.URL.String())
assert.Equal(t, "application/x-www-form-urlencoded; charset=utf-8", req.Header.Get("Content-Type"))
// Verify Basic auth header
authHeader := req.Header.Get("Authorization")
require.NotEmpty(t, authHeader)
expectedCredentials := base64.StdEncoding.EncodeToString([]byte("my-client-id:my-client-secret"))
assert.Equal(t, "Basic "+expectedCredentials, authHeader)
// Verify body does NOT contain client credentials
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
formValues, err := url.ParseQuery(string(body))
require.NoError(t, err)
assert.Empty(t, formValues.Get("client_id"))
assert.Empty(t, formValues.Get("client_secret"))
assert.Equal(t, "test-code", formValues.Get("code"))
assert.Equal(t, "https://example.com/callback", formValues.Get("redirect_uri"))
assert.Equal(t, "authorization_code", formValues.Get("grant_type"))
}
func TestBuildTokenRequest_BasicJSON(t *testing.T) {
t.Parallel()
connector := &OAuth2Connector{
ClientID: "my-client-id",
ClientSecret: "my-client-secret",
TokenURL: "https://provider.example.com/oauth/token",
TokenEndpointAuth: "basic-json",
}
req, err := connector.buildTokenRequest(
context.Background(),
"test-code",
"https://example.com/callback",
"",
)
require.NoError(t, err)
assert.Equal(t, http.MethodPost, req.Method)
assert.Equal(t, "https://provider.example.com/oauth/token", req.URL.String())
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
// Verify Basic auth header
authHeader := req.Header.Get("Authorization")
require.NotEmpty(t, authHeader)
expectedCredentials := base64.StdEncoding.EncodeToString([]byte("my-client-id:my-client-secret"))
assert.Equal(t, "Basic "+expectedCredentials, authHeader)
// Verify body is valid JSON
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
var jsonBody map[string]string
err = json.Unmarshal(body, &jsonBody)
require.NoError(t, err)
assert.Equal(t, "test-code", jsonBody["code"])
assert.Equal(t, "https://example.com/callback", jsonBody["redirect_uri"])
assert.Equal(t, "authorization_code", jsonBody["grant_type"])
// JSON body must NOT contain client credentials
_, hasClientID := jsonBody["client_id"]
_, hasClientSecret := jsonBody["client_secret"]
assert.False(t, hasClientID, "JSON body should not contain client_id")
assert.False(t, hasClientSecret, "JSON body should not contain client_secret")
}
func TestClientCredentialsClient(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
// Verify Basic auth header is present
authHeader := r.Header.Get("Authorization")
assert.NotEmpty(t, authHeader)
decoded, err := base64.StdEncoding.DecodeString(authHeader[len("Basic "):])
require.NoError(t, err)
assert.Equal(t, "cc-client-id:cc-client-secret", string(decoded))
// Verify form body
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
formValues, err := url.ParseQuery(string(body))
require.NoError(t, err)
assert.Equal(t, "client_credentials", formValues.Get("grant_type"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token": "test-token", "expires_in": 3600, "token_type": "Bearer"}`))
}))
defer server.Close()
beforeRequest := time.Now()
conn := &OAuth2Connection{
GrantType: OAuth2GrantTypeClientCredentials,
ClientID: "cc-client-id",
ClientSecret: "cc-client-secret",
TokenURL: server.URL,
}
// httptest binds to loopback, which the SSRF-protected default
// transport refuses; relax just for this test.
client, err := conn.clientCredentialsClient(context.Background(), httpclient.WithSSRFAllowLoopback())
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, "test-token", conn.AccessToken)
assert.Equal(t, "Bearer", conn.TokenType)
// ExpiresAt should be approximately now + 1 hour
expectedExpiry := beforeRequest.Add(1 * time.Hour)
assert.WithinDuration(t, expectedExpiry, conn.ExpiresAt, 5*time.Second)
}
func TestClientCredentialsClient_ReusesValidToken(t *testing.T) {
t.Parallel()
conn := &OAuth2Connection{
GrantType: OAuth2GrantTypeClientCredentials,
AccessToken: "existing-token",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
// No test server -- calling clientCredentialsClient should not make any HTTP request
// because the token is still valid.
client, err := conn.clientCredentialsClient(context.Background())
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, "existing-token", conn.AccessToken)
}
func TestInitiateWithState_Scopes(t *testing.T) {
t.Parallel()
t.Run("scopes are joined and set on auth URL", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{Scopes: []string{"read:user", "write:user"}},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
assert.Equal(t, "read:user write:user", parsed.Query().Get("scope"))
})
t.Run("empty scopes omits scope parameter", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
assert.False(t, parsed.Query().Has("scope"), "scope param should be absent when no scopes provided")
})
t.Run("include_granted_scopes set when provider supports and caller requests", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
SupportsIncrementalAuth: true,
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{
Scopes: []string{"read:user"},
IncludeGrantedScopes: true,
},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
assert.Equal(t, "true", parsed.Query().Get("include_granted_scopes"))
})
t.Run("include_granted_scopes absent when provider does not support it", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
SupportsIncrementalAuth: false,
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{
Scopes: []string{"read:user"},
IncludeGrantedScopes: true,
},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
assert.False(t, parsed.Query().Has("include_granted_scopes"))
})
t.Run("include_granted_scopes absent when caller does not request", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
SupportsIncrementalAuth: 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)
assert.False(t, parsed.Query().Has("include_granted_scopes"))
})
t.Run("prompt=consent skipped when incremental auth is active", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
SupportsIncrementalAuth: true,
ExtraAuthParams: map[string]string{
"access_type": "offline",
"prompt": "consent",
},
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{
Scopes: []string{"read:user"},
IncludeGrantedScopes: true,
},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
assert.Equal(t, "offline", parsed.Query().Get("access_type"))
assert.False(t, parsed.Query().Has("prompt"), "prompt=consent should be skipped when doing incremental auth on a provider that supports it")
assert.Equal(t, "true", parsed.Query().Get("include_granted_scopes"))
})
t.Run("prompt=consent preserved on first install", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
SupportsIncrementalAuth: true,
ExtraAuthParams: map[string]string{
"access_type": "offline",
"prompt": "consent",
},
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{
Scopes: []string{"read:user"},
IncludeGrantedScopes: false, // first install, no existing grant
},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
assert.Equal(t, "offline", parsed.Query().Get("access_type"))
assert.Equal(t, "consent", parsed.Query().Get("prompt"), "prompt=consent must still fire on first install so Google issues a refresh token")
assert.False(t, parsed.Query().Has("include_granted_scopes"))
})
t.Run("prompt=consent preserved when provider does not support incremental auth", func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "id",
ClientSecret: "secret",
RedirectURI: "https://example.com/cb",
AuthURL: "https://provider.example.com/authorize",
SupportsIncrementalAuth: false,
ExtraAuthParams: map[string]string{
"prompt": "consent",
},
}
orgID := gid.New(gid.NewTenantID(), 0)
u, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{
Scopes: []string{"read:user"},
IncludeGrantedScopes: true, // caller requested, but provider does not support
},
)
require.NoError(t, err)
parsed, err := url.Parse(u)
require.NoError(t, err)
assert.Equal(t, "consent", parsed.Query().Get("prompt"), "prompt=consent must not be skipped for providers that do not support incremental auth")
})
}
// TestCompleteWithState_ScopeFallback verifies that when the provider's
// token endpoint returns a successful token response that omits the
// `scope` field (which RFC 6749 §5.1 allows when the granted scope is
// identical to the requested scope), CompleteWithState falls back to
// the RequestedScopes carried in the OAuth2State so the persisted
// connection still carries the scope set. This is load-bearing for the
// scope-union logic on subsequent reconnects -- without it we would
// store empty scope and lose the diff.
func TestCompleteWithState_ScopeFallback(t *testing.T) {
t.Parallel()
// Fake provider token endpoint: returns a valid token response
// with NO `scope` field, matching RFC 6749 §5.1 "identical to
// requested" shape.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
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,
// httptest binds to loopback, which the SSRF-protected
// default client refuses; inject a permissive client.
HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()),
}
orgID := gid.New(gid.NewTenantID(), 0)
stateData := OAuth2State{
OrganizationID: orgID.String(),
Provider: "TEST",
RequestedScopes: []string{"read:user", "write:user"},
}
stateToken, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
require.NoError(t, err)
// Fabricate a callback request with a code + the signed state.
req := httptest.NewRequest(http.MethodGet, "https://example.com/cb?code=the-code&state="+stateToken, nil)
conn, returnedState, err := c.CompleteWithState(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, conn)
require.NotNil(t, returnedState)
oauth2Conn, ok := conn.(*OAuth2Connection)
require.True(t, ok, "expected *OAuth2Connection, got %T", conn)
assert.Equal(t, "live-token", oauth2Conn.AccessToken)
// The provider omitted scope, so CompleteWithState must fall back
// to the RequestedScopes carried in the state token, formatted as
// a space-separated RFC 6749 §3.3 scope string (sorted).
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")
})
}
// 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)
})
}
// TestGeneratePKCEVerifier exercises the verifier generator: each call
// must return a fresh value, encoded as RFC 4648 §5 base64url-without-
// padding (RFC 7636 §4.1 mandates 43–128 unreserved chars; 32 bytes
// yields 43 chars). Anything outside that contract weakens PKCE.
func TestGeneratePKCEVerifier(t *testing.T) {
t.Parallel()
v1, err := generatePKCEVerifier()
require.NoError(t, err)
v2, err := generatePKCEVerifier()
require.NoError(t, err)
assert.GreaterOrEqual(t, len(v1), 43, "verifier must be at least 43 base64url chars")
assert.LessOrEqual(t, len(v1), 128, "verifier must be at most 128 chars per RFC 7636")
assert.NotEqual(t, v1, v2, "verifier must be unpredictable across calls")
// Charset: base64url unreserved (RFC 4648 §5) — A-Z a-z 0-9 - _.
for _, c := range v1 {
switch {
case c >= 'A' && c <= 'Z':
case c >= 'a' && c <= 'z':
case c >= '0' && c <= '9':
case c == '-' || c == '_':
default:
t.Errorf("verifier contains non-base64url character %q", c)
}
}
}
// TestApplyProviderDefaults_PKCEDefaults asserts that the registered
// PAGERDUTY provider defaults flip RequiresPKCE on so the downstream
// Initiate/Complete flow generates a verifier and replays it.
func TestApplyProviderDefaults_PKCEDefaults(t *testing.T) {
t.Parallel()
for _, provider := range []string{"PAGERDUTY"} {
t.Run(provider, func(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{ClientID: "id", ClientSecret: "secret"}
ApplyProviderDefaults(provider, "https://example.com/cb", c)
assert.True(t, c.RequiresPKCE,
"provider %s must enable PKCE so Initiate generates a verifier", provider)
})
}
}
// TestCompleteWithState_PKCEMismatch confirms that a token endpoint
// rejecting a stale or mismatched code_verifier (the standard PKCE
// failure path) surfaces as an error from CompleteWithState rather
// than being silently swallowed.
func TestCompleteWithState_PKCEMismatch(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The provider is supposed to validate the verifier; emulate a
// reject so we can observe the failure path.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"invalid_grant"}`))
}))
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()),
}
orgID := gid.New(gid.NewTenantID(), 0)
authURL, err := c.InitiateWithState(
context.Background(),
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
InitiateOptions{Scopes: []string{"read"}},
)
require.NoError(t, err)
parsed, err := url.Parse(authURL)
require.NoError(t, err)
req := httptest.NewRequest(
http.MethodGet,
"https://example.com/cb?code=the-code&state="+parsed.Query().Get("state"),
nil,
)
_, _, err = c.CompleteWithState(context.Background(), req)
require.Error(t, err, "PKCE rejection from token endpoint must propagate")
}