← Back to README · 1 of 4 · Next: UI Components →
This guide provides step-by-step instructions, installation commands, configuration guides, and production-grade code examples for integrating and deploying the wasi-auth framework.
| Requirement | Version | Notes |
|---|---|---|
| Rust edition | 2024 |
Required for workspace crates |
| Rust stable channel | ≥ 1.93.0 | Target compilation |
| WASI target | wasm32-wasip2 |
WebAssembly target |
To install the WASI target:
rustup target add wasm32-wasip2| Tool | Install Command | Purpose |
|---|---|---|
| Wasmtime | wasmtime.dev (≥ 45.0.0) | Serves composed WASI components |
| wac-cli | cargo install wac-cli |
Links/composes WASI components together |
| wasm-tools | cargo install wasm-tools |
Inspects and manipulates Wasm binaries |
| just | cargo install just |
Task automation and run commands |
The following crate versions are required for WASI compatibility:
| Crate | Version | Notes |
|---|---|---|
wasi |
0.14.7 |
WASI Preview 2 bindings |
wit-bindgen |
0.33.0 |
WIT binding code generation |
rsa |
0.9 |
Pure-Rust RSA for JWT signing |
sha2 |
0.10 |
SHA-256 hashing for JWT |
leptos |
0.8.9 |
Leptos framework (example/integration) |
leptos_wasi |
0.3.x |
WASI integration for Leptos (external) |
Note: The
leptos-auth-demoexample depends onleptos_wasiwhich must be cloned separately at the path../../../leptos_wasirelative to the example crate.
Build the workspace crates and WASI components:
# Build all workspace crates for the host (tests, mock server)
cargo build --workspace
# Build WASI components
cargo build -p wasi-auth-interceptor --target wasm32-wasip2 --release
cargo build -p leptos-auth-demo --target wasm32-wasip2 --releaseUse wac to compose the interceptor with the downstream web app:
wac plug \
target/wasm32-wasip2/release/wasi_auth_interceptor.wasm \
--plug target/wasm32-wasip2/release/leptos_auth_demo.wasm \
-o composed.wasmRun the composed application under Wasmtime, passing key config values as environment variables:
wasmtime serve composed.wasm \
--addr 127.0.0.1:8080 \
--wasi inherit-network \
--env JWT_PUBLIC_KEY="$(cat public_key.pem)" \
--env JWT_AUDIENCE="my-app" \
--env JWT_ISSUER="my-auth-server"Run test checks or execute individual examples:
# Run all workspace formatting, clippy lints, and test checks:
just check
# Run individual examples (e.g. totp, magic_link, oauth, otp, storage_backends):
just example <name>The standalone wasi-auth-interceptor acts as an authentication gateway proxy, exporting and importing wasi:http/incoming-handler@0.2.9. It sits in front of your application component, strips client-injected headers, cryptographically validates the JWT session, injects verified identity headers, and forwards requests.
Request (Browser) ---> [wasi-auth-interceptor] (inspects cookie/JWT)
|
(if valid) v (injects X-User-Id)
[Your Web App (e.g. Leptos)]
You can configure path bypasses and redirection rules using a wasi-auth.toml file in the working directory:
[auth]
# Paths bypassing authentication checks (supports suffix wildcards)
public_paths = [
"/",
"/login",
"/signup",
"/static/*",
"/pkg/*"
]
# Path to redirect unauthenticated GET requests to
login_redirect = "/login"
[jwt]
# Expected claims
audience = "my-application"
issuer = "https://auth.example.com"Ensure the WASI target is installed and compile:
rustup target add wasm32-wasip2
cargo build -p wasi-auth-interceptor --target wasm32-wasip2 --release
cargo build -p leptos-auth-demo --target wasm32-wasip2 --releaseUse wac-cli to compose the interceptor with your web app:
wac plug \
target/wasm32-wasip2/release/wasi_auth_interceptor.wasm \
--plug target/wasm32-wasip2/release/leptos_auth_demo.wasm \
-o composed_app.wasmServe using Wasmtime:
wasmtime serve composed_app.wasm \
--addr 127.0.0.1:8080 \
--wasi inherit-network \
--env JWT_PUBLIC_KEY="$(cat public_key.pem)" \
--env JWT_AUDIENCE="my-application" \
--env JWT_ISSUER="https://auth.example.com"In Library Mode, your Leptos application is directly responsible for extracting the JWT session from incoming cookies or headers and verifying the signature in-process using public keys or a dynamic JWKS cache.
Inside a Leptos server function or SSR route handler, call extract_session_from_parts_with_options to parse and validate the request:
use leptos::prelude::*;
use leptos_wasi_auth::{
extract_session_from_parts_with_options,
CookieOptions, SameSite, build_set_cookie_header
};
use wasi_auth_core::jwt::ValidationOptions;
#[server(LoginUser, "/api")]
pub async fn login_user(email: String, secret_otp: String) -> Result<bool, ServerFnError> {
let state = use_context::<AppState>().ok_or_else(|| ServerFnError::new("State missing"))?;
// 1. Verify OTP against DB
let is_valid = state.storage.verify_otp(&email, &secret_otp)
.map_err(|e| ServerFnError::new(format!("Storage error: {:?}", e)))?;
if !is_valid {
return Err(ServerFnError::new("Invalid OTP code"));
}
// 2. Issue a JWT Session
let claims = wasi_auth_core::jwt::Claims {
sub: email.clone(),
iss: "my-app-issuer".to_string(),
aud: "my-app-audience".to_string(),
exp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() + 3600, // 1 hour expiry
iat: 0,
nbf: None,
jti: None,
roles: vec!["user".to_string()],
name: Some(email.split('@').next().unwrap_or("User").to_string()),
email: Some(email),
};
let token = wasi_auth_core::jwt::generate_jwt(&claims, &state.private_key_pem, Some("key-v1"))
.map_err(|e| ServerFnError::new(format!("Crypto error: {:?}", e)))?;
// 3. Register session in database
state.storage.store_session(&token, &claims.sub, &claims.roles, claims.exp)
.map_err(|e| ServerFnError::new(format!("Storage error: {:?}", e)))?;
// 4. Inject Set-Cookie Header into the Response
if let Some(resp_opts) = use_context::<leptos_wasi::response::ResponseOptions>() {
let cookie_opts = CookieOptions {
name: "__Host-jwt".to_string(),
http_only: true,
secure: true,
same_site: SameSite::Lax,
path: "/".to_string(),
max_age_secs: Some(3600),
};
let cookie_value = build_set_cookie_header(&token, &cookie_opts);
resp_opts.insert_header(
http::header::SET_COOKIE,
http::HeaderValue::from_str(&cookie_value).unwrap(),
);
}
Ok(true)
}Extract user session context in routing logic:
#[component]
pub fn Dashboard() -> impl IntoView {
// Session context is provided globally in SSR / Hydration flow
let session = use_context::<leptos_wasi_auth::UserSession>();
view! {
<div>
{match session {
Some(s) => view! {
<h1>"Welcome back, " {s.name.clone().unwrap_or_default()}</h1>
<p>"Roles: " {s.roles.join(", ")}</p>
}.into_any(),
None => view! {
<p>"Please log in."</p>
}.into_any()
}}
</div>
}
}In Gateway Mode, the standalone interceptor acts as the TLS and JWT validation gateway. It strips spoof headers and injects trusted identity headers. Your Leptos application simply trusts these upstream headers.
Set the environment variable:
TRUST_PROXY_HEADERS=trueOr enable programmatically inside your initialization code (e.g., in main.rs):
leptos_wasi_auth::set_trust_proxy_headers(true);#[server(GetUserInfo, "/api")]
pub async fn get_user_info() -> Result<String, ServerFnError> {
// Retrieve parts from the Leptos context
let parts = use_context::<http::request::Parts>()
.ok_or_else(|| ServerFnError::new("Request parts missing"))?;
// Direct mode parameters (keys/aud/iss) can be None as proxy headers are trusted
let session = leptos_wasi_auth::extract_session_from_parts::<wasi_auth_traits::InMemoryStorage>(
&parts,
None, // no storage checks needed when trusting upstream proxy
None,
None,
None
).map_err(|e| ServerFnError::new(format!("Auth error: {:?}", e)))?;
if let Some(s) = session {
Ok(format!("Logged in as: {} with roles: {:?}", s.user_id, s.roles))
} else {
Err(ServerFnError::new("Unauthorized"))
}
}You can swap out the default in-memory or Spin SDK key-value storage for custom backends (such as Redis, DynamoDB, or PostgreSQL) and delivery channels (SendGrid, AWS SES) by implementing the core traits.
Below is an example implementing AuthStorage mapped to an external key-value database connection:
use wasi_auth_traits::{AuthStorage, AuthError, Session};
pub struct MyRedisStorage {
redis_client: RedisClient,
}
impl AuthStorage for MyRedisStorage {
fn store_session(&self, session_id: &str, user_id: &str, roles: &[String], expires_at: u64) -> Result<(), AuthError> {
let key = format!("session:{}", session_id);
let roles_csv = roles.join(",");
self.redis_client.set_with_expiry(&key, &format!("{}:{}", user_id, roles_csv), expires_at)
.map_err(|e| AuthError::Storage(e.to_string()))
}
fn get_session(&self, session_id: &str) -> Result<Option<Session>, AuthError> {
let key = format!("session:{}", session_id);
match self.redis_client.get(&key) {
Ok(Some(val)) => {
let parts: Vec<&str> = val.splitn(2, ':').collect();
if parts.len() == 2 {
Ok(Some(Session {
session_id: session_id.to_string(),
user_id: parts[0].to_string(),
roles: parts[1].split(',').map(|s| s.to_string()).collect(),
expires_at: 0, // Set correctly if required
}))
} else {
Err(AuthError::Storage("Malformed session string".to_string()))
}
}
Ok(None) => Ok(None),
Err(e) => Err(AuthError::Storage(e.to_string())),
}
}
fn delete_session(&self, session_id: &str) -> Result<(), AuthError> {
let key = format!("session:{}", session_id);
self.redis_client.del(&key).map_err(|e| AuthError::Storage(e.to_string()))
}
fn store_otp(&self, email: &str, otp: &str, expires_at: u64) -> Result<(), AuthError> {
let key = format!("otp:{}", email);
self.redis_client.set_with_expiry(&key, otp, expires_at)
.map_err(|e| AuthError::Storage(e.to_string()))
}
fn verify_otp(&self, email: &str, otp: &str) -> Result<bool, AuthError> {
let key = format!("otp:{}", email);
match self.redis_client.get(&key) {
Ok(Some(stored_val)) => {
let _ = self.redis_client.del(&key); // Single use consumption
Ok(stored_val == otp)
}
_ => Ok(false),
}
}
fn store_totp_secret(&self, email: &str, secret: &str) -> Result<(), AuthError> {
let key = format!("totp:{}", email);
self.redis_client.set(&key, secret).map_err(|e| AuthError::Storage(e.to_string()))
}
fn get_totp_secret(&self, email: &str) -> Result<Option<String>, AuthError> {
let key = format!("totp:{}", email);
self.redis_client.get(&key).map_err(|e| AuthError::Storage(e.to_string()))
}
fn delete_totp_secret(&self, email: &str) -> Result<(), AuthError> {
let key = format!("totp:{}", email);
self.redis_client.del(&key).map_err(|e| AuthError::Storage(e.to_string()))
}
fn blacklist_jti(&self, jti: &str, expires_at: u64) -> Result<(), AuthError> {
let key = format!("blacklist:{}", jti);
self.redis_client.set_with_expiry(&key, "1", expires_at)
.map_err(|e| AuthError::Storage(e.to_string()))
}
fn is_jti_blacklisted(&self, jti: &str) -> Result<bool, AuthError> {
let key = format!("blacklist:{}", jti);
match self.redis_client.get(&key) {
Ok(Some(_)) => Ok(true),
_ => Ok(false),
}
}
fn cleanup_expired(&self) -> Result<(), AuthError> {
// Handled automatically by Redis TTL expiry settings
Ok(())
}
}Here is an example sending transaction verification emails via SendGrid's API over standard outbound HTTP:
use wasi_auth_traits::{EmailSender, AuthError};
pub struct SendGridEmailSender {
api_key: String,
}
impl EmailSender for SendGridEmailSender {
fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), AuthError> {
let payload = serde_json::json!({
"personalizations": [{ "to": [{ "email": to }] }],
"from": { "email": "noreply@example.com" },
"subject": subject,
"content": [{ "type": "text/plain", "value": body }]
});
// Use standard outgoing HTTP requests supported on WASI (wasi:http/outgoing-handler)
let response = spin_sdk::http::send(
http::Request::builder()
.method("POST")
.uri("https://api.sendgrid.com/v3/mail/send")
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.body(Some(serde_json::to_vec(&payload).unwrap().into()))
.unwrap()
).map_err(|e| AuthError::Email(format!("HTTP transport error: {:?}", e)))?;
if response.status().is_success() {
Ok(())
} else {
Err(AuthError::Email(format!("SendGrid rejected request: {}", response.status())))
}
}
}#[server(EnrollTotp, "/api")]
pub async fn enroll_totp(email: String) -> Result<String, ServerFnError> {
let state = use_context::<AppState>().ok_or_else(|| ServerFnError::new("State missing"))?;
// Generates secret, stores it in database, and returns standard provisioning URI
let (_secret, uri) = leptos_wasi_auth::register_totp(&email, "MyCoolService", &*state.storage)
.map_err(|e| ServerFnError::new(format!("TOTP enrollment failed: {:?}", e)))?;
// Return provisioning URI (suitable for displaying as a QR code to the user)
Ok(uri)
}#[server(VerifyTotp, "/api")]
pub async fn verify_totp(email: String, code: String) -> Result<bool, ServerFnError> {
let state = use_context::<AppState>().ok_or_else(|| ServerFnError::new("State missing"))?;
// Verifies code with ±1 time-step drift window and enforces single-use replay protection
let is_valid = leptos_wasi_auth::verify_totp_login(&email, &code, &*state.storage)
.map_err(|e| ServerFnError::new(format!("Verification failed: {:?}", e)))?;
if !is_valid {
return Err(ServerFnError::new("Invalid or replayed TOTP code"));
}
// Proceed to create a JWT cookie and store the authenticated session ...
Ok(true)
}#[server(RequestMagicLink, "/api")]
pub async fn request_magic_link(email: String) -> Result<String, ServerFnError> {
let state = use_context::<AppState>().ok_or_else(|| ServerFnError::new("State missing"))?;
let callback_base_url = "https://my-app.example.com/magic-callback";
let expiry_seconds = 300; // link valid for 5 minutes
let magic_link_url = leptos_wasi_auth::generate_magic_link(
&email,
callback_base_url,
&state.private_key_pem,
Some("key-v1"), // Optional Key ID (kid)
expiry_seconds,
"my-app-audience",
"my-app-issuer",
).map_err(|e| ServerFnError::new(format!("Magic link generation failed: {:?}", e)))?;
// Send the link via the configured EmailSender trait
state.email_sender.send_email(
&email,
"Your Secure Login Link",
&format!("Click the link to complete login: {}", magic_link_url)
).map_err(|e| ServerFnError::new(format!("Failed to send email: {:?}", e)))?;
Ok("Magic login link has been sent to your email inbox.".to_string())
}When a user clicks the magic link, they are directed to the callback route (e.g. /magic-callback?token=...). Extract the query token and verify it:
#[server(ConsumeMagicLinkToken, "/api")]
pub async fn consume_magic_link_token(token: String) -> Result<bool, ServerFnError> {
let state = use_context::<AppState>().ok_or_else(|| ServerFnError::new("State missing"))?;
// Validates signature, verifies expiration, checks JTI blacklist, and consumes the link (inserts JTI into blacklist)
let email = leptos_wasi_auth::verify_magic_link(
&token,
&state.public_key_pem,
"my-app-audience",
"my-app-issuer",
&*state.storage,
).map_err(|e| ServerFnError::new(format!("Link expired or already consumed: {:?}", e)))?;
// Setup JWT Session Cookie ...
Ok(true)
}← Back to README · 1 of 4 · Next: UI Components →