Skip to content

Commit 91b1244

Browse files
amirejazclaude
andcommitted
Add integration tests for CIMD support in embedded auth server
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) <noreply@anthropic.com>
1 parent 5d11a41 commit 91b1244

2 files changed

Lines changed: 322 additions & 0 deletions

File tree

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package authserver_test
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"net/http"
10+
"net/http/httptest"
11+
"net/url"
12+
"testing"
13+
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/stacklok/toolhive/pkg/authserver"
18+
servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto"
19+
"github.com/stacklok/toolhive/pkg/oauthproto/cimd"
20+
"github.com/stacklok/toolhive/test/integration/authserver/helpers"
21+
)
22+
23+
// serveCIMDDoc starts an httptest.Server serving a valid CIMD document at
24+
// /metadata.json. The document's client_id equals the full URL
25+
// ("http://" + r.Host + "/metadata.json"), and redirect_uris contains
26+
// "http://localhost:8080/callback". The server is registered for cleanup
27+
// via t.Cleanup. Returns the server and the full CIMD URL string.
28+
func serveCIMDDoc(t *testing.T) string {
29+
t.Helper()
30+
31+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32+
if r.URL.Path != "/metadata.json" {
33+
http.NotFound(w, r)
34+
return
35+
}
36+
clientID := "http://" + r.Host + r.URL.Path
37+
doc := cimd.ClientMetadataDocument{
38+
ClientID: clientID,
39+
RedirectURIs: []string{"http://localhost:8080/callback"},
40+
}
41+
w.Header().Set("Content-Type", "application/json")
42+
_ = json.NewEncoder(w).Encode(doc)
43+
}))
44+
t.Cleanup(srv.Close)
45+
46+
cimdURL := srv.URL + "/metadata.json"
47+
return cimdURL
48+
}
49+
50+
// TestEmbeddedAuthServer_CIMD_DiscoveryAdvertisesSupport verifies that both
51+
// discovery endpoints advertise client_id_metadata_document_supported: true
52+
// when CIMD is enabled, and omit / set it to false when CIMD is disabled.
53+
//
54+
//nolint:paralleltest,tparallel // Subtests share expensive test fixtures
55+
func TestEmbeddedAuthServer_CIMD_DiscoveryAdvertisesSupport(t *testing.T) {
56+
t.Parallel()
57+
58+
ctx := context.Background()
59+
upstream := helpers.NewMockUpstreamIDP(t)
60+
61+
t.Run("CIMD enabled advertises support in both discovery endpoints", func(t *testing.T) {
62+
cfg := helpers.NewTestAuthServerConfig(t, upstream.URL(),
63+
helpers.WithCIMD(&authserver.CIMDRunConfig{
64+
Enabled: true,
65+
CacheMaxSize: 16,
66+
}),
67+
)
68+
69+
authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg)
70+
server := httptest.NewServer(authServer.Handler())
71+
t.Cleanup(server.Close)
72+
73+
client := helpers.NewOAuthClient(server.URL)
74+
75+
oauthMeta, statusCode, err := client.GetOAuthDiscovery()
76+
require.NoError(t, err)
77+
assert.Equal(t, http.StatusOK, statusCode)
78+
assert.Equal(t, true, oauthMeta["client_id_metadata_document_supported"],
79+
"OAuth discovery must advertise client_id_metadata_document_supported: true when CIMD is enabled")
80+
81+
oidcMeta, statusCode, err := client.GetOIDCDiscovery()
82+
require.NoError(t, err)
83+
assert.Equal(t, http.StatusOK, statusCode)
84+
assert.Equal(t, true, oidcMeta["client_id_metadata_document_supported"],
85+
"OIDC discovery must advertise client_id_metadata_document_supported: true when CIMD is enabled")
86+
})
87+
88+
t.Run("CIMD disabled does not advertise support", func(t *testing.T) {
89+
// No WithCIMD option — CIMD is disabled by default.
90+
cfg := helpers.NewTestAuthServerConfig(t, upstream.URL())
91+
92+
authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg)
93+
server := httptest.NewServer(authServer.Handler())
94+
t.Cleanup(server.Close)
95+
96+
client := helpers.NewOAuthClient(server.URL)
97+
98+
oauthMeta, statusCode, err := client.GetOAuthDiscovery()
99+
require.NoError(t, err)
100+
assert.Equal(t, http.StatusOK, statusCode)
101+
// Field absent or false — both mean "not supported".
102+
cimdFlag := oauthMeta["client_id_metadata_document_supported"]
103+
assert.True(t, cimdFlag == nil || cimdFlag == false,
104+
"OAuth discovery must not advertise CIMD support when disabled (got %v)", cimdFlag)
105+
106+
oidcMeta, statusCode, err := client.GetOIDCDiscovery()
107+
require.NoError(t, err)
108+
assert.Equal(t, http.StatusOK, statusCode)
109+
cimdFlag = oidcMeta["client_id_metadata_document_supported"]
110+
assert.True(t, cimdFlag == nil || cimdFlag == false,
111+
"OIDC discovery must not advertise CIMD support when disabled (got %v)", cimdFlag)
112+
})
113+
}
114+
115+
// TestEmbeddedAuthServer_CIMD_AuthorizeAcceptsCIMDClientID verifies that when
116+
// CIMD is enabled, the authorization endpoint accepts a CIMD URL as client_id
117+
// and redirects to the upstream IDP without requiring prior DCR registration.
118+
func TestEmbeddedAuthServer_CIMD_AuthorizeAcceptsCIMDClientID(t *testing.T) {
119+
t.Parallel()
120+
121+
ctx := context.Background()
122+
upstream := helpers.NewMockUpstreamIDP(t)
123+
cimdURL := serveCIMDDoc(t)
124+
125+
cfg := helpers.NewTestAuthServerConfig(t, upstream.URL(),
126+
helpers.WithCIMD(&authserver.CIMDRunConfig{
127+
Enabled: true,
128+
CacheMaxSize: 16,
129+
}),
130+
)
131+
132+
authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg)
133+
server := httptest.NewServer(authServer.Handler())
134+
t.Cleanup(server.Close)
135+
136+
client := helpers.NewOAuthClient(server.URL)
137+
138+
verifier := servercrypto.GeneratePKCEVerifier()
139+
challenge := servercrypto.ComputePKCEChallenge(verifier)
140+
141+
params := url.Values{
142+
"response_type": {"code"},
143+
"client_id": {cimdURL},
144+
"redirect_uri": {"http://localhost:8080/callback"},
145+
"code_challenge": {challenge},
146+
"code_challenge_method": {"S256"},
147+
"state": {"test-state-cimd"},
148+
"resource": {cfg.AllowedAudiences[0]},
149+
}
150+
151+
resp, err := client.StartAuthorization(params)
152+
require.NoError(t, err)
153+
t.Cleanup(func() {
154+
_ = resp.Body.Close()
155+
})
156+
157+
// CIMD resolution must succeed and redirect to the upstream IDP — not an
158+
// invalid_client error.
159+
assert.Equal(t, http.StatusFound, resp.StatusCode,
160+
"CIMD-resolved client must produce a 302 redirect to the upstream IDP")
161+
162+
location := resp.Header.Get("Location")
163+
assert.NotEmpty(t, location, "Location header must be set on redirect")
164+
165+
redirectURL, err := url.Parse(location)
166+
require.NoError(t, err)
167+
assert.Contains(t, redirectURL.String(), upstream.URL(),
168+
"redirect Location must point to the upstream IDP authorization endpoint")
169+
}
170+
171+
// TestEmbeddedAuthServer_CIMD_DisabledRejectsCIMDClientID verifies that when
172+
// CIMD is disabled, a CIMD URL presented as client_id is rejected — it is not
173+
// resolved via the metadata document protocol and the request does not
174+
// produce a 302 redirect to the upstream IDP.
175+
func TestEmbeddedAuthServer_CIMD_DisabledRejectsCIMDClientID(t *testing.T) {
176+
t.Parallel()
177+
178+
ctx := context.Background()
179+
upstream := helpers.NewMockUpstreamIDP(t)
180+
cimdURL := serveCIMDDoc(t)
181+
182+
// No WithCIMD option — CIMD is disabled.
183+
cfg := helpers.NewTestAuthServerConfig(t, upstream.URL())
184+
185+
authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg)
186+
server := httptest.NewServer(authServer.Handler())
187+
t.Cleanup(server.Close)
188+
189+
client := helpers.NewOAuthClient(server.URL)
190+
191+
verifier := servercrypto.GeneratePKCEVerifier()
192+
challenge := servercrypto.ComputePKCEChallenge(verifier)
193+
194+
params := url.Values{
195+
"response_type": {"code"},
196+
"client_id": {cimdURL},
197+
"redirect_uri": {"http://localhost:8080/callback"},
198+
"code_challenge": {challenge},
199+
"code_challenge_method": {"S256"},
200+
"state": {"test-state-cimd-disabled"},
201+
"resource": {cfg.AllowedAudiences[0]},
202+
}
203+
204+
resp, err := client.StartAuthorization(params)
205+
require.NoError(t, err)
206+
t.Cleanup(func() {
207+
_ = resp.Body.Close()
208+
})
209+
210+
// With CIMD disabled the CIMD URL is treated as an unknown opaque client_id
211+
// and the authorize request must fail — either a non-302 error response or
212+
// a redirect to the client's redirect_uri carrying an error parameter, but
213+
// NOT a redirect to the upstream IDP.
214+
location := resp.Header.Get("Location")
215+
216+
isUpstreamRedirect := func() bool {
217+
if location == "" {
218+
return false
219+
}
220+
redirectURL, parseErr := url.Parse(location)
221+
if parseErr != nil {
222+
return false
223+
}
224+
return redirectURL.Host == mustParseURL(t, upstream.URL()).Host
225+
}
226+
227+
assert.False(t, isUpstreamRedirect(),
228+
"CIMD disabled: authorize must NOT redirect to the upstream IDP (Location: %q)", location)
229+
230+
// The response must signal an error — either directly (4xx) or as an
231+
// error redirect to the registered redirect_uri.
232+
if resp.StatusCode == http.StatusFound {
233+
// Redirect-with-error case: the redirect must carry an error parameter
234+
// and must NOT point to the upstream IDP (already asserted above).
235+
redirectURL, err := url.Parse(location)
236+
require.NoError(t, err)
237+
assert.NotEmpty(t, redirectURL.Query().Get("error"),
238+
"error redirect must carry an error query parameter")
239+
} else {
240+
assert.GreaterOrEqual(t, resp.StatusCode, http.StatusBadRequest,
241+
"CIMD disabled: authorize must return an error status (4xx) when client_id is unrecognised")
242+
}
243+
}
244+
245+
// TestEmbeddedAuthServer_CIMD_NoDCRRequired verifies that when CIMD is enabled
246+
// a client can complete the authorization step without any prior call to the
247+
// DCR registration endpoint. This is the core CIMD value proposition: the
248+
// client_id URL itself carries the registration metadata.
249+
func TestEmbeddedAuthServer_CIMD_NoDCRRequired(t *testing.T) {
250+
t.Parallel()
251+
252+
ctx := context.Background()
253+
upstream := helpers.NewMockUpstreamIDP(t)
254+
cimdURL := serveCIMDDoc(t)
255+
256+
cfg := helpers.NewTestAuthServerConfig(t, upstream.URL(),
257+
helpers.WithCIMD(&authserver.CIMDRunConfig{
258+
Enabled: true,
259+
CacheMaxSize: 16,
260+
}),
261+
)
262+
263+
authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg)
264+
server := httptest.NewServer(authServer.Handler())
265+
t.Cleanup(server.Close)
266+
267+
// Deliberately do NOT call client.RegisterClient() before StartAuthorization.
268+
// This test asserts that the absence of a prior DCR call is not an obstacle.
269+
client := helpers.NewOAuthClient(server.URL)
270+
271+
verifier := servercrypto.GeneratePKCEVerifier()
272+
challenge := servercrypto.ComputePKCEChallenge(verifier)
273+
274+
params := url.Values{
275+
"response_type": {"code"},
276+
"client_id": {cimdURL},
277+
"redirect_uri": {"http://localhost:8080/callback"},
278+
"code_challenge": {challenge},
279+
"code_challenge_method": {"S256"},
280+
"state": {"test-state-no-dcr"},
281+
"resource": {cfg.AllowedAudiences[0]},
282+
}
283+
284+
resp, err := client.StartAuthorization(params)
285+
require.NoError(t, err)
286+
t.Cleanup(func() {
287+
_ = resp.Body.Close()
288+
})
289+
290+
// Without any DCR call the authorize request must still succeed because
291+
// the CIMD decorator resolves the client on the fly from the metadata URL.
292+
assert.Equal(t, http.StatusFound, resp.StatusCode,
293+
"authorize must succeed (302 to upstream) without any prior DCR call when CIMD is enabled")
294+
295+
location := resp.Header.Get("Location")
296+
assert.NotEmpty(t, location)
297+
298+
redirectURL, err := url.Parse(location)
299+
require.NoError(t, err)
300+
assert.Contains(t, redirectURL.String(), upstream.URL(),
301+
"Location must point to the upstream IDP, proving CIMD resolved the client without DCR")
302+
}
303+
304+
// mustParseURL parses rawURL and fails the test on error.
305+
func mustParseURL(t *testing.T, rawURL string) *url.URL {
306+
t.Helper()
307+
u, err := url.Parse(rawURL)
308+
require.NoError(t, err, "failed to parse URL %q", rawURL)
309+
return u
310+
}

test/integration/authserver/helpers/authserver.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type authServerConfig struct {
2929
tokenLifespans *authserver.TokenLifespanRunConfig
3030
scopesSupported []string
3131
baselineClientScopes []string
32+
cimd *authserver.CIMDRunConfig
3233
}
3334

3435
// WithSigningKey sets the signing key configuration.
@@ -53,6 +54,16 @@ func WithBaselineClientScopes(scopes []string) AuthServerOption {
5354
}
5455
}
5556

57+
// WithCIMD enables Client ID Metadata Document (CIMD) support on the test
58+
// auth server. When cfg is non-nil and cfg.Enabled is true, the auth server
59+
// accepts HTTPS (and http://localhost) URLs as client_id values and resolves
60+
// them on the fly without requiring prior DCR registration.
61+
func WithCIMD(cfg *authserver.CIMDRunConfig) AuthServerOption {
62+
return func(c *authServerConfig) {
63+
c.cimd = cfg
64+
}
65+
}
66+
5667
// GetFreePort returns an available TCP port on localhost.
5768
func GetFreePort(tb testing.TB) int {
5869
tb.Helper()
@@ -113,6 +124,7 @@ func NewTestAuthServerConfig(tb testing.TB, upstreamURL string, opts ...AuthServ
113124
ScopesSupported: cfg.scopesSupported,
114125
BaselineClientScopes: cfg.baselineClientScopes,
115126
AllowedAudiences: cfg.allowedAudiences,
127+
CIMD: cfg.cimd,
116128
}
117129
}
118130

0 commit comments

Comments
 (0)