Frontend Labs
⋯

56. Project: an auth service with Argon2 and JWTs

Implements: Go §48 in Rust, using Security from Zero Ch 46 and Security in Depth Ch 24.

This project brings Part VI together: validated input types, Argon2id in spawn_blocking, protection against user enumeration, strict JWT validation, and an Axum extractor that makes authentication part of the handler's signature.

// src/bin/authsvc.rs
use argon2::password_hash::{phc::PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::Argon2;
use axum::extract::{FromRequestParts, State};
use axum::http::{request::Parts, StatusCode};
use axum::routing::{get, post};
use axum::{Json, Router};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};

const ISSUER: &str = "authsvc.rust-guide.local";
const AUDIENCE: &str = "api.rust-guide.local";
const TOKEN_TTL_SECS: u64 = 15 * 60;              // short-lived access tokens

#[derive(Clone)]
struct AppState { users: Arc<RwLock<HashMap<String, String>>>, jwt_secret: Arc<[u8]> }

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Credentials { username: String, password: String }

#[derive(Serialize, Deserialize)]
struct Claims { sub: String, iss: String, aud: String, exp: u64, iat: u64 }

type ApiResult<T> = Result<T, (StatusCode, &'static str)>;

fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() }

/// A real hash of a random password: verifying against it costs the same as a real user.
static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| {
    Argon2::default().hash_password(&rand::random::<[u8; 16]>()).unwrap().to_string()
});

async fn register(State(st): State<AppState>, Json(c): Json<Credentials>) -> ApiResult<StatusCode> {
    let valid_name = (3..=32).contains(&c.username.len()) && c.username.bytes().all(|b| b.is_ascii_alphanumeric());
    if !valid_name { return Err((StatusCode::UNPROCESSABLE_ENTITY, "invalid username")); }
    if !(12..=128).contains(&c.password.len()) { return Err((StatusCode::UNPROCESSABLE_ENTITY, "password must be 12-128 chars")); }
    let hash = tokio::task::spawn_blocking(move || {        // ~tens of ms of CPU: keep it off the async workers
        Argon2::default().hash_password(c.password.as_bytes()).map(|h| h.to_string())
    }).await.unwrap().map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "hash failed"))?;
    let mut users = st.users.write().unwrap();
    if users.contains_key(&c.username) { return Err((StatusCode::CONFLICT, "username taken")); }
    users.insert(c.username, hash);
    Ok(StatusCode::CREATED)
}

async fn login(State(st): State<AppState>, Json(c): Json<Credentials>) -> ApiResult<Json<serde_json::Value>> {
    let stored = st.users.read().unwrap().get(&c.username).cloned();
    let user_exists = stored.is_some();
    let hash = stored.unwrap_or_else(|| DUMMY_HASH.clone());
    // Always run a full Argon2 verification, so "no such user" and "wrong password" take the same time.
    let password_ok = tokio::task::spawn_blocking(move || {
        PasswordHash::new(&hash).is_ok_and(|h| Argon2::default().verify_password(c.password.as_bytes(), &h).is_ok())
    }).await.unwrap();
    if !(user_exists && password_ok) {
        return Err((StatusCode::UNAUTHORIZED, "invalid credentials"));  // one message for both cases
    }
    let iat = now();
    let claims = Claims { sub: c.username, iss: ISSUER.into(), aud: AUDIENCE.into(), iat, exp: iat + TOKEN_TTL_SECS };
    let token = encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(&st.jwt_secret))
        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "token error"))?;
    Ok(Json(serde_json::json!({ "access_token": token, "token_type": "Bearer", "expires_in": TOKEN_TTL_SECS })))
}

/// Extractor: a handler that takes `AuthUser` cannot run without a valid token.
struct AuthUser { name: String }

impl FromRequestParts<AppState> for AuthUser {
    type Rejection = (StatusCode, &'static str);
    async fn from_request_parts(parts: &mut Parts, st: &AppState) -> Result<Self, Self::Rejection> {
        let token = parts.headers.get("authorization").and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .ok_or((StatusCode::UNAUTHORIZED, "missing bearer token"))?;
        let mut v = Validation::new(Algorithm::HS256);    // pin the algorithm: no "alg": "none", no RS/HS confusion
        v.set_issuer(&[ISSUER]);
        v.set_audience(&[AUDIENCE]);
        v.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
        v.leeway = 30;                                    // seconds of clock skew allowed
        let data = decode::<Claims>(token, &DecodingKey::from_secret(&st.jwt_secret), &v)
            .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid token"))?;
        Ok(AuthUser { name: data.claims.sub })
    }
}

async fn me(user: AuthUser) -> String { format!("hello, {}", user.name) }

fn app() -> Router {
    let secret: [u8; 32] = rand::random();              // production: from a secret manager, rotated
    let state = AppState { users: Arc::default(), jwt_secret: Arc::from(secret.as_slice()) };
    Router::new()
        .route("/register", post(register))
        .route("/login", post(login))
        .route("/me", get(me))
        .with_state(state)
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await?;
    println!("authsvc on {}", listener.local_addr()?);
    axum::serve(listener, app()).await
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::{to_bytes, Body};
    use axum::http::Request;
    use tower::ServiceExt;

    async fn call(app: &Router, method: &str, uri: &str, body: &str, bearer: Option<&str>) -> (StatusCode, String) {
        let mut b = Request::builder().method(method).uri(uri).header("content-type", "application/json");
        if let Some(t) = bearer { b = b.header("authorization", format!("Bearer {t}")); }
        let resp = app.clone().oneshot(b.body(Body::from(body.to_string())).unwrap()).await.unwrap();
        let status = resp.status();
        (status, String::from_utf8_lossy(&to_bytes(resp.into_body(), 1 << 20).await.unwrap()).into_owned())
    }

    #[tokio::test]
    async fn full_flow() {
        let app = app();
        let creds = r#"{"username":"ada","password":"correct horse battery"}"#;
        assert_eq!(call(&app, "POST", "/register", creds, None).await.0, StatusCode::CREATED);
        assert_eq!(call(&app, "POST", "/register", creds, None).await.0, StatusCode::CONFLICT);

        let (s, body) = call(&app, "POST", "/login", creds, None).await;
        assert_eq!(s, StatusCode::OK);
        let token = serde_json::from_str::<serde_json::Value>(&body).unwrap()["access_token"].as_str().unwrap().to_string();
        assert_eq!(call(&app, "GET", "/me", "", Some(&token)).await, (StatusCode::OK, "hello, ada".into()));

        // Same status and message for an unknown user and a wrong password: no user enumeration.
        let wrong_pw = call(&app, "POST", "/login", r#"{"username":"ada","password":"wrong password!!"}"#, None).await;
        let no_user = call(&app, "POST", "/login", r#"{"username":"bob","password":"whatever12345"}"#, None).await;
        assert_eq!(wrong_pw, no_user);

        // Tampered token: change one character of the signature.
        let mut bad = token.clone();
        let last = bad.pop().unwrap();
        bad.push(if last == 'A' { 'B' } else { 'A' });
        assert_eq!(call(&app, "GET", "/me", "", Some(&bad)).await.0, StatusCode::UNAUTHORIZED);
        assert_eq!(call(&app, "GET", "/me", "", None).await.0, StatusCode::UNAUTHORIZED);
    }
}
cargo test --bin authsvc

What this does not do yet, and where the guides cover it:

  • Refresh tokens and revocation. A 15-minute access token cannot be revoked before it expires. Pair it with a server-side refresh token that can be.
  • Asymmetric signing. With HS256, anyone who can verify a token can also mint one. If other services verify tokens, sign with Ed25519 or ES256 and publish a JWKS (Security in Depth Ch 24).
  • Rate limiting on /login per account and per IP, so that the expensive Argon2 hash is not itself a DoS lever (Security in Depth Ch 40B).
  • Persistent storage, with a database and parameterized queries (§53.3).

Generated from real-life-rust-guide.md · 8 Oct 2026 16:36