diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index 39a22468ca..91932395c2 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -177,7 +177,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se // so that GetClient calls for HTTPS client_id values are intercepted at the // fosite level (not just the handler level). if cfg.CIMDEnabled { - stor, err = storage.NewCIMDStorageDecorator(stor, true, cfg.CIMDCacheMaxSize, cfg.CIMDCacheFallbackTTL) + stor, err = storage.NewCIMDStorageDecorator(stor, true, cfg.CIMDCacheMaxSize, cfg.CIMDCacheFallbackTTL, cfg.ScopesSupported) if err != nil { return nil, fmt.Errorf("failed to initialize CIMD storage decorator: %w", err) } diff --git a/pkg/authserver/storage/cimd_decorator.go b/pkg/authserver/storage/cimd_decorator.go index fbead1ef50..95c812304d 100644 --- a/pkg/authserver/storage/cimd_decorator.go +++ b/pkg/authserver/storage/cimd_decorator.go @@ -28,10 +28,11 @@ import ( // Only GetClient is overridden. DCR clients (opaque IDs) continue to work // exactly as before. type CIMDStorageDecorator struct { - Storage // embed full interface — all methods delegate - sf singleflight.Group // deduplicates concurrent fetches for the same URL - cache *lru.Cache[string, *cimdCacheEntry] - ttl time.Duration + Storage // embed full interface — all methods delegate + sf singleflight.Group // deduplicates concurrent fetches for the same URL + cache *lru.Cache[string, *cimdCacheEntry] + ttl time.Duration + scopesSupported []string // AS-configured scopes; nil means accept any } type cimdCacheEntry struct { @@ -43,11 +44,15 @@ type cimdCacheEntry struct { // it returns base unchanged (no allocation). cacheMaxSize must be >= 1; // fallbackTTL is the fixed TTL applied to every cache entry (Cache-Control // header parsing is not yet implemented; all entries use this value). +// scopesSupported is the AS-configured scope allowlist; documents that declare +// scopes outside this set are rejected at fetch time. Pass nil to skip scope +// validation (e.g. when ScopesSupported is unset and DefaultScopes applies). func NewCIMDStorageDecorator( base Storage, enabled bool, cacheMaxSize int, fallbackTTL time.Duration, + scopesSupported []string, ) (Storage, error) { if !enabled { return base, nil @@ -63,9 +68,10 @@ func NewCIMDStorageDecorator( } return &CIMDStorageDecorator{ - Storage: base, - cache: c, - ttl: fallbackTTL, + Storage: base, + cache: c, + ttl: fallbackTTL, + scopesSupported: scopesSupported, }, nil } @@ -129,7 +135,49 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli id, m, defaultCIMDTokenEndpointAuthMethod) } - client := buildFositeClient(doc) + // Reject documents that declare grant_types the embedded AS does not support. + // Consistent with DCR which restricts public clients to authorization_code + refresh_token. + allowedGrantTypes := map[string]bool{"authorization_code": true, "refresh_token": true} + for _, gt := range doc.GrantTypes { + if !allowedGrantTypes[gt] { + return nil, fmt.Errorf("%w: CIMD document at %s claims grant_type %q "+ + "but this server only supports %v for public clients", + fosite.ErrNotFound.WithHint("unsupported grant_type"), + id, gt, defaultCIMDGrantTypes) + } + } + + // Reject documents that declare response_types the embedded AS does not support. + allowedResponseTypes := map[string]bool{"code": true} + for _, rt := range doc.ResponseTypes { + if !allowedResponseTypes[rt] { + return nil, fmt.Errorf("%w: CIMD document at %s claims response_type %q "+ + "but this server only supports %v", + fosite.ErrNotFound.WithHint("unsupported response_type"), + id, rt, defaultCIMDResponseTypes) + } + } + + // Compute and validate the client scope list consistent with DCR. + // When ScopesSupported is configured: use registration.ValidateScopes which + // validates each declared scope against the allowlist and falls back to + // DefaultScopes (also validated) when the document omits the field — the + // same logic the DCR handler applies. + // When ScopesSupported is not configured: no AS-level validation; use the + // declared scopes directly, or nil to let buildFositeClient apply DefaultScopes. + var resolvedScopes []string + if len(d.scopesSupported) > 0 { + computed, dcrErr := registration.ValidateScopes(strings.Fields(doc.Scope), d.scopesSupported) + if dcrErr != nil { + return nil, fmt.Errorf("%w: CIMD document at %s: %s", + fosite.ErrNotFound.WithHint(string(dcrErr.Error)), id, dcrErr.ErrorDescription) + } + resolvedScopes = computed + } else if doc.Scope != "" { + resolvedScopes = strings.Fields(doc.Scope) + } + + client := buildFositeClient(doc, resolvedScopes) d.cache.Add(id, &cimdCacheEntry{ client: client, @@ -157,7 +205,9 @@ const defaultCIMDTokenEndpointAuthMethod = "none" // buildFositeClient converts a ClientMetadataDocument into a fosite.Client. // Redirect URIs containing http://localhost are wrapped in a LoopbackClient // so that RFC 8252 §7.3 dynamic port matching applies. -func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client { +// resolvedScopes is the already-validated scope list computed by fetch() via +// registration.ValidateScopes; when nil, DefaultScopes is used (unconstrained AS). +func buildFositeClient(doc *cimd.ClientMetadataDocument, resolvedScopes []string) fosite.Client { grantTypes := doc.GrantTypes if len(grantTypes) == 0 { grantTypes = defaultCIMDGrantTypes @@ -173,13 +223,12 @@ func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client { tokenEndpointAuthMethod = defaultCIMDTokenEndpointAuthMethod } - // When the document omits the scope field, apply the same defaults as DCR - // registration so CIMD clients can request openid/profile/email/offline_access - // without needing to enumerate them explicitly in the metadata document. - // Clone to avoid aliasing the package-level DefaultScopes slice. - scopes := slices.Clone(registration.DefaultScopes) - if doc.Scope != "" { - scopes = strings.Fields(doc.Scope) + // Scopes were computed and validated by fetch() via registration.ValidateScopes, + // consistent with the DCR handler. Fall back to DefaultScopes only when the + // decorator has no ScopesSupported restriction (unconstrained AS). + scopes := resolvedScopes + if len(scopes) == 0 { + scopes = slices.Clone(registration.DefaultScopes) } defaultClient := &fosite.DefaultClient{ diff --git a/pkg/authserver/storage/cimd_decorator_test.go b/pkg/authserver/storage/cimd_decorator_test.go index 99c3d66dc8..202cde45b9 100644 --- a/pkg/authserver/storage/cimd_decorator_test.go +++ b/pkg/authserver/storage/cimd_decorator_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "sync" "sync/atomic" "testing" @@ -62,7 +63,7 @@ func newTestBase(t *testing.T) *MemoryStorage { // newEnabledDecorator creates a CIMDStorageDecorator wrapping base. func newEnabledDecorator(t *testing.T, base *MemoryStorage, maxSize int, ttl time.Duration) *CIMDStorageDecorator { t.Helper() - got, err := NewCIMDStorageDecorator(base, true, maxSize, ttl) + got, err := NewCIMDStorageDecorator(base, true, maxSize, ttl, nil) require.NoError(t, err) return got.(*CIMDStorageDecorator) } @@ -77,7 +78,7 @@ func cimdURL(srv *httptest.Server, path string) string { func TestNewCIMDStorageDecorator_DisabledReturnsBase(t *testing.T) { t.Parallel() base := newTestBase(t) - got, err := NewCIMDStorageDecorator(base, false, 10, time.Minute) + got, err := NewCIMDStorageDecorator(base, false, 10, time.Minute, nil) require.NoError(t, err) assert.Same(t, base, got, "disabled decorator must return base unchanged") } @@ -85,21 +86,21 @@ func TestNewCIMDStorageDecorator_DisabledReturnsBase(t *testing.T) { func TestNewCIMDStorageDecorator_ZeroCacheSizeReturnsError(t *testing.T) { t.Parallel() base := newTestBase(t) - _, err := NewCIMDStorageDecorator(base, true, 0, time.Minute) + _, err := NewCIMDStorageDecorator(base, true, 0, time.Minute, nil) require.Error(t, err) } func TestNewCIMDStorageDecorator_NegativeCacheSizeReturnsError(t *testing.T) { t.Parallel() base := newTestBase(t) - _, err := NewCIMDStorageDecorator(base, true, -1, time.Minute) + _, err := NewCIMDStorageDecorator(base, true, -1, time.Minute, nil) require.Error(t, err) } func TestNewCIMDStorageDecorator_EnabledReturnsCIMDDecorator(t *testing.T) { t.Parallel() base := newTestBase(t) - got, err := NewCIMDStorageDecorator(base, true, 10, time.Minute) + got, err := NewCIMDStorageDecorator(base, true, 10, time.Minute, nil) require.NoError(t, err) require.NotNil(t, got) _, isCIMD := got.(*CIMDStorageDecorator) @@ -336,7 +337,7 @@ func TestBuildFositeClient_Defaults(t *testing.T) { RedirectURIs: []string{"https://example.com/callback"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) assert.Equal(t, "https://example.com/meta.json", got.GetID()) assert.True(t, got.IsPublic()) assert.ElementsMatch(t, []string{"authorization_code", "refresh_token"}, []string(got.GetGrantTypes())) @@ -355,7 +356,7 @@ func TestBuildFositeClient_ExplicitGrantTypes(t *testing.T) { GrantTypes: []string{"authorization_code"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) assert.ElementsMatch(t, []string{"authorization_code"}, []string(got.GetGrantTypes())) } @@ -368,7 +369,9 @@ func TestBuildFositeClient_ScopeParsing(t *testing.T) { Scope: "openid profile email", } - got := buildFositeClient(doc) + // Scope parsing is now done by fetch() before calling buildFositeClient. + resolvedScopes := strings.Fields(doc.Scope) + got := buildFositeClient(doc, resolvedScopes) assert.ElementsMatch(t, []string{"openid", "profile", "email"}, []string(got.GetScopes())) } @@ -380,7 +383,7 @@ func TestBuildFositeClient_LoopbackRedirectWrapsInLoopbackClient(t *testing.T) { RedirectURIs: []string{"http://localhost/callback"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) // LoopbackClient adds MatchRedirectURI — check the distinctive method is present. type loopbackMatcher interface { MatchRedirectURI(string) bool @@ -403,7 +406,7 @@ func TestBuildFositeClient_NonLoopbackRedirectReturnsOpenIDConnectClient(t *test RedirectURIs: []string{"https://example.com/callback"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) _, ok := got.(*fosite.DefaultOpenIDConnectClient) assert.True(t, ok, "non-loopback redirect URI must produce a DefaultOpenIDConnectClient") } @@ -416,7 +419,7 @@ func TestBuildFositeClient_TokenEndpointAuthMethodDefault(t *testing.T) { RedirectURIs: []string{"https://example.com/callback"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) if oidc, ok := got.(fosite.OpenIDConnectClient); ok { assert.Equal(t, "none", oidc.GetTokenEndpointAuthMethod()) } @@ -442,3 +445,127 @@ func TestFetch_RejectsUnsupportedTokenEndpointAuthMethod(t *testing.T) { _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") require.Error(t, err, "fetch must fail when token_endpoint_auth_method is not \"none\"") } + +// --- C4: grant_types / response_types validation --- + +func TestFetch_RejectsUnsupportedGrantType(t *testing.T) { + t.Parallel() + for _, unsupported := range []string{"client_credentials", "implicit", "urn:ietf:params:oauth:grant-type:device_code"} { + t.Run(unsupported, func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + GrantTypes: []string{unsupported}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err, "unsupported grant_type %q must be rejected", unsupported) + }) + } +} + +func TestFetch_AcceptsSupportedGrantTypes(t *testing.T) { + t.Parallel() + srv := serveCIMDDoc(t, "/meta.json", nil) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + // Default grant_types (omitted in document) must succeed + _, err := dec.fetchOrCached(context.Background(), cimdURL(srv, "/meta.json")) + require.NoError(t, err) +} + +func TestFetch_RejectsUnsupportedResponseType(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + ResponseTypes: []string{"token"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err, "unsupported response_type \"token\" must be rejected") +} + +// --- C3: scope validation against ScopesSupported --- + +func TestBuildFositeClient_ScopeDefaultsToScopesSupported(t *testing.T) { + t.Parallel() + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + // Scope deliberately omitted + } + scopesSupported := []string{"openid", "profile"} + got := buildFositeClient(doc, scopesSupported) + assert.ElementsMatch(t, scopesSupported, []string(got.GetScopes()), + "omitted scope should default to ScopesSupported, not DefaultScopes") +} + +func TestBuildFositeClient_ScopeDefaultsToDefaultScopesWhenNoScopesSupported(t *testing.T) { + t.Parallel() + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + } + got := buildFositeClient(doc, nil) + assert.ElementsMatch(t, registration.DefaultScopes, []string(got.GetScopes()), + "omitted scope with no ScopesSupported should default to registration.DefaultScopes") +} + +func TestFetch_RejectsScopeOutsideScopesSupported(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + Scope: "openid profile email", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + + // Decorator configured with scopesSupported=["openid"] only + got, err := NewCIMDStorageDecorator(newTestBase(t), true, 10, time.Minute, []string{"openid"}) + require.NoError(t, err) + dec := got.(*CIMDStorageDecorator) + + _, err = dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err, "scope outside ScopesSupported must be rejected") +} + +func TestFetch_AcceptsScopeWithinScopesSupported(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + Scope: "openid", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + + got, err := NewCIMDStorageDecorator(newTestBase(t), true, 10, time.Minute, []string{"openid", "profile"}) + require.NoError(t, err) + dec := got.(*CIMDStorageDecorator) + + client, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"openid"}, []string(client.GetScopes())) +} diff --git a/test/integration/authserver/cimd_test.go b/test/integration/authserver/cimd_test.go new file mode 100644 index 0000000000..493d595eb9 --- /dev/null +++ b/test/integration/authserver/cimd_test.go @@ -0,0 +1,310 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver" + servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/oauthproto/cimd" + "github.com/stacklok/toolhive/test/integration/authserver/helpers" +) + +// serveCIMDDoc starts an httptest.Server serving a valid CIMD document at +// /metadata.json. The document's client_id equals the full URL +// ("http://" + r.Host + "/metadata.json"), and redirect_uris contains +// "http://localhost:8080/callback". The server is registered for cleanup +// via t.Cleanup. Returns the server and the full CIMD URL string. +func serveCIMDDoc(t *testing.T) string { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata.json" { + http.NotFound(w, r) + return + } + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + + cimdURL := srv.URL + "/metadata.json" + return cimdURL +} + +// TestEmbeddedAuthServer_CIMD_DiscoveryAdvertisesSupport verifies that both +// discovery endpoints advertise client_id_metadata_document_supported: true +// when CIMD is enabled, and omit / set it to false when CIMD is disabled. +// +//nolint:paralleltest,tparallel // Subtests share expensive test fixtures +func TestEmbeddedAuthServer_CIMD_DiscoveryAdvertisesSupport(t *testing.T) { + t.Parallel() + + ctx := context.Background() + upstream := helpers.NewMockUpstreamIDP(t) + + t.Run("CIMD enabled advertises support in both discovery endpoints", func(t *testing.T) { + cfg := helpers.NewTestAuthServerConfig(t, upstream.URL(), + helpers.WithCIMD(&authserver.CIMDRunConfig{ + Enabled: true, + CacheMaxSize: 16, + }), + ) + + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + server := httptest.NewServer(authServer.Handler()) + t.Cleanup(server.Close) + + client := helpers.NewOAuthClient(server.URL) + + oauthMeta, statusCode, err := client.GetOAuthDiscovery() + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, true, oauthMeta["client_id_metadata_document_supported"], + "OAuth discovery must advertise client_id_metadata_document_supported: true when CIMD is enabled") + + oidcMeta, statusCode, err := client.GetOIDCDiscovery() + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, true, oidcMeta["client_id_metadata_document_supported"], + "OIDC discovery must advertise client_id_metadata_document_supported: true when CIMD is enabled") + }) + + t.Run("CIMD disabled does not advertise support", func(t *testing.T) { + // No WithCIMD option — CIMD is disabled by default. + cfg := helpers.NewTestAuthServerConfig(t, upstream.URL()) + + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + server := httptest.NewServer(authServer.Handler()) + t.Cleanup(server.Close) + + client := helpers.NewOAuthClient(server.URL) + + oauthMeta, statusCode, err := client.GetOAuthDiscovery() + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + // Field absent or false — both mean "not supported". + cimdFlag := oauthMeta["client_id_metadata_document_supported"] + assert.True(t, cimdFlag == nil || cimdFlag == false, + "OAuth discovery must not advertise CIMD support when disabled (got %v)", cimdFlag) + + oidcMeta, statusCode, err := client.GetOIDCDiscovery() + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + cimdFlag = oidcMeta["client_id_metadata_document_supported"] + assert.True(t, cimdFlag == nil || cimdFlag == false, + "OIDC discovery must not advertise CIMD support when disabled (got %v)", cimdFlag) + }) +} + +// TestEmbeddedAuthServer_CIMD_AuthorizeAcceptsCIMDClientID verifies that when +// CIMD is enabled, the authorization endpoint accepts a CIMD URL as client_id +// and redirects to the upstream IDP without requiring prior DCR registration. +func TestEmbeddedAuthServer_CIMD_AuthorizeAcceptsCIMDClientID(t *testing.T) { + t.Parallel() + + ctx := context.Background() + upstream := helpers.NewMockUpstreamIDP(t) + cimdURL := serveCIMDDoc(t) + + cfg := helpers.NewTestAuthServerConfig(t, upstream.URL(), + helpers.WithCIMD(&authserver.CIMDRunConfig{ + Enabled: true, + CacheMaxSize: 16, + }), + ) + + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + server := httptest.NewServer(authServer.Handler()) + t.Cleanup(server.Close) + + client := helpers.NewOAuthClient(server.URL) + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + params := url.Values{ + "response_type": {"code"}, + "client_id": {cimdURL}, + "redirect_uri": {"http://localhost:8080/callback"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {"test-state-cimd"}, + "resource": {cfg.AllowedAudiences[0]}, + } + + resp, err := client.StartAuthorization(params) + require.NoError(t, err) + t.Cleanup(func() { + _ = resp.Body.Close() + }) + + // CIMD resolution must succeed and redirect to the upstream IDP — not an + // invalid_client error. + assert.Equal(t, http.StatusFound, resp.StatusCode, + "CIMD-resolved client must produce a 302 redirect to the upstream IDP") + + location := resp.Header.Get("Location") + assert.NotEmpty(t, location, "Location header must be set on redirect") + + redirectURL, err := url.Parse(location) + require.NoError(t, err) + assert.Contains(t, redirectURL.String(), upstream.URL(), + "redirect Location must point to the upstream IDP authorization endpoint") +} + +// TestEmbeddedAuthServer_CIMD_DisabledRejectsCIMDClientID verifies that when +// CIMD is disabled, a CIMD URL presented as client_id is rejected — it is not +// resolved via the metadata document protocol and the request does not +// produce a 302 redirect to the upstream IDP. +func TestEmbeddedAuthServer_CIMD_DisabledRejectsCIMDClientID(t *testing.T) { + t.Parallel() + + ctx := context.Background() + upstream := helpers.NewMockUpstreamIDP(t) + cimdURL := serveCIMDDoc(t) + + // No WithCIMD option — CIMD is disabled. + cfg := helpers.NewTestAuthServerConfig(t, upstream.URL()) + + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + server := httptest.NewServer(authServer.Handler()) + t.Cleanup(server.Close) + + client := helpers.NewOAuthClient(server.URL) + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + params := url.Values{ + "response_type": {"code"}, + "client_id": {cimdURL}, + "redirect_uri": {"http://localhost:8080/callback"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {"test-state-cimd-disabled"}, + "resource": {cfg.AllowedAudiences[0]}, + } + + resp, err := client.StartAuthorization(params) + require.NoError(t, err) + t.Cleanup(func() { + _ = resp.Body.Close() + }) + + // With CIMD disabled the CIMD URL is treated as an unknown opaque client_id + // and the authorize request must fail — either a non-302 error response or + // a redirect to the client's redirect_uri carrying an error parameter, but + // NOT a redirect to the upstream IDP. + location := resp.Header.Get("Location") + + isUpstreamRedirect := func() bool { + if location == "" { + return false + } + redirectURL, parseErr := url.Parse(location) + if parseErr != nil { + return false + } + return redirectURL.Host == mustParseURL(t, upstream.URL()).Host + } + + assert.False(t, isUpstreamRedirect(), + "CIMD disabled: authorize must NOT redirect to the upstream IDP (Location: %q)", location) + + // The response must signal an error — either directly (4xx) or as an + // error redirect to the registered redirect_uri. + if resp.StatusCode == http.StatusFound { + // Redirect-with-error case: the redirect must carry an error parameter + // and must NOT point to the upstream IDP (already asserted above). + redirectURL, err := url.Parse(location) + require.NoError(t, err) + assert.NotEmpty(t, redirectURL.Query().Get("error"), + "error redirect must carry an error query parameter") + } else { + assert.GreaterOrEqual(t, resp.StatusCode, http.StatusBadRequest, + "CIMD disabled: authorize must return an error status (4xx) when client_id is unrecognised") + } +} + +// TestEmbeddedAuthServer_CIMD_NoDCRRequired verifies that when CIMD is enabled +// a client can complete the authorization step without any prior call to the +// DCR registration endpoint. This is the core CIMD value proposition: the +// client_id URL itself carries the registration metadata. +func TestEmbeddedAuthServer_CIMD_NoDCRRequired(t *testing.T) { + t.Parallel() + + ctx := context.Background() + upstream := helpers.NewMockUpstreamIDP(t) + cimdURL := serveCIMDDoc(t) + + cfg := helpers.NewTestAuthServerConfig(t, upstream.URL(), + helpers.WithCIMD(&authserver.CIMDRunConfig{ + Enabled: true, + CacheMaxSize: 16, + }), + ) + + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + server := httptest.NewServer(authServer.Handler()) + t.Cleanup(server.Close) + + // Deliberately do NOT call client.RegisterClient() before StartAuthorization. + // This test asserts that the absence of a prior DCR call is not an obstacle. + client := helpers.NewOAuthClient(server.URL) + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + params := url.Values{ + "response_type": {"code"}, + "client_id": {cimdURL}, + "redirect_uri": {"http://localhost:8080/callback"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {"test-state-no-dcr"}, + "resource": {cfg.AllowedAudiences[0]}, + } + + resp, err := client.StartAuthorization(params) + require.NoError(t, err) + t.Cleanup(func() { + _ = resp.Body.Close() + }) + + // Without any DCR call the authorize request must still succeed because + // the CIMD decorator resolves the client on the fly from the metadata URL. + assert.Equal(t, http.StatusFound, resp.StatusCode, + "authorize must succeed (302 to upstream) without any prior DCR call when CIMD is enabled") + + location := resp.Header.Get("Location") + assert.NotEmpty(t, location) + + redirectURL, err := url.Parse(location) + require.NoError(t, err) + assert.Contains(t, redirectURL.String(), upstream.URL(), + "Location must point to the upstream IDP, proving CIMD resolved the client without DCR") +} + +// mustParseURL parses rawURL and fails the test on error. +func mustParseURL(t *testing.T, rawURL string) *url.URL { + t.Helper() + u, err := url.Parse(rawURL) + require.NoError(t, err, "failed to parse URL %q", rawURL) + return u +} diff --git a/test/integration/authserver/helpers/authserver.go b/test/integration/authserver/helpers/authserver.go index f4939f445f..533d2962b0 100644 --- a/test/integration/authserver/helpers/authserver.go +++ b/test/integration/authserver/helpers/authserver.go @@ -29,6 +29,7 @@ type authServerConfig struct { tokenLifespans *authserver.TokenLifespanRunConfig scopesSupported []string baselineClientScopes []string + cimd *authserver.CIMDRunConfig } // WithSigningKey sets the signing key configuration. @@ -53,6 +54,16 @@ func WithBaselineClientScopes(scopes []string) AuthServerOption { } } +// WithCIMD enables Client ID Metadata Document (CIMD) support on the test +// auth server. When cfg is non-nil and cfg.Enabled is true, the auth server +// accepts HTTPS (and http://localhost) URLs as client_id values and resolves +// them on the fly without requiring prior DCR registration. +func WithCIMD(cfg *authserver.CIMDRunConfig) AuthServerOption { + return func(c *authServerConfig) { + c.cimd = cfg + } +} + // GetFreePort returns an available TCP port on localhost. func GetFreePort(tb testing.TB) int { tb.Helper() @@ -113,6 +124,7 @@ func NewTestAuthServerConfig(tb testing.TB, upstreamURL string, opts ...AuthServ ScopesSupported: cfg.scopesSupported, BaselineClientScopes: cfg.baselineClientScopes, AllowedAudiences: cfg.allowedAudiences, + CIMD: cfg.cimd, } }