Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions cmd/conformance-as/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down
53 changes: 53 additions & 0 deletions cmd/conformance-as/federation.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
122 changes: 122 additions & 0 deletions cmd/conformance-as/federation_config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading