From 338c1157e883d35133bfc66b537420da61267397 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 23:10:32 +0000 Subject: [PATCH] feat(edgeauth): verify guard's identity JWT in Go and TypeScript (COR-1076) edgeauth verifies the x-bastion-identity JWT guard sends upstream (ES256, typ bastion-id+jwt, iss bastion, aud in the app's hosts, exp/iat with 10 s leeway) against guard's JWKS, fetched lazily, cached 5 min and refetched for an unknown kid at most once per 30 s. Middleware puts the identity in the context, answers 401 for an invalid token and 503 when the keys are unreachable; RequireIdentity and RequireRole refuse anonymous and unauthorised callers. edgeauthtest mints identities for app tests. @metsaapp/edgeauth is the TypeScript twin on jose, installable from this repository by tag. Both run the conformance vectors in edgeauth/testdata/vectors.json. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MwB9uUtkK8BFT7ZGoXwX9u --- .github/workflows/ci.yml | 29 + .gitignore | 3 + README.md | 118 ++- edgeauth/doc.go | 47 + edgeauth/edgeauth_test.go | 374 ++++++++ edgeauth/edgeauthtest/edgeauthtest.go | 151 ++++ edgeauth/identity.go | 64 ++ edgeauth/internal/vectorgen/main.go | 32 + edgeauth/internal/vectors/vectors.go | 499 +++++++++++ edgeauth/jwks.go | 168 ++++ edgeauth/middleware.go | 73 ++ edgeauth/testdata/vectors.json | 469 ++++++++++ edgeauth/vectors_test.go | 88 ++ edgeauth/verifier.go | 253 ++++++ go.mod | 1 + go.sum | 2 + package.json | 34 + release-please-config.json | 9 +- ts/edgeauth/dist/index.d.ts | 52 ++ ts/edgeauth/dist/index.js | 176 ++++ ts/edgeauth/package-lock.json | 1163 +++++++++++++++++++++++++ ts/edgeauth/package.json | 20 + ts/edgeauth/scripts/check-deps.mjs | 11 + ts/edgeauth/src/index.ts | 245 ++++++ ts/edgeauth/test/vectors.test.ts | 151 ++++ ts/edgeauth/tsconfig.json | 17 + ts/edgeauth/tsconfig.test.json | 10 + ts/go.mod | 6 + 28 files changed, 4262 insertions(+), 3 deletions(-) create mode 100644 edgeauth/doc.go create mode 100644 edgeauth/edgeauth_test.go create mode 100644 edgeauth/edgeauthtest/edgeauthtest.go create mode 100644 edgeauth/identity.go create mode 100644 edgeauth/internal/vectorgen/main.go create mode 100644 edgeauth/internal/vectors/vectors.go create mode 100644 edgeauth/jwks.go create mode 100644 edgeauth/middleware.go create mode 100644 edgeauth/testdata/vectors.json create mode 100644 edgeauth/vectors_test.go create mode 100644 edgeauth/verifier.go create mode 100644 package.json create mode 100644 ts/edgeauth/dist/index.d.ts create mode 100644 ts/edgeauth/dist/index.js create mode 100644 ts/edgeauth/package-lock.json create mode 100644 ts/edgeauth/package.json create mode 100644 ts/edgeauth/scripts/check-deps.mjs create mode 100644 ts/edgeauth/src/index.ts create mode 100644 ts/edgeauth/test/vectors.test.ts create mode 100644 ts/edgeauth/tsconfig.json create mode 100644 ts/edgeauth/tsconfig.test.json create mode 100644 ts/go.mod diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a3690a..914e486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,32 @@ jobs: - uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7 with: version: v2.11.3 + + # ts/edgeauth: the TypeScript verifier apps install from this repository. + # dist/ is committed (git installs run no build), so CI checks it matches + # the source, then runs the shared vectors under Node and under Bun. + edgeauth-ts: + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 10 + permissions: + contents: read + defaults: + run: + working-directory: ts/edgeauth + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - name: Install + run: npm ci + - name: Check, build and test + run: | + set -euo pipefail + npm run check + npm run typecheck + npm run build + git diff --exit-code -- dist || { echo "ts/edgeauth/dist is stale: run npm run build and commit it"; exit 1; } + npm test + bun --bun x vitest run diff --git a/.gitignore b/.gitignore index f9a8779..a9342cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # macOS AppleDouble junk ._* .DS_Store + +# ts/edgeauth's dev dependencies +node_modules/ diff --git a/README.md b/README.md index 69a0b59..c0934e7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Shared Go module for services on the platform. The `runtime` package implements the runtime contract once, so a service's `main` is: load the runtime, register readiness checks, serve. The `natsclient` package is the -NATS client every service shares. +NATS client every service shares, and `edgeauth` verifies the identity guard +hands every authenticated request. ```go ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) @@ -51,7 +52,12 @@ exported over OTLP gRPC only when `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` or These names are the contract's and carry no prefix. Anything else is namespaced: a service prefixes its own settings with its name (`LIVE_...`, `BILLING_...`) and parses them itself, and any variable this module adds -beyond the contract starts with `PLATFORM_KIT_`. +beyond the contract starts with `PLATFORM_KIT_`: + +| Variable | Read by | | +|---|---|---| +| `PLATFORM_KIT_EDGEAUTH_JWKS_URL` | `edgeauth.Config.JWKSURL` | default `http://guard.guard-production.svc.cluster.local:8082/jwks.json` | +| `PLATFORM_KIT_EDGEAUTH_AUDIENCES` | `edgeauth.Config.Audiences` | required by `edgeauth.New`: the hosts the app serves, comma-separated | Logs are JSON on stderr with `service`, `environment` and, when logged with a `*Context` method inside a span, `trace_id` and `span_id`. Use `rt.Logger`; @@ -141,6 +147,114 @@ nothing is wrapped. - Metrics are `nats_requests_total{service,endpoint,outcome}` and `nats_request_duration_seconds` with the same labels. +## edgeauth + +Guard is the only OIDC relying party on the platform. It sends every +authenticated request upstream with a short-lived identity JWT in the +`x-bastion-identity` header, and `edgeauth` verifies it, so an app never runs +OIDC and never trusts a plain header. A token is accepted when: + +- the JOSE header is `alg: ES256`, `typ: bastion-id+jwt` and a `kid` in + guard's JWKS, and the signature verifies; +- `iss` is `bastion` and `aud` is one of `Config.Audiences`; +- `exp` has not passed and `iat` is not in the future, each with 10 s leeway, + and `sub` is set. + +The JWKS is fetched on the first token, never at start, cached for 5 minutes, +and refetched for an unknown `kid` at most once every 30 seconds (concurrent +requests share one fetch). An app boots and serves anonymous traffic while +guard is down. + +| Request | `Middleware` | +|---|---| +| no header | passes on anonymous: `FromContext` reports false | +| valid token | passes on with the `Identity` in the context | +| invalid token (`ErrInvalid`) | 401, logged through `WithLogger` | +| JWKS unreachable (`ErrUnavailable`) | 503 | + +Responses are plain text and never echo the token. `RequireIdentity` (401 +when anonymous) and `RequireRole(roles...)` (any of; 401 anonymous, 403 +without a role) go on the handlers that need a caller; they are the app's +second fail-closed check after guard's own. Domain authorization stays in the +app. + +```go +cfg, err := edgeauth.ConfigFromEnv() // PLATFORM_KIT_EDGEAUTH_* +if err != nil { ... } +v, err := edgeauth.New(cfg, edgeauth.WithLogger(rt.Logger)) +if err != nil { ... } + +mux := http.NewServeMux() +mux.Handle("GET /", publicHandler) +mux.Handle("GET /me", edgeauth.RequireIdentity(meHandler)) +mux.Handle("/admin/", edgeauth.RequireRole("admin")(adminHandler)) +err = rt.Serve(ctx, v.Middleware(mux)) + +// in a handler +id, ok := edgeauth.FromContext(r.Context()) // id.Subject, id.Roles, id.HasRole("admin") +``` + +With echo: + +```go +e.Use(echo.WrapMiddleware(v.Middleware)) +admin := e.Group("/admin", echo.WrapMiddleware(edgeauth.RequireRole("admin"))) +``` + +Tests mint identities with `edgeauthtest`, which signs with a throwaway key +and serves its JWKS on an `httptest` server closed at the end of the test: + +```go +iss := edgeauthtest.NewIssuer(t) +v, err := edgeauth.New(iss.Config("live.metsa.app")) + +req := httptest.NewRequest(http.MethodGet, "https://live.metsa.app/admin", nil) +iss.Sign(req, edgeauth.Identity{Subject: "u1", Roles: []string{"admin"}}) // aud = req.Host + +expired := iss.MintWith(id, "live.metsa.app", func(c *edgeauthtest.Claims) { c.Expiry -= 600 }) +``` + +A handler test that does not care about tokens puts the identity in the +context directly with `edgeauth.ContextWith`. + +`edgeauth/testdata/vectors.json` holds conformance vectors: a JWKS, a fixed +`now`, and tokens with the identity or the error class (`invalid`, +`unavailable`) each must give. The Go and TypeScript tests and guard's signer +test run them; `go generate ./edgeauth` rewrites them, and a test fails when +the committed file is stale. + +### TypeScript + +`@metsaapp/edgeauth` (`ts/edgeauth/`) applies the same rules on top of +[jose](https://github.com/panva/jose). It is installed from this repository +by tag; there is no registry: + +``` +bun add @metsaapp/edgeauth@github:MetsaApp/platform-kit#v0.3.0 +npm install @metsaapp/edgeauth@github:MetsaApp/platform-kit#v0.3.0 +``` + +```ts +import { createVerifier, EdgeAuthInvalid, EdgeAuthUnavailable } from "@metsaapp/edgeauth"; + +const verifier = createVerifier({ audiences: ["umami.metsa.app"] }); // jwksUrl defaults as in Go +const id = await verifier.fromHeaders(request.headers); // null when anonymous +// rejects with EdgeAuthInvalid (answer 401) or EdgeAuthUnavailable (503) +``` + +The package's `package.json` is at the repository root, with `files` limited +to `ts/edgeauth`. Bun imports the TypeScript source (the `bun` export +condition); Node and bundlers import the compiled `ts/edgeauth/dist`, which is +committed, because git installs do not run a build reliably (Bun and pnpm skip +lifecycle scripts of git dependencies). CI fails when `dist` does not match +the source. release-please bumps the root `package.json` version with each +release. + +``` +cd ts/edgeauth && npm ci +npm run build && npm run typecheck && npm test # commit dist/ with the source +``` + ## Working here ``` diff --git a/edgeauth/doc.go b/edgeauth/doc.go new file mode 100644 index 0000000..b28d7af --- /dev/null +++ b/edgeauth/doc.go @@ -0,0 +1,47 @@ +// Package edgeauth verifies the identity guard hands every authenticated +// request upstream, so an app knows who is calling without running OIDC +// itself. +// +// Guard is the platform's only OIDC relying party. It owns browser sessions, +// validates Zitadel bearer tokens for native clients, and forwards each +// authenticated request with one short-lived signed identity JWT in the +// x-bastion-identity header (Header). Envoy strips a client's copy of that +// header and guard overwrites it, but an app still never trusts it as a plain +// header: it verifies the token, and keeps its own domain authorization on top +// of the identity it gets. +// +// A token is accepted only when every one of these holds: +// +// - the JOSE header is alg ES256 and typ bastion-id+jwt, with a kid that is +// in guard's JWKS; +// - the ES256 signature verifies against that key; +// - iss is "bastion" and aud is one of the hosts the app serves +// (Config.Audiences); +// - exp has not passed and iat is not in the future, both with a 10 s leeway +// for clock skew, and sub is set. +// +// The JWKS is fetched lazily, on the first token, never in New, so an app +// boots and serves anonymous traffic while guard is unreachable. It is cached +// for 5 minutes; a token whose kid is not in the cache refetches it at most +// once every 30 seconds, and concurrent requests share one fetch. Guard +// publishes a new signing key at least 10 minutes before it signs with it, so +// a rotation never meets a verifier with a stale cache. +// +// Verify tells two failures apart. ErrInvalid means the token is wrong +// (forged, expired, for another host, signed by an unknown key): Middleware +// answers 401 and logs it, because behind guard it can only be an attack or a +// key mistake. ErrUnavailable means the token could not be checked because the +// JWKS could not be fetched: Middleware answers 503, and the request may +// succeed on a retry. A request without the header is anonymous: Middleware +// passes it on with no identity in the context, and RequireIdentity or +// RequireRole refuse it on the handlers that need one. Guard fails closed on +// routes with an auth rule, and the app fails closed again on its protected +// handlers, so a request that slipped past guard is never treated as signed in. +// +// The edgeauthtest package mints identities for an app's own tests, and +// testdata/vectors.json holds conformance vectors that the Go verifier, the +// TypeScript verifier (@metsaapp/edgeauth, ts/edgeauth in this repository) and +// guard's signer all run. +package edgeauth + +//go:generate go run ./internal/vectorgen -o testdata/vectors.json diff --git a/edgeauth/edgeauth_test.go b/edgeauth/edgeauth_test.go new file mode 100644 index 0000000..7f4e74f --- /dev/null +++ b/edgeauth/edgeauth_test.go @@ -0,0 +1,374 @@ +package edgeauth_test + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/MetsaApp/platform-kit/edgeauth" + "github.com/MetsaApp/platform-kit/edgeauth/edgeauthtest" +) + +const host = "live.metsa.app" + +var ada = edgeauth.Identity{ //nolint:gochecknoglobals // a fixture shared by the tests + Subject: "u1", Email: "ada@example.org", EmailVerified: true, Name: "Ada", + Roles: []string{"admin", "member"}, SessionID: "0123456789abcdef", ClientID: "c1", + Source: edgeauth.SourceSession, AuthTime: time.Unix(1_700_000_000, 0), +} + +// clock is a settable clock for WithClock. +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *clock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +// countingProxy forwards to the issuer's JWKS and counts the fetches. +func countingProxy(t *testing.T, iss *edgeauthtest.Issuer, fetches *atomic.Int32) string { + t.Helper() + target, err := url.Parse(iss.JWKSURL()) + require.NoError(t, err) + proxy := httputil.NewSingleHostReverseProxy(target) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetches.Add(1) + proxy.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + return srv.URL + "/jwks.json" +} + +// mintAt mints ada's token as issued at now, for a verifier on a test clock. +func mintAt(iss *edgeauthtest.Issuer, now time.Time) string { + return iss.MintWith(ada, host, func(c *edgeauthtest.Claims) { + c.IssuedAt, c.Expiry = now.Unix(), now.Add(edgeauthtest.Lifetime).Unix() + }) +} + +func TestNewNeedsAudiences(t *testing.T) { + t.Parallel() + _, err := edgeauth.New(edgeauth.Config{JWKSURL: edgeauth.DefaultJWKSURL}) + require.ErrorIs(t, err, edgeauth.ErrConfig) + _, err = edgeauth.New(edgeauth.Config{Audiences: []string{host}}) + require.ErrorIs(t, err, edgeauth.ErrConfig) +} + +func TestConfigFromEnv(t *testing.T) { + t.Setenv("PLATFORM_KIT_EDGEAUTH_AUDIENCES", "live.metsa.app,admin.live.metsa.app") + cfg, err := edgeauth.ConfigFromEnv() + require.NoError(t, err) + assert.Equal(t, edgeauth.DefaultJWKSURL, cfg.JWKSURL) + assert.Equal(t, []string{"live.metsa.app", "admin.live.metsa.app"}, cfg.Audiences) +} + +func TestVerifyRoundTrip(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + v, err := edgeauth.New(iss.Config(host)) + require.NoError(t, err) + + id, err := v.Verify(t.Context(), iss.Mint(ada, host)) + require.NoError(t, err) + assert.Equal(t, ada, id) + assert.True(t, id.HasRole("admin")) + assert.False(t, id.HasRole("host")) + assert.True(t, id.HasAnyRole("host", "member")) + assert.False(t, id.HasAnyRole()) + + _, err = v.Verify(t.Context(), iss.Mint(ada, "other.metsa.app")) + require.ErrorIs(t, err, edgeauth.ErrInvalid) + + expired := iss.MintWith(ada, host, func(c *edgeauthtest.Claims) { + c.IssuedAt -= 600 + c.Expiry -= 600 + }) + _, err = v.Verify(t.Context(), expired) + require.ErrorIs(t, err, edgeauth.ErrInvalid) + + // A token from another issuer's key is unknown here. + _, err = v.Verify(t.Context(), edgeauthtest.NewIssuer(t).Mint(ada, host)) + require.ErrorIs(t, err, edgeauth.ErrInvalid) +} + +func TestNewDoesNotFetch(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + var fetches atomic.Int32 + cfg := edgeauth.Config{JWKSURL: countingProxy(t, iss, &fetches), Audiences: []string{host}} + v, err := edgeauth.New(cfg) + require.NoError(t, err) + assert.Zero(t, fetches.Load()) + + _, err = v.Verify(t.Context(), iss.Mint(ada, host)) + require.NoError(t, err) + assert.EqualValues(t, 1, fetches.Load()) +} + +func TestJWKSCache(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + var fetches atomic.Int32 + clk := &clock{now: time.Now()} + v, err := edgeauth.New( + edgeauth.Config{JWKSURL: countingProxy(t, iss, &fetches), Audiences: []string{host}}, + edgeauth.WithClock(clk.Now), + ) + require.NoError(t, err) + verify := func() error { + _, verr := v.Verify(t.Context(), mintAt(iss, clk.Now())) + return verr + } + stranger := edgeauthtest.NewIssuer(t).Mint(ada, host) // signed by a key with the same kid, never published + + require.NoError(t, verify()) + require.NoError(t, verify()) + assert.EqualValues(t, 1, fetches.Load(), "a fresh cache is reused") + + clk.Advance(20 * time.Second) + _, err = v.Verify(t.Context(), stranger) + require.ErrorIs(t, err, edgeauth.ErrInvalid) + assert.EqualValues(t, 1, fetches.Load(), "a known kid does not refetch") + + clk.Advance(4*time.Minute + 50*time.Second) + require.NoError(t, verify()) + assert.EqualValues(t, 2, fetches.Load(), "an expired cache refetches") +} + +func TestJWKSRefetchOnUnknownKid(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + var fetches atomic.Int32 + proxy := countingProxy(t, iss, &fetches) + // Serve a JWKS without the key first, as during a rotation that has not + // reached this verifier's cache. + var published atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !published.Load() { + fetches.Add(1) + _, _ = w.Write([]byte(`{"keys":[]}`)) + return + } + http.Redirect(w, r, proxy, http.StatusFound) + })) + t.Cleanup(srv.Close) + clk := &clock{now: time.Now()} + v, err := edgeauth.New( + edgeauth.Config{JWKSURL: srv.URL, Audiences: []string{host}}, + edgeauth.WithClock(clk.Now), + ) + require.NoError(t, err) + token := iss.Mint(ada, host) + + _, err = v.Verify(t.Context(), token) + require.ErrorIs(t, err, edgeauth.ErrInvalid) + assert.EqualValues(t, 1, fetches.Load()) + + published.Store(true) + clk.Advance(10 * time.Second) + _, err = v.Verify(t.Context(), token) + require.ErrorIs(t, err, edgeauth.ErrInvalid, "an unknown kid refetches at most once per 30 s") + assert.EqualValues(t, 1, fetches.Load()) + + clk.Advance(20 * time.Second) + _, err = v.Verify(t.Context(), token) + require.NoError(t, err) + assert.EqualValues(t, 2, fetches.Load()) +} + +func TestJWKSSingleFlight(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + var fetches atomic.Int32 + proxy := countingProxy(t, iss, &fetches) + release := make(chan struct{}) + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + http.Redirect(w, r, proxy, http.StatusFound) + })) + t.Cleanup(slow.Close) + v, err := edgeauth.New(edgeauth.Config{JWKSURL: slow.URL, Audiences: []string{host}}) + require.NoError(t, err) + token := iss.Mint(ada, host) + + const callers = 20 + errs := make(chan error, callers) + for range callers { + go func() { + _, verr := v.Verify(t.Context(), token) + errs <- verr + }() + } + time.Sleep(50 * time.Millisecond) + close(release) + for range callers { + require.NoError(t, <-errs) + } + assert.EqualValues(t, 1, fetches.Load()) +} + +func TestJWKSUnavailable(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + var fail atomic.Bool + fail.Store(true) + var fetches atomic.Int32 + proxy := countingProxy(t, iss, &fetches) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail.Load() { + http.Error(w, "down", http.StatusBadGateway) + return + } + http.Redirect(w, r, proxy, http.StatusFound) + })) + t.Cleanup(srv.Close) + clk := &clock{now: time.Now()} + v, err := edgeauth.New( + edgeauth.Config{JWKSURL: srv.URL, Audiences: []string{host}}, + edgeauth.WithClock(clk.Now), + ) + require.NoError(t, err) + token := iss.Mint(ada, host) + + _, err = v.Verify(t.Context(), token) + require.ErrorIs(t, err, edgeauth.ErrUnavailable) + + fail.Store(false) + _, err = v.Verify(t.Context(), token) + require.ErrorIs(t, err, edgeauth.ErrUnavailable, "a failed fetch is not retried at once") + + clk.Advance(5 * time.Second) + _, err = v.Verify(t.Context(), token) + require.NoError(t, err) + + // Once the cache expires, an outage still verifies with the keys it had. + fail.Store(true) + clk.Advance(6 * time.Minute) + _, err = v.Verify(t.Context(), mintAt(iss, clk.Now())) + require.NoError(t, err) +} + +func serve( + t *testing.T, v *edgeauth.Verifier, guard func(http.Handler) http.Handler, r *http.Request, +) *httptest.ResponseRecorder { + t.Helper() + h := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, ok := edgeauth.FromContext(r.Context()) + if !ok { + _, _ = w.Write([]byte("anonymous")) + return + } + _, _ = w.Write([]byte(id.Subject)) + })) + if guard != nil { + h = guard(h) + } + rec := httptest.NewRecorder() + v.Middleware(h).ServeHTTP(rec, r) + return rec +} + +func TestMiddleware(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + var logs bytes.Buffer + v, err := edgeauth.New(iss.Config(host), edgeauth.WithLogger(slog.New(slog.NewJSONHandler(&logs, nil)))) + require.NoError(t, err) + req := func() *http.Request { + return httptest.NewRequest(http.MethodGet, "https://live.metsa.app:443/admin", nil) + } + + rec := serve(t, v, nil, req()) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "anonymous", rec.Body.String()) + + r := req() + iss.Sign(r, ada) + rec = serve(t, v, nil, r) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "u1", rec.Body.String()) + + r = req() + token := iss.Mint(ada, "other.metsa.app") + r.Header.Set(edgeauth.Header, token) + rec = serve(t, v, nil, r) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "text/plain; charset=utf-8", rec.Header().Get("Content-Type")) + assert.NotContains(t, rec.Body.String(), token) + assert.Contains(t, logs.String(), "identity token refused") + assert.NotContains(t, logs.String(), token) +} + +func TestMiddlewareUnavailable(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + down := httptest.NewServer(http.NotFoundHandler()) + down.Close() + v, err := edgeauth.New(edgeauth.Config{JWKSURL: down.URL, Audiences: []string{host}}) + require.NoError(t, err) + r := httptest.NewRequest(http.MethodGet, "https://live.metsa.app/", nil) + iss.Sign(r, ada) + rec := serve(t, v, nil, r) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) +} + +func TestRequire(t *testing.T) { + t.Parallel() + iss := edgeauthtest.NewIssuer(t) + v, err := edgeauth.New(iss.Config(host)) + require.NoError(t, err) + member := ada + member.Roles = []string{"member"} + tests := []struct { + name string + guard func(http.Handler) http.Handler + id *edgeauth.Identity + want int + }{ + {"identity, anonymous", edgeauth.RequireIdentity, nil, http.StatusUnauthorized}, + {"identity, signed in", edgeauth.RequireIdentity, &member, http.StatusOK}, + {"role, anonymous", edgeauth.RequireRole("admin"), nil, http.StatusUnauthorized}, + {"role, missing", edgeauth.RequireRole("admin", "host"), &member, http.StatusForbidden}, + {"role, any of", edgeauth.RequireRole("host", "member"), &member, http.StatusOK}, + {"no roles, signed in", edgeauth.RequireRole(), &member, http.StatusOK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := httptest.NewRequest(http.MethodGet, "https://live.metsa.app/admin", nil) + if tt.id != nil { + iss.Sign(r, *tt.id) + } + assert.Equal(t, tt.want, serve(t, v, tt.guard, r).Code) + }) + } +} + +func TestContextWith(t *testing.T) { + t.Parallel() + _, ok := edgeauth.FromContext(t.Context()) + assert.False(t, ok) + id, ok := edgeauth.FromContext(edgeauth.ContextWith(t.Context(), ada)) + assert.True(t, ok) + assert.Equal(t, ada, id) +} diff --git a/edgeauth/edgeauthtest/edgeauthtest.go b/edgeauth/edgeauthtest/edgeauthtest.go new file mode 100644 index 0000000..71b4cbd --- /dev/null +++ b/edgeauth/edgeauthtest/edgeauthtest.go @@ -0,0 +1,151 @@ +// Package edgeauthtest mints identity JWTs for an app's tests, the way guard +// signs them, with a throwaway key served from an in-process JWKS: +// +// iss := edgeauthtest.NewIssuer(t) +// v, err := edgeauth.New(iss.Config("live.metsa.app")) +// ... +// req := httptest.NewRequest(http.MethodGet, "https://live.metsa.app/admin", nil) +// iss.Sign(req, edgeauth.Identity{Subject: "u1", Roles: []string{"admin"}}) +// +// MintWith edits the claims before signing, for the tokens an app must refuse +// (expired, for another host). +package edgeauthtest + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "github.com/MetsaApp/platform-kit/edgeauth" +) + +// KeyID is the kid of every Issuer's key. +const KeyID = "edgeauthtest" + +// Lifetime is how long a minted token is valid, as guard's are. +const Lifetime = 60 * time.Second + +// Claims is a token's payload before signing, for MintWith to edit. +type Claims struct { + Issuer string `json:"iss"` + Audience string `json:"aud"` + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Roles []string `json:"roles"` + SessionID string `json:"sid"` + AuthTime int64 `json:"auth_time,omitempty"` + ClientID string `json:"azp"` + Source string `json:"src"` + IssuedAt int64 `json:"iat"` + Expiry int64 `json:"exp"` +} + +// Issuer signs identity JWTs with an in-memory P-256 key and serves its JWKS +// on a test server that is closed when the test ends. +type Issuer struct { + t testing.TB + signer jose.Signer + server *httptest.Server +} + +// NewIssuer starts an Issuer for the test. +func NewIssuer(t testing.TB) *Issuer { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("edgeauthtest: generate key: %v", err) + } + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: jose.JSONWebKey{Key: key, KeyID: KeyID}}, + (&jose.SignerOptions{}).WithType(edgeauth.TokenType), + ) + if err != nil { + t.Fatalf("edgeauthtest: signer: %v", err) + } + jwks, err := json.Marshal(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{ + Key: &key.PublicKey, KeyID: KeyID, Algorithm: string(jose.ES256), Use: "sig", + }}}) + if err != nil { + t.Fatalf("edgeauthtest: marshal JWKS: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwks) + })) + t.Cleanup(server.Close) + return &Issuer{t: t, signer: signer, server: server} +} + +// JWKSURL is where the Issuer serves its public key. +func (i *Issuer) JWKSURL() string { + return i.server.URL + "/jwks.json" +} + +// Config is a verifier config that trusts this Issuer for the hosts aud. +func (i *Issuer) Config(aud ...string) edgeauth.Config { + return edgeauth.Config{JWKSURL: i.JWKSURL(), Audiences: aud} +} + +// Mint signs an identity JWT for id with audience aud, issued now and valid +// for Lifetime. An empty Source becomes edgeauth.SourceSession. +func (i *Issuer) Mint(id edgeauth.Identity, aud string) string { + i.t.Helper() + return i.MintWith(id, aud, nil) +} + +// MintWith is Mint with edit applied to the claims before signing. +func (i *Issuer) MintWith(id edgeauth.Identity, aud string, edit func(*Claims)) string { + i.t.Helper() + now := time.Now() + c := Claims{ + Issuer: edgeauth.Issuer, + Audience: aud, + Subject: id.Subject, + Email: id.Email, + EmailVerified: id.EmailVerified, + Name: id.Name, + Roles: append([]string{}, id.Roles...), + SessionID: id.SessionID, + ClientID: id.ClientID, + Source: id.Source, + IssuedAt: now.Unix(), + Expiry: now.Add(Lifetime).Unix(), + } + if c.Source == "" { + c.Source = edgeauth.SourceSession + } + if !id.AuthTime.IsZero() { + c.AuthTime = id.AuthTime.Unix() + } + if edit != nil { + edit(&c) + } + token, err := jwt.Signed(i.signer).Claims(c).Serialize() + if err != nil { + i.t.Fatalf("edgeauthtest: sign: %v", err) + } + return token +} + +// Sign sets r's identity header to a token for id, with the audience guard +// would use: r's host, lower case, without the port. +func (i *Issuer) Sign(r *http.Request, id edgeauth.Identity) { + i.t.Helper() + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + r.Header.Set(edgeauth.Header, i.Mint(id, strings.ToLower(host))) +} diff --git a/edgeauth/identity.go b/edgeauth/identity.go new file mode 100644 index 0000000..b935812 --- /dev/null +++ b/edgeauth/identity.go @@ -0,0 +1,64 @@ +package edgeauth + +import ( + "context" + "slices" + "time" +) + +// Sources of an identity (Identity.Source). +const ( + // SourceSession is a browser session guard holds. + SourceSession = "session" + // SourceBearer is a Zitadel access token guard validated. + SourceBearer = "bearer" +) + +// Identity is the caller guard vouches for. +type Identity struct { + // Subject is the Zitadel user id. + Subject string + // Email may be empty. + Email string + EmailVerified bool + // Name is the display name and may be empty. + Name string + // Roles are the role keys of the host's Zitadel project, sorted and + // de-duplicated; empty, never nil, when the user has none. + Roles []string + // SessionID is an opaque session id, empty for a bearer. + SessionID string + // AuthTime is when the user logged in at Zitadel; zero when unknown. + AuthTime time.Time + // ClientID is the Zitadel client the session or bearer belongs to. + ClientID string + // Source is SourceSession or SourceBearer. + Source string +} + +// HasRole reports whether the identity holds role. +func (i Identity) HasRole(role string) bool { + return slices.Contains(i.Roles, role) +} + +// HasAnyRole reports whether the identity holds at least one of roles. It is +// false when roles is empty. +func (i Identity) HasAnyRole(roles ...string) bool { + return slices.ContainsFunc(roles, i.HasRole) +} + +type contextKey struct{} + +// ContextWith returns a copy of ctx carrying id. Middleware calls it for a +// verified token; a test calls it to run a handler as someone without minting +// a token. +func ContextWith(ctx context.Context, id Identity) context.Context { + return context.WithValue(ctx, contextKey{}, id) +} + +// FromContext returns the identity Middleware verified, and false for an +// anonymous request. +func FromContext(ctx context.Context) (Identity, bool) { + id, ok := ctx.Value(contextKey{}).(Identity) + return id, ok +} diff --git a/edgeauth/internal/vectorgen/main.go b/edgeauth/internal/vectorgen/main.go new file mode 100644 index 0000000..0e0970f --- /dev/null +++ b/edgeauth/internal/vectorgen/main.go @@ -0,0 +1,32 @@ +// Command vectorgen writes the edgeauth conformance vectors: +// +// go generate ./edgeauth +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/MetsaApp/platform-kit/edgeauth/internal/vectors" +) + +func main() { + out := flag.String("o", "testdata/vectors.json", "file to write") + flag.Parse() + if err := run(*out); err != nil { + fmt.Fprintln(os.Stderr, "vectorgen:", err) + os.Exit(1) + } +} + +func run(out string) error { + data, err := vectors.Generate() + if err != nil { + return fmt.Errorf("generate: %w", err) + } + if err = os.WriteFile(out, data, 0o644); err != nil { //nolint:gosec // the vectors are public test data + return fmt.Errorf("write %s: %w", out, err) + } + return nil +} diff --git a/edgeauth/internal/vectors/vectors.go b/edgeauth/internal/vectors/vectors.go new file mode 100644 index 0000000..fa48db9 --- /dev/null +++ b/edgeauth/internal/vectors/vectors.go @@ -0,0 +1,499 @@ +// Package vectors builds the conformance vectors in edgeauth/testdata +// (vectors.json): a JWKS, a fixed "now", and tokens with the result every +// verifier must give for them. The Go verifier, the TypeScript verifier and +// guard's signer test all run them. +// +// The tokens are assembled by hand, not with a JOSE library, so a vector +// can break the rules in ways a library refuses to (alg none, a DER +// signature) and so the verifiers are checked against an independent +// encoder. ECDSA signatures are deterministic (RFC 6979), so the output is +// stable and the edgeauth tests check the committed file is current. +package vectors + +import ( + "crypto" + "crypto/ecdsa" + "crypto/hmac" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "strings" +) + +// The fixed key and clock of the vectors. +const ( + // KeyID is the kid of the vectors' only published key. + KeyID = "vectors-2026-11-a" + // Now is the time the verifiers run the vectors at (2027-01-01T00:00:00Z). + Now = 1798761600 + // Audience is the host most vectors are minted for. + Audience = "live.metsa.app" +) + +// signingKeyPEM is the published key. It is a test key: it signs nothing but +// these vectors. +const signingKeyPEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgGaBAURWXeaTW2jDS +gGE88+H0gB04n8Rg37ajnoxgvkyhRANCAARMUjHkSfTB0fCnaCfikau/ijQQEnNZ +0NdQrHvNNdhGr917ST60SWXseBRMJiDkMLS9G4lb3XrEa+TThIHr+HJw +-----END PRIVATE KEY----- +` + +// otherKeyPEM is not published; tokens it signs under KeyID are forgeries. +const otherKeyPEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgB0721h7M82AYVhC+ +Z3vvougCyipORzP5pCSyiMUL39GhRANCAAS+u20iv5NcuK6DAt33DWCZltOyoeSv +FmfFrFnJes45k0+xV3VPfjGSgn1E/WbcQFD99YstnA4XsmHzWoOrds0E +-----END PRIVATE KEY----- +` + +// coordinateBytes is the length of a P-256 coordinate and of each half of an +// ES256 signature. +const coordinateBytes = 32 + +// File is vectors.json. +type File struct { + Comment string `json:"comment"` + Now int64 `json:"now"` + JWKS JWKS `json:"jwks"` + Cases []Case `json:"cases"` +} + +// JWKS is the published key set. +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// JWK is one published key. +type JWK struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + Kid string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` +} + +// Case is one token and what a verifier must make of it. +type Case struct { + Name string `json:"name"` + Token string `json:"token"` + Audiences []string `json:"audiences"` + // JWKSUnreachable runs the case with guard's JWKS endpoint down. + JWKSUnreachable bool `json:"jwksUnreachable,omitempty"` + Want Want `json:"want"` +} + +// Want is either an identity (OK) or an error class, "invalid" or +// "unavailable". +type Want struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Subject string `json:"subject,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"emailVerified,omitempty"` + Name string `json:"name,omitempty"` + Roles []string `json:"roles,omitempty"` + SessionID string `json:"sessionId,omitempty"` + AuthTime int64 `json:"authTime,omitempty"` + ClientID string `json:"clientId,omitempty"` + Source string `json:"source,omitempty"` +} + +// Error classes of Want. +const ( + Invalid = "invalid" + Unavailable = "unavailable" +) + +type claims = map[string]any + +type header = map[string]any + +// generator holds the keys while the cases are built. +type generator struct { + key, other *ecdsa.PrivateKey +} + +// Generate returns vectors.json. +func Generate() ([]byte, error) { + key, err := parseKey(signingKeyPEM) + if err != nil { + return nil, err + } + other, err := parseKey(otherKeyPEM) + if err != nil { + return nil, err + } + g := generator{key: key, other: other} + cases, err := g.cases() + if err != nil { + return nil, err + } + jwk, err := publicJWK(&key.PublicKey) + if err != nil { + return nil, err + } + file := File{ + Comment: "Generated by edgeauth/internal/vectorgen (go generate ./edgeauth). " + + "Run each case at now (unix seconds) against jwks; the key is a test key.", + Now: Now, + JWKS: JWKS{Keys: []JWK{jwk}}, + Cases: cases, + } + out, err := json.MarshalIndent(file, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal vectors: %w", err) + } + return append(out, '\n'), nil +} + +// base is a valid session token's claims. +// +//nolint:mnd // times are offsets from Now +func base() claims { + return claims{ + "iss": "bastion", + "aud": Audience, + "sub": "302871234567890123", + "email": "ada@example.org", + "email_verified": true, + "name": "Ada Lovelace", + "roles": []string{"admin", "member"}, + "sid": "0123456789abcdef", + "auth_time": Now - 3600, + "azp": "294729172937281", + "src": "session", + "iat": Now - 5, + "exp": Now + 55, + } +} + +//nolint:mnd // times are offsets from Now +func baseWant() Want { + return Want{ + OK: true, + Subject: "302871234567890123", + Email: "ada@example.org", + EmailVerified: true, + Name: "Ada Lovelace", + Roles: []string{"admin", "member"}, + SessionID: "0123456789abcdef", + AuthTime: Now - 3600, + ClientID: "294729172937281", + Source: "session", + } +} + +func esHeader() header { + return header{"alg": "ES256", "typ": "bastion-id+jwt", "kid": KeyID} +} + +func with(c claims, edit func(claims)) claims { + edit(c) + return c +} + +func withHeader(edit func(header)) header { + h := esHeader() + edit(h) + return h +} + +func invalid() Want { return Want{Error: Invalid} } + +//nolint:funlen,mnd // one table of cases, with times as offsets from Now +func (g generator) cases() ([]Case, error) { + one := []string{Audience} + type spec struct { + name string + header header + claims claims + sign func(signingInput string) (string, error) + audiences []string + down bool + want Want + } + es := g.es256(g.key) + specs := []spec{ + {name: "valid session", claims: base(), want: baseWant()}, + { + name: "valid bearer", + claims: with(base(), func(c claims) { + c["src"], c["sid"], c["azp"], c["roles"] = "bearer", "", "394513759751636980", []string{"member"} + }), + want: func() Want { + w := baseWant() + w.Source, w.SessionID, w.ClientID, w.Roles = "bearer", "", "394513759751636980", []string{"member"} + return w + }(), + }, + { + name: "roles empty", + claims: with(base(), func(c claims) { c["roles"] = []string{} }), + want: func() Want { w := baseWant(); w.Roles = nil; return w }(), + }, + { + name: "roles absent", + claims: with(base(), func(c claims) { delete(c, "roles") }), + want: func() Want { w := baseWant(); w.Roles = nil; return w }(), + }, + { + name: "empty profile", + claims: with(base(), func(c claims) { + c["email"], c["email_verified"], c["name"] = "", false, "" + delete(c, "auth_time") + }), + want: func() Want { + w := baseWant() + w.Email, w.EmailVerified, w.Name, w.AuthTime = "", false, "", 0 + return w + }(), + }, + { + name: "second audience", + claims: with(base(), func(c claims) { c["aud"] = "admin.live.metsa.app" }), + audiences: []string{Audience, "admin.live.metsa.app"}, + want: baseWant(), + }, + { + name: "aud array containing the audience", + claims: with(base(), func(c claims) { c["aud"] = []string{"other.metsa.app", Audience} }), + want: baseWant(), + }, + { + name: "typ with application prefix", + header: withHeader(func(h header) { h["typ"] = "application/bastion-id+jwt" }), + claims: base(), want: baseWant(), + }, + { + name: "expired within leeway", + claims: with(base(), func(c claims) { c["iat"], c["exp"] = Now-69, Now-9 }), + want: baseWant(), + }, + { + name: "expired", + claims: with(base(), func(c claims) { c["iat"], c["exp"] = Now-70, Now-10 }), + want: invalid(), + }, + { + name: "iat ahead within leeway", + claims: with(base(), func(c claims) { c["iat"], c["exp"] = Now+10, Now+70 }), + want: baseWant(), + }, + { + name: "future iat", + claims: with(base(), func(c claims) { c["iat"], c["exp"] = Now+11, Now+71 }), + want: invalid(), + }, + { + name: "future nbf", + claims: with(base(), func(c claims) { c["nbf"] = Now + 30 }), + want: invalid(), + }, + {name: "wrong aud", claims: with(base(), func(c claims) { c["aud"] = "other.metsa.app" }), want: invalid()}, + { + name: "aud not lower case", + claims: with(base(), func(c claims) { c["aud"] = "Live.Metsa.App" }), + want: invalid(), + }, + { + name: "wrong iss", + claims: with(base(), func(c claims) { c["iss"] = "https://auth.metsa.app" }), + want: invalid(), + }, + {name: "missing sub", claims: with(base(), func(c claims) { delete(c, "sub") }), want: invalid()}, + {name: "missing exp", claims: with(base(), func(c claims) { delete(c, "exp") }), want: invalid()}, + {name: "missing iat", claims: with(base(), func(c claims) { delete(c, "iat") }), want: invalid()}, + { + name: "email_verified not a bool", + claims: with(base(), func(c claims) { c["email_verified"] = "true" }), + want: invalid(), + }, + {name: "roles not a list", claims: with(base(), func(c claims) { c["roles"] = "admin" }), want: invalid()}, + { + name: "alg HS256 keyed with the public key", + header: withHeader(func(h header) { h["alg"] = "HS256" }), + claims: base(), sign: g.hs256(), want: invalid(), + }, + { + name: "alg none", + header: withHeader(func(h header) { h["alg"] = "none" }), + claims: base(), sign: func(string) (string, error) { return "", nil }, want: invalid(), + }, + { + name: "wrong typ", + header: withHeader(func(h header) { h["typ"] = "JWT" }), + claims: base(), want: invalid(), + }, + {name: "missing typ", header: withHeader(func(h header) { delete(h, "typ") }), claims: base(), want: invalid()}, + { + name: "unknown kid", + header: withHeader(func(h header) { h["kid"] = "vectors-unknown" }), + claims: base(), want: invalid(), + }, + {name: "missing kid", header: withHeader(func(h header) { delete(h, "kid") }), claims: base(), want: invalid()}, + {name: "bad signature", claims: base(), sign: g.es256(g.other), want: invalid()}, + {name: "DER signature", claims: base(), sign: g.es256DER(), want: invalid()}, + {name: "valid token, JWKS unreachable", claims: base(), down: true, want: Want{Error: Unavailable}}, + } + cases := make([]Case, 0, len(specs)+2) + for _, s := range specs { + h, sign, aud := s.header, s.sign, s.audiences + if h == nil { + h = esHeader() + } + if sign == nil { + sign = es + } + if aud == nil { + aud = one + } + token, err := assemble(h, s.claims, sign) + if err != nil { + return nil, fmt.Errorf("case %q: %w", s.name, err) + } + cases = append(cases, Case{Name: s.name, Token: token, Audiences: aud, JWKSUnreachable: s.down, Want: s.want}) + } + tampered, err := g.tampered() + if err != nil { + return nil, err + } + cases = append(cases, + Case{Name: "payload changed after signing", Token: tampered, Audiences: one, Want: invalid()}, + Case{Name: "not a JWS", Token: "not-a-token", Audiences: one, Want: invalid()}, + ) + return cases, nil +} + +// tampered is a valid token whose payload is swapped for one granting more +// roles, keeping the original signature. +func (g generator) tampered() (string, error) { + token, err := assemble(esHeader(), base(), g.es256(g.key)) + if err != nil { + return "", err + } + elevated := with(base(), func(c claims) { c["roles"] = []string{"admin", "cluster-admin", "member"} }) + payload, err := json.Marshal(elevated) + if err != nil { + return "", fmt.Errorf("marshal claims: %w", err) + } + parts := strings.Split(token, ".") + return parts[0] + "." + b64(payload) + "." + parts[2], nil +} + +func assemble(h header, c claims, sign func(string) (string, error)) (string, error) { + hb, err := json.Marshal(h) + if err != nil { + return "", fmt.Errorf("marshal header: %w", err) + } + cb, err := json.Marshal(c) + if err != nil { + return "", fmt.Errorf("marshal claims: %w", err) + } + input := b64(hb) + "." + b64(cb) + sig, err := sign(input) + if err != nil { + return "", err + } + return input + "." + sig, nil +} + +// es256 signs with key, deterministically, as r || s. +func (g generator) es256(key *ecdsa.PrivateKey) func(string) (string, error) { + return func(input string) (string, error) { + r, s, err := signRS(key, input) + if err != nil { + return "", err + } + sig := make([]byte, coordinateBytes+coordinateBytes) + r.FillBytes(sig[:coordinateBytes]) + s.FillBytes(sig[coordinateBytes:]) + return b64(sig), nil + } +} + +// es256DER signs with the published key but encodes the signature as ASN.1 +// DER, which JWS does not allow. +func (g generator) es256DER() func(string) (string, error) { + return func(input string) (string, error) { + r, s, err := signRS(g.key, input) + if err != nil { + return "", err + } + der, err := asn1.Marshal(struct{ R, S *big.Int }{r, s}) + if err != nil { + return "", fmt.Errorf("marshal DER signature: %w", err) + } + return b64(der), nil + } +} + +// hs256 is the classic key-confusion forgery: an HMAC keyed with the +// published public key's encoding. +func (g generator) hs256() func(string) (string, error) { + return func(input string) (string, error) { + pub, err := x509.MarshalPKIXPublicKey(&g.key.PublicKey) + if err != nil { + return "", fmt.Errorf("marshal public key: %w", err) + } + mac := hmac.New(sha256.New, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})) + mac.Write([]byte(input)) + return b64(mac.Sum(nil)), nil + } +} + +func signRS(key *ecdsa.PrivateKey, input string) (*big.Int, *big.Int, error) { + digest := sha256.Sum256([]byte(input)) + // A nil random source signs deterministically (RFC 6979). + der, err := key.Sign(nil, digest[:], crypto.SHA256) + if err != nil { + return nil, nil, fmt.Errorf("sign: %w", err) + } + var sig struct{ R, S *big.Int } + if _, err = asn1.Unmarshal(der, &sig); err != nil { + return nil, nil, fmt.Errorf("parse signature: %w", err) + } + return sig.R, sig.S, nil +} + +func publicJWK(pub *ecdsa.PublicKey) (JWK, error) { + // Bytes is the uncompressed point: 0x04 || x || y. + point, err := pub.Bytes() + if err != nil { + return JWK{}, fmt.Errorf("encode public key: %w", err) + } + return JWK{ + Kty: "EC", Crv: "P-256", + X: b64(point[1 : 1+coordinateBytes]), + Y: b64(point[1+coordinateBytes:]), + Kid: KeyID, Use: "sig", Alg: "ES256", + }, nil +} + +func parseKey(text string) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(text)) + if block == nil { + return nil, errors.New("vectors: no PEM block") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse key: %w", err) + } + ec, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("vectors: key is not ECDSA") + } + return ec, nil +} + +func b64(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/edgeauth/jwks.go b/edgeauth/jwks.go new file mode 100644 index 0000000..c553ad2 --- /dev/null +++ b/edgeauth/jwks.go @@ -0,0 +1,168 @@ +package edgeauth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/go-jose/go-jose/v4" +) + +// The JWKS cache's timings, shared with the TypeScript verifier. Guard +// publishes a key at least 10 minutes before signing with it, twice the +// cache lifetime. +const ( + // cacheTTL is how long a fetched JWKS is used without refetching. + cacheTTL = 5 * time.Minute + // refetchInterval is the least time between fetches triggered by a kid + // that is not in a fresh cache, so a flood of forged kids costs guard + // one request per interval. + refetchInterval = 30 * time.Second + // failureBackoff is how long a failed fetch is remembered before an + // expired or empty cache tries again, so requests during an outage do + // not each wait on guard. + failureBackoff = 5 * time.Second + // maxJWKSBytes bounds the JWKS response. + maxJWKSBytes = 1 << 20 +) + +// keyCache holds guard's public keys by kid. Fetches are lazy and +// single-flight: one request fetches, the others wait for it. +type keyCache struct { + url string + client *http.Client + clock func() time.Time + + mu sync.Mutex + keys map[string]*ecdsa.PublicKey // nil until the first successful fetch + fetchedAt time.Time // of the last successful fetch + attemptAt time.Time // when the last fetch ended, successful or not + lastErr error // of the last fetch; nil when it succeeded + inflight chan struct{} // closed when the running fetch ends +} + +func newKeyCache(url string, client *http.Client, clock func() time.Time) *keyCache { + return &keyCache{url: url, client: client, clock: clock} +} + +// key returns the public key for kid, fetching the JWKS when the cache is +// expired, or when kid is unknown and the last fetch is old enough. +func (c *keyCache) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) { + for { + c.mu.Lock() + if c.inflight != nil { + wait := c.inflight + c.mu.Unlock() + select { + case <-wait: + continue + case <-ctx.Done(): + return nil, fmt.Errorf("%w: waiting for the JWKS: %w", ErrUnavailable, ctx.Err()) + } + } + key, done, err := c.lookup(kid) + if done { + c.mu.Unlock() + return key, err + } + c.inflight = make(chan struct{}) + c.mu.Unlock() + + keys, err := c.fetch(ctx) + + c.mu.Lock() + // The attempt is timed at its end, so a slow fetch still counts + // against the refetch interval and the next pass decides from it. + c.attemptAt, c.lastErr = c.clock(), err + if err == nil { + c.keys, c.fetchedAt = keys, c.attemptAt + } + close(c.inflight) + c.inflight = nil + c.mu.Unlock() + } +} + +// lookup decides from the cache alone, with c.mu held. done is false when the +// JWKS must be fetched first. +func (c *keyCache) lookup(kid string) (*ecdsa.PublicKey, bool, error) { + now := c.clock() + key, known := c.keys[kid] + fresh := c.keys != nil && now.Sub(c.fetchedAt) < cacheTTL + switch { + case fresh && known: + return key, true, nil + case fresh: + if now.Sub(c.attemptAt) >= refetchInterval { + return nil, false, nil + } + if c.lastErr != nil { + return nil, true, fmt.Errorf("%w: %w", ErrUnavailable, c.lastErr) + } + return nil, true, fmt.Errorf("%w: unknown kid", ErrInvalid) + case c.lastErr != nil && now.Sub(c.attemptAt) < failureBackoff: + // The cache is expired or empty and guard just failed to answer. + // A key it published before is still good: rotation keeps an old + // key published until no token signed by it is live. + if known { + return key, true, nil + } + return nil, true, fmt.Errorf("%w: %w", ErrUnavailable, c.lastErr) + default: + return nil, false, nil + } +} + +// fetch gets and parses the JWKS. It is detached from the caller's +// cancellation, because other requests wait on the same fetch. +func (c *keyCache) fetch(ctx context.Context) (map[string]*ecdsa.PublicKey, error) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), defaultFetchTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) + if err != nil { + return nil, fmt.Errorf("build JWKS request: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch JWKS: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch JWKS: status %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxJWKSBytes)) + if err != nil { + return nil, fmt.Errorf("read JWKS: %w", err) + } + return parseJWKS(body) +} + +// parseJWKS keeps the P-256 signing keys of a JWKS, by kid. Keys of another +// type, curve, use or alg are skipped, as are keys without a kid. +func parseJWKS(body []byte) (map[string]*ecdsa.PublicKey, error) { + var set jose.JSONWebKeySet + if err := json.Unmarshal(body, &set); err != nil { + return nil, fmt.Errorf("parse JWKS: %w", err) + } + keys := make(map[string]*ecdsa.PublicKey, len(set.Keys)) + for _, k := range set.Keys { + pub, ok := k.Key.(*ecdsa.PublicKey) + switch { + case !ok, pub.Curve != elliptic.P256(), k.KeyID == "": + continue + case k.Use != "" && k.Use != "sig": + continue + case k.Algorithm != "" && k.Algorithm != string(jose.ES256): + continue + } + keys[k.KeyID] = pub + } + return keys, nil +} diff --git a/edgeauth/middleware.go b/edgeauth/middleware.go new file mode 100644 index 0000000..d08d64a --- /dev/null +++ b/edgeauth/middleware.go @@ -0,0 +1,73 @@ +package edgeauth + +import ( + "errors" + "log/slog" + "net/http" +) + +// Middleware verifies the identity header and puts the identity in the +// request's context (FromContext). A request without the header passes on +// anonymous. A token that is invalid is refused with 401 and logged; one that +// cannot be checked because guard's JWKS is unreachable is refused with 503. +// The responses are plain text and never echo the token. +// +// With echo: e.Use(echo.WrapMiddleware(v.Middleware)). +func (v *Verifier) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get(Header) //nolint:canonicalheader // the contract names it in lower case; Get canonicalizes + if token == "" { + next.ServeHTTP(w, r) + return + } + id, err := v.Verify(r.Context(), token) + if err != nil { + attrs := []any{ + slog.String("host", r.Host), + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("error", err.Error()), + } + if errors.Is(err, ErrUnavailable) { + v.logger.ErrorContext(r.Context(), "identity token not checked: guard's keys unavailable", attrs...) + http.Error(w, "identity keys unavailable", http.StatusServiceUnavailable) + return + } + v.logger.WarnContext(r.Context(), "identity token refused", attrs...) + http.Error(w, "invalid identity", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r.WithContext(ContextWith(r.Context(), id))) + }) +} + +// RequireIdentity refuses an anonymous request with 401. It goes inside +// Middleware, on the handlers that need a signed-in caller. +func RequireIdentity(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := FromContext(r.Context()); !ok { + http.Error(w, "sign-in required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// RequireRole refuses an anonymous request with 401 and a caller holding none +// of roles with 403. With no roles it is RequireIdentity. +func RequireRole(roles ...string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, ok := FromContext(r.Context()) + if !ok { + http.Error(w, "sign-in required", http.StatusUnauthorized) + return + } + if len(roles) > 0 && !id.HasAnyRole(roles...) { + http.Error(w, "missing role", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/edgeauth/testdata/vectors.json b/edgeauth/testdata/vectors.json new file mode 100644 index 0000000..f665993 --- /dev/null +++ b/edgeauth/testdata/vectors.json @@ -0,0 +1,469 @@ +{ + "comment": "Generated by edgeauth/internal/vectorgen (go generate ./edgeauth). Run each case at now (unix seconds) against jwks; the key is a test key.", + "now": 1798761600, + "jwks": { + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "TFIx5En0wdHwp2gn4pGrv4o0EBJzWdDXUKx7zTXYRq8", + "y": "3XtJPrRJZex4FEwmIOQwtL0biVvdesRr5NOEgev4cnA", + "kid": "vectors-2026-11-a", + "use": "sig", + "alg": "ES256" + } + ] + }, + "cases": [ + { + "name": "valid session", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.oDknH03gSMoxXIfffC-_G9pnRRIyzW9IgF7JoJ1wA5ZJVITgiOBqoEwVcZa1fJZZwSe3cltPnbv0oe5IMsEp8A", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "roles": [ + "admin", + "member" + ], + "sessionId": "0123456789abcdef", + "authTime": 1798758000, + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "valid bearer", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMzk0NTEzNzU5NzUxNjM2OTgwIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsibWVtYmVyIl0sInNpZCI6IiIsInNyYyI6ImJlYXJlciIsInN1YiI6IjMwMjg3MTIzNDU2Nzg5MDEyMyJ9.KtcbftLRxYXxW35LSocRqYGG8Pa3tDGgdumHc0aMB0ymLDmdGOSAAWJ75ZdCz5dmzMfVU66ntGG-pNZOT7ql5w", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "roles": [ + "member" + ], + "authTime": 1798758000, + "clientId": "394513759751636980", + "source": "bearer" + } + }, + { + "name": "roles empty", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOltdLCJzaWQiOiIwMTIzNDU2Nzg5YWJjZGVmIiwic3JjIjoic2Vzc2lvbiIsInN1YiI6IjMwMjg3MTIzNDU2Nzg5MDEyMyJ9.bWJ1G9I-bi3DKRit45XW_JRdg_u8R0__jC5u9rRyvWy7HuJDE_BFNWX9zf5gRt5-eZzgYNrVmLcAIhLuPTcprA", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "sessionId": "0123456789abcdef", + "authTime": 1798758000, + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "roles absent", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.s0EY6u7IPQFvegnESDmJbzCj443PurExJ9lIItV2ilgNZ3_ZTwVF_RV2NOGbY2G7NxI9sGI0aKl4jxv7-ed0UQ", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "sessionId": "0123456789abcdef", + "authTime": 1798758000, + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "empty profile", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF6cCI6IjI5NDcyOTE3MjkzNzI4MSIsImVtYWlsIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJleHAiOjE3OTg3NjE2NTUsImlhdCI6MTc5ODc2MTU5NSwiaXNzIjoiYmFzdGlvbiIsIm5hbWUiOiIiLCJyb2xlcyI6WyJhZG1pbiIsIm1lbWJlciJdLCJzaWQiOiIwMTIzNDU2Nzg5YWJjZGVmIiwic3JjIjoic2Vzc2lvbiIsInN1YiI6IjMwMjg3MTIzNDU2Nzg5MDEyMyJ9.kp8dWPZrqAo4WCo-BCEQeIJIl88TsEw9aVT4X4y4Fu4F4osxQe0SrD1nCDg1GRu9lofg_qTfMu3UjVribtACPQ", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "roles": [ + "admin", + "member" + ], + "sessionId": "0123456789abcdef", + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "second audience", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJhZG1pbi5saXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.HNyeNqtuVcQWwn9kkSYeIEKTpu3Cj30qnOOcEIXH_ouUiSBndfidLpL9p2u1PtMN1ACNPwCFmXQr1sQECyakSA", + "audiences": [ + "live.metsa.app", + "admin.live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "roles": [ + "admin", + "member" + ], + "sessionId": "0123456789abcdef", + "authTime": 1798758000, + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "aud array containing the audience", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOlsib3RoZXIubWV0c2EuYXBwIiwibGl2ZS5tZXRzYS5hcHAiXSwiYXV0aF90aW1lIjoxNzk4NzU4MDAwLCJhenAiOiIyOTQ3MjkxNzI5MzcyODEiLCJlbWFpbCI6ImFkYUBleGFtcGxlLm9yZyIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJleHAiOjE3OTg3NjE2NTUsImlhdCI6MTc5ODc2MTU5NSwiaXNzIjoiYmFzdGlvbiIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJyb2xlcyI6WyJhZG1pbiIsIm1lbWJlciJdLCJzaWQiOiIwMTIzNDU2Nzg5YWJjZGVmIiwic3JjIjoic2Vzc2lvbiIsInN1YiI6IjMwMjg3MTIzNDU2Nzg5MDEyMyJ9.H8dclHwjtpa0azwy7vhhzlXtZCE_uTRrzYYneeYo-jWeF1b4zCn9uWEs7sMPwfYIAuIicO4ug25WJ9Ow_0P_oQ", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "roles": [ + "admin", + "member" + ], + "sessionId": "0123456789abcdef", + "authTime": 1798758000, + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "typ with application prefix", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYXBwbGljYXRpb24vYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.RHcPO25iwre4AjeEYxLpOYOG50OS-z6HYY2egGvD7SHhvaGgfNCGGUQwTt3z5vgieeL8nOtwlYr4o5ErMUu5WA", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "roles": [ + "admin", + "member" + ], + "sessionId": "0123456789abcdef", + "authTime": 1798758000, + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "expired within leeway", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNTkxLCJpYXQiOjE3OTg3NjE1MzEsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.G4U3kX-QhyB_LzjSf3X01zsopdnZekaFcviepLEjulOg4ng6-CFYA64S7wVKyWqLgAeGac9ny_ttNXoQbX7Vkw", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "roles": [ + "admin", + "member" + ], + "sessionId": "0123456789abcdef", + "authTime": 1798758000, + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "expired", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNTkwLCJpYXQiOjE3OTg3NjE1MzAsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.0C8v8O2PN0MeljNTEaUkn0lmYesTk74c5oQ-CncSltTJyWbz0QYi6akHnynJoy_fcJb0JNZkAUzhkpXk0Xrwkw", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "iat ahead within leeway", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjcwLCJpYXQiOjE3OTg3NjE2MTAsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.1StvPN98da8DvbuMHVeGeU8Q-oGqIqlN4zPkA4I7k_--vRj3ocA4dR21eFgEgP2WbKPxBzDcWmwUcs07zgjihw", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": true, + "subject": "302871234567890123", + "email": "ada@example.org", + "emailVerified": true, + "name": "Ada Lovelace", + "roles": [ + "admin", + "member" + ], + "sessionId": "0123456789abcdef", + "authTime": 1798758000, + "clientId": "294729172937281", + "source": "session" + } + }, + { + "name": "future iat", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjcxLCJpYXQiOjE3OTg3NjE2MTEsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.gmXwnRdRjwj3s-HV_vI_fVbLRx8_tIEaeG15pO13b4ppxxbnNdyQ8DO_Jz-lPlmiB9CJtcAfvt6OquTohI1EYw", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "future nbf", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwibmJmIjoxNzk4NzYxNjMwLCJyb2xlcyI6WyJhZG1pbiIsIm1lbWJlciJdLCJzaWQiOiIwMTIzNDU2Nzg5YWJjZGVmIiwic3JjIjoic2Vzc2lvbiIsInN1YiI6IjMwMjg3MTIzNDU2Nzg5MDEyMyJ9.lm1eUuOy88-KlC9XmKpNSKv5rfxJuaUUKg2zNcVlJtEOZ6xbEF9zp8vgfvMzUwGP-MC4xh436DArFMtJ7ZAjIg", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "wrong aud", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJvdGhlci5tZXRzYS5hcHAiLCJhdXRoX3RpbWUiOjE3OTg3NTgwMDAsImF6cCI6IjI5NDcyOTE3MjkzNzI4MSIsImVtYWlsIjoiYWRhQGV4YW1wbGUub3JnIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTc5ODc2MTY1NSwiaWF0IjoxNzk4NzYxNTk1LCJpc3MiOiJiYXN0aW9uIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsInJvbGVzIjpbImFkbWluIiwibWVtYmVyIl0sInNpZCI6IjAxMjM0NTY3ODlhYmNkZWYiLCJzcmMiOiJzZXNzaW9uIiwic3ViIjoiMzAyODcxMjM0NTY3ODkwMTIzIn0.ZVeQ5iWe5AV6O25JGE6SFwKoHBWS8iRNL0VSa0u3Ne_LxHRNbp2ibreRnedw6DhYdQeLQ0hCJBpJkLSAHmfydA", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "aud not lower case", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJMaXZlLk1ldHNhLkFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.ENa_4ocLEhS6wpIhKiFE_9A0gLbEMAjguWTGgY-ybMjLO8DI2Gv6dZE2VhMjCXkD6GIbVYOolVP28_h_MS_7JA", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "wrong iss", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6Imh0dHBzOi8vYXV0aC5tZXRzYS5hcHAiLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.XR_XGjVq4M4ytciaUfpd7r4rG3KCCTpq7rvUl0m0n7KBW-BKkUT5SzjstiRWe3uWu6o6sJDRHdtQqWEOl8F6FQ", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "missing sub", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24ifQ.q2pwW4M986cIJrsbjCZ_AI9cfGO9v-XP48V_fD4woW7pqs7S9CxXaCelZASDVT-iWCaL-2gE443PC43QFMHa4w", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "missing exp", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWF0IjoxNzk4NzYxNTk1LCJpc3MiOiJiYXN0aW9uIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsInJvbGVzIjpbImFkbWluIiwibWVtYmVyIl0sInNpZCI6IjAxMjM0NTY3ODlhYmNkZWYiLCJzcmMiOiJzZXNzaW9uIiwic3ViIjoiMzAyODcxMjM0NTY3ODkwMTIzIn0.09HQVwbfUiXy1fl3ciy9A0URXxcPyI21jOtD1ghpVFlGYCB-L6alT0lwk00utFmLMt75LRgQ-fXckHbJMUP5QQ", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "missing iat", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpc3MiOiJiYXN0aW9uIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsInJvbGVzIjpbImFkbWluIiwibWVtYmVyIl0sInNpZCI6IjAxMjM0NTY3ODlhYmNkZWYiLCJzcmMiOiJzZXNzaW9uIiwic3ViIjoiMzAyODcxMjM0NTY3ODkwMTIzIn0.RgYxW7gnsMWMuAheDvRE-WUG1Crt0gyOKYe07VDkHnJO_jW94ikDpXKZTwdfgUsypxCf-Ee8k8VxSuN-aub3PQ", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "email_verified not a bool", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6InRydWUiLCJleHAiOjE3OTg3NjE2NTUsImlhdCI6MTc5ODc2MTU5NSwiaXNzIjoiYmFzdGlvbiIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJyb2xlcyI6WyJhZG1pbiIsIm1lbWJlciJdLCJzaWQiOiIwMTIzNDU2Nzg5YWJjZGVmIiwic3JjIjoic2Vzc2lvbiIsInN1YiI6IjMwMjg3MTIzNDU2Nzg5MDEyMyJ9.rYOODYSghIczjpBRAH6lMBsoRlWuFMdPI5AFvmraXhI8Eo_Hrmjvov9mWDQJ288t6IFM3X4PZQfLJJW4zchhIw", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "roles not a list", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOiJhZG1pbiIsInNpZCI6IjAxMjM0NTY3ODlhYmNkZWYiLCJzcmMiOiJzZXNzaW9uIiwic3ViIjoiMzAyODcxMjM0NTY3ODkwMTIzIn0.LaoA7WoEt3vp_XLysn_GrrusVVwKnUeSmSte7eK6g1bIEtg1KjaUDdkW7eP-F4pnTdkbj95gHwTTIUnbaU6tRg", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "alg HS256 keyed with the public key", + "token": "eyJhbGciOiJIUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.XzzxDs22E2hgCjkIRDSfKLrC-N1PjJnyELzxBfVptfE", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "alg none", + "token": "eyJhbGciOiJub25lIiwia2lkIjoidmVjdG9ycy0yMDI2LTExLWEiLCJ0eXAiOiJiYXN0aW9uLWlkK2p3dCJ9.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "wrong typ", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiSldUIn0.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.MIQhLrfWtbPjpbatJyglWOAvC3VFbMv3vJj4ZTwRkiqUM4PP6hFS6bE86NQszf5DcjSSQNpOJUeGGK7w0M1hSw", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "missing typ", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIn0.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.JBI3ADwCVzYWNVNzAXB0gWbvGafJYtedDGWjdIiwqlXvNJ2cJQy0a2Cuky61ru9Lv-Z8tKuxc2UbMSc7DjIEMw", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "unknown kid", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtdW5rbm93biIsInR5cCI6ImJhc3Rpb24taWQrand0In0.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.2I12CjqWRvWk-dVlb_pk9w0av-t9-EWOyiU6kNKqNok6ZL01QYUNx_uMgM9jW68KcFlsPD6r5Ls-XKNXMlGnLg", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "missing kid", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImJhc3Rpb24taWQrand0In0.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.2-Z03XeVZ3sLmH4GrNZAhkiuQr6-Mozw6LzsezKsO_pZyJR8m1oSbGo-J8t4L28MqOk44rWSIFni5-UH8eVlzA", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "bad signature", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.lMcYGaM1a6zSRPdL2C1e4i-IUg489vrKTGE8ovPlQUzn1EsjQW-DT-kiTj_XLA3Z7rzZNLO8Q0pcp6gV7kOFBg", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "DER signature", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.MEUCIQCgOScfTeBIyjFch998L78b2mdFEjLNb0iAXsmgnXADlgIgSVSE4IjgaqBMFXGWtXyWWcEnt3JbT5279KHuSDLBKfA", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "valid token, JWKS unreachable", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwic2lkIjoiMDEyMzQ1Njc4OWFiY2RlZiIsInNyYyI6InNlc3Npb24iLCJzdWIiOiIzMDI4NzEyMzQ1Njc4OTAxMjMifQ.oDknH03gSMoxXIfffC-_G9pnRRIyzW9IgF7JoJ1wA5ZJVITgiOBqoEwVcZa1fJZZwSe3cltPnbv0oe5IMsEp8A", + "audiences": [ + "live.metsa.app" + ], + "jwksUnreachable": true, + "want": { + "ok": false, + "error": "unavailable" + } + }, + { + "name": "payload changed after signing", + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InZlY3RvcnMtMjAyNi0xMS1hIiwidHlwIjoiYmFzdGlvbi1pZCtqd3QifQ.eyJhdWQiOiJsaXZlLm1ldHNhLmFwcCIsImF1dGhfdGltZSI6MTc5ODc1ODAwMCwiYXpwIjoiMjk0NzI5MTcyOTM3MjgxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5vcmciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZXhwIjoxNzk4NzYxNjU1LCJpYXQiOjE3OTg3NjE1OTUsImlzcyI6ImJhc3Rpb24iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZXMiOlsiYWRtaW4iLCJjbHVzdGVyLWFkbWluIiwibWVtYmVyIl0sInNpZCI6IjAxMjM0NTY3ODlhYmNkZWYiLCJzcmMiOiJzZXNzaW9uIiwic3ViIjoiMzAyODcxMjM0NTY3ODkwMTIzIn0.oDknH03gSMoxXIfffC-_G9pnRRIyzW9IgF7JoJ1wA5ZJVITgiOBqoEwVcZa1fJZZwSe3cltPnbv0oe5IMsEp8A", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + }, + { + "name": "not a JWS", + "token": "not-a-token", + "audiences": [ + "live.metsa.app" + ], + "want": { + "ok": false, + "error": "invalid" + } + } + ] +} diff --git a/edgeauth/vectors_test.go b/edgeauth/vectors_test.go new file mode 100644 index 0000000..d9a5995 --- /dev/null +++ b/edgeauth/vectors_test.go @@ -0,0 +1,88 @@ +package edgeauth_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/MetsaApp/platform-kit/edgeauth" + "github.com/MetsaApp/platform-kit/edgeauth/internal/vectors" +) + +const vectorsFile = "testdata/vectors.json" + +func TestVectorsAreCurrent(t *testing.T) { + t.Parallel() + want, err := vectors.Generate() + require.NoError(t, err) + got, err := os.ReadFile(vectorsFile) + require.NoError(t, err) + assert.Equal(t, string(want), string(got), "testdata/vectors.json is stale: run go generate ./edgeauth") +} + +func TestVectors(t *testing.T) { + t.Parallel() + raw, err := os.ReadFile(vectorsFile) + require.NoError(t, err) + var file vectors.File + require.NoError(t, json.Unmarshal(raw, &file)) + jwks, err := json.Marshal(file.JWKS) + require.NoError(t, err) + + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(jwks) + })) + t.Cleanup(up.Close) + down := httptest.NewServer(http.NotFoundHandler()) + down.Close() + + now := time.Unix(file.Now, 0) + for _, c := range file.Cases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + url := up.URL + if c.JWKSUnreachable { + url = down.URL + } + v, nerr := edgeauth.New( + edgeauth.Config{JWKSURL: url, Audiences: c.Audiences}, + edgeauth.WithClock(func() time.Time { return now }), + ) + require.NoError(t, nerr) + id, verr := v.Verify(t.Context(), c.Token) + switch c.Want.Error { + case vectors.Invalid: + require.ErrorIs(t, verr, edgeauth.ErrInvalid) + return + case vectors.Unavailable: + require.ErrorIs(t, verr, edgeauth.ErrUnavailable) + return + } + require.NoError(t, verr) + assert.Equal(t, wantIdentity(c.Want), id) + }) + } +} + +func wantIdentity(w vectors.Want) edgeauth.Identity { + id := edgeauth.Identity{ + Subject: w.Subject, + Email: w.Email, + EmailVerified: w.EmailVerified, + Name: w.Name, + Roles: append([]string{}, w.Roles...), + SessionID: w.SessionID, + ClientID: w.ClientID, + Source: w.Source, + } + if w.AuthTime != 0 { + id.AuthTime = time.Unix(w.AuthTime, 0) + } + return id +} diff --git a/edgeauth/verifier.go b/edgeauth/verifier.go new file mode 100644 index 0000000..e795f97 --- /dev/null +++ b/edgeauth/verifier.go @@ -0,0 +1,253 @@ +package edgeauth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/caarlos0/env/v11" + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// The token's fixed values. +const ( + // Header carries the identity JWT from guard to the app. + Header = "x-bastion-identity" + // Issuer is the iss of every identity JWT. + Issuer = "bastion" + // TokenType is the typ in the JOSE header of every identity JWT. + TokenType = "bastion-id+jwt" //nolint:gosec // a media type, not a credential + // DefaultJWKSURL is guard's in-cluster key endpoint. + DefaultJWKSURL = "http://guard.guard-production.svc.cluster.local:8082/jwks.json" +) + +// Leeway is the clock skew tolerated on exp, iat and nbf, each way. +const Leeway = 10 * time.Second + +// defaultFetchTimeout bounds a JWKS fetch with the default HTTP client. +const defaultFetchTimeout = 5 * time.Second + +var ( + // ErrInvalid means the token is present but not acceptable: malformed, + // forged, expired, for another host or signed by an unknown key. + // Middleware answers 401. + ErrInvalid = errors.New("edgeauth: invalid identity token") + // ErrUnavailable means the token could not be checked because guard's + // JWKS could not be fetched. Middleware answers 503. + ErrUnavailable = errors.New("edgeauth: identity keys unavailable") + // ErrConfig means New was given an unusable Config. + ErrConfig = errors.New("edgeauth: invalid config") +) + +// Config is what New needs. Apps read it from the environment with +// ConfigFromEnv, or embed it in their own env-parsed config. +type Config struct { + // JWKSURL is guard's JWKS. The default is the in-cluster endpoint; a + // verifier outside the cluster uses + // https:///.well-known/bastion/jwks.json. + JWKSURL string `env:"PLATFORM_KIT_EDGEAUTH_JWKS_URL" envDefault:"http://guard.guard-production.svc.cluster.local:8082/jwks.json"` + // Audiences are the hosts this app serves (lower case, no port); a token + // is accepted only for one of them. Required, comma-separated in the + // environment. + Audiences []string `env:"PLATFORM_KIT_EDGEAUTH_AUDIENCES"` +} + +// ConfigFromEnv reads Config from PLATFORM_KIT_EDGEAUTH_JWKS_URL and +// PLATFORM_KIT_EDGEAUTH_AUDIENCES. +func ConfigFromEnv() (Config, error) { + cfg, err := env.ParseAs[Config]() + if err != nil { + return Config{}, fmt.Errorf("load edgeauth config: %w", err) + } + return cfg, nil +} + +// Option tunes New. +type Option func(*options) + +type options struct { + client *http.Client + clock func() time.Time + logger *slog.Logger +} + +// WithHTTPClient fetches the JWKS with client instead of a default client with +// a 5 s timeout. +func WithHTTPClient(client *http.Client) Option { + return func(o *options) { o.client = client } +} + +// WithClock replaces [time.Now] for the token's time checks and the JWKS cache, +// for tests. +func WithClock(clock func() time.Time) Option { + return func(o *options) { o.clock = clock } +} + +// WithLogger is where Middleware logs refused tokens. Without it nothing is +// logged. +func WithLogger(logger *slog.Logger) Option { + return func(o *options) { o.logger = logger } +} + +// Verifier checks identity JWTs against guard's JWKS. It is safe for +// concurrent use; an app builds one at start and shares it. +type Verifier struct { + audiences map[string]struct{} + clock func() time.Time + logger *slog.Logger + keys *keyCache +} + +// New builds a Verifier. It never contacts guard: the JWKS is fetched when the +// first token arrives. +func New(cfg Config, opts ...Option) (*Verifier, error) { + o := options{ + client: &http.Client{Timeout: defaultFetchTimeout}, + clock: time.Now, + logger: slog.New(slog.DiscardHandler), + } + for _, opt := range opts { + opt(&o) + } + if cfg.JWKSURL == "" { + return nil, fmt.Errorf("%w: JWKSURL is empty", ErrConfig) + } + audiences := make(map[string]struct{}, len(cfg.Audiences)) + for _, aud := range cfg.Audiences { + aud = strings.ToLower(strings.TrimSpace(aud)) + if aud != "" { + audiences[aud] = struct{}{} + } + } + if len(audiences) == 0 { + return nil, fmt.Errorf("%w: no audiences: set the hosts this app serves", ErrConfig) + } + return &Verifier{ + audiences: audiences, + clock: o.clock, + logger: o.logger, + keys: newKeyCache(cfg.JWKSURL, o.client, o.clock), + }, nil +} + +// claims is the identity JWT's payload as guard signs it. +type claims struct { + Issuer string `json:"iss"` + Audience jwt.Audience `json:"aud"` + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Roles []string `json:"roles"` + SessionID string `json:"sid"` + AuthTime *jwt.NumericDate `json:"auth_time"` + ClientID string `json:"azp"` + Source string `json:"src"` + IssuedAt *jwt.NumericDate `json:"iat"` + Expiry *jwt.NumericDate `json:"exp"` + NotBefore *jwt.NumericDate `json:"nbf"` +} + +// Verify checks token and returns the identity it carries. The error wraps +// ErrInvalid or ErrUnavailable. +func (v *Verifier) Verify(ctx context.Context, token string) (Identity, error) { + if token == "" { + return Identity{}, fmt.Errorf("%w: empty", ErrInvalid) + } + tok, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.ES256}) + if err != nil { + return Identity{}, fmt.Errorf("%w: %w", ErrInvalid, err) + } + if len(tok.Headers) != 1 { + return Identity{}, fmt.Errorf("%w: want one signature", ErrInvalid) + } + header := tok.Headers[0] + if typ, _ := header.ExtraHeaders[jose.HeaderType].(string); normalizeType(typ) != normalizeType(TokenType) { + return Identity{}, fmt.Errorf("%w: typ is not %s", ErrInvalid, TokenType) + } + if header.KeyID == "" { + return Identity{}, fmt.Errorf("%w: no kid", ErrInvalid) + } + key, err := v.keys.key(ctx, header.KeyID) + if err != nil { + return Identity{}, err + } + var c claims + if err = tok.Claims(key, &c); err != nil { + return Identity{}, fmt.Errorf("%w: %w", ErrInvalid, err) + } + if err = v.check(&c); err != nil { + return Identity{}, err + } + return identityOf(&c), nil +} + +// check applies the claim rules to a payload whose signature verified. +func (v *Verifier) check(c *claims) error { + if c.Issuer != Issuer { + return fmt.Errorf("%w: iss is not %s", ErrInvalid, Issuer) + } + if !v.acceptsAudience(c.Audience) { + return fmt.Errorf("%w: aud is not a host this app serves", ErrInvalid) + } + if c.Subject == "" { + return fmt.Errorf("%w: no sub", ErrInvalid) + } + now := v.clock().Unix() + leeway := int64(Leeway / time.Second) + switch { + case c.Expiry == nil: + return fmt.Errorf("%w: no exp", ErrInvalid) + case int64(*c.Expiry) <= now-leeway: + return fmt.Errorf("%w: expired", ErrInvalid) + case c.IssuedAt == nil: + return fmt.Errorf("%w: no iat", ErrInvalid) + case int64(*c.IssuedAt) > now+leeway: + return fmt.Errorf("%w: issued in the future", ErrInvalid) + case c.NotBefore != nil && int64(*c.NotBefore) > now+leeway: + return fmt.Errorf("%w: not valid yet", ErrInvalid) + } + return nil +} + +func (v *Verifier) acceptsAudience(aud jwt.Audience) bool { + for _, a := range aud { + if _, ok := v.audiences[a]; ok { + return true + } + } + return false +} + +func identityOf(c *claims) Identity { + id := Identity{ + Subject: c.Subject, + Email: c.Email, + EmailVerified: c.EmailVerified, + Name: c.Name, + Roles: append([]string{}, c.Roles...), + SessionID: c.SessionID, + ClientID: c.ClientID, + Source: c.Source, + } + if c.AuthTime != nil && *c.AuthTime > 0 { + id.AuthTime = c.AuthTime.Time() + } + return id +} + +// normalizeType compares typ the way RFC 7515 section 4.1.9 and the jose +// library behind the TypeScript verifier do: case-insensitively, with the +// "application/" prefix optional. +func normalizeType(typ string) string { + typ = strings.ToLower(typ) + if strings.Contains(typ, "/") { + return typ + } + return "application/" + typ +} diff --git a/go.mod b/go.mod index aa4b86c..a056535 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/caarlos0/env/v11 v11.4.1 + github.com/go-jose/go-jose/v4 v4.1.7 github.com/nats-io/nats-server/v2 v2.15.1 github.com/nats-io/nats.go v1.54.0 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index 2f0dbc4..1ca887b 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.1.7 h1:Nac/98uT5LPkfbiFuYa8ktf62a2VxwQ4xbq1GK8pV5E= +github.com/go-jose/go-jose/v4 v4.1.7/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/package.json b/package.json new file mode 100644 index 0000000..e4935b7 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "@metsaapp/edgeauth", + "version": "0.2.0", + "private": false, + "description": "Verifies guard's identity JWT (x-bastion-identity); the TypeScript twin of github.com/MetsaApp/platform-kit/edgeauth.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/MetsaApp/platform-kit.git", + "directory": "ts/edgeauth" + }, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "bun": "./ts/edgeauth/src/index.ts", + "types": "./ts/edgeauth/dist/index.d.ts", + "default": "./ts/edgeauth/dist/index.js" + } + }, + "main": "./ts/edgeauth/dist/index.js", + "types": "./ts/edgeauth/dist/index.d.ts", + "files": [ + "ts/edgeauth/src/index.ts", + "ts/edgeauth/dist/index.js", + "ts/edgeauth/dist/index.d.ts" + ], + "engines": { + "node": ">=20.19" + }, + "dependencies": { + "jose": "^6.2.14" + } +} diff --git a/release-please-config.json b/release-please-config.json index 3be862c..2d8c701 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,7 +4,14 @@ "packages": { ".": { "release-type": "go", - "initial-version": "0.1.0" + "initial-version": "0.1.0", + "extra-files": [ + { + "type": "json", + "path": "package.json", + "jsonpath": "$.version" + } + ] } } } diff --git a/ts/edgeauth/dist/index.d.ts b/ts/edgeauth/dist/index.d.ts new file mode 100644 index 0000000..7b4228c --- /dev/null +++ b/ts/edgeauth/dist/index.d.ts @@ -0,0 +1,52 @@ +/** The header guard sends the identity JWT in. */ +export declare const HEADER = "x-bastion-identity"; +/** The iss of every identity JWT. */ +export declare const ISSUER = "bastion"; +/** The typ in the JOSE header of every identity JWT. */ +export declare const TOKEN_TYPE = "bastion-id+jwt"; +/** Guard's in-cluster JWKS. */ +export declare const DEFAULT_JWKS_URL = "http://guard.guard-production.svc.cluster.local:8082/jwks.json"; +/** Clock skew tolerated on exp, iat and nbf, in seconds. */ +export declare const LEEWAY_SECONDS = 10; +/** The caller guard vouches for. */ +export interface Identity { + /** The Zitadel user id. */ + subject: string; + email: string; + emailVerified: boolean; + name: string; + /** Role keys of the host's Zitadel project; empty when none. */ + roles: string[]; + /** Opaque session id; empty for a bearer. */ + sessionId: string; + /** When the user logged in at Zitadel; null when unknown. */ + authTime: Date | null; + /** The Zitadel client the session or bearer belongs to. */ + clientId: string; + /** "session" or "bearer". */ + source: string; +} +/** The token is present but not acceptable. Answer 401. */ +export declare class EdgeAuthInvalid extends Error { + constructor(message: string, options?: ErrorOptions); +} +/** The token could not be checked because guard's JWKS is unreachable. Answer 503. */ +export declare class EdgeAuthUnavailable extends Error { + constructor(message: string, options?: ErrorOptions); +} +export interface VerifierOptions { + /** Guard's JWKS. Default PLATFORM_KIT_EDGEAUTH_JWKS_URL, else the in-cluster endpoint. */ + jwksUrl?: string; + /** The hosts this app serves (lower case, no port). Required. */ + audiences: string[]; + /** The clock for the token's time checks, for tests. */ + now?: () => Date; +} +export interface Verifier { + /** Checks a token; rejects with EdgeAuthInvalid or EdgeAuthUnavailable. */ + verify(token: string): Promise; + /** Verifies the identity header; null when the request has none (anonymous). */ + fromHeaders(headers: Headers): Promise; +} +/** Builds a verifier. It never contacts guard until the first token. */ +export declare function createVerifier(options: VerifierOptions): Verifier; diff --git a/ts/edgeauth/dist/index.js b/ts/edgeauth/dist/index.js new file mode 100644 index 0000000..b437b3a --- /dev/null +++ b/ts/edgeauth/dist/index.js @@ -0,0 +1,176 @@ +/** + * Verifies the identity JWT guard hands every authenticated request upstream + * in the x-bastion-identity header. The same rules as the Go package + * github.com/MetsaApp/platform-kit/edgeauth, checked by the same conformance + * vectors (edgeauth/testdata/vectors.json): + * + * - alg ES256 and typ bastion-id+jwt, with a kid in guard's JWKS; + * - iss "bastion", aud one of the configured audiences; + * - exp not passed and iat not in the future, with 10 s leeway, and sub set. + * + * The JWKS is fetched on the first token, cached for 5 minutes, and refetched + * for an unknown kid at most once every 30 seconds. + */ +import { createRemoteJWKSet, errors, jwtVerify, } from "jose"; +/** The header guard sends the identity JWT in. */ +export const HEADER = "x-bastion-identity"; +/** The iss of every identity JWT. */ +export const ISSUER = "bastion"; +/** The typ in the JOSE header of every identity JWT. */ +export const TOKEN_TYPE = "bastion-id+jwt"; +/** Guard's in-cluster JWKS. */ +export const DEFAULT_JWKS_URL = "http://guard.guard-production.svc.cluster.local:8082/jwks.json"; +/** Clock skew tolerated on exp, iat and nbf, in seconds. */ +export const LEEWAY_SECONDS = 10; +const CACHE_MAX_AGE_MS = 5 * 60 * 1000; +const COOLDOWN_MS = 30 * 1000; +const FETCH_TIMEOUT_MS = 5 * 1000; +/** The token is present but not acceptable. Answer 401. */ +export class EdgeAuthInvalid extends Error { + constructor(message, options) { + super(`edgeauth: invalid identity token: ${message}`, options); + this.name = "EdgeAuthInvalid"; + } +} +/** The token could not be checked because guard's JWKS is unreachable. Answer 503. */ +export class EdgeAuthUnavailable extends Error { + constructor(message, options) { + super(`edgeauth: identity keys unavailable: ${message}`, options); + this.name = "EdgeAuthUnavailable"; + } +} +function envJwksUrl() { + const env = globalThis.process?.env; + return env?.PLATFORM_KIT_EDGEAUTH_JWKS_URL || undefined; +} +/** Same comparison as jose and RFC 7515: case-insensitive, "application/" optional. */ +function normalizeType(typ) { + const lower = typ.toLowerCase(); + return lower.includes("/") ? lower : `application/${lower}`; +} +function optionalString(payload, claim) { + const value = payload[claim]; + if (value === undefined || value === null) { + return ""; + } + if (typeof value !== "string") { + throw new EdgeAuthInvalid(`${claim} is not a string`); + } + return value; +} +function identityOf(payload) { + const verified = payload.email_verified; + if (verified !== undefined && + verified !== null && + typeof verified !== "boolean") { + throw new EdgeAuthInvalid("email_verified is not a boolean"); + } + const roles = payload.roles ?? []; + if (!Array.isArray(roles) || + !roles.every((r) => typeof r === "string")) { + throw new EdgeAuthInvalid("roles is not a list of strings"); + } + const authTime = payload.auth_time; + if (authTime !== undefined && + authTime !== null && + typeof authTime !== "number") { + throw new EdgeAuthInvalid("auth_time is not a number"); + } + return { + subject: optionalString(payload, "sub"), + email: optionalString(payload, "email"), + emailVerified: verified === true, + name: optionalString(payload, "name"), + roles: [...roles], + sessionId: optionalString(payload, "sid"), + authTime: typeof authTime === "number" && authTime > 0 + ? new Date(Math.trunc(authTime) * 1000) + : null, + clientId: optionalString(payload, "azp"), + source: optionalString(payload, "src"), + }; +} +/** Builds a verifier. It never contacts guard until the first token. */ +export function createVerifier(options) { + const audiences = options.audiences + .map((a) => a.trim().toLowerCase()) + .filter((a) => a !== ""); + if (audiences.length === 0) { + throw new Error("edgeauth: no audiences: set the hosts this app serves"); + } + const jwksUrl = options.jwksUrl ?? envJwksUrl() ?? DEFAULT_JWKS_URL; + const now = options.now ?? (() => new Date()); + const remote = createRemoteJWKSet(new URL(jwksUrl), { + cacheMaxAge: CACHE_MAX_AGE_MS, + cooldownDuration: COOLDOWN_MS, + timeoutDuration: FETCH_TIMEOUT_MS, + }); + // Header checks come before the key lookup, as in the Go verifier, so a + // malformed token never costs a JWKS fetch; fetch failures are told apart + // from an unknown key. + const getKey = async (header, token) => { + if (typeof header.typ !== "string" || + normalizeType(header.typ) !== normalizeType(TOKEN_TYPE)) { + throw new EdgeAuthInvalid(`typ is not ${TOKEN_TYPE}`); + } + if (typeof header.kid !== "string" || header.kid === "") { + throw new EdgeAuthInvalid("no kid"); + } + try { + return await remote(header, token); + } + catch (err) { + if (err instanceof errors.JWKSNoMatchingKey || + err instanceof errors.JWKSMultipleMatchingKeys) { + throw new EdgeAuthInvalid("unknown kid", { cause: err }); + } + throw new EdgeAuthUnavailable("JWKS fetch failed", { cause: err }); + } + }; + async function verify(token) { + if (token === "") { + throw new EdgeAuthInvalid("empty"); + } + const currentDate = now(); + let payload; + try { + ({ payload } = await jwtVerify(token, getKey, { + algorithms: ["ES256"], + typ: TOKEN_TYPE, + issuer: ISSUER, + audience: audiences, + clockTolerance: LEEWAY_SECONDS, + currentDate, + requiredClaims: ["sub", "exp", "iat"], + })); + } + catch (err) { + if (err instanceof EdgeAuthInvalid || err instanceof EdgeAuthUnavailable) { + throw err; + } + const reason = err instanceof errors.JOSEError ? err.code : "malformed"; + throw new EdgeAuthInvalid(reason, { cause: err }); + } + // jose checks iat only with maxTokenAge; the contract refuses a token + // issued in the future. + const nowSeconds = Math.floor(currentDate.getTime() / 1000); + if (typeof payload.iat !== "number" || + payload.iat > nowSeconds + LEEWAY_SECONDS) { + throw new EdgeAuthInvalid("issued in the future"); + } + if (typeof payload.sub !== "string" || payload.sub === "") { + throw new EdgeAuthInvalid("no sub"); + } + return identityOf(payload); + } + return { + verify, + async fromHeaders(headers) { + const token = headers.get(HEADER); + if (token === null || token === "") { + return null; + } + return verify(token); + }, + }; +} diff --git a/ts/edgeauth/package-lock.json b/ts/edgeauth/package-lock.json new file mode 100644 index 0000000..f474fb2 --- /dev/null +++ b/ts/edgeauth/package-lock.json @@ -0,0 +1,1163 @@ +{ + "name": "@metsaapp/edgeauth-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@metsaapp/edgeauth-dev", + "dependencies": { + "jose": "^6.2.14" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "typescript": "^5.9.3", + "vitest": "^5.0.5" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/jose": { + "version": "6.2.14", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz", + "integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/ts/edgeauth/package.json b/ts/edgeauth/package.json new file mode 100644 index 0000000..2fedb76 --- /dev/null +++ b/ts/edgeauth/package.json @@ -0,0 +1,20 @@ +{ + "name": "@metsaapp/edgeauth-dev", + "private": true, + "description": "Build and tests for the root package @metsaapp/edgeauth; keep dependencies.jose equal to the root package.json's.", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.test.json", + "test": "vitest run", + "check": "node scripts/check-deps.mjs" + }, + "dependencies": { + "jose": "^6.2.14" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "typescript": "^5.9.3", + "vitest": "^5.0.5" + } +} diff --git a/ts/edgeauth/scripts/check-deps.mjs b/ts/edgeauth/scripts/check-deps.mjs new file mode 100644 index 0000000..aa8f75b --- /dev/null +++ b/ts/edgeauth/scripts/check-deps.mjs @@ -0,0 +1,11 @@ +// The root package.json is what apps install; this package only builds and +// tests it. Their jose ranges must agree, or the tests run another jose. +import { readFileSync } from "node:fs"; + +const read = (path) => JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); +const root = read("../../../package.json").dependencies; +const dev = read("../package.json").dependencies; +if (JSON.stringify(root) !== JSON.stringify(dev)) { + console.error(`dependencies differ: root ${JSON.stringify(root)}, ts/edgeauth ${JSON.stringify(dev)}`); + process.exit(1); +} diff --git a/ts/edgeauth/src/index.ts b/ts/edgeauth/src/index.ts new file mode 100644 index 0000000..b411533 --- /dev/null +++ b/ts/edgeauth/src/index.ts @@ -0,0 +1,245 @@ +/** + * Verifies the identity JWT guard hands every authenticated request upstream + * in the x-bastion-identity header. The same rules as the Go package + * github.com/MetsaApp/platform-kit/edgeauth, checked by the same conformance + * vectors (edgeauth/testdata/vectors.json): + * + * - alg ES256 and typ bastion-id+jwt, with a kid in guard's JWKS; + * - iss "bastion", aud one of the configured audiences; + * - exp not passed and iat not in the future, with 10 s leeway, and sub set. + * + * The JWKS is fetched on the first token, cached for 5 minutes, and refetched + * for an unknown kid at most once every 30 seconds. + */ +import { + createRemoteJWKSet, + errors, + type JWTPayload, + type JWTVerifyGetKey, + jwtVerify, +} from "jose"; + +/** The header guard sends the identity JWT in. */ +export const HEADER = "x-bastion-identity"; +/** The iss of every identity JWT. */ +export const ISSUER = "bastion"; +/** The typ in the JOSE header of every identity JWT. */ +export const TOKEN_TYPE = "bastion-id+jwt"; +/** Guard's in-cluster JWKS. */ +export const DEFAULT_JWKS_URL = + "http://guard.guard-production.svc.cluster.local:8082/jwks.json"; +/** Clock skew tolerated on exp, iat and nbf, in seconds. */ +export const LEEWAY_SECONDS = 10; + +const CACHE_MAX_AGE_MS = 5 * 60 * 1000; +const COOLDOWN_MS = 30 * 1000; +const FETCH_TIMEOUT_MS = 5 * 1000; + +/** The caller guard vouches for. */ +export interface Identity { + /** The Zitadel user id. */ + subject: string; + email: string; + emailVerified: boolean; + name: string; + /** Role keys of the host's Zitadel project; empty when none. */ + roles: string[]; + /** Opaque session id; empty for a bearer. */ + sessionId: string; + /** When the user logged in at Zitadel; null when unknown. */ + authTime: Date | null; + /** The Zitadel client the session or bearer belongs to. */ + clientId: string; + /** "session" or "bearer". */ + source: string; +} + +/** The token is present but not acceptable. Answer 401. */ +export class EdgeAuthInvalid extends Error { + constructor(message: string, options?: ErrorOptions) { + super(`edgeauth: invalid identity token: ${message}`, options); + this.name = "EdgeAuthInvalid"; + } +} + +/** The token could not be checked because guard's JWKS is unreachable. Answer 503. */ +export class EdgeAuthUnavailable extends Error { + constructor(message: string, options?: ErrorOptions) { + super(`edgeauth: identity keys unavailable: ${message}`, options); + this.name = "EdgeAuthUnavailable"; + } +} + +export interface VerifierOptions { + /** Guard's JWKS. Default PLATFORM_KIT_EDGEAUTH_JWKS_URL, else the in-cluster endpoint. */ + jwksUrl?: string; + /** The hosts this app serves (lower case, no port). Required. */ + audiences: string[]; + /** The clock for the token's time checks, for tests. */ + now?: () => Date; +} + +export interface Verifier { + /** Checks a token; rejects with EdgeAuthInvalid or EdgeAuthUnavailable. */ + verify(token: string): Promise; + /** Verifies the identity header; null when the request has none (anonymous). */ + fromHeaders(headers: Headers): Promise; +} + +function envJwksUrl(): string | undefined { + const env = ( + globalThis as { process?: { env?: Record } } + ).process?.env; + return env?.PLATFORM_KIT_EDGEAUTH_JWKS_URL || undefined; +} + +/** Same comparison as jose and RFC 7515: case-insensitive, "application/" optional. */ +function normalizeType(typ: string): string { + const lower = typ.toLowerCase(); + return lower.includes("/") ? lower : `application/${lower}`; +} + +function optionalString(payload: JWTPayload, claim: string): string { + const value = payload[claim]; + if (value === undefined || value === null) { + return ""; + } + if (typeof value !== "string") { + throw new EdgeAuthInvalid(`${claim} is not a string`); + } + return value; +} + +function identityOf(payload: JWTPayload): Identity { + const verified = payload.email_verified; + if ( + verified !== undefined && + verified !== null && + typeof verified !== "boolean" + ) { + throw new EdgeAuthInvalid("email_verified is not a boolean"); + } + const roles = payload.roles ?? []; + if ( + !Array.isArray(roles) || + !roles.every((r): r is string => typeof r === "string") + ) { + throw new EdgeAuthInvalid("roles is not a list of strings"); + } + const authTime = payload.auth_time; + if ( + authTime !== undefined && + authTime !== null && + typeof authTime !== "number" + ) { + throw new EdgeAuthInvalid("auth_time is not a number"); + } + return { + subject: optionalString(payload, "sub"), + email: optionalString(payload, "email"), + emailVerified: verified === true, + name: optionalString(payload, "name"), + roles: [...roles], + sessionId: optionalString(payload, "sid"), + authTime: + typeof authTime === "number" && authTime > 0 + ? new Date(Math.trunc(authTime) * 1000) + : null, + clientId: optionalString(payload, "azp"), + source: optionalString(payload, "src"), + }; +} + +/** Builds a verifier. It never contacts guard until the first token. */ +export function createVerifier(options: VerifierOptions): Verifier { + const audiences = options.audiences + .map((a) => a.trim().toLowerCase()) + .filter((a) => a !== ""); + if (audiences.length === 0) { + throw new Error( + "edgeauth: no audiences: set the hosts this app serves", + ); + } + const jwksUrl = options.jwksUrl ?? envJwksUrl() ?? DEFAULT_JWKS_URL; + const now = options.now ?? (() => new Date()); + const remote = createRemoteJWKSet(new URL(jwksUrl), { + cacheMaxAge: CACHE_MAX_AGE_MS, + cooldownDuration: COOLDOWN_MS, + timeoutDuration: FETCH_TIMEOUT_MS, + }); + + // Header checks come before the key lookup, as in the Go verifier, so a + // malformed token never costs a JWKS fetch; fetch failures are told apart + // from an unknown key. + const getKey: JWTVerifyGetKey = async (header, token) => { + if ( + typeof header.typ !== "string" || + normalizeType(header.typ) !== normalizeType(TOKEN_TYPE) + ) { + throw new EdgeAuthInvalid(`typ is not ${TOKEN_TYPE}`); + } + if (typeof header.kid !== "string" || header.kid === "") { + throw new EdgeAuthInvalid("no kid"); + } + try { + return await remote(header, token); + } catch (err) { + if ( + err instanceof errors.JWKSNoMatchingKey || + err instanceof errors.JWKSMultipleMatchingKeys + ) { + throw new EdgeAuthInvalid("unknown kid", { cause: err }); + } + throw new EdgeAuthUnavailable("JWKS fetch failed", { cause: err }); + } + }; + + async function verify(token: string): Promise { + if (token === "") { + throw new EdgeAuthInvalid("empty"); + } + const currentDate = now(); + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(token, getKey, { + algorithms: ["ES256"], + typ: TOKEN_TYPE, + issuer: ISSUER, + audience: audiences, + clockTolerance: LEEWAY_SECONDS, + currentDate, + requiredClaims: ["sub", "exp", "iat"], + })); + } catch (err) { + if (err instanceof EdgeAuthInvalid || err instanceof EdgeAuthUnavailable) { + throw err; + } + const reason = err instanceof errors.JOSEError ? err.code : "malformed"; + throw new EdgeAuthInvalid(reason, { cause: err }); + } + // jose checks iat only with maxTokenAge; the contract refuses a token + // issued in the future. + const nowSeconds = Math.floor(currentDate.getTime() / 1000); + if ( + typeof payload.iat !== "number" || + payload.iat > nowSeconds + LEEWAY_SECONDS + ) { + throw new EdgeAuthInvalid("issued in the future"); + } + if (typeof payload.sub !== "string" || payload.sub === "") { + throw new EdgeAuthInvalid("no sub"); + } + return identityOf(payload); + } + + return { + verify, + async fromHeaders(headers: Headers): Promise { + const token = headers.get(HEADER); + if (token === null || token === "") { + return null; + } + return verify(token); + }, + }; +} diff --git a/ts/edgeauth/test/vectors.test.ts b/ts/edgeauth/test/vectors.test.ts new file mode 100644 index 0000000..ac7aacd --- /dev/null +++ b/ts/edgeauth/test/vectors.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createVerifier, + EdgeAuthInvalid, + EdgeAuthUnavailable, + HEADER, + type Identity, +} from "../src/index.ts"; + +interface Want { + ok: boolean; + error?: "invalid" | "unavailable"; + subject?: string; + email?: string; + emailVerified?: boolean; + name?: string; + roles?: string[]; + sessionId?: string; + authTime?: number; + clientId?: string; + source?: string; +} + +interface Vectors { + now: number; + jwks: unknown; + cases: { + name: string; + token: string; + audiences: string[]; + jwksUnreachable?: boolean; + want: Want; + }[]; +} + +const vectors: Vectors = JSON.parse( + readFileSync( + new URL("../../../edgeauth/testdata/vectors.json", import.meta.url), + "utf8", + ), +); +const now = () => new Date(vectors.now * 1000); + +let server: Server; +let jwksUrl: string; +let fetches = 0; +// A port nothing listens on: a server bound and closed again. +let downUrl: string; + +function listen(s: Server): Promise { + return new Promise((resolve) => { + s.listen(0, "127.0.0.1", () => { + const { port } = s.address() as AddressInfo; + resolve(`http://127.0.0.1:${port}/jwks.json`); + }); + }); +} + +beforeAll(async () => { + server = createServer((_req, res) => { + fetches++; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(vectors.jwks)); + }); + jwksUrl = await listen(server); + const closed = createServer(); + downUrl = await listen(closed); + await new Promise((resolve) => closed.close(resolve)); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +function wantIdentity(w: Want): Identity { + return { + subject: w.subject ?? "", + email: w.email ?? "", + emailVerified: w.emailVerified ?? false, + name: w.name ?? "", + roles: w.roles ?? [], + sessionId: w.sessionId ?? "", + authTime: w.authTime ? new Date(w.authTime * 1000) : null, + clientId: w.clientId ?? "", + source: w.source ?? "", + }; +} + +describe("conformance vectors", () => { + it("has cases", () => { + expect(vectors.cases.length).toBeGreaterThan(20); + }); + + for (const c of vectors.cases) { + it(c.name, async () => { + const v = createVerifier({ + jwksUrl: c.jwksUnreachable ? downUrl : jwksUrl, + audiences: c.audiences, + now, + }); + const result = v.verify(c.token); + if (c.want.error === "invalid") { + await expect(result).rejects.toBeInstanceOf(EdgeAuthInvalid); + } else if (c.want.error === "unavailable") { + await expect(result).rejects.toBeInstanceOf(EdgeAuthUnavailable); + } else { + expect(await result).toEqual(wantIdentity(c.want)); + } + }); + } +}); + +describe("verifier", () => { + const valid = vectors.cases.find((c) => c.name === "valid session"); + if (!valid) { + throw new Error("no valid session vector"); + } + + it("needs audiences", () => { + expect(() => createVerifier({ audiences: [] })).toThrow(/audiences/); + }); + + it("does not fetch at construction and caches the JWKS", async () => { + const before = fetches; + const v = createVerifier({ jwksUrl, audiences: valid.audiences, now }); + expect(fetches).toBe(before); + await v.verify(valid.token); + await v.verify(valid.token); + expect(fetches).toBe(before + 1); + }); + + it("treats a request without the header as anonymous", async () => { + const v = createVerifier({ jwksUrl, audiences: valid.audiences, now }); + expect(await v.fromHeaders(new Headers())).toBeNull(); + const id = await v.fromHeaders(new Headers({ [HEADER]: valid.token })); + expect(id?.subject).toBe(valid.want.subject); + await expect( + v.fromHeaders(new Headers({ [HEADER]: "garbage" })), + ).rejects.toBeInstanceOf(EdgeAuthInvalid); + }); + + it("never puts the token in an error message", async () => { + const v = createVerifier({ jwksUrl, audiences: ["other.metsa.app"], now }); + await expect(v.verify(valid.token)).rejects.toSatisfy( + (err: Error) => !err.message.includes(valid.token), + ); + }); +}); diff --git a/ts/edgeauth/tsconfig.json b/ts/edgeauth/tsconfig.json new file mode 100644 index 0000000..806f697 --- /dev/null +++ b/ts/edgeauth/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "declaration": true, + "rootDir": "src", + "outDir": "dist", + "skipLibCheck": true, + "types": [] + }, + "include": ["src"] +} diff --git a/ts/edgeauth/tsconfig.test.json b/ts/edgeauth/tsconfig.test.json new file mode 100644 index 0000000..2209907 --- /dev/null +++ b/ts/edgeauth/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/ts/go.mod b/ts/go.mod new file mode 100644 index 0000000..91cc0e0 --- /dev/null +++ b/ts/go.mod @@ -0,0 +1,6 @@ +// Not Go. This stub makes ts/ a module of its own, so the platform-kit +// module's ./... patterns, golangci-lint and the module zip skip the +// TypeScript sources and their node_modules. +module github.com/MetsaApp/platform-kit/ts + +go 1.26.0