Our Personal Data Server from scratch!
0

Configure Feed

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

fix(oauth): gc tokens in pg in the right order, more exposure of dpop err

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
committer
Tangled
date (May 14, 2026, 4:51 PM +0300) commit a13343e1 parent 60e10af4 change-id mllnkvkw
+214 -205
-15
.sqlx/query-56cd24903171eddc2ededd9079ffe10937c34e99b0305f25c980ca754da44625.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Int8" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "56cd24903171eddc2ededd9079ffe10937c34e99b0305f25c980ca754da44625" 15 - }
-158
.sqlx/query-8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "user_id", 14 - "type_info": "Uuid" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "channel: CommsChannel", 19 - "type_info": { 20 - "Custom": { 21 - "name": "comms_channel", 22 - "kind": { 23 - "Enum": [ 24 - "email", 25 - "discord", 26 - "telegram", 27 - "signal" 28 - ] 29 - } 30 - } 31 - } 32 - }, 33 - { 34 - "ordinal": 3, 35 - "name": "comms_type: CommsType", 36 - "type_info": { 37 - "Custom": { 38 - "name": "comms_type", 39 - "kind": { 40 - "Enum": [ 41 - "welcome", 42 - "email_verification", 43 - "password_reset", 44 - "email_update", 45 - "account_deletion", 46 - "admin_email", 47 - "plc_operation", 48 - "two_factor_code", 49 - "channel_verification", 50 - "passkey_recovery", 51 - "legacy_login_alert", 52 - "migration_verification", 53 - "channel_verified" 54 - ] 55 - } 56 - } 57 - } 58 - }, 59 - { 60 - "ordinal": 4, 61 - "name": "status: CommsStatus", 62 - "type_info": { 63 - "Custom": { 64 - "name": "comms_status", 65 - "kind": { 66 - "Enum": [ 67 - "pending", 68 - "processing", 69 - "sent", 70 - "failed" 71 - ] 72 - } 73 - } 74 - } 75 - }, 76 - { 77 - "ordinal": 5, 78 - "name": "recipient", 79 - "type_info": "Text" 80 - }, 81 - { 82 - "ordinal": 6, 83 - "name": "subject", 84 - "type_info": "Text" 85 - }, 86 - { 87 - "ordinal": 7, 88 - "name": "body", 89 - "type_info": "Text" 90 - }, 91 - { 92 - "ordinal": 8, 93 - "name": "metadata", 94 - "type_info": "Jsonb" 95 - }, 96 - { 97 - "ordinal": 9, 98 - "name": "attempts", 99 - "type_info": "Int4" 100 - }, 101 - { 102 - "ordinal": 10, 103 - "name": "max_attempts", 104 - "type_info": "Int4" 105 - }, 106 - { 107 - "ordinal": 11, 108 - "name": "last_error", 109 - "type_info": "Text" 110 - }, 111 - { 112 - "ordinal": 12, 113 - "name": "created_at", 114 - "type_info": "Timestamptz" 115 - }, 116 - { 117 - "ordinal": 13, 118 - "name": "updated_at", 119 - "type_info": "Timestamptz" 120 - }, 121 - { 122 - "ordinal": 14, 123 - "name": "scheduled_for", 124 - "type_info": "Timestamptz" 125 - }, 126 - { 127 - "ordinal": 15, 128 - "name": "processed_at", 129 - "type_info": "Timestamptz" 130 - } 131 - ], 132 - "parameters": { 133 - "Left": [ 134 - "Timestamptz", 135 - "Int8" 136 - ] 137 - }, 138 - "nullable": [ 139 - false, 140 - false, 141 - false, 142 - false, 143 - false, 144 - false, 145 - true, 146 - false, 147 - true, 148 - false, 149 - false, 150 - true, 151 - false, 152 - false, 153 - false, 154 - true 155 - ] 156 - }, 157 - "hash": "8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a" 158 - }
+15
.sqlx/query-8f4357f7a18ddcf6b686a4555f244d37c35917364b8f917ca6ee2d4030ace742.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY created_at DESC\n OFFSET $2\n )\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Int8" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "8f4357f7a18ddcf6b686a4555f244d37c35917364b8f917ca6ee2d4030ace742" 15 + }
+1 -1
crates/tranquil-db/src/postgres/oauth.rs
··· 374 374 WHERE id IN ( 375 375 SELECT id FROM oauth_token 376 376 WHERE did = $1 377 - ORDER BY updated_at ASC 377 + ORDER BY created_at DESC 378 378 OFFSET $2 379 379 ) 380 380 "#,
+43 -3
crates/tranquil-pds/src/api/error.rs
··· 21 21 InvalidToken(Option<String>), 22 22 ExpiredToken(Option<String>), 23 23 OAuthExpiredToken(Option<String>), 24 + UseDpopNonce(String), 25 + InvalidDpopProof(String), 24 26 TokenRequired, 25 27 AccountDeactivated, 26 28 AccountTakedown, ··· 137 139 | Self::InvalidToken(_) 138 140 | Self::PasskeyCounterAnomaly 139 141 | Self::OAuthExpiredToken(_) 142 + | Self::UseDpopNonce(_) 143 + | Self::InvalidDpopProof(_) 140 144 | Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED, 141 145 Self::InvalidCode(_) => StatusCode::BAD_REQUEST, 142 146 Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, ··· 236 240 Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"), 237 241 Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"), 238 242 Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"), 243 + Self::UseDpopNonce(_) => Cow::Borrowed("use_dpop_nonce"), 244 + Self::InvalidDpopProof(_) => Cow::Borrowed("invalid_dpop_proof"), 239 245 Self::TokenRequired => Cow::Borrowed("TokenRequired"), 240 246 Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 241 247 Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), ··· 335 341 Self::ExpiredToken(msg) | Self::OAuthExpiredToken(msg) => { 336 342 msg.clone().unwrap_or_else(|| "Token has expired".into()) 337 343 } 344 + Self::UseDpopNonce(_) => "DPoP nonce required".into(), 345 + Self::InvalidDpopProof(msg) => msg.clone(), 338 346 Self::RepoNotFound(msg) => msg 339 347 .clone() 340 348 .unwrap_or_else(|| "Repository not found".into()), ··· 560 568 ), 561 569 ); 562 570 } 571 + Self::UseDpopNonce(nonce) => { 572 + match HeaderValue::from_str(nonce) { 573 + Ok(val) => { 574 + response 575 + .headers_mut() 576 + .insert(crate::util::HEADER_DPOP_NONCE, val); 577 + } 578 + Err(err) => { 579 + tracing::error!( 580 + ?err, 581 + nonce_len = nonce.len(), 582 + "generated DPoP nonce is not a valid header value" 583 + ); 584 + } 585 + } 586 + response.headers_mut().insert( 587 + http::header::WWW_AUTHENTICATE, 588 + HeaderValue::from_static( 589 + "DPoP error=\"use_dpop_nonce\", error_description=\"Resource server requires nonce in DPoP proof\"", 590 + ), 591 + ); 592 + } 593 + Self::InvalidDpopProof(_) => { 594 + response.headers_mut().insert( 595 + http::header::WWW_AUTHENTICATE, 596 + HeaderValue::from_static( 597 + "DPoP error=\"invalid_dpop_proof\", error_description=\"Invalid DPoP proof\"", 598 + ), 599 + ); 600 + } 563 601 _ => {} 564 602 } 565 603 response ··· 596 634 crate::auth::TokenValidationError::InvalidToken => { 597 635 Self::AuthenticationFailed(Some("Invalid token format".to_string())) 598 636 } 637 + crate::auth::TokenValidationError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce), 638 + crate::auth::TokenValidationError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg), 599 639 } 600 640 } 601 641 } ··· 625 665 crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => { 626 666 Self::OAuthExpiredToken(Some(msg)) 627 667 } 628 - crate::auth::extractor::AuthError::UseDpopNonce(_) 629 - | crate::auth::extractor::AuthError::InvalidDpopProof(_) => { 630 - Self::AuthenticationFailed(None) 668 + crate::auth::extractor::AuthError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce), 669 + crate::auth::extractor::AuthError::InvalidDpopProof(msg) => { 670 + Self::InvalidDpopProof(msg) 631 671 } 632 672 } 633 673 }
+2 -27
crates/tranquil-pds/src/auth/extractor.rs
··· 2 2 3 3 use axum::{ 4 4 extract::{FromRequestParts, OptionalFromRequestParts, OriginalUri}, 5 - http::{StatusCode, header::AUTHORIZATION, request::Parts}, 5 + http::{header::AUTHORIZATION, request::Parts}, 6 6 response::{IntoResponse, Response}, 7 7 }; 8 8 use tracing::{debug, error, info}; ··· 35 35 36 36 impl IntoResponse for AuthError { 37 37 fn into_response(self) -> Response { 38 - match self { 39 - Self::UseDpopNonce(nonce) => ( 40 - StatusCode::UNAUTHORIZED, 41 - [ 42 - ("DPoP-Nonce", nonce.as_str()), 43 - ("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""), 44 - ], 45 - axum::Json(serde_json::json!({ 46 - "error": "use_dpop_nonce", 47 - "message": "DPoP nonce required" 48 - })), 49 - ) 50 - .into_response(), 51 - Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(), 52 - Self::InvalidDpopProof(msg) => ( 53 - StatusCode::UNAUTHORIZED, 54 - [("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")], 55 - axum::Json(serde_json::json!({ 56 - "error": "invalid_dpop_proof", 57 - "message": msg 58 - })), 59 - ) 60 - .into_response(), 61 - Self::InsufficientScope(msg) => ApiError::InsufficientScope(Some(msg)).into_response(), 62 - other => ApiError::from(other).into_response(), 63 - } 38 + ApiError::from(self).into_response() 64 39 } 65 40 } 66 41
+11 -1
crates/tranquil-pds/src/auth/mod.rs
··· 106 106 is_admin: bool, 107 107 } 108 108 109 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 109 + #[derive(Debug, Clone, PartialEq, Eq)] 110 110 pub enum TokenValidationError { 111 111 AccountDeactivated, 112 112 AccountTakedown, ··· 115 115 TokenExpired, 116 116 OAuthTokenExpired, 117 117 InvalidToken, 118 + UseDpopNonce(String), 119 + InvalidDpopProof(String), 118 120 } 119 121 120 122 impl fmt::Display for TokenValidationError { ··· 126 128 Self::AuthenticationFailed => write!(f, "AuthenticationFailed"), 127 129 Self::TokenExpired | Self::OAuthTokenExpired => write!(f, "ExpiredToken"), 128 130 Self::InvalidToken => write!(f, "InvalidToken"), 131 + Self::UseDpopNonce(_) => write!(f, "use_dpop_nonce"), 132 + Self::InvalidDpopProof(_) => write!(f, "invalid_dpop_proof"), 129 133 } 130 134 } 131 135 } ··· 612 616 } 613 617 Err(crate::oauth::OAuthError::ExpiredToken(_)) => { 614 618 Err(TokenValidationError::OAuthTokenExpired) 619 + } 620 + Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => { 621 + Err(TokenValidationError::UseDpopNonce(nonce)) 622 + } 623 + Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => { 624 + Err(TokenValidationError::InvalidDpopProof(msg)) 615 625 } 616 626 Err(_) => Err(TokenValidationError::AuthenticationFailed), 617 627 }
+142
crates/tranquil-pds/tests/oauth_token_eviction.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use chrono::{DateTime, Duration, Utc}; 5 + use common::{base_url, client, get_test_db_pool, get_test_repos}; 6 + use helpers::verify_new_account; 7 + use reqwest::StatusCode; 8 + use serde_json::{Value, json}; 9 + use tranquil_types::Did; 10 + 11 + async fn create_account_and_get_did(handle: &str, email: &str, password: &str) -> Did { 12 + let client = client(); 13 + let res = client 14 + .post(format!( 15 + "{}/xrpc/com.atproto.server.createAccount", 16 + base_url().await 17 + )) 18 + .json(&json!({ 19 + "handle": handle, 20 + "email": email, 21 + "password": password, 22 + })) 23 + .send() 24 + .await 25 + .expect("createAccount request failed"); 26 + assert_eq!(res.status(), StatusCode::OK, "createAccount failed"); 27 + let body: Value = res.json().await.expect("invalid createAccount JSON"); 28 + let did_str = body["did"].as_str().expect("no did in response").to_string(); 29 + let _ = verify_new_account(&client, &did_str).await; 30 + Did::new(did_str).expect("invalid DID format") 31 + } 32 + 33 + async fn insert_token_with_created_at( 34 + pool: &sqlx::PgPool, 35 + did: &Did, 36 + token_id: &str, 37 + created_at: DateTime<Utc>, 38 + ) { 39 + sqlx::query( 40 + r#" 41 + INSERT INTO oauth_token ( 42 + did, token_id, created_at, updated_at, expires_at, 43 + client_id, client_auth, parameters 44 + ) VALUES ($1, $2, $3, $3, $4, $5, $6::jsonb, $7::jsonb) 45 + "#, 46 + ) 47 + .bind(did.as_str()) 48 + .bind(token_id) 49 + .bind(created_at) 50 + .bind(created_at + Duration::hours(1)) 51 + .bind("https://test.example/client") 52 + .bind(r#"{"method":"none"}"#) 53 + .bind( 54 + r#"{"response_type":"code","client_id":"https://test.example/client","redirect_uri":"https://test.example/cb","code_challenge":"x","code_challenge_method":"S256"}"#, 55 + ) 56 + .execute(pool) 57 + .await 58 + .expect("token insert failed"); 59 + } 60 + 61 + #[tokio::test] 62 + async fn delete_oldest_tokens_evicts_lowest_created_at() { 63 + let ts = Utc::now().timestamp_millis(); 64 + let handle = format!("tok-evict-{}.test", ts); 65 + let email = format!("tok-evict-{}@test.com", ts); 66 + let did = create_account_and_get_did(&handle, &email, "EvictTest123!").await; 67 + 68 + let pool = get_test_db_pool().await; 69 + let repos = get_test_repos().await; 70 + 71 + let base = Utc::now(); 72 + let token_ids: Vec<String> = (0..5) 73 + .map(|i| format!("tok-{}-{}", ts, i)) 74 + .collect(); 75 + 76 + for (i, tid) in token_ids.iter().enumerate() { 77 + let created = base + Duration::seconds(i as i64); 78 + insert_token_with_created_at(pool, &did, tid, created).await; 79 + } 80 + 81 + let count_before = repos 82 + .oauth 83 + .count_tokens_for_user(&did) 84 + .await 85 + .expect("count failed"); 86 + assert_eq!(count_before, 5, "all 5 tokens should be present"); 87 + 88 + let deleted = repos 89 + .oauth 90 + .delete_oldest_tokens_for_user(&did, 3) 91 + .await 92 + .expect("delete failed"); 93 + assert_eq!(deleted, 2, "two oldest tokens should be deleted"); 94 + 95 + let remaining = repos 96 + .oauth 97 + .list_tokens_for_user(&did) 98 + .await 99 + .expect("list failed"); 100 + assert_eq!(remaining.len(), 3, "three newest tokens should remain"); 101 + 102 + let remaining_ids: std::collections::HashSet<String> = 103 + remaining.iter().map(|t| t.token_id.0.clone()).collect(); 104 + let expected_ids: std::collections::HashSet<String> = 105 + token_ids[2..].iter().cloned().collect(); 106 + assert_eq!( 107 + remaining_ids, expected_ids, 108 + "surviving tokens must be the three newest by created_at" 109 + ); 110 + } 111 + 112 + #[tokio::test] 113 + async fn delete_oldest_tokens_no_op_when_under_keep_count() { 114 + let ts = Utc::now().timestamp_millis(); 115 + let handle = format!("tok-evict-noop-{}.test", ts); 116 + let email = format!("tok-evict-noop-{}@test.com", ts); 117 + let did = create_account_and_get_did(&handle, &email, "EvictTest123!").await; 118 + 119 + let pool = get_test_db_pool().await; 120 + let repos = get_test_repos().await; 121 + 122 + let base = Utc::now(); 123 + for i in 0..2 { 124 + let tid = format!("noop-tok-{}-{}", ts, i); 125 + let created = base + Duration::seconds(i); 126 + insert_token_with_created_at(pool, &did, &tid, created).await; 127 + } 128 + 129 + let deleted = repos 130 + .oauth 131 + .delete_oldest_tokens_for_user(&did, 5) 132 + .await 133 + .expect("delete failed"); 134 + assert_eq!(deleted, 0, "nothing to delete when count <= keep"); 135 + 136 + let remaining = repos 137 + .oauth 138 + .list_tokens_for_user(&did) 139 + .await 140 + .expect("list failed"); 141 + assert_eq!(remaining.len(), 2); 142 + }