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