From 459c7f578c88bcaf074dc7f1b1305ad4640676e4 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Fri, 11 Sep 2026 03:48:30 +0800 Subject: [PATCH 1/2] feat(conformance-as): add OpenID Federation self-issuance support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional "federation" config block wiring server.Config.Federation and server.Config.AutomaticRegistration into cmd/conformance-as, and serves the resulting self-issued Entity Configuration at /.well-known/openid-federation — carrying the same discovery document already served at /.well-known/openid-configuration as its own "openid_provider" Entity Type metadata, plus client_registration_types_supported: ["automatic"] (OpenID Federation 1.0 §12.1's own MUST for an OP supporting Automatic Registration). Omitting the "federation" key from the config file disables all of this entirely, matching the module's own established "zero/nil disables the feature" convention — every existing config file and test call site is unaffected. First step toward a real OpenID Federation conformance run against this binary; verified with a new smoke test that starts the real production wiring, fetches the well-known endpoint over real TLS, and verifies the returned statement self-verifies and carries the expected metadata. The actual live suite run (docker-compose service, trust anchor setup, automatic registration exercised end to end) follows in a later change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017N2kkxv9BR4Qmj8De3Ucs6 --- cmd/conformance-as/config.go | 82 ++++++++++ cmd/conformance-as/federation.go | 53 +++++++ cmd/conformance-as/federation_smoke_test.go | 165 ++++++++++++++++++++ cmd/conformance-as/metadata.go | 41 +++-- cmd/conformance-as/router.go | 7 +- cmd/conformance-as/wiring.go | 49 +++++- 6 files changed, 380 insertions(+), 17 deletions(-) create mode 100644 cmd/conformance-as/federation.go create mode 100644 cmd/conformance-as/federation_smoke_test.go diff --git a/cmd/conformance-as/config.go b/cmd/conformance-as/config.go index 90698e6..7312f12 100644 --- a/cmd/conformance-as/config.go +++ b/cmd/conformance-as/config.go @@ -25,6 +25,7 @@ import ( "time" fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/federation" "github.com/idfoundry/fapigo/keys/ephemeral" "github.com/idfoundry/fapigo/server" "github.com/idfoundry/fapigo/storage" @@ -56,12 +57,52 @@ type Config struct { Clients []ClientConfig `json:"clients"` + // Federation enables OpenID Federation 1.0 self-issuance + // (.well-known/openid-federation) and automatic client registration + // (§12.1) — see FederationConfig's own doc comment. Omit the + // "federation" key entirely (nil) to disable both: this binary then + // behaves exactly as it always has, serving no federation + // well-known endpoint and accepting only the Clients registered + // above. + Federation *FederationConfig `json:"federation,omitempty"` + TLS struct { CertFile string `json:"cert_file"` KeyFile string `json:"key_file"` } `json:"tls"` } +// FederationConfig configures this binary's OpenID Federation 1.0 +// support — see server.Config.Federation/AutomaticRegistration, which +// this maps directly onto. +type FederationConfig struct { + // EntityID is this AS's own Entity Identifier — conventionally + // equal to Issuer, since the federation well-known endpoint is + // served from the same origin. Required. + EntityID string `json:"entity_id"` + + // AuthorityHints is this AS's own "authority_hints" claim — see + // federation.SelfIssueConfig.AuthorityHints. Optional. + AuthorityHints []string `json:"authority_hints,omitempty"` + + // TrustAnchors is every Trust Anchor this AS accepts an + // automatically-registered Relying Party's Trust Chain rooted at — + // see server.AutomaticRegistrationConfig.TrustAnchors. Required — + // at least one. + TrustAnchors []TrustAnchorConfig `json:"trust_anchors"` + + // AllowedScopes is the scope allowlist granted to every + // automatically-registered client — see + // server.AutomaticRegistrationConfig.AllowedScopes. Required. + AllowedScopes []string `json:"allowed_scopes"` +} + +// TrustAnchorConfig is one federation.TrustAnchor, as JSON. +type TrustAnchorConfig struct { + EntityID string `json:"entity_id"` + JWKS json.RawMessage `json:"jwks"` +} + // AccessTokenFormat selects which server.AccessTokenIssuer/ // resource.AccessTokenResolver pair newServerMux wires up — see // main.go's -access-token-format flag. Deliberately not a Config field @@ -183,10 +224,23 @@ type ResolvedConfig struct { // scopes, published at the metadata endpoint's scopes_supported. AdvertisedScopes []string + // Federation is non-nil exactly when Config.Federation was set — + // see FederationConfig's own doc comment. + Federation *ResolvedFederation + TLSCertFile string TLSKeyFile string } +// ResolvedFederation is FederationConfig translated into the typed +// values server.Config.Federation/AutomaticRegistration need. +type ResolvedFederation struct { + EntityID string + AuthorityHints []string + TrustAnchors []federation.TrustAnchor + AllowedScopes []string +} + // Resolve validates cfg and converts it into a ResolvedConfig, or a // descriptive error identifying the first problem found. func (cfg Config) Resolve(allowLoopbackHTTP bool, accessTokenFormat AccessTokenFormat) (ResolvedConfig, error) { @@ -278,6 +332,34 @@ func (cfg Config) Resolve(allowLoopbackHTTP bool, accessTokenFormat AccessTokenF } } + if cfg.Federation != nil { + if cfg.Federation.EntityID == "" { + return ResolvedConfig{}, fmt.Errorf("federation.entity_id is required") + } + if len(cfg.Federation.TrustAnchors) == 0 { + return ResolvedConfig{}, fmt.Errorf("federation.trust_anchors: at least one is required") + } + if len(cfg.Federation.AllowedScopes) == 0 { + return ResolvedConfig{}, fmt.Errorf("federation.allowed_scopes is required") + } + anchors := make([]federation.TrustAnchor, len(cfg.Federation.TrustAnchors)) + for i, a := range cfg.Federation.TrustAnchors { + if a.EntityID == "" { + return ResolvedConfig{}, fmt.Errorf("federation.trust_anchors[%d].entity_id is required", i) + } + if len(a.JWKS) == 0 { + return ResolvedConfig{}, fmt.Errorf("federation.trust_anchors[%d].jwks is required", i) + } + anchors[i] = federation.TrustAnchor{EntityID: a.EntityID, JWKS: a.JWKS} + } + out.Federation = &ResolvedFederation{ + EntityID: cfg.Federation.EntityID, + AuthorityHints: cfg.Federation.AuthorityHints, + TrustAnchors: anchors, + AllowedScopes: cfg.Federation.AllowedScopes, + } + } + return out, nil } diff --git a/cmd/conformance-as/federation.go b/cmd/conformance-as/federation.go new file mode 100644 index 0000000..fca7b16 --- /dev/null +++ b/cmd/conformance-as/federation.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/url" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/federation" + "github.com/idfoundry/fapigo/server" +) + +// federationOpenIDProviderMetadata is buildWireMetadata's own document +// (the same one served at /.well-known/openid-configuration), extended +// with client_registration_types_supported — required by OpenID +// Federation 1.0 §12.1 on any OP that supports Automatic Registration, +// and not otherwise part of OIDC Discovery, so it's added only here, +// never on the plain discovery document. +type federationOpenIDProviderMetadata struct { + wireMetadata + ClientRegistrationTypesSupported []string `json:"client_registration_types_supported"` +} + +// wellKnownFederationHandler serves this AS's own self-issued Entity +// Configuration (OpenID Federation 1.0 §3.1/§9) at +// /.well-known/openid-federation — buildWireMetadata's own document, +// carried as this statement's "openid_provider" Entity Type metadata, +// signed with the federation identity key Config.Federation configures +// (see wiring.go's own federationSigningAlgorithm/keys.FederationEntitySigning). +// Only registered at all when Config.Federation is set (router.go). +func wellKnownFederationHandler(srv *server.Server, advertisedScopes []string, userinfoURL *url.URL, mtlsUserinfoURL *fapi.URL) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + doc := federationOpenIDProviderMetadata{ + wireMetadata: buildWireMetadata(srv, ctx, advertisedScopes, userinfoURL, mtlsUserinfoURL), + ClientRegistrationTypesSupported: []string{"automatic"}, + } + opMetadata, err := json.Marshal(doc) + if err != nil { + http.Error(w, "server_error", http.StatusInternalServerError) + return + } + token, err := srv.EntityConfiguration(ctx, map[string]json.RawMessage{ + "openid_provider": opMetadata, + }) + if err != nil { + http.Error(w, "server_error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", federation.EntityStatementContentType) + _, _ = w.Write([]byte(token)) + } +} diff --git a/cmd/conformance-as/federation_smoke_test.go b/cmd/conformance-as/federation_smoke_test.go new file mode 100644 index 0000000..511354e --- /dev/null +++ b/cmd/conformance-as/federation_smoke_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/federation" + intfed "github.com/idfoundry/fapigo/internal/federation" + "github.com/idfoundry/fapigo/internal/jose" + "github.com/idfoundry/fapigo/keys/ephemeral" + "github.com/idfoundry/fapigo/server" + "github.com/idfoundry/fapigo/storage" +) + +// TestSmokeFederationWellKnownEndpoint is the go/no-go gate before +// pointing a real OIDF Federation conformance run at this binary's +// -federation-enabled wiring: it starts the real production mux +// (newServerMux) with Config.Federation set, fetches +// /.well-known/openid-federation over real TLS, and verifies the +// returned Entity Configuration self-verifies and carries the +// openid_provider metadata this AS actually serves at +// /.well-known/openid-configuration, plus +// client_registration_types_supported (OpenID Federation 1.0 §12.1's +// own MUST for an OP that supports Automatic Registration). +func TestSmokeFederationWellKnownEndpoint(t *testing.T) { + cert, pool := selfSignedCert(t) + tcpListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tlsListener := tls.NewListener(tcpListener, &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }) + + issuer, err := fapi.ParseIssuerURL(fmt.Sprintf("https://%s", tcpListener.Addr().String())) + if err != nil { + t.Fatalf("parse issuer: %v", err) + } + + // A minimal static client, just to satisfy newServerMux's own + // "at least one registered client" requirement — this test only + // exercises the well-known endpoint, not automatic registration + // itself (that needs a real Trust Anchor and peer entity, covered + // live against the OIDF suite instead). + const testClientID = fapi.ClientID("federation-smoke-test-client") + registered, err := storage.NewRegisteredClient(storage.RegisteredClientConfig{ + ID: testClientID, + RedirectURIs: []fapi.RegisteredRedirectURI{"https://rp.smoketest.internal/callback"}, + ClientAssertionAlgorithm: fapi.ES256, + AllowedScopes: []string{"openid"}, + }) + if err != nil { + t.Fatalf("build registered client: %v", err) + } + + // Trust Anchor content is irrelevant to this test (no automatic + // registration is exercised) — any well-formed entry satisfies + // Resolve's own validation. + dummyAnchorJWKS := json.RawMessage(`{"keys":[]}`) + + resolved := ResolvedConfig{ + ListenAddr: tcpListener.Addr().String(), + Issuer: issuer, + Profile: server.ProfileFAPISecurity, + DefaultSubject: smokeSubject, + Algorithms: server.RecommendedAlgorithms(), + Limits: server.RecommendedLimits(), + AccessTokenFormat: AccessTokenFormatJWT, + Clients: []storage.RegisteredClient{registered}, + ClientKeys: []ephemeral.ClientKeySpec{{ClientID: testClientID, JWKS: json.RawMessage(`{"keys":[]}`)}}, + AdvertisedScopes: []string{"openid"}, + Federation: &ResolvedFederation{ + EntityID: issuer.String(), + TrustAnchors: []federation.TrustAnchor{{EntityID: "https://ta.smoketest.internal", JWKS: dummyAnchorJWKS}}, + AllowedScopes: []string{"openid"}, + }, + } + + mux, err := newServerMux(resolved, false, false, false, false, false, "") + if err != nil { + t.Fatalf("build server mux: %v", err) + } + httpServer := &http.Server{Handler: mux} + go httpServer.Serve(tlsListener) //nolint:errcheck + t.Cleanup(func() { httpServer.Close() }) + + httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}} + + // The listener may not have accepted its first connection yet the + // instant Serve's goroutine is scheduled — retry briefly rather + // than introduce a fixed sleep. + var resp *http.Response + deadline := time.Now().Add(5 * time.Second) + for { + resp, err = httpClient.Get(issuer.String() + federation.WellKnownPath) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("GET %s: %v", federation.WellKnownPath, err) + } + time.Sleep(10 * time.Millisecond) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s: status = %d, want 200", federation.WellKnownPath, resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != federation.EntityStatementContentType { + t.Errorf("Content-Type = %q, want %q", got, federation.EntityStatementContentType) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + + stmt, err := intfed.Parse(string(body)) + if err != nil { + t.Fatalf("intfed.Parse: %v", err) + } + if stmt.ClaimedIssuer() != issuer.String() || stmt.ClaimedSubject() != issuer.String() { + t.Errorf("iss/sub = %q/%q, want both equal to the issuer", stmt.ClaimedIssuer(), stmt.ClaimedSubject()) + } + + candidates, err := jose.ParseJWKSet(stmt.ClaimedJWKS()) + if err != nil { + t.Fatalf("jose.ParseJWKSet(ClaimedJWKS): %v", err) + } + if len(candidates) != 1 { + t.Fatalf("ParseJWKSet returned %d keys, want 1", len(candidates)) + } + claims, err := stmt.Verify(candidates[0].PublicKey, intfed.VerifyPolicy{ + ExpectedIssuer: issuer.String(), ExpectedSubject: issuer.String(), + Algorithm: fapi.ES256, Now: time.Now(), MaxLifetime: 2 * time.Hour, + }) + if err != nil { + t.Fatalf("Verify(self-issued statement against its own claimed key): %v", err) + } + + op, ok := claims.Metadata["openid_provider"] + if !ok { + t.Fatalf("Metadata missing openid_provider: %v", claims.Metadata) + } + var opMeta struct { + Issuer string `json:"issuer"` + ClientRegistrationTypesSupported []string `json:"client_registration_types_supported"` + } + if err := json.Unmarshal(op, &opMeta); err != nil { + t.Fatalf("unmarshal openid_provider metadata: %v", err) + } + if opMeta.Issuer != issuer.String() { + t.Errorf("openid_provider.issuer = %q, want %q", opMeta.Issuer, issuer.String()) + } + if len(opMeta.ClientRegistrationTypesSupported) != 1 || opMeta.ClientRegistrationTypesSupported[0] != "automatic" { + t.Errorf("openid_provider.client_registration_types_supported = %v, want [\"automatic\"]", opMeta.ClientRegistrationTypesSupported) + } +} diff --git a/cmd/conformance-as/metadata.go b/cmd/conformance-as/metadata.go index 220def9..151265d 100644 --- a/cmd/conformance-as/metadata.go +++ b/cmd/conformance-as/metadata.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "net/http" "net/url" @@ -63,22 +64,32 @@ var dpopSigningAlgValuesSupported = []string{fapi.ES256.String(), fapi.PS256.Str func metadataHandler(srv *server.Server, advertisedScopes []string, userinfoURL *url.URL, mtlsUserinfoURL *fapi.URL) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - md := srv.Metadata(r.Context()) - doc := wireMetadata{ - Metadata: md, - ScopesSupported: advertisedScopes, - ClaimsSupported: claimsSupported, - ClaimsParameterSupported: true, - UserinfoEndpoint: userinfoURL.String(), - DPoPSigningAlgValuesSupported: dpopSigningAlgValuesSupported, - } - if md.MTLSEndpointAliases != nil && mtlsUserinfoURL != nil { - doc.MTLSEndpointAliases = &wireMTLSEndpointAliases{ - MTLSEndpointAliases: *md.MTLSEndpointAliases, - UserinfoEndpoint: *mtlsUserinfoURL, - } - } + doc := buildWireMetadata(srv, r.Context(), advertisedScopes, userinfoURL, mtlsUserinfoURL) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(doc) } } + +// buildWireMetadata assembles this AS's full discovery document — +// shared by metadataHandler (GET /.well-known/openid-configuration) and +// wellKnownFederationHandler (federation.go), which embeds the same +// document as its own "openid_provider" Entity Type metadata rather +// than building it twice. +func buildWireMetadata(srv *server.Server, ctx context.Context, advertisedScopes []string, userinfoURL *url.URL, mtlsUserinfoURL *fapi.URL) wireMetadata { + md := srv.Metadata(ctx) + doc := wireMetadata{ + Metadata: md, + ScopesSupported: advertisedScopes, + ClaimsSupported: claimsSupported, + ClaimsParameterSupported: true, + UserinfoEndpoint: userinfoURL.String(), + DPoPSigningAlgValuesSupported: dpopSigningAlgValuesSupported, + } + if md.MTLSEndpointAliases != nil && mtlsUserinfoURL != nil { + doc.MTLSEndpointAliases = &wireMTLSEndpointAliases{ + MTLSEndpointAliases: *md.MTLSEndpointAliases, + UserinfoEndpoint: *mtlsUserinfoURL, + } + } + return doc +} diff --git a/cmd/conformance-as/router.go b/cmd/conformance-as/router.go index 0c67142..732c73a 100644 --- a/cmd/conformance-as/router.go +++ b/cmd/conformance-as/router.go @@ -23,9 +23,14 @@ import ( // POST, and an OIDF suite plan config's resource.resourceMethod is a // free-form per-run choice for the generic-resource role this endpoint // also plays — nothing here depends on which method the suite picks. -func newRouter(srv *server.Server, consent *consentHandler, backchannel *backchannelHandler, advertisedScopes []string, resourceVerifier *fapires.Verifier, userinfoURL *url.URL, mtlsUserinfoURL *fapi.URL, accountsURL *url.URL, identityClaims staticIdentityClaims, clients storage.ClientRepository, userinfoSigning bool, cibaApprovalUIToken string) *http.ServeMux { +func newRouter(srv *server.Server, consent *consentHandler, backchannel *backchannelHandler, advertisedScopes []string, resourceVerifier *fapires.Verifier, userinfoURL *url.URL, mtlsUserinfoURL *fapi.URL, accountsURL *url.URL, identityClaims staticIdentityClaims, clients storage.ClientRepository, userinfoSigning bool, cibaApprovalUIToken string, federationEnabled bool) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("GET /.well-known/openid-configuration", metadataHandler(srv, advertisedScopes, userinfoURL, mtlsUserinfoURL)) + // Off by default (Config.Federation unset) — see federation.go's own + // doc comment. + if federationEnabled { + mux.HandleFunc("GET /.well-known/openid-federation", wellKnownFederationHandler(srv, advertisedScopes, userinfoURL, mtlsUserinfoURL)) + } mux.HandleFunc("GET /jwks", jwksHandler(srv)) mux.HandleFunc("POST /par", parHandler(srv)) mux.HandleFunc("GET /authorize", consent.handleBegin) diff --git a/cmd/conformance-as/wiring.go b/cmd/conformance-as/wiring.go index fb49410..dcfb130 100644 --- a/cmd/conformance-as/wiring.go +++ b/cmd/conformance-as/wiring.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "fmt" "net/http" + "time" fapi "github.com/idfoundry/fapigo" "github.com/idfoundry/fapigo/fapihttp" @@ -14,6 +15,28 @@ import ( "github.com/idfoundry/fapigo/storage/memstore" ) +// federationSigningAlgorithm is the algorithm this binary self-issues +// its Entity Configuration with, and requires of an +// automatically-registered client's own openid_relying_party +// private_key_jwt registration — matches every other algorithm choice +// in this binary (server.RecommendedAlgorithms()'s own ES256 default). +const federationSigningAlgorithm = fapi.ES256 + +// federationStatementLifetime/federationMaxStatementLifetime/ +// federationMaxPathLength/federationMaxCacheAge bound this binary's own +// federation.SelfIssuer/federation.Resolver — see server.FederationConfig/ +// AutomaticRegistrationConfig for what each configures. Generous, fixed +// values: this binary's own federation posture isn't a conformance-run +// dimension the way -ciba/-mtls are, so unlike Algorithms/Limits (see +// Config's own doc comment) there's no server.Recommended* federation +// equivalent to defer to yet. +const ( + federationStatementLifetime = time.Hour + federationMaxStatementLifetime = 2 * time.Hour + federationMaxPathLength = 5 + federationMaxCacheAge = 5 * time.Minute +) + // newServerMux builds the full server.Server + HTTP router wiring from a // resolved config — everything main needs before it can start listening. // Factored out so the end-to-end smoke test can stand up the exact same @@ -64,6 +87,9 @@ func newServerMux(resolved ResolvedConfig, allowLoopbackHTTP bool, dpopNonceChal if userinfoSigning { purposes[keys.UserInfoSigning] = resolved.Algorithms.IDToken } + if resolved.Federation != nil { + purposes[keys.FederationEntitySigning] = federationSigningAlgorithm + } keyManager, err := ephemeral.NewKeyManager(purposes) if err != nil { return nil, err @@ -136,6 +162,22 @@ func newServerMux(resolved ResolvedConfig, allowLoopbackHTTP bool, dpopNonceChal // above. ClientCredentialsGrant: clientCredentialsGrant, } + if resolved.Federation != nil { + srvCfg.Federation = server.FederationConfig{ + EntityID: resolved.Federation.EntityID, + AuthorityHints: resolved.Federation.AuthorityHints, + Lifetime: federationStatementLifetime, + Algorithm: federationSigningAlgorithm, + } + srvCfg.AutomaticRegistration = server.AutomaticRegistrationConfig{ + TrustAnchors: resolved.Federation.TrustAnchors, + AllowedScopes: resolved.Federation.AllowedScopes, + MaxPathLength: federationMaxPathLength, + MaxStatementLifetime: federationMaxStatementLifetime, + MaxClockSkew: resolved.Limits.MaxClockSkew, + MaxCacheAge: federationMaxCacheAge, + } + } replayStore := memstore.NewReplayStore() revocationStore := memstore.NewRevocationStore() identityClaims := newStaticIdentityClaims(resolved.DefaultSubject, server.SystemClock{}) @@ -213,6 +255,11 @@ func newServerMux(resolved ResolvedConfig, allowLoopbackHTTP bool, dpopNonceChal ClientCredentialsRARPolicy: sampleRARPolicy{}, AuthorizationCodeRARPolicy: sampleRARPolicy{}, CIBARARPolicy: sampleRARPolicy{}, + // Only required when srvCfg.AutomaticRegistration.TrustAnchors + // is set (above); harmless to set unconditionally otherwise — + // reuses the same fapihttp.Client already built for + // ephemeral.NewClientKeySource's own remote jwks_uri fetches. + FederationHTTP: fetcher, } // Off by default (main.go's -dpop-nonce-challenge flag) — same // reasoning as the resource-side block below: client.ExchangeCode @@ -271,5 +318,5 @@ func newServerMux(resolved ResolvedConfig, allowLoopbackHTTP bool, dpopNonceChal backchannel := newBackchannelHandler(srv, server.SystemClock{}, resolved.DefaultSubject) userinfoURLValue := userinfoURL.URL() accountsURLValue := accountsURL.URL() - return newRouter(srv, consent, backchannel, resolved.AdvertisedScopes, resourceVerifier, &userinfoURLValue, mtlsUserinfoURL, &accountsURLValue, identityClaims, clientRepo, userinfoSigning, cibaApprovalUIToken), nil + return newRouter(srv, consent, backchannel, resolved.AdvertisedScopes, resourceVerifier, &userinfoURLValue, mtlsUserinfoURL, &accountsURLValue, identityClaims, clientRepo, userinfoSigning, cibaApprovalUIToken, resolved.Federation != nil), nil } From 1d005364d5a4709ebeb239915c93ebc32efbcad5 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Fri, 11 Sep 2026 03:55:32 +0800 Subject: [PATCH 2/2] test: cover Config.Resolve's new federation validation branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config.Resolve had no direct test coverage at all before this PR (every existing config_test.go case calls the unexported per-client resolveClient helper instead) — this PR's own federation validation additions inherited that gap wholesale, failing both codecov/patch and SonarCloud's new_coverage gate. Adds direct Resolve tests: a valid config with no federation block, a valid one with federation, every federation validation failure this PR added, and (as a side effect of finally exercising Resolve directly) the handful of pre-existing top-level validation branches that had never been covered either. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017N2kkxv9BR4Qmj8De3Ucs6 --- cmd/conformance-as/federation_config_test.go | 122 +++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 cmd/conformance-as/federation_config_test.go diff --git a/cmd/conformance-as/federation_config_test.go b/cmd/conformance-as/federation_config_test.go new file mode 100644 index 0000000..d6186fd --- /dev/null +++ b/cmd/conformance-as/federation_config_test.go @@ -0,0 +1,122 @@ +package main + +import "testing" + +func validTopLevelConfig() Config { + return Config{ + ListenAddr: "127.0.0.1:8443", + Issuer: "https://as.example.org", + Profile: "fapi2-security", + DefaultSubject: "test-user", + Clients: []ClientConfig{validClientConfig()}, + } +} + +func validFederationConfig() *FederationConfig { + return &FederationConfig{ + EntityID: "https://as.example.org", + TrustAnchors: []TrustAnchorConfig{ + {EntityID: "https://ta.example.org", JWKS: []byte(`{"keys":[]}`)}, + }, + AllowedScopes: []string{"openid"}, + } +} + +func TestResolveAcceptsValidConfig(t *testing.T) { + cfg := validTopLevelConfig() + resolved, err := cfg.Resolve(true, AccessTokenFormatJWT) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolved.Federation != nil { + t.Errorf("Federation = %+v, want nil (no federation block in cfg)", resolved.Federation) + } +} + +func TestResolveAcceptsValidFederationConfig(t *testing.T) { + cfg := validTopLevelConfig() + cfg.Federation = validFederationConfig() + resolved, err := cfg.Resolve(true, AccessTokenFormatJWT) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolved.Federation == nil { + t.Fatalf("Federation = nil, want non-nil") + } + if resolved.Federation.EntityID != "https://as.example.org" { + t.Errorf("Federation.EntityID = %q", resolved.Federation.EntityID) + } + if len(resolved.Federation.TrustAnchors) != 1 || resolved.Federation.TrustAnchors[0].EntityID != "https://ta.example.org" { + t.Errorf("Federation.TrustAnchors = %v", resolved.Federation.TrustAnchors) + } + if len(resolved.Federation.AllowedScopes) != 1 || resolved.Federation.AllowedScopes[0] != "openid" { + t.Errorf("Federation.AllowedScopes = %v", resolved.Federation.AllowedScopes) + } +} + +func TestResolveRejectsInvalidFederationConfig(t *testing.T) { + cases := map[string]func(*FederationConfig){ + "empty entity_id": func(f *FederationConfig) { f.EntityID = "" }, + "no trust anchors": func(f *FederationConfig) { f.TrustAnchors = nil }, + "trust anchor missing id": func(f *FederationConfig) { f.TrustAnchors[0].EntityID = "" }, + "trust anchor missing jwks": func(f *FederationConfig) { f.TrustAnchors[0].JWKS = nil }, + "empty allowed_scopes": func(f *FederationConfig) { f.AllowedScopes = nil }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + cfg := validTopLevelConfig() + fedCfg := validFederationConfig() + mutate(fedCfg) + cfg.Federation = fedCfg + if _, err := cfg.Resolve(true, AccessTokenFormatJWT); err == nil { + t.Fatalf("Resolve(%s) = nil error, want error", name) + } + }) + } +} + +func TestResolveRejectsInvalidAccessTokenFormat(t *testing.T) { + cfg := validTopLevelConfig() + if _, err := cfg.Resolve(true, AccessTokenFormat("bogus")); err == nil { + t.Fatalf("Resolve(bogus access token format) = nil error, want error") + } +} + +func TestResolveRejectsMissingListenAddr(t *testing.T) { + cfg := validTopLevelConfig() + cfg.ListenAddr = "" + if _, err := cfg.Resolve(true, AccessTokenFormatJWT); err == nil { + t.Fatalf("Resolve(no listen_addr) = nil error, want error") + } +} + +func TestResolveRejectsMissingDefaultSubject(t *testing.T) { + cfg := validTopLevelConfig() + cfg.DefaultSubject = "" + if _, err := cfg.Resolve(true, AccessTokenFormatJWT); err == nil { + t.Fatalf("Resolve(no default_subject) = nil error, want error") + } +} + +func TestResolveRejectsInvalidProfile(t *testing.T) { + cfg := validTopLevelConfig() + cfg.Profile = "bogus" + if _, err := cfg.Resolve(true, AccessTokenFormatJWT); err == nil { + t.Fatalf("Resolve(invalid profile) = nil error, want error") + } +} + +func TestResolveRejectsNoClients(t *testing.T) { + cfg := validTopLevelConfig() + cfg.Clients = nil + if _, err := cfg.Resolve(true, AccessTokenFormatJWT); err == nil { + t.Fatalf("Resolve(no clients) = nil error, want error") + } +} + +func TestResolveRejectsMissingTLSWithoutLoopback(t *testing.T) { + cfg := validTopLevelConfig() + if _, err := cfg.Resolve(false, AccessTokenFormatJWT); err == nil { + t.Fatalf("Resolve(no TLS cert/key, allowLoopbackHTTP=false) = nil error, want error") + } +}