From 73e46335f67bbf3b7e6beb9038497aefb8493989 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Fri, 11 Sep 2026 01:41:28 +0800 Subject: [PATCH] feat(server): wire automatic client registration into server.Config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds server.Config.AutomaticRegistration (optional, "zero disables the feature" like every other opt-in capability here) and Dependencies.FederationHTTP. When TrustAnchors is set, New transparently wraps Dependencies.Clients/ClientKeys with federation.AutomaticClientRepository/AutomaticClientKeySource — every other internal (PAR, the token endpoint, CIBA) keeps calling those same Dependencies fields exactly as before, unaware an automatically- registered Relying Party is now also possible. Statically registered clients always take priority, matching those types' own precedence. Also fixes a real bug in AutomaticClientKeySource found while writing this PR's own end-to-end test: it treated any error-free result from Underlying as final, including an empty VerificationKeySet — but keys.ClientKeySource legitimately returns an empty, error-free set to mean "no matching key" (e.g. an unrecognized kid), the same way fakeClientKeySource (server's own test double) already behaves for an unknown client. An empty result now falls through to federation resolution exactly like an error would. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017N2kkxv9BR4Qmj8De3Ucs6 --- federation/automatic_registration.go | 18 +- server/automatic_registration_test.go | 297 ++++++++++++++++++++++++++ server/config.go | 52 +++++ server/dependencies.go | 13 ++ server/server.go | 51 +++++ 5 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 server/automatic_registration_test.go diff --git a/federation/automatic_registration.go b/federation/automatic_registration.go index 8ea6320..24ac0df 100644 --- a/federation/automatic_registration.go +++ b/federation/automatic_registration.go @@ -216,7 +216,21 @@ func (a *AutomaticClientRepository) resolve(ctx context.Context, id fapi.ClientI // 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. +// be shadowed by a federation lookup. Unlike ResolveClient's own +// single-value result, a keys.ClientKeySource legitimately returns an +// empty, error-free VerificationKeySet to mean "no matching key" (e.g. +// a real client mid-rotation, or simply a kid Underlying doesn't +// recognize) — so an empty result from Underlying falls through to +// federation resolution exactly like an error would, on the +// expectation that an Underlying implementation unaware of a given +// client ID at all is at least as likely to signal that with an empty +// result as with an error (keys.ClientKeySource's own interface +// documents no requirement either way). This mirrors +// AutomaticClientRepository's own documented "Underlying's interface +// has no way to distinguish these cases" limitation — a statically +// registered client legitimately missing a requested key triggers one +// wasted federation resolution attempt (which then also fails), not a +// silent wrong answer. type AutomaticClientKeySource struct { underlying keys.ClientKeySource repo *AutomaticClientRepository @@ -239,7 +253,7 @@ func NewAutomaticClientKeySource(underlying keys.ClientKeySource, repo *Automati // 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 { + if set, err := s.underlying.ResolveVerificationKeys(ctx, req); err == nil && len(set.Keys) > 0 { return set, nil } return s.repo.resolveVerificationKeys(ctx, req) diff --git a/server/automatic_registration_test.go b/server/automatic_registration_test.go new file mode 100644 index 0000000..01a6e4c --- /dev/null +++ b/server/automatic_registration_test.go @@ -0,0 +1,297 @@ +package server_test + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/fapihttp" + "github.com/idfoundry/fapigo/federation" + "github.com/idfoundry/fapigo/internal/clientassertion" + intfed "github.com/idfoundry/fapigo/internal/federation" + "github.com/idfoundry/fapigo/internal/jose" + "github.com/idfoundry/fapigo/keys" + "github.com/idfoundry/fapigo/server" + "github.com/idfoundry/fapigo/storage" +) + +// automaticRegistrationFixture is a two-level federation (TA -> RP) +// standing in for a Relying Party this server has never statically +// registered — mirrors the fixture federation's own +// automatic_registration_test.go builds, adapted to this package since +// federation_test's own helpers aren't reachable from here. +type automaticRegistrationFixture struct { + taID, rpID string + taKey *ecdsa.PrivateKey + taJWKS json.RawMessage + rpOIDCKey *ecdsa.PrivateKey + fetcher *fapihttp.Client + now time.Time +} + +func fedJWKS(t *testing.T, kid string, key *ecdsa.PrivateKey) json.RawMessage { + t.Helper() + jwk, err := jose.NewJWK(&key.PublicKey, fapi.ES256) + if err != nil { + t.Fatalf("jose.NewJWK: %v", err) + } + jwkJSON, err := jwk.WithKeyID(kid).MarshalJSON() + if err != nil { + t.Fatalf("marshal jwk: %v", err) + } + set, err := json.Marshal(map[string][]json.RawMessage{"keys": {jwkJSON}}) + if err != nil { + t.Fatalf("marshal jwk set: %v", err) + } + return set +} + +// setupAutomaticRegistrationFixture builds a real TA -> RP federation +// over HTTPS test servers, with RP's own openid_relying_party metadata +// declaring redirectURI/private_key_jwt/rpOIDCKey — the same shape +// federation.AutomaticClientRepository already extensively tests on its +// own; this fixture exists only to prove server.New actually wires that +// mechanism in, not to re-prove the mechanism itself. +func setupAutomaticRegistrationFixture(t *testing.T, redirectURI string) *automaticRegistrationFixture { + t.Helper() + now := time.Now() + + taKey, rpFedKey, rpOIDCKey := generateKey(t), generateKey(t), generateKey(t) + taJWKS := fedJWKS(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 + } + + fetchMeta, err := json.Marshal(map[string]string{"federation_fetch_endpoint": taID + "/fetch"}) + if err != nil { + t.Fatalf("marshal federation_entity metadata: %v", err) + } + taConfig := sign(intfed.CreateParams{ + Signer: taKey, Algorithm: fapi.ES256, KeyID: "ta", + Issuer: taID, Subject: taID, Now: now, Lifetime: time.Hour, JWKS: taJWKS, + Metadata: map[string]json.RawMessage{"federation_entity": fetchMeta}, + }) + rpFedJWKS := fedJWKS(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, + }) + rpMetadataJSON, err := json.Marshal(map[string]any{ + "redirect_uris": []string{redirectURI}, + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "jwks": json.RawMessage(fedJWKS(t, "rp-oidc", rpOIDCKey)), + }) + if err != nil { + t.Fatalf("marshal openid_relying_party metadata: %v", err) + } + 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: map[string]json.RawMessage{"openid_relying_party": rpMetadataJSON}, + }) + + serveToken := func(token string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/entity-statement+jwt") + w.Write([]byte(token)) + } + } + taMux.HandleFunc("/.well-known/openid-federation", serveToken(taConfig)) + taMux.HandleFunc("/fetch", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("sub") != rpID { + w.WriteHeader(http.StatusNotFound) + return + } + serveToken(taAboutRP)(w, r) + }) + rpMux.HandleFunc("/.well-known/openid-federation", serveToken(rpConfig)) + + 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, + rpOIDCKey: rpOIDCKey, fetcher: fetcher, now: now, + } +} + +func validAutomaticRegistrationServerConfig(f *automaticRegistrationFixture) server.AutomaticRegistrationConfig { + return server.AutomaticRegistrationConfig{ + TrustAnchors: []federation.TrustAnchor{{EntityID: f.taID, JWKS: f.taJWKS}}, + AllowedScopes: []string{"openid", "accounts"}, + MaxPathLength: 5, + MaxStatementLifetime: 2 * time.Hour, + MaxClockSkew: 5 * time.Second, + MaxCacheAge: time.Hour, + } +} + +func TestNewAcceptsZeroValueAutomaticRegistrationConfig(t *testing.T) { + cfg := validConfig(t) + if len(cfg.AutomaticRegistration.TrustAnchors) != 0 { + t.Fatalf("validConfig's zero-value AutomaticRegistration.TrustAnchors is non-empty") + } + if _, err := server.New(cfg, validDependencies()); err != nil { + t.Fatalf("New: %v", err) + } +} + +func TestNewRejectsInvalidAutomaticRegistrationConfig(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, testRedirectURI) + + validCfg := validConfig(t) + validCfg.AutomaticRegistration = validAutomaticRegistrationServerConfig(f) + validDeps := validDependencies() + validDeps.FederationHTTP = f.fetcher + validDeps.Clock = fixedClock{now: f.now} + + cases := map[string]func(*server.Config, *server.Dependencies){ + "empty allowed scopes": func(c *server.Config, d *server.Dependencies) { c.AutomaticRegistration.AllowedScopes = nil }, + "zero max cache age": func(c *server.Config, d *server.Dependencies) { c.AutomaticRegistration.MaxCacheAge = 0 }, + "zero max path length": func(c *server.Config, d *server.Dependencies) { c.AutomaticRegistration.MaxPathLength = 0 }, + "nil federation http": func(c *server.Config, d *server.Dependencies) { d.FederationHTTP = nil }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + cfg := validCfg + deps := validDeps + mutate(&cfg, &deps) + if _, err := server.New(cfg, deps); err == nil { + t.Fatalf("New(%s) = nil error, want error", name) + } + }) + } +} + +func TestNewRejectsMalformedTrustAnchorEntry(t *testing.T) { + // TrustAnchors itself is non-empty (so AutomaticRegistration is + // "on"), but the one entry in it is malformed — validateConfig + // only checks the fields it owns (AllowedScopes, MaxCacheAge); + // federation.NewResolver validates TrustAnchors/Limits itself when + // New actually constructs a Resolver from them. + f := setupAutomaticRegistrationFixture(t, testRedirectURI) + cfg := validConfig(t) + cfg.AutomaticRegistration = validAutomaticRegistrationServerConfig(f) + cfg.AutomaticRegistration.TrustAnchors = []federation.TrustAnchor{{EntityID: "", JWKS: nil}} + deps := validDependencies() + deps.FederationHTTP = f.fetcher + if _, err := server.New(cfg, deps); err == nil { + t.Fatalf("New(malformed trust anchor entry) = nil error, want error") + } +} + +// TestNewWiresAutomaticRegistrationIntoPushAuthorizationRequest proves +// server.New actually wraps Dependencies.Clients/ClientKeys when +// AutomaticRegistration is configured: Dependencies.Clients/ClientKeys +// are both deliberately empty (no statically registered client would +// ever resolve), yet PushAuthorizationRequest still succeeds for a +// Relying Party this server has never seen before, authenticated with a +// client assertion signed by the key that RP published in its own +// federation-resolved openid_relying_party metadata. +func TestNewWiresAutomaticRegistrationIntoPushAuthorizationRequest(t *testing.T) { + f := setupAutomaticRegistrationFixture(t, testRedirectURI) + + issuer, err := fapi.ParseIssuerURL(testIssuer) + if err != nil { + t.Fatalf("ParseIssuerURL: %v", err) + } + cfg := server.Config{ + Issuer: issuer, + Endpoints: testEndpoints(t), + Profile: server.ProfileFAPISecurity, + Algorithms: server.AlgorithmPolicy{ + ClientAssertion: server.AlgorithmSet{fapi.ES256}, + RequestObject: server.AlgorithmSet{fapi.ES256}, + JARM: fapi.ES256, + IDToken: fapi.ES256, + }, + Limits: server.Limits{ + PushedRequestLifetime: 90 * time.Second, + MaxClientAssertionLifetime: time.Minute, + MaxRequestObjectLifetime: time.Minute, + InteractionLifetime: 5 * time.Minute, + AuthorizationCodeLifetime: time.Minute, + JARMResponseLifetime: time.Minute, + AccessTokenLifetime: 5 * time.Minute, + IDTokenLifetime: 5 * time.Minute, + RefreshTokenLifetime: 5 * time.Minute, + MaxDPoPProofAge: time.Minute, + MaxClockSkew: 5 * time.Second, + }, + Assurance: server.AssuranceDevelopment, + AutomaticRegistration: validAutomaticRegistrationServerConfig(f), + } + serverKey := generateKey(t) + serverKeyManager := &fakeKeyManager{key: serverKey, keyID: "as-key-1"} + deps := server.Dependencies{ + // Deliberately empty: no statically registered client would + // ever resolve, so PushAuthorizationRequest's success below can + // only be explained by AutomaticRegistration having kicked in. + Clients: &fakeClientRepository{clients: map[fapi.ClientID]storage.RegisteredClient{}}, + Transactions: &fakeTransactionStore{}, + Grants: &fakeGrantStore{}, + Replay: &fakeReplayStore{}, + ClientKeys: &fakeClientKeySource{keysByClient: map[fapi.ClientID][]keys.VerificationKey{}}, + Keys: serverKeyManager, + AccessTokens: server.JWTAccessTokens{Keys: serverKeyManager, Algorithm: fapi.ES256}, + Revocation: &fakeRevocationSink{}, + Audit: &fakeAuditSink{}, + Clock: fixedClock{now: f.now}, + Random: rand.Reader, + FederationHTTP: f.fetcher, + } + + srv, err := server.New(cfg, deps) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + assertion, err := clientassertion.CreateAssertion(clientassertion.AssertionRequest{ + Signer: f.rpOIDCKey, Algorithm: fapi.ES256, KeyID: "rp-oidc", + ClientID: f.rpID, Audience: testIssuer, + Now: f.now, Lifetime: 30 * time.Second, + }) + if err != nil { + t.Fatalf("CreateAssertion: %v", err) + } + + if _, err := srv.PushAuthorizationRequest(context.Background(), server.PushAuthorizationRequest{ + HTTP: server.FormRequest{Parameters: plainFormParameters(t, assertion, nil)}, + }); err != nil { + t.Fatalf("PushAuthorizationRequest (automatically-registered client): %v", err) + } +} diff --git a/server/config.go b/server/config.go index 2786fa6..f0452d9 100644 --- a/server/config.go +++ b/server/config.go @@ -5,6 +5,7 @@ import ( fapi "github.com/idfoundry/fapigo" "github.com/idfoundry/fapigo/extension" + "github.com/idfoundry/fapigo/federation" ) // Profile selects which FAPI 2.0 security profile this server enforces. @@ -343,6 +344,57 @@ type Config struct { // existing "zero disables the feature" precedent for // Endpoints.BackchannelAuthentication and friends. Federation FederationConfig + + // AutomaticRegistration configures OpenID Federation 1.0 §12.1 + // "Automatic Registration" — see AutomaticRegistrationConfig's own + // doc comment. Zero value (TrustAnchors empty) disables it + // entirely: New never wraps Dependencies.Clients/ClientKeys, and + // this server behaves exactly as it always has, accepting only + // statically registered clients. Independent of Federation — a + // server can self-issue its own Entity Configuration without + // accepting automatic registration, or vice versa. + AutomaticRegistration AutomaticRegistrationConfig +} + +// AutomaticRegistrationConfig configures this server to accept 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 via +// federation.AutomaticClientRepository/AutomaticClientKeySource — see +// their own doc comments for exactly which registration shapes are (and +// are not) supported. New wraps Dependencies.Clients and +// Dependencies.ClientKeys with these when TrustAnchors is non-empty; +// every other server internal (PAR, the token endpoint, CIBA) keeps +// calling those same Dependencies fields exactly as before, unaware of +// the wrapping — statically registered clients (the original +// Dependencies.Clients/ClientKeys) always take priority over a +// federation-resolved one. +type AutomaticRegistrationConfig struct { + // TrustAnchors is every Trust Anchor this server is willing to + // accept an automatically-registered Relying Party's Trust Chain + // rooted at — see federation.TrustAnchor. Required (at least one) + // to enable automatic registration at all. + TrustAnchors []federation.TrustAnchor + + // AllowedScopes is the scope allowlist granted to every + // automatically-registered client, uniformly — see + // federation.AutomaticRegistrationConfig.AllowedScopes for why this + // deliberately never comes from an RP's own self-published + // metadata. Required when TrustAnchors is set. + AllowedScopes []string + + // MaxPathLength/MaxStatementLifetime/MaxClockSkew bound Trust Chain + // resolution itself — see federation.Limits, which these configure + // directly. All required when TrustAnchors is set. + MaxPathLength int + MaxStatementLifetime time.Duration + MaxClockSkew time.Duration + + // MaxCacheAge bounds how long a resolved client's Trust Chain is + // reused before it is resolved again — see + // federation.AutomaticRegistrationConfig.MaxCacheAge. Required when + // TrustAnchors is set. + MaxCacheAge time.Duration } // FederationConfig configures this server's OpenID Federation 1.0 diff --git a/server/dependencies.go b/server/dependencies.go index ceb0e62..d50d31b 100644 --- a/server/dependencies.go +++ b/server/dependencies.go @@ -3,6 +3,7 @@ package server import ( "io" + "github.com/idfoundry/fapigo/fapihttp" "github.com/idfoundry/fapigo/keys" "github.com/idfoundry/fapigo/storage" ) @@ -142,4 +143,16 @@ type Dependencies struct { // actually what's wanted. AuthorizationCodeRARPolicy RARPolicy CIBARARPolicy RARPolicy + + // FederationHTTP performs every outbound fetch OpenID Federation + // 1.0 §12.1 "Automatic Registration" needs to resolve an + // automatically-registered Relying Party's own Trust Chain — + // through fapihttp's own hardened protections (ARCHITECTURE.md + // design rule 6), the same primitive federation.Resolver itself + // requires. Required exactly when + // Config.AutomaticRegistration.TrustAnchors is set; nil otherwise — + // like ClientEncryptionKeys/Backchannel, an opt-in dependency this + // server never touches unless the corresponding Config capability + // is enabled. + FederationHTTP *fapihttp.Client } diff --git a/server/server.go b/server/server.go index 9980c59..90547b5 100644 --- a/server/server.go +++ b/server/server.go @@ -36,6 +36,39 @@ func New(cfg Config, deps Dependencies) (*Server, error) { } cfg.Extensions = empty } + if len(cfg.AutomaticRegistration.TrustAnchors) > 0 { + // Wraps deps.Clients/ClientKeys transparently — every other + // internal (PAR, the token endpoint, CIBA) keeps calling those + // same Dependencies fields exactly as before, unaware an + // automatically-registered client is now also possible. The + // original deps.Clients/ClientKeys (statically registered + // clients) are passed in as Underlying, so they always take + // priority — see AutomaticRegistrationConfig's own doc comment. + resolver, err := federation.NewResolver(federation.Config{ + TrustAnchors: cfg.AutomaticRegistration.TrustAnchors, + Limits: federation.Limits{ + MaxPathLength: cfg.AutomaticRegistration.MaxPathLength, + MaxStatementLifetime: cfg.AutomaticRegistration.MaxStatementLifetime, + MaxClockSkew: cfg.AutomaticRegistration.MaxClockSkew, + }, + }, federation.Dependencies{HTTP: deps.FederationHTTP, Clock: deps.Clock}) + if err != nil { + return nil, fmt.Errorf("server: config: automatic_registration: %w", err) + } + automaticClients, err := federation.NewAutomaticClientRepository(deps.Clients, resolver, federation.AutomaticRegistrationConfig{ + AllowedScopes: cfg.AutomaticRegistration.AllowedScopes, + MaxCacheAge: cfg.AutomaticRegistration.MaxCacheAge, + }, deps.Clock) + if err != nil { + return nil, fmt.Errorf("server: config: automatic_registration: %w", err) + } + automaticClientKeys, err := federation.NewAutomaticClientKeySource(deps.ClientKeys, automaticClients) + if err != nil { + return nil, fmt.Errorf("server: config: automatic_registration: %w", err) + } + deps.Clients = automaticClients + deps.ClientKeys = automaticClientKeys + } return &Server{cfg: cfg, deps: deps}, nil } @@ -212,6 +245,21 @@ func validateConfig(cfg Config) error { return fmt.Errorf("server: config: federation.algorithm is required when federation.entity_id is set") } } + + if len(cfg.AutomaticRegistration.TrustAnchors) > 0 { + // TrustAnchors/MaxPathLength/MaxStatementLifetime/MaxClockSkew + // are validated when federation.NewResolver actually constructs + // a Resolver from them in New(), the same "nested validating + // constructor invoked directly in New(), not re-validated here" + // precedent Config.Extensions already follows — only the fields + // federation.NewResolver doesn't itself own need checking here. + if len(cfg.AutomaticRegistration.AllowedScopes) == 0 { + return fmt.Errorf("server: config: automatic_registration.allowed_scopes is required when automatic_registration.trust_anchors is set") + } + if cfg.AutomaticRegistration.MaxCacheAge <= 0 { + return fmt.Errorf("server: config: automatic_registration.max_cache_age must be positive when automatic_registration.trust_anchors is set") + } + } return nil } @@ -277,6 +325,9 @@ func validateDependencies(cfg Config, deps Dependencies) error { if cibaEnabled && deps.BackchannelNotifier == nil { return fmt.Errorf("server: dependencies: backchannel notifier is required when endpoints.backchannel_authentication is set") } + if len(cfg.AutomaticRegistration.TrustAnchors) > 0 && deps.FederationHTTP == nil { + return fmt.Errorf("server: dependencies: federation_http is required when automatic_registration.trust_anchors is set") + } if cfg.Assurance == AssuranceProduction { if deps.Audit == nil { return fmt.Errorf("server: dependencies: audit is required under AssuranceProduction")