From 90db2a6f8f69f1cc29ab2da37af5906f69f50bed Mon Sep 17 00:00:00 2001 From: amirejaz Date: Wed, 27 May 2026 02:01:51 +0500 Subject: [PATCH 1/2] Validate CIMD scope, grant_types and response_types against AS policy C3 - Thread ScopesSupported into NewCIMDStorageDecorator so CIMD scope handling is consistent with DCR. Uses registration.ValidateScopes (same function as the DCR handler) to validate declared scopes against the AS allowlist and compute the effective scope list. When ScopesSupported is unset, the document's declared scopes are used directly; omitted scopes default to DefaultScopes. C4 - Reject CIMD documents that declare grant_types or response_types the embedded AS does not support for public clients (authorization_code + refresh_token; code). Consistent with DCR which returns invalid_client_metadata for the same cases. buildFositeClient now receives pre-computed scopes from fetch() rather than re-parsing doc.Scope, matching the DCR handler pattern where scope computation and validation happen before client construction. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- pkg/authserver/server_impl.go | 2 +- pkg/authserver/storage/cimd_decorator.go | 81 ++++++++-- pkg/authserver/storage/cimd_decorator_test.go | 149 ++++++++++++++++-- 3 files changed, 204 insertions(+), 28 deletions(-) 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())) +} From f9e761d326735b8102638e01bf391a8a1c4536d6 Mon Sep 17 00:00:00 2001 From: amirejaz Date: Tue, 26 May 2026 02:09:06 +0500 Subject: [PATCH 2/2] Add integration tests for CIMD support in embedded auth server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four integration tests that exercise the CIMD flow end-to-end against a real EmbeddedAuthServer instance: - Discovery advertises client_id_metadata_document_supported when CIMD is enabled, and omits the flag when disabled - Authorize accepts a CIMD URL as client_id and redirects to the upstream IDP without any prior DCR registration call - Authorize rejects a CIMD URL when CIMD is disabled - No DCR call is required — CIMD resolves the client on the fly Also adds a WithCIMD functional option to the integration test helper. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- test/integration/authserver/cimd_test.go | 310 ++++++++++++++++++ .../authserver/helpers/authserver.go | 12 + 2 files changed, 322 insertions(+) create mode 100644 test/integration/authserver/cimd_test.go 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, } }