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
/loginper 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).