diff --git a/.github/workflows/proxy-auth-lib-release.yml b/.github/workflows/proxy-auth-lib-release.yml new file mode 100644 index 00000000..2d27717f --- /dev/null +++ b/.github/workflows/proxy-auth-lib-release.yml @@ -0,0 +1,125 @@ +name: proxy-auth-lib release + +# Publishes the proxy-auth-lib packages to every registry when a tag of the +# form proxy-auth-lib-v is pushed (e.g. proxy-auth-lib-v1.2.3). +# +# Each registry is an independent job so one failing publish never blocks the +# others. Every job derives the same version from the tag and runs the matching +# script in proxy-auth-lib/scripts/, which are the exact commands a maintainer +# can run locally. + +on: + push: + tags: + - 'proxy-auth-lib-v*' + +permissions: + contents: read + +jobs: + npm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: ./proxy-auth-lib/scripts/publish-npm.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}" + + jsr: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Publish to JSR + run: ./proxy-auth-lib/scripts/publish-jsr.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}" + + pypi: + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Build distribution + run: ./proxy-auth-lib/scripts/build-pypi.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}" + - name: Publish to PyPI (Trusted Publishing) + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: proxy-auth-lib/python/dist + + crates: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: ./proxy-auth-lib/scripts/publish-crates.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}" + + meteor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install Meteor + run: | + curl -fsSL https://install.meteor.com/ | sh + echo "$HOME/.meteor" >> "$GITHUB_PATH" + - name: Publish to Atmosphere + env: + METEOR_SESSION_FILE: ${{ runner.temp }}/meteor-session + run: | + printf '%s' "${{ secrets.METEOR_SESSION }}" > "$METEOR_SESSION_FILE" + ./proxy-auth-lib/scripts/publish-meteor.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}" + + go: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Tag and index the Go module + run: ./proxy-auth-lib/scripts/publish-go.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}" + + smoke-runtimes: + runs-on: ubuntu-latest + needs: npm + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Smoke test Bun and Deno + run: ./proxy-auth-lib/scripts/smoke-runtimes.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}" diff --git a/.gitignore b/.gitignore index d650d1a5..6b7cc7e1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules .env .tmp-verify/ .playwright-mcp/ +__pycache__/ +*.pyc +target/ # packaging build artifacts /dist/ diff --git a/README.md b/README.md index de6b424c..19a7e8c4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Full documentation lives in [`mie-opensource-landing/docs/`](mie-opensource-land | [`pull-config/`](pull-config/) | Cron-driven config distribution for nginx and dnsmasq on agents — see [pull-config docs](mie-opensource-landing/docs/developers/pull-config.md) | | [`images/`](images/) | Docker Bake definitions for the `base`, `nodejs`, `agent`, and `manager` images — see [Docker Images](mie-opensource-landing/docs/developers/docker-images.md) | | [`manager-control-program/`](manager-control-program/) | MCP server for AI-assisted container management — see [MCP Server](mie-opensource-landing/docs/users/mcp-server.md) | +| [`proxy-auth-lib/`](proxy-auth-lib/) | Vendor-neutral trusted proxy identity middleware examples for Node.js, Python, Rust, and Go | | [`mie-opensource-landing/`](mie-opensource-landing/) | Documentation site source | | [`error-pages/`](error-pages/) | Static error pages served by NGINX | | [`compose.yml`](compose.yml) | Local development stack (used by the Development Workflow guide) | diff --git a/create-a-container/.gitignore b/create-a-container/.gitignore index ca7e5ca8..8004e5f6 100644 --- a/create-a-container/.gitignore +++ b/create-a-container/.gitignore @@ -7,3 +7,7 @@ data/ /*.deb /*.rpm /*.apk + +# Playwright test artifacts +/playwright-report/ +/test-results/ diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index c9b2ffd5..2d755720 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -168,5 +168,7 @@ sequenceDiagram NGINX captures identity headers from the auth server subrequest (`X-User-ID`, `X-Username`, `X-User-First-Name`, `X-User-Last-Name`, `X-Email`, `X-Groups`) and forwards them to the backend container via `proxy_set_header`. +Backends should treat those raw headers as untrusted unless the auth layer also provides a signed assertion that the application verifies against JWKS, issuer, audience, and expiration checks. See [Trusted Proxy Auth Libraries](trusted-proxy-auth.md). + If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page. diff --git a/mie-opensource-landing/docs/developers/trusted-proxy-auth.md b/mie-opensource-landing/docs/developers/trusted-proxy-auth.md new file mode 100644 index 00000000..1e662a4d --- /dev/null +++ b/mie-opensource-landing/docs/developers/trusted-proxy-auth.md @@ -0,0 +1,88 @@ +# Trusted proxy auth libraries + +`proxy-auth-lib/` contains vendor-neutral middleware examples for applications that sit behind an identity-aware proxy or access gateway. + +## Problem boundary + +The middleware validates a signed JWT/JWS assertion from a trusted upstream component. It does not implement login flows, browser sessions, or authorization policy. + +## Trust model + +```mermaid +flowchart TD + User[User] --> Proxy[Identity-aware proxy or gateway] + Proxy -->|signed assertion header| App[Application middleware] + App -->|verified identity| Handler[Application handler] + + classDef trusted fill:#e8f5e9,stroke:#1b5e20 + classDef edge fill:#fff3e0,stroke:#e65100 + + class Proxy,App,Handler trusted + class User edge +``` + +Never trust raw identity headers such as `X-Forwarded-User`, `X-User`, `X-Email`, or `Remote-User` without cryptographic verification. The examples in this repository require a signed assertion, a JWKS, an expected issuer, an expected audience, and a valid expiration time. + +## Shared configuration + +Every setting is optional. By default the auth domain is derived from the host's FQDN (`web1.os.example.org` → `auth.os.example.org`); the issuer and JWKS URL come from that domain. Override any single value with its own variable. + +| Variable | Default | Purpose | +|---|---|---| +| `TRUSTED_PROXY_AUTH_DOMAIN` | `auth.` | Base domain used to derive the issuer and JWKS URL | +| `TRUSTED_PROXY_ASSERTION_HEADER` | `X-Trusted-Proxy-Assertion` | Header containing the signed assertion | +| `TRUSTED_PROXY_JWKS_URL` | `https:///.well-known/jwks.json` | JWKS URL for key discovery | +| `TRUSTED_PROXY_ISSUER` | `https://` | Expected issuer | +| `TRUSTED_PROXY_AUDIENCE` | `https://` | Expected audience | +| `TRUSTED_PROXY_PUBLIC_KEY` | _(unset)_ | Inline PEM public key; skips JWKS and verifies offline | +| `TRUSTED_PROXY_PUBLIC_KEY_FILE` | _(unset)_ | Path to a PEM public key (alternative to the inline form) | + +### Key source: JWKS vs static public key + +JWKS is preferred and is the default: it supports key rotation and is what OIDC providers (Authentik, Cloudflare Access, Pomerium) publish. A static public key is an opt-in alternative for the self-signed case where your own proxy mints assertions with a key you control. When `TRUSTED_PROXY_PUBLIC_KEY` (or `_FILE`) is set, verification uses it directly and never calls the network; otherwise the JWKS URL is used. Either way the algorithm is pinned to `RS256`. + +## Implemented MVP targets + +| Language | Framework target | Package path | +|---|---|---| +| Node.js | Express-style middleware | `proxy-auth-lib/nodejs/` | +| Python | ASGI middleware for FastAPI / Starlette-style apps | `proxy-auth-lib/python/` | +| Rust | Axum middleware | `proxy-auth-lib/rust/` | +| Go | `net/http` middleware | `proxy-auth-lib/go/` | + +## Vendor-neutral examples + +Cloudflare Access, Pomerium, OAuth2 Proxy, Envoy `ext_authz`, Traefik ForwardAuth, NGINX `auth_request`, and custom gateways all fit the same pattern when they can forward a signed assertion. The application should trust the signature and claims, not the proxy brand. + +## Verified identity examples + +### Node.js + +```js +app.use(createTrustedProxyAuth(loadConfigFromEnv())); +app.get('/me', (req, res) => res.json(req.trustedProxyIdentity)); +``` + +### Python + +```python +app = TrustedProxyAuthMiddleware(app, load_config_from_env()) +# request.scope["trusted_proxy_identity"] inside the downstream app +``` + +### Rust + +```rust +let app = Router::new() + .route("/me", get(handler)) + .layer(middleware::from_fn_with_state(auth.clone(), axum_middleware)); +``` + +### Go + +```go +mux.Handle("/me", auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + identity, _ := trustedproxyauth.IdentityFromContext(r.Context()) + json.NewEncoder(w).Encode(identity) +}))) +``` diff --git a/mie-opensource-landing/zensical.toml b/mie-opensource-landing/zensical.toml index 261e1c18..9951b284 100644 --- a/mie-opensource-landing/zensical.toml +++ b/mie-opensource-landing/zensical.toml @@ -53,6 +53,7 @@ nav = [ { "Docker Images" = "developers/docker-images.md" }, { "Release Pipeline" = "developers/release-pipeline.md" }, { "Database Schema" = "developers/database-schema.md" }, + { "Trusted Proxy Auth" = "developers/trusted-proxy-auth.md" }, { "Pull Config" = "developers/pull-config.md" }, { "Contributing" = "developers/contributing.md" }, ] }, diff --git a/proxy-auth-lib/README.md b/proxy-auth-lib/README.md new file mode 100644 index 00000000..3fd7c1f9 --- /dev/null +++ b/proxy-auth-lib/README.md @@ -0,0 +1,80 @@ +# Trusted proxy auth libraries + +Vendor-neutral JWT/JWKS middleware for applications that sit behind an identity-aware proxy, ingress, or access gateway. + +## Layout + +| Path | Target | +|---|---| +| `nodejs/` | Express, Fastify, and Hono adapters | +| `meteor/accounts-proxy-auth/` | `mieweb:accounts-proxy-auth` Atmosphere package (accounts-base login) | +| `python/` | ASGI (FastAPI/Starlette), WSGI (Flask), and Django middleware | +| `rust/` | Axum middleware | +| `go/` | `net/http` middleware (drops into Chi as-is) | +| `testdata/` | Shared JWKS and JWT fixtures used by all language tests | + +### Node.js entry points + +| Import | Adapter | +|---|---| +| `@mieweb/trusted-proxy-auth/express` | `createTrustedProxyAuth(config)` → `(req, res, next)` | +| `@mieweb/trusted-proxy-auth/fastify` | `fastifyTrustedProxyAuth(config)` → `preHandler` hook | +| `@mieweb/trusted-proxy-auth/hono` | `honoTrustedProxyAuth(config)` → `(c, next)` | + +### Meteor + +`meteor add mieweb:accounts-proxy-auth` wires the verification into +`accounts-base`: the server validates the assertion on page load and logs the +user in via a one-time login token. See +[meteor/accounts-proxy-auth](meteor/accounts-proxy-auth/README.md). + +### Python entry points + +| Import | Adapter | +|---|---| +| `trusted_proxy_auth.TrustedProxyAuthMiddleware` | ASGI (FastAPI/Starlette) | +| `trusted_proxy_auth.flask.TrustedProxyAuthMiddleware` | WSGI (`app.wsgi_app = ...`) | +| `trusted_proxy_auth.django.TrustedProxyAuthMiddleware` | Django `MIDDLEWARE` entry | + +The verified identity exposes `subject`, `email`, `name`, and the raw `claims`. + +## Shared configuration + +Every setting is optional. By default the auth domain is derived from the host's +FQDN (`web1.os.example.org` → `auth.os.example.org`), and the issuer and JWKS URL +are derived from that domain. Override any single value with its own variable. + +| Variable | Default | Purpose | +|---|---|---| +| `TRUSTED_PROXY_AUTH_DOMAIN` | `auth.` | Base domain used to derive the issuer and JWKS URL | +| `TRUSTED_PROXY_ASSERTION_HEADER` | `X-Trusted-Proxy-Assertion` | Header containing the signed identity assertion | +| `TRUSTED_PROXY_JWKS_URL` | `https:///.well-known/jwks.json` | JWKS URL used to resolve signing keys | +| `TRUSTED_PROXY_ISSUER` | `https://` | Expected JWT issuer | +| `TRUSTED_PROXY_AUDIENCE` | `https://` | Expected JWT audience | +| `TRUSTED_PROXY_PUBLIC_KEY` | _(unset)_ | Inline PEM public key; skips JWKS and verifies offline | +| `TRUSTED_PROXY_PUBLIC_KEY_FILE` | _(unset)_ | Path to a PEM public key (alternative to the inline form) | + +Set `TRUSTED_PROXY_AUTH_DOMAIN` explicitly in any environment where the host +FQDN does not match the auth domain, and set `TRUSTED_PROXY_AUDIENCE` to a +per-application value if you want tokens scoped to one service. + +### Key source: JWKS vs static public key + +JWKS is preferred and is the default: it supports key rotation and is what +OIDC providers (Authentik, Cloudflare Access, Pomerium) publish. A static +public key is an opt-in alternative for the self-signed case where your own +proxy mints assertions with a key you control. When `TRUSTED_PROXY_PUBLIC_KEY` +(or `_FILE`) is set, verification uses it directly and never calls the network; +otherwise the JWKS URL is used. Either way the algorithm is pinned to `RS256`. + +## Security boundary + +Do not trust raw identity headers such as `X-Forwarded-User`, `X-User`, `X-Email`, or `Remote-User` on their own. A backend should trust only a signed assertion that it verifies against a trusted JWKS, issuer, audience, and expiration policy. + +## Proxy pattern + +The same middleware works when the upstream component is Cloudflare Access, Pomerium, OAuth2 Proxy, Envoy `ext_authz`, Traefik ForwardAuth, NGINX `auth_request`, or a custom gateway. The application trusts the signature, not the proxy brand. + +## Releasing + +Pushing a `proxy-auth-lib-v` tag (e.g. `proxy-auth-lib-v1.2.3`) runs [`.github/workflows/proxy-auth-lib-release.yml`](../.github/workflows/proxy-auth-lib-release.yml), which stamps that one version into every manifest and publishes each package to its registry — npm, JSR, PyPI, crates.io, Atmosphere (Meteor), and the Go module proxy — as independent jobs. The same commands run locally from [`scripts/`](scripts/README.md). diff --git a/proxy-auth-lib/go/auth.go b/proxy-auth-lib/go/auth.go new file mode 100644 index 00000000..18852766 --- /dev/null +++ b/proxy-auth-lib/go/auth.go @@ -0,0 +1,313 @@ +package trustedproxyauth + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "net/http" + "os" + "strings" + "time" +) + +type Config struct { + Header string + JWKSURL string + Issuer string + Audience string + PublicKey string + HTTPClient *http.Client +} + +type Identity struct { + Subject string + Email string + Name string + Claims map[string]any +} + +type contextKey struct{} + +type Authenticator struct { + config Config + httpClient *http.Client + publicKey *rsa.PublicKey +} + +func New(config Config) (*Authenticator, error) { + if config.Header == "" { + return nil, errors.New("missing config: header") + } + if config.Issuer == "" { + return nil, errors.New("missing config: issuer") + } + if config.Audience == "" { + return nil, errors.New("missing config: audience") + } + if config.PublicKey == "" && config.JWKSURL == "" { + return nil, errors.New("missing config: jwks url or public key") + } + client := config.HTTPClient + if client == nil { + client = http.DefaultClient + } + auth := &Authenticator{config: config, httpClient: client} + if config.PublicKey != "" { + key, err := parseRSAPublicKey(config.PublicKey) + if err != nil { + return nil, err + } + auth.publicKey = key + } + return auth, nil +} + +// Reasonable defaults so every setting is optional. The auth domain is derived +// from the host's FQDN (`web1.os.example.org` -> `auth.os.example.org`); issuer +// and JWKS come from it. Override any single value with its own env var. +const DefaultAssertionHeader = "X-Trusted-Proxy-Assertion" + +func ConfigFromEnv(getenv func(string) string) Config { + domain := getenv("TRUSTED_PROXY_AUTH_DOMAIN") + if domain == "" { + host, _ := os.Hostname() + domain = deriveAuthDomain(host) + } + base := "https://" + domain + return Config{ + Header: firstNonEmpty(getenv("TRUSTED_PROXY_ASSERTION_HEADER"), DefaultAssertionHeader), + JWKSURL: firstNonEmpty(getenv("TRUSTED_PROXY_JWKS_URL"), base+"/.well-known/jwks.json"), + Issuer: firstNonEmpty(getenv("TRUSTED_PROXY_ISSUER"), base), + Audience: firstNonEmpty(getenv("TRUSTED_PROXY_AUDIENCE"), base), + PublicKey: resolvePublicKey(getenv), + } +} + +// JWKS is preferred for key rotation. A static public key (PEM) is an opt-in +// alternative for self-signed assertions: when set, verification uses it +// directly and never touches the network. +func resolvePublicKey(getenv func(string) string) string { + if inline := getenv("TRUSTED_PROXY_PUBLIC_KEY"); inline != "" { + return inline + } + if path := getenv("TRUSTED_PROXY_PUBLIC_KEY_FILE"); path != "" { + if data, err := os.ReadFile(path); err == nil { + return string(data) + } + } + return "" +} + +func parseRSAPublicKey(pemData string) (*rsa.PublicKey, error) { + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return nil, errors.New("invalid public key pem") + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + key, ok := parsed.(*rsa.PublicKey) + if !ok { + return nil, errors.New("public key is not rsa") + } + return key, nil +} + +func deriveAuthDomain(hostname string) string { + labels := make([]string, 0) + for _, label := range strings.Split(hostname, ".") { + if label != "" { + labels = append(labels, label) + } + } + switch { + case len(labels) > 1: + return "auth." + strings.Join(labels[1:], ".") + case len(labels) == 1: + return "auth." + labels[0] + default: + return "auth.localhost" + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func IdentityFromContext(ctx context.Context) (Identity, bool) { + identity, ok := ctx.Value(contextKey{}).(Identity) + return identity, ok +} + +func (a *Authenticator) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.Header.Get(a.config.Header)) + if token == "" { + unauthorized(w) + return + } + + identity, err := a.Verify(r.Context(), token) + if err != nil { + unauthorized(w) + return + } + + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, identity))) + }) +} + +func (a *Authenticator) Verify(ctx context.Context, token string) (Identity, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return Identity{}, errors.New("malformed assertion") + } + + var header struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + } + if err := decodeSegment(parts[0], &header); err != nil { + return Identity{}, err + } + if header.Alg != "RS256" { + return Identity{}, errors.New("unsupported assertion") + } + + var claims map[string]any + if err := decodeSegment(parts[1], &claims); err != nil { + return Identity{}, err + } + + key, err := a.keyForToken(ctx, header.Kid) + if err != nil { + return Identity{}, err + } + + hasher := sha256.New() + hasher.Write([]byte(parts[0] + "." + parts[1])) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return Identity{}, err + } + if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, hasher.Sum(nil), signature); err != nil { + return Identity{}, err + } + + if claims["iss"] != a.config.Issuer { + return Identity{}, errors.New("wrong issuer") + } + if !matchesAudience(claims["aud"], a.config.Audience) { + return Identity{}, errors.New("wrong audience") + } + exp, ok := claims["exp"].(float64) + if !ok || int64(exp) <= time.Now().Unix() { + return Identity{}, errors.New("expired assertion") + } + subject, _ := claims["sub"].(string) + if subject == "" { + return Identity{}, errors.New("missing subject") + } + + email, _ := claims["email"].(string) + name, _ := claims["name"].(string) + return Identity{Subject: subject, Email: email, Name: name, Claims: claims}, nil +} + +func (a *Authenticator) keyForToken(ctx context.Context, kid string) (*rsa.PublicKey, error) { + if a.publicKey != nil { + return a.publicKey, nil + } + if kid == "" { + return nil, errors.New("unsupported assertion") + } + return a.lookupKey(ctx, kid) +} + +func (a *Authenticator) lookupKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.config.JWKSURL, nil) + if err != nil { + return nil, err + } + res, err := a.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, errors.New("failed to fetch jwks") + } + + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err := json.NewDecoder(res.Body).Decode(&jwks); err != nil { + return nil, err + } + for _, key := range jwks.Keys { + if key.Kid == kid { + return rsaFromJWK(key.N, key.E) + } + } + return nil, errors.New("unknown signing key") +} + +func rsaFromJWK(modulus, exponent string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(modulus) + if err != nil { + return nil, err + } + eBytes, err := base64.RawURLEncoding.DecodeString(exponent) + if err != nil { + return nil, err + } + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + }, nil +} + +func decodeSegment(segment string, target any) error { + decoded, err := base64.RawURLEncoding.DecodeString(segment) + if err != nil { + return err + } + return json.Unmarshal(decoded, target) +} + +func matchesAudience(actual any, expected string) bool { + switch value := actual.(type) { + case string: + return value == expected + case []any: + for _, candidate := range value { + if text, ok := candidate.(string); ok && text == expected { + return true + } + } + } + return false +} + +func unauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_assertion"}`)) +} diff --git a/proxy-auth-lib/go/auth_test.go b/proxy-auth-lib/go/auth_test.go new file mode 100644 index 00000000..eed65408 --- /dev/null +++ b/proxy-auth-lib/go/auth_test.go @@ -0,0 +1,190 @@ +package trustedproxyauth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func loadFixtures(t *testing.T) (map[string]string, []byte) { + t.Helper() + root := filepath.Join("..", "testdata") + tokensData, err := os.ReadFile(filepath.Join(root, "tokens.json")) + if err != nil { + t.Fatal(err) + } + jwksData, err := os.ReadFile(filepath.Join(root, "jwks.json")) + if err != nil { + t.Fatal(err) + } + var tokens map[string]string + if err := json.Unmarshal(tokensData, &tokens); err != nil { + t.Fatal(err) + } + return tokens, jwksData +} + +func newAuthenticator(t *testing.T) (*Authenticator, map[string]string, func()) { + t.Helper() + tokens, jwksData := loadFixtures(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/jwks.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksData) + })) + auth, err := New(Config{ + Header: "X-Trusted-Proxy-Assertion", + JWKSURL: server.URL + "/jwks.json", + Issuer: "https://issuer.example.test", + Audience: "my-service", + }) + if err != nil { + t.Fatal(err) + } + return auth, tokens, server.Close +} + +func TestVerifyCoversFixtureCases(t *testing.T) { + auth, tokens, closeServer := newAuthenticator(t) + defer closeServer() + + identity, err := auth.Verify(context.Background(), tokens["valid"]) + if err != nil { + t.Fatalf("verify valid token: %v", err) + } + if identity.Subject != "user-123" { + t.Fatalf("got subject %q", identity.Subject) + } + + for _, name := range []string{"expired", "invalid_signature", "wrong_issuer", "wrong_audience", "malformed"} { + t.Run(name, func(t *testing.T) { + if _, err := auth.Verify(context.Background(), tokens[name]); err == nil { + t.Fatalf("expected %s to fail", name) + } + }) + } +} + +func TestMiddlewareRejectsMissingAndAcceptsValidAssertions(t *testing.T) { + auth, tokens, closeServer := newAuthenticator(t) + defer closeServer() + + handler := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + identity, ok := IdentityFromContext(r.Context()) + if !ok { + t.Fatal("missing identity in request context") + } + _, _ = w.Write([]byte("ok:" + identity.Subject)) + })) + + validRequest := httptest.NewRequest(http.MethodGet, "/", nil) + validRequest.Header.Set("X-Trusted-Proxy-Assertion", tokens["valid"]) + validRecorder := httptest.NewRecorder() + handler.ServeHTTP(validRecorder, validRequest) + if validRecorder.Code != http.StatusOK { + t.Fatalf("got status %d", validRecorder.Code) + } + if body := validRecorder.Body.String(); body != "ok:user-123" { + t.Fatalf("got body %q", body) + } + + for _, name := range []string{"missing", "expired", "invalid_signature", "wrong_issuer", "wrong_audience", "malformed"} { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/", nil) + if name != "missing" { + request.Header.Set("X-Trusted-Proxy-Assertion", tokens[name]) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("got status %d", recorder.Code) + } + }) + } +} + +func TestConfigFromEnvUsesSharedNames(t *testing.T) { + config := ConfigFromEnv(func(key string) string { + values := map[string]string{ + "TRUSTED_PROXY_ASSERTION_HEADER": "X-Test", + "TRUSTED_PROXY_JWKS_URL": "https://issuer.example.test/jwks.json", + "TRUSTED_PROXY_ISSUER": "https://issuer.example.test", + "TRUSTED_PROXY_AUDIENCE": "my-service", + } + return values[key] + }) + if config.Header != "X-Test" || config.Audience != "my-service" { + t.Fatalf("unexpected config: %+v", config) + } +} + +func TestDeriveAuthDomainFromHostFQDN(t *testing.T) { + if got := deriveAuthDomain("web1.os.example.org"); got != "auth.os.example.org" { + t.Fatalf("got %q", got) + } + if got := deriveAuthDomain("host"); got != "auth.host" { + t.Fatalf("got %q", got) + } +} + +func TestConfigFromEnvAuthDomainOverride(t *testing.T) { + config := ConfigFromEnv(func(key string) string { + if key == "TRUSTED_PROXY_AUTH_DOMAIN" { + return "auth.example.test" + } + return "" + }) + if config.Issuer != "https://auth.example.test" { + t.Fatalf("got issuer %q", config.Issuer) + } + if config.JWKSURL != "https://auth.example.test/.well-known/jwks.json" { + t.Fatalf("got jwks %q", config.JWKSURL) + } +} + +func TestVerifyWithStaticPublicKey(t *testing.T) { + tokens, _ := loadFixtures(t) + pemData, err := os.ReadFile(filepath.Join("..", "testdata", "public-key.pem")) + if err != nil { + t.Fatal(err) + } + auth, err := New(Config{ + Header: "X-Trusted-Proxy-Assertion", + PublicKey: string(pemData), + Issuer: "https://issuer.example.test", + Audience: "my-service", + }) + if err != nil { + t.Fatal(err) + } + + identity, err := auth.Verify(context.Background(), tokens["valid"]) + if err != nil { + t.Fatalf("verify valid token: %v", err) + } + if identity.Subject != "user-123" { + t.Fatalf("got subject %q", identity.Subject) + } + if _, err := auth.Verify(context.Background(), tokens["invalid_signature"]); err == nil { + t.Fatal("expected invalid signature to fail") + } +} + +func TestConfigFromEnvReadsInlinePublicKey(t *testing.T) { + config := ConfigFromEnv(func(key string) string { + if key == "TRUSTED_PROXY_PUBLIC_KEY" { + return "-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----" + } + return "" + }) + if config.PublicKey == "" { + t.Fatal("expected public key to be set") + } +} diff --git a/proxy-auth-lib/go/go.mod b/proxy-auth-lib/go/go.mod new file mode 100644 index 00000000..d2fbc7e3 --- /dev/null +++ b/proxy-auth-lib/go/go.mod @@ -0,0 +1,3 @@ +module github.com/mieweb/opensource-server/proxy-auth-lib/go + +go 1.24.0 diff --git a/proxy-auth-lib/meteor/accounts-proxy-auth/README.md b/proxy-auth-lib/meteor/accounts-proxy-auth/README.md new file mode 100644 index 00000000..39ca1526 --- /dev/null +++ b/proxy-auth-lib/meteor/accounts-proxy-auth/README.md @@ -0,0 +1,93 @@ +# mieweb:accounts-proxy-auth + +Logs Meteor users in from a signed identity assertion supplied by a trusted +upstream proxy. Reuses the audited verification core from the +`@mieweb/trusted-proxy-auth` npm package, so it stays consistent with the +Express, Fastify, Hono, Python, Rust, and Go implementations. + +## Install + +```sh +meteor add mieweb:accounts-proxy-auth +``` + +The package depends on `@mieweb/trusted-proxy-auth` (declared via `Npm.depends`), +which Meteor installs automatically when the package is built. + +> **Local development:** `Npm.depends` resolves `@mieweb/trusted-proxy-auth` from +> the npm registry, so the package must be published first. Until then, build +> against the in-repo source by linking it: +> +> ```sh +> cd proxy-auth-lib/nodejs && npm link +> cd && npm link @mieweb/trusted-proxy-auth +> ``` +> +> or vendor the package into the app's `node_modules` before running `meteor`. + +## Configure + +Every setting is optional. By default the auth domain is derived from the host's +FQDN (`web1.os.example.org` → `auth.os.example.org`). Override any single value +with its own environment variable: + +| Variable | Default | Purpose | +|---|---|---| +| `TRUSTED_PROXY_AUTH_DOMAIN` | `auth.` | Base domain used to derive the issuer and JWKS URL | +| `TRUSTED_PROXY_ASSERTION_HEADER` | `X-Trusted-Proxy-Assertion` | Header carrying the signed assertion | +| `TRUSTED_PROXY_JWKS_URL` | `https:///.well-known/jwks.json` | JWKS URL for signing keys | +| `TRUSTED_PROXY_ISSUER` | `https://` | Expected JWT issuer | +| `TRUSTED_PROXY_AUDIENCE` | `https://` | Expected JWT audience | +| `TRUSTED_PROXY_PUBLIC_KEY` | _(unset)_ | Inline PEM public key; skips JWKS and verifies offline | +| `TRUSTED_PROXY_PUBLIC_KEY_FILE` | _(unset)_ | Path to a PEM public key (alternative to the inline form) | + +JWKS is preferred (key rotation, and what OIDC providers publish); set a static +public key only for the self-signed case where your own proxy mints assertions. + +## Flow + +```mermaid +sequenceDiagram + participant Proxy as Identity-aware proxy + participant Server as Meteor server + participant Client as Meteor client + Proxy->>Server: GET / (signed assertion header) + Server->>Server: verify signature, issuer, audience, expiry + Server->>Server: resolve/create user, mint login token + Server-->>Client: page + one-time login-token cookie + Client->>Server: Meteor.loginWithToken(token) + Client->>Client: clear bootstrap cookie +``` + +The server verifies the assertion on the top-level page request, ensures a +matching Meteor user exists, mints a one-time login token, and delivers it to +the client in a short-lived (`60s`) cookie. The client calls +`Meteor.loginWithToken` on startup and clears the cookie. + +## Custom user mapping + +By default a user is matched/created by `services.proxyAuth.subject`. Override +the mapping on the server: + +```js +import { TrustedProxyAccounts } from 'meteor/mieweb:accounts-proxy-auth'; + +TrustedProxyAccounts.setUserResolver(async (identity) => { + const user = await Meteor.users.findOneAsync({ 'emails.address': identity.email }); + return user?._id ?? Accounts.insertUserDoc({}, { + emails: [{ address: identity.email, verified: true }], + services: { proxyAuth: { subject: identity.subject } }, + }); +}); +``` + +The `identity` argument exposes `subject`, `email`, `name`, and the raw verified +`claims`. + +## Security boundary + +A missing or invalid assertion never blocks page delivery — the client simply +stays logged out and the application enforces its own authorization. Only a +signature verified against the configured JWKS, issuer, audience, and expiration +results in a login. Raw identity headers such as `X-Forwarded-User` are never +trusted on their own. diff --git a/proxy-auth-lib/meteor/accounts-proxy-auth/client/proxy-login.js b/proxy-auth-lib/meteor/accounts-proxy-auth/client/proxy-login.js new file mode 100644 index 00000000..81b458bd --- /dev/null +++ b/proxy-auth-lib/meteor/accounts-proxy-auth/client/proxy-login.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; + +// The server hands us a one-time Meteor login token in a short-lived cookie +// after it verifies the upstream proxy assertion. Consume it once on startup, +// then clear it so it cannot be replayed. +const BOOTSTRAP_COOKIE = 'meteor_proxy_login_token'; + +function readBootstrapToken() { + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${BOOTSTRAP_COOKIE}=([^;]+)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +function clearBootstrapToken() { + document.cookie = `${BOOTSTRAP_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`; +} + +Meteor.startup(() => { + if (Meteor.userId()) { + clearBootstrapToken(); + return; + } + + const token = readBootstrapToken(); + if (!token) { + return; + } + + Meteor.loginWithToken(token, () => { + clearBootstrapToken(); + }); +}); diff --git a/proxy-auth-lib/meteor/accounts-proxy-auth/package.js b/proxy-auth-lib/meteor/accounts-proxy-auth/package.js new file mode 100644 index 00000000..3fd9f923 --- /dev/null +++ b/proxy-auth-lib/meteor/accounts-proxy-auth/package.js @@ -0,0 +1,24 @@ +Package.describe({ + name: 'mieweb:accounts-proxy-auth', + version: '0.1.0', + summary: 'Log Meteor users in from a trusted upstream proxy identity assertion', + documentation: 'README.md', +}); + +// Reuse the audited verification core shared by the other languages. +Npm.depends({ + '@mieweb/trusted-proxy-auth': '0.1.0', +}); + +Package.onUse(function (api) { + api.versionsFrom('3.0.1'); + + api.use('ecmascript'); + api.use(['accounts-base', 'webapp', 'ddp'], 'server'); + api.use('accounts-base', 'client'); + + api.mainModule('server/proxy-login.js', 'server'); + api.mainModule('client/proxy-login.js', 'client'); + + api.export('TrustedProxyAccounts', 'server'); +}); diff --git a/proxy-auth-lib/meteor/accounts-proxy-auth/server/proxy-login.js b/proxy-auth-lib/meteor/accounts-proxy-auth/server/proxy-login.js new file mode 100644 index 00000000..7daa29db --- /dev/null +++ b/proxy-auth-lib/meteor/accounts-proxy-auth/server/proxy-login.js @@ -0,0 +1,83 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { WebApp } from 'meteor/webapp'; +import { loadConfigFromEnv, verifyAssertion } from '@mieweb/trusted-proxy-auth'; + +const config = loadConfigFromEnv(); +const headerName = (config.header || '').toLowerCase(); + +// Short-lived cookie that carries a one-time Meteor login token to the client. +// It is intentionally readable by client JS (Meteor stores resume tokens in +// localStorage anyway) and is cleared by the client immediately after use. +const BOOTSTRAP_COOKIE = 'meteor_proxy_login_token'; +const BOOTSTRAP_MAX_AGE_SECONDS = 60; + +// Default identity -> Meteor user mapping. Apps override with +// `TrustedProxyAccounts.setUserResolver(async (identity) => userId)`. +let resolveUser = async (identity) => { + const selector = { 'services.proxyAuth.subject': identity.subject }; + const existing = await Meteor.users.findOneAsync(selector, { fields: { _id: 1 } }); + if (existing) { + return existing._id; + } + + return Accounts.insertUserDoc( + { profile: identity.name ? { name: identity.name } : {} }, + { + services: { proxyAuth: { subject: identity.subject } }, + emails: identity.email ? [{ address: identity.email, verified: true }] : [], + }, + ); +}; + +export const TrustedProxyAccounts = { + /** + * Override how a verified assertion identity maps to a Meteor user id. + * @param {(identity: {subject: string, email: string|null, name: string|null, claims: object}) => Promise|string} resolver + */ + setUserResolver(resolver) { + if (typeof resolver !== 'function') { + throw new Error('user resolver must be a function'); + } + resolveUser = resolver; + }, +}; + +function wantsHtml(req) { + return req.method === 'GET' && (req.headers.accept || '').includes('text/html'); +} + +function setBootstrapCookie(req, res, token) { + const secure = req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : ''; + res.setHeader( + 'Set-Cookie', + `${BOOTSTRAP_COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=${BOOTSTRAP_MAX_AGE_SECONDS}; SameSite=Lax${secure}`, + ); +} + +// On a top-level page load, verify the proxy assertion, ensure a matching +// Meteor user exists, mint a one-time login token, and hand it to the client. +WebApp.connectHandlers.use(async (req, res, next) => { + try { + if (!headerName || !wantsHtml(req)) { + return; + } + + const raw = req.headers[headerName]; + const token = (Array.isArray(raw) ? raw[0] : raw || '').trim(); + if (!token) { + return; + } + + const identity = await verifyAssertion(token, config); + const userId = await resolveUser(identity); + const stampedToken = Accounts._generateStampedLoginToken(); + await Accounts._insertLoginToken(userId, stampedToken); + setBootstrapCookie(req, res, stampedToken.token); + } catch { + // Never block page delivery on a missing or invalid assertion; the client + // simply stays logged out and the app enforces its own authorization. + } finally { + next(); + } +}); diff --git a/proxy-auth-lib/nodejs/jsr.json b/proxy-auth-lib/nodejs/jsr.json new file mode 100644 index 00000000..43d6bd04 --- /dev/null +++ b/proxy-auth-lib/nodejs/jsr.json @@ -0,0 +1,13 @@ +{ + "name": "@mieweb/trusted-proxy-auth", + "version": "0.1.0", + "exports": { + ".": "./src/index.mjs", + "./express": "./src/express.mjs", + "./fastify": "./src/fastify.mjs", + "./hono": "./src/hono.mjs" + }, + "publish": { + "include": ["src", "jsr.json", "README.md"] + } +} diff --git a/proxy-auth-lib/nodejs/package.json b/proxy-auth-lib/nodejs/package.json new file mode 100644 index 00000000..86bfe314 --- /dev/null +++ b/proxy-auth-lib/nodejs/package.json @@ -0,0 +1,14 @@ +{ + "name": "@mieweb/trusted-proxy-auth", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.mjs", + "./express": "./src/express.mjs", + "./fastify": "./src/fastify.mjs", + "./hono": "./src/hono.mjs" + }, + "scripts": { + "test": "node --test" + } +} diff --git a/proxy-auth-lib/nodejs/src/core.mjs b/proxy-auth-lib/nodejs/src/core.mjs new file mode 100644 index 00000000..b8358961 --- /dev/null +++ b/proxy-auth-lib/nodejs/src/core.mjs @@ -0,0 +1,171 @@ +import { createPublicKey, createVerify } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import os from 'node:os'; + +export const UNAUTHORIZED_RESPONSE = { error: 'invalid_assertion' }; +export const UNAUTHORIZED_BODY = JSON.stringify(UNAUTHORIZED_RESPONSE); + +// Reasonable defaults so every setting is optional. The auth domain is derived +// from the host's FQDN (`web1.os.example.org` -> `auth.os.example.org`); issuer +// and JWKS come from it. Override any single value with its own env var. +export const DEFAULT_ASSERTION_HEADER = 'X-Trusted-Proxy-Assertion'; + +export function deriveAuthDomain(hostname = os.hostname()) { + const labels = String(hostname).split('.').filter(Boolean); + const parent = labels.length > 1 ? labels.slice(1).join('.') : labels[0] || 'localhost'; + return `auth.${parent}`; +} + +export function loadConfigFromEnv(env = process.env, hostname = os.hostname()) { + const domain = env.TRUSTED_PROXY_AUTH_DOMAIN || deriveAuthDomain(hostname); + const base = `https://${domain}`; + return { + header: env.TRUSTED_PROXY_ASSERTION_HEADER || DEFAULT_ASSERTION_HEADER, + jwksUrl: env.TRUSTED_PROXY_JWKS_URL || `${base}/.well-known/jwks.json`, + issuer: env.TRUSTED_PROXY_ISSUER || base, + audience: env.TRUSTED_PROXY_AUDIENCE || base, + publicKey: resolvePublicKey(env), + }; +} + +// JWKS is preferred for key rotation. A static public key (PEM) is an opt-in +// alternative for self-signed assertions: when set, verification uses it +// directly and never touches the network. +function resolvePublicKey(env) { + if (env.TRUSTED_PROXY_PUBLIC_KEY) { + return env.TRUSTED_PROXY_PUBLIC_KEY; + } + if (env.TRUSTED_PROXY_PUBLIC_KEY_FILE) { + return readFileSync(env.TRUSTED_PROXY_PUBLIC_KEY_FILE, 'utf8'); + } + return null; +} + +export function resolveFetch(options = {}) { + const fetchImpl = options.fetch ?? globalThis.fetch; + if (typeof fetchImpl !== 'function') { + throw new Error('fetch is required to resolve JWKS'); + } + return fetchImpl; +} + +export function headerValue(raw) { + const value = Array.isArray(raw) ? raw[0] : raw; + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +export function sendUnauthorized(res) { + if (typeof res.status === 'function') { + res.status(401); + } else { + res.statusCode = 401; + } + + if (typeof res.json === 'function') { + res.json(UNAUTHORIZED_RESPONSE); + return; + } + + if (typeof res.setHeader === 'function') { + res.setHeader('content-type', 'application/json'); + } + res.end(UNAUTHORIZED_BODY); +} + +export async function verifyAssertion(token, config, fetchImpl = globalThis.fetch) { + validateConfig(config); + + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('malformed assertion'); + } + + const header = parseSegment(parts[0]); + const claims = parseSegment(parts[1]); + const signature = decodeBase64Url(parts[2]); + + // Pin the algorithm: never let the token pick a weaker scheme. + if (header.alg !== 'RS256') { + throw new Error('unsupported assertion'); + } + + const key = await resolveVerificationKey(header, config, fetchImpl); + + const verifier = createVerify('RSA-SHA256'); + verifier.update(`${parts[0]}.${parts[1]}`); + verifier.end(); + if (!verifier.verify(key, signature)) { + throw new Error('invalid signature'); + } + + if (claims.iss !== config.issuer) { + throw new Error('wrong issuer'); + } + + if (!matchesAudience(claims.aud, config.audience)) { + throw new Error('wrong audience'); + } + + if (typeof claims.exp !== 'number' || claims.exp <= Math.floor(Date.now() / 1000)) { + throw new Error('expired assertion'); + } + + return { + subject: claims.sub, + email: claims.email ?? null, + name: claims.name ?? null, + claims, + }; +} + +async function resolveVerificationKey(header, config, fetchImpl) { + if (config.publicKey) { + return createPublicKey(config.publicKey); + } + if (typeof fetchImpl !== 'function') { + throw new Error('fetch is required to resolve JWKS'); + } + if (!header.kid) { + throw new Error('unsupported assertion'); + } + const jwks = await fetchJwks(config.jwksUrl, fetchImpl); + const jwk = jwks.keys?.find((candidate) => candidate.kid === header.kid); + if (!jwk) { + throw new Error('unknown signing key'); + } + return createPublicKey({ key: jwk, format: 'jwk' }); +} + +export function validateConfig(config) { + for (const key of ['header', 'issuer', 'audience']) { + if (!config?.[key]) { + throw new Error(`missing config: ${key}`); + } + } + if (!config.publicKey && !config.jwksUrl) { + throw new Error('missing config: jwksUrl or publicKey'); + } +} + +async function fetchJwks(jwksUrl, fetchImpl) { + const response = await fetchImpl(jwksUrl); + if (!response.ok) { + throw new Error('failed to load jwks'); + } + return response.json(); +} + +function matchesAudience(actual, expected) { + if (Array.isArray(actual)) { + return actual.includes(expected); + } + return actual === expected; +} + +function parseSegment(segment) { + return JSON.parse(decodeBase64Url(segment).toString('utf8')); +} + +function decodeBase64Url(segment) { + return Buffer.from(segment, 'base64url'); +} diff --git a/proxy-auth-lib/nodejs/src/express.mjs b/proxy-auth-lib/nodejs/src/express.mjs new file mode 100644 index 00000000..e9f888b4 --- /dev/null +++ b/proxy-auth-lib/nodejs/src/express.mjs @@ -0,0 +1,23 @@ +import { headerValue, resolveFetch, sendUnauthorized, validateConfig, verifyAssertion } from './core.mjs'; + +// Express / connect-style middleware: (req, res, next). +export function createTrustedProxyAuth(config, options = {}) { + validateConfig(config); + const fetchImpl = resolveFetch(options); + + return async function trustedProxyAuth(req, res, next) { + try { + const raw = + req.headers?.[config.header.toLowerCase()] ?? req.headers?.[config.header] ?? req.get?.(config.header); + const token = headerValue(raw); + if (!token) { + throw new Error('missing assertion'); + } + + req.trustedProxyIdentity = await verifyAssertion(token, config, fetchImpl); + next(); + } catch { + sendUnauthorized(res); + } + }; +} diff --git a/proxy-auth-lib/nodejs/src/fastify.mjs b/proxy-auth-lib/nodejs/src/fastify.mjs new file mode 100644 index 00000000..7416f833 --- /dev/null +++ b/proxy-auth-lib/nodejs/src/fastify.mjs @@ -0,0 +1,22 @@ +import { UNAUTHORIZED_RESPONSE, headerValue, resolveFetch, validateConfig, verifyAssertion } from './core.mjs'; + +// Fastify preHandler hook. Register with `fastify.addHook('preHandler', hook)` +// or per-route via `{ preHandler: hook }`. On success the verified identity is +// attached as `request.trustedProxyIdentity`. +export function fastifyTrustedProxyAuth(config, options = {}) { + validateConfig(config); + const fetchImpl = resolveFetch(options); + + return async function trustedProxyAuthHook(request, reply) { + const token = headerValue(request.headers?.[config.header.toLowerCase()] ?? request.headers?.[config.header]); + if (!token) { + return reply.code(401).type('application/json').send(UNAUTHORIZED_RESPONSE); + } + + try { + request.trustedProxyIdentity = await verifyAssertion(token, config, fetchImpl); + } catch { + return reply.code(401).type('application/json').send(UNAUTHORIZED_RESPONSE); + } + }; +} diff --git a/proxy-auth-lib/nodejs/src/hono.mjs b/proxy-auth-lib/nodejs/src/hono.mjs new file mode 100644 index 00000000..b2e5df96 --- /dev/null +++ b/proxy-auth-lib/nodejs/src/hono.mjs @@ -0,0 +1,23 @@ +import { UNAUTHORIZED_RESPONSE, headerValue, resolveFetch, validateConfig, verifyAssertion } from './core.mjs'; + +// Hono middleware: `app.use('*', honoTrustedProxyAuth(config))`. On success the +// verified identity is available via `c.get('trustedProxyIdentity')`. +export function honoTrustedProxyAuth(config, options = {}) { + validateConfig(config); + const fetchImpl = resolveFetch(options); + + return async function trustedProxyAuthMiddleware(c, next) { + const token = headerValue(c.req.header(config.header)); + if (!token) { + return c.json(UNAUTHORIZED_RESPONSE, 401); + } + + try { + c.set('trustedProxyIdentity', await verifyAssertion(token, config, fetchImpl)); + } catch { + return c.json(UNAUTHORIZED_RESPONSE, 401); + } + + await next(); + }; +} diff --git a/proxy-auth-lib/nodejs/src/index.mjs b/proxy-auth-lib/nodejs/src/index.mjs new file mode 100644 index 00000000..6692410f --- /dev/null +++ b/proxy-auth-lib/nodejs/src/index.mjs @@ -0,0 +1,10 @@ +// Convenience barrel. For lighter imports use the per-framework subpaths: +// @mieweb/trusted-proxy-auth/express +// @mieweb/trusted-proxy-auth/fastify +// @mieweb/trusted-proxy-auth/hono +// Meteor apps use the Atmosphere package `mieweb:accounts-proxy-auth`, which +// depends on this package for verification. +export { loadConfigFromEnv, verifyAssertion } from './core.mjs'; +export { createTrustedProxyAuth } from './express.mjs'; +export { fastifyTrustedProxyAuth } from './fastify.mjs'; +export { honoTrustedProxyAuth } from './hono.mjs'; diff --git a/proxy-auth-lib/nodejs/test/index.test.mjs b/proxy-auth-lib/nodejs/test/index.test.mjs new file mode 100644 index 00000000..16bc654c --- /dev/null +++ b/proxy-auth-lib/nodejs/test/index.test.mjs @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { before, after, describe, test } from 'node:test'; + +import { createTrustedProxyAuth, loadConfigFromEnv, verifyAssertion } from '../src/index.mjs'; +import { fastifyTrustedProxyAuth } from '../src/fastify.mjs'; +import { honoTrustedProxyAuth } from '../src/hono.mjs'; + +const tokens = JSON.parse(await readFile(new URL('../../testdata/tokens.json', import.meta.url), 'utf8')); +const jwks = await readFile(new URL('../../testdata/jwks.json', import.meta.url), 'utf8'); + +let server; +let jwksUrl; +const config = { + header: 'x-trusted-proxy-assertion', + jwksUrl: '', + issuer: 'https://issuer.example.test', + audience: 'my-service', +}; + +before(async () => { + server = createServer((req, res) => { + if (req.url !== '/jwks.json') { + res.statusCode = 404; + res.end(); + return; + } + res.setHeader('content-type', 'application/json'); + res.end(jwks); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + jwksUrl = `http://127.0.0.1:${port}/jwks.json`; + config.jwksUrl = jwksUrl; +}); + +after(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +}); + +describe('verifyAssertion', () => { + test('accepts a valid assertion', async () => { + const identity = await verifyAssertion(tokens.valid, config); + assert.equal(identity.subject, 'user-123'); + assert.equal(identity.email, 'user@example.test'); + assert.equal(identity.name, 'Example User'); + }); + + for (const key of ['expired', 'invalid_signature', 'wrong_issuer', 'wrong_audience', 'malformed']) { + test(`rejects ${key} assertions`, async () => { + await assert.rejects(() => verifyAssertion(tokens[key], config)); + }); + } +}); + +describe('static public key', () => { + test('verifies with a PEM public key and no network', async () => { + const publicKey = await readFile(new URL('../../testdata/public-key.pem', import.meta.url), 'utf8'); + const staticConfig = { + header: 'x-trusted-proxy-assertion', + issuer: 'https://issuer.example.test', + audience: 'my-service', + publicKey, + }; + // Pass a null fetch to prove verification never touches the network. + const identity = await verifyAssertion(tokens.valid, staticConfig, null); + assert.equal(identity.subject, 'user-123'); + await assert.rejects(() => verifyAssertion(tokens.invalid_signature, staticConfig, null)); + }); +}); + +describe('createTrustedProxyAuth', () => { + async function invoke(token) { + let nextCalled = false; + const req = { + headers: token ? { 'x-trusted-proxy-assertion': token } : {}, + get(name) { + return this.headers[name.toLowerCase()]; + }, + }; + const res = { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + }, + end(payload) { + this.body = payload; + }, + }; + + await createTrustedProxyAuth(config)(req, res, () => { + nextCalled = true; + }); + + return { req, res, nextCalled }; + } + + test('sets req.trustedProxyIdentity for a valid assertion', async () => { + const result = await invoke(tokens.valid); + assert.equal(result.nextCalled, true); + assert.equal(result.req.trustedProxyIdentity.subject, 'user-123'); + }); + + for (const key of ['missing', 'expired', 'invalid_signature', 'wrong_issuer', 'wrong_audience', 'malformed']) { + test(`returns 401 for ${key} assertions`, async () => { + const result = await invoke(key === 'missing' ? null : tokens[key]); + assert.equal(result.nextCalled, false); + assert.equal(result.res.statusCode, 401); + assert.deepEqual(result.res.body, { error: 'invalid_assertion' }); + }); + } + + test('loads the shared environment variable names', () => { + assert.deepEqual( + loadConfigFromEnv({ + TRUSTED_PROXY_ASSERTION_HEADER: 'x-test', + TRUSTED_PROXY_JWKS_URL: jwksUrl, + TRUSTED_PROXY_ISSUER: config.issuer, + TRUSTED_PROXY_AUDIENCE: config.audience, + }), + { + header: 'x-test', + jwksUrl, + issuer: config.issuer, + audience: config.audience, + publicKey: null, + }, + ); + }); + + test('reads an inline static public key from the environment', () => { + const derived = loadConfigFromEnv({ TRUSTED_PROXY_PUBLIC_KEY: '-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----' }); + assert.match(derived.publicKey, /BEGIN PUBLIC KEY/); + }); + + test('derives the auth domain from the host fqdn', () => { + const derived = loadConfigFromEnv({}, 'web1.os.example.org'); + assert.equal(derived.header, 'X-Trusted-Proxy-Assertion'); + assert.equal(derived.issuer, 'https://auth.os.example.org'); + assert.equal(derived.jwksUrl, 'https://auth.os.example.org/.well-known/jwks.json'); + assert.equal(derived.audience, 'https://auth.os.example.org'); + }); + + test('an explicit auth domain overrides hostname derivation', () => { + const derived = loadConfigFromEnv({ TRUSTED_PROXY_AUTH_DOMAIN: 'auth.example.test' }, 'web1.os.example.org'); + assert.equal(derived.issuer, 'https://auth.example.test'); + assert.equal(derived.jwksUrl, 'https://auth.example.test/.well-known/jwks.json'); + }); +}); + +describe('fastifyTrustedProxyAuth', () => { + function fakeReply() { + return { + statusCode: 200, + payload: undefined, + headers: {}, + sent: false, + code(value) { + this.statusCode = value; + return this; + }, + type(value) { + this.headers['content-type'] = value; + return this; + }, + send(payload) { + this.sent = true; + this.payload = payload; + return this; + }, + }; + } + + test('attaches identity for a valid assertion', async () => { + const request = { headers: { 'x-trusted-proxy-assertion': tokens.valid } }; + const reply = fakeReply(); + await fastifyTrustedProxyAuth(config)(request, reply); + assert.equal(reply.sent, false); + assert.equal(request.trustedProxyIdentity.subject, 'user-123'); + }); + + for (const key of ['missing', 'expired', 'invalid_signature', 'wrong_issuer', 'wrong_audience', 'malformed']) { + test(`rejects ${key} assertions with 401`, async () => { + const request = { headers: key === 'missing' ? {} : { 'x-trusted-proxy-assertion': tokens[key] } }; + const reply = fakeReply(); + await fastifyTrustedProxyAuth(config)(request, reply); + assert.equal(reply.statusCode, 401); + assert.deepEqual(reply.payload, { error: 'invalid_assertion' }); + }); + } +}); + +describe('honoTrustedProxyAuth', () => { + function fakeContext(token) { + const store = new Map(); + return { + response: null, + req: { + header(name) { + return name.toLowerCase() === 'x-trusted-proxy-assertion' ? (token ?? undefined) : undefined; + }, + }, + set(key, value) { + store.set(key, value); + }, + get(key) { + return store.get(key); + }, + json(body, status) { + this.response = { body, status }; + return this.response; + }, + }; + } + + test('sets identity for a valid assertion', async () => { + const c = fakeContext(tokens.valid); + let nextCalled = false; + await honoTrustedProxyAuth(config)(c, async () => { + nextCalled = true; + }); + assert.equal(nextCalled, true); + assert.equal(c.get('trustedProxyIdentity').subject, 'user-123'); + }); + + for (const key of ['missing', 'expired', 'invalid_signature', 'wrong_issuer', 'wrong_audience', 'malformed']) { + test(`rejects ${key} assertions with 401`, async () => { + const c = fakeContext(key === 'missing' ? undefined : tokens[key]); + let nextCalled = false; + await honoTrustedProxyAuth(config)(c, async () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal(c.response.status, 401); + assert.deepEqual(c.response.body, { error: 'invalid_assertion' }); + }); + } +}); diff --git a/proxy-auth-lib/python/.gitignore b/proxy-auth-lib/python/.gitignore new file mode 100644 index 00000000..660a48e1 --- /dev/null +++ b/proxy-auth-lib/python/.gitignore @@ -0,0 +1,4 @@ +dist/ +build/ +*.egg-info/ +__pycache__/ diff --git a/proxy-auth-lib/python/pyproject.toml b/proxy-auth-lib/python/pyproject.toml new file mode 100644 index 00000000..4f592aa9 --- /dev/null +++ b/proxy-auth-lib/python/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "trusted-proxy-auth" +version = "0.1.0" +description = "Vendor-neutral ASGI middleware for signed proxy identity assertions" +requires-python = ">=3.11" +dependencies = [ + "PyJWT[crypto]>=2.13.0,<3.0.0", +] + +[tool.setuptools.packages.find] +include = ["trusted_proxy_auth*"] diff --git a/proxy-auth-lib/python/tests/test_middleware.py b/proxy-auth-lib/python/tests/test_middleware.py new file mode 100644 index 00000000..c7bdc29e --- /dev/null +++ b/proxy-auth-lib/python/tests/test_middleware.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import asyncio +import json +import threading +import unittest +import unittest.mock +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from trusted_proxy_auth import Config, TrustedProxyAuthMiddleware, load_config_from_env, verify_assertion +from trusted_proxy_auth.django import TrustedProxyAuthMiddleware as DjangoMiddleware +from trusted_proxy_auth.flask import TrustedProxyAuthMiddleware as FlaskMiddleware + +FIXTURES = Path(__file__).resolve().parents[2] / "testdata" +TOKENS = json.loads((FIXTURES / "tokens.json").read_text()) +JWKS = (FIXTURES / "jwks.json").read_bytes() + + +class JwksHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if self.path != "/jwks.json": + self.send_response(404) + self.end_headers() + return + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(JWKS) + + def log_message(self, format: str, *args: object) -> None: # noqa: A003 + return + + +class TrustedProxyAuthTests(unittest.IsolatedAsyncioTestCase): + @classmethod + def setUpClass(cls) -> None: + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), JwksHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.config = Config( + header="x-trusted-proxy-assertion", + jwks_url=f"http://127.0.0.1:{cls.server.server_port}/jwks.json", + issuer="https://issuer.example.test", + audience="my-service", + ) + + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.thread.join(timeout=5) + + async def test_accepts_valid_assertion(self) -> None: + identity = verify_assertion(TOKENS["valid"], self.config) + self.assertEqual(identity.subject, "user-123") + self.assertEqual(identity.email, "user@example.test") + self.assertEqual(identity.name, "Example User") + + async def test_rejects_missing_expired_invalid_and_malformed_assertions(self) -> None: + for key in ["expired", "invalid_signature", "wrong_issuer", "wrong_audience", "malformed"]: + with self.subTest(key=key): + with self.assertRaises(Exception): + verify_assertion(TOKENS[key], self.config) + + async def test_asgi_middleware_exposes_verified_identity(self) -> None: + async def app(scope, receive, send): + identity = scope["trusted_proxy_identity"] + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"application/json")]}) + await send({"type": "http.response.body", "body": json.dumps({"subject": identity.subject}).encode()}) + + middleware = TrustedProxyAuthMiddleware(app, self.config) + status, body = await invoke(middleware, TOKENS["valid"], self.config.header) + self.assertEqual(status, 200) + self.assertEqual(json.loads(body), {"subject": "user-123"}) + + async def test_asgi_middleware_returns_401_for_invalid_assertions(self) -> None: + middleware = TrustedProxyAuthMiddleware(empty_app, self.config) + for key in [None, "expired", "invalid_signature", "wrong_issuer", "wrong_audience", "malformed"]: + with self.subTest(key=key): + status, body = await invoke(middleware, TOKENS[key] if key else None, self.config.header) + self.assertEqual(status, 401) + self.assertEqual(json.loads(body), {"error": "invalid_assertion"}) + + async def test_load_config_from_env_uses_shared_names(self) -> None: + config = load_config_from_env( + { + "TRUSTED_PROXY_ASSERTION_HEADER": "x-test", + "TRUSTED_PROXY_JWKS_URL": self.config.jwks_url, + "TRUSTED_PROXY_ISSUER": self.config.issuer, + "TRUSTED_PROXY_AUDIENCE": self.config.audience, + } + ) + self.assertEqual(config.header, "x-test") + self.assertEqual(config.jwks_url, self.config.jwks_url) + + async def test_load_config_from_env_derives_auth_domain_from_host(self) -> None: + derived = load_config_from_env({}, "web1.os.example.org") + self.assertEqual(derived.header, "X-Trusted-Proxy-Assertion") + self.assertEqual(derived.issuer, "https://auth.os.example.org") + self.assertEqual(derived.jwks_url, "https://auth.os.example.org/.well-known/jwks.json") + self.assertEqual(derived.audience, "https://auth.os.example.org") + + override = load_config_from_env({"TRUSTED_PROXY_AUTH_DOMAIN": "auth.example.test"}, "web1.os.example.org") + self.assertEqual(override.issuer, "https://auth.example.test") + self.assertEqual(override.jwks_url, "https://auth.example.test/.well-known/jwks.json") + + async def test_static_public_key_verifies_without_network(self) -> None: + public_key = (FIXTURES / "public-key.pem").read_text() + config = Config( + header="x-trusted-proxy-assertion", + jwks_url="", + issuer="https://issuer.example.test", + audience="my-service", + public_key=public_key, + ) + identity = verify_assertion(TOKENS["valid"], config) + self.assertEqual(identity.subject, "user-123") + with self.assertRaises(Exception): + verify_assertion(TOKENS["invalid_signature"], config) + + async def test_load_config_from_env_reads_inline_public_key(self) -> None: + config = load_config_from_env( + {"TRUSTED_PROXY_PUBLIC_KEY": "-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----"} + ) + self.assertIn("BEGIN PUBLIC KEY", config.public_key) + + async def test_flask_wsgi_middleware_exposes_identity(self) -> None: + def app(environ, start_response): + identity = environ["trusted_proxy_identity"] + start_response("200 OK", [("Content-Type", "application/json")]) + return [json.dumps({"subject": identity.subject}).encode()] + + middleware = FlaskMiddleware(app, self.config) + status, body = call_wsgi(middleware, TOKENS["valid"], self.config.header) + self.assertTrue(status.startswith("200")) + self.assertEqual(json.loads(body), {"subject": "user-123"}) + + async def test_flask_wsgi_middleware_rejects_invalid(self) -> None: + middleware = FlaskMiddleware(wsgi_ok_app, self.config) + for key in [None, "expired", "invalid_signature", "wrong_issuer", "wrong_audience", "malformed"]: + with self.subTest(key=key): + status, body = call_wsgi(middleware, TOKENS[key] if key else None, self.config.header) + self.assertTrue(status.startswith("401")) + self.assertEqual(json.loads(body), {"error": "invalid_assertion"}) + + async def test_django_middleware_exposes_identity_and_rejects(self) -> None: + meta_key = "HTTP_" + self.config.header.upper().replace("-", "_") + sentinel = object() + response_marker = object() + + def get_response(request): + return response_marker + + with unittest.mock.patch("trusted_proxy_auth.django._django_unauthorized", return_value=sentinel): + middleware = DjangoMiddleware(get_response, self.config) + + valid_request = _FakeRequest({meta_key: TOKENS["valid"]}) + self.assertIs(middleware(valid_request), response_marker) + self.assertEqual(valid_request.trusted_proxy_identity.subject, "user-123") + + for key in [None, "expired", "invalid_signature", "wrong_issuer", "wrong_audience", "malformed"]: + with self.subTest(key=key): + meta = {} if key is None else {meta_key: TOKENS[key]} + self.assertIs(middleware(_FakeRequest(meta)), sentinel) + + +class _FakeRequest: + def __init__(self, meta: dict[str, str]): + self.META = meta + + +def wsgi_ok_app(environ, start_response): + start_response("200 OK", []) + return [b"ok"] + + +def call_wsgi(app, token: str | None, header_name: str) -> tuple[str, bytes]: + environ = {"REQUEST_METHOD": "GET", "PATH_INFO": "/"} + if token is not None: + environ["HTTP_" + header_name.upper().replace("-", "_")] = token + captured: dict[str, str] = {} + + def start_response(status: str, headers: list[tuple[str, str]]) -> None: + captured["status"] = status + + body = b"".join(app(environ, start_response)) + return captured["status"], body + + +async def empty_app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok"}) + + +async def invoke(app, token: str | None, header_name: str) -> tuple[int, bytes]: + headers = [] if token is None else [(header_name.encode(), token.encode())] + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": headers, + } + received = False + messages = [] + + async def receive(): + nonlocal received + if received: + await asyncio.sleep(0) + return {"type": "http.disconnect"} + received = True + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + messages.append(message) + + await app(scope, receive, send) + + status = next(message["status"] for message in messages if message["type"] == "http.response.start") + body = b"".join(message.get("body", b"") for message in messages if message["type"] == "http.response.body") + return status, body + + +if __name__ == "__main__": + unittest.main() diff --git a/proxy-auth-lib/python/trusted_proxy_auth/__init__.py b/proxy-auth-lib/python/trusted_proxy_auth/__init__.py new file mode 100644 index 00000000..75242b70 --- /dev/null +++ b/proxy-auth-lib/python/trusted_proxy_auth/__init__.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +import os +import socket +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +import jwt +from jwt import InvalidTokenError, PyJWKClient + +UNAUTHORIZED_RESPONSE = {"error": "invalid_assertion"} +UNAUTHORIZED_BODY = json.dumps(UNAUTHORIZED_RESPONSE, separators=(",", ":")).encode() + +# Reasonable defaults so every setting is optional. The auth domain is derived +# from the host's FQDN (`web1.os.example.org` -> `auth.os.example.org`); issuer +# and JWKS come from it. Override any single value with its own env var. +DEFAULT_ASSERTION_HEADER = "X-Trusted-Proxy-Assertion" + + +def derive_auth_domain(hostname: str | None = None) -> str: + host = hostname or socket.getfqdn() + labels = [label for label in host.split(".") if label] + parent = ".".join(labels[1:]) if len(labels) > 1 else (labels[0] if labels else "localhost") + return f"auth.{parent}" + + +@dataclass(frozen=True) +class Config: + header: str + jwks_url: str + issuer: str + audience: str + public_key: str | None = None + + +@dataclass(frozen=True) +class Identity: + subject: str + email: str | None + name: str | None + claims: dict[str, Any] + + +class InvalidAssertionError(Exception): + pass + + +class TrustedProxyAuthMiddleware: + def __init__(self, app: Any, config: Config): + _validate_config(config) + self.app = app + self.config = config + self.jwks_client = None if config.public_key else PyJWKClient(config.jwks_url) + + async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + + try: + token = _read_header(scope, self.config.header) + if token is None: + raise InvalidAssertionError("missing assertion") + scope["trusted_proxy_identity"] = verify_assertion(token, self.config, self.jwks_client) + await self.app(scope, receive, send) + except InvalidAssertionError: + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [(b"content-type", b"application/json")], + } + ) + await send({"type": "http.response.body", "body": UNAUTHORIZED_BODY}) + + +def load_config_from_env(env: dict[str, str] | None = None, hostname: str | None = None) -> Config: + values = env or os.environ + domain = values.get("TRUSTED_PROXY_AUTH_DOMAIN") or derive_auth_domain(hostname) + base = f"https://{domain}" + return Config( + header=values.get("TRUSTED_PROXY_ASSERTION_HEADER") or DEFAULT_ASSERTION_HEADER, + jwks_url=values.get("TRUSTED_PROXY_JWKS_URL") or f"{base}/.well-known/jwks.json", + issuer=values.get("TRUSTED_PROXY_ISSUER") or base, + audience=values.get("TRUSTED_PROXY_AUDIENCE") or base, + public_key=_resolve_public_key(values), + ) + + +# JWKS is preferred for key rotation. A static public key (PEM) is an opt-in +# alternative for self-signed assertions: when set, verification uses it +# directly and never touches the network. +def _resolve_public_key(values: Any) -> str | None: + inline = values.get("TRUSTED_PROXY_PUBLIC_KEY") + if inline: + return inline + path = values.get("TRUSTED_PROXY_PUBLIC_KEY_FILE") + if path: + with open(path, encoding="utf-8") as handle: + return handle.read() + return None + + +def verify_assertion(token: str, config: Config, jwks_client: PyJWKClient | None = None) -> Identity: + _validate_config(config) + try: + if config.public_key: + signing_key: Any = config.public_key + else: + client = jwks_client or PyJWKClient(config.jwks_url) + signing_key = client.get_signing_key_from_jwt(token).key + claims = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + issuer=config.issuer, + audience=config.audience, + options={"require": ["exp", "iss", "aud", "sub"]}, + ) + except (InvalidTokenError, ValueError) as error: + raise InvalidAssertionError("invalid assertion") from error + + return Identity( + subject=claims["sub"], + email=claims.get("email"), + name=claims.get("name"), + claims=deepcopy(claims), + ) + + +def _read_header(scope: dict[str, Any], header_name: str) -> str | None: + target = header_name.lower().encode() + for name, value in scope.get("headers", []): + if name != target: + continue + decoded = value.decode().strip() + if decoded: + return decoded + return None + + +def _validate_config(config: Config) -> None: + for key in ("header", "issuer", "audience"): + if not getattr(config, key): + raise ValueError(f"missing config: {key}") + if not config.public_key and not config.jwks_url: + raise ValueError("missing config: jwks_url or public_key") diff --git a/proxy-auth-lib/python/trusted_proxy_auth/django.py b/proxy-auth-lib/python/trusted_proxy_auth/django.py new file mode 100644 index 00000000..d2b47bc2 --- /dev/null +++ b/proxy-auth-lib/python/trusted_proxy_auth/django.py @@ -0,0 +1,52 @@ +"""Django middleware for signed proxy identity assertions. + +Add to ``MIDDLEWARE`` in settings:: + + MIDDLEWARE = [ + # ... + "trusted_proxy_auth.django.TrustedProxyAuthMiddleware", + ] + +Configuration is read from the shared ``TRUSTED_PROXY_*`` environment +variables. The verified identity is attached to the request as +``request.trusted_proxy_identity``. +""" + +from __future__ import annotations + +from typing import Any, Callable + +from jwt import PyJWKClient + +from . import ( + Config, + InvalidAssertionError, + load_config_from_env, + verify_assertion, + _validate_config, +) + + +def _django_unauthorized() -> Any: + from django.http import JsonResponse + + return JsonResponse({"error": "invalid_assertion"}, status=401) + + +class TrustedProxyAuthMiddleware: + def __init__(self, get_response: Callable[[Any], Any], config: Config | None = None): + self.get_response = get_response + self.config = config or load_config_from_env() + _validate_config(self.config) + self.jwks_client = None if self.config.public_key else PyJWKClient(self.config.jwks_url) + self._meta_key = "HTTP_" + self.config.header.upper().replace("-", "_") + + def __call__(self, request: Any) -> Any: + token = (request.META.get(self._meta_key) or "").strip() + if not token: + return _django_unauthorized() + try: + request.trusted_proxy_identity = verify_assertion(token, self.config, self.jwks_client) + except InvalidAssertionError: + return _django_unauthorized() + return self.get_response(request) diff --git a/proxy-auth-lib/python/trusted_proxy_auth/flask.py b/proxy-auth-lib/python/trusted_proxy_auth/flask.py new file mode 100644 index 00000000..92b3dd91 --- /dev/null +++ b/proxy-auth-lib/python/trusted_proxy_auth/flask.py @@ -0,0 +1,54 @@ +"""WSGI middleware for signed proxy identity assertions (Flask and friends). + +Flask apps are WSGI, so wrap the app's WSGI callable:: + + from trusted_proxy_auth import load_config_from_env + from trusted_proxy_auth.flask import TrustedProxyAuthMiddleware + + app.wsgi_app = TrustedProxyAuthMiddleware(app.wsgi_app, load_config_from_env()) + +The verified identity is exposed on the WSGI environ as +``trusted_proxy_identity`` and is reachable in a view via +``request.environ["trusted_proxy_identity"]``. +""" + +from __future__ import annotations + +from typing import Any, Callable, Iterable + +from jwt import PyJWKClient + +from . import ( + UNAUTHORIZED_BODY, + Config, + InvalidAssertionError, + load_config_from_env, + verify_assertion, + _validate_config, +) + +StartResponse = Callable[[str, list[tuple[str, str]]], Any] + + +class TrustedProxyAuthMiddleware: + def __init__(self, app: Callable[..., Iterable[bytes]], config: Config | None = None): + self.app = app + self.config = config or load_config_from_env() + _validate_config(self.config) + self.jwks_client = None if self.config.public_key else PyJWKClient(self.config.jwks_url) + self._environ_key = "HTTP_" + self.config.header.upper().replace("-", "_") + + def __call__(self, environ: dict[str, Any], start_response: StartResponse) -> Iterable[bytes]: + token = (environ.get(self._environ_key) or "").strip() + if not token: + return _reject(start_response) + try: + environ["trusted_proxy_identity"] = verify_assertion(token, self.config, self.jwks_client) + except InvalidAssertionError: + return _reject(start_response) + return self.app(environ, start_response) + + +def _reject(start_response: StartResponse) -> Iterable[bytes]: + start_response("401 Unauthorized", [("Content-Type", "application/json")]) + return [UNAUTHORIZED_BODY] diff --git a/proxy-auth-lib/rust/Cargo.lock b/proxy-auth-lib/rust/Cargo.lock new file mode 100644 index 00000000..55c832ea --- /dev/null +++ b/proxy-auth-lib/rust/Cargo.lock @@ -0,0 +1,2350 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trusted-proxy-auth" +version = "0.1.0" +dependencies = [ + "axum", + "gethostname", + "jsonwebtoken", + "reqwest", + "serde", + "serde_json", + "tokio", + "tower", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/proxy-auth-lib/rust/Cargo.toml b/proxy-auth-lib/rust/Cargo.toml new file mode 100644 index 00000000..d8772d33 --- /dev/null +++ b/proxy-auth-lib/rust/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "trusted-proxy-auth" +version = "0.1.0" +edition = "2021" +description = "Vendor-neutral Axum middleware that verifies signed proxy identity assertions (RS256 JWT via JWKS or a static public key)." +license = "Apache-2.0" +repository = "https://github.com/mieweb/opensource-server" +documentation = "https://github.com/mieweb/opensource-server/tree/main/proxy-auth-lib/rust" +readme = "../README.md" +keywords = ["jwt", "jwks", "proxy", "authentication", "axum"] +categories = ["authentication", "web-programming"] + +[dependencies] +axum = "0.8.9" +gethostname = "1.0" +jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust_crypto", "use_pem"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread"] } + +[dev-dependencies] +tower = { version = "0.5.3", features = ["util"] } diff --git a/proxy-auth-lib/rust/src/lib.rs b/proxy-auth-lib/rust/src/lib.rs new file mode 100644 index 00000000..86634ae5 --- /dev/null +++ b/proxy-auth-lib/rust/src/lib.rs @@ -0,0 +1,388 @@ +use std::{collections::BTreeMap, fmt, sync::Arc}; + +use axum::{ + extract::{Request, State}, + http::{HeaderMap, StatusCode}, + middleware::Next, + response::Response, +}; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug)] +pub struct Config { + pub header: String, + pub jwks_url: String, + pub issuer: String, + pub audience: String, + pub public_key: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Identity { + pub subject: String, + pub email: Option, + pub name: Option, + pub claims: Value, +} + +#[derive(Debug)] +pub enum AuthError { + Config(&'static str), + MissingAssertion, + UnsupportedAlgorithm, + MissingKeyId, + UnknownKey, + Jwt(jsonwebtoken::errors::Error), + Jwks(reqwest::Error), + Serde(serde_json::Error), +} + +impl fmt::Display for AuthError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "invalid assertion") + } +} + +impl std::error::Error for AuthError {} + +#[derive(Clone)] +pub struct TrustedProxyAuth { + config: Config, + client: reqwest::Client, +} + +impl TrustedProxyAuth { + pub fn new(config: Config) -> Result { + validate_config(&config)?; + Ok(Self { + config, + client: reqwest::Client::new(), + }) + } + + pub fn config_from_env() -> Config { + let domain = match std::env::var("TRUSTED_PROXY_AUTH_DOMAIN") { + Ok(value) if !value.is_empty() => value, + _ => default_auth_domain(), + }; + let base = format!("https://{domain}"); + Config { + header: var_or("TRUSTED_PROXY_ASSERTION_HEADER", DEFAULT_ASSERTION_HEADER), + jwks_url: var_or("TRUSTED_PROXY_JWKS_URL", format!("{base}/.well-known/jwks.json")), + issuer: var_or("TRUSTED_PROXY_ISSUER", base.clone()), + audience: var_or("TRUSTED_PROXY_AUDIENCE", base), + public_key: resolve_public_key(), + } + } + + pub async fn verify(&self, token: &str) -> Result { + let header = decode_header(token).map_err(AuthError::Jwt)?; + // Pin the algorithm: never let the token pick a weaker scheme. + if header.alg != Algorithm::RS256 { + return Err(AuthError::UnsupportedAlgorithm); + } + + let decoding_key = self.resolve_decoding_key(&header).await?; + + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&[self.config.issuer.clone()]); + validation.set_audience(&[self.config.audience.clone()]); + validation.required_spec_claims = ["aud", "exp", "iss", "sub"] + .into_iter() + .map(String::from) + .collect(); + + let token_data = + decode::(token, &decoding_key, &validation).map_err(AuthError::Jwt)?; + let claims = serde_json::to_value(&token_data.claims).map_err(AuthError::Serde)?; + + Ok(Identity { + subject: token_data.claims.sub, + email: token_data.claims.email, + name: token_data.claims.name, + claims, + }) + } + + // JWKS is preferred for key rotation. A static public key (PEM) is an + // opt-in alternative for self-signed assertions: when set, verification + // uses it directly and never touches the network. + async fn resolve_decoding_key( + &self, + header: &jsonwebtoken::Header, + ) -> Result { + if let Some(pem) = &self.config.public_key { + return DecodingKey::from_rsa_pem(pem.as_bytes()).map_err(AuthError::Jwt); + } + + let kid = header.kid.clone().ok_or(AuthError::MissingKeyId)?; + let jwks = self + .client + .get(&self.config.jwks_url) + .send() + .await + .map_err(AuthError::Jwks)? + .error_for_status() + .map_err(AuthError::Jwks)? + .json::() + .await + .map_err(AuthError::Jwks)?; + + let jwk = jwks + .keys + .into_iter() + .find(|candidate| candidate.kid == kid) + .ok_or(AuthError::UnknownKey)?; + DecodingKey::from_rsa_components(&jwk.n, &jwk.e).map_err(AuthError::Jwt) + } +} + +pub async fn axum_middleware( + State(auth): State>, + mut request: Request, + next: Next, +) -> Result { + let token = + read_header(request.headers(), &auth.config.header).ok_or(StatusCode::UNAUTHORIZED)?; + let identity = auth + .verify(token) + .await + .map_err(|_| StatusCode::UNAUTHORIZED)?; + request.extensions_mut().insert(identity); + Ok(next.run(request).await) +} + +fn read_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers + .get(name)? + .to_str() + .ok() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +// Reasonable defaults so every setting is optional. The auth domain is derived +// from the host name (`web1.os.example.org` -> `auth.os.example.org`); issuer +// and JWKS come from it. Override any single value with its own env var. +pub const DEFAULT_ASSERTION_HEADER: &str = "X-Trusted-Proxy-Assertion"; + +fn derive_auth_domain(hostname: &str) -> String { + let labels: Vec<&str> = hostname.split('.').filter(|label| !label.is_empty()).collect(); + match labels.len() { + 0 => "auth.localhost".to_string(), + 1 => format!("auth.{}", labels[0]), + _ => format!("auth.{}", labels[1..].join(".")), + } +} + +fn default_auth_domain() -> String { + let host = gethostname::gethostname().to_string_lossy().into_owned(); + derive_auth_domain(&host) +} + +fn resolve_public_key() -> Option { + if let Ok(inline) = std::env::var("TRUSTED_PROXY_PUBLIC_KEY") { + if !inline.is_empty() { + return Some(inline); + } + } + if let Ok(path) = std::env::var("TRUSTED_PROXY_PUBLIC_KEY_FILE") { + if !path.is_empty() { + if let Ok(data) = std::fs::read_to_string(path) { + return Some(data); + } + } + } + None +} + +fn var_or(key: &str, default: impl Into) -> String { + match std::env::var(key) { + Ok(value) if !value.is_empty() => value, + _ => default.into(), + } +} + +fn validate_config(config: &Config) -> Result<(), AuthError> { + if config.header.is_empty() { + return Err(AuthError::Config("header")); + } + if config.issuer.is_empty() { + return Err(AuthError::Config("issuer")); + } + if config.audience.is_empty() { + return Err(AuthError::Config("audience")); + } + if config.public_key.as_deref().unwrap_or("").is_empty() && config.jwks_url.is_empty() { + return Err(AuthError::Config("jwks_url or public_key")); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct Jwks { + keys: Vec, +} + +#[derive(Debug, Deserialize)] +struct Jwk { + kid: String, + n: String, + e: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Claims { + sub: String, + #[serde(default)] + email: Option, + #[serde(default)] + name: Option, + iss: String, + aud: String, + exp: usize, + #[serde(flatten)] + extra: BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{to_bytes, Body}, + extract::Extension, + middleware, + response::IntoResponse, + routing::get, + Router, + }; + use serde_json::from_str; + use std::{collections::HashMap, net::SocketAddr}; + use tokio::{net::TcpListener, task::JoinHandle}; + use tower::ServiceExt; + + fn fixtures(name: &str) -> String { + std::fs::read_to_string(format!( + "{}/../testdata/{}", + env!("CARGO_MANIFEST_DIR"), + name + )) + .unwrap() + } + + async fn start_jwks_server() -> (String, JoinHandle<()>) { + let jwks = fixtures("jwks.json"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address: SocketAddr = listener.local_addr().unwrap(); + let app = Router::new().route( + "/jwks.json", + get(move || { + let body = jwks.clone(); + async move { body.into_response() } + }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{address}/jwks.json"), server) + } + + fn config(jwks_url: String) -> Config { + Config { + header: "x-trusted-proxy-assertion".into(), + jwks_url, + issuer: "https://issuer.example.test".into(), + audience: "my-service".into(), + public_key: None, + } + } + + #[test] + fn derives_auth_domain_from_host() { + assert_eq!(derive_auth_domain("web1.os.example.org"), "auth.os.example.org"); + assert_eq!(derive_auth_domain("host"), "auth.host"); + } + + #[tokio::test] + async fn verify_accepts_valid_with_static_public_key() { + let tokens: HashMap = from_str(&fixtures("tokens.json")).unwrap(); + let mut cfg = config(String::new()); + cfg.public_key = Some(fixtures("public-key.pem")); + let auth = TrustedProxyAuth::new(cfg).unwrap(); + + let identity = auth.verify(&tokens["valid"]).await.unwrap(); + assert_eq!(identity.subject, "user-123"); + + assert!(auth.verify(&tokens["invalid_signature"]).await.is_err()); + } + + #[tokio::test] + async fn verify_covers_all_fixture_cases() { + let tokens: HashMap = from_str(&fixtures("tokens.json")).unwrap(); + let (jwks_url, server) = start_jwks_server().await; + let auth = TrustedProxyAuth::new(config(jwks_url)).unwrap(); + + let identity = auth.verify(&tokens["valid"]).await.unwrap(); + assert_eq!(identity.subject, "user-123"); + + for key in [ + "expired", + "invalid_signature", + "wrong_issuer", + "wrong_audience", + "malformed", + ] { + assert!( + auth.verify(&tokens[key]).await.is_err(), + "expected {key} to fail" + ); + } + + server.abort(); + } + + #[tokio::test] + async fn middleware_rejects_missing_and_accepts_valid_assertions() { + let tokens: HashMap = from_str(&fixtures("tokens.json")).unwrap(); + let (jwks_url, server) = start_jwks_server().await; + let auth = Arc::new(TrustedProxyAuth::new(config(jwks_url)).unwrap()); + + async fn protected(Extension(identity): Extension) -> String { + identity.subject + } + + let app = Router::new() + .route("/", get(protected)) + .layer(middleware::from_fn_with_state( + auth.clone(), + axum_middleware, + )); + + let valid_response = app + .clone() + .oneshot( + Request::builder() + .uri("/") + .header("x-trusted-proxy-assertion", &tokens["valid"]) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(valid_response.status(), StatusCode::OK); + let body = to_bytes(valid_response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(body, "user-123"); + + let missing_response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED); + + server.abort(); + } +} diff --git a/proxy-auth-lib/scripts/README.md b/proxy-auth-lib/scripts/README.md new file mode 100644 index 00000000..f8f01e8d --- /dev/null +++ b/proxy-auth-lib/scripts/README.md @@ -0,0 +1,31 @@ +# proxy-auth-lib release scripts + +Each script publishes one language package to its registry. They are the same +commands CI runs (see [`.github/workflows/proxy-auth-lib-release.yml`](../../.github/workflows/proxy-auth-lib-release.yml)), +so a release can be reproduced or debugged locally. + +All scripts take the release version as their first argument and call +[`stamp-version.sh`](stamp-version.sh) first, which writes that single version +into every package manifest (npm, JSR, PyPI, crates.io, Meteor). The Go module +has no version field — it is released by a git tag. + +| Script | Registry | Auth | +| --- | --- | --- | +| `publish-npm.sh` | npm (`@mieweb/trusted-proxy-auth`) | `NODE_AUTH_TOKEN` | +| `publish-jsr.sh` | JSR (`@mieweb/trusted-proxy-auth`) | OIDC in CI / browser locally | +| `build-pypi.sh` | builds dist for PyPI (`trusted-proxy-auth`) | upload via OIDC in CI / token locally | +| `publish-crates.sh` | crates.io (`trusted-proxy-auth`) | `CARGO_REGISTRY_TOKEN` | +| `publish-meteor.sh` | Atmosphere (`mieweb:accounts-proxy-auth`) | `meteor login` / `METEOR_SESSION` | +| `publish-go.sh` | Go proxy (`.../proxy-auth-lib/go`) | git tag push | +| `smoke-runtimes.sh` | installs published npm package under Bun and Deno | none | + +```bash +# Local dry run of a single registry (example: npm) +NODE_AUTH_TOKEN=*** ./publish-npm.sh 1.2.3 +``` + +The first Meteor publish needs the package created: + +```bash +./publish-meteor.sh 1.2.3 --create +``` diff --git a/proxy-auth-lib/scripts/build-pypi.sh b/proxy-auth-lib/scripts/build-pypi.sh new file mode 100755 index 00000000..068ef076 --- /dev/null +++ b/proxy-auth-lib/scripts/build-pypi.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Build the trusted-proxy-auth sdist + wheel into python/dist/. +# Upload is handled separately: CI uses PyPI Trusted Publishing (OIDC); for a +# local release run `python -m twine upload python/dist/*` with your token. +set -euo pipefail +version="${1:?usage: build-pypi.sh }" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$here/stamp-version.sh" "$version" +cd "$here/../python" +rm -rf dist +python -m pip install --quiet --upgrade build +python -m build diff --git a/proxy-auth-lib/scripts/publish-crates.sh b/proxy-auth-lib/scripts/publish-crates.sh new file mode 100755 index 00000000..b909e5cf --- /dev/null +++ b/proxy-auth-lib/scripts/publish-crates.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Publish the Rust crate trusted-proxy-auth to crates.io. +# Requires CARGO_REGISTRY_TOKEN in the environment. +set -euo pipefail +version="${1:?usage: publish-crates.sh }" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$here/stamp-version.sh" "$version" +cd "$here/../rust" +cargo publish --allow-dirty diff --git a/proxy-auth-lib/scripts/publish-go.sh b/proxy-auth-lib/scripts/publish-go.sh new file mode 100755 index 00000000..1bcd008b --- /dev/null +++ b/proxy-auth-lib/scripts/publish-go.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# "Publish" the Go module. Go modules are released purely by a version tag. +# For a module in a subdirectory the tag must be prefixed with that path, so +# github.com/mieweb/opensource-server/proxy-auth-lib/go resolves to the tag +# proxy-auth-lib/go/v. After tagging we warm the public module proxy +# so `go get` and pkg.go.dev see the release immediately. +set -euo pipefail +version="${1:?usage: publish-go.sh }" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +module="github.com/mieweb/opensource-server/proxy-auth-lib/go" +tag="proxy-auth-lib/go/v${version}" + +cd "$here/.." +go -C go vet ./... + +if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "Tag $tag already exists; skipping tag creation" +else + git tag "$tag" + git push origin "$tag" +fi + +# Trigger indexing on the Go module proxy (best effort). +curl -fsSL "https://proxy.golang.org/${module}/@v/v${version}.info" \ + && echo "Go module ${module}@v${version} indexed" diff --git a/proxy-auth-lib/scripts/publish-jsr.sh b/proxy-auth-lib/scripts/publish-jsr.sh new file mode 100755 index 00000000..dfc0e8cb --- /dev/null +++ b/proxy-auth-lib/scripts/publish-jsr.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Publish the same package to JSR (jsr.io) for Deno/Bun/Node consumers. +# In GitHub Actions authentication is via OIDC (id-token: write) — no token. +# Locally, `npx jsr publish` opens a browser to authenticate. +set -euo pipefail +version="${1:?usage: publish-jsr.sh }" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$here/stamp-version.sh" "$version" +cd "$here/../nodejs" +npx --yes jsr publish --allow-dirty diff --git a/proxy-auth-lib/scripts/publish-meteor.sh b/proxy-auth-lib/scripts/publish-meteor.sh new file mode 100755 index 00000000..961be671 --- /dev/null +++ b/proxy-auth-lib/scripts/publish-meteor.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Publish the Meteor package mieweb:accounts-proxy-auth to Atmosphere. +# Requires a logged-in Meteor session: in CI restore METEOR_SESSION, locally +# run `meteor login` first. Pass --create on the very first publish. +set -euo pipefail +version="${1:?usage: publish-meteor.sh [--create]}" +shift || true +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$here/stamp-version.sh" "$version" +cd "$here/../meteor/accounts-proxy-auth" +meteor publish "$@" diff --git a/proxy-auth-lib/scripts/publish-npm.sh b/proxy-auth-lib/scripts/publish-npm.sh new file mode 100755 index 00000000..36c1dabc --- /dev/null +++ b/proxy-auth-lib/scripts/publish-npm.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Publish the npm package @mieweb/trusted-proxy-auth. +# Requires NODE_AUTH_TOKEN (npm automation token) in the environment. +set -euo pipefail +version="${1:?usage: publish-npm.sh }" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$here/stamp-version.sh" "$version" +cd "$here/../nodejs" +npm publish --access public diff --git a/proxy-auth-lib/scripts/smoke-runtimes.sh b/proxy-auth-lib/scripts/smoke-runtimes.sh new file mode 100755 index 00000000..59ac0170 --- /dev/null +++ b/proxy-auth-lib/scripts/smoke-runtimes.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Smoke test: prove the published npm package installs and imports cleanly under +# Bun and Deno (both resolve it from the npm registry). Run after the npm +# publish has propagated. Pass the version to pin the install. +set -euo pipefail +version="${1:?usage: smoke-runtimes.sh }" +pkg="@mieweb/trusted-proxy-auth@${version}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$work" + +if command -v bun >/dev/null 2>&1; then + echo "== bun ==" + bun init -y >/dev/null + bun add "$pkg" + bun --eval "import { loadConfigFromEnv } from '@mieweb/trusted-proxy-auth'; if (typeof loadConfigFromEnv !== 'function') process.exit(1); console.log('bun ok');" +else + echo "bun not installed; skipping bun smoke test" >&2 +fi + +if command -v deno >/dev/null 2>&1; then + echo "== deno ==" + deno eval "import { loadConfigFromEnv } from 'npm:${pkg}'; if (typeof loadConfigFromEnv !== 'function') Deno.exit(1); console.log('deno ok');" +else + echo "deno not installed; skipping deno smoke test" >&2 +fi diff --git a/proxy-auth-lib/scripts/stamp-version.sh b/proxy-auth-lib/scripts/stamp-version.sh new file mode 100755 index 00000000..8e26ac88 --- /dev/null +++ b/proxy-auth-lib/scripts/stamp-version.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Stamp a single version into every proxy-auth-lib package manifest. +# +# ./stamp-version.sh 1.2.3 +# +# Portable (perl) in-place edits so the same command runs on macOS and Linux/CI. +# The Go module carries no version field — it is released via a git tag. +set -euo pipefail + +version="${1:?usage: stamp-version.sh }" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export TPA_VERSION="$version" + +# Node / npm + JSR (same exported package, two registries) +perl -pi -e 's/("version":\s*)"[^"]*"/$1"$ENV{TPA_VERSION}"/ if !$d && /"version"/ and $d=1' \ + "$root/nodejs/package.json" +perl -pi -e 's/("version":\s*)"[^"]*"/$1"$ENV{TPA_VERSION}"/ if !$d && /"version"/ and $d=1' \ + "$root/nodejs/jsr.json" + +# Python (pyproject [project] version) +perl -pi -e 's/^version = "[^"]*"/version = "$ENV{TPA_VERSION}"/' \ + "$root/python/pyproject.toml" + +# Rust ([package] version — anchored so dependency versions are untouched) +perl -pi -e 's/^version = "[^"]*"/version = "$ENV{TPA_VERSION}"/' \ + "$root/rust/Cargo.toml" + +# Meteor (package version + the audited npm core it depends on) +perl -pi -e "s/(version:\\s*)'[^']*'/\${1}'\$ENV{TPA_VERSION}'/ if !\$d && /version:/ and \$d=1" \ + "$root/meteor/accounts-proxy-auth/package.js" +perl -pi -e "s/('\@mieweb\/trusted-proxy-auth':\s*)'[^']*'/\${1}'\$ENV{TPA_VERSION}'/" \ + "$root/meteor/accounts-proxy-auth/package.js" + +echo "Stamped proxy-auth-lib packages to v$version" diff --git a/proxy-auth-lib/testdata/jwks.json b/proxy-auth-lib/testdata/jwks.json new file mode 100644 index 00000000..e22e45cc --- /dev/null +++ b/proxy-auth-lib/testdata/jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "kid": "test-key-1", + "use": "sig", + "alg": "RS256", + "n": "ycrqYbnYgzcEXRpUwkhnpBqljfuU71__kH0mpDBDMrM2s_NKV4mNOuWsf4LWZa1xnDAx_GkC592FhApgk8cSy9pYh8XZAyYOEU1-9MNA_Hb51-R73-p_ieWAOes_fI1_23ZSurXga4l39t7d9TSKglmgthG1KFnQFZu5RLhKL3gpNI_0u0CeH5s-DQ5rZBIfiysqaP-CucPB2NmhHTDGBA039iULuRD31_IWLpMtHHgUotQ1r01hDccAgz_Z2Kxhmw7F0z7yisCeAi1tu9LRY2auSa9JJ-3Wd6IOE95OIUsri11qnlmhjzUbCw2P_-a8rQ_x0zH3MENAI7GRlkeQ6w", + "e": "AQAB" + } + ] +} diff --git a/proxy-auth-lib/testdata/public-key.pem b/proxy-auth-lib/testdata/public-key.pem new file mode 100644 index 00000000..05fc0d8c --- /dev/null +++ b/proxy-auth-lib/testdata/public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAycrqYbnYgzcEXRpUwkhn +pBqljfuU71//kH0mpDBDMrM2s/NKV4mNOuWsf4LWZa1xnDAx/GkC592FhApgk8cS +y9pYh8XZAyYOEU1+9MNA/Hb51+R73+p/ieWAOes/fI1/23ZSurXga4l39t7d9TSK +glmgthG1KFnQFZu5RLhKL3gpNI/0u0CeH5s+DQ5rZBIfiysqaP+CucPB2NmhHTDG +BA039iULuRD31/IWLpMtHHgUotQ1r01hDccAgz/Z2Kxhmw7F0z7yisCeAi1tu9LR +Y2auSa9JJ+3Wd6IOE95OIUsri11qnlmhjzUbCw2P/+a8rQ/x0zH3MENAI7GRlkeQ +6wIDAQAB +-----END PUBLIC KEY----- diff --git a/proxy-auth-lib/testdata/tokens.json b/proxy-auth-lib/testdata/tokens.json new file mode 100644 index 00000000..03626cf6 --- /dev/null +++ b/proxy-auth-lib/testdata/tokens.json @@ -0,0 +1,8 @@ +{ + "valid": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLnRlc3QiLCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS50ZXN0IiwiYXVkIjoibXktc2VydmljZSIsImV4cCI6MjUyNDYwODAwMCwiaWF0IjoxNzA0MDY3MjAwfQ.oEvt8dCDUV91dwGMCeiD4AQqH-K6uE3Td80Wl3BgLSD6aim8U_4S8A_MBJ_pKIvPt2Wy3FluUzOc0H1_lBny7GchB-1PXeArgwmqISD1q_J3QcdFo2V3KEYbhqGS4mtk8dzKPaCO_rtHEiJALxx5Eh30qeHTL5EWYVuJPo__3kc3zqYrwNAzE8psMQIMtWmXb4QTpHxrCSEAlKF_bt9o_iS1ks1xguFn7pK4O-h350QB9kdd3IgsSM0AQdt5YK1QfgJmelWi5DBYXEI44Ki0i1I4TfdMBJzlFZsIr9rM0QVfjJt0PqmC2E7YKC9SOO_6EkJMb6eZNaQOkn1klHuz6A", + "expired": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLnRlc3QiLCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS50ZXN0IiwiYXVkIjoibXktc2VydmljZSIsImV4cCI6OTQ2Njg0ODAwLCJpYXQiOjk0NjY4MTIwMH0.Q1fg9nFLfnX444GJDWY7MhPlNntN6n9gC3I3hRakzDkdQBvntjIhObSUA1ot0SnNvFfHOaeXNrOlYeWTWB22CQuLil5p7pVFOI8LFV-eajsq3AMJCG2KSBdNODeJBZfYC0nR8Y8AA1kC_ZE6YnN2QgOlww93wkDtGRi_62qVJsIA3dmRItP9djACZqYgyaPOuFn1vKLV2RRHxywUgP509OFaIpsPTVYJ0FhRbZef26_ukEmdtVhQ_G1cmGt3DSgP6DRJYShwPeQl0W91492Hr0p-L1yd_W-N4yzLQ-_I3jeavP270sAH_NEcRhHoKFIbGwhaRcDszNtoCdL-nnN7jQ", + "wrong_issuer": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLnRlc3QiLCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwiaXNzIjoiaHR0cHM6Ly9vdGhlci1pc3N1ZXIuZXhhbXBsZS50ZXN0IiwiYXVkIjoibXktc2VydmljZSIsImV4cCI6MjUyNDYwODAwMCwiaWF0IjoxNzA0MDY3MjAwfQ.K-k_ew0nfTDD1D_iT2EPI3eIYMOZ5ltCZG2sZv3bwAKY5clH9ipV4YY5tGUMd5G6iHWNFeOVSUzFmTD6LdOZdTpX6HGaGQwiUKQ49m_JM3TAQXikg4FCehVryWoYuNSJjzOL805g6WbDr-hh9csCqoLfOSqmkiW8rBz1o_pwWYPwIlzrfAXES8DOtNASKogqgvCia37_bghATMjplHQTJU2l0Csv-CsL3WzGGV7DcTqEua3Q6lYEqGRIJEL7l000-H2bXX-SfDl0_6MdYy2r1zBjo2ui38GJCU-ktP442SLWjQqh_c_xFjNpb-_TSZPiiNZv8m26VfC_2q6xosGZEg", + "wrong_audience": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLnRlc3QiLCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS50ZXN0IiwiYXVkIjoib3RoZXItc2VydmljZSIsImV4cCI6MjUyNDYwODAwMCwiaWF0IjoxNzA0MDY3MjAwfQ.JXYcn9oTFgNo7EdYv2vrMTWL7wbQBsAJ_FxYZWyDVJ1VnHTXK03_7GF06Uu1I-mgafFk5wWzcv3_H_iEvsGxyblPmW7ZtkXZBRCCmbMBcJc-jycP7DOnwX0tYqrQas7Xmt0GMa-AXXmcknWA1Dv9RCvUDfd31K7C925qOV76k7kieVx0F0puh5tY4B72R_7BjoojkqpfWYrb42fEwkdiJsLcYS3dzruV2wBCMVusYsdE2QB55e-wu0QCvyBoYqWQR9jE5oyIKvOMeMtZoooMn1Lc_gdcvxJ1-R0btYDiamNVFL9fPoomhZ5Rn4hXQOqlSIDjse9gucQ2_B1BJZoAFg", + "invalid_signature": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLnRlc3QiLCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS50ZXN0IiwiYXVkIjoibXktc2VydmljZSIsImV4cCI6MjUyNDYwODAwMCwiaWF0IjoxNzA0MDY3MjAwfQ.FlnarSTf-ik7XSu1mUh4ZpjVl-dNZWG0hw5AZ3b2sevuS6h_INXQXViUfgU2aEudy0wQHiI7YC6H-lGY2WbLvfDWMJNQ4w97hibhkZR19yyDQhpJhg0NEIUSiV2rajyiKIHMglan2Q7O5MhP0uj2pOqceDzfZcgRhtzN5A-sZwNZ9i6Agovq5YBDDN-ZMtwlFMz7T14cDj353bY-C0Uspp0xKk5FRAzDaOy-_xOEJLMaL9bhcjpXzDdoSEqflbE4yvf3imdfmsmZuNfCUk0OMve-_H7RR7xm3h2FEH35m7dz371FePgTsc4vPEnIoo625ex6gvwKhEiGxyOZ4JlIBw", + "malformed": "not-a.jwt" +}