Rust
The base-idp crate — offline PASETO verification, key rotation, and a typed principal for Rust services.
The Rust SDK is the right tool whenever a Rust service needs to verify Base IdP tokens. The crate handles the PASETO v4.public format, the Ed25519 signature, the implicit assertion, key discovery, and rotation. You hand it a token; it hands you a principal.
Do not hand-roll PASETO verification in Rust. The constants are easy to get wrong (the audience, the implicit-assertion string), and the failure mode is silent: tokens that should verify do not, and you have nothing to debug. Use this crate.
Install
cargo add base-idpWhile the crate is moving toward a crates.io release you can also pin to the git repo or a local path:
[dependencies]
base-idp = { git = "https://github.com/squareexp/base-idp", package = "base-idp" }
# Or during development inside the monorepo:
# base-idp = { path = "../base-idp/sdk/rust" }The crate uses async I/O via tokio for HTTP calls (discovery and key
fetching). Verification itself is sync.
Quick start
use base_idp::{Client, Config, Error, Principal, VerifyOptions};
pub async fn verify(token: &str) -> Result<Principal, Error> {
let client = Client::new(Config::from_env()?)?;
client
.verify_access_token(token, VerifyOptions::default())
.await
}Config::from_env() reads BASE_IDP_CLIENT_ID if set and falls back to
the production issuer otherwise. Nothing else is required for a verify-only
service.
The Client
The client is the cached, reusable entry point. Build it once per process, reuse it for every request.
let client = Client::new(Config::from_env()?)?;In an Axum app the client typically lives in your AppState:
use std::sync::Arc;
use axum::{Router, routing::get, extract::State};
use base_idp::Client;
#[derive(Clone)]
struct AppState {
base_idp: Arc<Client>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let state = AppState {
base_idp: Arc::new(Client::new(base_idp::Config::from_env()?)?),
};
let app = Router::new()
.route("/me", get(me))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}Building config
From env
let config = Config::from_env()?;Reads:
| Variable | Required | Notes |
|---|---|---|
BASE_IDP_CLIENT_ID | Yes for login, optional for verify | Public client id. |
BASE_IDP_CLIENT_SECRET | Confidential clients only | Server-side only. |
BASE_IDP_ISSUER | No | Override for local dev. |
By hand
let config = Config {
client_id: "sq_live_yourapp".into(),
issuer: "https://authlayer.squareexp.com".into(),
..Config::default()
};Verifying tokens
The verify_access_token call returns a Principal on success and an
Error on failure. Errors include the reason — expired, bad signature,
audience mismatch, missing key — so you can log them usefully.
match client
.verify_access_token(token, VerifyOptions::default())
.await
{
Ok(principal) => println!("user {} ({})", principal.subject, principal.email.unwrap_or_default()),
Err(Error::InvalidToken(reason)) => eprintln!("token rejected: {reason}"),
Err(other) => eprintln!("verify failed: {other}"),
}VerifyOptions
let principal = client
.verify_access_token(
token,
VerifyOptions {
audience: Some("sq_live_yourapp".into()),
required_scope: Some("tasks:read".into()),
max_clock_skew_seconds: 30,
..VerifyOptions::default()
},
)
.await?;audience— pin the expected audience. If unset, the SDK uses the config's client id.required_scope— reject tokens that do not have this scope.max_clock_skew_seconds— tolerance for clock drift.implicit_assertion— override the assertion string. Almost never needed.
The Principal struct
pub struct Principal {
pub id: String, // gid
pub subject: String, // sub
pub email: Option<String>,
pub display_name: Option<String>,
pub scopes: Vec<String>,
pub role: Option<String>,
pub session_id: Option<String>,
pub assurance_level: Option<u8>,
pub audience: String,
pub issuer: String,
pub expires_at: time::OffsetDateTime,
}Use in Axum
A typical Axum middleware that requires a verified token:
use axum::{
body::Body,
extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode},
middleware::Next,
response::Response,
};
use base_idp::{Principal, VerifyOptions};
use std::sync::Arc;
use crate::AppState;
pub async fn require_auth(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let principal = state
.base_idp
.verify_access_token(token, VerifyOptions::default())
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
req.extensions_mut().insert(Arc::new(principal));
Ok(next.run(req).await)
}Wire it onto your routes:
let app = Router::new()
.route("/me", get(me))
.layer(axum::middleware::from_fn_with_state(state.clone(), require_auth))
.with_state(state);And read the principal inside a handler:
async fn me(Extension(p): Extension<Arc<Principal>>) -> Json<MeResponse> {
Json(MeResponse {
id: p.subject.clone(),
email: p.email.clone(),
})
}Running the full login (confidential)
A Rust service that runs the login passes the secret and uses authorize_url
plus exchange_code.
let url = base_idp::authorize_url(
&config,
AuthorizeOptions {
state: Some("return-to=/dashboard".into()),
..Default::default()
},
)?;let tokens = client
.exchange_code(TokenOptions {
code,
code_verifier: Some(verifier),
redirect_uri: None,
})
.await?;let principal = client
.verify_access_token(&tokens.access_token, VerifyOptions::default())
.await?;Refresh
let tokens = client
.refresh(RefreshOptions {
refresh_token: stored.refresh_token,
scopes: None,
})
.await?;Refresh tokens rotate. Store the new one and discard the old.
Key discovery and rotation
You normally do not call key discovery directly — the SDK does it for you,
and it re-fetches on a kid it does not recognize. For diagnostics or for
implementations that want to warm caches at startup:
let keys = client.public_keys(false).await?;Pass true to force a fresh fetch.
Errors
All errors flow through the base_idp::Error enum.
pub enum Error {
InvalidConfig(String),
InvalidToken(String),
TokenExchange(String),
Discovery(String),
KeyDiscovery(String),
Http(String),
// …
}Match on these for fine-grained logging or differentiated HTTP status codes.
A full Axum service
use std::sync::Arc;
use axum::{
routing::get,
Extension, Json, Router,
};
use base_idp::{Client, Config, Principal};
use serde::Serialize;
#[derive(Clone)]
struct AppState {
base_idp: Arc<Client>,
}
#[derive(Serialize)]
struct MeResponse {
id: String,
email: Option<String>,
name: Option<String>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let state = AppState {
base_idp: Arc::new(Client::new(Config::from_env()?)?),
};
let app = Router::new()
.route("/v1/me", get(me))
.layer(axum::middleware::from_fn_with_state(state.clone(), middleware::require_auth))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn me(Extension(p): Extension<Arc<Principal>>) -> Json<MeResponse> {
Json(MeResponse {
id: p.subject.clone(),
email: p.email.clone(),
name: p.display_name.clone(),
})
}
mod middleware {
use super::AppState;
use axum::{
body::Body, extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode},
middleware::Next, response::Response,
};
use base_idp::{Principal, VerifyOptions};
use std::sync::Arc;
pub async fn require_auth(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = req.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let principal = state.base_idp
.verify_access_token(token, VerifyOptions::default())
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
req.extensions_mut().insert(Arc::new(principal));
Ok(next.run(req).await)
}
}Two files. One env value (optional). Production-ready verification.