diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65c49c..9b5a7c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: Miakapp/Miakapp-V3 - ref: abee78b80383f0e7c900dbcaede3741095f08c35 + ref: cc3bcd70fdb4b058f990ca2607693a2043faebaf path: .contract/miakapp-v3 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -48,7 +48,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: Miakapp/MiakAPI - ref: 5c26eaa830015d94f53bf05fbbb0f5ebda6d290f + ref: a798a746847ba3d5c16128a08b33353269e770a4 path: miakapi - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -82,13 +82,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: Miakapp/Miakapp-V3 - ref: 259173ca730f0763710b7c99afa163ba26e70bb2 + ref: f9509c41ef1c0389623d31419372e6430a2313d9 path: .integration/miakapp-v3 - name: Check out the pinned MiakAPI coordinator SDK uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: Miakapp/MiakAPI - ref: 5c26eaa830015d94f53bf05fbbb0f5ebda6d290f + ref: a798a746847ba3d5c16128a08b33353269e770a4 path: .integration/miakapi - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -115,7 +115,10 @@ jobs: run: | bun install --frozen-lockfile bun run build - - name: Run the Home Key to relay authentication integration + - name: Install the pinned Chromium runtime + working-directory: .integration/miakapi + run: ./node_modules/.bin/playwright install --with-deps chromium + - name: Run the coordinator and browser relay authentication integration run: >- ./scripts/check-platform-integration.sh "$GITHUB_WORKSPACE/.integration/miakapp-v3" diff --git a/README.md b/README.md index 390991c..803e21d 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,23 @@ connects home coordinators, authenticated users, and future CLI sessions without persisting Firebase credentials, Home Keys, push credentials, or product business logic. -The standalone binary validates short-lived coordinator and CLI access tokens -against the configured control-plane JWKS and validates human sessions against -Firebase public certificates. It holds no verification secret and never calls -the platform with authenticated authority. The relay remains an implementation -preview until deployment admission limits and the staging relay integration gate -have passed; do not deploy it as a production replacement yet. +The standalone binary validates short-lived coordinator, CLI, and browser-user +access tokens against the configured control-plane JWKS. Firebase Auth and App +Check credentials remain on the browser-to-control-plane HTTPS boundary and are +never relay credentials. The relay holds no verification secret and never calls +the platform with authenticated authority. It remains an implementation preview +until deployment admission limits and the staging relay integration gate have +passed; do not deploy it as a production replacement yet. ## Contract The wire contract is owned by Miakapp-V3 -[`RFC 0001`](https://github.com/Miakapp/Miakapp-V3/blob/b927789691dd8cdebc91b673853cdc6711fe057d/docs/rfcs/0001-wire-protocol.md). -This module pins the independent canonical Go codec at that immutable commit. -It does not copy frame definitions into this repository. +[`RFC 0001`](https://github.com/Miakapp/Miakapp-V3/blob/b927789691dd8cdebc91b673853cdc6711fe057d/docs/rfcs/0001-wire-protocol.md), +and this module pins the canonical Go codec at that immutable commit. Relay +credential profiles are owned by +[`RFC 0004`](https://github.com/Miakapp/Miakapp-V3/blob/cc3bcd70fdb4b058f990ca2607693a2043faebaf/docs/rfcs/0004-platform-control-plane.md), +with the independent Go verifier pinned at that second commit. This repository +does not copy either contract. The implemented vertical slice includes: @@ -71,15 +75,16 @@ cd /absolute/path/to/Miakapp-Server ./scripts/check-miakapi-integration.sh /absolute/path/to/MiakAPI ``` -The gate serves the MiakAPI browser fixture from loopback HTTPS, allows exactly -that page Origin, and executes it in headless Chromium against the real relay. +This smaller gate serves the MiakAPI browser fixture from loopback HTTPS, allows +exactly that page Origin, and executes it in headless Chromium against the real +relay. It proves enrollment, initial state, one patch, one call/result and scheduled reauthentication on one WebSocket. A second call succeeds after the original four-second lease expires, proving the correlated renewal completed without a reconnect. The fixture uses only synthetic tokens and emits a closed semantic JSON result; browser traces and WebSocket frame inspection stay disabled. It -does not prove Google's live Firebase certificate path or memory isolation from -a malicious relay. +does not exercise the control-plane credential exchange or memory isolation +from a malicious relay. Run the production authentication adapter against the canonical control-plane vectors: @@ -88,9 +93,9 @@ vectors: ./scripts/check-control-plane-integration.sh /absolute/path/to/Miakapp-V3 ``` -Run the synthetic Home Key through the real emulator control plane, MiakAPI -provider, scheduled SDK reauthentication, and production relay verifier after -building both external checkouts: +Run the synthetic Home Key and browser source credentials through the real +emulator control plane, MiakAPI providers, scheduled SDK reauthentication, and +production relay verifier after building both external checkouts: ```sh ./scripts/check-platform-integration.sh /absolute/path/to/Miakapp-V3 /absolute/path/to/MiakAPI @@ -98,18 +103,25 @@ building both external checkouts: This gate uses only loopback HTTPS, the `demo-miakapp-v4` Auth and Firestore emulators, ephemeral certificates, Home Key and control-secret files, and two -independent instances of the production verifier cache. A fake-clock probe cache -proves one shared refresh for 32 concurrent future-key tokens, the ten-second -random-`kid` abuse bound, conditional expiry revalidation, fail-closed outage -handling and bounded recovery. The real-time cache remains on the relay socket -while the SDK changes signing key during scheduled `REAUTH` without changing its -principal or reconnecting. Integration-only constructors and authenticated -loopback controls are excluded from normal relay builds; evidence records only -bounded counters and consistency booleans, never tokens or claims. - -This local gate uses canonical synthetic keys. It does not claim live Cloud KMS -version rotation, Google's Firebase certificate endpoint, or public-ingress -behavior. +independent instances of the production verifier cache. It creates an unenrolled +synthetic Auth user and a signed synthetic App Check token in one private +temporary file. Because the Auth emulator emits deliberately unsigned JWTs, the +browser fixture applies a local shape adapter before the strict public provider +and restores the exact emulator token at the HTTPS boundary; production code is +not relaxed. The real control plane then issues the audience-bound user token. + +A fake-clock probe cache proves one shared refresh for 32 concurrent future-key +tokens, the ten-second random-`kid` abuse bound, conditional expiry revalidation, +fail-closed outage handling, and bounded recovery. The real-time relay cache +survives signing-key rotation while a coordinator and a real Chromium browser +each complete same-socket `REAUTH`. The fixture then changes the authoritative +Home relay, and the browser carries that single new credential to a second real +relay without another exchange or overlapping sockets. It recovers state and +executes calls on both relays. Integration-only constructors and authenticated +loopback controls are excluded from normal relay builds; transient frame checks +emit only bounded counters and a source-credential-presence boolean, never +tokens, claims, or frame contents. This local gate does not claim live Cloud KMS +rotation or public-ingress behavior. CI checks out both repositories at immutable commits so relay changes cannot silently drift against moving protocol, authentication, or SDK dependencies. @@ -130,7 +142,6 @@ The executable reads: | `MIAKAPP_CONTROL_PLANE_ISSUER` | required | Exact HTTPS control-plane issuer origin | | `MIAKAPP_CONTROL_PLANE_JWKS_URL` | required | Same-origin `/.well-known/jwks.json` endpoint | | `MIAKAPP_RELAY_AUDIENCE` | required | Exact public `wss://.../ws` URL for this relay | -| `MIAKAPP_FIREBASE_PROJECT_ID` | required | Firebase project accepted for human sessions | | `MIAKAPP_HANDSHAKE_TIMEOUT` | `5s` | Maximum wait for HELLO and authentication | | `MIAKAPP_WRITE_TIMEOUT` | `5s` | One WebSocket write deadline | | `MIAKAPP_PING_INTERVAL` | `30s` | RFC 6455 liveness interval | @@ -151,25 +162,31 @@ identity lease; the relay independently binds that lease to the requested role, home, coordinator name, principal ID, Home Key client ID, and expiry. Reauthentication cannot change the established principal or switch Home Keys. -Coordinator and CLI tokens use the canonical Ed25519 profile from RFC 0004. The -relay pins one issuer, one audience and one same-origin JWKS URL. The JWKS client -accepts no redirects, bounds responses to 64 KiB and 16 exact Ed25519 keys, -requires the specified ETag and 60-second cache policy, coalesces concurrent +Coordinator, CLI, and browser-user tokens use distinct canonical Ed25519 +profiles from RFC 0004. The browser profile contains exactly one relay audience, +Home ID, Firebase UID, `miakapp_role=user`, `scope=relay:user`, a maximum +five-minute lease, and an optional verified email; it contains no Home Key client +or coordinator claim. HELLO may only bind the already verified user to the same +Home. Firebase ID and App Check tokens are rejected as relay credentials. + +The relay pins one issuer, one audience, and one same-origin JWKS URL. The JWKS +client accepts no redirects, bounds responses to 64 KiB and 16 exact Ed25519 +keys, requires the specified ETag and 60-second cache policy, coalesces concurrent refreshes, and permits at most one unknown-key refresh per ten seconds. Expired caches fail closed if they cannot be refreshed. -Human sessions use Firebase ID tokens, the pinned Firebase project/issuer and -Google's fixed Secure Token certificate endpoint. Certificate lifetime follows -the endpoint's `Cache-Control` maximum age. This local verification deliberately -does not claim immediate Firebase account-disablement or token-revocation checks; -the browser must reauthenticate before its current ID-token lease expires. - -Under the current RFC 0004 profile, the selected relay necessarily receives the -Firebase ID token as a reusable bearer credential and observes home traffic. -Users may therefore select only an official relay or one they explicitly trust -as completely as the Miakapp backend. Arbitrary community-relay selection must -remain disabled until the control plane can issue a short-lived credential bound -to one relay audience, home, user and role. +The selected relay still observes plaintext Home traffic and must therefore be +an official instance, self-hosted by the user, or operated by somebody the user +explicitly trusts. Self-hosting requires configuring the relay's exact public +WSS URL in the authoritative Home record and as `MIAKAPP_RELAY_AUDIENCE`; a token +issued for another relay cannot be replayed here. The relay needs only public +control-plane configuration and no Firebase or Miakapp credential. + +This credential transition is intentionally fail-closed rather than dual-stack: +an old Firebase-token relay rejects the new user profile, and this relay rejects +Firebase source tokens. Roll out compatible control-plane, browser SDK, and relay +revisions together, retain the previous complete set for rollback, and do not +route browser traffic to a partially upgraded relay. This preview is not yet the deployment admission-control boundary. Per-IP connection limits, total-home admission, and aggregate cross-connection memory diff --git a/docker-compose.yml b/docker-compose.yml index f593bfa..c1a65f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,6 @@ services: MIAKAPP_ALLOWED_ORIGINS: ${MIAKAPP_ALLOWED_ORIGINS:-} MIAKAPP_CONTROL_PLANE_ISSUER: ${MIAKAPP_CONTROL_PLANE_ISSUER:?required} MIAKAPP_CONTROL_PLANE_JWKS_URL: ${MIAKAPP_CONTROL_PLANE_JWKS_URL:?required} - MIAKAPP_FIREBASE_PROJECT_ID: ${MIAKAPP_FIREBASE_PROJECT_ID:?required} MIAKAPP_LISTEN_ADDRESS: :3000 MIAKAPP_RELAY_AUDIENCE: ${MIAKAPP_RELAY_AUDIENCE:?required} ports: diff --git a/go.mod b/go.mod index 344cdd9..5f507be 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.26.6 require ( github.com/coder/websocket v1.8.15 - github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904115658-abee78b80383 + github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904181620-cc3bcd70fdb4 github.com/miakapp/miakapp-v3/protocol/go v0.0.0-20260831114037-b927789691dd ) diff --git a/go.sum b/go.sum index 3166245..b2977a9 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904115658-abee78b80383 h1:NN+AIZu7xw2ecFZxj9ysQw3133lfMyLa6FyaqakzEIY= -github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904115658-abee78b80383/go.mod h1:cQE1eJh3FwoiffiXMkCxsPyjll9nb9KE8G37mU6oFF4= +github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904181620-cc3bcd70fdb4 h1:fesdNm7llFrIjFEdR/auLe3nW0Y7PAlssVW2q8GacNQ= +github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904181620-cc3bcd70fdb4/go.mod h1:cQE1eJh3FwoiffiXMkCxsPyjll9nb9KE8G37mU6oFF4= github.com/miakapp/miakapp-v3/protocol/go v0.0.0-20260831114037-b927789691dd h1:QMcEzyWak+1XyEIDV7dgsw8XhF5w6Df4zHklBQSv31g= github.com/miakapp/miakapp-v3/protocol/go v0.0.0-20260831114037-b927789691dd/go.mod h1:3AmoKsBHhQ4xYN5f5fnHd4vlk7IlrJqINQ0GBmYzHN4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 5379403..697b0f5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -53,8 +53,8 @@ func (identity Identity) Principal(sessionID int64) []any { } // Verifier validates initial and replacement access material. Implementations -// must perform all signature, issuer, audience, expiry and revocation checks -// required by the future platform control-plane contract. +// must perform all signature, issuer, audience, expiry and profile checks +// required by the platform control-plane contract. type Verifier interface { Verify(context.Context, Request) (Identity, error) } @@ -102,15 +102,36 @@ func ValidateBinding(request Request, identity Identity, now time.Time) error { if !validIdentityValue(identity.HomeID, 128) || !validIdentityValue(identity.ID, 128) { return Failure(ErrRejected, errors.New("missing identity binding")) } + expectedScope := "" + switch identity.Role { + case RoleUser: + expectedScope = "relay:user" + case RoleCoordinator: + expectedScope = "relay:coordinator" + case RoleCLI: + expectedScope = "relay:cli" + default: + return Failure(ErrRejected, errors.New("unsupported identity role")) + } + if _, ok := identity.Scopes[expectedScope]; !ok || len(identity.Scopes) != 1 { + return Failure(ErrRejected, errors.New("identity scope does not match its role")) + } if identity.Role == RoleUser { if identity.ClientID != "" { return Failure(ErrRejected, errors.New("unexpected Home Key client binding")) } + if request.HomeID == "" || identity.HomeID != request.HomeID { + return Failure(ErrRejected, errors.New("user home mismatch")) + } } else if !validIdentityValue(identity.ClientID, 128) { return Failure(ErrRejected, errors.New("missing Home Key client binding")) - } - if request.HomeID != "" && identity.HomeID != request.HomeID { - return Failure(ErrRejected, errors.New("home mismatch")) + } else { + if request.HomeID != "" && identity.HomeID != request.HomeID { + return Failure(ErrRejected, errors.New("home mismatch")) + } + if identity.VerifiedEmail != "" { + return Failure(ErrRejected, errors.New("unexpected verified email")) + } } if request.Role == RoleCoordinator { if identity.CoordinatorName != request.CoordinatorName { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 7bdf6ec..f917b8c 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -16,6 +16,7 @@ func validIdentity() Identity { ClientID: "client-1", CoordinatorName: "automation", ExpiresAt: time.Now().Add(time.Minute), + Scopes: map[string]struct{}{"relay:coordinator": {}}, } } @@ -35,6 +36,9 @@ func TestValidateBindingRejectsPrincipalChanges(t *testing.T) { {name: "coordinator", mutate: func(identity *Identity) { identity.CoordinatorName = "other" }, kind: ErrRejected}, {name: "long ID", mutate: func(identity *Identity) { identity.ID = strings.Repeat("x", 129) }, kind: ErrRejected}, {name: "missing client", mutate: func(identity *Identity) { identity.ClientID = "" }, kind: ErrRejected}, + {name: "missing scope", mutate: func(identity *Identity) { identity.Scopes = nil }, kind: ErrRejected}, + {name: "wrong scope", mutate: func(identity *Identity) { identity.Scopes = map[string]struct{}{"relay:user": {}} }, kind: ErrRejected}, + {name: "multiple scopes", mutate: func(identity *Identity) { identity.Scopes["relay:user"] = struct{}{} }, kind: ErrRejected}, {name: "control character", mutate: func(identity *Identity) { identity.ID = "home\n1" }, kind: ErrRejected}, {name: "invalid email", mutate: func(identity *Identity) { identity.VerifiedEmail = "user\x00@example.test" }, kind: ErrRejected}, {name: "unrepresentable expiry", mutate: func(identity *Identity) { identity.ExpiresAt = time.UnixMilli(9_007_199_254_740_992) }, kind: ErrRejected}, @@ -52,6 +56,33 @@ func TestValidateBindingRejectsPrincipalChanges(t *testing.T) { } } +func TestValidateBindingRequiresTokenBoundUserHomeAndShape(t *testing.T) { + now := time.Now() + identity := Identity{ + Role: RoleUser, + HomeID: "home-1", + ID: "firebase-user", + VerifiedEmail: "user@example.test", + ExpiresAt: now.Add(time.Minute), + Scopes: map[string]struct{}{"relay:user": {}}, + } + if err := ValidateBinding(Request{Role: RoleUser, HomeID: "home-1"}, identity, now); err != nil { + t.Fatal(err) + } + for _, request := range []Request{ + {Role: RoleUser}, + {Role: RoleUser, HomeID: "home-2"}, + } { + if err := ValidateBinding(request, identity, now); Kind(err) != ErrRejected { + t.Fatalf("expected user Home binding rejection, received %v", err) + } + } + identity.ClientID = "home-key-client" + if err := ValidateBinding(Request{Role: RoleUser, HomeID: "home-1"}, identity, now); Kind(err) != ErrRejected { + t.Fatalf("expected user client binding rejection, received %v", err) + } +} + func TestFailureRedactsWrappedError(t *testing.T) { err := Failure(ErrRejected, errors.New("Bearer secret-token")) if err.Error() != "authentication rejected" { diff --git a/internal/auth/key_cache.go b/internal/auth/key_cache.go index 957bacb..dd41cdb 100644 --- a/internal/auth/key_cache.go +++ b/internal/auth/key_cache.go @@ -3,11 +3,8 @@ package auth import ( "bytes" "context" - "crypto/rsa" - "crypto/x509" "encoding/base64" "encoding/json" - "encoding/pem" "errors" "fmt" "io" @@ -27,14 +24,6 @@ const ( maximumPublicKeys = 16 unknownKIDRefreshDelay = 10 * time.Second failedFetchRetryDelay = time.Second - maximumFirebaseCacheTTL = 24 * time.Hour -) - -type keyDocumentKind uint8 - -const ( - controlPlaneJWKS keyDocumentKind = iota - firebaseCertificates ) type keyFetchResult struct { @@ -46,7 +35,6 @@ type keyFetchResult struct { type keySource struct { url string - kind keyDocumentKind client *http.Client } @@ -201,7 +189,7 @@ func (source keySource) fetch(ctx context.Context, etag string) (keyFetchResult, } defer response.Body.Close() - ttl, err := responseCacheTTL(response.Header, source.kind) + ttl, err := responseCacheTTL(response.Header) if err != nil { return keyFetchResult{}, err } @@ -226,19 +214,11 @@ func (source keySource) fetch(ctx context.Context, etag string) (keyFetchResult, if err != nil { return keyFetchResult{}, err } - var keys []controlplane.PublicJWK - var responseETag string - if source.kind == controlPlaneJWKS { - responseETag, err = canonicalETag(response.Header.Get("ETag")) - if err == nil { - keys, err = decodeControlPlaneJWKS(body) - } - } else { - keys, err = decodeFirebaseCertificates(body) - if rawETag := response.Header.Get("ETag"); rawETag != "" { - responseETag, err = canonicalETag(rawETag) - } + responseETag, err := canonicalETag(response.Header.Get("ETag")) + if err != nil { + return keyFetchResult{}, err } + keys, err := decodeControlPlaneJWKS(body) if err != nil { return keyFetchResult{}, err } @@ -259,7 +239,7 @@ func readBoundedBody(reader io.Reader) ([]byte, error) { return body, nil } -func responseCacheTTL(header http.Header, kind keyDocumentKind) (time.Duration, error) { +func responseCacheTTL(header http.Header) (time.Duration, error) { directives := make(map[string]string) for _, raw := range header.Values("Cache-Control") { for _, part := range strings.Split(raw, ",") { @@ -290,14 +270,10 @@ func responseCacheTTL(header http.Header, kind keyDocumentKind) (time.Duration, if !exists || err != nil || maxAge < 1 { return 0, errors.New("public-key cache control has no positive max-age") } - if kind == controlPlaneJWKS { - _, hasPublic := directives["public"] - _, hasMustRevalidate := directives["must-revalidate"] - if maxAge != 60 || !hasPublic || !hasMustRevalidate || directives["public"] != "" || directives["must-revalidate"] != "" || len(directives) != 3 { - return 0, errors.New("control-plane JWKS cache control does not match the required policy") - } - } else if maxAge > int64(maximumFirebaseCacheTTL/time.Second) { - return 0, errors.New("Firebase certificate cache lifetime is overlong") + _, hasPublic := directives["public"] + _, hasMustRevalidate := directives["must-revalidate"] + if maxAge != 60 || !hasPublic || !hasMustRevalidate || directives["public"] != "" || directives["must-revalidate"] != "" || len(directives) != 3 { + return 0, errors.New("control-plane JWKS cache control does not match the required policy") } age := int64(0) if ageText := header.Get("Age"); ageText != "" { @@ -449,72 +425,6 @@ func decodeControlPlaneKey(decoder *json.Decoder) (controlplane.PublicJWK, error }, nil } -func decodeFirebaseCertificates(body []byte) ([]controlplane.PublicJWK, error) { - decoder := json.NewDecoder(bytes.NewReader(body)) - opening, err := decoder.Token() - if err != nil || opening != json.Delim('{') { - return nil, errors.New("Firebase certificate document is not an object") - } - keys := make([]controlplane.PublicJWK, 0) - seen := make(map[string]struct{}) - for decoder.More() { - if len(keys) >= maximumPublicKeys { - return nil, errors.New("Firebase certificate document has too many keys") - } - member, memberErr := decoder.Token() - kid, ok := member.(string) - if memberErr != nil || !ok || !validKeyID(kid) { - return nil, errors.New("Firebase certificate key ID is invalid") - } - if _, duplicate := seen[kid]; duplicate { - return nil, errors.New("Firebase certificate document has a duplicate key ID") - } - seen[kid] = struct{}{} - var certificatePEM string - if err = decoder.Decode(&certificatePEM); err != nil { - return nil, errors.New("Firebase certificate value is not a string") - } - key, keyErr := firebaseCertificateJWK(kid, certificatePEM) - if keyErr != nil { - return nil, keyErr - } - keys = append(keys, key) - } - closing, err := decoder.Token() - if err != nil || closing != json.Delim('}') || len(keys) == 0 { - return nil, errors.New("Firebase certificate document is empty or incomplete") - } - if err = requireJSONEOF(decoder); err != nil { - return nil, err - } - return keys, nil -} - -func firebaseCertificateJWK(kid, value string) (controlplane.PublicJWK, error) { - block, rest := pem.Decode([]byte(value)) - if block == nil || block.Type != "CERTIFICATE" || len(bytes.TrimSpace(rest)) != 0 { - return controlplane.PublicJWK{}, errors.New("Firebase certificate PEM is invalid") - } - certificate, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return controlplane.PublicJWK{}, errors.New("Firebase certificate DER is invalid") - } - publicKey, ok := certificate.PublicKey.(*rsa.PublicKey) - if !ok || publicKey.N.BitLen() < 2_048 || publicKey.N.BitLen() > 4_096 || publicKey.E < 3 || publicKey.E%2 == 0 { - return controlplane.PublicJWK{}, errors.New("Firebase certificate RSA key is invalid") - } - modulus := publicKey.N.Bytes() - exponent := make([]byte, 0, 4) - for value := publicKey.E; value > 0; value >>= 8 { - exponent = append([]byte{byte(value)}, exponent...) - } - return controlplane.PublicJWK{ - KTY: "RSA", KID: kid, Use: "sig", Alg: "RS256", - N: base64.RawURLEncoding.EncodeToString(modulus), - E: base64.RawURLEncoding.EncodeToString(exponent), - }, nil -} - func validKeyID(value string) bool { if value == "" || len(value) > 128 { return false diff --git a/internal/auth/key_cache_test.go b/internal/auth/key_cache_test.go index 20c87ec..d8d9795 100644 --- a/internal/auth/key_cache_test.go +++ b/internal/auth/key_cache_test.go @@ -59,7 +59,7 @@ func TestControlPlaneJWKSDecoderIsClosedAndBounded(t *testing.T) { func TestCachePolicyRequiresExactControlPlaneDirectives(t *testing.T) { valid := http.Header{"Cache-Control": []string{"public, max-age=60, must-revalidate"}} - if ttl, err := responseCacheTTL(valid, controlPlaneJWKS); err != nil || ttl != time.Minute { + if ttl, err := responseCacheTTL(valid); err != nil || ttl != time.Minute { t.Fatalf("unexpected valid cache policy: %v, %v", ttl, err) } invalid := []string{ @@ -71,16 +71,16 @@ func TestCachePolicyRequiresExactControlPlaneDirectives(t *testing.T) { "public, max-age=\"60, must-revalidate", } for _, value := range invalid { - if _, err := responseCacheTTL(http.Header{"Cache-Control": []string{value}}, controlPlaneJWKS); err == nil { + if _, err := responseCacheTTL(http.Header{"Cache-Control": []string{value}}); err == nil { t.Fatalf("expected cache policy %q to be rejected", value) } } aged := http.Header{ - "Cache-Control": []string{"public, max-age=3600, must-revalidate"}, - "Age": []string{"60"}, + "Cache-Control": []string{"public, max-age=60, must-revalidate"}, + "Age": []string{"1"}, } - if ttl, err := responseCacheTTL(aged, firebaseCertificates); err != nil || ttl != 59*time.Minute { - t.Fatalf("unexpected aged Firebase cache policy: %v, %v", ttl, err) + if ttl, err := responseCacheTTL(aged); err != nil || ttl != 59*time.Second { + t.Fatalf("unexpected aged control-plane cache policy: %v, %v", ttl, err) } if _, err := canonicalETag(`"strong"`); err != nil { t.Fatal(err) @@ -116,7 +116,7 @@ func TestKeyCacheCoalescesConcurrentFetchesAndRevalidatesWithETag(t *testing.T) t.Cleanup(server.Close) clock := &lockedClock{now: time.Unix(1_788_211_200, 0)} cache := newKeyCache(keySource{ - url: server.URL, kind: controlPlaneJWKS, client: server.Client(), + url: server.URL, client: server.Client(), }, clock.read) const callers = 32 @@ -290,7 +290,7 @@ func TestPublicKeyFetchRejectsRedirects(t *testing.T) { t.Cleanup(redirect.Close) client := redirect.Client() client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } - source := keySource{url: redirect.URL, kind: controlPlaneJWKS, client: client} + source := keySource{url: redirect.URL, client: client} if _, err := source.fetch(context.Background(), ""); err == nil { t.Fatal("expected public-key redirect to be rejected") } diff --git a/internal/auth/platform.go b/internal/auth/platform.go index 8c1c0d3..38adc8d 100644 --- a/internal/auth/platform.go +++ b/internal/auth/platform.go @@ -10,18 +10,16 @@ import ( ) type platformDependencies struct { - client *http.Client - now func() time.Time - firebaseCertificatesURL string + client *http.Client + now func() time.Time } -// PlatformVerifier validates Miakapp access tokens and Firebase ID tokens -// without holding a platform secret or making an authenticated request. +// PlatformVerifier validates Miakapp access tokens without holding a platform +// secret or making an authenticated request. type PlatformVerifier struct { - config PlatformConfig - now func() time.Time - controlKeys *keyCache - firebaseKeys *keyCache + config PlatformConfig + now func() time.Time + controlKeys *keyCache } // NewPlatformVerifier constructs the production verifier with pinned HTTPS @@ -34,9 +32,8 @@ func NewPlatformVerifier(config PlatformConfig) (*PlatformVerifier, error) { }, } return newPlatformVerifier(config, platformDependencies{ - client: client, - now: time.Now, - firebaseCertificatesURL: firebaseCertificateURL, + client: client, + now: time.Now, }) } @@ -47,23 +44,17 @@ func newPlatformVerifier(config PlatformConfig, dependencies platformDependencie if dependencies.client == nil || dependencies.now == nil { return nil, errors.New("platform verifier dependencies are incomplete") } - if _, err := canonicalURL(dependencies.firebaseCertificatesURL, "https", true); err != nil { - return nil, errors.New("Firebase certificate endpoint is invalid") - } return &PlatformVerifier{ config: config, now: dependencies.now, controlKeys: newKeyCache(keySource{ - url: config.JWKSURL, kind: controlPlaneJWKS, client: dependencies.client, - }, dependencies.now), - firebaseKeys: newKeyCache(keySource{ - url: dependencies.firebaseCertificatesURL, kind: firebaseCertificates, client: dependencies.client, + url: config.JWKSURL, client: dependencies.client, }, dependencies.now), }, nil } // Verify derives the immutable relay principal exclusively from verified token -// claims and the HELLO home context used by Firebase users. +// claims. HELLO fields are checked separately as connection bindings. func (verifier *PlatformVerifier) Verify(ctx context.Context, request Request) (Identity, error) { switch request.Role { case RoleCoordinator: @@ -71,7 +62,7 @@ func (verifier *PlatformVerifier) Verify(ctx context.Context, request Request) ( case RoleCLI: return verifier.verifyAccess(ctx, request, "cli", "relay:cli") case RoleUser: - return verifier.verifyFirebase(ctx, request) + return verifier.verifyAccess(ctx, request, "user", "relay:user") default: return Identity{}, Failure(ErrRejected, errors.New("unsupported authentication role")) } @@ -87,37 +78,77 @@ func (verifier *PlatformVerifier) verifyAccess( if err != nil { return Identity{}, Failure(ErrTemporary, err) } - identity, verifyErr := verifier.verifyMiakappToken(request.Token, profile, keys) + verified, verifyErr := verifier.verifyMiakappToken(request.Token, profile, keys) if controlplane.VerificationCode(verifyErr) == controlplane.UnknownKID { keys, err = verifier.controlKeys.refreshUnknownKID(ctx) if err != nil { return Identity{}, Failure(ErrTemporary, err) } - identity, verifyErr = verifier.verifyMiakappToken(request.Token, profile, keys) + verified, verifyErr = verifier.verifyMiakappToken(request.Token, profile, keys) } if verifyErr != nil { return Identity{}, verificationFailureForContract(verifyErr) } - coordinatorName := "" - if identity.CoordinatorName != nil { - coordinatorName = *identity.CoordinatorName + return mapAccessIdentity(verified, profile, scope) +} + +func mapAccessIdentity( + verified controlplane.AccessIdentity, + profile string, + scope string, +) (Identity, error) { + scopes := map[string]struct{}{scope: {}} + switch identity := verified.(type) { + case *controlplane.HomeKeyAccessIdentity: + if profile == "user" || identity.Scope != scope || identity.Role == nil || *identity.Role != profile { + return Identity{}, Failure(ErrRejected, errors.New("unexpected Home Key access-token profile")) + } + coordinatorName := "" + if identity.CoordinatorName != nil { + coordinatorName = *identity.CoordinatorName + } + if (profile == "coordinator") != (coordinatorName != "") { + return Identity{}, Failure(ErrRejected, errors.New("unexpected coordinator access-token binding")) + } + role := RoleCLI + if profile == "coordinator" { + role = RoleCoordinator + } + return Identity{ + Role: role, + HomeID: identity.HomeID, + ID: identity.PrincipalID, + ClientID: identity.ClientID, + CoordinatorName: coordinatorName, + ExpiresAt: time.Unix(identity.ExpiresAt, 0), + Scopes: scopes, + }, nil + case *controlplane.UserAccessIdentity: + if profile != "user" || identity.Scope != scope || identity.Role != "user" { + return Identity{}, Failure(ErrRejected, errors.New("unexpected user access-token profile")) + } + verifiedEmail := "" + if identity.VerifiedEmail != nil { + verifiedEmail = *identity.VerifiedEmail + } + return Identity{ + Role: RoleUser, + HomeID: identity.HomeID, + ID: identity.PrincipalID, + VerifiedEmail: verifiedEmail, + ExpiresAt: time.Unix(identity.ExpiresAt, 0), + Scopes: scopes, + }, nil + default: + return Identity{}, Failure(ErrRejected, errors.New("unsupported access-token identity")) } - return Identity{ - Role: request.Role, - HomeID: identity.HomeID, - ID: identity.PrincipalID, - ClientID: identity.ClientID, - CoordinatorName: coordinatorName, - ExpiresAt: time.Unix(identity.ExpiresAt, 0), - Scopes: map[string]struct{}{scope: {}}, - }, nil } func (verifier *PlatformVerifier) verifyMiakappToken( token string, profile string, keys []controlplane.PublicJWK, -) (*controlplane.AccessIdentity, error) { +) (controlplane.AccessIdentity, error) { fixture := &controlplane.Fixture{ Now: verifier.now().Unix(), Deployment: controlplane.Deployment{ @@ -128,49 +159,6 @@ func (verifier *PlatformVerifier) verifyMiakappToken( return controlplane.VerifyMiakappAccessToken(token, fixture, profile, keys) } -func (verifier *PlatformVerifier) verifyFirebase(ctx context.Context, request Request) (Identity, error) { - keys, err := verifier.firebaseKeys.current(ctx) - if err != nil { - return Identity{}, Failure(ErrTemporary, err) - } - identity, verifyErr := verifier.verifyFirebaseToken(request.Token, keys) - if controlplane.VerificationCode(verifyErr) == controlplane.UnknownKID { - keys, err = verifier.firebaseKeys.refreshUnknownKID(ctx) - if err != nil { - return Identity{}, Failure(ErrTemporary, err) - } - identity, verifyErr = verifier.verifyFirebaseToken(request.Token, keys) - } - if verifyErr != nil { - return Identity{}, verificationFailureForContract(verifyErr) - } - verifiedEmail := "" - if identity.VerifiedEmail != nil { - verifiedEmail = *identity.VerifiedEmail - } - return Identity{ - Role: RoleUser, - HomeID: request.HomeID, - ID: identity.UserID, - VerifiedEmail: verifiedEmail, - ExpiresAt: time.Unix(identity.ExpiresAt, 0), - }, nil -} - -func (verifier *PlatformVerifier) verifyFirebaseToken( - token string, - keys []controlplane.PublicJWK, -) (*controlplane.FirebaseIdentity, error) { - fixture := &controlplane.Fixture{ - Now: verifier.now().Unix(), - Firebase: controlplane.FirebaseProfile{ - ProjectID: verifier.config.FirebaseProjectID, - Issuer: "https://securetoken.google.com/" + verifier.config.FirebaseProjectID, - }, - } - return controlplane.VerifyFirebaseIDToken(token, fixture, keys) -} - func verificationFailureForContract(err error) error { switch controlplane.VerificationCode(err) { case controlplane.Expired: diff --git a/internal/auth/platform_config.go b/internal/auth/platform_config.go index 0c6b056..40609d3 100644 --- a/internal/auth/platform_config.go +++ b/internal/auth/platform_config.go @@ -4,30 +4,23 @@ import ( "errors" "net/url" "os" - "regexp" "strings" ) -const firebaseCertificateURL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" - -var firebaseProjectIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{4,28}[a-z0-9]$`) - // PlatformConfig pins every authority used by the production verifier. None of // these values is a credential; deployments may publish them verbatim. type PlatformConfig struct { - Issuer string - JWKSURL string - RelayAudience string - FirebaseProjectID string + Issuer string + JWKSURL string + RelayAudience string } // LoadPlatformConfig reads the closed production authentication configuration. func LoadPlatformConfig() (PlatformConfig, error) { config := PlatformConfig{ - Issuer: os.Getenv("MIAKAPP_CONTROL_PLANE_ISSUER"), - JWKSURL: os.Getenv("MIAKAPP_CONTROL_PLANE_JWKS_URL"), - RelayAudience: os.Getenv("MIAKAPP_RELAY_AUDIENCE"), - FirebaseProjectID: os.Getenv("MIAKAPP_FIREBASE_PROJECT_ID"), + Issuer: os.Getenv("MIAKAPP_CONTROL_PLANE_ISSUER"), + JWKSURL: os.Getenv("MIAKAPP_CONTROL_PLANE_JWKS_URL"), + RelayAudience: os.Getenv("MIAKAPP_RELAY_AUDIENCE"), } if err := config.Validate(); err != nil { return PlatformConfig{}, err @@ -53,9 +46,6 @@ func (config PlatformConfig) Validate() error { if err != nil || !strings.HasSuffix(relay.Path, "/ws") { return errors.New("MIAKAPP_RELAY_AUDIENCE must be a canonical WSS URL ending in /ws") } - if !firebaseProjectIDPattern.MatchString(config.FirebaseProjectID) { - return errors.New("MIAKAPP_FIREBASE_PROJECT_ID is invalid") - } return nil } diff --git a/internal/auth/platform_config_test.go b/internal/auth/platform_config_test.go index d920d6d..b91c1cf 100644 --- a/internal/auth/platform_config_test.go +++ b/internal/auth/platform_config_test.go @@ -4,10 +4,9 @@ import "testing" func validPlatformConfig() PlatformConfig { return PlatformConfig{ - Issuer: "https://control.example.test", - JWKSURL: "https://control.example.test/.well-known/jwks.json", - RelayAudience: "wss://relay.example.test/ws", - FirebaseProjectID: "demo-miakapp-v4", + Issuer: "https://control.example.test", + JWKSURL: "https://control.example.test/.well-known/jwks.json", + RelayAudience: "wss://relay.example.test/ws", } } @@ -32,7 +31,6 @@ func TestPlatformConfigPinsCanonicalAuthorities(t *testing.T) { {name: "relay query", mutate: func(config *PlatformConfig) { config.RelayAudience += "?key=value" }}, {name: "relay path", mutate: func(config *PlatformConfig) { config.RelayAudience = "wss://relay.example.test/socket" }}, {name: "insecure relay", mutate: func(config *PlatformConfig) { config.RelayAudience = "ws://relay.example.test/ws" }}, - {name: "Firebase project", mutate: func(config *PlatformConfig) { config.FirebaseProjectID = "miakapp-3.example" }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -50,7 +48,7 @@ func TestLoadPlatformConfigRequiresEveryPublicAuthority(t *testing.T) { t.Setenv("MIAKAPP_CONTROL_PLANE_ISSUER", config.Issuer) t.Setenv("MIAKAPP_CONTROL_PLANE_JWKS_URL", config.JWKSURL) t.Setenv("MIAKAPP_RELAY_AUDIENCE", config.RelayAudience) - t.Setenv("MIAKAPP_FIREBASE_PROJECT_ID", config.FirebaseProjectID) + t.Setenv("MIAKAPP_FIREBASE_PROJECT_ID", "ignored-obsolete-setting") loaded, err := LoadPlatformConfig() if err != nil { t.Fatal(err) diff --git a/internal/auth/platform_integration.go b/internal/auth/platform_integration.go index 0d3d2e2..1cc036b 100644 --- a/internal/auth/platform_integration.go +++ b/internal/auth/platform_integration.go @@ -32,8 +32,7 @@ func NewPlatformVerifierForIntegration( }, } return newPlatformVerifier(config, platformDependencies{ - client: client, - now: now, - firebaseCertificatesURL: config.Issuer + "/firebase-certificates", + client: client, + now: now, }) } diff --git a/internal/auth/platform_test.go b/internal/auth/platform_test.go index f6b3d22..67eebf2 100644 --- a/internal/auth/platform_test.go +++ b/internal/auth/platform_test.go @@ -2,17 +2,10 @@ package auth import ( "context" - "crypto" "crypto/ed25519" - "crypto/rand" - "crypto/rsa" "crypto/sha256" - "crypto/x509" - "crypto/x509/pkix" "encoding/base64" "encoding/json" - "encoding/pem" - "math/big" "net/http" "net/http/httptest" "os" @@ -25,10 +18,9 @@ import ( ) const ( - testControlKID = "test-control-key" - testFirebaseKID = "test-firebase-key" - testClientID = "AAAAAAAAAAAAAAAAAAAAAA" - testTokenID = "AQEBAQEBAQEBAQEBAQEBAQ" + testControlKID = "test-control-key" + testClientID = "AAAAAAAAAAAAAAAAAAAAAA" + testTokenID = "AQEBAQEBAQEBAQEBAQEBAQ" ) type lockedClock struct { @@ -49,29 +41,21 @@ func (clock *lockedClock) advance(duration time.Duration) { } type verifierFixture struct { - verifier *PlatformVerifier - server *httptest.Server - clock *lockedClock - controlPrivate ed25519.PrivateKey - firebasePrivate *rsa.PrivateKey - controlFetches atomic.Int64 - firebaseFetches atomic.Int64 - failControl atomic.Bool + verifier *PlatformVerifier + server *httptest.Server + clock *lockedClock + controlPrivate ed25519.PrivateKey + controlFetches atomic.Int64 + failControl atomic.Bool } func newVerifierFixture(t *testing.T) *verifierFixture { t.Helper() seed := sha256.Sum256([]byte("miakapp-relay-platform-verifier-test-key")) controlPrivate := ed25519.NewKeyFromSeed(seed[:]) - firebasePrivate, err := rsa.GenerateKey(rand.Reader, 2_048) - if err != nil { - t.Fatal(err) - } - certificate := testCertificate(t, firebasePrivate) fixture := &verifierFixture{ - clock: &lockedClock{now: time.Unix(1_788_211_200, 0)}, - controlPrivate: controlPrivate, - firebasePrivate: firebasePrivate, + clock: &lockedClock{now: time.Unix(1_788_211_200, 0)}, + controlPrivate: controlPrivate, } controlPublic := controlPrivate.Public().(ed25519.PublicKey) controlBody, err := json.Marshal(map[string]any{ @@ -83,10 +67,6 @@ func newVerifierFixture(t *testing.T) *verifierFixture { if err != nil { t.Fatal(err) } - firebaseBody, err := json.Marshal(map[string]string{testFirebaseKID: certificate}) - if err != nil { - t.Fatal(err) - } fixture.server = httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch request.URL.Path { case "/.well-known/jwks.json": @@ -103,26 +83,19 @@ func newVerifierFixture(t *testing.T) *verifierFixture { } response.Header().Set("Content-Type", "application/json") _, _ = response.Write(controlBody) - case "/firebase-certificates": - fixture.firebaseFetches.Add(1) - response.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate") - response.Header().Set("Content-Type", "application/json; charset=utf-8") - _, _ = response.Write(firebaseBody) default: http.NotFound(response, request) } })) t.Cleanup(fixture.server.Close) config := PlatformConfig{ - Issuer: fixture.server.URL, - JWKSURL: fixture.server.URL + "/.well-known/jwks.json", - RelayAudience: "wss://relay.example.test/ws", - FirebaseProjectID: "demo-miakapp-v4", + Issuer: fixture.server.URL, + JWKSURL: fixture.server.URL + "/.well-known/jwks.json", + RelayAudience: "wss://relay.example.test/ws", } verifier, err := newPlatformVerifier(config, platformDependencies{ - client: fixture.server.Client(), - now: fixture.clock.read, - firebaseCertificatesURL: fixture.server.URL + "/firebase-certificates", + client: fixture.server.Client(), + now: fixture.clock.read, }) if err != nil { t.Fatal(err) @@ -131,22 +104,6 @@ func newVerifierFixture(t *testing.T) *verifierFixture { return fixture } -func testCertificate(t *testing.T, key *rsa.PrivateKey) string { - t.Helper() - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "synthetic Firebase signer"}, - NotBefore: time.Unix(1_700_000_000, 0), - NotAfter: time.Unix(1_900_000_000, 0), - KeyUsage: x509.KeyUsageDigitalSignature, - } - der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) - if err != nil { - t.Fatal(err) - } - return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) -} - func compactToken(t *testing.T, header, claims any, sign func([]byte) []byte) string { t.Helper() encode := func(value any) string { @@ -175,30 +132,33 @@ func (fixture *verifierFixture) accessToken(t *testing.T, kid, audience string, ) } -func (fixture *verifierFixture) firebaseToken(t *testing.T, verified bool) string { +func (fixture *verifierFixture) userAccessToken( + t *testing.T, + kid string, + audience string, + homeID string, + userID string, + verifiedEmail *string, + expiresOffset int64, +) string { t.Helper() now := fixture.clock.read().Unix() claims := map[string]any{ - "iss": "https://securetoken.google.com/demo-miakapp-v4", - "sub": "synthetic-user", "aud": "demo-miakapp-v4", - "exp": now + 3_600, "iat": now, "auth_time": now - 10, - "email": "user@example.test", "email_verified": verified, + "iss": fixture.server.URL, "sub": userID, "aud": audience, + "exp": now + expiresOffset, "iat": now, "jti": testTokenID, + "scope": "relay:user", "miakapp_home": homeID, "miakapp_role": "user", + } + if verifiedEmail != nil { + claims["miakapp_verified_email"] = *verifiedEmail } return compactToken(t, - map[string]any{"alg": "RS256", "kid": testFirebaseKID, "typ": "JWT"}, + map[string]any{"alg": "EdDSA", "kid": kid, "typ": "at+jwt"}, claims, - func(input []byte) []byte { - digest := sha256.Sum256(input) - signature, err := rsa.SignPKCS1v15(rand.Reader, fixture.firebasePrivate, crypto.SHA256, digest[:]) - if err != nil { - t.Fatal(err) - } - return signature - }, + func(input []byte) []byte { return ed25519.Sign(fixture.controlPrivate, input) }, ) } -func TestPlatformVerifierMapsCoordinatorAndFirebasePrincipals(t *testing.T) { +func TestPlatformVerifierMapsCoordinatorAndUserAccessPrincipals(t *testing.T) { fixture := newVerifierFixture(t) coordinator, err := fixture.verifier.Verify(context.Background(), Request{ Role: RoleCoordinator, Token: fixture.accessToken(t, testControlKID, "wss://relay.example.test/ws", 300), @@ -216,24 +176,86 @@ func TestPlatformVerifierMapsCoordinatorAndFirebasePrincipals(t *testing.T) { t.Fatalf("unexpected coordinator scope: %#v", coordinator.Scopes) } + verifiedEmail := "user@example.test" user, err := fixture.verifier.Verify(context.Background(), Request{ - Role: RoleUser, Token: fixture.firebaseToken(t, true), HomeID: "synthetic-home", + Role: RoleUser, + Token: fixture.userAccessToken( + t, testControlKID, "wss://relay.example.test/ws", "synthetic-home", + "synthetic-user", &verifiedEmail, 300, + ), + HomeID: "caller-selected-home-is-not-trusted", }) if err != nil { t.Fatal(err) } if user.HomeID != "synthetic-home" || user.ID != "synthetic-user" || - user.VerifiedEmail != "user@example.test" || user.Role != RoleUser { - t.Fatalf("unexpected Firebase identity: %#v", user) + user.VerifiedEmail != verifiedEmail || user.Role != RoleUser || user.ClientID != "" || + user.CoordinatorName != "" { + t.Fatalf("unexpected user access identity: %#v", user) } - if fixture.controlFetches.Load() != 1 || fixture.firebaseFetches.Load() != 1 { - t.Fatalf("unexpected public-key fetch count: control=%d Firebase=%d", fixture.controlFetches.Load(), fixture.firebaseFetches.Load()) + if _, ok := user.Scopes["relay:user"]; !ok || len(user.Scopes) != 1 { + t.Fatalf("unexpected user scope: %#v", user.Scopes) } - if _, err = fixture.verifier.Verify(context.Background(), Request{ - Role: RoleUser, Token: fixture.firebaseToken(t, false), HomeID: "synthetic-home", - }); err != nil { + if err = ValidateBinding(Request{Role: RoleUser, HomeID: "synthetic-home"}, user, fixture.clock.read()); err != nil { t.Fatal(err) } + if err = ValidateBinding(Request{Role: RoleUser, HomeID: "other-home"}, user, fixture.clock.read()); Kind(err) != ErrRejected { + t.Fatalf("HELLO unexpectedly changed the token-bound Home: %v", err) + } + if fixture.controlFetches.Load() != 1 { + t.Fatalf("user verification unexpectedly used another key source: %d", fixture.controlFetches.Load()) + } + withoutEmail, err := fixture.verifier.Verify(context.Background(), Request{ + Role: RoleUser, + Token: fixture.userAccessToken( + t, testControlKID, "wss://relay.example.test/ws", "synthetic-home", + "synthetic-user", nil, 300, + ), + HomeID: "synthetic-home", + }) + if err != nil { + t.Fatal(err) + } + if withoutEmail.VerifiedEmail != "" { + t.Fatalf("absent verified email was invented: %#v", withoutEmail) + } +} + +func TestPlatformVerifierRejectsCrossProfileAndFirebaseSourceTokens(t *testing.T) { + fixture := newVerifierFixture(t) + verifiedEmail := "user@example.test" + userToken := fixture.userAccessToken( + t, testControlKID, "wss://relay.example.test/ws", "synthetic-home", + "synthetic-user", &verifiedEmail, 300, + ) + if _, err := fixture.verifier.Verify(context.Background(), Request{ + Role: RoleCoordinator, Token: userToken, CoordinatorName: "automation", + }); Kind(err) != ErrRejected { + t.Fatalf("user access token crossed into the coordinator profile: %v", err) + } + coordinatorToken := fixture.accessToken(t, testControlKID, "wss://relay.example.test/ws", 300) + if _, err := fixture.verifier.Verify(context.Background(), Request{ + Role: RoleUser, Token: coordinatorToken, HomeID: "synthetic-home", + }); Kind(err) != ErrRejected { + t.Fatalf("coordinator access token crossed into the user profile: %v", err) + } + firebaseSourceToken := compactToken(t, + map[string]any{"alg": "RS256", "kid": testControlKID, "typ": "JWT"}, + map[string]any{ + "iss": "https://securetoken.google.com/demo-miakapp-v4", + "sub": "synthetic-user", "aud": "demo-miakapp-v4", + "exp": fixture.clock.read().Unix() + 3_600, "iat": fixture.clock.read().Unix(), + }, + func(input []byte) []byte { return ed25519.Sign(fixture.controlPrivate, input) }, + ) + if _, err := fixture.verifier.Verify(context.Background(), Request{ + Role: RoleUser, Token: firebaseSourceToken, HomeID: "synthetic-home", + }); Kind(err) != ErrRejected { + t.Fatalf("Firebase source token was accepted at the relay: %v", err) + } + if fixture.controlFetches.Load() != 1 { + t.Fatalf("cross-profile verification used an unexpected key source: %d", fixture.controlFetches.Load()) + } } func TestPlatformVerifierClassifiesTokenFailuresWithoutRefetchingKnownKeys(t *testing.T) { @@ -367,23 +389,24 @@ func TestCanonicalControlPlaneTokenVectors(t *testing.T) { t.Fatal(err) } for _, vector := range fixture.Vectors { - if vector.Kind != "miakapp" && vector.Kind != "firebase" { + if vector.Kind != "miakapp" { continue } - if vector.Kind == "miakapp" && vector.Profile != "coordinator" && vector.Profile != "cli" { + if vector.Profile != "coordinator" && vector.Profile != "cli" && vector.Profile != "user" { continue } vector := vector t.Run(vector.ID, func(t *testing.T) { verifier := canonicalVectorVerifier(t, fixture, vector) request := Request{Role: RoleUser, Token: vector.Token, HomeID: "synthetic-home"} - if vector.Kind == "miakapp" { - if vector.Profile == "coordinator" { - request.Role = RoleCoordinator - request.CoordinatorName = "automation" - } else { - request.Role = RoleCLI - } + switch vector.Profile { + case "coordinator": + request.Role = RoleCoordinator + request.HomeID = "" + request.CoordinatorName = "automation" + case "cli": + request.Role = RoleCLI + request.HomeID = "" } identity, verifyErr := verifier.Verify(context.Background(), request) if !vector.Valid { @@ -407,49 +430,35 @@ func TestCanonicalControlPlaneTokenVectors(t *testing.T) { if err = ValidateBinding(request, identity, time.Unix(vector.VerificationTime, 0)); err != nil { t.Fatal(err) } - if vector.Kind == "miakapp" { - var expected controlplane.AccessIdentity + if vector.Profile == "user" { + var expected controlplane.UserAccessIdentity if err = json.Unmarshal(vector.Expected, &expected); err != nil { t.Fatal(err) } + email := "" + if expected.VerifiedEmail != nil { + email = *expected.VerifiedEmail + } if identity.HomeID != expected.HomeID || identity.ID != expected.PrincipalID || - identity.ClientID != expected.ClientID || identity.ExpiresAt.Unix() != expected.ExpiresAt { - t.Fatalf("canonical access identity mismatch: %#v", identity) + identity.ClientID != "" || identity.VerifiedEmail != email || + identity.ExpiresAt.Unix() != expected.ExpiresAt { + t.Fatalf("canonical user access identity mismatch: %#v", identity) } } else { - var expected controlplane.FirebaseIdentity + var expected controlplane.HomeKeyAccessIdentity if err = json.Unmarshal(vector.Expected, &expected); err != nil { t.Fatal(err) } - email := "" - if expected.VerifiedEmail != nil { - email = *expected.VerifiedEmail - } - if identity.ID != expected.UserID || identity.VerifiedEmail != email || + if identity.HomeID != expected.HomeID || identity.ID != expected.PrincipalID || + identity.ClientID != expected.ClientID || identity.ExpiresAt.Unix() != expected.ExpiresAt { - t.Fatalf("canonical Firebase identity mismatch: %#v", identity) + t.Fatalf("canonical Home Key access identity mismatch: %#v", identity) } } }) } } -func TestLiveFirebaseCertificateEndpointShape(t *testing.T) { - if os.Getenv("MIAKAPP_RUN_LIVE_FIREBASE_CERTIFICATE_TEST") != "1" { - t.Skip("set MIAKAPP_RUN_LIVE_FIREBASE_CERTIFICATE_TEST=1 for the read-only live check") - } - verifier, err := NewPlatformVerifier(validPlatformConfig()) - if err != nil { - t.Fatal(err) - } - _, err = verifier.Verify(context.Background(), Request{ - Role: RoleUser, Token: "deliberately-malformed", HomeID: "synthetic-home", - }) - if Kind(err) != ErrRejected { - t.Fatalf("live Firebase certificate response could not be consumed: %v", err) - } -} - func canonicalVectorVerifier( t *testing.T, fixture *controlplane.Fixture, @@ -461,104 +470,31 @@ func canonicalVectorVerifier( t.Fatalf("canonical vector references missing key set %q", vector.KeySet) } clock := &lockedClock{now: time.Unix(vector.VerificationTime, 0)} - var body []byte - if vector.Kind == "firebase" { - certificates := make(map[string]string, len(keySet.Keys)) - for _, publicKey := range keySet.Keys { - privateKey := canonicalRSAPrivateKey(t, fixture, publicKey.KID) - certificates[publicKey.KID] = testCertificate(t, privateKey) - } - var err error - body, err = json.Marshal(certificates) - if err != nil { - t.Fatal(err) - } - } else { - var err error - body, err = json.Marshal(keySet) - if err != nil { - t.Fatal(err) - } + body, err := json.Marshal(keySet) + if err != nil { + t.Fatal(err) } server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.Header().Set("Content-Type", "application/json") - if vector.Kind == "firebase" { - response.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate") - } else { - response.Header().Set("Cache-Control", "public, max-age=60, must-revalidate") - response.Header().Set("ETag", `"canonical-vector"`) - } + response.Header().Set("Cache-Control", "public, max-age=60, must-revalidate") + response.Header().Set("ETag", `"canonical-vector"`) _, _ = response.Write(body) })) t.Cleanup(server.Close) config := PlatformConfig{ - Issuer: fixture.Deployment.Issuer, - JWKSURL: fixture.Deployment.JWKSURI, - RelayAudience: fixture.Deployment.RelayAudience, - FirebaseProjectID: fixture.Firebase.ProjectID, + Issuer: fixture.Deployment.Issuer, + JWKSURL: fixture.Deployment.JWKSURI, + RelayAudience: fixture.Deployment.RelayAudience, } verifier, err := newPlatformVerifier(config, platformDependencies{ - client: server.Client(), - now: clock.read, - firebaseCertificatesURL: server.URL, + client: server.Client(), + now: clock.read, }) if err != nil { t.Fatal(err) } - if vector.Kind == "firebase" { - verifier.firebaseKeys = newKeyCache(keySource{ - url: server.URL, kind: firebaseCertificates, client: server.Client(), - }, clock.read) - } else { - verifier.controlKeys = newKeyCache(keySource{ - url: server.URL, kind: controlPlaneJWKS, client: server.Client(), - }, clock.read) - } + verifier.controlKeys = newKeyCache(keySource{ + url: server.URL, client: server.Client(), + }, clock.read) return verifier } - -type canonicalPrivateJWK struct { - KID string `json:"kid"` - KTY string `json:"kty"` - N string `json:"n"` - E string `json:"e"` - D string `json:"d"` - P string `json:"p"` - Q string `json:"q"` -} - -func canonicalRSAPrivateKey(t *testing.T, fixture *controlplane.Fixture, kid string) *rsa.PrivateKey { - t.Helper() - for _, raw := range fixture.PrivateKeys { - var jwk canonicalPrivateJWK - if err := json.Unmarshal(raw, &jwk); err != nil || jwk.KTY != "RSA" || jwk.KID != kid { - continue - } - decode := func(value string) []byte { - decoded, err := base64.RawURLEncoding.DecodeString(value) - if err != nil || len(decoded) == 0 || base64.RawURLEncoding.EncodeToString(decoded) != value { - t.Fatalf("canonical private JWK %q is invalid", kid) - } - return decoded - } - exponent := 0 - for _, value := range decode(jwk.E) { - exponent = exponent<<8 | int(value) - } - key := &rsa.PrivateKey{ - PublicKey: rsa.PublicKey{N: new(big.Int).SetBytes(decode(jwk.N)), E: exponent}, - D: new(big.Int).SetBytes(decode(jwk.D)), - Primes: []*big.Int{ - new(big.Int).SetBytes(decode(jwk.P)), - new(big.Int).SetBytes(decode(jwk.Q)), - }, - } - if err := key.Validate(); err != nil { - t.Fatalf("canonical private JWK %q failed RSA validation: %v", kid, err) - } - key.Precompute() - return key - } - t.Fatalf("canonical Firebase key %q has no test-only private fixture", kid) - return nil -} diff --git a/internal/relay/hardening_test.go b/internal/relay/hardening_test.go index e89c5c8..b34b19e 100644 --- a/internal/relay/hardening_test.go +++ b/internal/relay/hardening_test.go @@ -29,6 +29,7 @@ func (verifier shortLeaseVerifier) Verify(_ context.Context, request auth.Reques ID: "user-1", VerifiedEmail: "user@example.test", ExpiresAt: time.Now().Add(verifier.lifetime), + Scopes: map[string]struct{}{"relay:user": {}}, }, nil } diff --git a/internal/relay/server_test.go b/internal/relay/server_test.go index 9c6c613..bda4a42 100644 --- a/internal/relay/server_test.go +++ b/internal/relay/server_test.go @@ -29,6 +29,7 @@ func (fixtureVerifier) Verify(_ context.Context, request auth.Request) (auth.Ide ClientID: "client-1", CoordinatorName: "automation", ExpiresAt: expiry, + Scopes: map[string]struct{}{"relay:coordinator": {}}, }, nil case "coordinator-token-changed-client": return auth.Identity{ @@ -38,6 +39,7 @@ func (fixtureVerifier) Verify(_ context.Context, request auth.Request) (auth.Ide ClientID: "client-2", CoordinatorName: "automation", ExpiresAt: expiry, + Scopes: map[string]struct{}{"relay:coordinator": {}}, }, nil case "coordinator-b-token": return auth.Identity{ @@ -47,6 +49,7 @@ func (fixtureVerifier) Verify(_ context.Context, request auth.Request) (auth.Ide ClientID: "client-3", CoordinatorName: "secondary", ExpiresAt: expiry, + Scopes: map[string]struct{}{"relay:coordinator": {}}, }, nil case "user-token", "user-token-new": return auth.Identity{ @@ -55,6 +58,7 @@ func (fixtureVerifier) Verify(_ context.Context, request auth.Request) (auth.Ide ID: "user-1", VerifiedEmail: "user@example.test", ExpiresAt: expiry, + Scopes: map[string]struct{}{"relay:user": {}}, }, nil case "user-token-changed": return auth.Identity{ @@ -63,6 +67,7 @@ func (fixtureVerifier) Verify(_ context.Context, request auth.Request) (auth.Ide ID: "user-2", VerifiedEmail: "other@example.test", ExpiresAt: expiry, + Scopes: map[string]struct{}{"relay:user": {}}, }, nil default: return auth.Identity{}, auth.Failure(auth.ErrRejected, nil) diff --git a/scripts/check-platform-integration.sh b/scripts/check-platform-integration.sh index b3d3e01..20ca609 100755 --- a/scripts/check-platform-integration.sh +++ b/scripts/check-platform-integration.sh @@ -18,10 +18,15 @@ relay_repository=$(cd "$(dirname "$0")/.." && pwd) v3_repository=$1 api_repository=$2 if [ ! -f "$v3_repository/control-plane/lib/api.js" ] \ - || [ ! -f "$api_repository/dist/access-token-provider.js" ]; then + || [ ! -f "$api_repository/dist/access-token-provider.js" ] \ + || [ ! -f "$api_repository/dist/browser.js" ]; then echo "Miakapp-V3 control plane and MiakAPI must be built before integration" >&2 exit 66 fi +if [ ! -f "$api_repository/node_modules/playwright/package.json" ]; then + echo "MiakAPI development dependencies must be installed before integration" >&2 + exit 66 +fi if [ -z "${JAVA_HOME:-}" ]; then for candidate in \ @@ -62,6 +67,14 @@ openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 1 \ go build -tags integration -trimpath \ -o "$integration_directory/platform-fixture-server" \ ./test/platform-fixture-server +browser_build_directory="$integration_directory/browser-build" +mkdir -p "$browser_build_directory/node_modules" +ln -s "$api_repository" "$browser_build_directory/node_modules/miakapi" +cp "$relay_repository/test/integration/platform-browser.ts" \ + "$browser_build_directory/platform-browser.ts" +bun build "$browser_build_directory/platform-browser.ts" \ + --target=browser \ + --outfile="$integration_directory/platform-browser.js" export MIAKAPP_V3_REPOSITORY="$v3_repository" export MIAKAPP_API_REPOSITORY="$api_repository" @@ -73,8 +86,12 @@ export MIAKAPP_CONTROL_EVIDENCE_FILE="$integration_directory/control-evidence.js export MIAKAPP_CONTROL_SECRET_FILE="$integration_directory/control-secret" export MIAKAPP_RELAY_METADATA_FILE="$integration_directory/relay.json" export MIAKAPP_RELAY_EVIDENCE_FILE="$integration_directory/evidence.json" +export MIAKAPP_SECONDARY_RELAY_METADATA_FILE="$integration_directory/secondary-relay.json" +export MIAKAPP_SECONDARY_RELAY_EVIDENCE_FILE="$integration_directory/secondary-evidence.json" export MIAKAPP_RELAY_CONTROL_SECRET_FILE="$integration_directory/relay-secret" export MIAKAPP_HOME_KEY_FILE="$integration_directory/home-key" +export MIAKAPP_BROWSER_BUNDLE="$integration_directory/platform-browser.js" +export MIAKAPP_BROWSER_SOURCE_FILE="$integration_directory/browser-source.json" export FIREBASE_CLI_DISABLE_UPDATE_CHECK=true export GCLOUD_PROJECT=demo-miakapp-v4 export GOOGLE_CLOUD_PROJECT=demo-miakapp-v4 diff --git a/scripts/run-platform-integration.sh b/scripts/run-platform-integration.sh index 0f5419f..4106988 100755 --- a/scripts/run-platform-integration.sh +++ b/scripts/run-platform-integration.sh @@ -20,14 +20,23 @@ for name in \ MIAKAPP_CONTROL_SECRET_FILE \ MIAKAPP_RELAY_METADATA_FILE \ MIAKAPP_RELAY_EVIDENCE_FILE \ + MIAKAPP_SECONDARY_RELAY_METADATA_FILE \ + MIAKAPP_SECONDARY_RELAY_EVIDENCE_FILE \ MIAKAPP_RELAY_CONTROL_SECRET_FILE \ - MIAKAPP_HOME_KEY_FILE; do + MIAKAPP_HOME_KEY_FILE \ + MIAKAPP_BROWSER_BUNDLE \ + MIAKAPP_BROWSER_SOURCE_FILE; do required_environment "$name" done control_pid= relay_pid= +secondary_relay_pid= cleanup() { + if [ -n "$secondary_relay_pid" ]; then + kill -TERM "$secondary_relay_pid" 2>/dev/null || true + wait "$secondary_relay_pid" 2>/dev/null || true + fi if [ -n "$relay_pid" ]; then kill -TERM "$relay_pid" 2>/dev/null || true wait "$relay_pid" 2>/dev/null || true @@ -76,19 +85,38 @@ wait_for_file \ "$relay_pid" \ "$MIAKAPP_RELAY_METADATA_FILE.stderr" +MIAKAPP_RELAY_METADATA_FILE="$MIAKAPP_SECONDARY_RELAY_METADATA_FILE" \ +MIAKAPP_RELAY_EVIDENCE_FILE="$MIAKAPP_SECONDARY_RELAY_EVIDENCE_FILE" \ + "$MIAKAPP_RELAY_FIXTURE_BINARY" \ + >"$MIAKAPP_SECONDARY_RELAY_METADATA_FILE.stdout" \ + 2>"$MIAKAPP_SECONDARY_RELAY_METADATA_FILE.stderr" & +secondary_relay_pid=$! +wait_for_file \ + "$MIAKAPP_SECONDARY_RELAY_METADATA_FILE" \ + "$secondary_relay_pid" \ + "$MIAKAPP_SECONDARY_RELAY_METADATA_FILE.stderr" + NODE_EXTRA_CA_CERTS="$MIAKAPP_INTEGRATION_CERT_FILE" node \ "$MIAKAPP_V3_REPOSITORY/control-plane/scripts/setup-relay-integration.mjs" \ "$MIAKAPP_CONTROL_METADATA_FILE" \ "$MIAKAPP_RELAY_METADATA_FILE" \ "$MIAKAPP_HOME_KEY_FILE" +relay_repository=$(cd "$(dirname "$0")/.." && pwd) +node "$relay_repository/test/integration/prepare-browser-source.mjs" \ + "$MIAKAPP_V3_REPOSITORY" \ + "$MIAKAPP_BROWSER_SOURCE_FILE" + NODE_EXTRA_CA_CERTS="$MIAKAPP_INTEGRATION_CERT_FILE" node \ - "$(cd "$(dirname "$0")/.." && pwd)/test/integration/platform-auth.mjs" \ + "$relay_repository/test/integration/platform-auth.mjs" \ "$MIAKAPP_API_REPOSITORY" \ "$MIAKAPP_CONTROL_METADATA_FILE" \ "$MIAKAPP_RELAY_METADATA_FILE" \ + "$MIAKAPP_SECONDARY_RELAY_METADATA_FILE" \ "$MIAKAPP_HOME_KEY_FILE" \ "$MIAKAPP_CONTROL_SECRET_FILE" \ "$MIAKAPP_RELAY_CONTROL_SECRET_FILE" \ "$MIAKAPP_CONTROL_EVIDENCE_FILE" \ - "$MIAKAPP_RELAY_EVIDENCE_FILE" + "$MIAKAPP_RELAY_EVIDENCE_FILE" \ + "$MIAKAPP_SECONDARY_RELAY_EVIDENCE_FILE" \ + "$MIAKAPP_BROWSER_SOURCE_FILE" diff --git a/test/fixture-server/main.go b/test/fixture-server/main.go index ab3b7d8..f79ebac 100644 --- a/test/fixture-server/main.go +++ b/test/fixture-server/main.go @@ -32,22 +32,25 @@ func (verifier) Verify(_ context.Context, request auth.Request) (auth.Identity, ClientID: "integration-client", CoordinatorName: "integration", ExpiresAt: time.Now().Add(10 * time.Minute), + Scopes: map[string]struct{}{"relay:coordinator": {}}, }, nil - case "integration-user-token": + case "integration.user.initial": return auth.Identity{ Role: auth.RoleUser, HomeID: "integration-home", ID: "integration-user", VerifiedEmail: "integration@example.test", ExpiresAt: time.Now().Add(4 * time.Second), + Scopes: map[string]struct{}{"relay:user": {}}, }, nil - case "integration-user-token-new": + case "integration.user.renewed": return auth.Identity{ Role: auth.RoleUser, HomeID: "integration-home", ID: "integration-user", VerifiedEmail: "integration@example.test", - ExpiresAt: time.Now().Add(10 * time.Minute), + ExpiresAt: time.Now().Add(30 * time.Second), + Scopes: map[string]struct{}{"relay:user": {}}, }, nil default: return auth.Identity{}, auth.Failure(auth.ErrRejected, nil) diff --git a/test/integration/miakapi.mjs b/test/integration/miakapi.mjs index a64e054..da65165 100644 --- a/test/integration/miakapi.mjs +++ b/test/integration/miakapi.mjs @@ -115,7 +115,7 @@ try { } await eventually( - () => page.evaluate(() => globalThis.miakappIntegration.tokenReasons()), + () => page.evaluate(() => globalThis.miakappIntegration.credentialReasons()), (reasons) => reasons.includes('reauth'), 'the scheduled browser reauthentication attempt', ); @@ -133,13 +133,21 @@ try { const browserStatus = await page.evaluate(() => ({ failures: globalThis.miakappIntegration.failures(), statuses: globalThis.miakappIntegration.statuses(), - tokenReasons: globalThis.miakappIntegration.tokenReasons(), + credentialReasons: globalThis.miakappIntegration.credentialReasons(), })); if (browserStatus.failures.length !== 0 || browserStatus.statuses.at(-1) !== 'ready' - || browserStatus.tokenReasons.includes('reconnect') + || browserStatus.credentialReasons.includes('reconnect') || websocketCount !== 1 || pageErrorCount !== 0) { + process.stderr.write(`${JSON.stringify({ + schema: 'miakapp.sdk-browser-integration-diagnostic/1', + failures: browserStatus.failures, + statuses: browserStatus.statuses, + credential_reasons: browserStatus.credentialReasons, + websocket_count: websocketCount, + page_error_count: pageErrorCount, + })}\n`); throw new Error('Browser lifecycle produced an unexpected failure or reconnect'); } diff --git a/test/integration/platform-auth.mjs b/test/integration/platform-auth.mjs index 678dab7..9d9b808 100644 --- a/test/integration/platform-auth.mjs +++ b/test/integration/platform-auth.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { readFileSync, statSync } from 'node:fs'; import { Agent, request as httpsRequest } from 'node:https'; +import { createRequire } from 'node:module'; import path from 'node:path'; import process from 'node:process'; import { pathToFileURL } from 'node:url'; @@ -9,25 +10,32 @@ const [ miakapiRepository, controlMetadataFile, relayMetadataFile, + secondaryRelayMetadataFile, homeKeyFile, controlSecretFile, relaySecretFile, controlEvidenceFile, relayEvidenceFile, + secondaryRelayEvidenceFile, + browserSourceFile, ] = process.argv.slice(2); if ([ miakapiRepository, controlMetadataFile, relayMetadataFile, + secondaryRelayMetadataFile, homeKeyFile, controlSecretFile, relaySecretFile, controlEvidenceFile, relayEvidenceFile, + secondaryRelayEvidenceFile, + browserSourceFile, ].some((value) => value === undefined)) { throw new Error( 'Usage: node platform-auth.mjs ' - + ' ', + + ' ' + + ' ', ); } @@ -91,7 +99,18 @@ const relay = exactObject( ['schema', 'relayUrl'], 'Relay metadata', ); +const secondaryRelay = exactObject( + jsonFile(secondaryRelayMetadataFile, 'miakapp.relay-integration-relay/1'), + ['schema', 'relayUrl'], + 'Secondary relay metadata', +); const controlURL = loopbackURL(control.controlUrl, 'https:', '/', 'Control-plane URL'); +const browserURL = loopbackURL( + `${controlURL.origin}/__integration/browser`, + 'https:', + '/__integration/browser', + 'Control-plane browser URL', +); const exchangeURL = loopbackURL( control.exchangeEndpoint, 'https:', @@ -104,22 +123,66 @@ const jwksURL = loopbackURL( '/.well-known/jwks.json', 'Control-plane JWKS URL', ); +const userExchangeURL = loopbackURL( + `${controlURL.origin}/v1/user-relay-tokens:exchange`, + 'https:', + '/v1/user-relay-tokens:exchange', + 'Control-plane user exchange URL', +); const relayURL = loopbackURL(relay.relayUrl, 'wss:', '/ws', 'Relay URL'); -if (exchangeURL.origin !== controlURL.origin || jwksURL.origin !== controlURL.origin) { +const secondaryRelayURL = loopbackURL( + secondaryRelay.relayUrl, + 'wss:', + '/ws', + 'Secondary relay URL', +); +if (secondaryRelayURL.href === relayURL.href) { + throw new Error('Relay integration URLs are not distinct'); +} +if (exchangeURL.origin !== controlURL.origin + || userExchangeURL.origin !== controlURL.origin + || browserURL.origin !== controlURL.origin + || jwksURL.origin !== controlURL.origin) { throw new Error('Control-plane metadata origins are inconsistent'); } const controlEndpoint = `${controlURL.origin}/__integration/control`; const relayControlEndpoint = `https://${relayURL.host}/__integration/control`; const relayVerifyEndpoint = `https://${relayURL.host}/__integration/verify`; +const secondaryRelayControlEndpoint = + `https://${secondaryRelayURL.host}/__integration/control`; if (Object.hasOwn(control, 'controlEndpoint') && control.controlEndpoint !== controlEndpoint) { throw new Error('Transitional control-plane metadata is inconsistent'); } const homeKey = readFileSync(homeKeyFile, 'ascii'); const controlSecret = readFileSync(controlSecretFile, 'ascii').trim(); const relaySecret = readFileSync(relaySecretFile, 'ascii').trim(); +const browserSource = exactObject( + jsonFile(browserSourceFile, 'miakapp.browser-source-credentials/1'), + [ + 'schema', + 'homeId', + 'firebaseUid', + 'firebaseVerifiedEmail', + 'firebaseIdToken', + 'appCheckToken', + ], + 'Browser source credentials', +); if (!/^mhk1_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{43}$/.test(homeKey) || !/^[0-9a-f]{64}$/.test(controlSecret) - || !/^[0-9a-f]{64}$/.test(relaySecret)) { + || !/^[0-9a-f]{64}$/.test(relaySecret) + || typeof browserSource.homeId !== 'string' + || browserSource.homeId !== 'synthetic-relay-home' + || typeof browserSource.firebaseUid !== 'string' + || browserSource.firebaseUid.length === 0 + || browserSource.firebaseUid.length > 128 + || browserSource.firebaseVerifiedEmail !== null + || typeof browserSource.firebaseIdToken !== 'string' + || browserSource.firebaseIdToken.split('.').length !== 3 + || typeof browserSource.appCheckToken !== 'string' + || browserSource.appCheckToken.split('.').length !== 3 + || browserSource.firebaseIdToken === browserSource.appCheckToken + || (statSync(browserSourceFile).mode & 0o777) !== 0o600) { throw new Error('Platform integration inputs are invalid'); } @@ -170,8 +233,8 @@ function fixtureRequest(endpoint, secret, body) { }); } -async function controlAction(action) { - const response = await fixtureRequest(controlEndpoint, controlSecret, { action }); +async function controlAction(action, fields = {}) { + const response = await fixtureRequest(controlEndpoint, controlSecret, { action, ...fields }); assert.equal(response.status, 200, `Control action ${action} failed`); return response.value; } @@ -185,6 +248,16 @@ async function relayAction(action, offsetMilliseconds) { return response.value; } +async function secondaryRelayAction(action) { + const response = await fixtureRequest( + secondaryRelayControlEndpoint, + relaySecret, + { action }, + ); + assert.equal(response.status, 200, `Secondary relay action ${action} failed`); + return response.value; +} + async function verifyToken(target, token) { const response = await fixtureRequest(relayVerifyEndpoint, relaySecret, { target, token }); return response.status; @@ -193,7 +266,8 @@ async function verifyToken(target, token) { async function waitUntil(predicate, description, milliseconds = 10_000) { const deadline = Date.now() + milliseconds; while (Date.now() < deadline) { - if (await predicate()) return; + const value = await predicate(); + if (value) return value; await new Promise((resolve) => setTimeout(resolve, 25)); } throw new Error(`${description} timed out`); @@ -244,6 +318,8 @@ const { createHomeKeyAccessTokenProvider } = await import(indexUrl); const { createCoordinatorWithRuntime } = await import(coordinatorUrl); const { createProductionRuntime } = await import(socketUrl); const { decodeFrame, Opcode } = await import(codecUrl); +const requireFromMiakAPI = createRequire(path.join(miakapiRepository, 'package.json')); +const { chromium } = requireFromMiakAPI('playwright'); const reasons = []; const sdkKids = []; @@ -340,13 +416,20 @@ const coordinator = createCoordinatorWithRuntime({ coordinator.subscribe((event) => { if (lifecycle.length < 128) lifecycle.push(event.current); }); -coordinator.configure({ - state: { 'integration.temperature': 20 }, - stateAccess: [], - events: [], - eventAccess: [], - functions: {}, -}); +function configureIntegrationCoordinator(target) { + target.configure({ + state: { 'integration.temperature': 20 }, + stateAccess: [{ userId: browserSource.firebaseUid, patterns: ['integration.*'] }], + events: [], + eventAccess: [], + functions: { + async 'integration.set'(call) { + return { accepted: true, arguments: call.arguments }; + }, + }, + }); +} +configureIntegrationCoordinator(coordinator); const startController = new AbortController(); const startTimeout = setTimeout( @@ -481,6 +564,286 @@ if (reasons.length !== 2 || socketConnections !== 1) { throw new Error('Coordinator rotation did not remain on one healthy session'); } + +let browser; +let page; +let browserClientStopped = false; +let browserClosed = false; +let browserWebSockets = 0; +let activeBrowserWebSockets = 0; +let maximumActiveBrowserWebSockets = 0; +const browserWebSocketURLs = []; +let browserSentFrames = 0; +let sourceCredentialsOnWebSocket = false; +let browserPageErrors = 0; +let observedUserExchangePosts = 0; +let browserConsoleErrors = 0; +let userExchangeRequestFailures = 0; +const userExchangeResponses = []; +let browserBoundary; +let patchedBrowserState; +let handoffBrowserState; +let secondaryCoordinator; +let secondaryCoordinatorStopped = false; +try { + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + await context.addInitScript((value) => { + Object.defineProperty(globalThis, '__miakappIntegrationBootstrap', { + configurable: true, + enumerable: false, + value, + writable: false, + }); + }, { + schema: 'miakapp.browser-source-bootstrap/1', + exchangeEndpoint: userExchangeURL.href, + homeId: browserSource.homeId, + firebaseIdToken: browserSource.firebaseIdToken, + appCheckToken: browserSource.appCheckToken, + }); + page = await context.newPage(); + page.on('websocket', (webSocket) => { + browserWebSockets += 1; + activeBrowserWebSockets += 1; + maximumActiveBrowserWebSockets = Math.max( + maximumActiveBrowserWebSockets, + activeBrowserWebSockets, + ); + browserWebSocketURLs.push(webSocket.url()); + webSocket.on('close', () => { activeBrowserWebSockets -= 1; }); + webSocket.on('framesent', ({ payload }) => { + browserSentFrames += 1; + const bytes = typeof payload === 'string' ? Buffer.from(payload, 'utf8') : payload; + sourceCredentialsOnWebSocket ||= bytes.includes(Buffer.from( + browserSource.firebaseIdToken, + 'ascii', + )) || bytes.includes(Buffer.from(browserSource.appCheckToken, 'ascii')); + }); + }); + page.on('pageerror', () => { browserPageErrors += 1; }); + page.on('console', (message) => { + if (message.type() !== 'error') return; + browserConsoleErrors += 1; + }); + page.on('request', (request) => { + if (request.url() === userExchangeURL.href && request.method() === 'POST') { + observedUserExchangePosts += 1; + } + }); + page.on('requestfailed', (request) => { + if (request.url() !== userExchangeURL.href) return; + userExchangeRequestFailures += 1; + }); + page.on('response', (response) => { + if (response.url() === userExchangeURL.href) { + userExchangeResponses.push({ + method: response.request().method(), + status: response.status(), + }); + } + }); + await page.goto(browserURL.href, { waitUntil: 'load' }); + + let browserStartTimer; + const browserReady = await Promise.race([ + page.evaluate(() => globalThis.miakappIntegration.start()), + new Promise((_, reject) => { + browserStartTimer = setTimeout( + () => reject(new Error('Browser readiness timed out')), + 15_000, + ); + }), + ]).finally(() => clearTimeout(browserStartTimer)); + assert.equal(browserReady.enrolled, true); + assert.equal(browserReady.coordinatorCount, 1); + await waitUntil( + () => page.evaluate(() => globalThis.miakappIntegration.state()) + .then((state) => state?.temperature === 20 && state.stale === false), + 'Authoritative browser state', + ); + + await coordinator.state.set([{ path: 'integration.temperature', value: 21 }]); + patchedBrowserState = await waitUntil( + async () => { + const state = await page.evaluate(() => globalThis.miakappIntegration.state()); + return state?.temperature === 21 && state.stale === false ? state : undefined; + }, + 'Browser state patch', + ); + const initialCall = await page.evaluate( + () => globalThis.miakappIntegration.call(22), + ); + assert.deepEqual(initialCall, { accepted: true, arguments: { target: 22 } }); + + browserBoundary = await page.evaluate(() => globalThis.miakappIntegration.boundary()); + assert.deepEqual(browserBoundary.credentialReasons, ['initial']); + assert.equal(browserBoundary.pendingControlledTimers, 1); + await page.evaluate(() => globalThis.miakappIntegration.fireReauthentication()); + await waitUntil(async () => { + const [boundary, relayState] = await Promise.all([ + page.evaluate(() => globalThis.miakappIntegration.boundary()), + relayAction('status'), + ]); + return boundary.httpsExchanges === 2 + && boundary.credentialReasons[1] === 'reauth' + && relayState.successful_user_verifications === 2; + }, 'Browser same-socket reauthentication'); + + const postReauthenticationCall = await page.evaluate( + () => globalThis.miakappIntegration.call(23), + ); + assert.deepEqual(postReauthenticationCall, { accepted: true, arguments: { target: 23 } }); + const sameRelayBrowserStatus = await page.evaluate(() => ({ + boundary: globalThis.miakappIntegration.boundary(), + failures: globalThis.miakappIntegration.failures(), + statuses: globalThis.miakappIntegration.statuses(), + })); + browserBoundary = sameRelayBrowserStatus.boundary; + assert.deepEqual(browserBoundary.credentialReasons, ['initial', 'reauth']); + assert.equal(browserBoundary.firebaseTokenRequests, 2); + assert.equal(browserBoundary.appCheckTokenRequests, 2); + assert.equal(browserBoundary.httpsExchanges, 2); + assert.equal(browserBoundary.sourceHeadersConformant, true); + assert.equal(browserBoundary.emulatorAuthAdapted, true); + assert.equal(browserBoundary.pendingControlledTimers, 1); + assert.deepEqual(sameRelayBrowserStatus.failures, []); + assert.equal(sameRelayBrowserStatus.statuses.at(-1), 'ready'); + assert.equal(sameRelayBrowserStatus.statuses.includes('reconnecting'), false); + assert.equal(observedUserExchangePosts, 2); + assert.equal(browserWebSockets, 1); + assert.ok(browserSentFrames > 0); + assert.equal(sourceCredentialsOnWebSocket, false); + assert.equal(browserPageErrors, 0); + assert.equal(browserConsoleErrors, 0); + assert.equal(userExchangeRequestFailures, 0); + + await controlAction('route_relay', { relay_url: secondaryRelayURL.href }); + secondaryCoordinator = createCoordinatorWithRuntime({ + name: 'integration', + accessTokenProvider: directProvider, + logger: { + write(record) { + if (record.level === 'error') failures.push(record); + }, + }, + }, productionRuntime); + configureIntegrationCoordinator(secondaryCoordinator); + const secondaryStartController = new AbortController(); + const secondaryStartTimeout = setTimeout( + () => secondaryStartController.abort(new Error('Secondary coordinator readiness timed out')), + 10_000, + ); + let secondaryReady; + try { + secondaryReady = await secondaryCoordinator.start({ signal: secondaryStartController.signal }); + } finally { + clearTimeout(secondaryStartTimeout); + } + assert.equal(secondaryReady.generation, 1); + assert.equal(secondaryCoordinator.status, 'ready'); + assert.equal((await secondaryRelayAction('status')).successful_coordinator_verifications, 1); + assert.equal((await relayAction('status')).successful_user_verifications, 2); + + await page.evaluate(() => globalThis.miakappIntegration.fireReauthentication()); + await waitUntil(async () => { + const [boundary, secondaryRelayState, status] = await Promise.all([ + page.evaluate(() => globalThis.miakappIntegration.boundary()), + secondaryRelayAction('status'), + page.evaluate(() => globalThis.miakappIntegration.statuses().at(-1)), + ]); + return boundary.httpsExchanges === 3 + && boundary.credentialReasons[2] === 'reauth' + && secondaryRelayState.successful_user_verifications === 1 + && browserWebSockets === 2 + && status === 'ready'; + }, 'Browser authoritative relay handoff'); + await waitUntil( + () => activeBrowserWebSockets === 1, + 'Old browser WebSocket closure', + ); + handoffBrowserState = await waitUntil( + async () => { + const state = await page.evaluate(() => globalThis.miakappIntegration.state()); + return state?.temperature === 20 && state.stale === false ? state : undefined; + }, + 'Browser state after relay handoff', + ); + await secondaryCoordinator.state.set([{ path: 'integration.temperature', value: 24 }]); + handoffBrowserState = await waitUntil( + async () => { + const state = await page.evaluate(() => globalThis.miakappIntegration.state()); + return state?.temperature === 24 && state.stale === false ? state : undefined; + }, + 'Browser state patch after relay handoff', + ); + const postHandoffCall = await page.evaluate( + () => globalThis.miakappIntegration.call(24), + ); + assert.deepEqual(postHandoffCall, { accepted: true, arguments: { target: 24 } }); + + const handoffBrowserStatus = await page.evaluate(() => ({ + boundary: globalThis.miakappIntegration.boundary(), + failures: globalThis.miakappIntegration.failures(), + statuses: globalThis.miakappIntegration.statuses(), + })); + browserBoundary = handoffBrowserStatus.boundary; + assert.deepEqual(browserBoundary.credentialReasons, ['initial', 'reauth', 'reauth']); + assert.equal(browserBoundary.firebaseTokenRequests, 3); + assert.equal(browserBoundary.appCheckTokenRequests, 3); + assert.equal(browserBoundary.httpsExchanges, 3); + assert.equal(browserBoundary.sourceHeadersConformant, true); + assert.equal(browserBoundary.pendingControlledTimers, 1); + assert.deepEqual(handoffBrowserStatus.failures, []); + assert.equal(handoffBrowserStatus.statuses.at(-1), 'ready'); + assert.equal(handoffBrowserStatus.statuses.includes('reconnecting'), true); + assert.equal(handoffBrowserStatus.statuses.includes('failed'), false); + assert.equal(observedUserExchangePosts, 3); + assert.deepEqual(browserWebSocketURLs, [relayURL.href, secondaryRelayURL.href]); + assert.equal(browserWebSockets, 2); + assert.equal(maximumActiveBrowserWebSockets, 1); + assert.equal(sourceCredentialsOnWebSocket, false); + assert.equal(browserPageErrors, 0); + assert.equal(browserConsoleErrors, 0); + assert.equal(userExchangeRequestFailures, 0); + + await page.evaluate(() => globalThis.miakappIntegration.stop()); + browserClientStopped = true; + await browser.close(); + browserClosed = true; + await secondaryCoordinator.stop({ deadlineMs: 2_000 }); + secondaryCoordinatorStopped = true; + assert.equal(secondaryCoordinator.status, 'stopped'); +} catch { + const pageState = page === undefined ? undefined : await page.evaluate(() => ({ + boundary: globalThis.miakappIntegration?.boundary(), + failures: globalThis.miakappIntegration?.failures(), + statuses: globalThis.miakappIntegration?.statuses(), + })).catch(() => undefined); + process.stderr.write(`${JSON.stringify({ + schema: 'miakapp.browser-relay-integration-diagnostic/1', + browser_websockets: browserWebSockets, + maximum_active_browser_websockets: maximumActiveBrowserWebSockets, + page_errors: browserPageErrors, + console_errors: browserConsoleErrors, + browser_sent_frames: browserSentFrames, + source_credentials_on_websocket: sourceCredentialsOnWebSocket, + user_exchange_posts: observedUserExchangePosts, + user_exchange_request_failures: userExchangeRequestFailures, + user_exchange_responses: userExchangeResponses, + page_state: pageState, + })}\n`); + throw new Error('Browser relay integration failed'); +} finally { + if (!browserClientStopped && page !== undefined) { + await page.evaluate(() => globalThis.miakappIntegration?.stop()).catch(() => undefined); + } + if (!browserClosed && browser !== undefined) await browser.close().catch(() => undefined); + if (!secondaryCoordinatorStopped && secondaryCoordinator !== undefined) { + await secondaryCoordinator.stop({ deadlineMs: 2_000 }).catch(() => undefined); + } +} + await coordinator.stop({ deadlineMs: 2_000 }); if (coordinator.status !== 'stopped') throw new Error('Coordinator did not stop cleanly'); assert.equal(socketConnections, 1); @@ -491,12 +854,12 @@ const expectedControlEvidence = { held: false, failing: false, jwks: { - requests: 8, + requests: 9, conditional_requests: 6, in_flight: 0, maximum_in_flight: 1, responses: { - ok: 4, + ok: 5, not_modified: 3, unavailable: 1, other: 0, @@ -504,9 +867,12 @@ const expectedControlEvidence = { }, }; const expectedRelayEvidence = { - schema: 'miakapp.relay-integration-evidence/2', + schema: 'miakapp.relay-integration-evidence/3', successful_coordinator_verifications: 2, - principal_consistent: true, + successful_user_verifications: 2, + coordinator_principal_consistent: true, + user_principal_consistent: true, + unexpected_roles: 0, relay_warmup_successes: 1, probe_clock_offset_milliseconds: 133_000, probe: { @@ -518,28 +884,70 @@ const expectedRelayEvidence = { temporary: 2, }, }; +const expectedSecondaryRelayEvidence = { + schema: 'miakapp.relay-integration-evidence/3', + successful_coordinator_verifications: 1, + successful_user_verifications: 1, + coordinator_principal_consistent: true, + user_principal_consistent: true, + unexpected_roles: 0, + relay_warmup_successes: 0, + probe_clock_offset_milliseconds: 0, + probe: { + requests: 0, + in_flight: 0, + maximum_in_flight: 0, + succeeded: 0, + rejected: 0, + temporary: 0, + }, +}; const finalControlState = await controlAction('status'); const finalRelayState = await relayAction('status'); +const finalSecondaryRelayState = await secondaryRelayAction('status'); assert.deepEqual(finalControlState, expectedControlEvidence); assert.deepEqual(finalRelayState, expectedRelayEvidence); +assert.deepEqual(finalSecondaryRelayState, expectedSecondaryRelayEvidence); assert.deepEqual( jsonFile(controlEvidenceFile, 'miakapp.relay-integration-jwks/1'), expectedControlEvidence, ); assert.deepEqual( - jsonFile(relayEvidenceFile, 'miakapp.relay-integration-evidence/2'), + jsonFile(relayEvidenceFile, 'miakapp.relay-integration-evidence/3'), expectedRelayEvidence, ); +assert.deepEqual( + jsonFile(secondaryRelayEvidenceFile, 'miakapp.relay-integration-evidence/3'), + expectedSecondaryRelayEvidence, +); assert.equal(statSync(controlEvidenceFile).mode & 0o777, 0o600); assert.equal(statSync(relayEvidenceFile).mode & 0o777, 0o600); +assert.equal(statSync(secondaryRelayEvidenceFile).mode & 0o777, 0o600); fixtureAgent.destroy(); process.stdout.write(`${JSON.stringify({ - schema: 'miakapp.relay-auth-integration/2', + schema: 'miakapp.relay-auth-integration/4', generation: ready.generation, - exchange_reasons: reasons, + coordinator_exchange_reasons: reasons, + browser_exchange_reasons: browserBoundary.credentialReasons, signing_key_changed: true, - successful_relay_verifications: expectedRelayEvidence.successful_coordinator_verifications, + successful_coordinator_verifications: + expectedRelayEvidence.successful_coordinator_verifications, + successful_user_verifications: expectedRelayEvidence.successful_user_verifications, + secondary_coordinator_verifications: + expectedSecondaryRelayEvidence.successful_coordinator_verifications, + secondary_user_verifications: + expectedSecondaryRelayEvidence.successful_user_verifications, + browser_state_before_handoff: patchedBrowserState.temperature, + browser_state_after_handoff: handoffBrowserState.temperature, + browser_calls: 3, + browser: 'chromium', + browser_websockets: browserWebSockets, + browser_handoffs: browserWebSockets - 1, + maximum_active_browser_websockets: maximumActiveBrowserWebSockets, + browser_sent_frames: browserSentFrames, + source_credentials_on_websocket: sourceCredentialsOnWebSocket, + user_exchange_posts: observedUserExchangePosts, jwks_requests: expectedControlEvidence.jwks.requests, concurrent_verifications: 32, reconnects: socketConnections - 1, diff --git a/test/integration/platform-browser.ts b/test/integration/platform-browser.ts new file mode 100644 index 0000000..c86679d --- /dev/null +++ b/test/integration/platform-browser.ts @@ -0,0 +1,237 @@ +import type { + BrowserClientFailure, + BrowserClientStatus, + BrowserRelayCredentialReason, +} from 'miakapi/browser'; +import { + createBrowserClient, + createControlPlaneBrowserRelayCredentialProvider, +} from 'miakapi/browser'; + +interface BrowserBootstrap { + readonly schema: 'miakapp.browser-source-bootstrap/1'; + readonly exchangeEndpoint: string; + readonly homeId: string; + readonly firebaseIdToken: string; + readonly appCheckToken: string; +} + +interface BrowserIntegrationFailure { + readonly kind: BrowserClientFailure['kind']; + readonly code?: number; + readonly outcome: BrowserClientFailure['outcome']; +} + +interface BrowserIntegration { + start(): Promise<{ enrolled: boolean; coordinatorCount: number }>; + state(): { revision: number; stale: boolean; temperature: unknown } | undefined; + call(target: number): Promise; + fireReauthentication(): number; + boundary(): Readonly<{ + credentialReasons: readonly BrowserRelayCredentialReason[]; + firebaseTokenRequests: number; + appCheckTokenRequests: number; + httpsExchanges: number; + sourceHeadersConformant: boolean; + emulatorAuthAdapted: boolean; + pendingControlledTimers: number; + }>; + statuses(): readonly BrowserClientStatus[]; + failures(): readonly BrowserIntegrationFailure[]; + stop(): Promise; +} + +interface BrowserGlobal { + __miakappIntegrationBootstrap?: BrowserBootstrap; + miakappIntegration?: BrowserIntegration; +} + +function isExactObject(value: unknown, keys: readonly string[]): value is Record { + return value !== null + && !Array.isArray(value) + && typeof value === 'object' + && Object.keys(value).length === keys.length + && keys.every((key) => Object.hasOwn(value, key)); +} + +function sourceToken(value: unknown): value is string { + return typeof value === 'string' + && value.length > 0 + && value.length <= 8_192 + && /^[\x21-\x7e]+$/.test(value) + && value.split('.').length === 3; +} + +const browserGlobal = globalThis as unknown as BrowserGlobal; +const bootstrap = browserGlobal.__miakappIntegrationBootstrap; +if (!isExactObject(bootstrap, [ + 'schema', + 'exchangeEndpoint', + 'homeId', + 'firebaseIdToken', + 'appCheckToken', +]) + || bootstrap.schema !== 'miakapp.browser-source-bootstrap/1' + || typeof bootstrap.exchangeEndpoint !== 'string' + || typeof bootstrap.homeId !== 'string' + || !sourceToken(bootstrap.firebaseIdToken) + || !sourceToken(bootstrap.appCheckToken) + || bootstrap.firebaseIdToken === bootstrap.appCheckToken) { + throw new Error('Browser integration bootstrap is invalid'); +} +delete browserGlobal.__miakappIntegrationBootstrap; + +const productionFetch = globalThis.fetch.bind(globalThis); +const nativeSetTimeout = globalThis.setTimeout.bind(globalThis); +const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis); +const controlledTimers = new Map void>(); +let nextControlledTimer = -1; + +globalThis.setTimeout = ((handler: TimerHandler, timeout = 0, ...arguments_: unknown[]) => { + if (typeof handler === 'function' && timeout >= 240_000) { + const id = nextControlledTimer; + nextControlledTimer -= 1; + controlledTimers.set(id, () => handler(...arguments_)); + return id; + } + return nativeSetTimeout(handler, timeout, ...arguments_); +}) as typeof globalThis.setTimeout; +globalThis.clearTimeout = ((id: number | undefined) => { + if (typeof id === 'number' && controlledTimers.delete(id)) return; + nativeClearTimeout(id); +}) as typeof globalThis.clearTimeout; + +const credentialReasons: BrowserRelayCredentialReason[] = []; +const statuses: BrowserClientStatus[] = []; +const failures: BrowserIntegrationFailure[] = []; +let firebaseTokenRequests = 0; +let appCheckTokenRequests = 0; +let httpsExchanges = 0; +let sourceHeadersConformant = true; + +function emulatorProviderToken(value: string): string { + const segments = value.split('.'); + if (segments.length !== 3 || segments[0] === undefined || segments[2] !== '') { + throw new Error('Browser integration Firebase emulator token is invalid'); + } + let header: unknown; + try { + header = JSON.parse(atob(segments[0].replaceAll('-', '+').replaceAll('_', '/'))); + } catch { + throw new Error('Browser integration Firebase emulator token is invalid'); + } + if (!isExactObject(header, ['alg', 'typ']) + || header.alg !== 'none' + || header.typ !== 'JWT') { + throw new Error('Browser integration Firebase emulator token is invalid'); + } + return `${value}ZW11bGF0b3I`; +} + +const providerFirebaseIdToken = emulatorProviderToken(bootstrap.firebaseIdToken); + +const instrumentedFetch: typeof globalThis.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const headers = new Headers(init?.headers); + let body: unknown; + try { + body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + } catch { + body = undefined; + } + const requestConformant = url === bootstrap.exchangeEndpoint + && init?.method === 'POST' + && headers.get('authorization') === `Bearer ${providerFirebaseIdToken}` + && headers.get('x-firebase-appcheck') === bootstrap.appCheckToken + && isExactObject(body, ['home_id', 'reason']) + && body.home_id === bootstrap.homeId + && (body.reason === 'initial' || body.reason === 'reauth' || body.reason === 'reconnect') + && !url.includes(bootstrap.firebaseIdToken) + && !url.includes(bootstrap.appCheckToken); + sourceHeadersConformant &&= requestConformant; + if (!requestConformant) throw new Error('Browser credential request crossed an invalid boundary'); + credentialReasons.push(body.reason as BrowserRelayCredentialReason); + httpsExchanges += 1; + const networkHeaders = new Headers(init?.headers); + networkHeaders.set('authorization', `Bearer ${bootstrap.firebaseIdToken}`); + return productionFetch(input, { ...init, headers: networkHeaders }); +}; + +const credentialProvider = createControlPlaneBrowserRelayCredentialProvider({ + exchangeEndpoint: bootstrap.exchangeEndpoint, + async getFirebaseIdToken() { + firebaseTokenRequests += 1; + return providerFirebaseIdToken; + }, + async getAppCheckToken() { + appCheckTokenRequests += 1; + return bootstrap.appCheckToken; + }, + fetch: instrumentedFetch, +}); +const client = createBrowserClient({ + homeId: bootstrap.homeId, + credentialProvider, +}); + +client.subscribe(({ current }) => statuses.push(current)); +client.errors.subscribe((failure) => failures.push(Object.freeze({ + kind: failure.kind, + ...(failure.code === undefined ? {} : { code: failure.code }), + outcome: failure.outcome, +}))); + +browserGlobal.miakappIntegration = Object.freeze({ + async start() { + const ready = await client.start(); + return Object.freeze({ + enrolled: ready.enrolled, + coordinatorCount: ready.coordinators.length, + }); + }, + state() { + const snapshot = client.state.snapshot(); + if (snapshot === undefined) return undefined; + return Object.freeze({ + revision: snapshot.revision, + stale: snapshot.stale, + temperature: snapshot.values['integration.temperature'], + }); + }, + async call(target: number) { + const call = client.calls.start({ + function: 'integration.set', + arguments: { target }, + timeoutMs: 5_000, + idempotencyKey: `browser-integration-${target}`, + }); + await call.accepted; + return call.result; + }, + fireReauthentication() { + const selected = controlledTimers.entries().next().value as [number, () => void] | undefined; + if (selected === undefined) throw new Error('Browser reauthentication timer is unavailable'); + controlledTimers.delete(selected[0]); + queueMicrotask(selected[1]); + return controlledTimers.size; + }, + boundary() { + return Object.freeze({ + credentialReasons: Object.freeze([...credentialReasons]), + firebaseTokenRequests, + appCheckTokenRequests, + httpsExchanges, + sourceHeadersConformant, + emulatorAuthAdapted: true, + pendingControlledTimers: controlledTimers.size, + }); + }, + statuses: () => Object.freeze([...statuses]), + failures: () => Object.freeze([...failures]), + async stop() { + await client.stop({ deadlineMs: 2_000 }); + controlledTimers.clear(); + globalThis.setTimeout = nativeSetTimeout; + globalThis.clearTimeout = nativeClearTimeout; + }, +}); diff --git a/test/integration/prepare-browser-source.mjs b/test/integration/prepare-browser-source.mjs new file mode 100644 index 0000000..9639c6f --- /dev/null +++ b/test/integration/prepare-browser-source.mjs @@ -0,0 +1,108 @@ +import { createPrivateKey, sign } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +const [v3Repository, outputFile] = process.argv.slice(2); +if (v3Repository === undefined || outputFile === undefined || !path.isAbsolute(v3Repository)) { + throw new Error( + 'Usage: node prepare-browser-source.mjs ', + ); +} + +const authHost = process.env.FIREBASE_AUTH_EMULATOR_HOST; +if (authHost === undefined || !/^127\.0\.0\.1:[0-9]+$/.test(authHost)) { + throw new Error('Firebase Auth Emulator is required'); +} + +const projectId = 'demo-miakapp-v4'; +const signUp = await fetch( + `http://${authHost}/identitytoolkit.googleapis.com/v1/accounts:signUp?key=synthetic-api-key`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'relay-integration-browser@example.test', + password: 'synthetic-browser-password-123', + returnSecureToken: true, + }), + }, +); +const signedUp = await signUp.json(); +if (!signUp.ok + || typeof signedUp.idToken !== 'string' + || typeof signedUp.localId !== 'string' + || signedUp.localId.length === 0 + || signedUp.localId.length > 128) { + throw new Error(`Synthetic browser Auth signup failed with HTTP ${signUp.status}`); +} + +const configModule = pathToFileURL(path.join( + v3Repository, + 'control-plane/lib/config.js', +)).href; +const { loadEmulatorConfig } = await import(configModule); +const config = loadEmulatorConfig({ + FUNCTIONS_EMULATOR: 'true', + GCLOUD_PROJECT: projectId, +}); +const fixture = JSON.parse(readFileSync(path.join( + v3Repository, + 'control-plane-contract/fixtures/v1/access-tokens.json', +), 'utf8')); +if (fixture?.provenance?.kind !== 'hand_authored_synthetic' + || fixture.provenance.contains_production_data !== false + || fixture?.test_only_private_keys?.warning + !== 'SYNTHETIC TEST KEYS. NEVER LOAD IN PRODUCTION.') { + throw new Error('Browser integration signing fixture is not explicitly synthetic'); +} +const privateKey = fixture.test_only_private_keys.firebase; +if (privateKey?.kty !== 'RSA' + || typeof privateKey.kid !== 'string' + || typeof privateKey.d !== 'string' + || privateKey.d.length === 0) { + throw new Error('Browser integration App Check key is invalid'); +} + +const now = Math.floor(Date.now() / 1_000); +const header = Buffer.from(JSON.stringify({ + alg: 'RS256', + kid: privateKey.kid, + typ: 'JWT', +}), 'utf8').toString('base64url'); +const claims = Buffer.from(JSON.stringify({ + iss: config.appCheckIssuer, + aud: [config.appCheckAudience], + sub: config.appCheckAppId, + iat: now, + exp: now + 3_600, +}), 'utf8').toString('base64url'); +const signingInput = `${header}.${claims}`; +const signature = sign( + 'RSA-SHA256', + Buffer.from(signingInput, 'ascii'), + createPrivateKey({ key: privateKey, format: 'jwk' }), +).toString('base64url'); + +writeFileSync(outputFile, `${JSON.stringify({ + schema: 'miakapp.browser-source-credentials/1', + homeId: 'synthetic-relay-home', + firebaseUid: signedUp.localId, + firebaseVerifiedEmail: null, + firebaseIdToken: signedUp.idToken, + appCheckToken: `${signingInput}.${signature}`, +})}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, +}); + +process.stdout.write(`${JSON.stringify({ + schema: 'miakapp.browser-source-preparation/1', + firebase_auth: 'emulator', + app_check: 'synthetic-signed', + user: 'unenrolled', + persistent_credentials_created: false, + ephemeral_private_file: true, +})}\n`); diff --git a/test/platform-fixture-server/main.go b/test/platform-fixture-server/main.go index 1145ae8..9891bed 100644 --- a/test/platform-fixture-server/main.go +++ b/test/platform-fixture-server/main.go @@ -50,7 +50,10 @@ type probeEvidence struct { type integrationEvidence struct { Schema string `json:"schema"` SuccessfulCoordinatorVerifications int `json:"successful_coordinator_verifications"` - PrincipalConsistent bool `json:"principal_consistent"` + SuccessfulUserVerifications int `json:"successful_user_verifications"` + CoordinatorPrincipalConsistent bool `json:"coordinator_principal_consistent"` + UserPrincipalConsistent bool `json:"user_principal_consistent"` + UnexpectedRoles int `json:"unexpected_roles"` RelayWarmupSuccesses int `json:"relay_warmup_successes"` ProbeClockOffsetMilliseconds int64 `json:"probe_clock_offset_milliseconds"` Probe probeEvidence `json:"probe"` @@ -61,35 +64,44 @@ type principalBinding struct { principalID string clientID string coordinatorName string + verifiedEmail string role auth.Role } type integrationState struct { evidenceFile string - mu sync.Mutex - successes int - principalConsistent bool - firstPrincipal *principalBinding - relayWarmups int - probeBase time.Time - probeFrozen bool - probeOffset time.Duration - probe probeEvidence + mu sync.Mutex + coordinatorSuccesses int + userSuccesses int + coordinatorPrincipalConsistent bool + userPrincipalConsistent bool + unexpectedRoles int + firstCoordinatorPrincipal *principalBinding + firstUserPrincipal *principalBinding + relayWarmups int + probeBase time.Time + probeFrozen bool + probeOffset time.Duration + probe probeEvidence } func newIntegrationState(evidenceFile string) *integrationState { return &integrationState{ - evidenceFile: evidenceFile, - principalConsistent: true, + evidenceFile: evidenceFile, + coordinatorPrincipalConsistent: true, + userPrincipalConsistent: true, } } func (state *integrationState) evidenceLocked() integrationEvidence { return integrationEvidence{ - Schema: "miakapp.relay-integration-evidence/2", - SuccessfulCoordinatorVerifications: state.successes, - PrincipalConsistent: state.principalConsistent, + Schema: "miakapp.relay-integration-evidence/3", + SuccessfulCoordinatorVerifications: state.coordinatorSuccesses, + SuccessfulUserVerifications: state.userSuccesses, + CoordinatorPrincipalConsistent: state.coordinatorPrincipalConsistent, + UserPrincipalConsistent: state.userPrincipalConsistent, + UnexpectedRoles: state.unexpectedRoles, RelayWarmupSuccesses: state.relayWarmups, ProbeClockOffsetMilliseconds: state.probeOffset.Milliseconds(), Probe: state.probe, @@ -120,14 +132,31 @@ func (state *integrationState) recordRelay(identity auth.Identity) error { principalID: identity.ID, clientID: identity.ClientID, coordinatorName: identity.CoordinatorName, + verifiedEmail: identity.VerifiedEmail, role: identity.Role, } - if state.firstPrincipal == nil { - state.firstPrincipal = &principal - } else if *state.firstPrincipal != principal { - state.principalConsistent = false + switch identity.Role { + case auth.RoleCoordinator: + if state.firstCoordinatorPrincipal == nil { + state.firstCoordinatorPrincipal = &principal + } else if *state.firstCoordinatorPrincipal != principal { + state.coordinatorPrincipalConsistent = false + } + state.coordinatorSuccesses++ + case auth.RoleUser: + if state.firstUserPrincipal == nil { + state.firstUserPrincipal = &principal + } else if *state.firstUserPrincipal != principal { + state.userPrincipalConsistent = false + } + state.userSuccesses++ + default: + state.unexpectedRoles++ + if err := state.persistLocked(); err != nil { + return err + } + return errors.New("relay verifier returned an unexpected integration role") } - state.successes++ return state.persistLocked() } @@ -197,7 +226,7 @@ type recordingVerifier struct { func (verifier *recordingVerifier) Verify(ctx context.Context, request auth.Request) (auth.Identity, error) { identity, err := verifier.delegate.Verify(ctx, request) - if err != nil || request.Role != auth.RoleCoordinator { + if err != nil || (request.Role != auth.RoleCoordinator && request.Role != auth.RoleUser) { return identity, err } if err = verifier.state.recordRelay(identity); err != nil { @@ -440,10 +469,9 @@ func main() { panic(err) } platformConfig := auth.PlatformConfig{ - Issuer: control.ControlURL, - JWKSURL: control.JWKSURL, - RelayAudience: relayURL, - FirebaseProjectID: "demo-miakapp-v4", + Issuer: control.ControlURL, + JWKSURL: control.JWKSURL, + RelayAudience: relayURL, } relayPlatformVerifier, err := auth.NewPlatformVerifierForIntegration( platformConfig, @@ -469,7 +497,7 @@ func main() { cfg := config.Config{ ListenAddress: listener.Addr().String(), - AllowedOrigins: map[string]struct{}{}, + AllowedOrigins: map[string]struct{}{control.ControlURL: {}}, Handshake: 5 * time.Second, WriteTimeout: 5 * time.Second, PingInterval: time.Minute,