OAuth2 & OpenID Connect

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against oauth2 5.0.0 and openidconnect 4.0.1 with axum 0.8.9 and axum-extra 0.12.6 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf lets members sign in with GitHub. The protocol itself — roles, grants, why PKCE, what state and nonce protect against — is explained on this site’s Authorization Code and PKCE and OpenID Connect pages; this page shows the axum implementation.

The flow

sequenceDiagram participant U as Browser participant B as Bookshelf (axum) participant G as GitHub U->>B: GET /auth/github B->>B: generate state + PKCE verifier/challenge B-->>U: 303 to GitHub authorize URL (client_id, state, code_challenge)<br/>Set-Cookie: oauth_flow (private, encrypted: state + verifier) U->>G: log in, approve "read:user" G-->>U: 302 to /auth/github/callback?code=...&state=... U->>B: GET /auth/github/callback (+ oauth_flow cookie) B->>B: compare state with cookie B->>G: POST token endpoint (code + code_verifier + client secret) G-->>B: access token B->>G: GET /user (Bearer access token) G-->>B: GitHub user id and login B->>B: find or create local account, start session (new session id) B-->>U: 303 to /account, clear oauth_flow cookie

Implementation

[dependencies]
oauth2 = "5.0.0"   # default features: reqwest 0.12 client with rustls
axum-extra = { version = "0.12.6", features = ["cookie-private"] }
use axum::{
    Router,
    extract::{FromRef, Query, State},
    http::StatusCode,
    response::{IntoResponse, Redirect, Response},
    routing::get,
};
use axum_extra::extract::{
    PrivateCookieJar,
    cookie::{Cookie, Key, SameSite},
};
use oauth2::{
    AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointNotSet, EndpointSet, PkceCodeChallenge,
    PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl, basic::BasicClient,
};
use serde::{Deserialize, Serialize};

type GitHubClient = BasicClient<EndpointSet, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointSet>;

#[derive(Clone)]
struct AppState {
    oauth: GitHubClient,
    http: oauth2::reqwest::Client,
    user_api: String,
    cookie_key: Key,
}

impl FromRef<AppState> for Key {
    fn from_ref(state: &AppState) -> Self {
        state.cookie_key.clone()
    }
}

/// What we must remember between the redirect and the callback.
#[derive(Serialize, Deserialize)]
struct Flow {
    state: String,
    pkce_verifier: String,
}

const FLOW_COOKIE: &str = "oauth_flow";

async fn start(State(app): State<AppState>, jar: PrivateCookieJar) -> (PrivateCookieJar, Redirect) {
    let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
    let (url, csrf_state) = app
        .oauth
        .authorize_url(CsrfToken::new_random)
        .add_scope(Scope::new("read:user".into()))
        .set_pkce_challenge(challenge)
        .url();

    let flow = Flow { state: csrf_state.secret().clone(), pkce_verifier: verifier.secret().clone() };
    let cookie = Cookie::build((FLOW_COOKIE, serde_json::to_string(&flow).expect("serialisable")))
        .http_only(true)
        .secure(true)
        .same_site(SameSite::Lax) // must survive the top-level redirect back from GitHub
        .path("/auth")
        .max_age(time::Duration::minutes(10))
        .build();
    (jar.add(cookie), Redirect::to(url.as_str()))
}

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

#[derive(Deserialize, Debug)]
struct GitHubUser {
    id: u64,
    login: String,
}

async fn callback(State(app): State<AppState>, jar: PrivateCookieJar, Query(cb): Query<Callback>) -> Response {
    // 1. the flow cookie must exist and its state must match: otherwise this is a forged or replayed callback
    let Some(flow) = jar.get(FLOW_COOKIE).and_then(|c| serde_json::from_str::<Flow>(c.value()).ok()) else {
        return (StatusCode::BAD_REQUEST, "login expired, please try again\n").into_response();
    };
    let jar = jar.remove(Cookie::build(FLOW_COOKIE).path("/auth"));
    if flow.state != cb.state {
        return (jar, (StatusCode::BAD_REQUEST, "state mismatch\n")).into_response();
    }

    // 2. exchange the code, proving possession of the PKCE verifier
    let token = match app
        .oauth
        .exchange_code(AuthorizationCode::new(cb.code))
        .set_pkce_verifier(PkceCodeVerifier::new(flow.pkce_verifier))
        .request_async(&app.http)
        .await
    {
        Ok(token) => token,
        Err(err) => {
            tracing::warn!(%err, "code exchange failed");
            return (jar, StatusCode::BAD_GATEWAY).into_response();
        }
    };

    // 3. who is this? (GitHub is OAuth2-only, so we call its API instead of reading an ID token)
    let user: GitHubUser = match app
        .http
        .get(&app.user_api)
        .bearer_auth(token.access_token().secret())
        .header("user-agent", "bookshelf")
        .send()
        .await
        .and_then(|r| r.error_for_status())
    {
        // oauth2's reqwest is built without the `json` feature: parse the bytes with serde_json
        Ok(resp) => match resp.bytes().await.ok().and_then(|b| serde_json::from_slice(&b).ok()) {
            Some(user) => user,
            None => return (jar, StatusCode::BAD_GATEWAY).into_response(),
        },
        Err(_) => return (jar, StatusCode::BAD_GATEWAY).into_response(),
    };

    // 4. link to a local account by the provider's immutable id (not by e-mail or login name),
    //    then start a session with session.cycle_id() as on the Sessions page
    tracing::info!(github_id = user.id, login = %user.login, "signed in with GitHub");
    (jar, Redirect::to("/account")).into_response()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // non-secret endpoints may default to GitHub's; the client credentials must be configured
    let env = |k: &str, default: &str| std::env::var(k).unwrap_or_else(|_| default.to_owned());
    let client_id = std::env::var("GITHUB_CLIENT_ID").expect("GITHUB_CLIENT_ID must be set");
    let client_secret = std::env::var("GITHUB_CLIENT_SECRET").expect("GITHUB_CLIENT_SECRET must be set");
    let oauth = BasicClient::new(ClientId::new(client_id))
        .set_client_secret(ClientSecret::new(client_secret))
        .set_auth_uri(AuthUrl::new(env("OAUTH_AUTH_URL", "https://github.com/login/oauth/authorize"))?)
        .set_token_uri(TokenUrl::new(env("OAUTH_TOKEN_URL", "https://github.com/login/oauth/access_token"))?)
        .set_redirect_uri(RedirectUrl::new(env("OAUTH_REDIRECT_URL", "http://localhost:3000/auth/github/callback"))?);

    let http = oauth2::reqwest::ClientBuilder::new()
        .redirect(oauth2::reqwest::redirect::Policy::none()) // never follow redirects on token requests
        .build()?;

    let state = AppState {
        oauth,
        http,
        user_api: env("GITHUB_USER_API", "https://api.github.com/user"),
        cookie_key: Key::generate(), // load a persistent 64-byte key from a secret store in production
    };
    let app = Router::new()
        .route("/auth/github", get(start))
        .route("/auth/github/callback", get(callback))
        .with_state(state);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}
$ curl -si localhost:3000/auth/github | grep -i -E '^HTTP|^location|^set-cookie' | cut -c1-120
HTTP/1.1 303 See Other
location: https://github.com/login/oauth/authorize?response_type=code&client_id=...&state=...&code_challenge=...
set-cookie: oauth_flow=6ADLyrbu...; HttpOnly; SameSite=Lax; Secure; Path=/auth; Max-Age=600

Design notes:

  • PKCE everywhere. OAuth 2.1 and the current security BCP require PKCE for all clients, including confidential ones with a client secret. PkceCodeChallenge::new_random_sha256() creates the S256 challenge; the verifier is sent only with the token request.

  • State in an encrypted cookie. The state value and the PKCE verifier must survive the round trip through GitHub and be bound to this browser. A PrivateCookieJar cookie does that without a server-side store; it is SameSite=Lax so it is sent on the top-level redirect back, scoped to /auth, and short-lived. Removing it in the callback makes the code single-use from our side.

  • Typed client. oauth2 5.0 tracks at compile time which endpoints are configured (EndpointSet/EndpointNotSet), so calling exchange_code on a client without a token URL does not compile. It also no longer ships its own HTTP client functions: you pass a reqwest::Client (re-exported as oauth2::reqwest, version 0.12) and should disable redirects on it to avoid SSRF through redirecting token endpoints.

  • Link by provider id. Store (provider = "github", subject = 12345) in an identities table pointing at the local user. E-mail addresses and logins can change and, for some providers, are not verified.

  • Then use your own session. The GitHub access token is only used once, to read the profile; the user’s session is a normal Bookshelf session (see Sessions & Cookies), started with a new session id.

OpenID Connect: validating ID tokens

Providers that implement OpenID Connect (Google, Microsoft Entra ID, Keycloak, Auth0, …​) return an ID token, a JWT describing the user, alongside the access token. Its validation is subtle — issuer, audience, signature against the provider’s rotating JWKS keys, expiry, and the nonce that binds it to this login attempt — so use the openidconnect crate, which implements discovery and all of these checks:

use openidconnect::{
    AuthenticationFlow, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge, RedirectUrl, Scope,
    core::{CoreClient, CoreProviderMetadata, CoreResponseType},
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let http = openidconnect::reqwest::ClientBuilder::new()
        .redirect(openidconnect::reqwest::redirect::Policy::none())
        .build()?;

    // Discovery: fetches https://accounts.example.com/.well-known/openid-configuration and the JWKS
    let issuer = IssuerUrl::new(std::env::var("OIDC_ISSUER").unwrap_or_else(|_| "https://accounts.example.com".into()))?;
    let metadata = CoreProviderMetadata::discover_async(issuer, &http).await?;

    let client = CoreClient::from_provider_metadata(
        metadata,
        ClientId::new(std::env::var("OIDC_CLIENT_ID")?),
        Some(ClientSecret::new(std::env::var("OIDC_CLIENT_SECRET")?)),
    )
    .set_redirect_uri(RedirectUrl::new("https://bookshelf.example/auth/oidc/callback".into())?);

    let (challenge, _verifier) = PkceCodeChallenge::new_random_sha256();
    let (url, _state, _nonce) = client
        .authorize_url(AuthenticationFlow::<CoreResponseType>::AuthorizationCode, CsrfToken::new_random, Nonce::new_random)
        .add_scope(Scope::new("email".into()))
        .set_pkce_challenge(challenge)
        .url();
    println!("redirect the browser to {url}");

    // In the callback, after exchange_code(...).set_pkce_verifier(...).request_async(&http):
    //   let id_token = token.id_token().ok_or("no ID token")?;
    //   let claims = id_token.claims(&client.id_token_verifier(), &nonce)?;  // signature, iss, aud, exp, nonce
    //   let subject = claims.subject();                                      // the stable user id
    Ok(())
}

Store the nonce next to state and the PKCE verifier in the flow cookie, and link accounts by (issuer, sub). The claims carry email and email_verified; only trust the e-mail when the provider says it is verified.

Where the books are outdated

Yoon (Beginning Axum) does not cover OAuth2. Tutorials written for oauth2 4.x use oauth2::reqwest::async_http_client and an untyped BasicClient::new(id, secret, auth_url, token_url); oauth2 5.0 replaced both with the builder above and caller-supplied HTTP clients. The axum oauth example at axum-v0.8.9 uses Discord; the flow is identical for GitHub.