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_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") + } +} 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 }