An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(db): consume_oauth_refresh_token + RefreshTokenRow

+128
+128
crates/relay/src/db/oauth.rs
··· 246 246 Ok(()) 247 247 } 248 248 249 + /// A row read from `oauth_tokens` during refresh token rotation. 250 + pub struct RefreshTokenRow { 251 + pub client_id: String, 252 + #[allow(dead_code)] 253 + pub did: String, 254 + pub scope: String, 255 + /// DPoP key thumbprint bound to this refresh token. `None` for tokens 256 + /// issued before DPoP binding was enforced (not expected after V012). 257 + pub jkt: Option<String>, 258 + } 259 + 260 + /// Atomically consume a refresh token: SELECT + DELETE in one transaction. 261 + /// 262 + /// Returns `None` if the token does not exist or has already expired 263 + /// (`expires_at <= now`). Callers must treat `None` as `invalid_grant`. 264 + /// 265 + /// The `id` column stores the SHA-256 hex hash of the raw token bytes. 266 + /// Callers must hash the presented token before calling this function 267 + /// using the same approach as `store_oauth_refresh_token`. 268 + pub async fn consume_oauth_refresh_token( 269 + pool: &SqlitePool, 270 + token_hash: &str, 271 + ) -> Result<Option<RefreshTokenRow>, sqlx::Error> { 272 + let mut tx = pool.begin().await?; 273 + 274 + let row: Option<(String, String, String, Option<String>)> = sqlx::query_as( 275 + "SELECT client_id, did, scope, jkt FROM oauth_tokens \ 276 + WHERE id = ? AND expires_at > datetime('now')", 277 + ) 278 + .bind(token_hash) 279 + .fetch_optional(&mut *tx) 280 + .await?; 281 + 282 + if row.is_some() { 283 + sqlx::query("DELETE FROM oauth_tokens WHERE id = ?") 284 + .bind(token_hash) 285 + .execute(&mut *tx) 286 + .await?; 287 + } 288 + 289 + tx.commit().await?; 290 + 291 + Ok(row.map(|(client_id, did, scope, jkt)| RefreshTokenRow { 292 + client_id, 293 + did, 294 + scope, 295 + jkt, 296 + })) 297 + } 298 + 249 299 #[cfg(test)] 250 300 mod tests { 251 301 use super::*; ··· 554 604 "scope must be com.atproto.refresh (AC1.3)" 555 605 ); 556 606 assert_eq!(jkt.as_deref(), Some("jkt-thumbprint")); 607 + } 608 + 609 + #[tokio::test] 610 + async fn consume_oauth_refresh_token_returns_row_and_deletes_it() { 611 + // AC4.2: consumed token must not be found again. 612 + let pool = test_pool().await; 613 + register_oauth_client( 614 + &pool, 615 + "https://app.example.com/client-metadata.json", 616 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 617 + ) 618 + .await 619 + .unwrap(); 620 + insert_test_account(&pool).await; 621 + 622 + store_oauth_refresh_token( 623 + &pool, 624 + "consume-test-token-hash", 625 + "https://app.example.com/client-metadata.json", 626 + "did:plc:testaccount000000000000", 627 + "test-jkt-thumbprint", 628 + ) 629 + .await 630 + .unwrap(); 631 + 632 + let row = consume_oauth_refresh_token(&pool, "consume-test-token-hash") 633 + .await 634 + .unwrap() 635 + .expect("token must be found on first use"); 636 + 637 + assert_eq!(row.client_id, "https://app.example.com/client-metadata.json"); 638 + assert_eq!(row.scope, "com.atproto.refresh"); 639 + assert_eq!(row.jkt.as_deref(), Some("test-jkt-thumbprint")); 640 + 641 + // Second consume must return None (already deleted) — AC4.2. 642 + let second = consume_oauth_refresh_token(&pool, "consume-test-token-hash") 643 + .await 644 + .unwrap(); 645 + assert!(second.is_none(), "consumed token must not be found again (AC4.2)"); 646 + } 647 + 648 + #[tokio::test] 649 + async fn consume_oauth_refresh_token_returns_none_for_expired_token() { 650 + // AC4.3: expired tokens are rejected. 651 + let pool = test_pool().await; 652 + register_oauth_client( 653 + &pool, 654 + "https://app.example.com/client-metadata.json", 655 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 656 + ) 657 + .await 658 + .unwrap(); 659 + insert_test_account(&pool).await; 660 + 661 + // Insert an already-expired row directly (bypassing store_oauth_refresh_token's +24h default). 662 + sqlx::query( 663 + "INSERT INTO oauth_tokens (id, client_id, did, scope, jkt, expires_at, created_at) \ 664 + VALUES (?, ?, ?, 'com.atproto.refresh', ?, datetime('now', '-1 seconds'), datetime('now'))", 665 + ) 666 + .bind("expired-hash") 667 + .bind("https://app.example.com/client-metadata.json") 668 + .bind("did:plc:testaccount000000000000") 669 + .bind("test-jkt") 670 + .execute(&pool) 671 + .await 672 + .unwrap(); 673 + 674 + let result = consume_oauth_refresh_token(&pool, "expired-hash") 675 + .await 676 + .unwrap(); 677 + assert!(result.is_none(), "expired refresh token must return None (AC4.3)"); 678 + } 679 + 680 + #[tokio::test] 681 + async fn consume_oauth_refresh_token_returns_none_for_unknown_token() { 682 + let pool = test_pool().await; 683 + let result = consume_oauth_refresh_token(&pool, "nonexistent-hash").await.unwrap(); 684 + assert!(result.is_none()); 557 685 } 558 686 }