Add per-site authorize URL and per-domain token URL OAuth2 plumbing

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-05-29 14:48:30 +02:00
parent 5effc4bbf9
commit 082772465d
7 changed files with 296 additions and 10 deletions

View File

@@ -18,6 +18,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -50,6 +51,7 @@ func TestBuildTokenRequest_PostForm(t *testing.T) {
"test-code",
"https://example.com/callback",
"",
connector.TokenURL,
)
require.NoError(t, err)
@@ -86,6 +88,7 @@ func TestBuildTokenRequest_PostForm(t *testing.T) {
"test-code",
"https://example.com/callback",
"",
connector.TokenURL,
)
require.NoError(t, err)
@@ -121,6 +124,7 @@ func TestBuildTokenRequest_BasicForm(t *testing.T) {
"test-code",
"https://example.com/callback",
"",
connector.TokenURL,
)
require.NoError(t, err)
@@ -164,6 +168,7 @@ func TestBuildTokenRequest_BasicJSON(t *testing.T) {
"test-code",
"https://example.com/callback",
"",
connector.TokenURL,
)
require.NoError(t, err)
@@ -704,6 +709,187 @@ func TestInitiateWithState_PKCE(t *testing.T) {
})
}
func TestInitiateWithState_PerSiteAuthURL(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "cid",
ClientSecret: "secret",
RedirectURI: "https://probo.example/cb",
RequiresPKCE: true,
BuildAuthURLForSite: DatadogAuthorizeURL,
}
got, err := c.InitiateWithState(context.Background(),
OAuth2State{OrganizationID: "org", Provider: DatadogProvider},
InitiateOptions{Scopes: []string{"user_access_read"}, Site: "US3"},
)
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
assert.Equal(t, "us3.datadoghq.com", u.Host)
assert.Equal(t, "/oauth2/v1/authorize", u.Path)
assert.NotEmpty(t, u.Query().Get("code_challenge"))
}
func TestInitiateWithState_MissingSiteForMultiSite(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "cid",
ClientSecret: "secret",
BuildAuthURLForSite: DatadogAuthorizeURL,
}
_, err := c.InitiateWithState(context.Background(),
OAuth2State{OrganizationID: "org", Provider: DatadogProvider},
InitiateOptions{Site: ""},
)
require.Error(t, err)
}
func TestInitiateWithState_InvalidSiteRejected(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "cid",
ClientSecret: "secret",
BuildAuthURLForSite: DatadogAuthorizeURL,
}
_, err := c.InitiateWithState(context.Background(),
OAuth2State{OrganizationID: "org", Provider: DatadogProvider},
InitiateOptions{Site: "BOGUS"},
)
require.Error(t, err)
}
func TestCompleteWithState_PerDomainTokenURL(t *testing.T) {
t.Parallel()
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"at","refresh_token":"rt","expires_in":3600,"token_type":"Bearer"}`))
}))
defer srv.Close()
// Build a token-URL closure that targets the httptest server,
// mirroring DatadogTokenURL's shape (validate then build).
c := &OAuth2Connector{
ClientID: "cid",
ClientSecret: "secret",
RedirectURI: "https://probo.example/cb",
RequiresPKCE: true,
HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()),
BuildTokenURLForDomain: func(domain string) (string, error) {
if domain != "us3.datadoghq.com" {
return "", fmt.Errorf("unknown domain")
}
return srv.URL + "/oauth2/v1/token", nil
},
}
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL,
OAuth2State{OrganizationID: validOrgGID(t), Provider: DatadogProvider})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet,
"https://probo.example/cb?code=abc&state="+state+"&domain=us3.datadoghq.com", nil)
conn, _, err := c.CompleteWithState(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, "/oauth2/v1/token", gotPath)
oc, ok := conn.(*OAuth2Connection)
require.True(t, ok)
assert.Equal(t, srv.URL+"/oauth2/v1/token", oc.TokenURL)
}
func TestCompleteWithState_MissingDomainForMultiSite(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "cid",
ClientSecret: "secret",
RedirectURI: "https://probo.example/cb",
HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()),
BuildTokenURLForDomain: func(string) (string, error) { return "", fmt.Errorf("unused") },
}
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL,
OAuth2State{OrganizationID: validOrgGID(t), Provider: DatadogProvider})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet,
"https://probo.example/cb?code=abc&state="+state, nil)
_, _, err = c.CompleteWithState(context.Background(), req)
require.Error(t, err)
}
// TestCompleteWithState_InvalidDomainRejected exercises the SSRF guard: a
// tampered callback `domain` must fail the flow (the closure validates against
// the fixed allow-list) before credentials are POSTed anywhere.
func TestCompleteWithState_InvalidDomainRejected(t *testing.T) {
t.Parallel()
c := &OAuth2Connector{
ClientID: "cid",
ClientSecret: "secret",
RedirectURI: "https://probo.example/cb",
HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()),
BuildTokenURLForDomain: DatadogTokenURL,
}
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL,
OAuth2State{OrganizationID: validOrgGID(t), Provider: DatadogProvider})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet,
"https://probo.example/cb?code=abc&state="+state+"&domain=evil.example.com", nil)
_, _, err = c.CompleteWithState(context.Background(), req)
require.Error(t, err)
}
func TestRefreshableClient_PrefersConnectionTokenURL(t *testing.T) {
t.Parallel()
var gotHost string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHost = r.Host
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"new","token_type":"Bearer","expires_in":3600}`))
}))
defer srv.Close()
conn := &OAuth2Connection{
AccessToken: "old",
RefreshToken: "rt",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(-time.Hour),
TokenURL: srv.URL, // per-connection (Datadog-style)
}
// cfg.TokenURL is empty (multi-site providers carry no static token URL).
_, err := conn.RefreshableClient(context.Background(), OAuth2RefreshConfig{
ClientID: "cid", ClientSecret: "secret",
}, httpclient.WithSSRFAllowLoopback())
require.NoError(t, err)
u, _ := url.Parse(srv.URL)
assert.Equal(t, u.Host, gotHost)
assert.Equal(t, "new", conn.AccessToken)
}
func validOrgGID(t *testing.T) string {
t.Helper()
return gid.New(gid.NewTenantID(), 0).String()
}
// TestGeneratePKCENonce exercises the nonce generator: each call must
// return a fresh value, encoded as RFC 4648 §5 base64url-without-padding
// (32 bytes yields 43 chars). The nonce seeds derivePKCEVerifier, so a