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