From 84a58cd7578f9418dd864010cb6d7634679a5196 Mon Sep 17 00:00:00 2001 From: "Alessandro (Ale) Segala" <43508+ItalyPaleAle@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:45:34 -0700 Subject: [PATCH] fix: ignore unsupported grant types in client ID metadata documents (#1682) --- backend/internal/oidc/cimd.go | 54 ++++++++++++++++++------------ backend/internal/oidc/cimd_test.go | 26 +++++++++++++- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/backend/internal/oidc/cimd.go b/backend/internal/oidc/cimd.go index e0293dcf..a16f6907 100644 --- a/backend/internal/oidc/cimd.go +++ b/backend/internal/oidc/cimd.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "strings" "github.com/ory/fosite" @@ -129,23 +130,9 @@ func (cimdPolicy) ValidateCIMDClient(_ context.Context, doc *fosite.ClientMetada return fmt.Errorf("client metadata documents only support token_endpoint_auth_method %q, got %q", "none", doc.TokenEndpointAuthMethod) } - // Restrict metadata clients to grant types implemented by Pocket ID and require a flow that can initiate authorization - grantTypes := doc.GrantTypes - if len(grantTypes) == 0 { - grantTypes = []string{string(fosite.GrantTypeAuthorizationCode)} - } - hasInitiatingGrant := false - for _, grantType := range grantTypes { - switch grantType { - case string(fosite.GrantTypeAuthorizationCode), string(fosite.GrantTypeDeviceCode): - hasInitiatingGrant = true - case string(fosite.GrantTypeRefreshToken): - default: - return fmt.Errorf("client metadata document contains unsupported grant_type %q", grantType) - } - } - if !hasInitiatingGrant { - return errors.New("client metadata document must enable authorization_code or device_code") + // Require a flow that can initiate authorization (ignoring the grant types Pocket ID does not implement) + if !hasInitiatingGrantType(supportedMetadataGrantTypes(doc.GrantTypes)) { + return errInitiatingGrantRequired } // Pocket ID only implements the code response type for metadata clients @@ -162,6 +149,30 @@ func (cimdPolicy) ValidateCIMDClient(_ context.Context, doc *fosite.ClientMetada return nil } +var errInitiatingGrantRequired = errors.New("client metadata document must enable authorization_code or device_code") + +// supportedMetadataGrantTypes keeps only the grant types Pocket ID implements for metadata clients and drops the rest +func supportedMetadataGrantTypes(grantTypes []string) []string { + if len(grantTypes) == 0 { + // Per RFC 7591 section 2: an omitted grant_types defaults to authorization_code + return []string{string(fosite.GrantTypeAuthorizationCode)} + } + + supported := make([]string, 0, len(grantTypes)) + for _, grantType := range grantTypes { + switch grantType { + case string(fosite.GrantTypeAuthorizationCode), string(fosite.GrantTypeDeviceCode), string(fosite.GrantTypeRefreshToken): + supported = append(supported, grantType) + } + } + return supported +} + +func hasInitiatingGrantType(grantTypes []string) bool { + return slices.Contains(grantTypes, string(fosite.GrantTypeAuthorizationCode)) || + slices.Contains(grantTypes, string(fosite.GrantTypeDeviceCode)) +} + // validateMetadataRedirectURIs rejects self-asserted redirect URIs Pocket ID must not accept func validateMetadataRedirectURIs(field string, uris []string) error { for _, raw := range uris { @@ -193,11 +204,10 @@ func buildClientFromMetadata(doc *fosite.ClientMetadataDocument, rawURL string) return model.OidcClient{}, err } - // Record what the document says the client restricts itself to, so it is not silently granted capabilities it never declared - // RFC 7591 section 2 defaults an omitted grant_types to authorization_code - grantTypes := doc.GrantTypes - if len(grantTypes) == 0 { - grantTypes = []string{"authorization_code"} + // Record the supported grant types the document declares, so the client is neither silently granted capabilities it never declared nor persisted with grants Pocket ID cannot honor + grantTypes := supportedMetadataGrantTypes(doc.GrantTypes) + if !hasInitiatingGrantType(grantTypes) { + return model.OidcClient{}, errInitiatingGrantRequired } client := model.OidcClient{ diff --git a/backend/internal/oidc/cimd_test.go b/backend/internal/oidc/cimd_test.go index f75105c7..4ae2af23 100644 --- a/backend/internal/oidc/cimd_test.go +++ b/backend/internal/oidc/cimd_test.go @@ -52,6 +52,29 @@ func TestBuildClientFromMetadata(t *testing.T) { } }) + t.Run("unsupported grant types are dropped", func(t *testing.T) { + doc := &fosite.ClientMetadataDocument{ + ClientID: id, + RedirectURIs: []string{"https://app.example.com/callback"}, + TokenEndpointAuthMethod: "none", + GrantTypes: []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"}, + } + c, err := buildClientFromMetadata(doc, id) + require.NoError(t, err) + assert.Equal(t, []string{"authorization_code", "refresh_token"}, []string(c.MetadataGrantTypes)) + }) + + t.Run("documents without a supported initiating grant are rejected", func(t *testing.T) { + doc := &fosite.ClientMetadataDocument{ + ClientID: id, + RedirectURIs: []string{"https://app.example.com/callback"}, + TokenEndpointAuthMethod: "none", + GrantTypes: []string{"refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"}, + } + _, err := buildClientFromMetadata(doc, id) + require.ErrorIs(t, err, errInitiatingGrantRequired) + }) + t.Run("name falls back to the client ID host", func(t *testing.T) { c, err := buildClientFromMetadata(&fosite.ClientMetadataDocument{ClientID: id, TokenEndpointAuthMethod: "none"}, id) require.NoError(t, err) @@ -71,7 +94,8 @@ func TestCIMDPolicyValidate(t *testing.T) { {name: "defaults are supported"}, {name: "authorization code and refresh token are supported", grantTypes: []string{"authorization_code", "refresh_token"}}, {name: "device code is supported", grantTypes: []string{string(fosite.GrantTypeDeviceCode)}}, - {name: "client credentials is rejected", grantTypes: []string{"client_credentials"}, wantError: "unsupported grant_type"}, + {name: "unsupported grant types are ignored", grantTypes: []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"}}, + {name: "client credentials cannot initiate authorization", grantTypes: []string{"client_credentials"}, wantError: "must enable"}, {name: "refresh token cannot initiate authorization", grantTypes: []string{"refresh_token"}, wantError: "must enable"}, {name: "implicit response is rejected", grantTypes: []string{"authorization_code"}, responseTypes: []string{"token"}, wantError: "unsupported response_type"}, } {