From 72f4842ef1a3a7e3b2adff52ed434a58db048c7a Mon Sep 17 00:00:00 2001 From: doemijdienaammaar Date: Mon, 2 Mar 2026 14:04:05 +0100 Subject: [PATCH 1/8] feat(authserver): wire client_credentials grant factory into fosite provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add compose.OAuth2ClientCredentialsGrantFactory to the fosite composition, enabling the token endpoint to accept client_credentials grant requests. This is the foundational change — subsequent commits will update DCR validation, token session handling, and discovery metadata. Ref: MCP spec 2025-03-26 recommends client_credentials for M2M clients. --- pkg/authserver/server_impl.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index 92276e05e6..486a557e90 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -218,8 +218,9 @@ func createProvider(authServerConfig *oauthserver.AuthorizationServerConfig, sto authServerConfig.Config, stor, &compose.CommonStrategy{CoreStrategy: jwtStrategy}, - compose.OAuth2AuthorizeExplicitFactory, // Authorization code grant - compose.OAuth2RefreshTokenGrantFactory, // Refresh token grant - compose.OAuth2PKCEFactory, // PKCE for public clients + compose.OAuth2AuthorizeExplicitFactory, // Authorization code grant + compose.OAuth2ClientCredentialsGrantFactory, // Client credentials grant (M2M) + compose.OAuth2RefreshTokenGrantFactory, // Refresh token grant + compose.OAuth2PKCEFactory, // PKCE for public clients ) } From c24e5a244812dc907aa45819962bf2621191fbe2 Mon Sep 17 00:00:00 2001 From: doemijdienaammaar Date: Mon, 2 Mar 2026 14:14:19 +0100 Subject: [PATCH 2/8] feat(dcr): support confidential client registration for client_credentials Update DCR validation to accept client_credentials grant type and confidential client semantics: - redirect_uris optional for client_credentials-only clients - token_endpoint_auth_method allows client_secret_basic/client_secret_post - authorization_code no longer required when client_credentials is present - refresh_token alone is still rejected (must accompany a primary grant) Backward compatible: existing public client registrations are unaffected. --- pkg/authserver/server/registration/dcr.go | 135 ++++++++++++++-------- 1 file changed, 90 insertions(+), 45 deletions(-) diff --git a/pkg/authserver/server/registration/dcr.go b/pkg/authserver/server/registration/dcr.go index 31b8f9670f..1effa99cfe 100644 --- a/pkg/authserver/server/registration/dcr.go +++ b/pkg/authserver/server/registration/dcr.go @@ -115,10 +115,11 @@ type DCRError struct { // defaultGrantTypes are the default grant types for registered clients. var defaultGrantTypes = []string{"authorization_code", "refresh_token"} -// allowedGrantTypes defines the grant types permitted for public clients. +// allowedGrantTypes defines the grant types permitted for registered clients. var allowedGrantTypes = map[string]bool{ "authorization_code": true, "refresh_token": true, + "client_credentials": true, } // defaultResponseTypes are the default response types for registered clients. @@ -129,34 +130,50 @@ var allowedResponseTypes = map[string]bool{ "code": true, } +// isClientCredentialsOnly returns true when the request is for a client_credentials-only +// client (no authorization_code grant). Such clients are confidential by definition. +func isClientCredentialsOnly(grantTypes []string) bool { + return slices.Contains(grantTypes, "client_credentials") && + !slices.Contains(grantTypes, "authorization_code") +} + // ValidateDCRRequest validates a DCR request according to RFC 7591 // and the server's security policy (loopback-only public clients). // Returns the validated request with defaults applied, or an error. func ValidateDCRRequest(req *DCRRequest) (*DCRRequest, *DCRError) { - // 1. Validate redirect_uris - required - if len(req.RedirectURIs) == 0 { - return nil, &DCRError{ - Error: DCRErrorInvalidRedirectURI, - ErrorDescription: "redirect_uris is required", - } + // 1. Validate/default grant_types first — we need this to determine client type. + grantTypes, err := validateGrantTypes(req.GrantTypes) + if err != nil { + return nil, err } - // 2. Validate redirect_uris count limit - if len(req.RedirectURIs) > MaxRedirectURICount { - return nil, &DCRError{ - Error: DCRErrorInvalidRedirectURI, - ErrorDescription: "too many redirect_uris (maximum 10)", - } - } + confidential := isClientCredentialsOnly(grantTypes) - // 3. Validate all redirect_uris per RFC 8252 - for _, uri := range req.RedirectURIs { - if err := ValidateRedirectURI(uri); err != nil { - return nil, err + // 2. Validate redirect_uris — required for public clients, optional for confidential. + if confidential { + // Confidential client_credentials-only clients don't use redirect URIs. + // Ignore any provided redirect_uris (don't fail, just don't store them). + } else { + if len(req.RedirectURIs) == 0 { + return nil, &DCRError{ + Error: DCRErrorInvalidRedirectURI, + ErrorDescription: "redirect_uris is required", + } + } + if len(req.RedirectURIs) > MaxRedirectURICount { + return nil, &DCRError{ + Error: DCRErrorInvalidRedirectURI, + ErrorDescription: "too many redirect_uris (maximum 10)", + } + } + for _, uri := range req.RedirectURIs { + if err := ValidateRedirectURI(uri); err != nil { + return nil, err + } } } - // 4. Validate client_name length + // 3. Validate client_name length if len(req.ClientName) > MaxClientNameLength { return nil, &DCRError{ Error: DCRErrorInvalidClientMetadata, @@ -164,33 +181,56 @@ func ValidateDCRRequest(req *DCRRequest) (*DCRRequest, *DCRError) { } } - // 5. Validate/default token_endpoint_auth_method + // 4. Validate/default token_endpoint_auth_method authMethod := req.TokenEndpointAuthMethod - if authMethod == "" { - authMethod = "none" - } - if authMethod != "none" { - return nil, &DCRError{ - Error: DCRErrorInvalidClientMetadata, - ErrorDescription: "token_endpoint_auth_method must be 'none' for public clients", + if confidential { + // Confidential clients must authenticate at the token endpoint. + if authMethod == "" { + authMethod = "client_secret_basic" + } + if authMethod != "client_secret_basic" && authMethod != "client_secret_post" { + return nil, &DCRError{ + Error: DCRErrorInvalidClientMetadata, + ErrorDescription: "token_endpoint_auth_method must be 'client_secret_basic' or 'client_secret_post' for confidential clients", + } + } + } else { + // Public clients don't authenticate at the token endpoint. + if authMethod == "" { + authMethod = "none" + } + if authMethod != "none" { + return nil, &DCRError{ + Error: DCRErrorInvalidClientMetadata, + ErrorDescription: "token_endpoint_auth_method must be 'none' for public clients", + } } } - // 6. Validate/default grant_types - grantTypes, err := validateGrantTypes(req.GrantTypes) - if err != nil { - return nil, err + // 5. Validate/default response_types + var responseTypes []string + if confidential { + // client_credentials-only clients don't use the authorize endpoint, + // so response_types is not applicable. Default to empty. + responseTypes = req.ResponseTypes + if len(responseTypes) == 0 { + responseTypes = nil + } + } else { + responseTypes, err = validateResponseTypes(req.ResponseTypes) + if err != nil { + return nil, err + } } - // 7. Validate/default response_types - responseTypes, err := validateResponseTypes(req.ResponseTypes) - if err != nil { - return nil, err + // Build validated redirect URIs — empty for confidential clients. + redirectURIs := req.RedirectURIs + if confidential { + redirectURIs = nil } - // Return validated request with defaults applied return &DCRRequest{ - RedirectURIs: req.RedirectURIs, + RedirectURIs: redirectURIs, ClientName: req.ClientName, TokenEndpointAuthMethod: authMethod, GrantTypes: grantTypes, @@ -202,14 +242,7 @@ func validateGrantTypes(grantTypes []string) ([]string, *DCRError) { if len(grantTypes) == 0 { grantTypes = defaultGrantTypes } - // Require authorization_code explicitly - provides a clearer error for the - // "refresh_token only" case that would otherwise pass the allowlist. - if !slices.Contains(grantTypes, "authorization_code") { - return nil, &DCRError{ - Error: DCRErrorInvalidClientMetadata, - ErrorDescription: "grant_types must include 'authorization_code'", - } - } + for _, gt := range grantTypes { if !allowedGrantTypes[gt] { return nil, &DCRError{ @@ -218,6 +251,18 @@ func validateGrantTypes(grantTypes []string) ([]string, *DCRError) { } } } + + // At least one primary grant type must be present. + // "refresh_token" alone is not valid — it must accompany a primary grant. + hasAuthCode := slices.Contains(grantTypes, "authorization_code") + hasClientCredentials := slices.Contains(grantTypes, "client_credentials") + if !hasAuthCode && !hasClientCredentials { + return nil, &DCRError{ + Error: DCRErrorInvalidClientMetadata, + ErrorDescription: "grant_types must include 'authorization_code' or 'client_credentials'", + } + } + return grantTypes, nil } From bff5b80b18799af482b7b7eaae51d71594ef859d Mon Sep 17 00:00:00 2001 From: doemijdienaammaar Date: Mon, 2 Mar 2026 14:17:15 +0100 Subject: [PATCH 3/8] feat(dcr): generate and return client_secret for confidential clients When a client registers with client_credentials grant type, the DCR handler now generates a random secret, creates a confidential fosite client with bcrypt-hashed secret, and returns the plaintext secret in the registration response (RFC 7591 Section 3.2.1). Public client registrations are unaffected. --- pkg/authserver/server/handlers/dcr.go | 13 +++++++++++-- pkg/authserver/server/registration/dcr.go | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/authserver/server/handlers/dcr.go b/pkg/authserver/server/handlers/dcr.go index 226c225940..0b373dd7ed 100644 --- a/pkg/authserver/server/handlers/dcr.go +++ b/pkg/authserver/server/handlers/dcr.go @@ -63,14 +63,22 @@ func (h *Handler) RegisterClientHandler(w http.ResponseWriter, req *http.Request return } - // Generate client ID + // Determine if this is a confidential client registration. + isConfidential := validated.TokenEndpointAuthMethod != "none" + + // Generate client credentials. clientID := uuid.NewString() + var clientSecret string + if isConfidential { + clientSecret = uuid.NewString() // Secure random secret + } // Create fosite client using factory. fositeClient, err := registration.New(registration.Config{ ID: clientID, + Secret: clientSecret, RedirectURIs: validated.RedirectURIs, - Public: true, + Public: !isConfidential, GrantTypes: validated.GrantTypes, ResponseTypes: validated.ResponseTypes, Scopes: scopes, @@ -106,6 +114,7 @@ func (h *Handler) RegisterClientHandler(w http.ResponseWriter, req *http.Request // the client know exactly which scopes it can request. response := registration.DCRResponse{ ClientID: clientID, + ClientSecret: clientSecret, // Only set for confidential clients ClientIDIssuedAt: time.Now().Unix(), RedirectURIs: validated.RedirectURIs, ClientName: validated.ClientName, diff --git a/pkg/authserver/server/registration/dcr.go b/pkg/authserver/server/registration/dcr.go index 1effa99cfe..e05eaaa4dc 100644 --- a/pkg/authserver/server/registration/dcr.go +++ b/pkg/authserver/server/registration/dcr.go @@ -82,6 +82,11 @@ type DCRResponse struct { // as a Unix timestamp. ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` + // ClientSecret is the client secret for confidential clients. + // Only returned during registration (cannot be retrieved later). + // Omitted for public clients. + ClientSecret string `json:"client_secret,omitempty"` + // RedirectURIs is an array of redirection URIs for the client. RedirectURIs []string `json:"redirect_uris"` From a3274349cb9cd757acc81078978e87cc3364d8e4 Mon Sep 17 00:00:00 2001 From: doemijdienaammaar Date: Mon, 2 Mar 2026 14:17:33 +0100 Subject: [PATCH 4/8] feat(token): populate session subject for client_credentials grant For client_credentials grants, fosite uses the placeholder session directly (no stored authorize session exists). Populate the session's subject and client_id claims with the authenticated client ID so the resulting JWT has a meaningful 'sub' claim for downstream authorization. The Cedar authorizer extracts Client:: principal from the 'sub' claim, so this is required for M2M authorization to work. --- pkg/authserver/server/handlers/token.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/authserver/server/handlers/token.go b/pkg/authserver/server/handlers/token.go index 551360deab..755bc56e6c 100644 --- a/pkg/authserver/server/handlers/token.go +++ b/pkg/authserver/server/handlers/token.go @@ -34,6 +34,14 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { return } + // For client_credentials grant, fosite uses the placeholder session directly + // (there's no stored authorize session to retrieve). We must populate the + // session's subject with the client ID so the JWT has a meaningful "sub" claim. + if accessRequest.GetGrantTypes().ExactOne("client_credentials") { + clientID := accessRequest.GetClient().GetID() + accessRequest.SetSession(session.New(clientID, "", clientID)) + } + // RFC 8707: Handle resource parameter for audience claim. // The resource parameter allows clients to specify which protected resource (MCP server) // the token is intended for. This value becomes the "aud" claim in the JWT. From 8731ccb9bf90832f0b50730358a3b80755226f10 Mon Sep 17 00:00:00 2001 From: doemijdienaammaar Date: Mon, 2 Mar 2026 14:17:49 +0100 Subject: [PATCH 5/8] feat(discovery): advertise client_credentials and client_secret_basic Update OAuth AS metadata and OIDC discovery endpoints to advertise: - grant_types_supported: add client_credentials - token_endpoint_auth_methods_supported: add client_secret_basic, client_secret_post Per RFC 8414, this tells clients the server supports M2M authentication. --- pkg/authserver/server/handlers/discovery.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/authserver/server/handlers/discovery.go b/pkg/authserver/server/handlers/discovery.go index 3e47b45484..4c7ea32cfb 100644 --- a/pkg/authserver/server/handlers/discovery.go +++ b/pkg/authserver/server/handlers/discovery.go @@ -110,10 +110,15 @@ func (h *Handler) buildOAuthMetadata() sharedobauth.AuthorizationServerMetadata // OPTIONAL GrantTypesSupported: []string{ string(fosite.GrantTypeAuthorizationCode), + string(fosite.GrantTypeClientCredentials), string(fosite.GrantTypeRefreshToken), }, - CodeChallengeMethodsSupported: []string{crypto.PKCEChallengeMethodS256}, - TokenEndpointAuthMethodsSupported: []string{sharedobauth.TokenEndpointAuthMethodNone}, + CodeChallengeMethodsSupported: []string{crypto.PKCEChallengeMethodS256}, + TokenEndpointAuthMethodsSupported: []string{ + sharedobauth.TokenEndpointAuthMethodNone, + "client_secret_basic", + "client_secret_post", + }, } } From 06481e49b31322ac3bc40172c088618152260ccb Mon Sep 17 00:00:00 2001 From: doemijdienaammaar Date: Mon, 2 Mar 2026 14:20:28 +0100 Subject: [PATCH 6/8] test(dcr): add confidential client validation tests Update existing tests that expected client_credentials rejection to verify acceptance. Add new test cases covering: - client_secret_basic and client_secret_post auth methods - redirect_uris handling for confidential clients - rejection of auth_method=none for confidential clients - refresh_token-only and implicit grant type rejection --- .../server/registration/dcr_test.go | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/pkg/authserver/server/registration/dcr_test.go b/pkg/authserver/server/registration/dcr_test.go index ac836c9583..280bf22562 100644 --- a/pkg/authserver/server/registration/dcr_test.go +++ b/pkg/authserver/server/registration/dcr_test.go @@ -310,13 +310,14 @@ func TestValidateDCRRequest(t *testing.T) { errorCode: DCRErrorInvalidClientMetadata, }, { - name: "grant_types with only client_credentials fails", + name: "grant_types with only client_credentials succeeds for confidential client", request: &DCRRequest{ - RedirectURIs: []string{"http://127.0.0.1/callback"}, - GrantTypes: []string{"client_credentials"}, + GrantTypes: []string{"client_credentials"}, + TokenEndpointAuthMethod: "client_secret_basic", }, - expectError: true, - errorCode: DCRErrorInvalidClientMetadata, + expectError: false, + expectedAuthMethod: "client_secret_basic", + expectedGrants: []string{"client_credentials"}, }, { name: "grant_types with authorization_code passes", @@ -328,11 +329,73 @@ func TestValidateDCRRequest(t *testing.T) { expectedGrants: []string{"authorization_code"}, }, { - name: "grant_types with unsupported type rejected", + name: "grant_types with authorization_code and client_credentials succeeds", request: &DCRRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, GrantTypes: []string{"authorization_code", "client_credentials"}, }, + expectError: false, + expectedAuthMethod: "none", + expectedGrants: []string{"authorization_code", "client_credentials"}, + }, + + // Confidential client (client_credentials) validation + { + name: "confidential client with client_secret_basic", + request: &DCRRequest{ + ClientName: "My M2M Service", + GrantTypes: []string{"client_credentials"}, + TokenEndpointAuthMethod: "client_secret_basic", + }, + expectError: false, + expectedAuthMethod: "client_secret_basic", + expectedGrants: []string{"client_credentials"}, + }, + { + name: "confidential client with client_secret_post", + request: &DCRRequest{ + GrantTypes: []string{"client_credentials"}, + TokenEndpointAuthMethod: "client_secret_post", + }, + expectError: false, + expectedAuthMethod: "client_secret_post", + expectedGrants: []string{"client_credentials"}, + }, + { + name: "confidential client with auth_method none fails", + request: &DCRRequest{ + GrantTypes: []string{"client_credentials"}, + TokenEndpointAuthMethod: "none", + }, + expectError: true, + errorCode: DCRErrorInvalidClientMetadata, + }, + { + name: "confidential client ignores redirect_uris", + request: &DCRRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + GrantTypes: []string{"client_credentials"}, + TokenEndpointAuthMethod: "client_secret_basic", + }, + expectError: false, + expectedAuthMethod: "client_secret_basic", + expectedGrants: []string{"client_credentials"}, + }, + { + name: "grant_types with only refresh_token still fails", + request: &DCRRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + GrantTypes: []string{"refresh_token"}, + }, + expectError: true, + errorCode: DCRErrorInvalidClientMetadata, + }, + { + name: "grant_types with unsupported implicit type rejected", + request: &DCRRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + GrantTypes: []string{"authorization_code", "implicit"}, + }, expectError: true, errorCode: DCRErrorInvalidClientMetadata, }, @@ -446,8 +509,12 @@ func TestValidateDCRRequest(t *testing.T) { assert.ElementsMatch(t, tt.expectedResponses, result.ResponseTypes) } - // Verify redirect_uris are preserved - assert.Equal(t, tt.request.RedirectURIs, result.RedirectURIs) + // Verify redirect_uris: nil for confidential clients, preserved for public + if result.TokenEndpointAuthMethod != "none" { + assert.Nil(t, result.RedirectURIs, "confidential clients should have nil redirect_uris") + } else { + assert.Equal(t, tt.request.RedirectURIs, result.RedirectURIs) + } // Verify client_name is preserved assert.Equal(t, tt.request.ClientName, result.ClientName) From c25120f650645c24824594cc80b0e7525dc1bbac Mon Sep 17 00:00:00 2001 From: doemijdienaammaar Date: Mon, 2 Mar 2026 14:26:35 +0100 Subject: [PATCH 7/8] test(handlers): add client_credentials token and discovery tests - Wire OAuth2ClientCredentialsGrantFactory into test setup - Register confidential test client with bcrypt-hashed secret - Replace unsupported grant type test (was client_credentials, now implicit) - Add success, wrong-secret, and resource-parameter tests for client_credentials - Update discovery assertions to include client_credentials and auth methods --- .../server/handlers/handlers_test.go | 6 ++ .../server/handlers/helpers_test.go | 27 ++++++- pkg/authserver/server/handlers/token_test.go | 71 ++++++++++++++++++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/pkg/authserver/server/handlers/handlers_test.go b/pkg/authserver/server/handlers/handlers_test.go index bd09b8d9d3..07d91c6d86 100644 --- a/pkg/authserver/server/handlers/handlers_test.go +++ b/pkg/authserver/server/handlers/handlers_test.go @@ -167,8 +167,11 @@ func TestOAuthDiscoveryHandler(t *testing.T) { // Verify OPTIONAL fields per RFC 8414 assert.Contains(t, metadata.GrantTypesSupported, "authorization_code") assert.Contains(t, metadata.GrantTypesSupported, "refresh_token") + assert.Contains(t, metadata.GrantTypesSupported, "client_credentials") assert.Contains(t, metadata.CodeChallengeMethodsSupported, "S256") assert.Contains(t, metadata.TokenEndpointAuthMethodsSupported, "none") + assert.Contains(t, metadata.TokenEndpointAuthMethodsSupported, "client_secret_basic") + assert.Contains(t, metadata.TokenEndpointAuthMethodsSupported, "client_secret_post") } func TestOAuthDiscoveryHandler_DoesNotContainOIDCFields(t *testing.T) { @@ -228,8 +231,11 @@ func TestOIDCDiscoveryHandler(t *testing.T) { // Verify OPTIONAL fields assert.Contains(t, discovery.GrantTypesSupported, "authorization_code") assert.Contains(t, discovery.GrantTypesSupported, "refresh_token") + assert.Contains(t, discovery.GrantTypesSupported, "client_credentials") assert.Contains(t, discovery.CodeChallengeMethodsSupported, "S256") assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "none") + assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "client_secret_basic") + assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "client_secret_post") } // TODO: Add tests for TokenHandler once implemented: diff --git a/pkg/authserver/server/handlers/helpers_test.go b/pkg/authserver/server/handlers/helpers_test.go index 918d192189..d9c5e570c8 100644 --- a/pkg/authserver/server/handlers/helpers_test.go +++ b/pkg/authserver/server/handlers/helpers_test.go @@ -14,6 +14,7 @@ import ( "github.com/ory/fosite/compose" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "golang.org/x/crypto/bcrypt" "github.com/stacklok/toolhive/pkg/authserver/server" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" @@ -29,6 +30,11 @@ const ( testInternalState = "internal-state-123" ) +const ( + testConfidentialClientID = "test-confidential-client" + testConfidentialClientSecret = "test-secret-12345" +) + // mockIDPProvider implements upstream.OAuth2Provider for testing. type mockIDPProvider struct { providerType upstream.ProviderType @@ -150,14 +156,28 @@ func handlerTestSetup(t *testing.T) (*Handler, *testStorageState, *mockIDPProvid } storState.clients[testAuthClientID] = testClient - // Setup mock expectations for GetClient - stor.EXPECT().GetClient(gomock.Any(), testAuthClientID).DoAndReturn(func(_ context.Context, id string) (fosite.Client, error) { + // Register a confidential test client (for client_credentials) + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(testConfidentialClientSecret), bcrypt.DefaultCost) + require.NoError(t, err) + + confidentialClient := &fosite.DefaultClient{ + ID: testConfidentialClientID, + Secret: hashedSecret, + RedirectURIs: nil, + ResponseTypes: nil, + GrantTypes: []string{"client_credentials"}, + Scopes: []string{"openid"}, + Public: false, + } + storState.clients[testConfidentialClientID] = confidentialClient + + // Setup mock expectations for GetClient — return any registered client + stor.EXPECT().GetClient(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, id string) (fosite.Client, error) { if c, ok := storState.clients[id]; ok { return c, nil } return nil, fosite.ErrNotFound }).AnyTimes() - stor.EXPECT().GetClient(gomock.Any(), gomock.Not(testAuthClientID)).Return(nil, fosite.ErrNotFound).AnyTimes() // Setup mock expectations for pending authorization storage stor.EXPECT().StorePendingAuthorization(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( @@ -317,6 +337,7 @@ func handlerTestSetup(t *testing.T) (*Handler, *testStorageState, *mockIDPProvid stor, &compose.CommonStrategy{CoreStrategy: jwtStrategy}, compose.OAuth2AuthorizeExplicitFactory, + compose.OAuth2ClientCredentialsGrantFactory, compose.OAuth2RefreshTokenGrantFactory, compose.OAuth2PKCEFactory, ) diff --git a/pkg/authserver/server/handlers/token_test.go b/pkg/authserver/server/handlers/token_test.go index e6d1e795be..9e2211d49e 100644 --- a/pkg/authserver/server/handlers/token_test.go +++ b/pkg/authserver/server/handlers/token_test.go @@ -38,7 +38,7 @@ func TestTokenHandler_UnsupportedGrantType(t *testing.T) { handler, _, _ := handlerTestSetup(t) form := url.Values{ - "grant_type": {"client_credentials"}, // Not supported + "grant_type": {"implicit"}, // Not supported by this server } req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -46,7 +46,6 @@ func TestTokenHandler_UnsupportedGrantType(t *testing.T) { handler.TokenHandler(rec, req) - // fosite returns invalid_request for unsupported grant types when the handler isn't registered assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "invalid_request") } @@ -221,6 +220,74 @@ func TestTokenHandler_RouteRegistered(t *testing.T) { require.NotEqual(t, http.StatusMethodNotAllowed, rec.Code, "POST method should be allowed") } +func TestTokenHandler_ClientCredentials_Success(t *testing.T) { + t.Parallel() + handler, _, _ := handlerTestSetup(t) + + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {testConfidentialClientID}, + "client_secret": {testConfidentialClientSecret}, + "scope": {"openid"}, + } + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.TokenHandler(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "expected 200 OK, got %d: %s", rec.Code, rec.Body.String()) + + body := rec.Body.String() + assert.Contains(t, body, "access_token") + assert.Contains(t, body, "token_type") + assert.Contains(t, body, "expires_in") + // client_credentials should NOT return a refresh_token + assert.NotContains(t, body, "refresh_token") +} + +func TestTokenHandler_ClientCredentials_WrongSecret(t *testing.T) { + t.Parallel() + handler, _, _ := handlerTestSetup(t) + + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {testConfidentialClientID}, + "client_secret": {"wrong-secret"}, + } + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.TokenHandler(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid_client") +} + +func TestTokenHandler_ClientCredentials_WithResource(t *testing.T) { + t.Parallel() + handler, _, _ := handlerTestSetup(t) + + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {testConfidentialClientID}, + "client_secret": {testConfidentialClientSecret}, + "scope": {"openid"}, + "resource": {"https://api.example.com"}, + } + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.TokenHandler(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "expected 200 OK, got %d: %s", rec.Code, rec.Body.String()) + + body := rec.Body.String() + assert.Contains(t, body, "access_token") +} + // testPKCEVerifier is a valid PKCE verifier (43-128 characters, URL-safe). const testPKCEVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" From a0f636ab3786d975e7f158657471d32edd4f4d9c Mon Sep 17 00:00:00 2001 From: doemijdienaammaar Date: Mon, 2 Mar 2026 14:28:57 +0100 Subject: [PATCH 8/8] test(integration): add client_credentials grant flow tests Add integration tests covering: - Basic client_credentials flow with JWT claim validation (sub=clientID) - RFC 8707 resource parameter for audience binding - Wrong secret rejection (401 invalid_client) - Verify no refresh token issued for client_credentials Uses setupTestServer with new withConfidentialClient() option that registers a bcrypt-hashed confidential client. --- pkg/authserver/integration_test.go | 137 +++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index 684569997e..f12af8d8f7 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -22,6 +22,7 @@ import ( "github.com/ory/fosite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/server/keys" @@ -36,6 +37,9 @@ const ( testIssuer = "http://localhost" testAudience = "https://mcp.example.com" + testConfidentialClientID = "test-confidential-client" + testConfidentialClientSecret = "test-confidential-secret" + // testAccessTokenLifetime is the configured access token lifetime in setupTestServer. testAccessTokenLifetime = time.Hour ) @@ -51,6 +55,7 @@ type testServerOptions struct { upstream upstream.OAuth2Provider scopes []string accessTokenLifespan time.Duration + confidentialClient bool } // testServerOption is a functional option for test server setup. @@ -77,6 +82,13 @@ func withAccessTokenLifespan(d time.Duration) testServerOption { } } +// withConfidentialClient registers a confidential client for client_credentials testing. +func withConfidentialClient() testServerOption { + return func(opts *testServerOptions) { + opts.confidentialClient = true + } +} + // testKeyProvider is a simple KeyProvider for tests that uses a pre-generated RSA key. type testKeyProvider struct { key *rsa.PrivateKey @@ -138,6 +150,22 @@ func setupTestServer(t *testing.T, opts ...testServerOption) *testServer { }) require.NoError(t, err) + // Optionally register a confidential client for client_credentials testing + if options.confidentialClient { + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(testConfidentialClientSecret), bcrypt.DefaultCost) + require.NoError(t, err) + err = stor.RegisterClient(ctx, &fosite.DefaultClient{ + ID: testConfidentialClientID, + Secret: hashedSecret, + RedirectURIs: nil, + ResponseTypes: nil, + GrantTypes: []string{"client_credentials"}, + Scopes: []string{"openid"}, + Public: false, + }) + require.NoError(t, err) + } + // 5. Build upstream config for newServer // When no upstream is provided, use a dummy config that satisfies validation // Note: Uses HTTPS to pass config validation @@ -1162,3 +1190,112 @@ func TestIntegration_RefreshToken_ShortLivedAccessToken(t *testing.T) { require.True(t, ok) assert.Greater(t, int64(exp), time.Now().Unix(), "refreshed token exp must be in the future") } + +// ============================================================================ +// Client Credentials Flow Integration Tests +// ============================================================================ + +// TestIntegration_ClientCredentials_BasicFlow tests the client_credentials grant flow. +func TestIntegration_ClientCredentials_BasicFlow(t *testing.T) { + t.Parallel() + + ts := setupTestServer(t, withConfidentialClient()) + + // Make client_credentials token request with client_secret_post + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {testConfidentialClientID}, + "client_secret": {testConfidentialClientSecret}, + "scope": {"openid"}, + } + + resp := makeTokenRequest(t, ts.Server.URL, params) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "client_credentials request should succeed") + tokenData := parseTokenResponse(t, resp) + + // Verify access token is present + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok, "access_token should be a string") + require.NotEmpty(t, accessToken) + + // Verify token_type + tokenType, ok := tokenData["token_type"].(string) + require.True(t, ok) + assert.Equal(t, "bearer", strings.ToLower(tokenType)) + + // Verify no refresh token (client_credentials should not issue refresh tokens) + _, hasRefresh := tokenData["refresh_token"] + assert.False(t, hasRefresh, "client_credentials should not issue refresh_token") + + // Verify JWT claims + parsedToken, err := jwt.ParseSigned(accessToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + + var claims map[string]interface{} + err = parsedToken.Claims(ts.PrivateKey.Public(), &claims) + require.NoError(t, err) + + // Subject should be the client ID for M2M tokens + assert.Equal(t, testConfidentialClientID, claims["sub"], "sub should be client ID for M2M tokens") + assert.Equal(t, testConfidentialClientID, claims["client_id"], "client_id claim should match") + assert.Equal(t, testIssuer, claims["iss"], "issuer should match") +} + +// TestIntegration_ClientCredentials_WithAudience tests client_credentials with RFC 8707 resource parameter. +func TestIntegration_ClientCredentials_WithAudience(t *testing.T) { + t.Parallel() + + ts := setupTestServer(t, withConfidentialClient()) + + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {testConfidentialClientID}, + "client_secret": {testConfidentialClientSecret}, + "scope": {"openid"}, + "resource": {testAudience}, + } + + resp := makeTokenRequest(t, ts.Server.URL, params) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + tokenData := parseTokenResponse(t, resp) + + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok) + + parsedToken, err := jwt.ParseSigned(accessToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + + var claims map[string]interface{} + err = parsedToken.Claims(ts.PrivateKey.Public(), &claims) + require.NoError(t, err) + + // Verify audience from resource parameter + aud, ok := claims["aud"].([]interface{}) + require.True(t, ok, "aud should be an array") + require.Len(t, aud, 1) + assert.Equal(t, testAudience, aud[0], "audience should match requested resource") +} + +// TestIntegration_ClientCredentials_WrongSecret tests that wrong secrets are rejected. +func TestIntegration_ClientCredentials_WrongSecret(t *testing.T) { + t.Parallel() + + ts := setupTestServer(t, withConfidentialClient()) + + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {testConfidentialClientID}, + "client_secret": {"wrong-secret"}, + } + + resp := makeTokenRequest(t, ts.Server.URL, params) + defer resp.Body.Close() + + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + errResp := parseTokenResponse(t, resp) + assert.Equal(t, "invalid_client", errResp["error"]) +}