From c8837e86cd62d7c5e7c0507563f4e8714a7c19e2 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Wed, 30 Sep 2026 00:11:55 +0800 Subject: [PATCH 1/2] docs(examples): add the federated-union OpenID Federation demo examples/federated-union runs three fictional countries' identity federations joined into one OpenID Federation 1.0 trust anchor, the Meridian Union, in one process with each entity at its own *.localhost host. A service in one country signs in a citizen of another with no prior registration on either side: services find identity providers by resolving their Trust Chains, and identity providers register services automatically. The Union's metadata policy and naming constraints apply on top of each country's own, identity providers carry level-of-assurance Trust Marks from accreditation offices the Union lists in trust_mark_issuers, and citizens choose which requested claims to share. A console shows every member, decodes its statements, resolves any Trust Chain, logs federation traffic and runs three scenes: a forged assurance mark, a suspended country, and a compromised national authority contained by naming constraints. It's a separate Go module built against this checkout, uses only the public API, and has end-to-end tests for every scene. CI gains an examples job for it; release-please and Sonar's coverage metric exclude it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 29 ++ README.md | 5 + examples/federated-union/.gitignore | 2 + examples/federated-union/README.md | 126 ++++++ .../cmd/federated-union/main.go | 42 ++ examples/federated-union/go.mod | 10 + .../internal/demonet/demonet.go | 211 +++++++++ .../federated-union/union/accreditation.go | 49 +++ examples/federated-union/union/console.go | 303 +++++++++++++ examples/federated-union/union/e2e_test.go | 180 ++++++++ examples/federated-union/union/entity.go | 196 +++++++++ examples/federated-union/union/idp.go | 412 ++++++++++++++++++ examples/federated-union/union/pages.go | 231 ++++++++++ examples/federated-union/union/rp.go | 367 ++++++++++++++++ examples/federated-union/union/world.go | 361 +++++++++++++++ release-please-config.json | 3 +- sonar-project.properties | 7 +- 17 files changed, 2532 insertions(+), 2 deletions(-) create mode 100644 examples/federated-union/.gitignore create mode 100644 examples/federated-union/README.md create mode 100644 examples/federated-union/cmd/federated-union/main.go create mode 100644 examples/federated-union/go.mod create mode 100644 examples/federated-union/internal/demonet/demonet.go create mode 100644 examples/federated-union/union/accreditation.go create mode 100644 examples/federated-union/union/console.go create mode 100644 examples/federated-union/union/e2e_test.go create mode 100644 examples/federated-union/union/entity.go create mode 100644 examples/federated-union/union/idp.go create mode 100644 examples/federated-union/union/pages.go create mode 100644 examples/federated-union/union/rp.go create mode 100644 examples/federated-union/union/world.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 648b5b90..034f933d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,35 @@ jobs: with: version: v2.12 + # examples/federated-union is its own Go module (it builds against + # this checkout through a replace directive), so the jobs above never + # see it. This keeps it compiling, linted and passing its end-to-end + # tests as the library changes underneath it. + examples: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/federated-union + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version-file: examples/federated-union/go.mod + check-latest: true + cache-dependency-path: examples/federated-union/go.mod + + - name: go vet + run: go vet ./... + + - name: go test (race) + run: go test -race ./... + + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.12 + working-directory: examples/federated-union + govulncheck: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index c7078e37..d530e015 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ See [GETTING_STARTED.md](GETTING_STARTED.md) for a full walkthrough of standing up an authorization server and resource server end to end, including a runnable configuration you can start from. +[examples/federated-union](examples/federated-union/README.md) is a +runnable demo: three fictional countries' identity federations joined +into one OpenID Federation, with cross-border sign-in, automatic +registration, accredited Trust Marks and a console of attack scenes. + `storage/memstore` and `keys/ephemeral` provide in-memory, non-durable implementations of every interface `server` needs (client repository, transaction/grant/replay/access-token stores, key manager, client key diff --git a/examples/federated-union/.gitignore b/examples/federated-union/.gitignore new file mode 100644 index 00000000..2328b500 --- /dev/null +++ b/examples/federated-union/.gitignore @@ -0,0 +1,2 @@ +# Written at startup so a browser can be told to trust the demo's CA. +union-ca.pem diff --git a/examples/federated-union/README.md b/examples/federated-union/README.md new file mode 100644 index 00000000..7ceb8926 --- /dev/null +++ b/examples/federated-union/README.md @@ -0,0 +1,126 @@ +# Federated union demo + +Three fictional countries — **Northland**, **Southport** and +**Eastmark** — each run their own identity federation, then join the +**Meridian Union**, an [OpenID Federation 1.0](https://openid.net/specs/openid-federation-1_0.html) +trust anchor above them. A service in one country can then accept a +citizen of another: neither registers with the other beforehand, and the +Union's rules apply on top of each country's own. + +Everything runs in one process, on your machine, each entity at its own +`*.localhost` host. + +> This is a demo. Every store is in memory, every key is generated at +> startup, and the countries, schemes and people are made up. + +This is a separate Go module. It builds against this checkout of the +library (`replace` in `go.mod`), and release-please ignores it. + +## Running it + +```sh +cd examples/federated-union +go run ./cmd/federated-union +``` + +Then open **https://console.localhost:8443/**. + +The demo issues its own TLS certificate and writes the CA to +`union-ca.pem`. Either trust that CA in your browser, or accept the +certificate warning once per host. `*.localhost` names resolve to your +own machine without any setup in Chrome and Firefox; `-port` changes the +port. + +## Who's who + +``` + Meridian Union (trust anchor) + ┌─────────────────────┼─────────────────────┐ + Northland authority Southport authority Eastmark authority national authorities + │ │ │ │ │ │ + NorthID Accreditation SouthID Accreditation EastID Accreditation identity providers + Office Office Office + accreditation bodies + Southport Eastmark + Savings Bank Telecom services +``` + +| Host | Entity | +|---|---| +| `console.localhost` | The Union's console: members, scenes, Trust Chain inspector, federation traffic | +| `union.localhost` | Meridian Union — the trust anchor | +| `ta..localhost` | Each country's federation authority | +| `accreditation..localhost` | Each country's accreditation office, which issues level-of-assurance Trust Marks | +| `id..localhost` | Each country's identity provider: NorthID, SouthID, EastID | +| `bank.southport.localhost` | Southport Savings Bank | +| `telco.eastmark.localhost` | Eastmark Telecom | +| `bank.northland.localhost` | An impostor, used by one scene | + +Services trust the Union and their own country's authority. Identity +providers do too, and register services they've never seen automatically +(OpenID Federation 1.0 §12.1). + +## What to try + +**1. Cross-border sign-in with no onboarding.** Open Southport Savings +Bank and sign in with **EastID**. The bank finds EastID by resolving its +Trust Chain (EastID → Eastmark authority → Meridian Union); EastID +registers the bank the same way when its request arrives. Neither was +told about the other. + +**2. Union rules on top of national rules.** In the console, open the +bank's **trust chain**. The bank declares three grant types; the Union's +metadata policy allows two, and Southport's adds that services must +publish a contact. Compare "as the entity declares it" with "after every +superior's policy". + +**3. Recognised assurance.** Each identity provider carries a +level-of-assurance Trust Mark from its country's accreditation office. +The Union lists exactly those offices as accredited issuers, and +services only accept providers whose mark checks out. + +**4. Data minimisation.** The bank asks for name, date of birth, +nationality and address. On EastID's consent page, untick the address: +only what's ticked leaves EastID, and the bank's page shows the address +as not shared. + +Then the console's scenes: + +| Scene | What happens | What stops it | +|---|---|---| +| **EastID forges its assurance mark** | EastID publishes a level-of-assurance mark it signed itself | The signature verifies — EastID is a federation member — but the Union doesn't accredit EastID to issue that mark, so services refuse EastID | +| **Suspend Eastmark** | The Union stops vouching for Eastmark's authority | Cross-border sign-ins with EastID fail; Eastmark Telecom, which also trusts Eastmark's authority directly, keeps working | +| **Eastmark's authority is compromised** | Eastmark's authority vouches for an impostor claiming a Northland host | The Union's naming constraints confine Eastmark to `*.eastmark.localhost`: the impostor resolves through Eastmark alone, but not through the Union | + +Identity providers cache a service's registration for 10 seconds, so a +scene can take that long to affect a sign-in already in progress. + +The console's **Recent federation traffic** shows every request one +entity made to another: each Entity Configuration, Subordinate Statement +and key set fetched to build a Trust Chain. + +## How it's built + +Everything here uses FAPIgo's public API only. + +| Piece | FAPIgo | +|---|---| +| Union and national authorities | `federation.SelfIssuer` (with `TrustMarkIssuers`), `federation.SubordinateIssuer` with `MetadataPolicy` and `Constraints` | +| Accreditation offices | `federation.TrustMarkIssuer` | +| Identity providers | `server.Server` with `Config.AutomaticRegistration` and `Config.Federation`; `Server.EntityConfiguration` publishing the Trust Mark; `GrantedAuthorization.ApprovedIdentityClaims` from the consent page | +| Services | `client.DiscoverViaFederation`, `client.NewFromDiscovery`, `BeginAuthorizationRequest.Claims`, `Resolver.VerifyTrustMark` with `RequireFederationAccreditation` | +| Sign-in | PAR with a signed request object, PKCE, DPoP-bound tokens, and the session cookie binding (`client.ParseSessionHandle`) | + +The code: + +- [`union/world.go`](union/world.go) — the countries, the Union and its policy, the scenes. +- [`union/idp.go`](union/idp.go) — the identity providers. +- [`union/rp.go`](union/rp.go) — the services. +- [`union/console.go`](union/console.go) — the console. +- [`internal/demonet`](internal/demonet/demonet.go) — one listener serving every host, and the certificate. +- [`union/e2e_test.go`](union/e2e_test.go) — every scene, driven end to end. + +## Not shown + +- Explicit registration (§12.2) — FAPIgo implements automatic registration only. +- Key rollover through the Federation Historical Keys endpoint. +- Trust Mark status queries. diff --git a/examples/federated-union/cmd/federated-union/main.go b/examples/federated-union/cmd/federated-union/main.go new file mode 100644 index 00000000..f64b9e38 --- /dev/null +++ b/examples/federated-union/cmd/federated-union/main.go @@ -0,0 +1,42 @@ +// Command federated-union runs the Meridian Union demo: three fictional +// countries' identity federations joined into one OpenID Federation. +// Open https://console.localhost:8443/ once it's running. +package main + +import ( + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "strconv" + "time" + + "github.com/idfoundry/fapigo/examples/federated-union/internal/demonet" + "github.com/idfoundry/fapigo/examples/federated-union/union" +) + +func main() { + port := flag.Int("port", 8443, "HTTPS port every demo host is served on") + caFile := flag.String("ca", "union-ca.pem", "where to write the demo CA certificate, for a browser to trust") + flag.Parse() + + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(*port)) + n, err := demonet.New(addr, union.Hosts()) + if err != nil { + log.Fatalf("demo network: %v", err) + } + if err := os.WriteFile(*caFile, n.CAPEM(), 0o600); err != nil { + log.Fatalf("write %s: %v", *caFile, err) + } + world, err := union.New(*port, n) + if err != nil { + log.Fatalf("build the union: %v", err) + } + + srv := &http.Server{Addr: addr, Handler: world.Handler(), TLSConfig: n.ServerTLS(), ReadHeaderTimeout: 10 * time.Second} + fmt.Printf("Meridian Union demo\n\n console: %s\n services: %s\n %s\n\nDemo CA written to %s (trust it in your browser, or accept the certificate warning).\n", + world.URL("console.localhost", "/"), world.URL("bank.southport.localhost", "/"), world.URL("telco.eastmark.localhost", "/"), *caFile) + log.Fatal(srv.ListenAndServeTLS("", "")) +} diff --git a/examples/federated-union/go.mod b/examples/federated-union/go.mod new file mode 100644 index 00000000..23ff2e93 --- /dev/null +++ b/examples/federated-union/go.mod @@ -0,0 +1,10 @@ +module github.com/idfoundry/fapigo/examples/federated-union + +go 1.26.6 + +require github.com/idfoundry/fapigo v0.0.0-00010101000000-000000000000 + +// The demo always builds against this checkout of the library, not a +// published release, so a library change and the demo using it land +// together. +replace github.com/idfoundry/fapigo => ../.. diff --git a/examples/federated-union/internal/demonet/demonet.go b/examples/federated-union/internal/demonet/demonet.go new file mode 100644 index 00000000..885d9a32 --- /dev/null +++ b/examples/federated-union/internal/demonet/demonet.go @@ -0,0 +1,211 @@ +// Package demonet runs every entity of the demo federation on one local +// HTTPS listener, telling them apart by host name, and gives each entity +// an HTTP client that reaches the others through that same listener. +// +// Host names are subdomains of "localhost" (id.eastmark.localhost, ...): +// browsers resolve those to the loopback address on their own (RFC 6761), +// and the clients built here dial the listener directly, so nothing needs +// adding to /etc/hosts. The TLS certificate is issued at startup by a +// throwaway certificate authority, written out so a browser can be told +// to trust it. +package demonet + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// Net is the demo's shared network: one TLS certificate covering every +// entity host, and the address every client dials. +type Net struct { + addr string + caPEM []byte + caPool *x509.CertPool + serving tls.Certificate + log *FetchLog +} + +// New issues a certificate for hosts, signed by a fresh demo CA, for a +// listener at addr (host:port, the address clients dial). +func New(addr string, hosts []string) (*Net, error) { + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + now := time.Now() + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Meridian Union demo CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(30 * 24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + return nil, err + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + return nil, err + } + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: hosts[0]}, + DNSNames: hosts, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(30 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCert, &leafKey.PublicKey, caKey) + if err != nil { + return nil, err + } + + pool := x509.NewCertPool() + pool.AddCert(caCert) + return &Net{ + addr: addr, + caPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), + caPool: pool, + serving: tls.Certificate{Certificate: [][]byte{leafDER}, PrivateKey: leafKey}, + log: &FetchLog{}, + }, nil +} + +// CAPEM is the demo CA certificate, PEM-encoded. +func (n *Net) CAPEM() []byte { return n.caPEM } + +// ServerTLS is the listener's TLS configuration. +func (n *Net) ServerTLS() *tls.Config { + return &tls.Config{Certificates: []tls.Certificate{n.serving}, MinVersion: tls.VersionTLS12} +} + +// Log is the record of every request the entities have made to each +// other. +func (n *Net) Log() *FetchLog { return n.log } + +// Client is an HTTP client for the entity at host: it trusts the demo CA, +// dials the demo listener whatever host a URL names, never follows +// redirects (fapihttp handles those itself), and records each request in +// Log under from. +func (n *Net) Client(from string) *http.Client { + dialer := &net.Dialer{Timeout: 5 * time.Second} + transport := &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: n.caPool, MinVersion: tls.VersionTLS12}, + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, network, n.addr) + }, + } + return &http.Client{ + Transport: &loggingTransport{from: from, next: transport, log: n.log}, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: 10 * time.Second, + } +} + +// Browser is an HTTP client for driving the demo's pages in a test: like +// Client, but following redirects and keeping cookies with jar. +func (n *Net) Browser(jar http.CookieJar) *http.Client { + c := n.Client("browser") + c.CheckRedirect = nil + c.Jar = jar + return c +} + +// HostRouter dispatches each request to the handler registered for its +// Host header, ignoring the port. +type HostRouter map[string]http.Handler + +func (h HostRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + host := r.Host + if i := strings.LastIndexByte(host, ':'); i >= 0 { + host = host[:i] + } + handler, ok := h[strings.ToLower(host)] + if !ok { + http.Error(w, fmt.Sprintf("no demo entity at %q", host), http.StatusNotFound) + return + } + handler.ServeHTTP(w, r) +} + +// FetchEntry is one request an entity made to another. +type FetchEntry struct { + Time time.Time + From string + Method string + URL string + Status int + Err string +} + +// FetchLog keeps the most recent requests between entities. +type FetchLog struct { + mu sync.Mutex + entries []FetchEntry +} + +const maxFetchEntries = 300 + +func (l *FetchLog) add(e FetchEntry) { + l.mu.Lock() + defer l.mu.Unlock() + l.entries = append(l.entries, e) + if len(l.entries) > maxFetchEntries { + l.entries = l.entries[len(l.entries)-maxFetchEntries:] + } +} + +// Recent returns up to n entries, newest first. +func (l *FetchLog) Recent(n int) []FetchEntry { + l.mu.Lock() + defer l.mu.Unlock() + out := make([]FetchEntry, 0, n) + for i := len(l.entries) - 1; i >= 0 && len(out) < n; i-- { + out = append(out, l.entries[i]) + } + return out +} + +type loggingTransport struct { + from string + next http.RoundTripper + log *FetchLog +} + +func (t *loggingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + entry := FetchEntry{Time: time.Now(), From: t.from, Method: r.Method, URL: r.URL.String()} + res, err := t.next.RoundTrip(r) + if err != nil { + entry.Err = err.Error() + } else { + entry.Status = res.StatusCode + } + if t.from != "browser" { + t.log.add(entry) + } + return res, err +} diff --git a/examples/federated-union/union/accreditation.go b/examples/federated-union/union/accreditation.go new file mode 100644 index 00000000..1dc4f0e3 --- /dev/null +++ b/examples/federated-union/union/accreditation.go @@ -0,0 +1,49 @@ +package union + +import ( + "time" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/federation" + "github.com/idfoundry/fapigo/keys" +) + +// accreditation is a country's accreditation body: the only entity the +// Union recognises as able to certify that country's identity provider +// at a high level of assurance. +type accreditation struct { + entity *entity + issuer *federation.TrustMarkIssuer + markTy string +} + +func (w *World) newAccreditation(c country, ta *entity) (*accreditation, error) { + host := "accreditation." + c.key + ".localhost" + key, err := newSigningKey(c.key+"-accreditation-1", keys.FederationEntitySigning) + if err != nil { + return nil, err + } + e := &entity{ + id: w.entityID(host), host: host, name: c.name + " Accreditation Office", role: "accreditation body", + country: c.key, key: key, authorityHints: []string{ta.id}, + } + if err := w.add(e); err != nil { + return nil, err + } + issuer, err := federation.NewTrustMarkIssuer(federation.TrustMarkIssueConfig{EntityID: e.id}, + federation.TrustMarkIssueDependencies{Signer: key.signer, Algorithm: fapi.ES256, KeyID: key.kid, Clock: federation.SystemClock{}}) + if err != nil { + return nil, err + } + return &accreditation{entity: e, issuer: issuer, markTy: w.loaHighType}, nil +} + +// certify issues a fresh level-of-assurance-high Trust Mark about +// subject. +func (a *accreditation) certify(subject string) (federation.RawTrustMark, error) { + jwt, err := a.issuer.TrustMark(federation.TrustMarkParams{Subject: subject, TrustMarkType: a.markTy, Lifetime: time.Hour}) + if err != nil { + return federation.RawTrustMark{}, err + } + return federation.RawTrustMark{TrustMarkType: a.markTy, TrustMark: jwt}, nil +} diff --git a/examples/federated-union/union/console.go b/examples/federated-union/union/console.go new file mode 100644 index 00000000..cae6e895 --- /dev/null +++ b/examples/federated-union/union/console.go @@ -0,0 +1,303 @@ +package union + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "sort" + "strings" + "time" + + "github.com/idfoundry/fapigo/federation" +) + +// console is the Union operator's view: every entity, the scene +// switches, a Trust Chain inspector and the log of what the entities +// fetch from one another. +type console struct { + w *World + // resolvers by trust anchor: the Union, or one country's authority. + resolvers map[string]*federation.Resolver +} + +func (w *World) newConsole() (*console, error) { + c := &console{w: w, resolvers: map[string]*federation.Resolver{}} + r, err := w.resolverFor(consoleHost, w.union) + if err != nil { + return nil, err + } + c.resolvers["union"] = r + for key, ta := range w.authorities { + if c.resolvers[key], err = w.resolverFor(consoleHost, ta); err != nil { + return nil, err + } + } + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", c.home) + mux.HandleFunc("POST /scene", c.scene) + mux.HandleFunc("GET /entity", c.entityPage) + mux.HandleFunc("GET /resolve", c.resolve) + w.router[consoleHost] = mux + return c, nil +} + +// entityRow is one entity in the console's list. +type entityRow struct { + ID, Host, Name, Role, Country, Color string + Page string // its own page, for services +} + +func (c *console) rows() []entityRow { + var rows []entityRow + for _, e := range c.w.sortedEntities() { + country := countryByKey(e.country) + row := entityRow{ID: e.id, Host: e.host, Name: e.name, Role: e.role, Country: country.name, Color: country.color} + if e.role == "service" { + row.Page = e.id + "/" + } + rows = append(rows, row) + } + return rows +} + +// sceneRow is one console switch. +type sceneRow struct { + Key, Title, Description string + On bool +} + +func (c *console) sceneRows() []sceneRow { + s := &c.w.scenes + return []sceneRow{ + {Key: "suspend", Title: "Suspend Eastmark", On: s.SuspendEastmark.Load(), + Description: "The Union stops vouching for Eastmark's authority. Cross-border sign-ins with EastID fail; Eastmark's own services, which also trust their national authority directly, keep working."}, + {Key: "forge", Title: "EastID forges its assurance mark", On: s.ForgeEastmarkMark.Load(), + Description: "EastID publishes a level-of-assurance Trust Mark it signed itself. It verifies as a signature — EastID is a federation member — but the Union doesn't accredit EastID to issue it, so services refuse it."}, + {Key: "compromise", Title: "Eastmark's authority is compromised", On: s.CompromiseEastmark.Load(), + Description: "Eastmark's authority vouches for an impostor at bank.northland.localhost. The Union's naming constraints confine Eastmark to *.eastmark.localhost, so the impostor fails to resolve through the Union."}, + } +} + +func (c *console) home(w http.ResponseWriter, _ *http.Request) { + var services []entityRow + for _, rp := range c.w.services { + country := rp.country + services = append(services, entityRow{ID: rp.entity.id, Name: rp.entity.name, Country: country.name, Color: country.color, Page: rp.entity.id + "/"}) + } + c.w.render(w, "console", consolePage{ + Page: c.w.page("Meridian Union console", country{}), Entities: c.rows(), Scenes: c.sceneRows(), + Services: services, Log: c.w.net.Log().Recent(40), Impostor: c.w.impostor.id, + }) +} + +func (c *console) scene(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + on := r.PostForm.Get("on") == "true" + switch r.PostForm.Get("scene") { + case "suspend": + c.w.scenes.SuspendEastmark.Store(on) + case "forge": + c.w.scenes.ForgeEastmarkMark.Store(on) + case "compromise": + c.w.scenes.CompromiseEastmark.Store(on) + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// statementView is one decoded Entity Statement. +type statementView struct { + Title, JSON, Error string +} + +func (c *console) entityPage(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + var e *entity + for _, candidate := range c.w.entities { + if candidate.id == id { + e = candidate + } + } + if e == nil { + c.w.renderError(w, http.StatusNotFound, "Unknown entity", id) + return + } + views := []statementView{decodeStatement("Entity Configuration", func() (string, error) { return e.EntityConfiguration(r.Context()) })} + if e.subordinates != nil { + for sub := range e.subordinates() { + views = append(views, decodeStatement("Subordinate Statement about "+c.w.displayName(sub), func() (string, error) { + token, _, err := e.SubordinateStatement(sub) + return token, err + })) + } + } + country := countryByKey(e.country) + c.w.render(w, "entity", entityPage{Page: c.w.page(e.name, country), Entity: entityRow{ID: e.id, Name: e.name, Role: e.role, Country: country.name}, Statements: views}) +} + +// decodeStatement shows a signed statement's payload. Display only: the +// signature isn't checked here — resolution does that. +func decodeStatement(title string, sign func() (string, error)) statementView { + token, err := sign() + if err != nil { + return statementView{Title: title, Error: err.Error()} + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return statementView{Title: title, Error: "not a compact JWS"} + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return statementView{Title: title, Error: err.Error()} + } + return statementView{Title: title, JSON: prettyJSON(payload)} +} + +func prettyJSON(raw []byte) string { + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + return string(raw) + } + return buf.String() +} + +// resolveView is the Trust Chain inspector's result. +type resolveView struct { + Subject, SubjectName, Via, ViaName string + Chain []entityRow + ExpiresAt time.Time + Declared, Resolved []metadataView + Marks []markView + Error string +} + +type metadataView struct { + Type, JSON string +} + +type markView struct { + Type, Issuer, Status string + OK bool +} + +func (c *console) resolve(w http.ResponseWriter, r *http.Request) { + subject := r.URL.Query().Get("subject") + via := r.URL.Query().Get("via") + if via == "" { + via = "union" + } + resolver, ok := c.resolvers[via] + if !ok { + c.w.renderError(w, http.StatusBadRequest, "Unknown trust anchor", via) + return + } + view := resolveView{Subject: subject, SubjectName: c.w.displayName(subject), Via: via, ViaName: c.viaName(via)} + view.Declared = c.declaredMetadata(r.Context(), subject) + + resolved, err := resolver.Resolve(r.Context(), subject) + if err != nil { + view.Error = err.Error() + } else { + view.ExpiresAt = resolved.ExpiresAt + for _, id := range resolved.Chain { + e := c.w.entityByID(id) + row := entityRow{ID: id, Name: c.w.displayName(id)} + if e != nil { + country := countryByKey(e.country) + row.Role, row.Country, row.Color = e.role, country.name, country.color + } + view.Chain = append(view.Chain, row) + } + for _, t := range sortedKeys(resolved.Metadata) { + view.Resolved = append(view.Resolved, metadataView{Type: t, JSON: prettyJSON(resolved.Metadata[t])}) + } + for _, mark := range resolved.TrustMarks { + mv := markView{Type: mark.TrustMarkType} + claims, err := resolver.VerifyTrustMark(r.Context(), subject, mark, federation.RequireFederationAccreditation) + if err != nil { + mv.Status = err.Error() + } else { + mv.OK, mv.Issuer, mv.Status = true, c.w.displayName(claims.Issuer), "accredited by the Union" + } + view.Marks = append(view.Marks, mv) + } + } + country := country{} + if e := c.w.entityByID(subject); e != nil { + country = countryByKey(e.country) + } + c.w.render(w, "resolve", resolvePage{Page: c.w.page("Trust Chain of "+view.SubjectName, country), View: view, Entities: c.rows(), Anchors: c.anchorOptions()}) +} + +// declaredMetadata is the subject's own metadata as it declares it, +// before any superior's policy — for comparison with the resolved form. +func (c *console) declaredMetadata(ctx context.Context, subject string) []metadataView { + e := c.w.entityByID(subject) + if e == nil { + return nil + } + token, err := e.EntityConfiguration(ctx) + if err != nil { + return nil + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + var claims struct { + Metadata map[string]json.RawMessage `json:"metadata"` + } + if json.Unmarshal(payload, &claims) != nil { + return nil + } + var out []metadataView + for _, t := range sortedKeys(claims.Metadata) { + out = append(out, metadataView{Type: t, JSON: prettyJSON(claims.Metadata[t])}) + } + return out +} + +type anchorOption struct{ Key, Name string } + +func (c *console) anchorOptions() []anchorOption { + opts := []anchorOption{{Key: "union", Name: "Meridian Union"}} + for _, ct := range countries { + opts = append(opts, anchorOption{Key: ct.key, Name: ct.name + " Federation Authority"}) + } + return opts +} + +func (c *console) viaName(via string) string { + for _, o := range c.anchorOptions() { + if o.Key == via { + return o.Name + } + } + return via +} + +func (w *World) entityByID(id string) *entity { + for _, e := range w.entities { + if e.id == id { + return e + } + } + return nil +} + +func sortedKeys(m map[string]json.RawMessage) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/examples/federated-union/union/e2e_test.go b/examples/federated-union/union/e2e_test.go new file mode 100644 index 00000000..29974ece --- /dev/null +++ b/examples/federated-union/union/e2e_test.go @@ -0,0 +1,180 @@ +package union_test + +import ( + "crypto/tls" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "testing" + + "github.com/idfoundry/fapigo/examples/federated-union/internal/demonet" + "github.com/idfoundry/fapigo/examples/federated-union/union" +) + +// startUnion runs the whole federation on a free local port. +func startUnion(t *testing.T) (*union.World, *demonet.Net) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + n, err := demonet.New(listener.Addr().String(), union.Hosts()) + if err != nil { + t.Fatalf("demonet.New: %v", err) + } + w, err := union.New(port, n) + if err != nil { + t.Fatalf("union.New: %v", err) + } + srv := &http.Server{Handler: w.Handler()} + go func() { _ = srv.Serve(tls.NewListener(listener, n.ServerTLS())) }() + t.Cleanup(func() { _ = srv.Close() }) + return w, n +} + +type browser struct { + t *testing.T + c *http.Client +} + +func newBrowser(t *testing.T, n *demonet.Net) browser { + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("cookiejar: %v", err) + } + return browser{t: t, c: n.Browser(jar)} +} + +func (b browser) get(u string) (int, string, *url.URL) { + b.t.Helper() + res, err := b.c.Get(u) + if err != nil { + b.t.Fatalf("GET %s: %v", u, err) + } + defer func() { _ = res.Body.Close() }() + body, _ := io.ReadAll(res.Body) + return res.StatusCode, string(body), res.Request.URL +} + +func (b browser) post(u string, form url.Values) (int, string, *url.URL) { + b.t.Helper() + res, err := b.c.PostForm(u, form) + if err != nil { + b.t.Fatalf("POST %s: %v", u, err) + } + defer func() { _ = res.Body.Close() }() + body, _ := io.ReadAll(res.Body) + return res.StatusCode, string(body), res.Request.URL +} + +func mustContain(t *testing.T, body string, want ...string) { + t.Helper() + for _, w := range want { + if !strings.Contains(body, w) { + t.Fatalf("page is missing %q:\n%s", w, body) + } + } +} + +// signIn runs a sign-in from service with provider, sharing only share. +func signIn(t *testing.T, w *union.World, b browser, service, provider, citizen string, share ...string) (int, string) { + t.Helper() + status, body, at := b.get(w.URL(service, "/login?provider="+url.QueryEscape(w.URL(provider, "")))) + if status != http.StatusOK || at.Hostname() != provider { + return status, body + } + mustContain(t, body, "What may") + form := url.Values{"citizen": {citizen}, "decision": {"approve"}, "claim": share} + status, body, _ = b.post(w.URL(provider, "/authorize"), form) + return status, body +} + +func TestCrossBorderSignInSharesOnlyApprovedClaims(t *testing.T) { + w, n := startUnion(t) + b := newBrowser(t, n) + + status, body, _ := b.get(w.URL("bank.southport.localhost", "/")) + if status != http.StatusOK { + t.Fatalf("bank home = %d:\n%s", status, body) + } + if strings.Count(body, "high assurance") < 3 { + t.Fatalf("bank home doesn't show all three providers as accredited:\n%s", body) + } + + status, body = signIn(t, w, b, "bank.southport.localhost", "id.eastmark.localhost", "em-3306", "given_name", "family_name", "nationality") + if status != http.StatusOK { + t.Fatalf("sign-in = %d:\n%s", status, body) + } + mustContain(t, body, "signed in", "cross-border", "Tomas", "Novak", "Eastmark", "not shared") + if strings.Contains(body, "Linden Square") { + t.Fatalf("the unshared address reached the bank:\n%s", body) + } +} + +func TestDomesticSignIn(t *testing.T) { + w, n := startUnion(t) + status, body := signIn(t, w, newBrowser(t, n), "telco.eastmark.localhost", "id.eastmark.localhost", "em-5512", "given_name") + if status != http.StatusOK { + t.Fatalf("sign-in = %d:\n%s", status, body) + } + mustContain(t, body, "signed in", "Ilse") +} + +func TestForgedAssuranceMarkIsRejected(t *testing.T) { + w, n := startUnion(t) + w.Scenes().ForgeEastmarkMark.Store(true) + b := newBrowser(t, n) + + _, body, _ := b.get(w.URL("bank.southport.localhost", "/")) + mustContain(t, body, "does not accredit") + status, body := signIn(t, w, b, "bank.southport.localhost", "id.eastmark.localhost", "em-3306", "given_name") + if status != http.StatusForbidden { + t.Fatalf("sign-in with a forged mark = %d, want 403:\n%s", status, body) + } +} + +func TestSuspensionCutsOffCrossBorderOnly(t *testing.T) { + w, n := startUnion(t) + w.Scenes().SuspendEastmark.Store(true) + + status, _ := signIn(t, w, newBrowser(t, n), "bank.southport.localhost", "id.eastmark.localhost", "em-3306", "given_name") + if status != http.StatusForbidden { + t.Fatalf("cross-border sign-in while suspended = %d, want 403", status) + } + status, body := signIn(t, w, newBrowser(t, n), "telco.eastmark.localhost", "id.eastmark.localhost", "em-3306", "given_name") + if status != http.StatusOK { + t.Fatalf("domestic sign-in while suspended = %d:\n%s", status, body) + } + mustContain(t, body, "signed in") +} + +func TestNamingConstraintsContainACompromisedAuthority(t *testing.T) { + w, n := startUnion(t) + w.Scenes().CompromiseEastmark.Store(true) + b := newBrowser(t, n) + impostor := url.QueryEscape(w.URL("bank.northland.localhost", "")) + + _, body, _ := b.get(w.URL("console.localhost", "/resolve?via=union&subject="+impostor)) + mustContain(t, body, "not trusted", "naming_constraints") + _, body, _ = b.get(w.URL("console.localhost", "/resolve?via=eastmark&subject="+impostor)) + mustContain(t, body, "Trust Chain, trusting") +} + +func TestConsolePagesRender(t *testing.T) { + w, n := startUnion(t) + b := newBrowser(t, n) + for _, path := range []string{"/", "/entity?id=" + url.QueryEscape(w.URL("union.localhost", "")), "/resolve?via=union&subject=" + url.QueryEscape(w.URL("bank.southport.localhost", ""))} { + status, body, _ := b.get(w.URL("console.localhost", path)) + if status != http.StatusOK { + t.Fatalf("console %s = %d:\n%s", path, status, body) + } + } + // The resolved bank metadata shows the Union's policy narrowing its + // grant types. + _, body, _ := b.get(w.URL("console.localhost", "/resolve?via=union&subject="+url.QueryEscape(w.URL("bank.southport.localhost", "")))) + mustContain(t, body, "client_credentials", "Metadata after every superior") +} diff --git a/examples/federated-union/union/entity.go b/examples/federated-union/union/entity.go new file mode 100644 index 00000000..16c98908 --- /dev/null +++ b/examples/federated-union/union/entity.go @@ -0,0 +1,196 @@ +package union + +import ( + "context" + "crypto" + "encoding/json" + "fmt" + "net/http" + "time" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/federation" + "github.com/idfoundry/fapigo/keys" + "github.com/idfoundry/fapigo/keys/ephemeral" +) + +// statementLifetime is how long every Entity Statement the demo signs +// stays valid. +const statementLifetime = 24 * time.Hour + +// signingKey is one entity's key: the signer, its key ID, and the +// public JWK Set other entities verify it with. +type signingKey struct { + signer crypto.Signer + kid string + jwks json.RawMessage +} + +// newSigningKey generates an ES256 key published for purpose. +func newSigningKey(kid string, purpose keys.SigningPurpose) (signingKey, error) { + signer, err := ephemeral.GenerateSigner(fapi.ES256) + if err != nil { + return signingKey{}, err + } + manager, err := keys.NewKeyManagerFromSigners( + map[keys.SigningPurpose]crypto.Signer{purpose: signer}, + map[keys.SigningPurpose]fapi.SignatureAlgorithm{purpose: fapi.ES256}, + map[keys.SigningPurpose]string{purpose: kid}, + ) + if err != nil { + return signingKey{}, err + } + set, err := keys.PublicJWKS(context.Background(), []keys.SigningKeyUse{{Manager: manager, Purpose: purpose, Algorithm: fapi.ES256}}, nil) + if err != nil { + return signingKey{}, err + } + jwks, err := json.Marshal(set) + if err != nil { + return signingKey{}, err + } + return signingKey{signer: signer, kid: kid, jwks: jwks}, nil +} + +// subordinate is what a superior says about one entity below it: the +// keys it vouches for, and the metadata policy and constraints it +// imposes. +type subordinate struct { + jwks json.RawMessage + policy federation.MetadataPolicy + constraints *federation.Constraints +} + +// entity is one member of the demo federation, served at its own host. +type entity struct { + id string // Entity Identifier, https://host:port + host string + name string + role string // shown in the console + country string // "" for the Union itself + + key signingKey + authorityHints []string + + // metadata is the entity's own metadata, by entity type, for its + // Entity Configuration. + metadata func(ctx context.Context) (map[string]json.RawMessage, error) + // trustMarks are the Trust Marks it publishes about itself. + trustMarks func() ([]federation.RawTrustMark, error) + // trustMarkIssuers is set on the Union only. + trustMarkIssuers map[string][]string + // subordinates, for an authority, is who it currently vouches for. + subordinates func() map[string]subordinate + // entityConfiguration overrides SelfIssuer signing (an identity + // provider signs its own through server.Server). + entityConfiguration func(ctx context.Context) (string, error) + + self *federation.SelfIssuer + subIssuer *federation.SubordinateIssuer + mux *http.ServeMux +} + +// init builds the entity's issuers and registers its federation +// endpoints on its own mux; callers add their own pages afterwards. +func (e *entity) init() error { + var err error + e.self, err = federation.NewSelfIssuer(federation.SelfIssueConfig{ + EntityID: e.id, AuthorityHints: e.authorityHints, Lifetime: statementLifetime, + TrustMarkIssuers: e.trustMarkIssuers, + }, federation.SelfIssueDependencies{ + Signer: e.key.signer, Algorithm: fapi.ES256, KeyID: e.key.kid, JWKS: e.key.jwks, Clock: federation.SystemClock{}, + }) + if err != nil { + return fmt.Errorf("%s: self issuer: %w", e.host, err) + } + if e.subordinates != nil { + e.subIssuer, err = federation.NewSubordinateIssuer(federation.SubordinateIssueConfig{EntityID: e.id, Lifetime: statementLifetime}, + federation.SubordinateIssueDependencies{Signer: e.key.signer, Algorithm: fapi.ES256, KeyID: e.key.kid, Clock: federation.SystemClock{}}) + if err != nil { + return fmt.Errorf("%s: subordinate issuer: %w", e.host, err) + } + } + e.mux = http.NewServeMux() + e.mux.HandleFunc("GET "+federation.WellKnownPath, e.serveEntityConfiguration) + if e.subordinates != nil { + e.mux.HandleFunc("GET /fetch", e.serveFetch) + } + return nil +} + +// federationEntityMetadata is the "federation_entity" metadata every +// entity publishes: its name, and its fetch endpoint if it vouches for +// anyone. +func (e *entity) federationEntityMetadata() json.RawMessage { + md := map[string]string{"organization_name": e.name} + if e.subordinates != nil { + md["federation_fetch_endpoint"] = e.id + "/fetch" + } + raw, _ := json.Marshal(md) + return raw +} + +// EntityConfiguration signs the entity's current Entity Configuration. +func (e *entity) EntityConfiguration(ctx context.Context) (string, error) { + if e.entityConfiguration != nil { + return e.entityConfiguration(ctx) + } + md := map[string]json.RawMessage{"federation_entity": e.federationEntityMetadata()} + if e.metadata != nil { + extra, err := e.metadata(ctx) + if err != nil { + return "", err + } + for k, v := range extra { + md[k] = v + } + } + var marks []federation.RawTrustMark + if e.trustMarks != nil { + var err error + if marks, err = e.trustMarks(); err != nil { + return "", err + } + } + return e.self.EntityConfiguration(md, marks...) +} + +func (e *entity) serveEntityConfiguration(w http.ResponseWriter, r *http.Request) { + token, err := e.EntityConfiguration(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + federation.WriteEntityStatement(w, token) +} + +// SubordinateStatement signs what the entity says about sub, or reports +// that it vouches for no such entity. +func (e *entity) SubordinateStatement(sub string) (string, bool, error) { + s, ok := e.subordinates()[sub] + if !ok { + return "", false, nil + } + token, err := e.subIssuer.SubordinateStatement(federation.SubordinateStatementParams{ + Subject: sub, JWKS: s.jwks, MetadataPolicy: s.policy, Constraints: s.constraints, + SourceEndpoint: e.id + "/fetch", + }) + return token, true, err +} + +func (e *entity) serveFetch(w http.ResponseWriter, r *http.Request) { + sub, err := federation.SubjectFromFetchRequest(r) + if err != nil { + federation.WriteError(w, err) + return + } + token, ok, err := e.SubordinateStatement(sub) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !ok { + federation.WriteError(w, federation.NewError(federation.ErrorNotFound, http.StatusNotFound, "this entity does not vouch for "+sub)) + return + } + federation.WriteEntityStatement(w, token) +} diff --git a/examples/federated-union/union/idp.go b/examples/federated-union/union/idp.go new file mode 100644 index 00000000..6869a576 --- /dev/null +++ b/examples/federated-union/union/idp.go @@ -0,0 +1,412 @@ +package union + +import ( + "context" + "crypto" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "slices" + "sync" + "time" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/federation" + "github.com/idfoundry/fapigo/keys" + "github.com/idfoundry/fapigo/keys/ephemeral" + "github.com/idfoundry/fapigo/server" + "github.com/idfoundry/fapigo/storage/memstore" +) + +// citizen is one person an identity provider can sign in. +type citizen struct { + sub, given, family, birthdate, email, address string +} + +// claims is c's identity claims, JSON-encoded, with nationality set to +// the issuing country. +func (c citizen) claims(nationality string) map[string]json.RawMessage { + enc := func(v string) json.RawMessage { raw, _ := json.Marshal(v); return raw } + return map[string]json.RawMessage{ + "given_name": enc(c.given), "family_name": enc(c.family), "birthdate": enc(c.birthdate), + "email": enc(c.email), "address": json.RawMessage(fmt.Sprintf(`{"formatted":%s}`, enc(c.address))), + "nationality": enc(nationality), + } +} + +// identityProvider is a country's national identity provider: a FAPI +// 2.0 authorization server that registers foreign services automatically +// through the Union's federation. +type identityProvider struct { + entity *entity + country country + srv *server.Server + fedKey signingKey + forgedBy *federation.TrustMarkIssuer // EastID signing its own mark, for the forge scene + w *World + + mu sync.Mutex + pending map[string]pendingLogin // by interaction handle +} + +// pendingLogin is what the consent page needs back when it's submitted. +type pendingLogin struct { + handle server.InteractionHandle + interaction server.InteractionRequest +} + +const interactionCookie = "fu_interaction" + +func (w *World) newIdentityProvider(c country, ta *entity) (*identityProvider, error) { + host := "id." + c.key + ".localhost" + id := w.entityID(host) + issuer, err := fapi.ParseIssuerURL(id) + if err != nil { + return nil, err + } + endpoint := func(path string) (fapi.URL, error) { return fapi.ParseEndpointURL(id + path) } + authz, err := endpoint("/authorize") + if err != nil { + return nil, err + } + tokenURL, err := endpoint("/token") + if err != nil { + return nil, err + } + parURL, err := endpoint("/par") + if err != nil { + return nil, err + } + jwksURL, err := endpoint("/jwks") + if err != nil { + return nil, err + } + + fedKey, err := newSigningKey(c.key+"-id-fed-1", keys.FederationEntitySigning) + if err != nil { + return nil, err + } + tokenSigner, err := ephemeral.GenerateSigner(fapi.ES256) + if err != nil { + return nil, err + } + manager, err := keys.NewKeyManagerFromSigners( + map[keys.SigningPurpose]crypto.Signer{ + keys.FederationEntitySigning: fedKey.signer, keys.IDTokenSigning: tokenSigner, keys.AccessTokenSigning: tokenSigner, + }, + map[keys.SigningPurpose]fapi.SignatureAlgorithm{ + keys.FederationEntitySigning: fapi.ES256, keys.IDTokenSigning: fapi.ES256, keys.AccessTokenSigning: fapi.ES256, + }, + map[keys.SigningPurpose]string{ + keys.FederationEntitySigning: fedKey.kid, keys.IDTokenSigning: c.key + "-id-token-1", keys.AccessTokenSigning: c.key + "-id-token-1", + }, + ) + if err != nil { + return nil, err + } + accessTokens, err := server.NewJWTAccessTokens(manager, fapi.ES256) + if err != nil { + return nil, err + } + clientKeys, err := ephemeral.NewClientKeySource(nil, nil) + if err != nil { + return nil, err + } + fetcher, err := w.fetcher(host) + if err != nil { + return nil, err + } + + cfg := server.Config{ + Issuer: issuer, + Endpoints: server.Endpoints{Authorization: authz, Token: tokenURL, PushedAuthorizationRequest: parURL, JWKS: jwksURL}, + Profile: server.ProfileFAPISecurity, + Algorithms: server.RecommendedAlgorithms(), + Limits: server.RecommendedLimits(), + Assurance: server.AssuranceDevelopment, + Federation: server.FederationConfig{ + EntityID: id, AuthorityHints: []string{ta.id}, Lifetime: statementLifetime, Algorithm: fapi.ES256, + }, + // Services register themselves on first use: trusted through the + // Union, or directly through this country's own authority. + AutomaticRegistration: server.AutomaticRegistrationConfig{ + TrustAnchors: []federation.TrustAnchor{anchor(w.union), anchor(ta)}, + AllowedScopes: []string{"openid"}, + MaxPathLength: 4, MaxAuthorityHints: 5, + MaxStatementLifetime: statementLifetime + time.Hour, MaxClockSkew: 30 * time.Second, + // Short, so suspending a country takes effect within the + // demo rather than after a real deployment's caching period. + MaxCacheAge: 10 * time.Second, + }, + } + idp := &identityProvider{country: c, fedKey: fedKey, w: w, pending: map[string]pendingLogin{}} + deps := server.Dependencies{ + Clients: memstore.NewClientRepository(nil), + Transactions: memstore.NewTransactionStore(), + Grants: memstore.NewGrantStore(), + Replay: memstore.NewReplayStore(), + ClientKeys: clientKeys, + Keys: manager, + AccessTokens: accessTokens, + Revocation: memstore.NewRevocationStore(), + ClientCertificateTrust: server.NoClientCertificateChainTrust{}, + Clock: server.SystemClock{}, + Random: rand.Reader, + IdentityClaims: idp, + FederationHTTP: fetcher, + } + idp.srv, err = server.New(cfg, deps) + if err != nil { + return nil, fmt.Errorf("%s: %w", host, err) + } + idp.forgedBy, err = federation.NewTrustMarkIssuer(federation.TrustMarkIssueConfig{EntityID: id}, + federation.TrustMarkIssueDependencies{Signer: fedKey.signer, Algorithm: fapi.ES256, KeyID: fedKey.kid, Clock: federation.SystemClock{}}) + if err != nil { + return nil, err + } + + idp.entity = &entity{ + id: id, host: host, name: c.idpName, role: "national identity provider", country: c.key, + key: fedKey, authorityHints: []string{ta.id}, + entityConfiguration: idp.entityConfiguration, + } + if err := w.add(idp.entity); err != nil { + return nil, err + } + mux := idp.entity.mux + mux.HandleFunc("GET /jwks", idp.jwks) + mux.HandleFunc("POST /par", idp.par) + mux.HandleFunc("GET /authorize", idp.authorize) + mux.HandleFunc("POST /authorize", idp.decide) + mux.HandleFunc("POST /token", idp.token) + return idp, nil +} + +// trustMark is the level-of-assurance mark this provider publishes: its +// accreditation body's, or — in the forge scene — one it signed itself. +func (p *identityProvider) trustMark() (federation.RawTrustMark, error) { + if p.country.key == "eastmark" && p.w.scenes.ForgeEastmarkMark.Load() { + jwt, err := p.forgedBy.TrustMark(federation.TrustMarkParams{Subject: p.entity.id, TrustMarkType: p.w.loaHighType, Lifetime: time.Hour}) + if err != nil { + return federation.RawTrustMark{}, err + } + return federation.RawTrustMark{TrustMarkType: p.w.loaHighType, TrustMark: jwt}, nil + } + return p.w.accreditors[p.country.key].certify(p.entity.id) +} + +// entityConfiguration is the provider's Entity Configuration: its OpenID +// Provider metadata, taken from the server, and its Trust Mark. +func (p *identityProvider) entityConfiguration(ctx context.Context) (string, error) { + md, err := json.Marshal(p.srv.Metadata(ctx)) + if err != nil { + return "", err + } + var op map[string]any + if err := json.Unmarshal(md, &op); err != nil { + return "", err + } + op["client_registration_types_supported"] = []string{"automatic"} + op["claims_parameter_supported"] = true + op["scopes_supported"] = []string{"openid"} + op["claims_supported"] = []string{"sub", "given_name", "family_name", "birthdate", "email", "address", "nationality"} + opRaw, err := json.Marshal(op) + if err != nil { + return "", err + } + mark, err := p.trustMark() + if err != nil { + return "", err + } + return p.srv.EntityConfiguration(ctx, map[string]json.RawMessage{ + "openid_provider": opRaw, + "federation_entity": p.entity.federationEntityMetadata(), + }, mark) +} + +// ResolveIdentityClaims implements server.IdentityClaimsSource: the +// approved claims of the citizen who signed in. +func (p *identityProvider) ResolveIdentityClaims(_ context.Context, subject string, names []string) (map[string]json.RawMessage, error) { + for _, c := range p.country.citizens { + if c.sub != subject { + continue + } + all := c.claims(p.country.name) + out := map[string]json.RawMessage{} + for _, n := range names { + if v, ok := all[n]; ok { + out[n] = v + } + } + return out, nil + } + return nil, nil +} + +func (p *identityProvider) jwks(w http.ResponseWriter, r *http.Request) { + set, err := p.srv.PublicJWKS(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(set) +} + +func (p *identityProvider) par(w http.ResponseWriter, r *http.Request) { + form, err := server.FormRequestFromHTTP(r) + if err != nil { + server.NewError(server.ErrorInvalidRequest, http.StatusBadRequest, err.Error()).WriteJSON(w) + return + } + result, err := p.srv.PushAuthorizationRequest(r.Context(), server.PushAuthorizationRequest{ + HTTP: form, DPoPProofs: r.Header.Values("DPoP"), + }) + if err != nil { + server.WriteError(w, err) + return + } + result.WriteJSON(w) +} + +func (p *identityProvider) token(w http.ResponseWriter, r *http.Request) { + form, err := server.FormRequestFromHTTP(r) + if err != nil { + server.NewError(server.ErrorInvalidRequest, http.StatusBadRequest, err.Error()).WriteJSON(w) + return + } + if form.Get("grant_type") != "authorization_code" { + server.NewError(server.ErrorUnsupportedGrantType, http.StatusBadRequest, "only authorization_code is supported").WriteJSON(w) + return + } + result, err := p.srv.ExchangeAuthorizationCode(r.Context(), server.AuthorizationCodeExchangeRequest{ + HTTP: form, DPoPProofs: r.Header.Values("DPoP"), + }) + if err != nil { + server.WriteError(w, err) + return + } + result.WriteJSON(w) +} + +// authorize starts the interaction: it keeps the interaction handle in a +// cookie bound to this browser — never in the form — and renders the +// sign-in and consent page. +func (p *identityProvider) authorize(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + action, err := p.srv.BeginAuthorization(r.Context(), server.BeginAuthorizationRequest{ + RequestURI: q.Get("request_uri"), ClientID: fapi.ClientID(q.Get("client_id")), + }) + if err != nil { + p.w.renderError(w, http.StatusInternalServerError, "Sign-in could not start", err.Error()) + return + } + switch a := action.(type) { + case server.InteractionRequired: + p.mu.Lock() + p.pending[a.Handle.String()] = pendingLogin{handle: a.Handle, interaction: a.Interaction} + p.mu.Unlock() + http.SetCookie(w, &http.Cookie{ + Name: interactionCookie, Value: a.Handle.String(), Path: "/authorize", + HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, + }) + p.w.render(w, "consent", consentPage{ + Page: p.w.page(p.country.idpName, p.country), Provider: p.country.idpName, + Client: string(a.Interaction.ClientID), ClientName: p.w.displayName(string(a.Interaction.ClientID)), + Scope: a.Interaction.Scope, Citizens: p.country.citizens, + Claims: claimRows(a.Interaction.RequestedClaims), + }) + case server.RedirectResponse: + http.Redirect(w, r, a.Destination.String(), http.StatusFound) + case server.LocalErrorResponse: + p.w.renderError(w, a.Error.HTTPStatus(), "The sign-in request was rejected", string(a.Error.Code())+": "+a.Error.PublicDescription()) + } +} + +// decide completes the interaction with the citizen chosen and the +// claims they agreed to share. +func (p *identityProvider) decide(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(interactionCookie) + if err != nil { + p.w.renderError(w, http.StatusBadRequest, "Session expired", "This browser has no sign-in in progress.") + return + } + p.mu.Lock() + login, ok := p.pending[cookie.Value] + delete(p.pending, cookie.Value) + p.mu.Unlock() + if !ok { + p.w.renderError(w, http.StatusBadRequest, "Session expired", "The sign-in in progress is unknown or already finished.") + return + } + http.SetCookie(w, &http.Cookie{Name: interactionCookie, Path: "/authorize", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) + if err := r.ParseForm(); err != nil { + p.w.renderError(w, http.StatusBadRequest, "Malformed form", err.Error()) + return + } + + var result server.InteractionResult + if r.PostForm.Get("decision") != "approve" { + result = server.Deny("the citizen declined") + } else { + subjectID, err := server.NewSubjectID(r.PostForm.Get("citizen")) + if err != nil { + p.w.renderError(w, http.StatusBadRequest, "No citizen chosen", err.Error()) + return + } + subject, err := server.NewAuthenticatedSubject(subjectID) + if err != nil { + p.w.renderError(w, http.StatusInternalServerError, "Sign-in failed", err.Error()) + return + } + authCtx, err := server.NewAuthenticationContext(time.Now(), "https://union.localhost/loa/high", []string{"hwk"}) + if err != nil { + p.w.renderError(w, http.StatusInternalServerError, "Sign-in failed", err.Error()) + return + } + var approved []string + for _, name := range r.PostForm["claim"] { + if slices.Contains(login.interaction.RequestedClaims.Names(), name) { + approved = append(approved, name) + } + } + result = server.Authorize(subject, authCtx, server.GrantedAuthorization{ + Scope: login.interaction.Scope, ApprovedIdentityClaims: approved, + }) + } + + outcome, err := p.srv.CompleteAuthorization(r.Context(), server.CompleteAuthorizationRequest{Handle: login.handle, Result: result}) + if err != nil { + p.w.renderError(w, http.StatusInternalServerError, "Sign-in failed", err.Error()) + return + } + switch o := outcome.(type) { + case server.AuthorizationRedirect: + http.Redirect(w, r, o.Destination().String(), http.StatusFound) + case server.AuthorizationLocalError: + p.w.renderError(w, o.Error.HTTPStatus(), "Sign-in failed", string(o.Error.Code())+": "+o.Error.PublicDescription()) + } +} + +// claimRow is one requested claim on the consent page. +type claimRow struct { + Name, Label string +} + +var claimLabels = map[string]string{ + "given_name": "Given name", "family_name": "Family name", "birthdate": "Date of birth", + "email": "Email address", "address": "Home address", "nationality": "Nationality", +} + +func claimRows(requested server.RequestedClaims) []claimRow { + var rows []claimRow + for _, name := range requested.Names() { + label := claimLabels[name] + if label == "" { + label = name + } + rows = append(rows, claimRow{Name: name, Label: label}) + } + return rows +} diff --git a/examples/federated-union/union/pages.go b/examples/federated-union/union/pages.go new file mode 100644 index 00000000..fd8e148f --- /dev/null +++ b/examples/federated-union/union/pages.go @@ -0,0 +1,231 @@ +package union + +import ( + "html/template" + "log" + "net/http" + "strings" + + "github.com/idfoundry/fapigo/examples/federated-union/internal/demonet" +) + +// Page is what every page's layout needs. +type Page struct { + Title, Country, Color, Console string +} + +func (w *World) page(title string, c country) Page { + color := c.color + if color == "" { + color = "#4f46e5" + } + return Page{Title: title, Country: c.name, Color: color, Console: w.URL(consoleHost, "/")} +} + +type consolePage struct { + Page + Entities []entityRow + Scenes []sceneRow + Services []entityRow + Log []demonet.FetchEntry + Impostor string +} + +type entityPage struct { + Page + Entity entityRow + Statements []statementView +} + +type resolvePage struct { + Page + View resolveView + Entities []entityRow + Anchors []anchorOption +} + +type servicePage struct { + Page + Providers []providerStatus + Requested []string +} + +type consentPage struct { + Page + Provider, Client, ClientName string + Scope []string + Citizens []citizen + Claims []claimRow +} + +// Citizen fields for the consent template, which can't reach +// unexported fields. +func (c citizen) Sub() string { return c.sub } +func (c citizen) Name() string { return c.given + " " + c.family } + +type welcomePage struct { + Page + Provider, ProviderCountry, Subject, Issuer string + Claims []sharedClaim + CrossBorder bool +} + +type errorPage struct { + Page + Heading, Detail string +} + +const layout = `{{define "top"}} + +{{.Title}} + +

{{.Title}}

{{if .Country}}{{.Country}} · {{end}}Meridian Union console
+
{{end}} +{{define "bottom"}}
{{end}}` + +var pageTemplates = map[string]string{ + "console": `{{template "top" .}} +

Three countries — Northland, Southport and Eastmark — run their own identity federations and have joined the Meridian Union, so a service in one country can accept a citizen of another without either registering with the other. Everything below runs in this one process, each entity at its own host.

+ +

Try it

+
{{range .Services}}
{{.Name}} ({{.Country}})

Sign in with any Union country's national identity.

Open
{{end}}
+ +

Scenes

+
{{range .Scenes}}
+{{.Title}} {{if .On}}on{{else}}off{{end}} +

{{.Description}}

+
+{{if .On}}{{else}}{{end}}
+
{{end}}
+

With the compromise scene on, compare the impostor through the Union with the impostor through Eastmark's authority alone.

+ +

Members

+
+{{range .Entities}} +{{end}} +
EntityRoleCountry
{{.Name}}
{{.ID}}
{{.Role}}{{.Country}}statements · trust chain{{if .Page}} · open{{end}}
+ +

Recent federation traffic

+

Every request one entity made to another: Entity Configurations, Subordinate Statements, keys. Newest first.

+ +{{range .Log}}{{else}}{{end}} +
WhenFromRequestResult
{{.Time.Format "15:04:05"}}{{.From}}{{.Method}} {{.URL}}{{if .Err}}{{.Err}}{{else}}{{.Status}}{{end}}
Nothing yet — open a service.
+{{template "bottom" .}}`, + + "entity": `{{template "top" .}} +

{{.Entity.Name}} — {{.Entity.Role}}{{if .Entity.Country}}, {{.Entity.Country}}{{end}}
{{.Entity.ID}}

+

Payloads decoded for reading; their signatures are checked when a Trust Chain is resolved.

+{{range .Statements}}

{{.Title}}

{{if .Error}}

{{.Error}}

{{else}}
{{.JSON}}
{{end}}{{end}} +{{template "bottom" .}}`, + + "resolve": `{{template "top" .}} +
+ + +
+{{with .View}} +{{if .Error}}
not trusted

{{.Error}}

{{else}} +

Trust Chain, trusting {{.ViaName}}

+
{{range $i, $n := .Chain}}{{if $i}}→{{end}}{{$n.Name}}
{{$n.Role}}
{{end}}
+

Valid until {{.ExpiresAt.Format "2006-01-02 15:04 MST"}} — the earliest expiry of any statement in the chain.

+{{if .Marks}}

Trust Marks

{{range .Marks}}{{end}}
TypeResult
{{.Type}}{{if .OK}}accredited issued by {{.Issuer}}{{else}}rejected {{.Status}}{{end}}
{{end}} +{{end}} +
+

Metadata as the entity declares it

{{range .Declared}}

{{.Type}}

{{.JSON}}
{{end}}
+{{if not .Error}}

Metadata after every superior's policy

{{range .Resolved}}

{{.Type}}

{{.JSON}}
{{end}}
{{end}} +
+{{end}} +{{template "bottom" .}}`, + + "service": `{{template "top" .}} +

Sign in with your national identity. This service has never registered with any of these providers: it finds each one through the Meridian Union federation, and they recognise this service the same way.

+

Only providers accredited by the Union at a high level of assurance are accepted. This service will ask for: {{range $i, $c := .Requested}}{{if $i}}, {{end}}{{$c}}{{end}}.

+
{{range .Providers}}
+{{.Name}} {{.Country}} +

{{if .Reachable}}trusted{{else}}not trusted{{end}} +{{if .LoAHigh}}high assurance{{else if .Reachable}}assurance not accredited{{end}}

+{{if .Reachable}}

Chain: {{range $i, $n := .Chain}}{{if $i}} → {{end}}{{$n}}{{end}}

{{else}}

{{.Problem}}

{{end}} +{{if and .Reachable (not .LoAHigh)}}

{{.LoAProblem}}

{{end}} +
+
{{end}}
+{{template "bottom" .}}`, + + "consent": `{{template "top" .}} +
+

{{.ClientName}} wants you to sign in with {{.Provider}}.

+

{{.Client}} — registered automatically through the Meridian Union federation.

+
+

Who are you?

+{{range $i, $c := .Citizens}}{{end}} +{{if .Claims}}

What may {{.ClientName}} see?

+

Untick anything you'd rather not share. Only what you leave ticked leaves {{.Provider}}.

+{{range .Claims}}{{end}}{{end}} +

+
+{{template "bottom" .}}`, + + "welcome": `{{template "top" .}} +
+

signed in {{if .CrossBorder}}cross-border{{end}}

+

Signed in with {{.Provider}} ({{.ProviderCountry}}) as {{.Subject}}.

+ +{{range .Claims}}{{end}}
Asked forReceived
{{.Label}}{{if .Shared}}{{.Value}}{{else}}not shared{{end}}
+

From an ID token issued by {{.Issuer}}, over PAR, a signed request object, PKCE and a DPoP-bound access token.

+

Back

+{{template "bottom" .}}`, + + "error": `{{template "top" .}} +

{{.Heading}}

{{.Detail}}

Back

+{{template "bottom" .}}`, +} + +var templates = func() map[string]*template.Template { + out := make(map[string]*template.Template, len(pageTemplates)) + for name, body := range pageTemplates { + out[name] = template.Must(template.Must(template.New(name).Parse(layout)).Parse(body)) + } + return out +}() + +func (w *World) render(rw http.ResponseWriter, name string, data any) { + rw.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := templates[name].ExecuteTemplate(rw, name, data); err != nil { + log.Printf("render %s: %v", name, err) + } +} + +func (w *World) renderError(rw http.ResponseWriter, status int, heading, detail string) { + rw.Header().Set("Content-Type", "text/html; charset=utf-8") + rw.WriteHeader(status) + if err := templates["error"].ExecuteTemplate(rw, "error", errorPage{Page: w.page("Meridian Union", country{}), Heading: heading, Detail: strings.TrimSpace(detail)}); err != nil { + log.Printf("render error page: %v", err) + } +} diff --git a/examples/federated-union/union/rp.go b/examples/federated-union/union/rp.go new file mode 100644 index 00000000..2ec64a50 --- /dev/null +++ b/examples/federated-union/union/rp.go @@ -0,0 +1,367 @@ +package union + +import ( + "context" + "crypto" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "sort" + "sync" + "time" + + fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/client" + "github.com/idfoundry/fapigo/federation" + "github.com/idfoundry/fapigo/keys" + "github.com/idfoundry/fapigo/keys/ephemeral" + "github.com/idfoundry/fapigo/storage" + "github.com/idfoundry/fapigo/storage/memstore" +) + +// requestedClaims is what every service asks each citizen to share. +var requestedClaims = client.RequestedClaims{ + IDToken: []string{"given_name", "family_name", "birthdate", "nationality", "address"}, +} + +// relyingParty is a service in one Union country that accepts citizens +// of every country, discovering and trusting their identity providers +// through the federation alone. +type relyingParty struct { + entity *entity + country country + w *World + resolver *federation.Resolver + keys keys.KeyManager + authJWKS json.RawMessage + sessions storage.SessionStore + redirect string + + mu sync.Mutex + clients map[string]*client.Client // by identity provider entity ID +} + +const ( + sessionCookie = "fu_session" + providerCookie = "fu_provider" +) + +func (w *World) newRelyingParty(s serviceSpec) (*relyingParty, error) { + c := countryByKey(s.country) + fedKey, err := newSigningKey(c.key+"-"+s.host+"-fed-1", keys.FederationEntitySigning) + if err != nil { + return nil, err + } + authKey, err := newSigningKey(s.host+"-auth-1", keys.ClientAuthentication) + if err != nil { + return nil, err + } + dpop, err := ephemeral.GenerateSigner(fapi.ES256) + if err != nil { + return nil, err + } + manager, err := keys.NewKeyManagerFromSigners( + // OpenID Federation 1.0 §12.1.1.1: the request object is signed + // with a key from the RP's own published JWK Set, the same one + // its client assertions use. + map[keys.SigningPurpose]crypto.Signer{ + keys.ClientAuthentication: authKey.signer, keys.RequestObjectSigning: authKey.signer, keys.DPoPProofSigning: dpop, + }, + map[keys.SigningPurpose]fapi.SignatureAlgorithm{ + keys.ClientAuthentication: fapi.ES256, keys.RequestObjectSigning: fapi.ES256, keys.DPoPProofSigning: fapi.ES256, + }, + map[keys.SigningPurpose]string{ + keys.ClientAuthentication: authKey.kid, keys.RequestObjectSigning: authKey.kid, + }, + ) + if err != nil { + return nil, err + } + + rp := &relyingParty{ + country: c, w: w, keys: manager, authJWKS: authKey.jwks, + sessions: memstore.NewSessionStore(), clients: map[string]*client.Client{}, + } + rp.entity = &entity{ + id: w.entityID(s.host), host: s.host, name: s.name, role: "service", country: c.key, + key: fedKey, authorityHints: []string{w.authorities[c.key].id}, + metadata: rp.metadata, + } + rp.redirect = rp.entity.id + "/callback" + if err := w.add(rp.entity); err != nil { + return nil, err + } + // A service trusts the Union and its own country's authority. + rp.resolver, err = w.resolverFor(s.host, w.union, w.authorities[c.key]) + if err != nil { + return nil, err + } + mux := rp.entity.mux + mux.HandleFunc("GET /{$}", rp.home) + mux.HandleFunc("GET /login", rp.login) + mux.HandleFunc("GET /callback", rp.callback) + return rp, nil +} + +// metadata is the service's openid_relying_party metadata. It declares +// more grant types than the Union allows, so the console can show the +// Union's policy narrowing them. +func (rp *relyingParty) metadata(context.Context) (map[string]json.RawMessage, error) { + md, err := json.Marshal(federation.OpenIDRelyingPartyMetadata{ //nolint:gosec // "private_key_jwt" names an auth method, not a secret + ClientRegistrationTypes: []string{"automatic"}, + ResponseTypes: []string{"code"}, + RedirectURIs: []string{rp.redirect}, + TokenEndpointAuthMethod: "private_key_jwt", + TokenEndpointAuthSigningAlg: fapi.ES256.String(), + RequestObjectSigningAlg: fapi.ES256.String(), + JWKS: rp.authJWKS, + ClientName: rp.entity.name, + Contacts: []string{"federation@" + rp.entity.host}, + }) + if err != nil { + return nil, err + } + var withGrants map[string]any + if err := json.Unmarshal(md, &withGrants); err != nil { + return nil, err + } + withGrants["grant_types"] = []string{"authorization_code", "refresh_token", "client_credentials"} + raw, err := json.Marshal(withGrants) + if err != nil { + return nil, err + } + return map[string]json.RawMessage{"openid_relying_party": raw}, nil +} + +// providerStatus is how one identity provider looks from this service. +type providerStatus struct { + ID, Name, Country, Color string + Chain []string + Reachable bool + Problem string + LoAHigh bool + LoAProblem string + LoAIssuer string +} + +// assess resolves provider's Trust Chain from this service and checks its +// level-of-assurance Trust Mark, requiring the Union's accreditation of +// the mark's issuer. +func (rp *relyingParty) assess(ctx context.Context, idp *identityProvider) providerStatus { + st := providerStatus{ID: idp.entity.id, Name: idp.country.idpName, Country: idp.country.name, Color: idp.country.color} + resolved, err := rp.resolver.Resolve(ctx, idp.entity.id) + if err != nil { + st.Problem = err.Error() + return st + } + st.Reachable = true + st.Chain = resolved.Chain + st.LoAProblem = "publishes no level-of-assurance mark" + for _, mark := range resolved.TrustMarks { + if mark.TrustMarkType != rp.w.loaHighType { + continue + } + claims, err := rp.resolver.VerifyTrustMark(ctx, idp.entity.id, mark, federation.RequireFederationAccreditation) + if err != nil { + st.LoAProblem = err.Error() + continue + } + st.LoAHigh, st.LoAProblem, st.LoAIssuer = true, "", claims.Issuer + break + } + return st +} + +func (rp *relyingParty) home(w http.ResponseWriter, r *http.Request) { + var providers []providerStatus + for _, c := range countries { + providers = append(providers, rp.assess(r.Context(), rp.w.idps[c.key])) + } + sort.SliceStable(providers, func(i, j int) bool { + return providers[i].Country == rp.country.name && providers[j].Country != rp.country.name + }) + rp.w.render(w, "service", servicePage{Page: rp.w.page(rp.entity.name, rp.country), Providers: providers, Requested: requestedClaims.IDToken}) +} + +// clientFor discovers provider through the federation and builds a +// client for it, identified by this service's own Entity Identifier — +// no registration with the provider beforehand. +func (rp *relyingParty) clientFor(ctx context.Context, provider string) (*client.Client, error) { + discovered, err := client.DiscoverViaFederation(ctx, rp.resolver, provider) + if err != nil { + return nil, err + } + fetcher, err := rp.w.fetcher(rp.entity.host) + if err != nil { + return nil, err + } + issuerKeys, err := keys.NewJWKSIssuerKeySource(fetcher, discovered.JWKSURI, time.Minute) + if err != nil { + return nil, err + } + issuer, err := fapi.ParseIssuerURL(provider) + if err != nil { + return nil, err + } + cl, err := client.NewFromDiscovery(discovered, client.Config{ + Issuer: issuer, + Endpoints: discovered.Endpoints, + ClientID: fapi.ClientID(rp.entity.id), + RedirectURI: rp.redirect, + Profile: client.ProfileFAPISecurity, + Assurance: client.AssuranceDevelopment, + // A service registered automatically proves control of its keys + // with a signed request object (OpenID Federation 1.0 §12.1.1.1). + PushedRequestEncoding: client.PushedRequestEncodingRequestObject, + AuthorizationResponseIssPolicy: client.TolerateAbsentAuthorizationResponseIss, + Algorithms: client.Algorithms{ + DPoP: fapi.ES256, IDToken: fapi.ES256, ClientAuthentication: fapi.ES256, RequestObject: fapi.ES256, + }, + Limits: client.Limits{ + ClientAssertionLifetime: time.Minute, RequestObjectLifetime: time.Minute, + SessionLifetime: 10 * time.Minute, MaxIDTokenLifetime: 10 * time.Minute, + MaxClockSkew: 30 * time.Second, HTTPTimeout: 10 * time.Second, + MaxHTTPResponseBytes: 1 << 20, MaxJOSECompactBytes: 16 * 1024, + }, + }, client.Dependencies{ + Sessions: rp.sessions, Keys: rp.keys, IssuerKeys: issuerKeys, + HTTP: rp.w.net.Client(rp.entity.host), Clock: client.SystemClock{}, Random: rand.Reader, + }) + if err != nil { + return nil, err + } + rp.mu.Lock() + rp.clients[provider] = cl + rp.mu.Unlock() + return cl, nil +} + +func (rp *relyingParty) login(w http.ResponseWriter, r *http.Request) { + provider := r.URL.Query().Get("provider") + idp := rp.w.idpByID(provider) + if idp == nil { + rp.w.renderError(w, http.StatusBadRequest, "Unknown identity provider", provider) + return + } + status := rp.assess(r.Context(), idp) + if !status.Reachable { + rp.w.renderError(w, http.StatusForbidden, idp.country.idpName+" is not trusted", status.Problem) + return + } + if !status.LoAHigh { + rp.w.renderError(w, http.StatusForbidden, idp.country.idpName+" is not accredited at a high level of assurance", status.LoAProblem) + return + } + cl, err := rp.clientFor(r.Context(), provider) + if err != nil { + rp.w.renderError(w, http.StatusBadGateway, "Could not discover "+idp.country.idpName, err.Error()) + return + } + session, err := cl.BeginAuthorization(r.Context(), client.BeginAuthorizationRequest{ + Scope: []string{"openid"}, Claims: requestedClaims, + }) + if err != nil { + rp.w.renderError(w, http.StatusBadGateway, idp.country.idpName+" refused the sign-in request", err.Error()) + return + } + // The session handle binds the callback to this browser (RFC 9700 + // §4.7): the callback is only accepted alongside this cookie. + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: session.Handle().String(), Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: providerCookie, Value: provider, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) + u := session.URL() + // Not an open redirect: provider must be one of the demo's own + // identity providers (idpByID above), and the URL is its + // authorization endpoint, from Trust-Chain-verified metadata. + http.Redirect(w, r, u.String(), http.StatusFound) //nolint:gosec // G710: see above + +} + +func (rp *relyingParty) callback(w http.ResponseWriter, r *http.Request) { + sessionC, err1 := r.Cookie(sessionCookie) + providerC, err2 := r.Cookie(providerCookie) + if err1 != nil || err2 != nil { + rp.w.renderError(w, http.StatusBadRequest, "No sign-in in progress", "This browser didn't start a sign-in here.") + return + } + handle, err := client.ParseSessionHandle(sessionC.Value) + if err != nil { + rp.w.renderError(w, http.StatusBadRequest, "No sign-in in progress", err.Error()) + return + } + rp.mu.Lock() + cl := rp.clients[providerC.Value] + rp.mu.Unlock() + if cl == nil { + rp.w.renderError(w, http.StatusBadRequest, "No sign-in in progress", "Unknown identity provider.") + return + } + result, err := cl.CompleteAuthorization(r.Context(), client.AuthorizationCallback{RawQuery: r.URL.RawQuery, Session: handle}) + if err != nil { + rp.w.renderError(w, http.StatusBadGateway, "Sign-in failed", err.Error()) + return + } + idp := rp.w.idpByID(providerC.Value) + switch res := result.(type) { + case client.CompletionSuccess: + claims := res.Tokens.IDTokenClaims + var shared []sharedClaim + for _, name := range requestedClaims.IDToken { + row := sharedClaim{Label: claimLabels[name]} + if raw, ok := claims.Parameters[name]; ok { + row.Value, row.Shared = displayClaim(raw), true + } + shared = append(shared, row) + } + rp.w.render(w, "welcome", welcomePage{ + Page: rp.w.page(rp.entity.name, rp.country), Provider: idp.country.idpName, ProviderCountry: idp.country.name, + Subject: res.Tokens.Subject, Issuer: claims.Issuer, Claims: shared, + CrossBorder: idp.country.key != rp.country.key, + }) + case client.CompletionDenied: + rp.w.renderError(w, http.StatusForbidden, "Sign-in declined", res.Code+": "+res.Description) + default: + rp.w.renderError(w, http.StatusBadGateway, "Sign-in failed", fmt.Sprintf("unexpected result %T", result)) + } +} + +// sharedClaim is one requested claim on the welcome page. +type sharedClaim struct { + Label, Value string + Shared bool +} + +// displayClaim renders a JSON claim value for the page: a string as-is, +// an address by its formatted form. +func displayClaim(raw json.RawMessage) string { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var addr struct { + Formatted string `json:"formatted"` + } + if json.Unmarshal(raw, &addr) == nil && addr.Formatted != "" { + return addr.Formatted + } + return string(raw) +} + +func (w *World) idpByID(id string) *identityProvider { + for _, p := range w.idps { + if p.entity.id == id { + return p + } + } + return nil +} + +// displayName is the organisation name of the entity with id, for pages. +func (w *World) displayName(id string) string { + for _, e := range w.entities { + if e.id == id { + return e.name + } + } + return id +} diff --git a/examples/federated-union/union/world.go b/examples/federated-union/union/world.go new file mode 100644 index 00000000..882da806 --- /dev/null +++ b/examples/federated-union/union/world.go @@ -0,0 +1,361 @@ +// Package union builds the Meridian Union: three fictional countries +// whose national federations join a regional OpenID Federation, so a +// service in one country can accept a citizen of another without either +// side registering with the other in advance. +// +// Every entity — the Union, each national authority, accreditation body, +// identity provider and service — runs in one process behind a +// demonet.HostRouter, each at its own *.localhost host. +package union + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "sync/atomic" + "time" + + "github.com/idfoundry/fapigo/examples/federated-union/internal/demonet" + "github.com/idfoundry/fapigo/fapihttp" + "github.com/idfoundry/fapigo/federation" + "github.com/idfoundry/fapigo/keys" +) + +// country is one Union member. +type country struct { + key string // host label: northland, southport, eastmark + name string // Northland + idpName string // NorthID + color string // accent colour in the pages + citizens []citizen +} + +var countries = []country{ + {key: "northland", name: "Northland", idpName: "NorthID", color: "#2563eb", citizens: []citizen{ + {sub: "nl-4417", given: "Astrid", family: "Holm", birthdate: "1988-03-14", email: "astrid.holm@mail.northland.example", address: "12 Fjord Lane, Kalvik"}, + {sub: "nl-9021", given: "Leif", family: "Brandt", birthdate: "1975-11-02", email: "leif.brandt@mail.northland.example", address: "3 Harbour Row, Nordby"}, + }}, + {key: "southport", name: "Southport", idpName: "SouthID", color: "#059669", citizens: []citizen{ + {sub: "sp-1180", given: "Marisol", family: "Vega", birthdate: "1992-07-21", email: "marisol.vega@mail.southport.example", address: "48 Palm Street, Porto Sul"}, + }}, + {key: "eastmark", name: "Eastmark", idpName: "EastID", color: "#d97706", citizens: []citizen{ + {sub: "em-3306", given: "Tomas", family: "Novak", birthdate: "1983-01-30", email: "tomas.novak@mail.eastmark.example", address: "7 Linden Square, Ostrava Nova"}, + {sub: "em-5512", given: "Ilse", family: "Varga", birthdate: "1999-09-09", email: "ilse.varga@mail.eastmark.example", address: "21 River Walk, Marktel"}, + }}, +} + +// service is a relying party of the demo. +type serviceSpec struct { + host, name, country string +} + +var services = []serviceSpec{ + {host: "bank.southport.localhost", name: "Southport Savings Bank", country: "southport"}, + {host: "telco.eastmark.localhost", name: "Eastmark Telecom", country: "eastmark"}, +} + +const ( + unionHost = "union.localhost" + consoleHost = "console.localhost" + impostorHost = "bank.northland.localhost" +) + +// Scenes are the switches the console flips. +type Scenes struct { + // SuspendEastmark: the Union stops vouching for Eastmark's national + // authority, cutting Eastmark off from the rest of the Union. + SuspendEastmark atomic.Bool + // ForgeEastmarkMark: EastID replaces its accredited level-of-assurance + // Trust Mark with one it issued itself. + ForgeEastmarkMark atomic.Bool + // CompromiseEastmark: Eastmark's national authority starts vouching + // for an impostor claiming a Northland host. + CompromiseEastmark atomic.Bool +} + +// World is the whole running federation. +type World struct { + port int + net *demonet.Net + scenes Scenes + + union *entity + authorities map[string]*entity // country key -> national authority + accreditors map[string]*accreditation + idps map[string]*identityProvider + services []*relyingParty + impostor *entity + entities []*entity // every entity, for the console + loaHighType string + router demonet.HostRouter + consoleState *console +} + +// Hosts is every host name the demo serves, for its TLS certificate. +func Hosts() []string { + hosts := []string{unionHost, consoleHost, impostorHost} + for _, c := range countries { + hosts = append(hosts, "ta."+c.key+".localhost", "accreditation."+c.key+".localhost", "id."+c.key+".localhost") + } + for _, s := range services { + hosts = append(hosts, s.host) + } + return hosts +} + +// New builds the federation for a listener on port, reached through n. +func New(port int, n *demonet.Net) (*World, error) { + w := &World{ + port: port, net: n, + authorities: map[string]*entity{}, + accreditors: map[string]*accreditation{}, + idps: map[string]*identityProvider{}, + router: demonet.HostRouter{}, + } + w.loaHighType = w.entityID(unionHost) + "/marks/loa-high" + if err := w.build(); err != nil { + return nil, err + } + return w, nil +} + +// Handler serves every entity. +func (w *World) Handler() http.Handler { return w.router } + +// Scenes is the console's switches. +func (w *World) Scenes() *Scenes { return &w.scenes } + +// URL is the address of host's page at path. +func (w *World) URL(host, path string) string { return w.entityID(host) + path } + +func (w *World) entityID(host string) string { + if w.port == 443 { + return "https://" + host + } + return fmt.Sprintf("https://%s:%d", host, w.port) +} + +// fetcher is the hardened federation fetch client for the entity at +// host, going through the demo network. +func (w *World) fetcher(host string) (*fapihttp.Client, error) { + return fapihttp.New(w.net.Client(host), fapihttp.Config{ + MaxResponseBytes: 1 << 20, RequestTimeout: 10 * time.Second, MaxRedirects: 2, + // Every demo host resolves to the loopback address. + AllowLoopbackHTTP: true, + }) +} + +// anchor is a Trust Anchor as a resolver is configured with it: the +// entity ID and the keys it's trusted with, provided out of band. +func anchor(e *entity) federation.TrustAnchor { + return federation.TrustAnchor{EntityID: e.id, JWKS: e.key.jwks} +} + +// resolverFor is a Trust Chain resolver trusting anchors, fetching as +// host. +func (w *World) resolverFor(host string, anchors ...*entity) (*federation.Resolver, error) { + fetcher, err := w.fetcher(host) + if err != nil { + return nil, err + } + tas := make([]federation.TrustAnchor, len(anchors)) + for i, a := range anchors { + tas[i] = anchor(a) + } + return federation.NewResolver(federation.Config{ + TrustAnchors: tas, + Limits: federation.Limits{ + MaxPathLength: 4, MaxAuthorityHints: 5, + MaxStatementLifetime: statementLifetime + time.Hour, MaxClockSkew: 30 * time.Second, + }, + }, federation.Dependencies{HTTP: fetcher, Clock: federation.SystemClock{}}) +} + +func (w *World) add(e *entity) error { + if err := e.init(); err != nil { + return err + } + w.entities = append(w.entities, e) + w.router[e.host] = e.mux + return nil +} + +// build creates every entity: the Union first, then each country's +// authority, accreditation body and identity provider, then the +// services, then the impostor used by the compromise scene. +func (w *World) build() error { + unionKey, err := newSigningKey("union-1", keys.FederationEntitySigning) + if err != nil { + return err + } + w.union = &entity{ + id: w.entityID(unionHost), host: unionHost, name: "Meridian Union", role: "Union trust anchor", + key: unionKey, + trustMarkIssuers: map[string][]string{}, + } + for _, c := range countries { + w.union.trustMarkIssuers[w.loaHighType] = append(w.union.trustMarkIssuers[w.loaHighType], w.entityID("accreditation."+c.key+".localhost")) + } + w.union.subordinates = w.unionSubordinates + if err := w.add(w.union); err != nil { + return err + } + + for _, c := range countries { + if err := w.buildCountry(c); err != nil { + return err + } + } + for _, s := range services { + rp, err := w.newRelyingParty(s) + if err != nil { + return err + } + w.services = append(w.services, rp) + } + + impostorKey, err := newSigningKey("impostor-1", keys.FederationEntitySigning) + if err != nil { + return err + } + w.impostor = &entity{ + id: w.entityID(impostorHost), host: impostorHost, name: "Northland Bank (impostor)", role: "impostor service", + country: "eastmark", key: impostorKey, + authorityHints: []string{w.entityID("ta.eastmark.localhost")}, + metadata: func(context.Context) (map[string]json.RawMessage, error) { + return map[string]json.RawMessage{"openid_relying_party": json.RawMessage(`{"client_name":"Northland Bank","response_types":["code"]}`)}, nil + }, + } + if err := w.add(w.impostor); err != nil { + return err + } + + c, err := w.newConsole() + if err != nil { + return err + } + w.consoleState = c + return nil +} + +// unionSubordinates is who the Union vouches for: each national +// authority, confined to its own country's hosts and to at most one +// level of entities below it, under the Union's baseline policy. +func (w *World) unionSubordinates() map[string]subordinate { + out := map[string]subordinate{} + for _, c := range countries { + if c.key == "eastmark" && w.scenes.SuspendEastmark.Load() { + continue + } + ta := w.authorities[c.key] + out[ta.id] = subordinate{ + jwks: ta.key.jwks, + constraints: &federation.Constraints{ + MaxPathLength: 1, HasMaxPathLength: true, + NamingConstraints: &federation.NamingConstraints{Permitted: []string{"." + c.key + ".localhost"}}, + }, + policy: federation.MetadataPolicy{ + "openid_relying_party": { + "grant_types": {"subset_of": json.RawMessage(`["authorization_code","refresh_token"]`)}, + "token_endpoint_auth_method": {"one_of": json.RawMessage(`["private_key_jwt"]`)}, + }, + }, + } + } + return out +} + +// buildCountry creates c's national authority, accreditation body and +// identity provider. +func (w *World) buildCountry(c country) error { + taHost := "ta." + c.key + ".localhost" + taKey, err := newSigningKey(c.key+"-ta-1", keys.FederationEntitySigning) + if err != nil { + return err + } + ta := &entity{ + id: w.entityID(taHost), host: taHost, name: c.name + " Federation Authority", role: "national authority", + country: c.key, key: taKey, + authorityHints: []string{w.union.id}, + // A service trusting its own authority directly finishes its + // Trust Chains there, so the authority publishes which + // accreditation bodies it recognises too — the same ones the + // Union does. + trustMarkIssuers: w.union.trustMarkIssuers, + } + ta.subordinates = func() map[string]subordinate { return w.nationalSubordinates(c) } + w.authorities[c.key] = ta + if err := w.add(ta); err != nil { + return err + } + + acc, err := w.newAccreditation(c, ta) + if err != nil { + return err + } + w.accreditors[c.key] = acc + + idp, err := w.newIdentityProvider(c, ta) + if err != nil { + return err + } + w.idps[c.key] = idp + return nil +} + +// nationalSubordinates is who c's national authority vouches for: its +// accreditation body, identity provider and services — plus, once +// compromised, the impostor. +func (w *World) nationalSubordinates(c country) map[string]subordinate { + out := map[string]subordinate{} + if acc, ok := w.accreditors[c.key]; ok { + out[acc.entity.id] = subordinate{jwks: acc.entity.key.jwks} + } + if idp, ok := w.idps[c.key]; ok { + out[idp.entity.id] = subordinate{jwks: idp.entity.key.jwks} + } + for _, rp := range w.services { + if rp.country.key == c.key { + out[rp.entity.id] = subordinate{jwks: rp.entity.key.jwks, policy: nationalServicePolicy(c)} + } + } + if c.key == "eastmark" && w.scenes.CompromiseEastmark.Load() && w.impostor != nil { + out[w.impostor.id] = subordinate{jwks: w.impostor.key.jwks} + } + return out +} + +// nationalServicePolicy is what each country adds on top of the Union's +// baseline for its own services: Southport requires a named, contactable +// operator. +func nationalServicePolicy(c country) federation.MetadataPolicy { + if c.key != "southport" { + return nil + } + return federation.MetadataPolicy{ + "openid_relying_party": { + "contacts": {"essential": json.RawMessage(`true`)}, + }, + } +} + +// countryByKey returns the country with key. +func countryByKey(key string) country { + for _, c := range countries { + if c.key == key { + return c + } + } + return country{} +} + +// sortedEntities is every entity grouped for the console: the Union, +// then each country's entities, then the impostor. +func (w *World) sortedEntities() []*entity { + out := append([]*entity{}, w.entities...) + order := map[string]int{"": 0, "northland": 1, "southport": 2, "eastmark": 3} + sort.SliceStable(out, func(i, j int) bool { return order[out[i].country] < order[out[j].country] }) + return out +} diff --git a/release-please-config.json b/release-please-config.json index 33da6d6e..c593f56c 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -6,7 +6,8 @@ "release-type": "go", "include-component-in-tag": false, "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": false + "bump-patch-for-minor-pre-major": false, + "exclude-paths": ["examples"] } } } diff --git a/sonar-project.properties b/sonar-project.properties index bd50a0ae..bd300b39 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -71,7 +71,12 @@ sonar.go.tests.reportPaths=report.json # live OIDF suite instance, verified by actually running it # (conformance/server/scripts/README.md's own "OpenID Federation" # section), not by unit tests. -sonar.coverage.exclusions=conformance/**/*.py,conformance/**/*.go,cmd/conformance-client/**/*.go,cmd/conformance-as/main.go,cmd/conformance-federation-trust-anchor/**/*.go +# +# examples/** are separate Go modules (their own go.mod), tested by +# ci.yml's own examples job; the library's coverage.out never includes +# them, so every line would otherwise read as uncovered. Bug, +# vulnerability and code-smell rules still apply to them. +sonar.coverage.exclusions=conformance/**/*.py,conformance/**/*.go,cmd/conformance-client/**/*.go,cmd/conformance-as/main.go,cmd/conformance-federation-trust-anchor/**/*.go,examples/** # internal/jose/header.go and internal/jwe/header.go's parseHeader # functions are structurally identical for ~10 lines (decode, then From deda7e3e22440c7518a707be0464439ca588ebec Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Wed, 30 Sep 2026 00:20:40 +0800 Subject: [PATCH 2/2] docs(examples): clear federated-union Sonar findings Share the demo's repeated literals as constants and a countryHost helper, split the console's chain view out of its resolve handler, and suppress text:S8566 for examples' go.mod files, which have no third-party dependencies for a go.sum to pin. Co-Authored-By: Claude Opus 5.5 --- .../federated-union/union/accreditation.go | 2 +- examples/federated-union/union/console.go | 54 ++++++++++--------- examples/federated-union/union/idp.go | 26 +++++---- examples/federated-union/union/rp.go | 11 ++-- examples/federated-union/union/world.go | 16 ++++-- sonar-project.properties | 10 +++- 6 files changed, 71 insertions(+), 48 deletions(-) diff --git a/examples/federated-union/union/accreditation.go b/examples/federated-union/union/accreditation.go index 1dc4f0e3..0ec7defc 100644 --- a/examples/federated-union/union/accreditation.go +++ b/examples/federated-union/union/accreditation.go @@ -18,7 +18,7 @@ type accreditation struct { } func (w *World) newAccreditation(c country, ta *entity) (*accreditation, error) { - host := "accreditation." + c.key + ".localhost" + host := countryHost("accreditation", c.key) key, err := newSigningKey(c.key+"-accreditation-1", keys.FederationEntitySigning) if err != nil { return nil, err diff --git a/examples/federated-union/union/console.go b/examples/federated-union/union/console.go index cae6e895..d1faae4a 100644 --- a/examples/federated-union/union/console.go +++ b/examples/federated-union/union/console.go @@ -198,33 +198,10 @@ func (c *console) resolve(w http.ResponseWriter, r *http.Request) { view := resolveView{Subject: subject, SubjectName: c.w.displayName(subject), Via: via, ViaName: c.viaName(via)} view.Declared = c.declaredMetadata(r.Context(), subject) - resolved, err := resolver.Resolve(r.Context(), subject) - if err != nil { + if resolved, err := resolver.Resolve(r.Context(), subject); err != nil { view.Error = err.Error() } else { - view.ExpiresAt = resolved.ExpiresAt - for _, id := range resolved.Chain { - e := c.w.entityByID(id) - row := entityRow{ID: id, Name: c.w.displayName(id)} - if e != nil { - country := countryByKey(e.country) - row.Role, row.Country, row.Color = e.role, country.name, country.color - } - view.Chain = append(view.Chain, row) - } - for _, t := range sortedKeys(resolved.Metadata) { - view.Resolved = append(view.Resolved, metadataView{Type: t, JSON: prettyJSON(resolved.Metadata[t])}) - } - for _, mark := range resolved.TrustMarks { - mv := markView{Type: mark.TrustMarkType} - claims, err := resolver.VerifyTrustMark(r.Context(), subject, mark, federation.RequireFederationAccreditation) - if err != nil { - mv.Status = err.Error() - } else { - mv.OK, mv.Issuer, mv.Status = true, c.w.displayName(claims.Issuer), "accredited by the Union" - } - view.Marks = append(view.Marks, mv) - } + c.describeResolved(r.Context(), resolver, subject, resolved, &view) } country := country{} if e := c.w.entityByID(subject); e != nil { @@ -233,6 +210,33 @@ func (c *console) resolve(w http.ResponseWriter, r *http.Request) { c.w.render(w, "resolve", resolvePage{Page: c.w.page("Trust Chain of "+view.SubjectName, country), View: view, Entities: c.rows(), Anchors: c.anchorOptions()}) } +// describeResolved fills view with resolved's Trust Chain, resolved +// metadata and Trust Marks, each mark verified against the Union's +// accreditation. +func (c *console) describeResolved(ctx context.Context, resolver *federation.Resolver, subject string, resolved federation.ResolvedEntity, view *resolveView) { + view.ExpiresAt = resolved.ExpiresAt + for _, id := range resolved.Chain { + row := entityRow{ID: id, Name: c.w.displayName(id)} + if e := c.w.entityByID(id); e != nil { + country := countryByKey(e.country) + row.Role, row.Country, row.Color = e.role, country.name, country.color + } + view.Chain = append(view.Chain, row) + } + for _, t := range sortedKeys(resolved.Metadata) { + view.Resolved = append(view.Resolved, metadataView{Type: t, JSON: prettyJSON(resolved.Metadata[t])}) + } + for _, mark := range resolved.TrustMarks { + mv := markView{Type: mark.TrustMarkType} + if claims, err := resolver.VerifyTrustMark(ctx, subject, mark, federation.RequireFederationAccreditation); err != nil { + mv.Status = err.Error() + } else { + mv.OK, mv.Issuer, mv.Status = true, c.w.displayName(claims.Issuer), "accredited by the Union" + } + view.Marks = append(view.Marks, mv) + } +} + // declaredMetadata is the subject's own metadata as it declares it, // before any superior's policy — for comparison with the resolved form. func (c *console) declaredMetadata(ctx context.Context, subject string) []metadataView { diff --git a/examples/federated-union/union/idp.go b/examples/federated-union/union/idp.go index 6869a576..5dda40ff 100644 --- a/examples/federated-union/union/idp.go +++ b/examples/federated-union/union/idp.go @@ -56,17 +56,21 @@ type pendingLogin struct { interaction server.InteractionRequest } -const interactionCookie = "fu_interaction" +const ( + interactionCookie = "fu_interaction" + authorizePath = "/authorize" + signInFailed = "Sign-in failed" +) func (w *World) newIdentityProvider(c country, ta *entity) (*identityProvider, error) { - host := "id." + c.key + ".localhost" + host := countryHost("id", c.key) id := w.entityID(host) issuer, err := fapi.ParseIssuerURL(id) if err != nil { return nil, err } endpoint := func(path string) (fapi.URL, error) { return fapi.ParseEndpointURL(id + path) } - authz, err := endpoint("/authorize") + authz, err := endpoint(authorizePath) if err != nil { return nil, err } @@ -177,8 +181,8 @@ func (w *World) newIdentityProvider(c country, ta *entity) (*identityProvider, e mux := idp.entity.mux mux.HandleFunc("GET /jwks", idp.jwks) mux.HandleFunc("POST /par", idp.par) - mux.HandleFunc("GET /authorize", idp.authorize) - mux.HandleFunc("POST /authorize", idp.decide) + mux.HandleFunc("GET "+authorizePath, idp.authorize) + mux.HandleFunc("POST "+authorizePath, idp.decide) mux.HandleFunc("POST /token", idp.token) return idp, nil } @@ -308,7 +312,7 @@ func (p *identityProvider) authorize(w http.ResponseWriter, r *http.Request) { p.pending[a.Handle.String()] = pendingLogin{handle: a.Handle, interaction: a.Interaction} p.mu.Unlock() http.SetCookie(w, &http.Cookie{ - Name: interactionCookie, Value: a.Handle.String(), Path: "/authorize", + Name: interactionCookie, Value: a.Handle.String(), Path: authorizePath, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) p.w.render(w, "consent", consentPage{ @@ -340,7 +344,7 @@ func (p *identityProvider) decide(w http.ResponseWriter, r *http.Request) { p.w.renderError(w, http.StatusBadRequest, "Session expired", "The sign-in in progress is unknown or already finished.") return } - http.SetCookie(w, &http.Cookie{Name: interactionCookie, Path: "/authorize", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: interactionCookie, Path: authorizePath, MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) if err := r.ParseForm(); err != nil { p.w.renderError(w, http.StatusBadRequest, "Malformed form", err.Error()) return @@ -357,12 +361,12 @@ func (p *identityProvider) decide(w http.ResponseWriter, r *http.Request) { } subject, err := server.NewAuthenticatedSubject(subjectID) if err != nil { - p.w.renderError(w, http.StatusInternalServerError, "Sign-in failed", err.Error()) + p.w.renderError(w, http.StatusInternalServerError, signInFailed, err.Error()) return } authCtx, err := server.NewAuthenticationContext(time.Now(), "https://union.localhost/loa/high", []string{"hwk"}) if err != nil { - p.w.renderError(w, http.StatusInternalServerError, "Sign-in failed", err.Error()) + p.w.renderError(w, http.StatusInternalServerError, signInFailed, err.Error()) return } var approved []string @@ -378,14 +382,14 @@ func (p *identityProvider) decide(w http.ResponseWriter, r *http.Request) { outcome, err := p.srv.CompleteAuthorization(r.Context(), server.CompleteAuthorizationRequest{Handle: login.handle, Result: result}) if err != nil { - p.w.renderError(w, http.StatusInternalServerError, "Sign-in failed", err.Error()) + p.w.renderError(w, http.StatusInternalServerError, signInFailed, err.Error()) return } switch o := outcome.(type) { case server.AuthorizationRedirect: http.Redirect(w, r, o.Destination().String(), http.StatusFound) case server.AuthorizationLocalError: - p.w.renderError(w, o.Error.HTTPStatus(), "Sign-in failed", string(o.Error.Code())+": "+o.Error.PublicDescription()) + p.w.renderError(w, o.Error.HTTPStatus(), signInFailed, string(o.Error.Code())+": "+o.Error.PublicDescription()) } } diff --git a/examples/federated-union/union/rp.go b/examples/federated-union/union/rp.go index 2ec64a50..1b7235cf 100644 --- a/examples/federated-union/union/rp.go +++ b/examples/federated-union/union/rp.go @@ -43,6 +43,7 @@ type relyingParty struct { } const ( + noSignIn = "No sign-in in progress" sessionCookie = "fu_session" providerCookie = "fu_provider" ) @@ -281,24 +282,24 @@ func (rp *relyingParty) callback(w http.ResponseWriter, r *http.Request) { sessionC, err1 := r.Cookie(sessionCookie) providerC, err2 := r.Cookie(providerCookie) if err1 != nil || err2 != nil { - rp.w.renderError(w, http.StatusBadRequest, "No sign-in in progress", "This browser didn't start a sign-in here.") + rp.w.renderError(w, http.StatusBadRequest, noSignIn, "This browser didn't start a sign-in here.") return } handle, err := client.ParseSessionHandle(sessionC.Value) if err != nil { - rp.w.renderError(w, http.StatusBadRequest, "No sign-in in progress", err.Error()) + rp.w.renderError(w, http.StatusBadRequest, noSignIn, err.Error()) return } rp.mu.Lock() cl := rp.clients[providerC.Value] rp.mu.Unlock() if cl == nil { - rp.w.renderError(w, http.StatusBadRequest, "No sign-in in progress", "Unknown identity provider.") + rp.w.renderError(w, http.StatusBadRequest, noSignIn, "Unknown identity provider.") return } result, err := cl.CompleteAuthorization(r.Context(), client.AuthorizationCallback{RawQuery: r.URL.RawQuery, Session: handle}) if err != nil { - rp.w.renderError(w, http.StatusBadGateway, "Sign-in failed", err.Error()) + rp.w.renderError(w, http.StatusBadGateway, signInFailed, err.Error()) return } idp := rp.w.idpByID(providerC.Value) @@ -321,7 +322,7 @@ func (rp *relyingParty) callback(w http.ResponseWriter, r *http.Request) { case client.CompletionDenied: rp.w.renderError(w, http.StatusForbidden, "Sign-in declined", res.Code+": "+res.Description) default: - rp.w.renderError(w, http.StatusBadGateway, "Sign-in failed", fmt.Sprintf("unexpected result %T", result)) + rp.w.renderError(w, http.StatusBadGateway, signInFailed, fmt.Sprintf("unexpected result %T", result)) } } diff --git a/examples/federated-union/union/world.go b/examples/federated-union/union/world.go index 882da806..198d7195 100644 --- a/examples/federated-union/union/world.go +++ b/examples/federated-union/union/world.go @@ -56,6 +56,12 @@ var services = []serviceSpec{ {host: "telco.eastmark.localhost", name: "Eastmark Telecom", country: "eastmark"}, } +// localhost is the suffix every demo host shares. +const localhost = ".localhost" + +// countryHost is role's host in country, e.g. id.eastmark.localhost. +func countryHost(role, countryKey string) string { return role + "." + countryKey + localhost } + const ( unionHost = "union.localhost" consoleHost = "console.localhost" @@ -97,7 +103,7 @@ type World struct { func Hosts() []string { hosts := []string{unionHost, consoleHost, impostorHost} for _, c := range countries { - hosts = append(hosts, "ta."+c.key+".localhost", "accreditation."+c.key+".localhost", "id."+c.key+".localhost") + hosts = append(hosts, countryHost("ta", c.key), countryHost("accreditation", c.key), countryHost("id", c.key)) } for _, s := range services { hosts = append(hosts, s.host) @@ -196,7 +202,7 @@ func (w *World) build() error { trustMarkIssuers: map[string][]string{}, } for _, c := range countries { - w.union.trustMarkIssuers[w.loaHighType] = append(w.union.trustMarkIssuers[w.loaHighType], w.entityID("accreditation."+c.key+".localhost")) + w.union.trustMarkIssuers[w.loaHighType] = append(w.union.trustMarkIssuers[w.loaHighType], w.entityID(countryHost("accreditation", c.key))) } w.union.subordinates = w.unionSubordinates if err := w.add(w.union); err != nil { @@ -223,7 +229,7 @@ func (w *World) build() error { w.impostor = &entity{ id: w.entityID(impostorHost), host: impostorHost, name: "Northland Bank (impostor)", role: "impostor service", country: "eastmark", key: impostorKey, - authorityHints: []string{w.entityID("ta.eastmark.localhost")}, + authorityHints: []string{w.entityID(countryHost("ta", "eastmark"))}, metadata: func(context.Context) (map[string]json.RawMessage, error) { return map[string]json.RawMessage{"openid_relying_party": json.RawMessage(`{"client_name":"Northland Bank","response_types":["code"]}`)}, nil }, @@ -254,7 +260,7 @@ func (w *World) unionSubordinates() map[string]subordinate { jwks: ta.key.jwks, constraints: &federation.Constraints{ MaxPathLength: 1, HasMaxPathLength: true, - NamingConstraints: &federation.NamingConstraints{Permitted: []string{"." + c.key + ".localhost"}}, + NamingConstraints: &federation.NamingConstraints{Permitted: []string{"." + c.key + localhost}}, }, policy: federation.MetadataPolicy{ "openid_relying_party": { @@ -270,7 +276,7 @@ func (w *World) unionSubordinates() map[string]subordinate { // buildCountry creates c's national authority, accreditation body and // identity provider. func (w *World) buildCountry(c country) error { - taHost := "ta." + c.key + ".localhost" + taHost := countryHost("ta", c.key) taKey, err := newSigningKey(c.key+"-ta-1", keys.FederationEntitySigning) if err != nil { return err diff --git a/sonar-project.properties b/sonar-project.properties index bd300b39..d33dbd80 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -147,7 +147,13 @@ sonar.cpd.exclusions=internal/jose/header.go,internal/jwe/header.go,server/error # run-all.sh's repeated leg names. Every other rule (bugs, # vulnerabilities, security hotspots, other code smells) stays active # on this code, and library packages keep all four rules. -sonar.issue.ignore.multicriteria=conformancePyComplexity,confCmdLiterals,confCmdComplexity,confCmdParams,confScriptLiterals,confScriptComplexity,confScriptParams,confShellLiterals +# +# text:S8566 ("go.sum missing") on examples/**/go.mod: each example +# module's only dependency is FAPIgo itself, via a local `replace +# => ../..`, so there is nothing for a go.sum to pin — `go mod tidy` +# writes no go.sum at all. If an example ever gains a third-party +# dependency, go.sum appears and this suppression stops mattering. +sonar.issue.ignore.multicriteria=conformancePyComplexity,confCmdLiterals,confCmdComplexity,confCmdParams,confScriptLiterals,confScriptComplexity,confScriptParams,confShellLiterals,examplesGoSum sonar.issue.ignore.multicriteria.conformancePyComplexity.ruleKey=python:S3776 sonar.issue.ignore.multicriteria.conformancePyComplexity.resourceKey=conformance/**/*.py sonar.issue.ignore.multicriteria.confCmdLiterals.ruleKey=go:S1192 @@ -164,3 +170,5 @@ sonar.issue.ignore.multicriteria.confScriptParams.ruleKey=go:S107 sonar.issue.ignore.multicriteria.confScriptParams.resourceKey=conformance/**/*.go sonar.issue.ignore.multicriteria.confShellLiterals.ruleKey=shelldre:S1192 sonar.issue.ignore.multicriteria.confShellLiterals.resourceKey=conformance/**/*.sh +sonar.issue.ignore.multicriteria.examplesGoSum.ruleKey=text:S8566 +sonar.issue.ignore.multicriteria.examplesGoSum.resourceKey=examples/**/go.mod