Rust
The forculus crate verifies the identity token the edge sends your app and gives you the caller. It works with any http::HeaderMap, and has an axum layer and extractor behind a feature.
cargo add forculusIt needs Rust 1.92 or newer and a Tokio runtime. It fetches our signing keys with reqwest over rustls.
The verifier
Section titled “The verifier”Build one Verifier at startup and share it. It caches our signing keys, so building one per request would fetch them every time. It’s Clone, and clones share the cache.
// Once, at startup. Reads FORCULUS_APP_ID, FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL.let verifier = forculus::Verifier::from_env()?;
// Per request, from any http::HeaderMap: axum's, hyper's, your own.let user = verifier.require_user(&headers).await?;println!("hello {}", user.email.as_deref().unwrap_or(&user.sub));from_env fails if any of the three variables is missing. We set them.
The caller is a forculus::User:
pub struct User { pub kind: Kind, // Kind::User, or Kind::Service for a bypass token or an agent pub sub: String, // "usr_…", "service:<token id>", or "agent:<agent id>" pub email: Option<String>, // None for a bypass token or an agent pub role: String, // "viewer", "editor" or "owner" pub workspace_id: String, pub agent_name: Option<String>, // the agent's name when an agent called, None for everyone else}user.is_agent() says whether one of the workspace’s agents made the request. It and agent_name are new in version 0.2.0: with an earlier version, change its version in Cargo.toml to "0.2".
let by = match &user.agent_name { Some(name) => format!("{name} (agent)"), None => user.email.clone().unwrap_or_else(|| user.sub.clone()),};See the SDK overview for what each field means and what the SDK checks.
verifier.verify(token).await checks a token you already have, the same way. To pass the values yourself, use Verifier::new(Config { app_id, issuer, jwks_url, dev: false }). dev is whether FORCULUS_DEV is 1, which only forculus dev sets. It became a field in version 0.3.0.
use forculus::Role;
user.require_role(Role::Editor)?; // Err(Forbidden) unless the caller is an editor or an owner
if user.has_role(Role::Owner) { // show the settings link}Role is ordered: Role::Viewer < Role::Editor < Role::Owner.
Turn on the feature:
cargo add forculus --features axumuse axum::{Router, routing::{get, post}};use forculus::{Role, User, Verifier, axum::Forculus};
let verifier = Verifier::from_env()?;let app = Router::new() .route("/", get(index)) .route("/orders", post(place_order).layer(Forculus::new(verifier.clone()).requires(Role::Editor))) .layer(Forculus::new(verifier.clone())) .with_state(verifier);
async fn index(user: User) -> String { format!("hello {}", user.email.as_deref().unwrap_or(&user.sub))}The Forculus layer verifies before your handler runs, and answers the requests it refuses itself. .requires(Role::Editor) also refuses a caller with less than that role.
User is an extractor. Behind the layer it reads what the layer verified. On a route without the layer it verifies the request itself, using the Verifier in the router’s state, which .with_state(verifier) puts there.
| Status | Body | When |
|---|---|---|
| 401 | unauthorized |
No valid caller |
| 403 | forbidden: this needs editor |
.requires(Role::Editor) and the caller has less |
Both come with Cache-Control: no-store. The rejection type is forculus::axum::Rejection.
Webhooks
Section titled “Webhooks”A delivery on a verified public path arrives with a token of kind webhook. require_webhook checks it and says which provider signed the delivery. require_user refuses a webhook’s token, and require_webhook refuses a person’s.
use forculus::Provider;
let hook = verifier.require_webhook(&headers, &[Provider::Stripe]).await?;// hook.delivery is Stripe's event id. Skip one you've handled.With axum, put the webhook routes in a router of their own behind ForculusWebhook, and merge it after the user layer so that layer doesn’t see the delivery. The Webhook extractor reads what ForculusWebhook verified.
use forculus::{Provider, Webhook, axum::{Forculus, ForculusWebhook}};
let hooks = Router::new() .route("/hooks/stripe", post(stripe)) .layer(ForculusWebhook::new(verifier.clone(), [Provider::Stripe]));let app = Router::new() .route("/", get(index)) .layer(Forculus::new(verifier.clone())) .merge(hooks) .with_state(verifier);
async fn stripe(hook: Webhook, body: String) -> StatusCode { StatusCode::NO_CONTENT}ForculusWebhook answers 401 unauthorized itself to anything but a verified delivery from its providers. The Webhook extractor only reads what the layer verified, and refuses with 401 on a route without it.
Name the providers a route takes. A delivery from any other is refused, and so is every delivery when the list is empty. Provider::from_name("stripe") reads a name from your own config. The delivery is a forculus::Webhook:
pub struct Webhook { pub provider: Provider, // Provider::Stripe and the rest pub pattern: String, // the public path it came in on, like "/hooks/stripe" pub delivery: Option<String>, // the provider's signed id, None where it signs none pub signed_at: Option<u64>, // unix seconds, None where the provider signs no time pub workspace_id: String,}The body arrives byte for byte as the provider sent it, so any body extractor reads it as usual. verifier.verify_webhook(token, &[Provider::Stripe]) checks a webhook’s token you already have.
Errors
Section titled “Errors”require_user and require_webhook return Err(Unauthorized), and require_role returns Err(Forbidden). Their Display says why, for your logs:
| Error | Why |
|---|---|
unauthorized: no identity token |
The request has no X-Forculus-Identity header |
unauthorized: invalid identity token |
The token failed a check |
unauthorized: unknown signing key |
No key in the key set matches the token’s kid |
unauthorized: couldn't fetch the identity keys |
The key set couldn’t be fetched, and there’s no fresh copy |
unauthorized: FORCULUS_APP_ID, FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL must be set |
From from_env or new, when a value is empty |
unauthorized: FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL must use https, except under forculus dev (FORCULUS_DEV=1, on this machine) |
From from_env or new. The issuer or key set is http, and FORCULUS_DEV isn’t 1. Only forculus dev uses http |
unauthorized: not a webhook this route takes |
From require_webhook: a provider the route doesn’t name signed the delivery |
unauthorized: invalid webhook token |
From require_webhook: the token’s webhook claims failed a check |
unauthorized: no provider named |
From require_webhook: the list of providers was empty, so it refuses everything |
unauthorized: no verified webhook |
From the Webhook extractor, on a route without ForculusWebhook |
forbidden: this needs editor |
From require_role(Role::Editor) |
A person’s token given to require_webhook, or a webhook’s given to require_user, fails as invalid identity token, because its audience is wrong for that call.
TLS in a container
Section titled “TLS in a container”The crate trusts the public root certificates it ships with, and the certificates in the file SSL_CERT_FILE names.
A container app’s outbound HTTPS goes through our proxy, and we set SSL_CERT_FILE to that proxy’s certificate. rustls doesn’t read the environment, so the crate reads SSL_CERT_FILE itself. Without it, the key fetch from a container would fail and every caller would be refused.
Run it locally
Section titled “Run it locally”forculus dev runs your app with cargo run on your machine behind a copy of our front door, with real tokens. See Local development.
forculus devNext steps
Section titled “Next steps”- SDK overview: every claim in the token, and each check.
- Local development.
- Webhooks and public paths.