← Auth & IdP overview

v0.3.2 JWT pass-through: keep your existing IdP, point assay-engine at it, accept upstream-minted JWTs natively.

JWT pass-through validation

Already running an IdP? Tell assay-engine who to trust and it accepts JWTs that IdP minted — verifying signatures against the upstream JWKS, refreshing keys in the background, routing tokens by iss claim across multiple issuers. No engine user table, no engine sessions, no schema migrations on the auth side.

What this is

The integration shape every operator who already runs an IdP wanted: keep your existing identity stack (Hydra, Keycloak, Auth0, Okta, your in-house provider — anything that publishes a standard /.well-known/openid-configuration discovery doc), point assay-engine at it, and accept JWTs forwarded by a trusted edge. The engine validates the signature against the upstream's published JWKS, checks iss, aud, exp, and treats the request as that user. Done.

What it isn't

This is not OIDC federation. There's no /login/<slug> redirect, no PKCE, no callback handling. For that case (operators wanting the engine to own the login flow against an upstream provider) see OIDC provider quick-start — assay-engine ALSO ships as a full IdP. The two modes coexist on one binary.

Why it matters

Most comparable auth stacks — Ory Hydra, Keycloak, the Auth0 SDKs — assume you bring your own edge that does the JWT validation and forwards. Few of them ship a single binary that does the validation natively, with JWKS caching, automatic refresh, multi-issuer routing, and a sensible default config — without making you stand up the IdP itself. That's the point of pass-through:

Configure it

One TOML block per trusted issuer. Empty list = pass-through disabled (existing operator-user / admin_api_keys auth still works as before).

# engine.toml
auto_enable_modules = ["auth"]

[server]
bind_addr = "0.0.0.0:3000"
public_url = "${PUBLIC_URL}"

[backend]
type = "postgres"
url = "${DATABASE_URL}"

[[auth.external_issuers]]
issuer_url = "https://hydra.example.com/"
audience = ["my-app"]
jwks_refresh_secs = 3600       # default; minimum 60

# Multiple issuers? Add more blocks. Tokens are routed by `iss` claim.
[[auth.external_issuers]]
issuer_url = "https://login.partner.example.com/"
audience = ["my-app", "shared-with-partner"]

When [[auth.external_issuers]] is non-empty, the engine boots without requiring operator users or admin_api_keys — the upstream IdP is the source of truth. Add either of those alongside if you want a break-glass admin path that survives upstream outage.

How verification flows

     Client request
     Authorization: Bearer eyJhbGc...
            |
            v
     ┌────────────────────────┐
     │ Internal JWT verify    │   Engine-issued tokens (OIDC provider mode)
     │ assay_auth::jwt        │   verify here first — no network round trip.
     └────────────────────────┘
            | failed (or no internal JWT config)
            v
     ┌────────────────────────────────────┐
     │ Peek at `iss` claim (unverified)   │   Cheap base64 split. No crypto.
     └────────────────────────────────────┘
            |
            v
     ┌────────────────────────────────────┐
     │ Match `iss` to a configured        │   Tokens from unconfigured
     │ external issuer's `issuer_url`     │   issuers fall through to 401.
     └────────────────────────────────────┘
            | matched
            v
     ┌────────────────────────────────────┐
     │ jsonwebtoken::decode against the   │   Cached JWKS, looked up by kid.
     │ matching issuer's cached JWKS      │   Refreshed in the background.
     └────────────────────────────────────┘
            |
            v
     Caller { user_id: jwt.sub, source: Jwt }

Typical deployment shape

A reverse proxy (Traefik, nginx, Envoy, …) terminates user sessions against an upstream OIDC provider and forwards an Authorization: Bearer <jwt> header to assay-engine. The proxy doesn't need to validate the JWT itself — that's the engine's job:

[[auth.external_issuers]]
issuer_url = "https://hydra.example.com/"
audience = ["my-app"]

This restores the exact integration model assay 0.12.1 supported via --auth-issuer / --auth-audience CLI flags, in the new TOML config shape, with multi-issuer routing and background JWKS refresh added.

Comparison with other approaches

Approach Components JWKS handling Multi-issuer
nginx auth_request + JWT module nginx + njs / lua-resty-jwt + your app Manual cache + refresh Possible but config-heavy
Envoy JWT filter Envoy + your app Built-in, polling Yes
OAuth2 Proxy in front oauth2-proxy + your app Handled by oauth2-proxy One issuer per proxy
Keycloak / Hydra adapter SDK Library inside your app Library-managed Per-app integration
assay-engine 0.3.2 pass-through One binary Built-in, background refresh, fail-soft Yes — TOML block per issuer

Operational notes

Implementation reference

Other ways into auth