Guide contents

Lesson 13 of 30

OAuth2, OIDC, JWT, and mTLS for Services

An identity provider (IdP) issues tokens. The Storefront API validates them. This lesson covers the OAuth2 and OpenID Connect flows, JWT validation against a JWKS, service-to-service tokens, mTLS, and secrets. The Principal idea comes from authentication and authorization.

Term Meaning
OAuth2 Framework for delegated authorization: an application gets limited access without the user's password
OIDC OpenID Connect: a layer on OAuth2 that adds an ID token and discovery
JWT Signed JSON document in three base64url parts: header.payload.signature
JWKS JSON Web Key Set: the public keys that verify an IdP's signatures, each with a kid
mTLS Mutual TLS: both sides of a TLS connection present certificates

Project setup

The service is a resource server: it validates bearer tokens and never issues them. A python3 static server plays the IdP's JWKS endpoint, and a small binary mints test tokens.

Cargo.toml

TOML
[package]
name = "storefront-oidc"
version = "0.1.0"
edition = "2021"
default-run = "storefront-oidc"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
sha2 = "0.10"
base64 = "0.22"

Generate an RSA key pair and a jwks.json file. The exponent 65537 encodes as AQAB. The JWKS file goes in its own jwks/ directory, so the static server never exposes private.pem. The commands use GNU basenc and xxd.

Terminal
openssl genrsa -out private.pem 2048
mkdir -p jwks
N=$(openssl rsa -in private.pem -noout -modulus | cut -d= -f2 | xxd -r -p | basenc --base64url -w0 | tr -d '=')
printf '{"keys":[{"kty":"RSA","kid":"key-1","use":"sig","alg":"RS256","n":"%s","e":"AQAB"}]}' "$N" > jwks/jwks.json
python3 -m http.server 9000 --bind 127.0.0.1 --directory jwks &

The JWKS URL is http://127.0.0.1:9000/jwks.json. Never serve private.pem or the other .key files.

Example 1: The OAuth2 roles

Role Storefront example
Resource owner The customer
Client The web or mobile application acting for the customer
Authorization server The identity provider
Resource server The Storefront API

An access token is a short-lived credential for calling an API. Its scopes are strings such as orders:read that name what it allows.

Example 2: Authorization code flow with PKCE

The authorization code flow signs a person in. PKCE (Proof Key for Code Exchange) protects it for clients that cannot keep a secret.

  1. The client creates a random code_verifier and sends code_challenge = BASE64URL(SHA256(code_verifier)) in the redirect to the IdP, with response_type=code, client_id, redirect_uri, scope, and a random state.
  2. The user signs in. The IdP redirects back with a short-lived code and the same state.
  3. The client checks state, then sends code and code_verifier to the token endpoint.
  4. The IdP hashes the verifier, compares it with the challenge, and returns an access token, usually a refresh token, and for OIDC an ID token.

A stolen code is useless without the verifier. The Axum service takes no part in these steps. This program computes the challenge for the verifier in RFC 7636, appendix B.

src/bin/pkce.rs

Rust
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use sha2::{Digest, Sha256};

fn main() {
    let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
    let digest = Sha256::digest(verifier.as_bytes());
    let challenge = URL_SAFE_NO_PAD.encode(digest);
    println!("code_challenge={challenge}");
}
Terminal
cargo run -q --bin pkce
Output
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

Example 3: OpenID Connect

OAuth2 says what a client may do. OIDC adds who the user is.

  • The openid scope requests an ID token: a JWT for the client with sub, iss, aud, exp, and optional profile claims.
  • Discovery lives at {issuer}/.well-known/openid-configuration and lists the endpoints and the jwks_uri.
  • An API accepts access tokens only. The aud of an ID token is the client. The aud of an access token names the API.

Example 4: Mint a test token

This binary signs an RS256 token with private.pem. The kid header selects the key in the JWKS. The first argument sets the scopes.

src/bin/mint.rs

Rust
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Serialize)]
struct Claims {
    iss: String,
    aud: String,
    sub: String,
    tenant: i64,
    scope: String,
    exp: u64,
}

fn main() {
    let scope = std::env::args().nth(1).unwrap_or_else(|| "orders:read".to_string());
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
    let claims = Claims {
        iss: "https://id.example.com/".to_string(),
        aud: "storefront-api".to_string(),
        sub: "user-42".to_string(),
        tenant: 7,
        scope,
        exp: now + 300,
    };
    let mut header = Header::new(Algorithm::RS256);
    header.kid = Some("key-1".to_string());
    let pem = std::fs::read("private.pem").expect("private.pem in the current directory");
    let key = EncodingKey::from_rsa_pem(&pem).expect("valid RSA private key");
    println!("{}", encode(&header, &claims, &key).unwrap());
}
Terminal
cargo run -q --bin mint inventory:read

The output is one line, a JWT with three dot-separated parts. It differs on each run because exp changes.

Example 5: Validate a JWT against a JWKS

The Complete example section holds src/jwks.rs and src/auth.rs. They implement these rules:

  • The IdP signs with a private key and publishes public keys at the jwks_uri. Each token header names the kid it was signed with.
  • The service caches keys by kid. An unknown kid triggers one refetch, so key rotation needs no restart.
  • The refetch has a 30 second minimum interval. Without it, tokens with random kid values make the service call the IdP on every request.
  • A mutex around the refetch makes concurrent tasks share one request.
  • Validation::new(Algorithm::RS256) fixes the accepted algorithm. A token that declares alg: none, or switches from RSA to an HMAC keyed with the public key, is rejected.
  • iss and aud are checked only because the code sets them. exp is checked by default with 60 seconds of leeway.
  • Every failure becomes 401. The specific cause goes to the log, not to the client.
Claim Check
iss Equals the configured IdP URL
aud Contains this API's identifier
exp Is in the future
sub Becomes the principal's user id
scope, tenant Map to Principal fields

Example 6: Service-to-service tokens

With no user involved, a service authenticates as itself with the client credentials flow: it posts grant_type=client_credentials with its id and secret to the token endpoint. src/outbound.rs caches the token until 30 seconds before expires_in, so the IdP is not called on every request. Holding the lock across the fetch makes concurrent callers wait for one request. The Secret type prints as Secret(***), so a {:?} in a log line cannot leak it.

The implicit flow and the password grant are deprecated in current OAuth guidance and are not used for new systems.

Example 7: Mutual TLS

In ordinary TLS the client verifies the server's certificate. In mTLS the server also requires a client certificate signed by a trusted certificate authority (CA) and reads the caller's identity from its subject. axum::serve does not terminate TLS. Kubernetes deployments usually let a mesh sidecar or a proxy do it, and the Axum process sees plain HTTP on loopback.

Create a CA, a server certificate, and a client certificate for orders.prod.svc.

Terminal
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -subj "/CN=storefront-ca" -days 30
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=localhost"
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.pem -days 7 \
  -extfile <(printf "subjectAltName=DNS:localhost")
openssl req -newkey rsa:2048 -nodes -keyout orders.key -out orders.csr -subj "/CN=orders.prod.svc"
openssl x509 -req -in orders.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out orders.pem -days 7
cat orders.pem orders.key > orders-identity.pem

nginx.conf (terminates mTLS and forwards the verified subject)

Text
events {}

http {
  server {
    listen 8443 ssl;
    ssl_certificate         server.pem;
    ssl_certificate_key     server.key;
    ssl_client_certificate  ca.pem;
    ssl_verify_client       on;

    location / {
      proxy_set_header x-client-subject $ssl_client_s_dn;
      proxy_pass http://127.0.0.1:3000;
    }
  }
}
  • ssl_verify_client on rejects connections without a certificate signed by ca.pem.
  • The Axum service must listen on loopback only and may trust x-client-subject only because the proxy sets it.
  • src/outbound_mtls.rs builds the client side with reqwest and rustls.
  • Certificates get short lifetimes and automatic renewal (cert-manager or a mesh). Revocation lists must reach every verifier, so short expiry is the practical control.

Refresh tokens, revocation, and secrets

  • Access tokens live 5 to 15 minutes because stateless validation cannot revoke them. A refresh token is held by the client and sent only to the IdP's token endpoint.
  • Refresh token rotation issues a new refresh token on each use. Reuse of an old one signals theft and lets the IdP revoke the chain.
  • Immediate revocation needs a deny list of jti or sub values in Redis (Redis caching) or token introspection. Both add a lookup per request.
  • Secrets come from the environment or a secret store at runtime, never from Git or an image layer. Kubernetes Secrets are base64, not encrypted, unless encryption at rest is enabled. See health, shutdown, and configuration.
  • The secrecy crate offers a ready-made redacting type. Each service gets its own least-privilege credentials, and accepts two keys during rotation, as the JWKS cache does.

Complete example

File tree:

Text
storefront-oidc/
  Cargo.toml
  private.pem          (generated)
  jwks/jwks.json       (generated)
  src/
    main.rs
    auth.rs
    jwks.rs
    outbound.rs
    outbound_mtls.rs
    bin/
      mint.rs
      pkce.rs

Cargo.toml

TOML
[package]
name = "storefront-oidc"
version = "0.1.0"
edition = "2021"
default-run = "storefront-oidc"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
sha2 = "0.10"
base64 = "0.22"

src/jwks.rs

Rust
use jsonwebtoken::{jwk::JwkSet, DecodingKey};
use std::{
    collections::HashMap,
    time::{Duration, Instant},
};
use tokio::sync::{Mutex, RwLock};

const MIN_REFRESH: Duration = Duration::from_secs(30);

struct Cache {
    keys: HashMap<String, DecodingKey>,
    fetched_at: Option<Instant>,
}

pub struct Jwks {
    url: String,
    http: reqwest::Client,
    cache: RwLock<Cache>,
    refresh_lock: Mutex<()>,
}

impl Jwks {
    pub fn new(url: String) -> Self {
        Self {
            url,
            http: reqwest::Client::new(),
            cache: RwLock::new(Cache { keys: HashMap::new(), fetched_at: None }),
            refresh_lock: Mutex::new(()),
        }
    }

    async fn cached(&self, kid: &str) -> Option<DecodingKey> {
        self.cache.read().await.keys.get(kid).cloned()
    }

    pub async fn key_for(&self, kid: &str) -> Option<DecodingKey> {
        if let Some(key) = self.cached(kid).await {
            return Some(key);
        }
        let _guard = self.refresh_lock.lock().await;
        if let Some(key) = self.cached(kid).await {
            return Some(key);
        }
        let recent = self
            .cache
            .read()
            .await
            .fetched_at
            .is_some_and(|at| at.elapsed() < MIN_REFRESH);
        if recent {
            return None;
        }
        if let Err(error) = self.refresh().await {
            eprintln!("jwks refresh failed: {error}");
            return None;
        }
        self.cached(kid).await
    }

    async fn refresh(&self) -> reqwest::Result<()> {
        let set: JwkSet = self
            .http
            .get(&self.url)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        let mut keys = HashMap::new();
        for jwk in &set.keys {
            if let (Some(kid), Ok(key)) = (jwk.common.key_id.clone(), DecodingKey::from_jwk(jwk)) {
                keys.insert(kid, key);
            }
        }
        *self.cache.write().await = Cache { keys, fetched_at: Some(Instant::now()) };
        Ok(())
    }
}

src/auth.rs

Rust
use crate::AppState;
use axum::{
    extract::FromRequestParts,
    http::{header::AUTHORIZATION, request::Parts, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use jsonwebtoken::{decode, decode_header, Algorithm, Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Debug, Deserialize)]
struct Claims {
    sub: String,
    tenant: i64,
    #[serde(default)]
    scope: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct Principal {
    pub sub: String,
    pub tenant: i64,
    pub scopes: Vec<String>,
}

impl Principal {
    pub fn require_scope(&self, scope: &str) -> Result<(), AuthError> {
        if self.scopes.iter().any(|s| s == scope) {
            Ok(())
        } else {
            Err(AuthError::Forbidden)
        }
    }
}

pub enum AuthError {
    Missing,
    Invalid,
    Forbidden,
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AuthError::Missing | AuthError::Invalid => (StatusCode::UNAUTHORIZED, "unauthorized"),
            AuthError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

impl FromRequestParts<AppState> for Principal {
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, AuthError> {
        let token = parts
            .headers
            .get(AUTHORIZATION)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| value.strip_prefix("Bearer "))
            .ok_or(AuthError::Missing)?;

        let kid = decode_header(token)
            .map_err(|_| AuthError::Invalid)?
            .kid
            .ok_or(AuthError::Invalid)?;
        let key = state.jwks.key_for(&kid).await.ok_or(AuthError::Invalid)?;

        let mut validation = Validation::new(Algorithm::RS256);
        validation.set_issuer(&[state.issuer.as_str()]);
        validation.set_audience(&[state.audience.as_str()]);
        validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);

        let data = decode::<Claims>(token, &key, &validation).map_err(|error| {
            eprintln!("token rejected: {error}");
            AuthError::Invalid
        })?;

        Ok(Principal {
            sub: data.claims.sub,
            tenant: data.claims.tenant,
            scopes: data.claims.scope.split_whitespace().map(str::to_string).collect(),
        })
    }
}

src/outbound.rs

Rust
use std::time::{Duration, Instant};
use tokio::sync::Mutex;

#[derive(Clone)]
pub struct Secret(pub String);

impl std::fmt::Debug for Secret {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("Secret(***)")
    }
}

#[derive(Debug, Clone)]
pub struct OAuthConfig {
    pub token_url: String,
    pub client_id: String,
    pub client_secret: Secret,
    pub scope: String,
}

#[derive(serde::Deserialize)]
struct TokenResponse {
    access_token: String,
    expires_in: u64,
}

pub struct TokenSource {
    cfg: OAuthConfig,
    http: reqwest::Client,
    cached: Mutex<Option<(String, Instant)>>,
}

impl TokenSource {
    pub fn new(cfg: OAuthConfig) -> Self {
        Self { cfg, http: reqwest::Client::new(), cached: Mutex::new(None) }
    }

    pub async fn token(&self) -> reqwest::Result<String> {
        let mut slot = self.cached.lock().await;
        if let Some((token, expires)) = slot.as_ref() {
            if Instant::now() < *expires {
                return Ok(token.clone());
            }
        }
        let response: TokenResponse = self
            .http
            .post(&self.cfg.token_url)
            .basic_auth(&self.cfg.client_id, Some(&self.cfg.client_secret.0))
            .form(&[("grant_type", "client_credentials"), ("scope", self.cfg.scope.as_str())])
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        let lifetime = Duration::from_secs(response.expires_in.saturating_sub(30));
        *slot = Some((response.access_token.clone(), Instant::now() + lifetime));
        Ok(response.access_token)
    }
}

src/outbound_mtls.rs

Rust
pub fn mtls_client() -> Result<reqwest::Client, Box<dyn std::error::Error>> {
    let identity = reqwest::Identity::from_pem(&std::fs::read("orders-identity.pem")?)?;
    let ca = reqwest::Certificate::from_pem(&std::fs::read("ca.pem")?)?;
    let client = reqwest::Client::builder()
        .use_rustls_tls()
        .identity(identity)
        .add_root_certificate(ca)
        .build()?;
    Ok(client)
}

src/main.rs

/me returns the principal. /orders requires the orders:read scope. The outbound modules are compiled but unused by these endpoints.

Rust
mod auth;
mod jwks;
#[allow(dead_code)]
mod outbound;
#[allow(dead_code)]
mod outbound_mtls;

use auth::{AuthError, Principal};
use axum::{extract::State, routing::get, Json, Router};
use jwks::Jwks;
use serde_json::{json, Value};
use std::sync::Arc;

#[derive(Clone)]
pub struct AppState {
    pub jwks: Arc<Jwks>,
    pub issuer: String,
    pub audience: String,
}

async fn me(principal: Principal) -> Json<Principal> {
    Json(principal)
}

async fn list_orders(
    State(_state): State<AppState>,
    principal: Principal,
) -> Result<Json<Value>, AuthError> {
    principal.require_scope("orders:read")?;
    Ok(Json(json!({ "tenant": principal.tenant, "orders": [] })))
}

#[tokio::main]
async fn main() {
    let jwks_url = std::env::var("JWKS_URL")
        .unwrap_or_else(|_| "http://127.0.0.1:9000/jwks.json".to_string());
    let state = AppState {
        jwks: Arc::new(Jwks::new(jwks_url)),
        issuer: "https://id.example.com/".to_string(),
        audience: "storefront-api".to_string(),
    };
    let app = Router::new()
        .route("/me", get(me))
        .route("/orders", get(list_orders))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

src/bin/mint.rs

Rust
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Serialize)]
struct Claims {
    iss: String,
    aud: String,
    sub: String,
    tenant: i64,
    scope: String,
    exp: u64,
}

fn main() {
    let scope = std::env::args().nth(1).unwrap_or_else(|| "orders:read".to_string());
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
    let claims = Claims {
        iss: "https://id.example.com/".to_string(),
        aud: "storefront-api".to_string(),
        sub: "user-42".to_string(),
        tenant: 7,
        scope,
        exp: now + 300,
    };
    let mut header = Header::new(Algorithm::RS256);
    header.kid = Some("key-1".to_string());
    let pem = std::fs::read("private.pem").expect("private.pem in the current directory");
    let key = EncodingKey::from_rsa_pem(&pem).expect("valid RSA private key");
    println!("{}", encode(&header, &claims, &key).unwrap());
}

src/bin/pkce.rs

Rust
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use sha2::{Digest, Sha256};

fn main() {
    let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
    let digest = Sha256::digest(verifier.as_bytes());
    let challenge = URL_SAFE_NO_PAD.encode(digest);
    println!("code_challenge={challenge}");
}

Setup: run the key and JWKS commands from Project setup in the crate directory, with the python3 server running. In a second terminal:

Terminal
cargo run

Requests:

Terminal
curl -s -w ' [%{http_code}]\n' localhost:3000/me
TOKEN=$(cargo run -q --bin mint)
curl -s -w ' [%{http_code}]\n' -H "Authorization: Bearer $TOKEN" localhost:3000/me
curl -s -w ' [%{http_code}]\n' -H "Authorization: Bearer $TOKEN" localhost:3000/orders
NOSCOPE=$(cargo run -q --bin mint inventory:read)
curl -s -w ' [%{http_code}]\n' -H "Authorization: Bearer $NOSCOPE" localhost:3000/orders
curl -s -w ' [%{http_code}]\n' -H "Authorization: Bearer garbage" localhost:3000/me
Output
{"error":"unauthorized"} [401]
{"sub":"user-42","tenant":7,"scopes":["orders:read"]} [200]
{"orders":[],"tenant":7} [200]
{"error":"forbidden"} [403]
{"error":"unauthorized"} [401]

serde_json writes object keys in sorted order by default, which is why orders precedes tenant. The Principal struct serializes in field order.

Summary

  • Resource servers validate bearer tokens. Identity providers issue them.
  • PKCE binds the authorization code to the client that started the flow.
  • OIDC adds the ID token and discovery. APIs accept access tokens only.
  • Validation covers signature, iss, aud, and exp, with an algorithm fixed in code.
  • The JWKS endpoint publishes public keys by kid. Services cache them and refetch on an unknown kid.
  • Service tokens are cached until shortly before expires_in.
  • mTLS needs certificates signed by a CA trusted by both sides. Short lifetimes replace revocation.
  • Secrets stay out of source and images, are redacted in logs, and are rotated.