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.deleteSession

Revokes a session by deleting all refresh tokens and the session row
atomically. Accepts expired refresh tokens (allowExpired: true) so users
can always log out. Idempotent: already-revoked tokens return 200 OK.

authored by

Malpercio and committed by
Tangled
fb1eb44e 8a7ff9cf

+523
+19
bruno/delete_session.bru
··· 1 + meta { 2 + name: Delete Session (deleteSession) 3 + type: http 4 + seq: 22 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/xrpc/com.atproto.server.deleteSession 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 + }
+5
crates/relay/src/app.rs
··· 22 22 use crate::routes::create_handle::create_handle_handler; 23 23 use crate::routes::create_mobile_account::create_mobile_account; 24 24 use crate::routes::create_session::create_session; 25 + use crate::routes::delete_session::delete_session; 25 26 use crate::routes::create_signing_key::create_signing_key; 26 27 use crate::routes::describe_server::describe_server; 27 28 use crate::routes::get_relay_signing_key::get_relay_signing_key; ··· 162 163 .route( 163 164 "/xrpc/com.atproto.server.refreshSession", 164 165 post(refresh_session), 166 + ) 167 + .route( 168 + "/xrpc/com.atproto.server.deleteSession", 169 + post(delete_session), 165 170 ) 166 171 .route( 167 172 "/xrpc/com.atproto.identity.resolveHandle",
+39
crates/relay/src/auth/jwt.rs
··· 206 206 }) 207 207 } 208 208 209 + /// Verify an HS256 refresh JWT issued by this server, accepting expired tokens. 210 + /// 211 + /// Validates HS256 signature and audience (when `server_did` is configured), but 212 + /// intentionally skips the expiry check. Used by `deleteSession` so that users 213 + /// can always revoke their session even after the refresh token has expired — 214 + /// matching the ATProto spec's `allowExpired: true` behavior. 215 + /// 216 + /// Security: HS256 signature is still fully verified. An expired-but-forged 217 + /// token is rejected. Only tokens we signed (but whose exp has passed) are accepted. 218 + /// 219 + /// Does NOT check `scope` — callers must verify `scope == "com.atproto.refresh"`. 220 + pub fn verify_refresh_token_allow_expired( 221 + token: &str, 222 + state: &AppState, 223 + ) -> Result<RefreshTokenClaims, ApiError> { 224 + let decoding_key = DecodingKey::from_secret(&state.jwt_secret); 225 + let mut validation = Validation::new(Algorithm::HS256); 226 + match state.config.server_did.as_deref() { 227 + Some(did) => validation.set_audience(&[did]), 228 + None => { 229 + validation.validate_aud = false; 230 + tracing::warn!( 231 + "server_did not configured; JWT audience validation is disabled — \ 232 + set server_did in config for production deployments" 233 + ); 234 + } 235 + } 236 + validation.validate_exp = false; 237 + validation.set_required_spec_claims(&["sub"]); 238 + validation.leeway = 0; 239 + 240 + decode::<RefreshTokenClaims>(token, &decoding_key, &validation) 241 + .map(|data| data.claims) 242 + .map_err(|e| { 243 + tracing::warn!(error = %e, error_kind = ?e.kind(), "refresh token verification failed"); 244 + ApiError::new(ErrorCode::InvalidToken, "invalid token") 245 + }) 246 + } 247 + 209 248 // ── Legacy HS256 token issuance ─────────────────────────────────────────────── 210 249 211 250 const ACCESS_TOKEN_TTL_SECS: u64 = 2 * 60 * 60; // 2 hours
+459
crates/relay/src/routes/delete_session.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: Authorization header (refresh JWT Bearer), DB pool 4 + // Processes: JWT verification (exp allowed) → scope check → DB lookup → 5 + // atomic session revocation (refresh_tokens + sessions) 6 + // Returns: 200 OK (empty) on success or idempotent already-revoked; ApiError on failure 7 + // 8 + // Implements: POST /xrpc/com.atproto.server.deleteSession 9 + 10 + use axum::{extract::State, http::HeaderMap, http::StatusCode}; 11 + 12 + use common::{ApiError, ErrorCode}; 13 + 14 + use crate::app::AppState; 15 + use crate::auth::extract_bearer_token; 16 + use crate::auth::jwt::{parse_scope, verify_refresh_token_allow_expired, AuthScope}; 17 + 18 + // ── Handler ────────────────────────────────────────────────────────────────── 19 + 20 + /// POST /xrpc/com.atproto.server.deleteSession 21 + /// 22 + /// Revokes the session identified by the refresh JWT's `jti` claim, deleting 23 + /// all associated refresh tokens and the session row atomically. 24 + /// 25 + /// Accepts expired refresh tokens (`allowExpired: true`) so users can always 26 + /// log out regardless of token age. Idempotent: if the token was already 27 + /// revoked (row not found), returns 200 OK — logout already succeeded. 28 + pub async fn delete_session( 29 + State(state): State<AppState>, 30 + headers: HeaderMap, 31 + ) -> Result<StatusCode, ApiError> { 32 + // --- Extract and verify the refresh JWT (expiry allowed) --- 33 + let token = extract_bearer_token(&headers)?; 34 + let claims = verify_refresh_token_allow_expired(token, &state)?; 35 + 36 + if parse_scope(&claims.scope)? != AuthScope::Refresh { 37 + return Err(ApiError::new( 38 + ErrorCode::InvalidToken, 39 + "refresh token required", 40 + )); 41 + } 42 + 43 + let jti = claims.jti.ok_or_else(|| { 44 + ApiError::new(ErrorCode::InvalidToken, "invalid refresh token") 45 + })?; 46 + 47 + // --- Look up the token — no expiry filter, revocation must always work --- 48 + let session_id: Option<String> = 49 + sqlx::query_scalar("SELECT session_id FROM refresh_tokens WHERE jti = ?") 50 + .bind(&jti) 51 + .fetch_optional(&state.db) 52 + .await 53 + .map_err(|e| { 54 + tracing::error!(error = %e, "DB error looking up refresh token for deleteSession"); 55 + ApiError::new(ErrorCode::InternalError, "internal error") 56 + })?; 57 + 58 + // Idempotent: token not found means already revoked — logout already done. 59 + let Some(session_id) = session_id else { 60 + return Ok(StatusCode::OK); 61 + }; 62 + 63 + // --- Atomically revoke: delete all refresh tokens + the session row --- 64 + let mut tx = state.db.begin().await.map_err(|e| { 65 + tracing::error!(error = %e, session_id = %session_id, "failed to begin revocation transaction"); 66 + ApiError::new(ErrorCode::InternalError, "internal error") 67 + })?; 68 + 69 + sqlx::query("DELETE FROM refresh_tokens WHERE session_id = ?") 70 + .bind(&session_id) 71 + .execute(&mut *tx) 72 + .await 73 + .map_err(|e| { 74 + tracing::error!(error = %e, session_id = %session_id, "failed to delete refresh tokens"); 75 + ApiError::new(ErrorCode::InternalError, "internal error") 76 + })?; 77 + 78 + sqlx::query("DELETE FROM sessions WHERE id = ?") 79 + .bind(&session_id) 80 + .execute(&mut *tx) 81 + .await 82 + .map_err(|e| { 83 + tracing::error!(error = %e, session_id = %session_id, "failed to delete session"); 84 + ApiError::new(ErrorCode::InternalError, "internal error") 85 + })?; 86 + 87 + tx.commit().await.map_err(|e| { 88 + tracing::error!(error = %e, session_id = %session_id, "failed to commit revocation transaction"); 89 + ApiError::new(ErrorCode::InternalError, "internal error") 90 + })?; 91 + 92 + tracing::info!(session_id = %session_id, jti = %jti, "session revoked via deleteSession"); 93 + 94 + Ok(StatusCode::OK) 95 + } 96 + 97 + // ── Tests ──────────────────────────────────────────────────────────────────── 98 + 99 + #[cfg(test)] 100 + mod tests { 101 + use axum::{ 102 + body::Body, 103 + http::{Request, StatusCode}, 104 + }; 105 + use tower::ServiceExt; 106 + 107 + use crate::app::{app, test_state}; 108 + use crate::routes::test_utils::{body_json, insert_account_with_password}; 109 + 110 + // ── Helpers ─────────────────────────────────────────────────────────────── 111 + 112 + fn post_delete_session(refresh_jwt: &str) -> Request<Body> { 113 + Request::builder() 114 + .method("POST") 115 + .uri("/xrpc/com.atproto.server.deleteSession") 116 + .header("Authorization", format!("Bearer {refresh_jwt}")) 117 + .body(Body::empty()) 118 + .unwrap() 119 + } 120 + 121 + async fn create_session_tokens( 122 + state: &crate::app::AppState, 123 + did: &str, 124 + password: &str, 125 + ) -> serde_json::Value { 126 + let request = Request::builder() 127 + .method("POST") 128 + .uri("/xrpc/com.atproto.server.createSession") 129 + .header("Content-Type", "application/json") 130 + .body(Body::from(format!( 131 + r#"{{"identifier":"{did}","password":"{password}"}}"# 132 + ))) 133 + .unwrap(); 134 + let response = app(state.clone()).oneshot(request).await.unwrap(); 135 + body_json(response).await 136 + } 137 + 138 + /// Build a syntactically valid HS256 refresh JWT whose `exp` is in the past. 139 + fn expired_refresh_jwt(secret: &[u8; 32], did: &str) -> String { 140 + let past = 1_000_000_000u64; 141 + let claims = serde_json::json!({ 142 + "scope": "com.atproto.refresh", 143 + "sub": did, 144 + "jti": uuid::Uuid::new_v4().to_string(), 145 + "iat": past, 146 + "exp": past + 1, 147 + }); 148 + jsonwebtoken::encode( 149 + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), 150 + &claims, 151 + &jsonwebtoken::EncodingKey::from_secret(secret), 152 + ) 153 + .unwrap() 154 + } 155 + 156 + // ── Happy path ──────────────────────────────────────────────────────────── 157 + 158 + #[tokio::test] 159 + async fn valid_refresh_token_returns_200() { 160 + let state = test_state().await; 161 + insert_account_with_password( 162 + &state.db, 163 + "did:plc:del1", 164 + "del1.test.example.com", 165 + "del1@example.com", 166 + "hunter2", 167 + ) 168 + .await; 169 + 170 + let tokens = create_session_tokens(&state, "did:plc:del1", "hunter2").await; 171 + let refresh_jwt = tokens["refreshJwt"].as_str().unwrap(); 172 + 173 + let response = app(state) 174 + .oneshot(post_delete_session(refresh_jwt)) 175 + .await 176 + .unwrap(); 177 + 178 + assert_eq!(response.status(), StatusCode::OK); 179 + } 180 + 181 + #[tokio::test] 182 + async fn revocation_deletes_session_and_refresh_tokens() { 183 + let state = test_state().await; 184 + insert_account_with_password( 185 + &state.db, 186 + "did:plc:del2", 187 + "del2.test.example.com", 188 + "del2@example.com", 189 + "hunter2", 190 + ) 191 + .await; 192 + 193 + let db = state.db.clone(); 194 + let tokens = create_session_tokens(&state, "did:plc:del2", "hunter2").await; 195 + let refresh_jwt = tokens["refreshJwt"].as_str().unwrap(); 196 + 197 + let session_id: String = 198 + sqlx::query_scalar("SELECT id FROM sessions WHERE did = 'did:plc:del2'") 199 + .fetch_one(&db) 200 + .await 201 + .unwrap(); 202 + 203 + let response = app(state) 204 + .oneshot(post_delete_session(refresh_jwt)) 205 + .await 206 + .unwrap(); 207 + assert_eq!(response.status(), StatusCode::OK); 208 + 209 + let session_count: i64 = 210 + sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 211 + .bind(&session_id) 212 + .fetch_one(&db) 213 + .await 214 + .unwrap(); 215 + assert_eq!(session_count, 0, "session must be deleted"); 216 + 217 + let token_count: i64 = 218 + sqlx::query_scalar("SELECT COUNT(*) FROM refresh_tokens WHERE session_id = ?") 219 + .bind(&session_id) 220 + .fetch_one(&db) 221 + .await 222 + .unwrap(); 223 + assert_eq!(token_count, 0, "all refresh tokens for the session must be deleted"); 224 + } 225 + 226 + #[tokio::test] 227 + async fn revoked_refresh_token_cannot_be_used_for_refresh() { 228 + let state = test_state().await; 229 + insert_account_with_password( 230 + &state.db, 231 + "did:plc:del3", 232 + "del3.test.example.com", 233 + "del3@example.com", 234 + "hunter2", 235 + ) 236 + .await; 237 + 238 + let tokens = create_session_tokens(&state, "did:plc:del3", "hunter2").await; 239 + let refresh_jwt = tokens["refreshJwt"].as_str().unwrap().to_string(); 240 + 241 + // Delete the session. 242 + let del = app(state.clone()) 243 + .oneshot(post_delete_session(&refresh_jwt)) 244 + .await 245 + .unwrap(); 246 + assert_eq!(del.status(), StatusCode::OK); 247 + 248 + // The revoked refresh token must no longer work for rotation. 249 + let refresh = app(state) 250 + .oneshot( 251 + Request::builder() 252 + .method("POST") 253 + .uri("/xrpc/com.atproto.server.refreshSession") 254 + .header("Authorization", format!("Bearer {refresh_jwt}")) 255 + .body(Body::empty()) 256 + .unwrap(), 257 + ) 258 + .await 259 + .unwrap(); 260 + assert_eq!( 261 + refresh.status(), 262 + StatusCode::UNAUTHORIZED, 263 + "revoked token must not be usable for refreshSession" 264 + ); 265 + } 266 + 267 + // ── Expired token revocation ────────────────────────────────────────────── 268 + 269 + #[tokio::test] 270 + async fn expired_token_with_valid_db_row_is_revoked() { 271 + let state = test_state().await; 272 + insert_account_with_password( 273 + &state.db, 274 + "did:plc:del4", 275 + "del4.test.example.com", 276 + "del4@example.com", 277 + "hunter2", 278 + ) 279 + .await; 280 + 281 + let db = state.db.clone(); 282 + 283 + // Create a real session so the session_id and refresh_tokens row exist. 284 + let tokens = create_session_tokens(&state, "did:plc:del4", "hunter2").await; 285 + let real_refresh_jwt = tokens["refreshJwt"].as_str().unwrap(); 286 + 287 + // Decode the JTI from the real token so we can build a matching expired JWT. 288 + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); 289 + validation.validate_exp = false; 290 + validation.validate_aud = false; 291 + validation.set_required_spec_claims(&["sub"]); 292 + let real_claims: serde_json::Value = jsonwebtoken::decode( 293 + real_refresh_jwt, 294 + &jsonwebtoken::DecodingKey::from_secret(&state.jwt_secret), 295 + &validation, 296 + ) 297 + .unwrap() 298 + .claims; 299 + let real_jti = real_claims["jti"].as_str().unwrap(); 300 + 301 + // Construct an expired JWT with the same JTI and valid signature. 302 + let past = 1_000_000_000u64; 303 + let expired_jwt_with_real_jti = jsonwebtoken::encode( 304 + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), 305 + &serde_json::json!({ 306 + "scope": "com.atproto.refresh", 307 + "sub": "did:plc:del4", 308 + "jti": real_jti, 309 + "iat": past, 310 + "exp": past + 1, 311 + }), 312 + &jsonwebtoken::EncodingKey::from_secret(&state.jwt_secret), 313 + ) 314 + .unwrap(); 315 + 316 + let session_id: String = 317 + sqlx::query_scalar("SELECT id FROM sessions WHERE did = 'did:plc:del4'") 318 + .fetch_one(&db) 319 + .await 320 + .unwrap(); 321 + 322 + // deleteSession with the expired JWT must succeed. 323 + let response = app(state) 324 + .oneshot(post_delete_session(&expired_jwt_with_real_jti)) 325 + .await 326 + .unwrap(); 327 + assert_eq!( 328 + response.status(), 329 + StatusCode::OK, 330 + "expired refresh token must still revoke the session" 331 + ); 332 + 333 + let session_count: i64 = 334 + sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 335 + .bind(&session_id) 336 + .fetch_one(&db) 337 + .await 338 + .unwrap(); 339 + assert_eq!(session_count, 0, "session must be deleted even with expired token"); 340 + } 341 + 342 + // ── Idempotency ─────────────────────────────────────────────────────────── 343 + 344 + #[tokio::test] 345 + async fn already_revoked_token_returns_200() { 346 + let state = test_state().await; 347 + insert_account_with_password( 348 + &state.db, 349 + "did:plc:del5", 350 + "del5.test.example.com", 351 + "del5@example.com", 352 + "hunter2", 353 + ) 354 + .await; 355 + 356 + let tokens = create_session_tokens(&state, "did:plc:del5", "hunter2").await; 357 + let refresh_jwt = tokens["refreshJwt"].as_str().unwrap().to_string(); 358 + 359 + // First deletion. 360 + let first = app(state.clone()) 361 + .oneshot(post_delete_session(&refresh_jwt)) 362 + .await 363 + .unwrap(); 364 + assert_eq!(first.status(), StatusCode::OK); 365 + 366 + // Second call with the same (now-revoked) token — must be idempotent 200. 367 + let second = app(state) 368 + .oneshot(post_delete_session(&refresh_jwt)) 369 + .await 370 + .unwrap(); 371 + assert_eq!( 372 + second.status(), 373 + StatusCode::OK, 374 + "deleteSession on already-revoked token must be idempotent 200" 375 + ); 376 + } 377 + 378 + // ── Error cases ─────────────────────────────────────────────────────────── 379 + 380 + #[tokio::test] 381 + async fn access_token_rejected() { 382 + let state = test_state().await; 383 + insert_account_with_password( 384 + &state.db, 385 + "did:plc:del6", 386 + "del6.test.example.com", 387 + "del6@example.com", 388 + "hunter2", 389 + ) 390 + .await; 391 + 392 + let tokens = create_session_tokens(&state, "did:plc:del6", "hunter2").await; 393 + let access_jwt = tokens["accessJwt"].as_str().unwrap(); 394 + 395 + let response = app(state) 396 + .oneshot(post_delete_session(access_jwt)) 397 + .await 398 + .unwrap(); 399 + 400 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 401 + let json = body_json(response).await; 402 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 403 + } 404 + 405 + #[tokio::test] 406 + async fn invalid_token_signature_returns_401() { 407 + let response = app(test_state().await) 408 + .oneshot(post_delete_session("not.a.valid.jwt")) 409 + .await 410 + .unwrap(); 411 + 412 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 413 + let json = body_json(response).await; 414 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 415 + } 416 + 417 + #[tokio::test] 418 + async fn missing_authorization_header_returns_401() { 419 + let request = Request::builder() 420 + .method("POST") 421 + .uri("/xrpc/com.atproto.server.deleteSession") 422 + .body(Body::empty()) 423 + .unwrap(); 424 + 425 + let response = app(test_state().await).oneshot(request).await.unwrap(); 426 + 427 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 428 + let json = body_json(response).await; 429 + assert_eq!(json["error"]["code"], "AUTHENTICATION_REQUIRED"); 430 + } 431 + 432 + #[tokio::test] 433 + async fn expired_token_not_in_db_returns_200() { 434 + let state = test_state().await; 435 + insert_account_with_password( 436 + &state.db, 437 + "did:plc:del7", 438 + "del7.test.example.com", 439 + "del7@example.com", 440 + "hunter2", 441 + ) 442 + .await; 443 + 444 + // A well-signed expired JWT whose JTI was never inserted into refresh_tokens. 445 + let expired_jwt = expired_refresh_jwt(&state.jwt_secret, "did:plc:del7"); 446 + 447 + let response = app(state) 448 + .oneshot(post_delete_session(&expired_jwt)) 449 + .await 450 + .unwrap(); 451 + 452 + // Token not found in DB → already revoked (or never existed) → idempotent 200. 453 + assert_eq!( 454 + response.status(), 455 + StatusCode::OK, 456 + "expired token not in DB must be idempotent 200" 457 + ); 458 + } 459 + }
+1
crates/relay/src/routes/mod.rs
··· 1 1 pub(crate) mod auth; 2 2 pub mod claim_codes; 3 + pub mod delete_session; 3 4 pub mod create_account; 4 5 pub mod create_did; 5 6 pub mod create_handle;