diff --git a/federation/automatic_registration.go b/federation/automatic_registration.go new file mode 100644 index 0000000..8ea6320 --- /dev/null +++ b/federation/automatic_registration.go @@ -0,0 +1,351 @@ +package federation + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/internal/jose" + "github.com/idfoundry/fapigo/keys" + "github.com/idfoundry/fapigo/storage" +) + +// relyingPartyEntityType is the Entity Type Identifier a Relying +// Party's own client registration metadata is published under (OpenID +// Federation 1.0 §5.1), read from Resolver.Resolve's own +// ResolvedEntity.Metadata. +const relyingPartyEntityType = "openid_relying_party" + +// AutomaticRegistrationConfig configures AutomaticClientRepository — +// see its own doc comment for the mechanism this implements (OpenID +// Federation 1.0 §12.1, "Automatic Registration"). +type AutomaticRegistrationConfig struct { + // AllowedScopes is the scope allowlist granted to every + // automatically-registered client, uniformly. This deliberately + // does NOT come from the Relying Party's own self-published + // metadata: an RP's openid_relying_party metadata is + // attacker-controlled (any federation member publishes its own), so + // trusting a self-asserted "these are the scopes I'm allowed" claim + // would let any RP grant itself unlimited scope. Required — at + // least one. + AllowedScopes []string + + // MaxCacheAge bounds how long a resolved client's Trust Chain is + // reused before it is resolved again — the actual cache lifetime + // for a given client is min(MaxCacheAge, its own Trust Chain's + // ResolvedEntity.ExpiresAt), never longer than the chain itself + // remains valid. Required — must be positive. + MaxCacheAge time.Duration +} + +// cachedClient is one Relying Party's resolved registration, cached +// until whichever of AutomaticRegistrationConfig.MaxCacheAge or the +// Trust Chain's own ResolvedEntity.ExpiresAt comes first. +type cachedClient struct { + client storage.RegisteredClient + jwks json.RawMessage + expiresAt time.Time +} + +// AutomaticClientRepository implements storage.ClientRepository via +// OpenID Federation 1.0 §12.1 ("Automatic Registration"): a Relying +// Party that presents its own Entity Identifier as client_id, with no +// prior registration step, is resolved on demand — its Trust Chain is +// walked (via Resolver) and its Resolved Metadata's own +// openid_relying_party object becomes this client's registration, +// exactly as if an operator had configured it via +// storage.NewRegisteredClient by hand. +// +// Static registration always wins: Underlying is tried first, and its +// result returned as-is when it succeeds — federation resolution is +// only attempted when Underlying fails to resolve id AND id is itself +// a well-formed https Entity Identifier (ValidEntityID). Underlying's +// own interface has no way to distinguish "no such client" from a real +// backing-store failure (storage.ClientRepository's own contract), so +// a transient Underlying failure for an id that happens to look like +// an Entity Identifier is indistinguishable from "not statically +// registered" here — an inherent limitation of that interface, not +// something this type can work around. +// +// See doc.go for what this first version deliberately does not +// implement: every RFC 8705 mTLS client authentication method +// (ClientAuthMethodSelfSignedTLSClientAuth and its siblings — verifying +// these correctly needs the "x5c" member of the client's own published +// JWK, which internal/jose's JWK Set parsing does not currently +// preserve, so only ClientAuthMethodPrivateKeyJWT is supported), remote +// jwks_uri (only an inline "jwks" object in the Resolved Metadata is +// read), the client_credentials grant and CIBA (neither is permitted +// for an automatically-registered client in this version), and +// request_uri/JAR/PAR-level enforcement of OpenID Federation 1.0 +// §12.1.1's own aud/sub/jti Request Object rules (a request-handling +// concern, not a client registration one). +type AutomaticClientRepository struct { + underlying storage.ClientRepository + resolver *Resolver + cfg AutomaticRegistrationConfig + clock Clock + + mu sync.Mutex + cache map[fapi.ClientID]cachedClient +} + +// NewAutomaticClientRepository validates cfg and returns an +// AutomaticClientRepository. underlying, resolver and clock must all be +// non-nil — there is no implicit fallback. Pass an underlying that +// always fails (e.g. one backed by an empty in-memory store) to get +// automatic-registration-only behavior, with no statically registered +// clients at all. +func NewAutomaticClientRepository(underlying storage.ClientRepository, resolver *Resolver, cfg AutomaticRegistrationConfig, clock Clock) (*AutomaticClientRepository, error) { + if underlying == nil { + return nil, fmt.Errorf("federation: underlying client repository is required") + } + if resolver == nil { + return nil, fmt.Errorf("federation: resolver is required") + } + if len(cfg.AllowedScopes) == 0 { + return nil, fmt.Errorf("federation: config: allowed_scopes is required") + } + if cfg.MaxCacheAge <= 0 { + return nil, fmt.Errorf("federation: config: max_cache_age must be positive") + } + if clock == nil { + return nil, fmt.Errorf("federation: clock is required") + } + return &AutomaticClientRepository{ + underlying: underlying, resolver: resolver, cfg: cfg, clock: clock, + cache: make(map[fapi.ClientID]cachedClient), + }, nil +} + +// ResolveClient implements storage.ClientRepository. +func (a *AutomaticClientRepository) ResolveClient(ctx context.Context, id fapi.ClientID) (storage.RegisteredClient, error) { + if client, err := a.underlying.ResolveClient(ctx, id); err == nil { + return client, nil + } + entry, err := a.resolve(ctx, id) + if err != nil { + return storage.RegisteredClient{}, err + } + return entry.client, nil +} + +// resolveVerificationKeys resolves id's own client authentication keys +// from its cached (or freshly resolved) openid_relying_party metadata — +// the shared implementation behind AutomaticClientKeySource, factored +// here so it can reuse this type's own cache directly instead of each +// resolving the same Trust Chain independently. +func (a *AutomaticClientRepository) resolveVerificationKeys(ctx context.Context, req keys.ClientKeyRequest) (keys.VerificationKeySet, error) { + entry, err := a.resolve(ctx, req.ClientID) + if err != nil { + return keys.VerificationKeySet{}, err + } + candidates, err := jose.ParseJWKSet(entry.jwks) + if err != nil { + return keys.VerificationKeySet{}, fmt.Errorf("federation: parse resolved client jwks: %w", err) + } + var matched []keys.VerificationKey + for _, c := range candidates { + if c.Algorithm != req.Algorithm { + continue + } + if req.KeyID != "" && c.KeyID != req.KeyID { + continue + } + matched = append(matched, keys.VerificationKey{KeyID: c.KeyID, Algorithm: c.Algorithm, PublicKey: c.PublicKey}) + } + return keys.VerificationKeySet{Keys: matched}, nil +} + +// resolve returns id's cached entry if still fresh, or resolves (via +// Resolver.Resolve) and caches a new one. +func (a *AutomaticClientRepository) resolve(ctx context.Context, id fapi.ClientID) (cachedClient, error) { + now := a.clock.Now() + + a.mu.Lock() + entry, ok := a.cache[id] + a.mu.Unlock() + if ok && now.Before(entry.expiresAt) { + return entry, nil + } + + if err := ValidEntityID(string(id)); err != nil { + return cachedClient{}, fmt.Errorf("federation: %q is not a registered client and not a valid federation entity ID: %w", id, err) + } + resolved, err := a.resolver.Resolve(ctx, string(id)) + if err != nil { + return cachedClient{}, fmt.Errorf("federation: resolve client %q: %w", id, err) + } + raw, ok := resolved.Metadata[relyingPartyEntityType] + if !ok { + return cachedClient{}, fmt.Errorf("federation: %q has no %s metadata", id, relyingPartyEntityType) + } + clientCfg, jwks, err := registeredClientConfigFromMetadata(id, raw, a.cfg.AllowedScopes) + if err != nil { + return cachedClient{}, fmt.Errorf("federation: %q: %w", id, err) + } + client, err := storage.NewRegisteredClient(clientCfg) + if err != nil { + return cachedClient{}, fmt.Errorf("federation: %q: %w", id, err) + } + + expiresAt := resolved.ExpiresAt + if maxAge := now.Add(a.cfg.MaxCacheAge); maxAge.Before(expiresAt) { + expiresAt = maxAge + } + entry = cachedClient{client: client, jwks: jwks, expiresAt: expiresAt} + + a.mu.Lock() + a.cache[id] = entry + a.mu.Unlock() + return entry, nil +} + +// AutomaticClientKeySource implements keys.ClientKeySource by resolving +// a client's verification keys from the SAME cached Trust Chain +// resolution AutomaticClientRepository performs for ResolveClient — an +// automatically-registered client's authentication key comes from its +// own openid_relying_party metadata's "jwks" member (OpenID Federation +// 1.0 §12.1.1.1.2's own "the key material the client published in its +// metadata for the openid_relying_party Entity Type"), never from its +// Entity Configuration's own top-level federation signing key — a +// different key, for a different purpose (federation/doc.go). +// +// Underlying is tried first, exactly mirroring +// AutomaticClientRepository's own "static registration always wins" +// precedence — a statically registered client's real keys must never +// be shadowed by a federation lookup. +type AutomaticClientKeySource struct { + underlying keys.ClientKeySource + repo *AutomaticClientRepository +} + +// NewAutomaticClientKeySource validates its arguments and returns an +// AutomaticClientKeySource. repo should typically be the same +// *AutomaticClientRepository passed as Dependencies.Clients, so both +// dependencies share one cache instead of each resolving the same +// chain independently. +func NewAutomaticClientKeySource(underlying keys.ClientKeySource, repo *AutomaticClientRepository) (*AutomaticClientKeySource, error) { + if underlying == nil { + return nil, fmt.Errorf("federation: underlying client key source is required") + } + if repo == nil { + return nil, fmt.Errorf("federation: automatic client repository is required") + } + return &AutomaticClientKeySource{underlying: underlying, repo: repo}, nil +} + +// ResolveVerificationKeys implements keys.ClientKeySource. +func (s *AutomaticClientKeySource) ResolveVerificationKeys(ctx context.Context, req keys.ClientKeyRequest) (keys.VerificationKeySet, error) { + if set, err := s.underlying.ResolveVerificationKeys(ctx, req); err == nil { + return set, nil + } + return s.repo.resolveVerificationKeys(ctx, req) +} + +// relyingPartyMetadata is the subset of OpenID Connect Dynamic Client +// Registration 1.0 / RFC 8705 client metadata this package reads from a +// Resolved openid_relying_party object to build a +// storage.RegisteredClient — see AutomaticClientRepository's own doc +// comment for exactly which registration shapes are (and are not) +// supported in this first version. +type relyingPartyMetadata struct { + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` + RequestObjectSigningAlg string `json:"request_object_signing_alg"` + JWKS json.RawMessage `json:"jwks"` + TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens"` + IDTokenEncryptedResponseAlg string `json:"id_token_encrypted_response_alg"` + IDTokenEncryptedResponseEnc string `json:"id_token_encrypted_response_enc"` + UserinfoEncryptedResponseAlg string `json:"userinfo_encrypted_response_alg"` + UserinfoEncryptedResponseEnc string `json:"userinfo_encrypted_response_enc"` +} + +// registeredClientConfigFromMetadata parses raw (an openid_relying_party +// Resolved Metadata object) and builds the storage.RegisteredClientConfig +// it describes, returning its inline jwks separately (needed by +// AutomaticClientKeySource, not stored on storage.RegisteredClient +// itself). +func registeredClientConfigFromMetadata(id fapi.ClientID, raw json.RawMessage, allowedScopes []string) (storage.RegisteredClientConfig, json.RawMessage, error) { + var m relyingPartyMetadata + if err := json.Unmarshal(raw, &m); err != nil { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("parse %s metadata: %w", relyingPartyEntityType, err) + } + if len(m.RedirectURIs) == 0 { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("%s metadata has no redirect_uris", relyingPartyEntityType) + } + if len(m.JWKS) == 0 { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("%s metadata has no inline jwks (jwks_uri is not supported)", relyingPartyEntityType) + } + + authMethod, err := storage.ParseClientAuthMethod(m.TokenEndpointAuthMethod) + if err != nil { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("token_endpoint_auth_method: %w", err) + } + if authMethod != storage.ClientAuthMethodPrivateKeyJWT { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("token_endpoint_auth_method %q is not yet supported by automatic registration (only private_key_jwt)", m.TokenEndpointAuthMethod) + } + assertionAlg, err := fapi.ParseSignatureAlgorithm(m.TokenEndpointAuthSigningAlg) + if err != nil { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("token_endpoint_auth_signing_alg: %w", err) + } + + var requestObjectAlg fapi.SignatureAlgorithm + if m.RequestObjectSigningAlg != "" { + requestObjectAlg, err = fapi.ParseSignatureAlgorithm(m.RequestObjectSigningAlg) + if err != nil { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("request_object_signing_alg: %w", err) + } + } + + senderConstrain := storage.SenderConstrainDPoP + if m.TLSClientCertificateBoundAccessTokens { + senderConstrain = storage.SenderConstrainMTLS + } + + redirectURIs := make([]fapi.RegisteredRedirectURI, len(m.RedirectURIs)) + for i, u := range m.RedirectURIs { + redirectURIs[i] = fapi.RegisteredRedirectURI(u) + } + + cfg := storage.RegisteredClientConfig{ + ID: id, RedirectURIs: redirectURIs, + ClientAuthMethod: authMethod, ClientAssertionAlgorithm: assertionAlg, + RequestObjectAlgorithm: requestObjectAlg, + SenderConstrain: senderConstrain, + AllowedScopes: allowedScopes, + } + + idTokenAlgSet := m.IDTokenEncryptedResponseAlg != "" + idTokenEncSet := m.IDTokenEncryptedResponseEnc != "" + if idTokenAlgSet != idTokenEncSet { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("id_token_encrypted_response_alg and id_token_encrypted_response_enc must both be set, or neither") + } + if idTokenAlgSet { + if cfg.IDTokenEncryptionKeyManagement, err = fapi.ParseKeyManagementAlgorithm(m.IDTokenEncryptedResponseAlg); err != nil { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("id_token_encrypted_response_alg: %w", err) + } + if cfg.IDTokenEncryptionContentEncryption, err = fapi.ParseContentEncryptionAlgorithm(m.IDTokenEncryptedResponseEnc); err != nil { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("id_token_encrypted_response_enc: %w", err) + } + } + + userInfoAlgSet := m.UserinfoEncryptedResponseAlg != "" + userInfoEncSet := m.UserinfoEncryptedResponseEnc != "" + if userInfoAlgSet != userInfoEncSet { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("userinfo_encrypted_response_alg and userinfo_encrypted_response_enc must both be set, or neither") + } + if userInfoAlgSet { + if cfg.UserInfoEncryptionKeyManagement, err = fapi.ParseKeyManagementAlgorithm(m.UserinfoEncryptedResponseAlg); err != nil { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("userinfo_encrypted_response_alg: %w", err) + } + if cfg.UserInfoEncryptionContentEncryption, err = fapi.ParseContentEncryptionAlgorithm(m.UserinfoEncryptedResponseEnc); err != nil { + return storage.RegisteredClientConfig{}, nil, fmt.Errorf("userinfo_encrypted_response_enc: %w", err) + } + } + + return cfg, m.JWKS, nil +} diff --git a/federation/automatic_registration_internal_test.go b/federation/automatic_registration_internal_test.go new file mode 100644 index 0000000..26a0519 --- /dev/null +++ b/federation/automatic_registration_internal_test.go @@ -0,0 +1,247 @@ +package federation + +import ( + "encoding/json" + "testing" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/storage" +) + +const testRPJWKS = `{"keys":[{"kty":"EC","crv":"P-256","kid":"rp-1","alg":"ES256","x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4","y":"4Etl4P0Sr2SFmvDMTFwbjKz8XkNhP4EQhQGE-tfWFdI"}]}` + +func validRPMetadataJSON() string { + return `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "jwks": ` + testRPJWKS + ` + }` +} + +func TestRegisteredClientConfigFromMetadata(t *testing.T) { + cfg, jwks, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(validRPMetadataJSON()), []string{"openid"}) + if err != nil { + t.Fatalf("registeredClientConfigFromMetadata: %v", err) + } + if cfg.ID != "https://rp.example.org" { + t.Errorf("ID = %q", cfg.ID) + } + if len(cfg.RedirectURIs) != 1 || cfg.RedirectURIs[0] != "https://rp.example.org/cb" { + t.Errorf("RedirectURIs = %v", cfg.RedirectURIs) + } + if cfg.ClientAssertionAlgorithm != fapi.ES256 { + t.Errorf("ClientAssertionAlgorithm = %v, want ES256", cfg.ClientAssertionAlgorithm) + } + if len(jwks) == 0 { + t.Errorf("jwks is empty") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsMissingRedirectURIs(t *testing.T) { + raw := `{"token_endpoint_auth_method":"private_key_jwt","token_endpoint_auth_signing_alg":"ES256","jwks":` + testRPJWKS + `}` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(no redirect_uris) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsMissingJWKS(t *testing.T) { + raw := `{"redirect_uris":["https://rp.example.org/cb"],"token_endpoint_auth_method":"private_key_jwt","token_endpoint_auth_signing_alg":"ES256"}` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(no jwks) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsUnsupportedAuthMethod(t *testing.T) { + raw := `{"redirect_uris":["https://rp.example.org/cb"],"token_endpoint_auth_method":"self_signed_tls_client_auth","jwks":` + testRPJWKS + `}` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(self_signed_tls_client_auth) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsInvalidAuthMethod(t *testing.T) { + raw := `{"redirect_uris":["https://rp.example.org/cb"],"token_endpoint_auth_method":"client_secret_basic","jwks":` + testRPJWKS + `}` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(client_secret_basic) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsInvalidAssertionAlgorithm(t *testing.T) { + raw := `{"redirect_uris":["https://rp.example.org/cb"],"token_endpoint_auth_method":"private_key_jwt","token_endpoint_auth_signing_alg":"none","jwks":` + testRPJWKS + `}` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(alg=none) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsMalformedJSON(t *testing.T) { + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(`not json`), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(malformed json) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataAcceptsRequestObjectSigningAlg(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "request_object_signing_alg": "ES256", + "jwks": ` + testRPJWKS + ` + }` + cfg, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}) + if err != nil { + t.Fatalf("registeredClientConfigFromMetadata: %v", err) + } + if alg, permitted := cfg.RequestObjectAlgorithm, cfg.RequestObjectAlgorithm != 0; !permitted || alg != fapi.ES256 { + t.Errorf("RequestObjectAlgorithm = %v, permitted = %v, want ES256/true", alg, permitted) + } +} + +func TestRegisteredClientConfigFromMetadataRejectsInvalidRequestObjectSigningAlg(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "request_object_signing_alg": "bogus", + "jwks": ` + testRPJWKS + ` + }` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(bad request_object_signing_alg) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataMapsMTLSSenderConstrain(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "tls_client_certificate_bound_access_tokens": true, + "jwks": ` + testRPJWKS + ` + }` + cfg, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}) + if err != nil { + t.Fatalf("registeredClientConfigFromMetadata: %v", err) + } + if cfg.SenderConstrain != storage.SenderConstrainMTLS { + t.Errorf("SenderConstrain = %v, want mtls", cfg.SenderConstrain) + } +} + +func TestRegisteredClientConfigFromMetadataMapsIDTokenEncryption(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "id_token_encrypted_response_alg": "ECDH-ES+A256KW", + "id_token_encrypted_response_enc": "A256GCM", + "jwks": ` + testRPJWKS + ` + }` + cfg, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}) + if err != nil { + t.Fatalf("registeredClientConfigFromMetadata: %v", err) + } + km, ce, enabled := cfg.IDTokenEncryptionKeyManagement, cfg.IDTokenEncryptionContentEncryption, cfg.IDTokenEncryptionKeyManagement != 0 + if !enabled || km == 0 || ce == 0 { + t.Errorf("IDTokenEncryption = %v/%v/%v, want a non-zero pair", km, ce, enabled) + } +} + +func TestRegisteredClientConfigFromMetadataRejectsUnpairedIDTokenEncryption(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "id_token_encrypted_response_alg": "ECDH-ES+A256KW", + "jwks": ` + testRPJWKS + ` + }` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(id_token enc alg without enc) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsInvalidIDTokenEncryptionKeyManagement(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "id_token_encrypted_response_alg": "bogus", + "id_token_encrypted_response_enc": "A256GCM", + "jwks": ` + testRPJWKS + ` + }` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(bogus id_token_encrypted_response_alg) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsInvalidIDTokenEncryptionContentEncryption(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "id_token_encrypted_response_alg": "ECDH-ES+A256KW", + "id_token_encrypted_response_enc": "bogus", + "jwks": ` + testRPJWKS + ` + }` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(bogus id_token_encrypted_response_enc) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataMapsUserInfoEncryption(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "userinfo_encrypted_response_alg": "ECDH-ES+A256KW", + "userinfo_encrypted_response_enc": "A256GCM", + "jwks": ` + testRPJWKS + ` + }` + cfg, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}) + if err != nil { + t.Fatalf("registeredClientConfigFromMetadata: %v", err) + } + km, ce, enabled := cfg.UserInfoEncryptionKeyManagement, cfg.UserInfoEncryptionContentEncryption, cfg.UserInfoEncryptionKeyManagement != 0 + if !enabled || km == 0 || ce == 0 { + t.Errorf("UserInfoEncryption = %v/%v/%v, want a non-zero pair", km, ce, enabled) + } +} + +func TestRegisteredClientConfigFromMetadataRejectsInvalidUserInfoEncryptionKeyManagement(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "userinfo_encrypted_response_alg": "bogus", + "userinfo_encrypted_response_enc": "A256GCM", + "jwks": ` + testRPJWKS + ` + }` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(bogus userinfo_encrypted_response_alg) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsInvalidUserInfoEncryptionContentEncryption(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "userinfo_encrypted_response_alg": "ECDH-ES+A256KW", + "userinfo_encrypted_response_enc": "bogus", + "jwks": ` + testRPJWKS + ` + }` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(bogus userinfo_encrypted_response_enc) = nil error, want error") + } +} + +func TestRegisteredClientConfigFromMetadataRejectsUnpairedUserInfoEncryption(t *testing.T) { + raw := `{ + "redirect_uris": ["https://rp.example.org/cb"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "userinfo_encrypted_response_enc": "A256GCM", + "jwks": ` + testRPJWKS + ` + }` + if _, _, err := registeredClientConfigFromMetadata("https://rp.example.org", json.RawMessage(raw), []string{"openid"}); err == nil { + t.Fatalf("registeredClientConfigFromMetadata(userinfo enc without alg) = nil error, want error") + } +} diff --git a/federation/automatic_registration_test.go b/federation/automatic_registration_test.go new file mode 100644 index 0000000..e5ad084 --- /dev/null +++ b/federation/automatic_registration_test.go @@ -0,0 +1,550 @@ +package federation_test + +import ( + "context" + "crypto/ecdsa" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/fapihttp" + "github.com/idfoundry/fapigo/federation" + intfed "github.com/idfoundry/fapigo/internal/federation" + "github.com/idfoundry/fapigo/keys" + "github.com/idfoundry/fapigo/storage" +) + +// automaticRegistrationFixture is a two-level federation (TA -> RP) +// purpose-built for AutomaticClientRepository/AutomaticClientKeySource +// tests — chain-walking itself is already covered by resolver_test.go, +// so this stays as shallow as the feature under test allows. rpFedKey +// signs RP's own Entity Configuration (its federation identity); rpOIDCKey +// is a deliberately different key, published only inside RP's own +// openid_relying_party metadata "jwks" — the two are never the same key +// (federation/doc.go), and AutomaticClientKeySource must resolve the +// latter, not the former. +type automaticRegistrationFixture struct { + taID, rpID string + taKey *ecdsa.PrivateKey + taJWKS json.RawMessage + rpFedKey, rpOIDCKey *ecdsa.PrivateKey + fetcher *fapihttp.Client + now time.Time + rpConfigCalls *int32 +} + +// setupAutomaticRegistrationFixture builds the fixture. metadataFn, if +// non-nil, is called with the RP's own entity ID and its (freshly +// generated) OIDC-level key to build RP's own openid_relying_party +// metadata — a callback rather than a precomputed value, since both +// only exist once setup is already underway. nil means RP publishes no +// openid_relying_party metadata at all. +func setupAutomaticRegistrationFixture(t *testing.T, metadataFn func(rpID string, rpOIDCKey *ecdsa.PrivateKey) json.RawMessage) *automaticRegistrationFixture { + t.Helper() + now := time.Now() + + taKey, rpFedKey, rpOIDCKey := generateKey(t), generateKey(t), generateKey(t) + taJWKS := jwksFor(t, "ta", taKey) + + taMux := http.NewServeMux() + taServer := httptest.NewTLSServer(taMux) + t.Cleanup(taServer.Close) + rpMux := http.NewServeMux() + rpServer := httptest.NewTLSServer(rpMux) + t.Cleanup(rpServer.Close) + + taID, rpID := taServer.URL, rpServer.URL + + sign := func(p intfed.CreateParams) string { + t.Helper() + token, err := intfed.Create(p) + if err != nil { + t.Fatalf("intfed.Create: %v", err) + } + return token + } + + taConfig := sign(intfed.CreateParams{ + Signer: taKey, Algorithm: fapi.ES256, KeyID: "ta", + Issuer: taID, Subject: taID, Now: now, Lifetime: time.Hour, JWKS: taJWKS, + Metadata: federationEntityMetadata(t, taID+"/fetch"), + }) + rpFedJWKS := jwksFor(t, "rp-fed", rpFedKey) + taAboutRP := sign(intfed.CreateParams{ + Signer: taKey, Algorithm: fapi.ES256, KeyID: "ta", + Issuer: taID, Subject: rpID, Now: now, Lifetime: time.Hour, JWKS: rpFedJWKS, + }) + var metadata map[string]json.RawMessage + if metadataFn != nil { + metadata = map[string]json.RawMessage{"openid_relying_party": metadataFn(rpID, rpOIDCKey)} + } + rpConfig := sign(intfed.CreateParams{ + Signer: rpFedKey, Algorithm: fapi.ES256, KeyID: "rp-fed", + Issuer: rpID, Subject: rpID, Now: now, Lifetime: time.Hour, JWKS: rpFedJWKS, + AuthorityHints: []string{taID}, Metadata: metadata, + }) + + taMux.HandleFunc("/.well-known/openid-federation", serveStatement(taConfig)) + taMux.HandleFunc("/fetch", serveFetch(map[string]string{rpID: taAboutRP})) + var calls int32 + rpMux.HandleFunc("/.well-known/openid-federation", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + serveStatement(rpConfig)(w, r) + }) + + pool := x509.NewCertPool() + pool.AddCert(taServer.Certificate()) + pool.AddCert(rpServer.Certificate()) + httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}} + fetcher, err := fapihttp.New(httpClient, fapihttp.Config{ + MaxResponseBytes: 1 << 16, RequestTimeout: 5 * time.Second, MaxRedirects: 1, + AllowLoopbackHTTP: true, + }) + if err != nil { + t.Fatalf("fapihttp.New: %v", err) + } + + return &automaticRegistrationFixture{ + taID: taID, rpID: rpID, taKey: taKey, taJWKS: taJWKS, + rpFedKey: rpFedKey, rpOIDCKey: rpOIDCKey, + fetcher: fetcher, now: now, rpConfigCalls: &calls, + } +} + +func (f *automaticRegistrationFixture) newResolver(t *testing.T) *federation.Resolver { + t.Helper() + r, err := federation.NewResolver(federation.Config{ + TrustAnchors: []federation.TrustAnchor{{EntityID: f.taID, JWKS: f.taJWKS}}, + Limits: federation.Limits{MaxPathLength: 5, MaxStatementLifetime: 2 * time.Hour, MaxClockSkew: 5 * time.Second}, + }, federation.Dependencies{HTTP: f.fetcher, Clock: fixedClock{now: f.now}}) + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + return r +} + +// rpMetadataBuilder returns a metadataFn (see setupAutomaticRegistrationFixture) +// building a minimal but complete openid_relying_party metadata object: +// redirect_uris, private_key_jwt authentication with rpOIDCKey (kid +// "rp-oidc") as its published jwks. A closure over t, rather than +// taking t as a direct parameter, so it matches metadataFn's own +// signature exactly and can be passed as a plain function value. +func rpMetadataBuilder(t *testing.T) func(rpID string, rpOIDCKey *ecdsa.PrivateKey) json.RawMessage { + return func(rpID string, rpOIDCKey *ecdsa.PrivateKey) json.RawMessage { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "redirect_uris": []string{rpID + "/cb"}, + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "jwks": json.RawMessage(jwksFor(t, "rp-oidc", rpOIDCKey)), + }) + if err != nil { + t.Fatalf("marshal openid_relying_party metadata: %v", err) + } + return raw + } +} + +// alwaysFailsRepository is a storage.ClientRepository that always +// fails — for tests wanting AutomaticClientRepository's federation +// fallback exercised unconditionally. +type alwaysFailsRepository struct{} + +func (alwaysFailsRepository) ResolveClient(context.Context, fapi.ClientID) (storage.RegisteredClient, error) { + return storage.RegisteredClient{}, fmt.Errorf("alwaysFailsRepository: no such client") +} + +// alwaysFailsKeySource mirrors alwaysFailsRepository for keys.ClientKeySource. +type alwaysFailsKeySource struct{} + +func (alwaysFailsKeySource) ResolveVerificationKeys(context.Context, keys.ClientKeyRequest) (keys.VerificationKeySet, error) { + return keys.VerificationKeySet{}, fmt.Errorf("alwaysFailsKeySource: no such client") +} + +// staticRepository resolves exactly one client ID to a fixed +// storage.RegisteredClient, failing for every other ID — a minimal +// storage.ClientRepository test double representing "statically +// registered" clients. +type staticRepository struct { + id fapi.ClientID + client storage.RegisteredClient +} + +func (s staticRepository) ResolveClient(_ context.Context, id fapi.ClientID) (storage.RegisteredClient, error) { + if id == s.id { + return s.client, nil + } + return storage.RegisteredClient{}, fmt.Errorf("staticRepository: no such client %q", id) +} + +// staticKeySource resolves exactly one client ID to a fixed +// keys.VerificationKeySet, failing for every other ID. +type staticKeySource struct { + id fapi.ClientID + keySet keys.VerificationKeySet +} + +func (s staticKeySource) ResolveVerificationKeys(_ context.Context, req keys.ClientKeyRequest) (keys.VerificationKeySet, error) { + if req.ClientID == s.id { + return s.keySet, nil + } + return keys.VerificationKeySet{}, fmt.Errorf("staticKeySource: no such client %q", req.ClientID) +} + +func validAutomaticRegistrationConfig() federation.AutomaticRegistrationConfig { + return federation.AutomaticRegistrationConfig{AllowedScopes: []string{"openid"}, MaxCacheAge: time.Hour} +} + +func TestNewAutomaticClientRepositoryRejectsInvalidConfig(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, nil) + resolver := f.newResolver(t) + + cases := map[string]func() (storage.ClientRepository, *federation.Resolver, federation.AutomaticRegistrationConfig, federation.Clock){ + "nil underlying": func() (storage.ClientRepository, *federation.Resolver, federation.AutomaticRegistrationConfig, federation.Clock) { + return nil, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now} + }, + "nil resolver": func() (storage.ClientRepository, *federation.Resolver, federation.AutomaticRegistrationConfig, federation.Clock) { + return alwaysFailsRepository{}, nil, validAutomaticRegistrationConfig(), fixedClock{now: f.now} + }, + "empty allowed scopes": func() (storage.ClientRepository, *federation.Resolver, federation.AutomaticRegistrationConfig, federation.Clock) { + cfg := validAutomaticRegistrationConfig() + cfg.AllowedScopes = nil + return alwaysFailsRepository{}, resolver, cfg, fixedClock{now: f.now} + }, + "zero max cache age": func() (storage.ClientRepository, *federation.Resolver, federation.AutomaticRegistrationConfig, federation.Clock) { + cfg := validAutomaticRegistrationConfig() + cfg.MaxCacheAge = 0 + return alwaysFailsRepository{}, resolver, cfg, fixedClock{now: f.now} + }, + "nil clock": func() (storage.ClientRepository, *federation.Resolver, federation.AutomaticRegistrationConfig, federation.Clock) { + return alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), nil + }, + } + for name, build := range cases { + t.Run(name, func(t *testing.T) { + underlying, resolver, cfg, clock := build() + if _, err := federation.NewAutomaticClientRepository(underlying, resolver, cfg, clock); err == nil { + t.Fatalf("NewAutomaticClientRepository(%s) = nil error, want error", name) + } + }) + } +} + +func TestAutomaticClientRepositoryResolveClientPrefersUnderlying(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, rpMetadataBuilder(t)) + resolver := f.newResolver(t) + + staticClient, err := storage.NewRegisteredClient(storage.RegisteredClientConfig{ + ID: fapi.ClientID(f.rpID), RedirectURIs: []fapi.RegisteredRedirectURI{fapi.RegisteredRedirectURI(f.rpID + "/static-cb")}, + ClientAuthMethod: storage.ClientAuthMethodPrivateKeyJWT, ClientAssertionAlgorithm: fapi.ES256, + SenderConstrain: storage.SenderConstrainDPoP, AllowedScopes: []string{"openid"}, + }) + if err != nil { + t.Fatalf("storage.NewRegisteredClient: %v", err) + } + + repo, err := federation.NewAutomaticClientRepository( + staticRepository{id: fapi.ClientID(f.rpID), client: staticClient}, resolver, + validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + + got, err := repo.ResolveClient(context.Background(), fapi.ClientID(f.rpID)) + if err != nil { + t.Fatalf("ResolveClient: %v", err) + } + if !got.HasRedirectURI(f.rpID + "/static-cb") { + t.Errorf("ResolveClient returned a client without the statically registered redirect URI — federation resolution must not have shadowed it") + } + if atomic.LoadInt32(f.rpConfigCalls) != 0 { + t.Errorf("RP's own well-known endpoint was fetched %d times, want 0 — underlying should have short-circuited federation resolution entirely", atomic.LoadInt32(f.rpConfigCalls)) + } +} + +func TestAutomaticClientRepositoryResolveClientFallsBackToFederation(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, rpMetadataBuilder(t)) + resolver := f.newResolver(t) + + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + + got, err := repo.ResolveClient(context.Background(), fapi.ClientID(f.rpID)) + if err != nil { + t.Fatalf("ResolveClient: %v", err) + } + if got.ID() != fapi.ClientID(f.rpID) { + t.Errorf("ID() = %q, want %q", got.ID(), f.rpID) + } + if !got.HasRedirectURI(f.rpID + "/cb") { + t.Errorf("ResolveClient returned a client missing the resolved redirect_uris") + } + if got.ClientAuthMethod() != storage.ClientAuthMethodPrivateKeyJWT { + t.Errorf("ClientAuthMethod() = %v, want ClientAuthMethodPrivateKeyJWT", got.ClientAuthMethod()) + } + if !got.AllowsScope("openid") { + t.Errorf("AllowsScope(openid) = false, want true (from AutomaticRegistrationConfig.AllowedScopes)") + } +} + +func TestAutomaticClientRepositoryResolveClientRejectsNonEntityID(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, nil) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + if _, err := repo.ResolveClient(context.Background(), "not-a-url"); err == nil { + t.Fatalf("ResolveClient(\"not-a-url\") = nil error, want error") + } +} + +func TestAutomaticClientRepositoryResolveClientRejectsMissingRelyingPartyMetadata(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, nil) // RP publishes no openid_relying_party metadata at all + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + if _, err := repo.ResolveClient(context.Background(), fapi.ClientID(f.rpID)); err == nil { + t.Fatalf("ResolveClient(no openid_relying_party metadata) = nil error, want error") + } +} + +func TestAutomaticClientRepositoryCachesAcrossCalls(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, rpMetadataBuilder(t)) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + + if _, err := repo.ResolveClient(context.Background(), fapi.ClientID(f.rpID)); err != nil { + t.Fatalf("ResolveClient (1st): %v", err) + } + if _, err := repo.ResolveClient(context.Background(), fapi.ClientID(f.rpID)); err != nil { + t.Fatalf("ResolveClient (2nd): %v", err) + } + if calls := atomic.LoadInt32(f.rpConfigCalls); calls != 1 { + t.Errorf("RP's own well-known endpoint was fetched %d times across two ResolveClient calls within the cache window, want 1", calls) + } +} + +func TestAutomaticClientKeySourceResolvesFromRelyingPartyMetadataJWKS(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, rpMetadataBuilder(t)) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + src, err := federation.NewAutomaticClientKeySource(alwaysFailsKeySource{}, repo) + if err != nil { + t.Fatalf("NewAutomaticClientKeySource: %v", err) + } + + set, err := src.ResolveVerificationKeys(context.Background(), keys.ClientKeyRequest{ + ClientID: fapi.ClientID(f.rpID), Purpose: keys.ClientAssertionVerification, + Algorithm: fapi.ES256, KeyID: "rp-oidc", + }) + if err != nil { + t.Fatalf("ResolveVerificationKeys: %v", err) + } + if len(set.Keys) != 1 { + t.Fatalf("ResolveVerificationKeys returned %d keys, want 1", len(set.Keys)) + } + pub, ok := set.Keys[0].PublicKey.(*ecdsa.PublicKey) + if !ok || !pub.Equal(&f.rpOIDCKey.PublicKey) { + t.Errorf("resolved key does not match the RP's own openid_relying_party jwks key (rpOIDCKey) — got a different key, possibly the federation entity key (rpFedKey) by mistake") + } +} + +func TestAutomaticClientKeySourcePrefersUnderlying(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, rpMetadataBuilder(t)) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + + underlyingKey := generateKey(t) + underlying := staticKeySource{ + id: fapi.ClientID(f.rpID), + keySet: keys.VerificationKeySet{Keys: []keys.VerificationKey{ + {KeyID: "static-kid", Algorithm: fapi.ES256, PublicKey: &underlyingKey.PublicKey}, + }}, + } + src, err := federation.NewAutomaticClientKeySource(underlying, repo) + if err != nil { + t.Fatalf("NewAutomaticClientKeySource: %v", err) + } + + set, err := src.ResolveVerificationKeys(context.Background(), keys.ClientKeyRequest{ + ClientID: fapi.ClientID(f.rpID), Purpose: keys.ClientAssertionVerification, Algorithm: fapi.ES256, + }) + if err != nil { + t.Fatalf("ResolveVerificationKeys: %v", err) + } + if len(set.Keys) != 1 || set.Keys[0].KeyID != "static-kid" { + t.Errorf("ResolveVerificationKeys = %+v, want the underlying static key, unshadowed by federation resolution", set) + } + if atomic.LoadInt32(f.rpConfigCalls) != 0 { + t.Errorf("RP's own well-known endpoint was fetched, want underlying to have short-circuited federation resolution entirely") + } +} + +func TestAutomaticClientRepositoryResolveClientRejectsUnreachableFederationEntity(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, nil) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + // A well-formed https Entity Identifier, but not one Resolve can + // ever reach (unresolvable host) — exercises Resolver.Resolve's own + // error path, distinct from the "not a valid Entity ID at all" case. + if _, err := repo.ResolveClient(context.Background(), "https://nonexistent-rp-host.example.invalid"); err == nil { + t.Fatalf("ResolveClient(unreachable federation entity) = nil error, want error") + } +} + +func TestAutomaticClientRepositoryResolveClientRejectsInvalidRelyingPartyMetadata(t *testing.T) { + // RP publishes openid_relying_party metadata missing redirect_uris + // — resolves successfully at the federation layer, but + // registeredClientConfigFromMetadata itself must reject it. + f := setupAutomaticRegistrationFixture(t, func(rpID string, rpOIDCKey *ecdsa.PrivateKey) json.RawMessage { + raw, err := json.Marshal(map[string]any{ + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "jwks": json.RawMessage(jwksFor(t, "rp-oidc", rpOIDCKey)), + }) + if err != nil { + t.Fatalf("marshal openid_relying_party metadata: %v", err) + } + return raw + }) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + if _, err := repo.ResolveClient(context.Background(), fapi.ClientID(f.rpID)); err == nil { + t.Fatalf("ResolveClient(openid_relying_party metadata missing redirect_uris) = nil error, want error") + } +} + +func TestAutomaticClientRepositoryResolveClientRejectsInvalidAllowedScope(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, rpMetadataBuilder(t)) + resolver := f.newResolver(t) + cfg := validAutomaticRegistrationConfig() + cfg.AllowedScopes = []string{""} // passes NewAutomaticClientRepository's own len()>0 check, but storage.NewRegisteredClient rejects an empty scope entry + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, cfg, fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + if _, err := repo.ResolveClient(context.Background(), fapi.ClientID(f.rpID)); err == nil { + t.Fatalf("ResolveClient(empty allowed scope entry) = nil error, want error") + } +} + +func TestAutomaticClientKeySourceRejectsUnresolvableClient(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, nil) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + src, err := federation.NewAutomaticClientKeySource(alwaysFailsKeySource{}, repo) + if err != nil { + t.Fatalf("NewAutomaticClientKeySource: %v", err) + } + if _, err := src.ResolveVerificationKeys(context.Background(), keys.ClientKeyRequest{ClientID: "not-a-url", Algorithm: fapi.ES256}); err == nil { + t.Fatalf("ResolveVerificationKeys(unresolvable client) = nil error, want error") + } +} + +func TestAutomaticClientKeySourceRejectsMalformedResolvedJWKS(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, func(rpID string, rpOIDCKey *ecdsa.PrivateKey) json.RawMessage { + raw, err := json.Marshal(map[string]any{ + "redirect_uris": []string{rpID + "/cb"}, + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "jwks": "not-a-jwk-set-object", + }) + if err != nil { + t.Fatalf("marshal openid_relying_party metadata: %v", err) + } + return raw + }) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + src, err := federation.NewAutomaticClientKeySource(alwaysFailsKeySource{}, repo) + if err != nil { + t.Fatalf("NewAutomaticClientKeySource: %v", err) + } + if _, err := src.ResolveVerificationKeys(context.Background(), keys.ClientKeyRequest{ClientID: fapi.ClientID(f.rpID), Algorithm: fapi.ES256}); err == nil { + t.Fatalf("ResolveVerificationKeys(malformed resolved jwks) = nil error, want error") + } +} + +func TestAutomaticClientKeySourceSkipsCandidatesWithWrongAlgorithmOrKeyID(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, rpMetadataBuilder(t)) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + src, err := federation.NewAutomaticClientKeySource(alwaysFailsKeySource{}, repo) + if err != nil { + t.Fatalf("NewAutomaticClientKeySource: %v", err) + } + + // Wrong algorithm: the resolved key is ES256, requesting PS256 + // must match nothing. + set, err := src.ResolveVerificationKeys(context.Background(), keys.ClientKeyRequest{ + ClientID: fapi.ClientID(f.rpID), Algorithm: fapi.PS256, + }) + if err != nil { + t.Fatalf("ResolveVerificationKeys(wrong algorithm): %v", err) + } + if len(set.Keys) != 0 { + t.Errorf("ResolveVerificationKeys(wrong algorithm) = %d keys, want 0", len(set.Keys)) + } + + // Wrong kid: the resolved key's kid is "rp-oidc". + set, err = src.ResolveVerificationKeys(context.Background(), keys.ClientKeyRequest{ + ClientID: fapi.ClientID(f.rpID), Algorithm: fapi.ES256, KeyID: "not-the-real-kid", + }) + if err != nil { + t.Fatalf("ResolveVerificationKeys(wrong kid): %v", err) + } + if len(set.Keys) != 0 { + t.Errorf("ResolveVerificationKeys(wrong kid) = %d keys, want 0", len(set.Keys)) + } +} + +func TestNewAutomaticClientKeySourceRejectsInvalidArguments(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, nil) + resolver := f.newResolver(t) + repo, err := federation.NewAutomaticClientRepository(alwaysFailsRepository{}, resolver, validAutomaticRegistrationConfig(), fixedClock{now: f.now}) + if err != nil { + t.Fatalf("NewAutomaticClientRepository: %v", err) + } + if _, err := federation.NewAutomaticClientKeySource(nil, repo); err == nil { + t.Fatalf("NewAutomaticClientKeySource(nil underlying) = nil error, want error") + } + if _, err := federation.NewAutomaticClientKeySource(alwaysFailsKeySource{}, nil); err == nil { + t.Fatalf("NewAutomaticClientKeySource(nil repo) = nil error, want error") + } +} diff --git a/federation/doc.go b/federation/doc.go index 6299c90..3b8e1b4 100644 --- a/federation/doc.go +++ b/federation/doc.go @@ -32,13 +32,24 @@ // mirroring how a TLS client is configured with a root CA bundle rather // than discovering trust roots live over the network. // -// Trust marks (OpenID Federation 1.0 §7), the naming_constraints and +// Trust marks (OpenID Federation 1.0 §7) and the naming_constraints and // allowed_entity_types members of a Subordinate Statement's own -// constraints claim (OpenID Federation 1.0 §6.2.2/§6.2.3), and -// automatic/explicit client registration are not implemented by this -// package — Resolve enforces max_path_length (the constraint most -// directly relevant to resource exhaustion) and leaves the rest for a -// later revision once a concrete caller needs them, the same -// deliberately-narrow-first-cut precedent internal/federation's own -// doc.go already sets for trust marks. +// constraints claim (OpenID Federation 1.0 §6.2.2/§6.2.3) are not +// implemented by this package — Resolve enforces max_path_length (the +// constraint most directly relevant to resource exhaustion) and leaves +// the rest for a later revision once a concrete caller needs them, the +// same deliberately-narrow-first-cut precedent internal/federation's +// own doc.go already sets for trust marks. +// +// Automatic client registration (OpenID Federation 1.0 §12.1) is +// implemented by AutomaticClientRepository/AutomaticClientKeySource, +// for an OpenID Provider that wants to accept a Relying Party's own +// Entity Identifier as client_id without a prior registration step — +// see AutomaticClientRepository's own doc comment for exactly which +// registration shapes this first version supports (only +// ClientAuthMethodPrivateKeyJWT, only an inline jwks, no CIBA or +// client_credentials) and which it deliberately doesn't yet +// (server-side request_uri/JAR/PAR-level enforcement of §12.1.1's own +// aud/sub/jti Request Object rules remains a caller concern; Explicit +// Registration, §12.2, is not implemented by this package at all). package federation