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")