Skip to content

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.

Terminal window
cargo add forculus

It needs Rust 1.92 or newer and a Tokio runtime. It fetches our signing keys with reqwest over rustls.

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:

Terminal window
cargo add forculus --features axum
use 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.

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.

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.

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.

forculus dev runs your app with cargo run on your machine behind a copy of our front door, with real tokens. See Local development.

Terminal window
forculus dev