package oidc import ( "context" "errors" "net/http" "strings" "testing" "time" "github.com/ory/fosite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) func TestBuildClientFromMetadata(t *testing.T) { const id = "https://app.example.com/oauth/client" t.Run("public client maps to PKCE", func(t *testing.T) { doc := &fosite.ClientMetadataDocument{ ClientID: id, ClientName: "Example App", RedirectURIs: []string{"https://app.example.com/callback"}, PostLogoutRedirectURIs: []string{"https://app.example.com/logout"}, TokenEndpointAuthMethod: "none", } c, err := buildClientFromMetadata(doc, id) require.NoError(t, err) assert.Equal(t, id, c.ID) assert.Equal(t, "Example App", c.Name) assert.True(t, c.IsPublic) assert.True(t, c.PkceEnabled) assert.True(t, c.IsMetadataDocument()) assert.Equal(t, []string{"https://app.example.com/callback"}, []string(c.CallbackURLs)) assert.Equal(t, []string{"https://app.example.com/logout"}, []string(c.LogoutCallbackURLs)) assert.Equal(t, []string{"authorization_code"}, []string(c.MetadataGrantTypes)) assert.Empty(t, c.Credentials.FederatedIdentities) assert.Equal(t, model.DefaultAccessTokenDurationMinutes, c.AccessTokenDurationMinutes) assert.Equal(t, model.DefaultRefreshTokenDurationMinutes, c.RefreshTokenDurationMinutes) }) t.Run("authenticated clients are rejected", func(t *testing.T) { for _, m := range []string{"private_key_jwt", "client_secret_basic", "client_secret_post", "client_secret_jwt"} { //nolint:gosec // G101 false positive: authentication method names, not credentials doc := &fosite.ClientMetadataDocument{ClientID: id, TokenEndpointAuthMethod: m} _, err := buildClientFromMetadata(doc, id) require.Errorf(t, err, "method %q", m) } }) 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) assert.Equal(t, "app.example.com", c.Name) }) } func TestCIMDPolicyValidate(t *testing.T) { policy := cimdPolicy{} for _, test := range []struct { name string grantTypes []string responseTypes []string wantError string }{ {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: "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"}, } { t.Run(test.name, func(t *testing.T) { err := policy.ValidateCIMDClient(t.Context(), &fosite.ClientMetadataDocument{ TokenEndpointAuthMethod: "none", GrantTypes: test.grantTypes, ResponseTypes: test.responseTypes, }) if test.wantError == "" { require.NoError(t, err) return } require.ErrorContains(t, err, test.wantError) }) } t.Run("omitted authentication method is rejected", func(t *testing.T) { err := policy.ValidateCIMDClient(t.Context(), &fosite.ClientMetadataDocument{}) require.ErrorContains(t, err, "token_endpoint_auth_method") }) } func TestMetadataClientChanges(t *testing.T) { base := model.OidcClient{ Name: "App", CallbackURLs: datatype.StringList{"https://app/cb"}, LogoutCallbackURLs: datatype.StringList{"https://app/lo"}, IsPublic: true, } t.Run("no changes", func(t *testing.T) { assert.Empty(t, metadataClientChanges(base, base)) }) t.Run("redirect_uris change", func(t *testing.T) { next := base next.CallbackURLs = datatype.StringList{"https://app/other"} assert.Equal(t, []string{"redirect_uris"}, metadataClientChanges(base, next)) }) t.Run("auth method change", func(t *testing.T) { next := base next.IsPublic = false got := metadataClientChanges(base, next) assert.Contains(t, got, "token_endpoint_auth_method") }) t.Run("grant types change", func(t *testing.T) { next := base next.MetadataGrantTypes = datatype.StringList{"authorization_code", "refresh_token"} assert.Contains(t, metadataClientChanges(base, next), "grant_types") }) } func TestRefreshMetadataClient(t *testing.T) { const id = "https://8.8.8.8/oauth/client" body := `{"client_id":"https://8.8.8.8/oauth/client","client_name":"App","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"none"}` t.Run("empty allowlist", func(t *testing.T) { s := newMetadataStore(t, nil, func() []string { return nil }) _, err := s.RefreshMetadataClient(t.Context(), id) require.Error(t, err) }) t.Run("non-URL id", func(t *testing.T) { s := newMetadataStore(t, nil) _, err := s.RefreshMetadataClient(t.Context(), "not-a-url") require.Error(t, err) }) t.Run("unknown client yields not found", func(t *testing.T) { s := newMetadataStore(t, nil) _, err := s.RefreshMetadataClient(t.Context(), id) require.ErrorIs(t, err, gorm.ErrRecordNotFound) }) t.Run("non-metadata client is rejected", func(t *testing.T) { s := newMetadataStore(t, nil) seed := model.OidcClient{Base: model.Base{ID: id}, Name: "Standard"} require.NoError(t, s.db.Create(&seed).Error) _, err := s.RefreshMetadataClient(t.Context(), id) require.Error(t, err) require.NotErrorIs(t, err, gorm.ErrRecordNotFound) }) t.Run("forces re-fetch even when cache is fresh", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}) fresh := datatype.DateTime(time.Now().Add(time.Hour)) seed := model.OidcClient{ Base: model.Base{ID: id}, Name: "Old", IsPublic: true, PkceEnabled: true, ClientType: model.OidcClientTypeCIMD, MetadataExpiresAt: &fresh, AccessTokenDurationMinutes: 2 * 60, RefreshTokenDurationMinutes: 7 * 24 * 60, } require.NoError(t, s.db.Create(&seed).Error) // A normal lookup still returns the cached value. fc, err := s.GetClient(t.Context(), id) require.NoError(t, err) assert.Equal(t, "Old", fc.(Client).Name) // A forced refresh re-fetches and updates the cached client. c, err := s.RefreshMetadataClient(t.Context(), id) require.NoError(t, err) assert.Equal(t, "App", c.Name) assert.True(t, c.IsMetadataDocument()) assert.Equal(t, int64(2*60), c.AccessTokenDurationMinutes) assert.Equal(t, int64(7*24*60), c.RefreshTokenDurationMinutes) }) } type metadataStore struct { *Store resolver *cimdClientResolver } func (s *metadataStore) GetClient(ctx context.Context, id string) (fosite.Client, error) { client, err := s.resolver.ResolveClient(ctx, id, s.Store.GetClient) if err == nil { return client, nil } if errors.Is(err, fosite.ErrNotFound) { return nil, fosite.ErrNotFound } if fosite.LooksLikeCIMDURL(id) { return nil, fosite.ErrInvalidClient.WithHint("The client metadata document could not be resolved.").WithWrap(err).WithDebug(err.Error()) } return nil, err } func (s *metadataStore) RefreshMetadataClient(ctx context.Context, id string) (model.OidcClient, error) { return s.resolver.RefreshMetadataClient(ctx, id) } func newMetadataStore(t *testing.T, responses map[string]*http.Response, allowlists ...func() []string) *metadataStore { t.Helper() getAllowlist := func() []string { return []string{"*"} } if len(allowlists) > 0 { getAllowlist = allowlists[0] } store := NewStore(testutils.NewDatabaseForTest(t), nil) return &metadataStore{ Store: store, resolver: newCIMDClientResolver(store, cimdResolverConfig{ getURLAllowlist: getAllowlist, transport: &testutils.MockRoundTripper{Responses: responses}, }), } } func TestGetClient_CIMDURLAllowlist(t *testing.T) { const id = "https://8.8.8.8/oauth/client" body := `{"client_id":"https://8.8.8.8/oauth/client","client_name":"App","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"none"}` t.Run("empty allowlist denies without fetching", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return nil }, ) _, err := s.GetClient(t.Context(), id) require.ErrorIs(t, err, fosite.ErrInvalidClient) var count int64 require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error) assert.Equal(t, int64(0), count) }) t.Run("non-matching allowlist denies", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return []string{"https://other.example.com/**"} }, ) _, err := s.GetClient(t.Context(), id) require.ErrorIs(t, err, fosite.ErrInvalidClient) }) t.Run("matching allowlist allows", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return []string{"https://8.8.8.8/**"} }, ) fc, err := s.GetClient(t.Context(), id) require.NoError(t, err) assert.Equal(t, id, fc.(Client).ID) }) t.Run("refresh denied when not allowlisted", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return nil }, ) _, err := s.RefreshMetadataClient(t.Context(), id) require.Error(t, err) }) } func TestGetClient_MetadataDocument(t *testing.T) { const id = "https://8.8.8.8/oauth/client" body := `{"client_id":"https://8.8.8.8/oauth/client","client_name":"App","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"none"}` t.Run("non-URL id falls through to the database", func(t *testing.T) { s := newMetadataStore(t, nil) _, err := s.GetClient(t.Context(), "does-not-exist") require.ErrorIs(t, err, fosite.ErrNotFound) }) t.Run("allowlist changes apply without rebuilding the store", func(t *testing.T) { allowlist := []string(nil) resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return allowlist }, ) _, err := s.GetClient(t.Context(), id) require.ErrorIs(t, err, fosite.ErrInvalidClient) allowlist = []string{"https://8.8.8.8/**"} fc, err := s.GetClient(t.Context(), id) require.NoError(t, err) assert.Equal(t, id, fc.(Client).ID) allowlist = nil _, err = s.GetClient(t.Context(), id) require.ErrorIs(t, err, fosite.ErrInvalidClient) }) t.Run("pre-registered URL client takes precedence", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return nil }, ) seed := model.OidcClient{Base: model.Base{ID: id}, Name: "Standard"} require.NoError(t, s.db.Create(&seed).Error) resolved, err := s.GetClient(t.Context(), id) require.NoError(t, err) assert.Equal(t, "Standard", resolved.(Client).Name) stored, err := s.firstClientByID(t.Context(), id) require.NoError(t, err) assert.Equal(t, "Standard", stored.Name) assert.False(t, stored.IsMetadataDocument()) }) t.Run("no-store metadata is rejected and not persisted", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body resp.Header.Set("Cache-Control", "no-store") s := newMetadataStore(t, map[string]*http.Response{id: resp}) _, err := s.GetClient(t.Context(), id) require.ErrorIs(t, err, fosite.ErrInvalidClient) var count int64 require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error) assert.Zero(t, count) }) t.Run("private_key_jwt metadata is rejected and not persisted", func(t *testing.T) { privateKeyBody := `{"client_id":"https://8.8.8.8/oauth/client","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"private_key_jwt","jwks_uri":"https://8.8.4.4/jwks"}` //nolint:gosec // G101 false positive: authentication method name, not a credential resp := testutils.NewMockResponse(http.StatusOK, privateKeyBody) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}) _, err := s.GetClient(t.Context(), id) require.ErrorIs(t, err, fosite.ErrInvalidClient) var count int64 require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error) assert.Zero(t, count) }) t.Run("cached authenticated metadata client is rejected", func(t *testing.T) { s := newMetadataStore(t, nil) fresh := datatype.DateTime(time.Now().Add(time.Hour)) seed := model.OidcClient{ Base: model.Base{ID: id}, ClientType: model.OidcClientTypeCIMD, MetadataExpiresAt: &fresh, Credentials: model.OidcClientCredentials{FederatedIdentities: []model.OidcClientFederatedIdentity{{ Issuer: id, JWKS: "https://8.8.4.4/jwks", }}}, } require.NoError(t, s.db.Create(&seed).Error) _, err := s.GetClient(t.Context(), id) require.ErrorIs(t, err, fosite.ErrInvalidClient) }) t.Run("cached authenticated metadata client is replaced when the document becomes public", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}) fresh := datatype.DateTime(time.Now().Add(time.Hour)) seed := model.OidcClient{ Base: model.Base{ID: id}, ClientType: model.OidcClientTypeCIMD, MetadataExpiresAt: &fresh, Credentials: model.OidcClientCredentials{FederatedIdentities: []model.OidcClientFederatedIdentity{{ Issuer: id, JWKS: "https://8.8.4.4/jwks", }}}, } require.NoError(t, s.db.Create(&seed).Error) resolved, err := s.GetClient(t.Context(), id) require.NoError(t, err) client := resolved.(Client) assert.True(t, client.OidcClient.IsPublic) assert.True(t, client.PkceEnabled) assert.Empty(t, client.Credentials.FederatedIdentities) }) t.Run("fetches, upserts, and reuses the cache", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body resp.Header.Set("Cache-Control", "max-age=600") s := newMetadataStore(t, map[string]*http.Response{id: resp}) fc, err := s.GetClient(t.Context(), id) require.NoError(t, err) c := fc.(Client).OidcClient assert.Equal(t, id, c.ID) assert.True(t, c.IsMetadataDocument()) assert.True(t, c.IsPublic) require.NotNil(t, c.MetadataExpiresAt) var count int64 require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error) assert.Equal(t, int64(1), count) fc2, err := s.GetClient(t.Context(), id) require.NoError(t, err) assert.Equal(t, "App", fc2.(Client).Name) }) // A display-only change must not cost the user their consent t.Run("refetch when stale preserves consent", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}) stale := datatype.DateTime(time.Now().Add(-time.Hour)) seed := model.OidcClient{ Base: model.Base{ID: id}, Name: "Old", IsPublic: true, PkceEnabled: true, ClientType: model.OidcClientTypeCIMD, CallbackURLs: datatype.StringList{"https://app/cb"}, MetadataExpiresAt: &stale, } require.NoError(t, s.db.Create(&seed).Error) require.NoError(t, s.db.Exec( "INSERT INTO user_authorized_oidc_clients (client_id, user_id, scope, last_used_at) VALUES (?, ?, ?, ?)", id, "user-1", "openid", time.Now()).Error) fc, err := s.GetClient(t.Context(), id) require.NoError(t, err) assert.Equal(t, "App", fc.(Client).Name) var consent int64 require.NoError(t, s.db.Table("user_authorized_oidc_clients"). Where("client_id = ?", id).Count(&consent).Error) assert.Equal(t, int64(1), consent) }) // Whoever controls the document could otherwise repoint an already-consented user's authorization code at a URL of their choosing, with no prompt t.Run("changed redirect_uris revoke consent", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}) stale := datatype.DateTime(time.Now().Add(-time.Hour)) seed := model.OidcClient{ Base: model.Base{ID: id}, Name: "App", IsPublic: true, PkceEnabled: true, ClientType: model.OidcClientTypeCIMD, CallbackURLs: datatype.StringList{"https://app/previous-cb"}, MetadataExpiresAt: &stale, } require.NoError(t, s.db.Create(&seed).Error) require.NoError(t, s.db.Exec( "INSERT INTO user_authorized_oidc_clients (client_id, user_id, scope, last_used_at) VALUES (?, ?, ?, ?)", id, "user-1", "openid", time.Now()).Error) _, err := s.GetClient(t.Context(), id) require.NoError(t, err) var consent int64 require.NoError(t, s.db.Table("user_authorized_oidc_clients"). Where("client_id = ?", id).Count(&consent).Error) assert.Zero(t, consent, "consent must not survive a redirect_uris change") }) t.Run("failed consent revocation rolls back refreshed metadata", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}) stale := datatype.DateTime(time.Now().Add(-time.Hour)) seed := model.OidcClient{ Base: model.Base{ID: id}, Name: "App", IsPublic: true, PkceEnabled: true, ClientType: model.OidcClientTypeCIMD, CallbackURLs: datatype.StringList{"https://app/previous-cb"}, MetadataExpiresAt: &stale, } require.NoError(t, s.db.Create(&seed).Error) require.NoError(t, s.db.Exec( "INSERT INTO user_authorized_oidc_clients (client_id, user_id, scope, last_used_at) VALUES (?, ?, ?, ?)", id, "user-1", "openid", time.Now()).Error) require.NoError(t, s.db.Exec(` CREATE TRIGGER reject_cimd_consent_delete BEFORE DELETE ON user_authorized_oidc_clients BEGIN SELECT RAISE(ABORT, 'consent deletion blocked'); END; `).Error) _, err := s.GetClient(t.Context(), id) require.Error(t, err) stored, err := s.firstClientByID(t.Context(), id) require.NoError(t, err) assert.Equal(t, []string{"https://app/previous-cb"}, []string(stored.CallbackURLs)) var consent int64 require.NoError(t, s.db.Table("user_authorized_oidc_clients").Where("client_id = ?", id).Count(&consent).Error) assert.Equal(t, int64(1), consent) }) } // Pocket ID matches administrator-registered callback URLs as wildcard patterns, so a self-asserted "*" would otherwise match every redirect URI in existence func TestBuildClientFromMetadata_RejectsPatternRedirectURIs(t *testing.T) { const id = "https://app.example.com/oauth/client" unsupportedByPocketID := []string{ "*", "https://*.example.com/cb", "javascript:alert(1)", } for _, uri := range unsupportedByPocketID { t.Run("redirect_uris "+uri, func(t *testing.T) { doc := &fosite.ClientMetadataDocument{ ClientID: id, RedirectURIs: []string{uri}, TokenEndpointAuthMethod: "none", } _, err := buildClientFromMetadata(doc, id) require.Error(t, err) }) } // post_logout_redirect_uris go through the same validation doc := &fosite.ClientMetadataDocument{ ClientID: id, RedirectURIs: []string{"https://app.example.com/cb"}, PostLogoutRedirectURIs: []string{"*"}, TokenEndpointAuthMethod: "none", } _, err := buildClientFromMetadata(doc, id) require.Error(t, err) } func TestMatchRedirectURI_MetadataClientExactMatchSucceeds(t *testing.T) { metadataClient := Client{OidcClient: model.OidcClient{ Base: model.Base{ID: "https://app.example.com/oauth/client"}, ClientType: model.OidcClientTypeCIMD, CallbackURLs: datatype.StringList{"https://app.example.com/callback"}, }} matched, err := matchRedirectURI("https://app.example.com/callback", metadataClient) require.NoError(t, err) require.NotNil(t, matched) assert.Equal(t, "https://app.example.com/callback", matched.String()) } // A document declaring only authorization_code must not silently receive refresh_token and device_code func TestClient_DeclaredCapabilitiesAreEnforced(t *testing.T) { t.Run("grant types are restricted to the declaration", func(t *testing.T) { client := Client{OidcClient: model.OidcClient{ ClientType: model.OidcClientTypeCIMD, IsPublic: true, MetadataGrantTypes: datatype.StringList{"authorization_code"}, }} assert.Equal(t, fosite.Arguments{"authorization_code"}, client.GetGrantTypes()) }) t.Run("declared refresh_token is honoured", func(t *testing.T) { client := Client{OidcClient: model.OidcClient{ ClientType: model.OidcClientTypeCIMD, IsPublic: true, MetadataGrantTypes: datatype.StringList{"authorization_code", "refresh_token"}, }} assert.Equal(t, fosite.Arguments{"authorization_code", "refresh_token"}, client.GetGrantTypes()) }) t.Run("an empty declaration uses the RFC default", func(t *testing.T) { client := Client{OidcClient: model.OidcClient{ClientType: model.OidcClientTypeCIMD, IsPublic: true}} assert.Equal(t, fosite.Arguments{"authorization_code"}, client.GetGrantTypes()) }) t.Run("registered clients are unaffected", func(t *testing.T) { client := Client{OidcClient: model.OidcClient{ ClientType: model.OidcClientTypeStandard, IsPublic: true, MetadataGrantTypes: datatype.StringList{"authorization_code"}, }} assert.Contains(t, client.GetGrantTypes(), "refresh_token") }) } func TestBuildClientFromMetadata_RecordsDeclaredCapabilities(t *testing.T) { const id = "https://app.example.com/oauth/client" t.Run("declared values are recorded", func(t *testing.T) { doc := &fosite.ClientMetadataDocument{ ClientID: id, RedirectURIs: []string{"https://app.example.com/cb"}, TokenEndpointAuthMethod: "none", GrantTypes: []string{"authorization_code", "refresh_token"}, } client, err := buildClientFromMetadata(doc, id) require.NoError(t, err) assert.Equal(t, datatype.StringList{"authorization_code", "refresh_token"}, client.MetadataGrantTypes) }) t.Run("omitted grant_types defaults to authorization_code", func(t *testing.T) { doc := &fosite.ClientMetadataDocument{ ClientID: id, RedirectURIs: []string{"https://app.example.com/cb"}, TokenEndpointAuthMethod: "none", } client, err := buildClientFromMetadata(doc, id) require.NoError(t, err) assert.Equal(t, datatype.StringList{"authorization_code"}, client.MetadataGrantTypes) }) } // The allowlist is the operator's only gate on which URLs may become clients, and it is matched with the same wildcard syntax as callback URLs func TestCIMDURLAllowlist_HostilePatterns(t *testing.T) { const id = "https://8.8.8.8/oauth/client" body := `{"client_id":"https://8.8.8.8/oauth/client","client_name":"App","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"none"}` denied := []struct { name string allowlist []string }{ {"empty list denies", nil}, {"different host denies", []string{"https://other.example.com/**"}}, {"different scheme denies", []string{"http://8.8.8.8/**"}}, {"host as a path segment denies", []string{"https://evil.example/8.8.8.8/**"}}, {"prefix of the host denies", []string{"https://8.8.8.8.evil.example/**"}}, } for _, tc := range denied { t.Run(tc.name, func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return tc.allowlist }) _, err := s.GetClient(t.Context(), id) require.ErrorIs(t, err, fosite.ErrInvalidClient) var count int64 require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error) assert.Zero(t, count, "a denied client must never be persisted") }) } // "*" matches everything, which is the fully open configuration and the operator's choice rather than a bug t.Run("bare wildcard allows everything", func(t *testing.T) { resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return []string{"*"} }) _, err := s.GetClient(t.Context(), id) require.NoError(t, err) }) } // Section 3 constrains the Client Identifier URL, and none of these vectors may reach a fetch func TestGetClient_HostileClientIDURLs(t *testing.T) { hostile := []string{ "http://8.8.8.8/oauth/client", // not https "https://8.8.8.8", // no path component "https://user:pass@8.8.8.8/oauth/client", // userinfo "https://8.8.8.8/oauth/client#frag", // fragment "https://8.8.8.8/oauth/client?x=1", // query component "https://8.8.8.8/oauth/../client", // dot segments "https://127.0.0.1/oauth/client", // loopback "https://169.254.169.254/latest/meta-data", // cloud metadata } for _, id := range hostile { t.Run(id, func(t *testing.T) { s := newMetadataStore(t, nil) _, err := s.GetClient(t.Context(), id) require.Error(t, err, "must not be accepted as a Client Identifier URL") var count int64 require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error) assert.Zero(t, count) }) } } // Section 5.2 forbids caching error responses or invalid documents, and section 5 requires every non-200 status to be treated as an error func TestGetClient_MetadataFailuresAreNotPersisted(t *testing.T) { const id = "https://8.8.8.8/oauth/client" cases := []struct { name string response *http.Response }{ {"404", testutils.NewMockResponse(http.StatusNotFound, `{}`)}, //nolint:bodyclose // mock {"500", testutils.NewMockResponse(http.StatusInternalServerError, `{}`)}, //nolint:bodyclose // mock {"204", testutils.NewMockResponse(http.StatusNoContent, ``)}, //nolint:bodyclose // mock {"invalid JSON", testutils.NewMockResponse(http.StatusOK, `{not json`)}, //nolint:bodyclose // mock {"truncated JSON", testutils.NewMockResponse(http.StatusOK, `{"client_id":`)}, //nolint:bodyclose // mock {"empty body", testutils.NewMockResponse(http.StatusOK, ``)}, //nolint:bodyclose // mock {"null body", testutils.NewMockResponse(http.StatusOK, `null`)}, //nolint:bodyclose // mock {"client_id mismatch", testutils.NewMockResponse(http.StatusOK, `{"client_id":"https://evil/x"}`)}, //nolint:bodyclose // mock {"wrong client_id type", testutils.NewMockResponse(http.StatusOK, `{"client_id":123}`)}, //nolint:bodyclose // mock {"oversize document", testutils.NewMockResponse(http.StatusOK, `{"client_id":"https://8.8.8.8/oauth/client","padding":"`+strings.Repeat("a", 6*1024)+`"}`)}, //nolint:bodyclose // mock } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s := newMetadataStore(t, map[string]*http.Response{id: tc.response}) _, err := s.GetClient(t.Context(), id) require.Error(t, err) var count int64 require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error) assert.Zero(t, count, "a failed or invalid document must never be cached") }) } t.Run("no response at all", func(t *testing.T) { s := newMetadataStore(t, nil) _, err := s.GetClient(t.Context(), id) require.Error(t, err) }) }