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..0ec7defc --- /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 := countryHost("accreditation", c.key) + 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..d1faae4a --- /dev/null +++ b/examples/federated-union/union/console.go @@ -0,0 +1,307 @@ +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) + + if resolved, err := resolver.Resolve(r.Context(), subject); err != nil { + view.Error = err.Error() + } else { + c.describeResolved(r.Context(), resolver, subject, resolved, &view) + } + 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()}) +} + +// 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 { + 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..5dda40ff --- /dev/null +++ b/examples/federated-union/union/idp.go @@ -0,0 +1,416 @@ +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" + authorizePath = "/authorize" + signInFailed = "Sign-in failed" +) + +func (w *World) newIdentityProvider(c country, ta *entity) (*identityProvider, error) { + 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(authorizePath) + 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 "+authorizePath, idp.authorize) + mux.HandleFunc("POST "+authorizePath, 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: authorizePath, + 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: 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 + } + + 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, 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, signInFailed, 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, 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(), signInFailed, 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..1b7235cf --- /dev/null +++ b/examples/federated-union/union/rp.go @@ -0,0 +1,368 @@ +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 ( + noSignIn = "No sign-in in progress" + 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, 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, noSignIn, err.Error()) + return + } + rp.mu.Lock() + cl := rp.clients[providerC.Value] + rp.mu.Unlock() + if cl == nil { + 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, signInFailed, 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, signInFailed, 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..198d7195 --- /dev/null +++ b/examples/federated-union/union/world.go @@ -0,0 +1,367 @@ +// 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"}, +} + +// 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" + 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, countryHost("ta", c.key), countryHost("accreditation", c.key), countryHost("id", c.key)) + } + 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(countryHost("accreditation", c.key))) + } + 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(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 + }, + } + 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 := countryHost("ta", c.key) + 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..d33dbd80 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 @@ -142,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 @@ -159,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