Skip to content

Embedded gateway: add the crate → full stateless REST/OIDC/forward-auth edge over your gRPC (axum-free, single port; stateful BFF behind opt-in feature) #66

Description

@polaz

Vision — "add the crate, get the whole REST edge" (stateless by default)

A service that already has gRPC (tonic) services + its proto descriptors should get a complete stateless HTTP edge — REST transcoding + OpenAPI + stateless auth (JWT verify / forward-auth / ext-authz) + OIDC discovery/JWKS/userinfo + shield + CORS + health + metrics, all on one port — by adding the crate, with zero HTTP boilerplate and no axum in its own code or Cargo.toml (axum stays transitive, under structured-proxy). cargo add structured-proxy and you ARE a production stateless gateway.

// The entire stateless edge — no axum, no tower, no router, no handler wiring:
structured_proxy::ProxyServer::from_config(cfg)
    .with_descriptors(MY_PROTO_DESCRIPTOR_SET)   // your gRPC → REST + OpenAPI
    .with_auth_decider(my_pdp)                    // optional: in-process forward-auth decision
    .serve()                                      // full stateless edge on one port
    .await?;

Two tiers, escalate only as needed:

  • Tier 1 — zero code. Config + descriptors → the stateless stack (transcode + OpenAPI + OIDC discovery/JWKS + shield + CORS + health + metrics) with sane defaults.
  • Tier 2 — opt-in axum-free hooks. Inject stateless service-specific logic (forward-auth/PDP decision, OIDC discovery/userinfo backing, extra stateless routes) through small traits — without naming an HTTP framework in your crate.

Scope boundary — this crate stays a stateless data plane by default

Per the README Non-goals, structured-proxy is a stateless transcoding data plane with stateless auth primitives. The default build does NOT change that: everything in Tier 1 / Tier 2 above is stateless, and the default feature set compiles zero stateful code. Stateful concerns (BFF sessions, OIDC authorize/token) live behind an opt-in bff Cargo feature that is OFF by default (see "Stateful auth" below). A transcode-only embedder enables nothing extra and pays nothing: no stateful code, no extra dependencies in the binary, cargo tree stays minimal.

Tier-2 hooks (this issue)

AuthDecider — the per-request gate (hot path)

Called on every proxied request (inline gate) and exposed at /verify (for a fronting proxy / Envoy ext-authz HTTP) — same trait, two call sites, near-zero cost, needed either way (not a fork). The fork was the data shape; chosen contract:

pub trait AuthDecider: Send + Sync {
    async fn decide(&self, req: &RequestParts<'_>) -> Decision;
}
pub struct RequestParts<'a> {
    pub method: &'a http::Method,
    pub path: &'a str,
    pub query: Option<&'a str>,
    pub headers: &'a http::HeaderMap,
    pub peer: std::net::SocketAddr,
}
pub enum Decision {
    Allow { inject_headers: http::HeaderMap },   // forwarded upstream
    Deny  { status: http::StatusCode, body: bytes::Bytes },
    Redirect { location: String },               // login flows handled by a fronting/stateful layer
}

Rationale (perf/binary first, then DX):

  • Hot path: borrow-only inputs, one vtable dispatch per request, allocation only for headers the decider chooses to inject. Cheapest possible gate.
  • Binary: sole dep is http (a foundational crate already in the tree via both axum and tonic). Zero new deps, zero growth.
  • axum-free: http is foundational, not an HTTP framework. The embedder never names axum.
  • DX: mirrors the pattern that already exists internally in sid-auth (AuthDecision: Allow(HeaderMap)/Unauthenticated/Forbidden) — we publish an existing shape, no protobuf knowledge required.

Rejected: Envoy CheckRequest/CheckResponse as the in-process contract (forces protobuf marshalling of a deeply-nested AttributeContext on every request — worst per-request cost, no binary saving since envoy-types is already linked for the out-of-process client). The existing config-driven out-of-process ext-authz gRPC client (AuthzConfig) stays as-is for external-PDP users — orthogonal to this hook. Exposing the proxy itself as an ext-authz gRPC server (so it can be someone else's PDP) is deferred to a separate, feature-gated task (adds tonic-server codegen; nobody needs it today).

OidcBackend — stateless OIDC surface only

Discovery (/.well-known/openid-configuration, sid-configuration, webfinger), JWKS, and userinfo (token → claims pass-through). Backed by the embedder's key/client metadata. No authorize/token in the default surface — those are stateful (auth codes, PKCE state, login session) and live behind the bff feature (below).

with_extra_routes(...)axum-free escape hatch and composition point

Register extra stateless routes via a framework-agnostic adapter (request parts → response parts), so the embedder never names axum::Router/Handler. This is also the seam the optional bff feature composes through (below): the stateful surface mounts its endpoints onto the same ProxyServer / port through this adapter, and it stays a general escape hatch for embedder-specific routes.

Stateful auth — opt-in bff feature (OFF by default)

BFF sessions and OIDC authorize/token are stateful (README Non-goal: "session lifecycle is a separate, stateful concern"). Rather than a separate companion crate, this is an opt-in Cargo feature bff, disabled by default, so the crate stays one repo / one version with shared internal types while the default build remains a lean stateless data plane:

  • bff OFF (default): nothing stateful compiles, no extra dependencies pulled, identical to today's stateless data plane. README Non-goals hold verbatim for this build.
  • bff ON (opt-in): compiles in the stateful surface — BFF sessions (cookie ↔ token, server-side token storage, refresh) and the OIDC authorization server (authorize/token with auth codes + PKCE state) — mounted onto the same ProxyServer / port via with_extra_routes. All stateful-only dependencies (cookie/session crates, any store backend) are optional = true and enabled solely by this feature, so they never enter a default cargo tree.

This keeps the crate reusable + lean for pure data-plane users (they pay nothing) while letting embedders that need stateful login flip one feature instead of wiring a second crate.

Migration impact (sid-auth — separate downstream task)

  • sid-auth's stateless edge (transcode + forward-auth + OIDC discovery/JWKS/userinfo + shield + CORS + health + metrics) → ProxyServer via the hooks above; drop axum + tower-http from crates/sid-auth/Cargo.toml; implement AuthDecider (Cedar PDP + JWT verify + header translation) and OidcBackend.
  • sid-auth's stateful BFF + OIDC authorize/token → enable structured-proxy's bff feature (the stateful surface moves into the proxy under the feature gate, composing via with_extra_routes), so sid-auth no longer hand-rolls it. NOT compiled into the default data-plane build.
  • Lands the proxy 2.x / axum 0.8 bump without sid pinning axum.

Acceptance criteria

  • An embedder runs the full stateless edge via ProxyServer::serve() with no axum / tower-http / tonic-web in its own Cargo.toml; cargo tree -i axum shows axum only under structured-proxy.
  • AuthDecider / OidcBackend / with_extra_routes are implementable without referencing axum types; AuthDecider adds zero new dependencies (only http).
  • Standalone binary behavior unchanged (same features, same port semantics).
  • Default build holds the README Non-goals: no session/BFF/stateful-OIDC code compiles unless --features bff is set; all stateful-only deps are optional and gated behind bff.
  • bff feature, when enabled, mounts the stateful surface (BFF sessions + OIDC authorization server) onto the same ProxyServer / port via with_extra_routes.
  • sid-auth's stateless edge migrated end-to-end; gateway (transcode + OIDC discovery + forward-auth + shield + health + metrics) works on one port.

Follow-ups (separate issues)

  • bff feature: stateful BFF (cookie ↔ token sessions) + OIDC authorization server (authorize/token), default-off, composing via with_extra_routes.
  • Proxy-as-ext-authz-gRPC-server (feature-gated).
  • Optional single-port co-served tonic gRPC for embedders that also host gRPC.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions