Axum (openidconnect-rs)

See the Applications overview for prerequisites, configuration endpoints, and available scopes.

openidconnect-rs provides a type-safe OpenID Connect client for Rust.

Add the required dependencies to your Cargo.toml:

[dependencies]
axum = "0.7"
axum-extra = { version = "0.9", features = ["cookie"] }
openidconnect = "3"
reqwest = { version = "0.12", features = ["rustls-tls"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
tower-sessions = "0.13"
url = "2"

Implement the OIDC integration:

use axum::{
    extract::{Query, State},
    response::{IntoResponse, Redirect},
    routing::get,
    Router,
};
use openidconnect::{
    core::{CoreClient, CoreProviderMetadata, CoreResponseType},
    reqwest::async_http_client,
    AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret,
    CsrfToken, IssuerUrl, Nonce, RedirectUrl, Scope, TokenResponse,
};
use serde::Deserialize;
use std::sync::Arc;
use tower_sessions::{MemoryStore, Session, SessionManagerLayer};

const CSRF_TOKEN_KEY: &str = "csrf_token";
const NONCE_KEY: &str = "nonce";

struct AppState {
    oidc_client: CoreClient,
}

#[derive(Deserialize)]
struct CallbackParams {
    code: String,
    state: String,
}

async fn login(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> impl IntoResponse {
    let (auth_url, csrf_token, nonce) = state
        .oidc_client
        .authorize_url(
            AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
            CsrfToken::new_random,
            Nonce::new_random,
        )
        .add_scope(Scope::new("email".to_string()))
        .url();

    // Store the CSRF token and nonce in the session so we can verify them
    // when the provider redirects back to the callback endpoint.
    session
        .insert(CSRF_TOKEN_KEY, csrf_token.secret().clone())
        .await
        .expect("Failed to store CSRF token");
    session
        .insert(NONCE_KEY, nonce.secret().clone())
        .await
        .expect("Failed to store nonce");

    Redirect::to(auth_url.as_str())
}

async fn callback(
    State(state): State<Arc<AppState>>,
    session: Session,
    Query(params): Query<CallbackParams>,
) -> impl IntoResponse {
    // Retrieve and remove the CSRF token from the session.
    let stored_csrf: String = session
        .remove(CSRF_TOKEN_KEY)
        .await
        .expect("Session error")
        .expect("Missing CSRF token in session -- login flow was not initiated");

    // Verify the state parameter matches the CSRF token we stored.
    if params.state != stored_csrf {
        panic!("CSRF token mismatch -- possible CSRF attack");
    }

    // Retrieve and remove the nonce from the session.
    let stored_nonce: String = session
        .remove(NONCE_KEY)
        .await
        .expect("Session error")
        .expect("Missing nonce in session");
    let nonce = Nonce::new(stored_nonce);

    let token_response = state
        .oidc_client
        .exchange_code(AuthorizationCode::new(params.code))
        .request_async(async_http_client)
        .await
        .expect("Failed to exchange code");

    let id_token = token_response
        .id_token()
        .expect("Server did not return an ID token");

    // Verify the ID token using the nonce from the original authorization request.
    let claims = id_token
        .claims(&state.oidc_client.id_token_verifier(), &nonce)
        .expect("Failed to verify ID token");

    format!(
        "Welcome! Subject: {}, Email: {:?}",
        claims.subject(),
        claims.email()
    )
}

#[tokio::main]
async fn main() {
    let issuer_url =
        IssuerUrl::new("https://us.vouch.sh".to_string()).expect("Invalid issuer URL");

    let provider_metadata =
        CoreProviderMetadata::discover_async(issuer_url, async_http_client)
            .await
            .expect("Failed to discover provider");

    let client = CoreClient::from_provider_metadata(
        provider_metadata,
        ClientId::new(std::env::var("VOUCH_CLIENT_ID").expect("VOUCH_CLIENT_ID not set")),
        Some(ClientSecret::new(
            std::env::var("VOUCH_CLIENT_SECRET").expect("VOUCH_CLIENT_SECRET not set"),
        )),
    )
    .set_redirect_uri(
        RedirectUrl::new("https://your-app.example.com/auth/callback".to_string())
            .expect("Invalid redirect URL"),
    );

    let state = Arc::new(AppState {
        oidc_client: client,
    });

    // Use an in-memory session store. In production, replace with a
    // persistent store (e.g., Redis) for multi-instance deployments.
    let session_store = MemoryStore::default();
    let session_layer = SessionManagerLayer::new(session_store);

    let app = Router::new()
        .route("/login", get(login))
        .route("/auth/callback", get(callback))
        .layer(session_layer)
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .expect("Failed to bind");
    axum::serve(listener, app).await.expect("Server error");
}