An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement POST /xrpc/com.atproto.server.refreshSession

Exchanges a refresh JWT for a new access + refresh token pair with
full token rotation and replay detection.

- Verify HS256 refresh JWT (signature, expiry, audience, scope)
- Look up token in refresh_tokens table; reject if not found/expired
- Detect replay: next_jti IS NOT NULL triggers full session revocation
(deletes session + all its refresh tokens atomically)
- On first use: atomically insert new token and set next_jti on old one
- Return { accessJwt, refreshJwt, handle, did }

Refactors issue_access_jwt and issue_refresh_jwt out of create_session
into auth/jwt.rs so both routes can share them without violating the
no-cross-route-imports rule. Also promotes extract_bearer_token to
pub(crate) so route handlers can call it directly.

10 tests covering all 4 ACs: valid rotation, scope/claim checks, DB
state after rotation, replay rejection, session revocation on replay,
expired/invalid/wrong-scope token errors, missing auth header.

authored by

Malpercio and committed by
Tangled
e1726c69 aece583d

+735 -79
+19
bruno/refresh_session.bru
··· 1 + meta { 2 + name: Refresh Session (refreshSession) 3 + type: http 4 + seq: 21 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/xrpc/com.atproto.server.refreshSession 9 + body: none 10 + auth: bearer 11 + } 12 + 13 + auth:bearer { 14 + token: {{refreshJwt}} 15 + } 16 + 17 + vars:pre-request { 18 + baseUrl: http://localhost:8080 19 + }
+6 -1
crates/relay/src/app.rs
··· 33 33 use crate::routes::oauth_server_metadata::oauth_server_metadata; 34 34 use crate::routes::oauth_token::post_token; 35 35 use crate::routes::provisioning_session::create_provisioning_session; 36 + use crate::routes::refresh_session::refresh_session; 36 37 use crate::routes::register_device::register_device; 37 38 use crate::routes::resolve_handle::resolve_handle_handler; 38 39 use crate::well_known::WellKnownResolver; ··· 158 159 post(create_session), 159 160 ) 160 161 .route("/xrpc/com.atproto.server.getSession", get(get_session)) 162 + .route( 163 + "/xrpc/com.atproto.server.refreshSession", 164 + post(refresh_session), 165 + ) 161 166 .route( 162 167 "/xrpc/com.atproto.identity.resolveHandle", 163 168 get(resolve_handle_handler), ··· 352 357 let response = app(test_state().await) 353 358 .oneshot( 354 359 Request::builder() 355 - .uri("/xrpc/com.atproto.server.refreshSession") 360 + .uri("/xrpc/com.example.notImplemented") 356 361 .body(Body::empty()) 357 362 .unwrap(), 358 363 )
+127 -2
crates/relay/src/auth/jwt.rs
··· 1 1 // pattern: Functional Core 2 2 3 3 use common::{ApiError, ErrorCode}; 4 - use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; 5 - use serde::Deserialize; 4 + use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; 5 + use serde::{Deserialize, Serialize}; 6 6 7 7 use crate::app::AppState; 8 8 ··· 149 149 )), 150 150 } 151 151 } 152 + 153 + // ── Refresh token verification ──────────────────────────────────────────────── 154 + 155 + /// Claims decoded from a refresh JWT (scope: com.atproto.refresh). 156 + /// 157 + /// `sub` is intentionally omitted — the authoritative DID is read from the DB 158 + /// after confirming the token exists, rather than trusting the JWT claim directly. 159 + /// JWT library still enforces `sub` presence via `set_required_spec_claims`. 160 + #[derive(Debug, Deserialize)] 161 + pub(crate) struct RefreshTokenClaims { 162 + pub scope: String, 163 + /// Token ID embedded in the JWT and stored in `refresh_tokens.jti`. 164 + /// `None` when an access token (no `jti`) is mistakenly presented here. 165 + pub jti: Option<String>, 166 + } 167 + 168 + /// Verify an HS256 refresh JWT issued by this server. 169 + /// 170 + /// Validates signature, expiry, and audience (when `server_did` is configured). 171 + /// Does NOT check that `scope == "com.atproto.refresh"` — callers are responsible 172 + /// for that check so that the error message can be precise. 173 + pub fn verify_refresh_token( 174 + token: &str, 175 + state: &crate::app::AppState, 176 + ) -> Result<RefreshTokenClaims, ApiError> { 177 + let decoding_key = DecodingKey::from_secret(&state.jwt_secret); 178 + let mut validation = Validation::new(Algorithm::HS256); 179 + match state.config.server_did.as_deref() { 180 + Some(did) => validation.set_audience(&[did]), 181 + None => { 182 + validation.validate_aud = false; 183 + } 184 + } 185 + validation.set_required_spec_claims(&["exp", "sub"]); 186 + validation.leeway = 0; 187 + 188 + decode::<RefreshTokenClaims>(token, &decoding_key, &validation) 189 + .map(|data| data.claims) 190 + .map_err(|e| { 191 + use jsonwebtoken::errors::ErrorKind; 192 + match e.kind() { 193 + ErrorKind::ExpiredSignature => { 194 + ApiError::new(ErrorCode::TokenExpired, "token has expired") 195 + } 196 + _ => { 197 + tracing::debug!(error = %e, error_kind = ?e.kind(), "refresh token verification failed"); 198 + ApiError::new(ErrorCode::InvalidToken, "invalid token") 199 + } 200 + } 201 + }) 202 + } 203 + 204 + // ── Legacy HS256 token issuance ─────────────────────────────────────────────── 205 + 206 + const ACCESS_TOKEN_TTL_SECS: u64 = 2 * 60 * 60; // 2 hours 207 + const REFRESH_TOKEN_TTL_SECS: u64 = 90 * 24 * 60 * 60; // 90 days 208 + 209 + #[derive(Serialize)] 210 + struct LegacyAccessClaims { 211 + scope: &'static str, 212 + sub: String, 213 + aud: String, 214 + iat: u64, 215 + exp: u64, 216 + } 217 + 218 + #[derive(Serialize)] 219 + struct LegacyRefreshClaims { 220 + scope: &'static str, 221 + sub: String, 222 + aud: String, 223 + jti: String, 224 + iat: u64, 225 + exp: u64, 226 + } 227 + 228 + /// Sign an HS256 access JWT (scope: com.atproto.access) with a 2-hour lifetime. 229 + pub(crate) fn issue_access_jwt( 230 + secret: &[u8; 32], 231 + did: &str, 232 + aud: &str, 233 + now: u64, 234 + ) -> Result<String, ApiError> { 235 + encode( 236 + &Header::new(Algorithm::HS256), 237 + &LegacyAccessClaims { 238 + scope: "com.atproto.access", 239 + sub: did.to_string(), 240 + aud: aud.to_string(), 241 + iat: now, 242 + exp: now + ACCESS_TOKEN_TTL_SECS, 243 + }, 244 + &EncodingKey::from_secret(secret), 245 + ) 246 + .map_err(|e| { 247 + tracing::error!(error = %e, "failed to sign access JWT"); 248 + ApiError::new(ErrorCode::InternalError, "failed to issue token") 249 + }) 250 + } 251 + 252 + /// Sign an HS256 refresh JWT (scope: com.atproto.refresh) with a 90-day lifetime. 253 + pub(crate) fn issue_refresh_jwt( 254 + secret: &[u8; 32], 255 + did: &str, 256 + aud: &str, 257 + jti: &str, 258 + now: u64, 259 + ) -> Result<String, ApiError> { 260 + encode( 261 + &Header::new(Algorithm::HS256), 262 + &LegacyRefreshClaims { 263 + scope: "com.atproto.refresh", 264 + sub: did.to_string(), 265 + aud: aud.to_string(), 266 + jti: jti.to_string(), 267 + iat: now, 268 + exp: now + REFRESH_TOKEN_TTL_SECS, 269 + }, 270 + &EncodingKey::from_secret(secret), 271 + ) 272 + .map_err(|e| { 273 + tracing::error!(error = %e, "failed to sign refresh JWT"); 274 + ApiError::new(ErrorCode::InternalError, "failed to issue token") 275 + }) 276 + }
+1 -2
crates/relay/src/auth/mod.rs
··· 6 6 pub mod signing_key; 7 7 8 8 mod bearer; 9 + pub(crate) use bearer::extract_bearer_token; 9 10 10 11 // Re-export the public API so callers don't need to know the internal layout. 11 12 pub use dpop::{ ··· 20 21 pub use signing_key::{load_or_create_oauth_signing_key, OAuthSigningKey}; 21 22 22 23 // Test-only: make private helpers visible to the test module below (which uses `use super::*`). 23 - #[cfg(test)] 24 - pub(super) use bearer::extract_bearer_token; 25 24 #[cfg(test)] 26 25 pub(super) use dpop::{ 27 26 dpop_alg_from_str, jwk_thumbprint, validate_and_consume_nonce, validate_dpop,
+1 -74
crates/relay/src/routes/create_session.rs
··· 10 10 use std::time::{SystemTime, UNIX_EPOCH}; 11 11 12 12 use axum::{extract::State, http::StatusCode, response::Json}; 13 - use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; 14 13 use serde::{Deserialize, Serialize}; 15 14 use uuid::Uuid; 16 15 17 16 use common::{ApiError, ErrorCode}; 18 17 19 18 use crate::app::AppState; 19 + use crate::auth::jwt::{issue_access_jwt, issue_refresh_jwt}; 20 20 use crate::auth::password::{verify_password, VerifyResult}; 21 21 use crate::auth::rate_limit::{clear_failures, is_rate_limited, record_failure}; 22 22 use crate::db::accounts::resolve_identifier; 23 - 24 - const ACCESS_TOKEN_TTL_SECS: u64 = 2 * 60 * 60; // 2 hours 25 - const REFRESH_TOKEN_TTL_SECS: u64 = 90 * 24 * 60 * 60; // 90 days 26 23 27 24 // ── Request / Response types ───────────────────────────────────────────────── 28 25 ··· 41 38 handle: String, 42 39 did: String, 43 40 email: String, 44 - } 45 - 46 - // ── JWT claim structs ──────────────────────────────────────────────────────── 47 - 48 - /// Claims for a legacy HS256 access token (scope: com.atproto.access). 49 - #[derive(Serialize)] 50 - struct LegacyAccessClaims { 51 - scope: &'static str, 52 - sub: String, 53 - /// Audience — server_did when configured, public_url otherwise. 54 - aud: String, 55 - iat: u64, 56 - exp: u64, 57 - } 58 - 59 - /// Claims for a legacy HS256 refresh token (scope: com.atproto.refresh). 60 - #[derive(Serialize)] 61 - struct LegacyRefreshClaims { 62 - scope: &'static str, 63 - sub: String, 64 - aud: String, 65 - /// Unique token ID stored in `refresh_tokens.jti` for refresh-token rotation. 66 - jti: String, 67 - iat: u64, 68 - exp: u64, 69 41 } 70 42 71 43 // ── Handler ────────────────────────────────────────────────────────────────── ··· 231 203 email: account.email, 232 204 }), 233 205 )) 234 - } 235 - 236 - /// Sign an HS256 access JWT with a 2-hour lifetime. 237 - fn issue_access_jwt(secret: &[u8; 32], did: &str, aud: &str, now: u64) -> Result<String, ApiError> { 238 - encode( 239 - &Header::new(Algorithm::HS256), 240 - &LegacyAccessClaims { 241 - scope: "com.atproto.access", 242 - sub: did.to_string(), 243 - aud: aud.to_string(), 244 - iat: now, 245 - exp: now + ACCESS_TOKEN_TTL_SECS, 246 - }, 247 - &EncodingKey::from_secret(secret), 248 - ) 249 - .map_err(|e| { 250 - tracing::error!(error = %e, "failed to sign access JWT"); 251 - ApiError::new(ErrorCode::InternalError, "failed to issue token") 252 - }) 253 - } 254 - 255 - /// Sign an HS256 refresh JWT with a 90-day lifetime. 256 - fn issue_refresh_jwt( 257 - secret: &[u8; 32], 258 - did: &str, 259 - aud: &str, 260 - jti: &str, 261 - now: u64, 262 - ) -> Result<String, ApiError> { 263 - encode( 264 - &Header::new(Algorithm::HS256), 265 - &LegacyRefreshClaims { 266 - scope: "com.atproto.refresh", 267 - sub: did.to_string(), 268 - aud: aud.to_string(), 269 - jti: jti.to_string(), 270 - iat: now, 271 - exp: now + REFRESH_TOKEN_TTL_SECS, 272 - }, 273 - &EncodingKey::from_secret(secret), 274 - ) 275 - .map_err(|e| { 276 - tracing::error!(error = %e, "failed to sign refresh JWT"); 277 - ApiError::new(ErrorCode::InternalError, "failed to issue token") 278 - }) 279 206 } 280 207 281 208 // ── Tests ────────────────────────────────────────────────────────────────────
+1
crates/relay/src/routes/mod.rs
··· 17 17 pub(super) mod oauth_templates; 18 18 pub mod oauth_token; 19 19 pub mod provisioning_session; 20 + pub mod refresh_session; 20 21 pub mod register_device; 21 22 pub mod resolve_handle; 22 23
+580
crates/relay/src/routes/refresh_session.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: Authorization header (refresh JWT Bearer), DB pool, jwt_secret, config 4 + // Processes: JWT verification → scope check → refresh_token DB lookup → 5 + // replay detection → new JWT issuance → token rotation DB update 6 + // Returns: JSON {accessJwt, refreshJwt, handle, did} on success; ApiError on failure 7 + // 8 + // Implements: POST /xrpc/com.atproto.server.refreshSession 9 + 10 + use std::time::{SystemTime, UNIX_EPOCH}; 11 + 12 + use axum::{extract::State, http::HeaderMap, http::StatusCode, response::Json}; 13 + use serde::Serialize; 14 + use uuid::Uuid; 15 + 16 + use common::{ApiError, ErrorCode}; 17 + 18 + use crate::app::AppState; 19 + use crate::auth::extract_bearer_token; 20 + use crate::auth::jwt::{issue_access_jwt, issue_refresh_jwt, parse_scope, verify_refresh_token, AuthScope}; 21 + 22 + // ── Response type ──────────────────────────────────────────────────────────── 23 + 24 + #[derive(Serialize)] 25 + #[serde(rename_all = "camelCase")] 26 + pub struct RefreshSessionResponse { 27 + access_jwt: String, 28 + refresh_jwt: String, 29 + handle: String, 30 + did: String, 31 + } 32 + 33 + // ── Handler ────────────────────────────────────────────────────────────────── 34 + 35 + /// POST /xrpc/com.atproto.server.refreshSession 36 + /// 37 + /// Exchanges a refresh JWT for a new access + refresh token pair. 38 + /// Implements token rotation: the old refresh token is invalidated on first use 39 + /// and a new one is issued. Replay detection: if an already-rotated refresh token 40 + /// is presented, the entire session is revoked as a security measure. 41 + pub async fn refresh_session( 42 + State(state): State<AppState>, 43 + headers: HeaderMap, 44 + ) -> Result<(StatusCode, Json<RefreshSessionResponse>), ApiError> { 45 + // --- Extract and verify the refresh JWT --- 46 + let token = extract_bearer_token(&headers)?; 47 + let claims = verify_refresh_token(token, &state)?; 48 + 49 + if parse_scope(&claims.scope)? != AuthScope::Refresh { 50 + return Err(ApiError::new( 51 + ErrorCode::InvalidToken, 52 + "refresh token required", 53 + )); 54 + } 55 + 56 + let jti = claims.jti.ok_or_else(|| { 57 + ApiError::new(ErrorCode::InvalidToken, "invalid refresh token") 58 + })?; 59 + 60 + // --- Look up the refresh token in the DB --- 61 + // `next_jti IS NULL` is not checked here — we need the row regardless to detect replays. 62 + type RefreshRow = (String, String, Option<String>); // (did, session_id, next_jti) 63 + let row: Option<RefreshRow> = sqlx::query_as( 64 + "SELECT did, session_id, next_jti FROM refresh_tokens \ 65 + WHERE jti = ? AND expires_at > datetime('now')", 66 + ) 67 + .bind(&jti) 68 + .fetch_optional(&state.db) 69 + .await 70 + .map_err(|e| { 71 + tracing::error!(error = %e, "DB error looking up refresh token"); 72 + ApiError::new(ErrorCode::InternalError, "internal error") 73 + })?; 74 + 75 + let (did, session_id, next_jti) = row.ok_or_else(|| { 76 + ApiError::new(ErrorCode::InvalidToken, "refresh token not found or expired") 77 + })?; 78 + 79 + // --- Replay detection: next_jti being set means this token was already rotated --- 80 + if next_jti.is_some() { 81 + // Revoke the entire session atomically. 82 + let mut tx = state.db.begin().await.map_err(|e| { 83 + tracing::error!(error = %e, "failed to begin revocation transaction"); 84 + ApiError::new(ErrorCode::InternalError, "internal error") 85 + })?; 86 + 87 + sqlx::query("DELETE FROM refresh_tokens WHERE session_id = ?") 88 + .bind(&session_id) 89 + .execute(&mut *tx) 90 + .await 91 + .map_err(|e| { 92 + tracing::error!(error = %e, "failed to delete refresh tokens during revocation"); 93 + ApiError::new(ErrorCode::InternalError, "internal error") 94 + })?; 95 + 96 + sqlx::query("DELETE FROM sessions WHERE id = ?") 97 + .bind(&session_id) 98 + .execute(&mut *tx) 99 + .await 100 + .map_err(|e| { 101 + tracing::error!(error = %e, "failed to delete session during revocation"); 102 + ApiError::new(ErrorCode::InternalError, "internal error") 103 + })?; 104 + 105 + tx.commit().await.map_err(|e| { 106 + tracing::error!(error = %e, "failed to commit revocation transaction"); 107 + ApiError::new(ErrorCode::InternalError, "internal error") 108 + })?; 109 + 110 + tracing::warn!( 111 + did = %did, 112 + session_id = %session_id, 113 + "refresh token replay detected; session revoked" 114 + ); 115 + return Err(ApiError::new( 116 + ErrorCode::InvalidToken, 117 + "refresh token already used", 118 + )); 119 + } 120 + 121 + // --- Issue new tokens --- 122 + let now = SystemTime::now() 123 + .duration_since(UNIX_EPOCH) 124 + .map_err(|e| { 125 + tracing::error!(error = %e, "system clock is before Unix epoch"); 126 + ApiError::new(ErrorCode::InternalError, "failed to issue token") 127 + })? 128 + .as_secs(); 129 + 130 + let aud = state 131 + .config 132 + .server_did 133 + .as_deref() 134 + .unwrap_or(&state.config.public_url) 135 + .to_string(); 136 + 137 + let new_access_jwt = issue_access_jwt(&state.jwt_secret, &did, &aud, now)?; 138 + let new_refresh_jti = Uuid::new_v4().to_string(); 139 + let new_refresh_jwt = issue_refresh_jwt(&state.jwt_secret, &did, &aud, &new_refresh_jti, now)?; 140 + 141 + // --- Atomically rotate: insert new token, mark old as used --- 142 + let mut tx = state.db.begin().await.map_err(|e| { 143 + tracing::error!(error = %e, "failed to begin token rotation transaction"); 144 + ApiError::new(ErrorCode::InternalError, "failed to refresh session") 145 + })?; 146 + 147 + sqlx::query( 148 + "INSERT INTO refresh_tokens (jti, did, session_id, expires_at, created_at) \ 149 + VALUES (?, ?, ?, datetime('now', '+90 days'), datetime('now'))", 150 + ) 151 + .bind(&new_refresh_jti) 152 + .bind(&did) 153 + .bind(&session_id) 154 + .execute(&mut *tx) 155 + .await 156 + .map_err(|e| { 157 + tracing::error!(error = %e, "failed to insert new refresh token"); 158 + ApiError::new(ErrorCode::InternalError, "failed to refresh session") 159 + })?; 160 + 161 + sqlx::query("UPDATE refresh_tokens SET next_jti = ? WHERE jti = ?") 162 + .bind(&new_refresh_jti) 163 + .bind(&jti) 164 + .execute(&mut *tx) 165 + .await 166 + .map_err(|e| { 167 + tracing::error!(error = %e, "failed to mark old refresh token as used"); 168 + ApiError::new(ErrorCode::InternalError, "failed to refresh session") 169 + })?; 170 + 171 + tx.commit().await.map_err(|e| { 172 + tracing::error!(error = %e, "failed to commit token rotation transaction"); 173 + ApiError::new(ErrorCode::InternalError, "failed to refresh session") 174 + })?; 175 + 176 + // --- Look up handle for the response --- 177 + let handle: Option<String> = sqlx::query_scalar( 178 + "SELECT h.handle FROM handles h WHERE h.did = ? LIMIT 1", 179 + ) 180 + .bind(&did) 181 + .fetch_optional(&state.db) 182 + .await 183 + .map_err(|e| { 184 + tracing::error!(error = %e, did = %did, "DB error fetching handle for refreshSession"); 185 + ApiError::new(ErrorCode::InternalError, "internal error") 186 + })?; 187 + 188 + // ATProto spec: "handle.invalid" is the sentinel for accounts without a resolvable handle. 189 + let handle = handle.unwrap_or_else(|| "handle.invalid".to_string()); 190 + 191 + Ok(( 192 + StatusCode::OK, 193 + Json(RefreshSessionResponse { 194 + access_jwt: new_access_jwt, 195 + refresh_jwt: new_refresh_jwt, 196 + handle, 197 + did, 198 + }), 199 + )) 200 + } 201 + 202 + // ── Tests ──────────────────────────────────────────────────────────────────── 203 + 204 + #[cfg(test)] 205 + mod tests { 206 + use axum::{ 207 + body::Body, 208 + http::{Request, StatusCode}, 209 + }; 210 + use tower::ServiceExt; 211 + 212 + use crate::app::{app, test_state}; 213 + use crate::routes::test_utils::{body_json, insert_account_with_password}; 214 + 215 + // ── Helpers ─────────────────────────────────────────────────────────────── 216 + 217 + fn post_refresh_session(refresh_jwt: &str) -> Request<Body> { 218 + Request::builder() 219 + .method("POST") 220 + .uri("/xrpc/com.atproto.server.refreshSession") 221 + .header("Authorization", format!("Bearer {refresh_jwt}")) 222 + .body(Body::empty()) 223 + .unwrap() 224 + } 225 + 226 + /// Call createSession and return the JSON response body. 227 + async fn create_session_tokens( 228 + state: &crate::app::AppState, 229 + did: &str, 230 + password: &str, 231 + ) -> serde_json::Value { 232 + let request = Request::builder() 233 + .method("POST") 234 + .uri("/xrpc/com.atproto.server.createSession") 235 + .header("Content-Type", "application/json") 236 + .body(Body::from(format!( 237 + r#"{{"identifier":"{did}","password":"{password}"}}"# 238 + ))) 239 + .unwrap(); 240 + let response = app(state.clone()).oneshot(request).await.unwrap(); 241 + body_json(response).await 242 + } 243 + 244 + /// Decode an HS256 JWT without audience validation and return its claims as JSON. 245 + fn decode_jwt(token: &str, secret: &[u8; 32]) -> serde_json::Value { 246 + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); 247 + validation.validate_aud = false; 248 + validation.set_required_spec_claims(&["exp", "sub"]); 249 + jsonwebtoken::decode::<serde_json::Value>( 250 + token, 251 + &jsonwebtoken::DecodingKey::from_secret(secret), 252 + &validation, 253 + ) 254 + .expect("JWT must decode without error") 255 + .claims 256 + } 257 + 258 + /// Build a syntactically valid HS256 refresh JWT whose `exp` is in the past. 259 + fn expired_refresh_jwt(secret: &[u8; 32], did: &str) -> String { 260 + let past = 1_000_000_000u64; 261 + let claims = serde_json::json!({ 262 + "scope": "com.atproto.refresh", 263 + "sub": did, 264 + "jti": uuid::Uuid::new_v4().to_string(), 265 + "iat": past, 266 + "exp": past + 1, 267 + }); 268 + jsonwebtoken::encode( 269 + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), 270 + &claims, 271 + &jsonwebtoken::EncodingKey::from_secret(secret), 272 + ) 273 + .unwrap() 274 + } 275 + 276 + // ── Happy path ──────────────────────────────────────────────────────────── 277 + 278 + #[tokio::test] 279 + async fn valid_refresh_token_returns_new_token_pair() { 280 + let state = test_state().await; 281 + insert_account_with_password( 282 + &state.db, 283 + "did:plc:alice", 284 + "alice.test.example.com", 285 + "alice@example.com", 286 + "hunter2", 287 + ) 288 + .await; 289 + 290 + let tokens = create_session_tokens(&state, "did:plc:alice", "hunter2").await; 291 + let refresh_jwt = tokens["refreshJwt"].as_str().unwrap().to_string(); 292 + 293 + let response = app(state) 294 + .oneshot(post_refresh_session(&refresh_jwt)) 295 + .await 296 + .unwrap(); 297 + 298 + assert_eq!(response.status(), StatusCode::OK); 299 + let json = body_json(response).await; 300 + assert!(json["accessJwt"].as_str().is_some(), "accessJwt required"); 301 + assert!(json["refreshJwt"].as_str().is_some(), "refreshJwt required"); 302 + assert_eq!(json["did"], "did:plc:alice"); 303 + assert_eq!(json["handle"], "alice.test.example.com"); 304 + } 305 + 306 + #[tokio::test] 307 + async fn new_access_jwt_has_access_scope() { 308 + let state = test_state().await; 309 + insert_account_with_password( 310 + &state.db, 311 + "did:plc:scope", 312 + "scope.test.example.com", 313 + "scope@example.com", 314 + "hunter2", 315 + ) 316 + .await; 317 + 318 + let secret = state.jwt_secret; 319 + let tokens = create_session_tokens(&state, "did:plc:scope", "hunter2").await; 320 + let refresh_jwt = tokens["refreshJwt"].as_str().unwrap().to_string(); 321 + 322 + let response = app(state) 323 + .oneshot(post_refresh_session(&refresh_jwt)) 324 + .await 325 + .unwrap(); 326 + assert_eq!(response.status(), StatusCode::OK); 327 + let json = body_json(response).await; 328 + 329 + let access_claims = decode_jwt(json["accessJwt"].as_str().unwrap(), &secret); 330 + assert_eq!(access_claims["scope"], "com.atproto.access"); 331 + assert_eq!(access_claims["sub"], "did:plc:scope"); 332 + } 333 + 334 + #[tokio::test] 335 + async fn new_refresh_jwt_has_refresh_scope_and_different_jti() { 336 + let state = test_state().await; 337 + insert_account_with_password( 338 + &state.db, 339 + "did:plc:jtirot", 340 + "jtirot.test.example.com", 341 + "jtirot@example.com", 342 + "hunter2", 343 + ) 344 + .await; 345 + 346 + let secret = state.jwt_secret; 347 + let tokens = create_session_tokens(&state, "did:plc:jtirot", "hunter2").await; 348 + let original_refresh_jwt = tokens["refreshJwt"].as_str().unwrap(); 349 + let original_jti = decode_jwt(original_refresh_jwt, &secret)["jti"] 350 + .as_str() 351 + .unwrap() 352 + .to_string(); 353 + 354 + let response = app(state) 355 + .oneshot(post_refresh_session(original_refresh_jwt)) 356 + .await 357 + .unwrap(); 358 + assert_eq!(response.status(), StatusCode::OK); 359 + let json = body_json(response).await; 360 + 361 + let new_claims = decode_jwt(json["refreshJwt"].as_str().unwrap(), &secret); 362 + assert_eq!(new_claims["scope"], "com.atproto.refresh"); 363 + let new_jti = new_claims["jti"].as_str().unwrap(); 364 + assert_ne!(new_jti, original_jti, "new jti must differ from original"); 365 + } 366 + 367 + #[tokio::test] 368 + async fn token_rotation_stored_in_db() { 369 + let state = test_state().await; 370 + insert_account_with_password( 371 + &state.db, 372 + "did:plc:dbcheck", 373 + "dbcheck.test.example.com", 374 + "dbcheck@example.com", 375 + "hunter2", 376 + ) 377 + .await; 378 + 379 + let secret = state.jwt_secret; 380 + let db = state.db.clone(); 381 + let tokens = create_session_tokens(&state, "did:plc:dbcheck", "hunter2").await; 382 + let original_refresh_jwt = tokens["refreshJwt"].as_str().unwrap(); 383 + let old_jti = decode_jwt(original_refresh_jwt, &secret)["jti"] 384 + .as_str() 385 + .unwrap() 386 + .to_string(); 387 + 388 + let response = app(state) 389 + .oneshot(post_refresh_session(original_refresh_jwt)) 390 + .await 391 + .unwrap(); 392 + assert_eq!(response.status(), StatusCode::OK); 393 + let json = body_json(response).await; 394 + let new_jti = decode_jwt(json["refreshJwt"].as_str().unwrap(), &secret)["jti"] 395 + .as_str() 396 + .unwrap() 397 + .to_string(); 398 + 399 + // Old token's next_jti must point to the new token. 400 + let next_jti_matches: i64 = sqlx::query_scalar( 401 + "SELECT COUNT(*) FROM refresh_tokens WHERE jti = ? AND next_jti = ?", 402 + ) 403 + .bind(&old_jti) 404 + .bind(&new_jti) 405 + .fetch_one(&db) 406 + .await 407 + .unwrap(); 408 + assert_eq!(next_jti_matches, 1, "old token's next_jti must point to the new jti"); 409 + 410 + // New token must exist in the DB. 411 + let new_exists: Option<String> = 412 + sqlx::query_scalar("SELECT jti FROM refresh_tokens WHERE jti = ?") 413 + .bind(&new_jti) 414 + .fetch_optional(&db) 415 + .await 416 + .unwrap(); 417 + assert!(new_exists.is_some(), "new refresh token must be persisted in DB"); 418 + } 419 + 420 + // ── Replay detection (AC2 + AC3) ────────────────────────────────────────── 421 + 422 + #[tokio::test] 423 + async fn old_refresh_token_rejected_after_rotation() { 424 + let state = test_state().await; 425 + insert_account_with_password( 426 + &state.db, 427 + "did:plc:replay", 428 + "replay.test.example.com", 429 + "replay@example.com", 430 + "hunter2", 431 + ) 432 + .await; 433 + 434 + let tokens = create_session_tokens(&state, "did:plc:replay", "hunter2").await; 435 + let refresh_jwt = tokens["refreshJwt"].as_str().unwrap().to_string(); 436 + 437 + // First use succeeds — token is rotated. 438 + let first = app(state.clone()) 439 + .oneshot(post_refresh_session(&refresh_jwt)) 440 + .await 441 + .unwrap(); 442 + assert_eq!(first.status(), StatusCode::OK); 443 + 444 + // Replaying the same (now rotated) token must be rejected. 445 + let replay = app(state) 446 + .oneshot(post_refresh_session(&refresh_jwt)) 447 + .await 448 + .unwrap(); 449 + assert_ne!(replay.status(), StatusCode::OK, "replay must be rejected"); 450 + } 451 + 452 + #[tokio::test] 453 + async fn replay_of_used_refresh_token_revokes_session() { 454 + let state = test_state().await; 455 + insert_account_with_password( 456 + &state.db, 457 + "did:plc:revoke", 458 + "revoke.test.example.com", 459 + "revoke@example.com", 460 + "hunter2", 461 + ) 462 + .await; 463 + 464 + let db = state.db.clone(); 465 + let tokens = create_session_tokens(&state, "did:plc:revoke", "hunter2").await; 466 + let refresh_jwt = tokens["refreshJwt"].as_str().unwrap().to_string(); 467 + 468 + // Capture the session ID before rotation. 469 + let session_id: String = 470 + sqlx::query_scalar("SELECT id FROM sessions WHERE did = 'did:plc:revoke'") 471 + .fetch_one(&db) 472 + .await 473 + .unwrap(); 474 + 475 + // First use rotates the token. 476 + app(state.clone()) 477 + .oneshot(post_refresh_session(&refresh_jwt)) 478 + .await 479 + .unwrap(); 480 + 481 + // Replay triggers full session revocation. 482 + app(state) 483 + .oneshot(post_refresh_session(&refresh_jwt)) 484 + .await 485 + .unwrap(); 486 + 487 + let session_count: i64 = 488 + sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 489 + .bind(&session_id) 490 + .fetch_one(&db) 491 + .await 492 + .unwrap(); 493 + assert_eq!(session_count, 0, "session must be deleted on replay"); 494 + 495 + let token_count: i64 = 496 + sqlx::query_scalar("SELECT COUNT(*) FROM refresh_tokens WHERE session_id = ?") 497 + .bind(&session_id) 498 + .fetch_one(&db) 499 + .await 500 + .unwrap(); 501 + assert_eq!(token_count, 0, "all refresh tokens for the session must be deleted on replay"); 502 + } 503 + 504 + // ── Error cases ─────────────────────────────────────────────────────────── 505 + 506 + #[tokio::test] 507 + async fn expired_refresh_token_returns_error() { 508 + let state = test_state().await; 509 + insert_account_with_password( 510 + &state.db, 511 + "did:plc:expired", 512 + "expired.test.example.com", 513 + "expired@example.com", 514 + "hunter2", 515 + ) 516 + .await; 517 + 518 + let expired_jwt = expired_refresh_jwt(&state.jwt_secret, "did:plc:expired"); 519 + let response = app(state) 520 + .oneshot(post_refresh_session(&expired_jwt)) 521 + .await 522 + .unwrap(); 523 + 524 + assert_ne!( 525 + response.status(), 526 + StatusCode::OK, 527 + "expired token must be rejected" 528 + ); 529 + } 530 + 531 + #[tokio::test] 532 + async fn invalid_token_signature_returns_error() { 533 + let response = app(test_state().await) 534 + .oneshot(post_refresh_session("not.a.valid.jwt")) 535 + .await 536 + .unwrap(); 537 + 538 + assert_ne!(response.status(), StatusCode::OK); 539 + } 540 + 541 + #[tokio::test] 542 + async fn access_token_rejected_as_refresh_token() { 543 + let state = test_state().await; 544 + insert_account_with_password( 545 + &state.db, 546 + "did:plc:wrongscope", 547 + "wrongscope.test.example.com", 548 + "wrongscope@example.com", 549 + "hunter2", 550 + ) 551 + .await; 552 + 553 + let tokens = create_session_tokens(&state, "did:plc:wrongscope", "hunter2").await; 554 + let access_jwt = tokens["accessJwt"].as_str().unwrap(); 555 + 556 + let response = app(state) 557 + .oneshot(post_refresh_session(access_jwt)) 558 + .await 559 + .unwrap(); 560 + 561 + assert_ne!( 562 + response.status(), 563 + StatusCode::OK, 564 + "access token must be rejected at the refresh endpoint" 565 + ); 566 + } 567 + 568 + #[tokio::test] 569 + async fn missing_authorization_header_returns_error() { 570 + let request = Request::builder() 571 + .method("POST") 572 + .uri("/xrpc/com.atproto.server.refreshSession") 573 + .body(Body::empty()) 574 + .unwrap(); 575 + 576 + let response = app(test_state().await).oneshot(request).await.unwrap(); 577 + 578 + assert_ne!(response.status(), StatusCode::OK); 579 + } 580 + }