mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-21 00:59:04 +02:00
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
co-authored by
Elias Schneider
parent
7c55bdf115
commit
1934efa84c
@@ -0,0 +1,236 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type cimdResolverConfig struct {
|
||||
getURLAllowlist func() []string
|
||||
transport http.RoundTripper
|
||||
transportDecorator func(http.RoundTripper) http.RoundTripper
|
||||
}
|
||||
|
||||
type cimdClientResolver struct {
|
||||
resolver *fosite.CIMDResolver
|
||||
store *Store
|
||||
policy cimdPolicy
|
||||
}
|
||||
|
||||
var _ fosite.ClientResolver = (*cimdClientResolver)(nil)
|
||||
var _ fosite.CIMDClientPolicy = cimdPolicy{}
|
||||
|
||||
func newCIMDClientResolver(store *Store, config cimdResolverConfig) *cimdClientResolver {
|
||||
options := []fosite.CIMDFetcherOption{
|
||||
fosite.WithCIMDUserAgent("pocket-id/oidc-client-metadata-fetcher"),
|
||||
fosite.WithCIMDExtraPrivateRanges(utils.LocalIPv6IPNets()),
|
||||
}
|
||||
if config.transport != nil {
|
||||
options = append(options, fosite.WithCIMDTransport(config.transport))
|
||||
}
|
||||
if config.transportDecorator != nil {
|
||||
options = append(options, fosite.WithCIMDTransportDecorator(config.transportDecorator))
|
||||
}
|
||||
|
||||
policy := cimdPolicy{getURLAllowlist: config.getURLAllowlist}
|
||||
return &cimdClientResolver{
|
||||
resolver: &fosite.CIMDResolver{
|
||||
Fetcher: fosite.NewDefaultCIMDFetcher(options...),
|
||||
Cache: store,
|
||||
Materializer: store,
|
||||
Policy: policy,
|
||||
MaxConcurrentDiscoveries: 10,
|
||||
},
|
||||
store: store,
|
||||
policy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *cimdClientResolver) ResolveClient(ctx context.Context, clientID string, next fosite.ClientLookupFunc) (fosite.Client, error) {
|
||||
if next == nil {
|
||||
return nil, errors.New("registered client resolver is required")
|
||||
}
|
||||
|
||||
// Exclude persisted metadata clients so Fosite can apply its cache policy while still giving real registrations precedence
|
||||
registeredOnly := func(ctx context.Context, clientID string) (fosite.Client, error) {
|
||||
client, err := next(ctx, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pocketIDClient, ok := client.(Client); ok && pocketIDClient.IsMetadataDocument() {
|
||||
return nil, fosite.ErrNotFound
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
return r.resolver.ResolveClient(ctx, clientID, registeredOnly)
|
||||
}
|
||||
|
||||
// RefreshMetadataClient forces a re-fetch of the metadata document for an already-cached CIMD client, bypassing the cache TTL
|
||||
func (r *cimdClientResolver) RefreshMetadataClient(ctx context.Context, id string) (model.OidcClient, error) {
|
||||
if !fosite.LooksLikeCIMDURL(id) {
|
||||
return model.OidcClient{}, errors.New("client is not a client ID metadata document client")
|
||||
}
|
||||
if err := r.policy.AllowCIMDClient(ctx, id); err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
existing, err := r.store.firstClientByID(ctx, id)
|
||||
if err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
if !existing.IsMetadataDocument() {
|
||||
return model.OidcClient{}, errors.New("client is not a client ID metadata document client")
|
||||
}
|
||||
client, err := r.resolver.RefreshClient(ctx, id)
|
||||
if err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
pocketIDClient, ok := client.(Client)
|
||||
if !ok {
|
||||
return model.OidcClient{}, errors.New("metadata resolver returned an incompatible client")
|
||||
}
|
||||
return pocketIDClient.OidcClient, nil
|
||||
}
|
||||
|
||||
type cimdPolicy struct {
|
||||
getURLAllowlist func() []string
|
||||
}
|
||||
|
||||
func (p cimdPolicy) cimdURLAllowed(id string) bool {
|
||||
if p.getURLAllowlist == nil {
|
||||
return false
|
||||
}
|
||||
return utils.MatchesAnyURLPattern(p.getURLAllowlist(), id)
|
||||
}
|
||||
|
||||
// AllowCIMDClient applies Pocket ID's operator-managed dynamic-client allowlist
|
||||
func (p cimdPolicy) AllowCIMDClient(_ context.Context, id string) error {
|
||||
if !p.cimdURLAllowed(id) {
|
||||
return errors.New("client ID is not in the metadata document allowlist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCIMDClient restricts generic CIMD features to those supported by Pocket ID's client model
|
||||
func (cimdPolicy) ValidateCIMDClient(_ context.Context, doc *fosite.ClientMetadataDocument) error {
|
||||
// Require public-client authentication because Pocket ID does not persist CIMD key material
|
||||
switch doc.TokenEndpointAuthMethod {
|
||||
case "none":
|
||||
default:
|
||||
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")
|
||||
}
|
||||
|
||||
// Pocket ID only implements the code response type for metadata clients
|
||||
responseTypes := doc.ResponseTypes
|
||||
if len(responseTypes) == 0 {
|
||||
responseTypes = []string{"code"}
|
||||
}
|
||||
for _, responseType := range responseTypes {
|
||||
if responseType != "code" {
|
||||
return fmt.Errorf("client metadata document contains unsupported response_type %q", responseType)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMetadataRedirectURIs rejects self-asserted redirect URIs Pocket ID must not accept
|
||||
func validateMetadataRedirectURIs(field string, uris []string) error {
|
||||
for _, raw := range uris {
|
||||
if strings.Contains(raw, "*") {
|
||||
return fmt.Errorf("%s entry %q must not contain a wildcard", field, raw)
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s entry %q is not a valid URL: %w", field, raw, err)
|
||||
}
|
||||
if !u.IsAbs() {
|
||||
return fmt.Errorf("%s entry %q must be an absolute URL", field, raw)
|
||||
}
|
||||
// Mirrors the scheme restriction every administrator-registered callback URL passes
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "javascript", "data":
|
||||
return fmt.Errorf("%s entry %q uses a disallowed scheme", field, raw)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildClientFromMetadata applies Pocket ID's persisted-client projection to validated generic metadata
|
||||
func buildClientFromMetadata(doc *fosite.ClientMetadataDocument, rawURL string) (model.OidcClient, error) {
|
||||
if err := validateMetadataRedirectURIs("redirect_uris", doc.RedirectURIs); err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
if err := validateMetadataRedirectURIs("post_logout_redirect_uris", doc.PostLogoutRedirectURIs); err != nil {
|
||||
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"}
|
||||
}
|
||||
|
||||
client := model.OidcClient{
|
||||
Base: model.Base{ID: rawURL},
|
||||
Name: doc.ClientName,
|
||||
CallbackURLs: datatype.StringList(doc.RedirectURIs),
|
||||
LogoutCallbackURLs: datatype.StringList(doc.PostLogoutRedirectURIs),
|
||||
ClientType: model.OidcClientTypeCIMD,
|
||||
MetadataGrantTypes: datatype.StringList(grantTypes),
|
||||
}
|
||||
|
||||
switch doc.TokenEndpointAuthMethod {
|
||||
case "none":
|
||||
client.IsPublic = true
|
||||
client.PkceEnabled = true
|
||||
default:
|
||||
return model.OidcClient{}, fmt.Errorf("client metadata documents only support token_endpoint_auth_method %q, got %q", "none", doc.TokenEndpointAuthMethod)
|
||||
}
|
||||
|
||||
if client.Name == "" {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
client.Name = u.Host
|
||||
}
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// MaterializeCIMDClient converts validated generic metadata into Pocket ID's runtime client
|
||||
func (s *Store) MaterializeCIMDClient(_ context.Context, doc *fosite.ClientMetadataDocument) (fosite.Client, error) {
|
||||
client, err := buildClientFromMetadata(doc, doc.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Client{OidcClient: client}, nil
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
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)
|
||||
})
|
||||
|
||||
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("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: "client credentials is rejected", grantTypes: []string{"client_credentials"}, wantError: "unsupported grant_type"},
|
||||
{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}
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
@@ -33,7 +35,22 @@ func (c Client) GetGrantTypes() fosite.Arguments {
|
||||
if !c.IsPublic() {
|
||||
grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
|
||||
}
|
||||
return grantTypes
|
||||
|
||||
if !c.IsMetadataDocument() {
|
||||
return grantTypes
|
||||
}
|
||||
if len(c.MetadataGrantTypes) == 0 {
|
||||
return fosite.Arguments{string(fosite.GrantTypeAuthorizationCode)}
|
||||
}
|
||||
|
||||
// If the client is a CIMD client, we need to filter the grant types based on the metadata document.
|
||||
allowed := make(fosite.Arguments, 0, len(c.MetadataGrantTypes))
|
||||
for _, value := range c.MetadataGrantTypes {
|
||||
if slices.Contains([]string(grantTypes), value) {
|
||||
allowed = append(allowed, value)
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func (c Client) GetResponseTypes() fosite.Arguments {
|
||||
|
||||
@@ -212,6 +212,7 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID
|
||||
HasDarkLogo: client.HasDarkLogo(),
|
||||
LaunchURL: client.LaunchURL,
|
||||
RequiresReauthentication: client.RequiresReauthentication,
|
||||
ClientType: string(client.ClientType),
|
||||
},
|
||||
Scope: scope,
|
||||
ScopeInfo: scopeInfo,
|
||||
|
||||
@@ -157,7 +157,7 @@ func newTestDeviceService(t *testing.T, clientID, userID string, requiresReauthe
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
claimsService := newClaimsService(db, nil, "", nil)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -25,7 +26,7 @@ import (
|
||||
// would be an open redirect.
|
||||
func TestLogoutCallbackURL(t *testing.T) {
|
||||
noURLs := &model.OidcClient{Base: model.Base{ID: "c"}}
|
||||
withURLs := &model.OidcClient{Base: model.Base{ID: "c"}, LogoutCallbackURLs: model.UrlList{
|
||||
withURLs := &model.OidcClient{Base: model.Base{ID: "c"}, LogoutCallbackURLs: datatype.StringList{
|
||||
"https://app.example/logout",
|
||||
"https://app.example/logout2",
|
||||
"https://*.example/logout",
|
||||
@@ -130,7 +131,7 @@ func TestEndSessionService(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
LogoutCallbackURLs: model.UrlList{"https://app.example/logout"},
|
||||
LogoutCallbackURLs: datatype.StringList{"https://app.example/logout"},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID}).Error)
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) {
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
issueAccessToken := func(t *testing.T, requestID, clientID, subject string) string {
|
||||
@@ -146,7 +146,7 @@ func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T)
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
session := NewEmptySession()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,8 @@ type Dependencies struct {
|
||||
Config Config
|
||||
HTTPClient *http.Client
|
||||
|
||||
GetCIMDURLAllowlist func() []string
|
||||
|
||||
Signer TokenSigner
|
||||
CustomClaims CustomClaimSource
|
||||
Reauth ReauthenticationTokenConsumer
|
||||
@@ -52,8 +55,9 @@ type Dependencies struct {
|
||||
type Module struct {
|
||||
Preview *ClientPreviewBuilder
|
||||
|
||||
config Config
|
||||
store *Store
|
||||
config Config
|
||||
store *Store
|
||||
cimdResolver *cimdClientResolver
|
||||
|
||||
authorizationHandler *authorizationHandler
|
||||
tokenHandler *tokenHandler
|
||||
@@ -66,11 +70,19 @@ type Module struct {
|
||||
|
||||
func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
store := NewStore(deps.DB, deps.APIAccess).WithIssuer(deps.Config.BaseURL)
|
||||
cimdResolver := newCIMDClientResolver(store, cimdResolverConfig{
|
||||
getURLAllowlist: deps.GetCIMDURLAllowlist,
|
||||
transportDecorator: func(transport http.RoundTripper) http.RoundTripper {
|
||||
return otelhttp.NewTransport(transport)
|
||||
},
|
||||
})
|
||||
store.clientResolver = cimdResolver
|
||||
|
||||
authenticator, err := newFederatedClientAuthenticator(ctx, store, deps.HTTPClient, deps.Config.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create federated client authenticator: %w", err)
|
||||
}
|
||||
provider, err := newProvider(store, authenticator, deps.Signer, deps.Config)
|
||||
provider, err := newProvider(store, authenticator, deps.Signer, deps.Config, cimdResolver)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth2 provider: %w", err)
|
||||
}
|
||||
@@ -85,8 +97,9 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
return &Module{
|
||||
Preview: previewBuilder,
|
||||
|
||||
config: deps.Config,
|
||||
store: store,
|
||||
config: deps.Config,
|
||||
store: store,
|
||||
cimdResolver: cimdResolver,
|
||||
|
||||
authorizationHandler: newAuthorizationHandler(provider, authorizationService, deps.Config.BaseURL),
|
||||
tokenHandler: newTokenHandler(provider, claimsService, deps.APIAccess),
|
||||
@@ -98,6 +111,11 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshClientMetadata forces a re-fetch of the OAuth Client ID Metadata Document.
|
||||
func (m *Module) RefreshClientMetadata(ctx context.Context, clientID string) (model.OidcClient, error) {
|
||||
return m.cimdResolver.RefreshMetadataClient(ctx, clientID)
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, browserAuth gin.HandlerFunc) {
|
||||
rootGroup.GET("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
|
||||
rootGroup.POST("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
|
||||
@@ -70,7 +70,7 @@ func TestClientPreviewBuilderIgnoresUnknownScopes(t *testing.T) {
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
|
||||
@@ -29,7 +29,7 @@ type tokenStrategies struct {
|
||||
config *fosite.Config
|
||||
}
|
||||
|
||||
func newProvider(store *Store, authenticator *federatedClientAuthenticator, signer TokenSigner, config Config) (*oidcProvider, error) {
|
||||
func newProvider(store *Store, authenticator *federatedClientAuthenticator, signer TokenSigner, config Config, clientResolver fosite.ClientResolver) (*oidcProvider, error) {
|
||||
secret, err := DeriveGlobalSecret(config.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -56,6 +56,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
RefreshTokenScopes: []string{},
|
||||
GlobalSecret: secret,
|
||||
JWTScopeClaimKey: jwt.JWTScopeFieldBoth,
|
||||
ClientResolver: clientResolver,
|
||||
}
|
||||
|
||||
keyGetter := func(context.Context) (interface{}, error) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/ory/fosite"
|
||||
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
|
||||
"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"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -61,7 +62,7 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) {
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
session := NewEmptySession()
|
||||
@@ -135,7 +136,7 @@ func TestProviderInsecureCallbackURLCompatibility(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"http://client.example.com/callback"},
|
||||
CallbackURLs: datatype.StringList{"http://client.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
@@ -143,7 +144,7 @@ func TestProviderInsecureCallbackURLCompatibility(t *testing.T) {
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
AllowInsecureCallbackURLs: tt.allowInsecureCallbackURLs,
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
@@ -172,14 +173,14 @@ func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://*.example.com/callback"},
|
||||
CallbackURLs: datatype.StringList{"https://*.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
const requestedRedirectURI = "https://tenant.example.com/callback"
|
||||
@@ -204,7 +205,7 @@ func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://*.example.com/callback"},
|
||||
CallbackURLs: datatype.StringList{"https://*.example.com/callback"},
|
||||
IsPublic: true,
|
||||
}).Error)
|
||||
|
||||
@@ -212,7 +213,7 @@ func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
const requestedRedirectURI = "https://tenant.example.com/callback"
|
||||
@@ -237,14 +238,14 @@ func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://*.example.com/callback"},
|
||||
CallbackURLs: datatype.StringList{"https://*.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
const requestedRedirectURI = "https://evil.example.net/callback"
|
||||
@@ -277,14 +278,14 @@ func TestProviderAcceptsUnsignedRequestObject(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://client.example.com/callback"},
|
||||
CallbackURLs: datatype.StringList{"https://client.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requestObject := encodeRequestObject(t,
|
||||
@@ -318,14 +319,14 @@ func TestProviderRejectsSignedRequestObject(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://client.example.com/callback"},
|
||||
CallbackURLs: datatype.StringList{"https://client.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The signature is never verified: the request object must already be rejected because only
|
||||
@@ -401,7 +402,7 @@ func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) {
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
session := NewEmptySession()
|
||||
@@ -464,14 +465,14 @@ func TestProviderIgnoresUnknownScopes(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://app.example.com/callback"},
|
||||
CallbackURLs: datatype.StringList{"https://app.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clients such as MCP clients blindly request scopes Pocket ID does not support, like
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
@@ -48,9 +51,10 @@ func NewStore(db *gorm.DB, apiAccess APIAccessProvider) *Store {
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
apiAccess APIAccessProvider
|
||||
issuer string
|
||||
db *gorm.DB
|
||||
apiAccess APIAccessProvider
|
||||
issuer string
|
||||
clientResolver fosite.ClientResolver
|
||||
}
|
||||
|
||||
// WithIssuer sets the issuer that is added as an extra audience to access tokens carrying an identity scope, so they can be presented to Pocket ID's own endpoints such as /userinfo
|
||||
@@ -115,6 +119,169 @@ func (s *Store) GetClient(ctx context.Context, id string) (fosite.Client, error)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// resolvePersistedClient restores a client from storage and falls back to the configured generic resolver for uncached clients
|
||||
func (s *Store) resolvePersistedClient(ctx context.Context, id string) (fosite.Client, error) {
|
||||
if s.clientResolver != nil {
|
||||
return s.clientResolver.ResolveClient(ctx, id, s.GetClient)
|
||||
}
|
||||
return s.GetClient(ctx, id)
|
||||
}
|
||||
|
||||
// clientFromModel populates the provider-specific runtime fields on a stored client
|
||||
func (s *Store) clientFromModel(ctx context.Context, tx *gorm.DB, clientModel model.OidcClient) (Client, error) {
|
||||
client := Client{OidcClient: clientModel}
|
||||
|
||||
// Populate the custom-API scopes and audiences the client may request only when the API feature is wired
|
||||
if s.apiAccess != nil {
|
||||
apiScopes, apiAudiences, err := s.apiAccess.ClientAPIScopes(ctx, tx, clientModel.ID)
|
||||
if err != nil {
|
||||
return Client{}, err
|
||||
}
|
||||
|
||||
client.apiScopes = apiScopes
|
||||
client.apiAudiences = apiAudiences
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// LoadCIMDClient loads only clients that Pocket ID previously associated with a metadata document
|
||||
func (s *Store) LoadCIMDClient(ctx context.Context, id string) (fosite.CIMDCachedClient, bool, error) {
|
||||
clientModel, err := s.firstClientByID(ctx, id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fosite.CIMDCachedClient{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return fosite.CIMDCachedClient{}, false, err
|
||||
}
|
||||
if !clientModel.IsMetadataDocument() {
|
||||
return fosite.CIMDCachedClient{}, false, nil
|
||||
}
|
||||
|
||||
client, err := s.clientFromModel(ctx, s.dbFor(ctx), clientModel)
|
||||
if err != nil {
|
||||
return fosite.CIMDCachedClient{}, false, err
|
||||
}
|
||||
var expiresAt time.Time
|
||||
if clientModel.MetadataExpiresAt != nil {
|
||||
expiresAt = time.Time(*clientModel.MetadataExpiresAt)
|
||||
}
|
||||
// Force incompatible cached entries through discovery so current policy applies before they can be used
|
||||
if !clientModel.IsPublic || !clientModel.PkceEnabled || len(clientModel.Credentials.FederatedIdentities) > 0 {
|
||||
expiresAt = time.Time{}
|
||||
}
|
||||
return fosite.CIMDCachedClient{Client: client, ExpiresAt: expiresAt}, true, nil
|
||||
}
|
||||
|
||||
// StoreCIMDClient persists metadata-derived fields while preserving local consent and policy state
|
||||
func (s *Store) StoreCIMDClient(ctx context.Context, resolved fosite.Client, _ *fosite.ClientMetadataDocument, expiresAt time.Time) (fosite.Client, error) {
|
||||
client, ok := resolved.(Client)
|
||||
if !ok {
|
||||
return nil, errors.New("metadata resolver returned an incompatible client")
|
||||
}
|
||||
expiry := datatype.DateTime(expiresAt)
|
||||
client.MetadataExpiresAt = &expiry
|
||||
|
||||
var changes []string
|
||||
var revokeConsent bool
|
||||
var stored fosite.Client
|
||||
err := withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
existing, err := s.firstClientByID(ctx, client.ID)
|
||||
found := err == nil
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if found && !existing.IsMetadataDocument() {
|
||||
return errors.New("client is already registered without a metadata document")
|
||||
}
|
||||
if found {
|
||||
changes = metadataClientChanges(existing, client.OidcClient)
|
||||
// Every detected change except the display name invalidates what the user agreed to
|
||||
revokeConsent = slices.ContainsFunc(changes, func(field string) bool { return field != "client_name" })
|
||||
}
|
||||
|
||||
// Persist the refreshed metadata and consent invalidation together so a failed deletion cannot suppress the next revocation attempt
|
||||
if err := s.upsertMetadataClient(ctx, s.dbFor(ctx), &client.OidcClient, found); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A security-relevant document change invalidates consent because it changes what the user previously approved
|
||||
if revokeConsent {
|
||||
err := s.dbFor(ctx).
|
||||
Where("client_id = ?", client.ID).
|
||||
Delete(&model.UserAuthorizedOidcClient{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revoke consent after metadata change: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload inside the transaction so DB-managed columns and preloads are populated consistently
|
||||
stored, err = s.GetClient(ctx, client.ID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(changes) > 0 {
|
||||
slog.InfoContext(ctx, "Client metadata changed",
|
||||
slog.String("client_id", client.ID),
|
||||
slog.Any("changed_fields", changes),
|
||||
)
|
||||
}
|
||||
if revokeConsent {
|
||||
slog.WarnContext(ctx, "Revoked existing user consent after a security-relevant client metadata change",
|
||||
slog.String("client_id", client.ID),
|
||||
)
|
||||
}
|
||||
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// metadataClientChanges returns the names of security-relevant metadata fields that differ between the stored client and a freshly fetched one
|
||||
func metadataClientChanges(old, next model.OidcClient) []string {
|
||||
var changed []string
|
||||
if !slices.Equal([]string(old.CallbackURLs), next.CallbackURLs) {
|
||||
changed = append(changed, "redirect_uris")
|
||||
}
|
||||
if !slices.Equal([]string(old.LogoutCallbackURLs), next.LogoutCallbackURLs) {
|
||||
changed = append(changed, "post_logout_redirect_uris")
|
||||
}
|
||||
if old.IsPublic != next.IsPublic {
|
||||
changed = append(changed, "token_endpoint_auth_method")
|
||||
}
|
||||
if old.Name != next.Name {
|
||||
changed = append(changed, "client_name")
|
||||
}
|
||||
if !slices.Equal(effectiveMetadataGrantTypes(old.MetadataGrantTypes), effectiveMetadataGrantTypes(next.MetadataGrantTypes)) {
|
||||
changed = append(changed, "grant_types")
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func effectiveMetadataGrantTypes(grantTypes datatype.StringList) []string {
|
||||
if len(grantTypes) == 0 {
|
||||
return []string{string(fosite.GrantTypeAuthorizationCode)}
|
||||
}
|
||||
return grantTypes
|
||||
}
|
||||
|
||||
// upsertMetadataClient inserts a new managed client or updates the metadata-derived columns of an existing one, leaving consent, grants, and group links untouched
|
||||
func (s *Store) upsertMetadataClient(ctx context.Context, tx *gorm.DB, client *model.OidcClient, update bool) error {
|
||||
if !update {
|
||||
return tx.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).
|
||||
Create(client).Error
|
||||
}
|
||||
return tx.WithContext(ctx).
|
||||
Model(&model.OidcClient{Base: model.Base{ID: client.ID}}).
|
||||
Select("Name", "CallbackURLs", "LogoutCallbackURLs", "Credentials",
|
||||
"IsPublic", "PkceEnabled", "ClientType", "MetadataExpiresAt",
|
||||
"MetadataGrantTypes").
|
||||
Updates(client).Error
|
||||
}
|
||||
|
||||
func (s *Store) ClientAssertionJWTValid(ctx context.Context, jti string) error {
|
||||
var count int64
|
||||
err := s.dbFor(ctx).
|
||||
@@ -142,6 +309,18 @@ func (s *Store) SetClientAssertionJWT(ctx context.Context, jti string, exp time.
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) firstClientByID(ctx context.Context, id string) (model.OidcClient, error) {
|
||||
var client model.OidcClient
|
||||
err := s.dbFor(ctx).
|
||||
Preload("AllowedUserGroups").
|
||||
First(&client, "id = ?", id).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Satisfies fositeoauth2.CoreStorage
|
||||
|
||||
func (s *Store) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error {
|
||||
@@ -742,7 +921,7 @@ func (s *Store) decodeDeviceRequester(ctx context.Context, data string) (fosite.
|
||||
}
|
||||
|
||||
func (s *Store) requesterFromStored(ctx context.Context, stored storedRequester) (fosite.Requester, error) {
|
||||
client, err := s.GetClient(ctx, stored.ClientID)
|
||||
client, err := s.resolvePersistedClient(ctx, stored.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestTokenHandlerClientCredentialsDropsIdentityScopes(t *testing.T) {
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
||||
|
||||
@@ -183,7 +183,7 @@ func TestTokenHandlerClientCredentialsUsesClientSubjectGrants(t *testing.T) {
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
|
||||
|
||||
@@ -256,7 +256,7 @@ func TestTokenHandlerClientCredentialsDefaultsResourceScopes(t *testing.T) {
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
|
||||
|
||||
@@ -411,7 +411,7 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
||||
|
||||
@@ -555,7 +555,7 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestUserInfoHandler(t *testing.T) {
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil), baseURL)
|
||||
|
||||
Reference in New Issue
Block a user