Base IdP

Base IdP

Frameworks

Rust

Verify Base IdP tokens in a Rust service — Axum middleware, offline verification, no secret required.

A Rust service behind a frontend is verify-only. It verifies the PASETO token offline against published public keys and serves the request. No secret. No required env. This guide walks through the integration in Axum, which is the most common Rust web framework, but the pattern adapts to any tower::Service-based stack.

Scaffold

npx base-idp create --stack rust

This writes src/base_idp.rs, a Cargo.add.txt hint, and a setup readme into ./base-idp-rust/. Move the Rust file into your project.

Add the crate

cargo add base-idp

Or, during development inside the monorepo:

Cargo.toml
[dependencies]
base-idp = { git = "https://github.com/squareexp/base-idp", package = "base-idp" }

You also need axum, tokio, and tower-http if you do not already.

Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"

No required env

.env
# Verify-only. Nothing required.
# Optional: pin the audience.
# BASE_IDP_CLIENT_ID=sq_live_yourapp

The verification module

src/base_idp.rs
use base_idp::{Client, Config, Error, Principal, VerifyOptions};

/// Verify a Base IdP access token offline. Returns the verified principal
/// or an Error explaining why the token was rejected.
pub async fn verify(client: &Client, token: &str) -> Result<Principal, Error> {
    client
        .verify_access_token(token, VerifyOptions::default())
        .await
}

App state

The Base IdP client is cheap to build once and reuse. Put it in your Axum AppState:

src/state.rs
use std::sync::Arc;
use base_idp::Client;

#[derive(Clone)]
pub struct AppState {
    pub base_idp: Arc<Client>,
}

impl AppState {
    pub fn new() -> anyhow::Result<Self> {
        let client = Client::new(base_idp::Config::from_env()?)?;
        Ok(Self {
            base_idp: Arc::new(client),
        })
    }
}

Arc lets you clone the state into each request handler cheaply.

Middleware

Axum has a few ways to write middleware. The simplest for auth is axum::middleware::from_fn_with_state.

src/middleware/auth.rs
use std::sync::Arc;
use axum::{
    extract::{Request, State},
    http::{header::AUTHORIZATION, StatusCode},
    middleware::Next,
    response::Response,
};
use base_idp::{Principal, VerifyOptions};

use crate::state::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)
}

Wiring it up

src/main.rs
use axum::{
    routing::{get, post},
    Router,
};
use crate::state::AppState;

mod middleware;
mod routes;
mod state;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let state = AppState::new()?;

    let public = Router::new()
        .route("/healthz", get(routes::healthz))
        .route("/v1/auth/idp-exchange", post(routes::idp_exchange));

    let protected = Router::new()
        .route("/v1/me", get(routes::me))
        .route("/v1/admin", get(routes::admin))
        .layer(axum::middleware::from_fn_with_state(
            state.clone(),
            middleware::auth::require_auth,
        ));

    let app = Router::new()
        .merge(public)
        .merge(protected)
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

Reading the principal in handlers

The middleware attaches the principal to the request as an extension. Pull it out with an Extension extractor.

src/routes/me.rs
use std::sync::Arc;
use axum::{Extension, Json};
use base_idp::Principal;
use serde::Serialize;

#[derive(Serialize)]
pub struct MeResponse {
    id: String,
    email: Option<String>,
    name: Option<String>,
}

pub 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(),
    })
}

Scope enforcement

The VerifyOptions::required_scope field rejects tokens missing a scope. For per-route scope checks, do them in the handler.

src/routes/admin.rs
use std::sync::Arc;
use axum::{Extension, http::StatusCode, response::Json};
use base_idp::Principal;
use serde_json::{json, Value};

pub async fn admin(
    Extension(p): Extension<Arc<Principal>>,
) -> Result<Json<Value>, StatusCode> {
    if !p.scopes.iter().any(|s| s == "admin") {
        return Err(StatusCode::FORBIDDEN);
    }
    Ok(Json(json!({ "status": "ok" })))
}

A mobile-to-backend exchange endpoint

The pattern used by Square products: a mobile app sends the session it got from Base IdP, and the Rust backend verifies it, upserts a user, and issues its own session token.

src/routes/idp_exchange.rs
use axum::{extract::State, http::StatusCode, response::Json};
use base_idp::VerifyOptions;
use serde::{Deserialize, Serialize};

use crate::state::AppState;

#[derive(Deserialize)]
pub struct ExchangeRequest {
    pub access_token: String,
    pub refresh_token: Option<String>,
}

#[derive(Serialize)]
pub struct ExchangeResponse {
    pub user_id: String,
    pub session_token: String,
}

pub async fn idp_exchange(
    State(state): State<AppState>,
    Json(req): Json<ExchangeRequest>,
) -> Result<Json<ExchangeResponse>, StatusCode> {
    let principal = state
        .base_idp
        .verify_access_token(&req.access_token, VerifyOptions::default())
        .await
        .map_err(|_| StatusCode::UNAUTHORIZED)?;

    // Your application logic:
    let user_id = upsert_user(&principal).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let session_token = mint_session(&user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(ExchangeResponse { user_id, session_token }))
}

async fn upsert_user(_p: &base_idp::Principal) -> anyhow::Result<String> {
    // Insert/update in your database, return the user id.
    Ok("usr_01HRZ".into())
}

fn mint_session(_user_id: &str) -> anyhow::Result<String> {
    // Sign your own JWT or store a session row, return the token.
    Ok("sess_01HRZ".into())
}

This endpoint is public — the Base IdP access token itself is what proves the request is legitimate. Adding the auth middleware would lock mobile apps out.

A complete service

src/
├── main.rs
├── state.rs
├── middleware/
│   ├── mod.rs
│   └── auth.rs
└── routes/
    ├── mod.rs
    ├── healthz.rs
    ├── idp_exchange.rs
    ├── me.rs
    └── admin.rs

Five route files, one middleware, one state module. The pattern scales — add more handlers, the auth and state code stay the same.

Errors you might see

failed to fetch public keys

The service could not reach /v1/keys/paseto-v4-public. Check network, check that the issuer URL is correct.

Every request returns 401

Decode the incoming token: npx base-idp token <token>. Check aud. If it does not match the client id you pinned, the mobile app is using the wrong client id.

signature error: Verification equation was not satisfied

The verifier is using the wrong public key, the wrong implicit assertion, or the wrong audience. The SDK handles all of this — if you see this error, you are probably not on the official SDK. Switch to the base-idp crate.

Where to go next

On this page