Base IdP

Base IdP

SDKs

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-idp

While the crate is moving toward a crates.io release you can also pin to the git repo or a local path:

Cargo.toml
[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

src/auth.rs
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:

main.rs
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:

VariableRequiredNotes
BASE_IDP_CLIENT_IDYes for login, optional for verifyPublic client id.
BASE_IDP_CLIENT_SECRETConfidential clients onlyServer-side only.
BASE_IDP_ISSUERNoOverride 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:

src/middleware/auth.rs
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

src/main.rs
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.

Where to go next

On this page