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

fix(relay): address final review — comment hygiene, dpop alg, test coverage

Before-merge fixes:
- Strip F#/AC# traceability prefixes from production source comments
- Replace AC-labeled test section headers with descriptive names
- dpop_alg_from_str: restrict to ES256 only (removes ES384 inconsistency
with server metadata which advertises ES256 as sole supported algorithm)
- into_response: return server_error when nonce cannot be set as header
(instead of silently omitting the nonce, which leaves client with no
retry path per RFC 9449 §7.1)
- code_challenge_method mismatch: invalid_request not invalid_grant
(RFC 7636 §4.6)
- HeaderValue::from_static for the application/json content-type literal

Test gap coverage (high priority):
- C-1/C-2: auth code and refresh token are NOT consumed on client_id mismatch
- F3: refresh token with NULL jkt returns invalid_grant
- F1: ES256 AT+JWT round-trip accepted at resource endpoint (AuthenticatedUser)
- F1: forged ES256 token signed by wrong key returns 401

Test gap coverage (medium/low priority):
- C-5: multiple DPoP headers at token endpoint returns invalid_dpop_proof
- F2: code_challenge_method=plain returns invalid_request
- F7: code_verifier < 43 chars returns invalid_grant
- F5: Cache-Control: no-store present on authorization_code success response

+495 -20
+93 -3
crates/relay/src/auth/mod.rs
··· 445 445 )); 446 446 } 447 447 448 - // Validate that the embedded JWK curve matches the declared algorithm (F10). 448 + // Validate that the embedded JWK curve matches the declared algorithm. 449 449 if dpop_header.alg == "ES256" 450 450 && dpop_header.jwk.get("crv").and_then(|v| v.as_str()) != Some("P-256") 451 451 { ··· 694 694 )); 695 695 } 696 696 697 - // Validate that the embedded JWK curve matches the declared algorithm (F10). 697 + // Validate that the embedded JWK curve matches the declared algorithm. 698 698 if dpop_header.alg == "ES256" 699 699 && dpop_header.jwk.get("crv").and_then(|v| v.as_str()) != Some("P-256") 700 700 { ··· 847 847 fn dpop_alg_from_str(alg: &str) -> Option<Algorithm> { 848 848 match alg { 849 849 "ES256" => Some(Algorithm::ES256), 850 - "ES384" => Some(Algorithm::ES384), 851 850 _ => None, 852 851 } 853 852 } ··· 1802 1801 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')); 1803 1802 // Stable regression guard. 1804 1803 assert_eq!(thumb, "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"); 1804 + } 1805 + 1806 + // ── ES256 round-trip (F1) ───────────────────────────────────────────────── 1807 + 1808 + #[tokio::test] 1809 + async fn es256_at_jwt_accepted_at_resource_endpoint() { 1810 + // Verifies the full dispatch path: peek_jwt_typ → verify_es256_access_token → 1811 + // AuthenticatedUser. A token issued by the OAuth token endpoint (ES256, typ=at+jwt) 1812 + // must be accepted at any endpoint that uses the AuthenticatedUser extractor. 1813 + let state = test_state().await; 1814 + 1815 + // Issue an ES256 AT+JWT using the test state's signing key — no cnf.jkt so no 1816 + // DPoP header is required; we are testing ES256 signature verification only. 1817 + #[derive(Serialize)] 1818 + struct Es256Claims { 1819 + iss: String, 1820 + jti: String, 1821 + sub: String, 1822 + aud: String, 1823 + iat: u64, 1824 + exp: u64, 1825 + scope: String, 1826 + } 1827 + let now = now_secs() as u64; 1828 + let claims = Es256Claims { 1829 + iss: state.config.public_url.clone(), 1830 + jti: uuid::Uuid::new_v4().to_string(), 1831 + sub: "did:plc:alice".to_string(), 1832 + aud: state.config.public_url.clone(), 1833 + iat: now, 1834 + exp: now + 300, 1835 + scope: "com.atproto.access".to_string(), 1836 + }; 1837 + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); 1838 + header.typ = Some("at+jwt".to_string()); 1839 + let token = encode(&header, &claims, &state.oauth_signing_keypair.encoding_key).unwrap(); 1840 + 1841 + let resp = get_protected(protected_app(state), Some(&token)).await; 1842 + assert_eq!(resp.status(), StatusCode::OK); 1843 + let text = String::from_utf8( 1844 + axum::body::to_bytes(resp.into_body(), 4096) 1845 + .await 1846 + .unwrap() 1847 + .to_vec(), 1848 + ) 1849 + .unwrap(); 1850 + assert!(text.contains("did=did:plc:alice")); 1851 + } 1852 + 1853 + #[tokio::test] 1854 + async fn es256_at_jwt_with_wrong_key_returns_401() { 1855 + // A token signed by a different key (e.g. an attacker's P-256 key) must not 1856 + // pass ES256 verification even if the typ header claims "at+jwt". 1857 + let state = test_state().await; 1858 + 1859 + let attacker_key = p256::ecdsa::SigningKey::random(&mut OsRng); 1860 + let attacker_pkcs8 = attacker_key.to_pkcs8_der().unwrap(); 1861 + 1862 + #[derive(Serialize)] 1863 + struct Es256Claims { 1864 + iss: String, 1865 + jti: String, 1866 + sub: String, 1867 + aud: String, 1868 + iat: u64, 1869 + exp: u64, 1870 + scope: String, 1871 + } 1872 + let now = now_secs() as u64; 1873 + let claims = Es256Claims { 1874 + iss: state.config.public_url.clone(), 1875 + jti: uuid::Uuid::new_v4().to_string(), 1876 + sub: "did:plc:attacker".to_string(), 1877 + aud: state.config.public_url.clone(), 1878 + iat: now, 1879 + exp: now + 300, 1880 + scope: "com.atproto.access".to_string(), 1881 + }; 1882 + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); 1883 + header.typ = Some("at+jwt".to_string()); 1884 + let forged_token = encode( 1885 + &header, 1886 + &claims, 1887 + &EncodingKey::from_ec_der(attacker_pkcs8.as_bytes()), 1888 + ) 1889 + .unwrap(); 1890 + 1891 + let resp = get_protected(protected_app(state), Some(&forged_token)).await; 1892 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 1893 + let json = json_body(resp).await; 1894 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 1805 1895 } 1806 1896 }
+1 -1
crates/relay/src/db/oauth.rs
··· 768 768 assert_eq!(row.jkt.as_deref(), Some("test-jkt-thumbprint")); 769 769 assert_eq!(row.did, "did:plc:testaccount000000000000"); 770 770 771 - // Second consume must return None (already deleted) — AC4.2. 771 + // Second consume must return None since the token was deleted above. 772 772 let second = consume_oauth_refresh_token(&pool, "consume-test-token-hash") 773 773 .await 774 774 .unwrap();
+401 -16
crates/relay/src/routes/oauth_token.rs
··· 95 95 let mut headers = axum::http::HeaderMap::new(); 96 96 headers.insert( 97 97 axum::http::header::CONTENT_TYPE, 98 - "application/json".parse().unwrap(), 98 + axum::http::HeaderValue::from_static("application/json"), 99 99 ); 100 100 if let Some(nonce) = self.dpop_nonce { 101 101 match axum::http::HeaderValue::from_str(&nonce) { ··· 103 103 headers.insert("DPoP-Nonce", hval); 104 104 } 105 105 Err(e) => { 106 - tracing::warn!(nonce = ?nonce, error = %e, "failed to insert DPoP-Nonce header, nonce format invalid"); 106 + // This should never happen: nonces are base64url ASCII, always valid 107 + // header values. If it does happen, returning use_dpop_nonce without 108 + // the nonce header leaves the client with no retry path (RFC 9449 §7.1). 109 + // Return server_error instead. 110 + tracing::error!(nonce = ?nonce, error = %e, "nonce string cannot be encoded as HTTP header value; this is a server bug"); 111 + return ( 112 + StatusCode::INTERNAL_SERVER_ERROR, 113 + [( 114 + axum::http::header::CONTENT_TYPE, 115 + axum::http::HeaderValue::from_static("application/json"), 116 + )], 117 + Json(serde_json::json!({ 118 + "error": "server_error", 119 + "error_description": "internal server error", 120 + })), 121 + ) 122 + .into_response(); 107 123 } 108 124 } 109 125 } ··· 228 244 headers: &HeaderMap, 229 245 form: TokenRequestForm, 230 246 ) -> Response { 231 - // Prune stale nonces on every request. 247 + // Prune stale nonces and expired tokens on every request. 232 248 cleanup_expired_nonces(&state.dpop_nonces).await; 233 - // F6: Clean up expired auth codes and refresh tokens to prevent DB growth. 234 249 cleanup_expired_auth_codes(&state.db) 235 250 .await 236 251 .unwrap_or_else(|e| { ··· 365 380 return OAuthTokenError::new("invalid_grant", "redirect_uri mismatch").into_response(); 366 381 } 367 382 368 - // Enforce S256 — reject plain (or any other method) in case it ever enters the DB (F2). 383 + // Enforce S256: reject plain (or any other method) in case it ever enters the DB. 369 384 if auth_code.code_challenge_method != "S256" { 370 - return OAuthTokenError::new("invalid_grant", "unsupported code_challenge_method") 385 + return OAuthTokenError::new("invalid_request", "unsupported code_challenge_method") 371 386 .into_response(); 372 387 } 373 388 ··· 430 445 .into_response(); 431 446 } 432 447 } 433 - // F5: Add Cache-Control headers to prevent caching of sensitive token response. 448 + // Add Cache-Control headers to prevent caching of sensitive token responses (RFC 6749 §5.1). 434 449 response_headers.insert( 435 450 axum::http::header::CACHE_CONTROL, 436 451 axum::http::HeaderValue::from_static("no-store"), ··· 456 471 headers: &HeaderMap, 457 472 form: TokenRequestForm, 458 473 ) -> Response { 459 - // Prune stale nonces on every request. 474 + // Prune stale nonces and expired tokens on every request. 460 475 cleanup_expired_nonces(&state.dpop_nonces).await; 461 - // F6: Clean up expired auth codes and refresh tokens to prevent DB growth. 462 476 cleanup_expired_auth_codes(&state.db) 463 477 .await 464 478 .unwrap_or_else(|e| { ··· 558 572 return OAuthTokenError::new("invalid_grant", "client_id mismatch").into_response(); 559 573 } 560 574 561 - // DPoP binding check before consuming: if the refresh token was bound to a specific key, the same key must be used. 562 - // F3: NULL jkt on refresh token bypasses DPoP binding — treat None jkt as invalid_grant. 575 + // DPoP binding check: tokens issued since V012 always carry jkt. 576 + // A NULL jkt means the token predates DPoP binding enforcement — reject it. 563 577 match stored.jkt.as_deref() { 564 578 None => { 565 579 // Refresh tokens issued after V012 always have a jkt. A NULL jkt means ··· 626 640 .into_response(); 627 641 } 628 642 } 629 - // F5: Add Cache-Control headers to prevent caching of sensitive token response. 643 + // Add Cache-Control headers to prevent caching of sensitive token responses (RFC 6749 §5.1). 630 644 response_headers.insert( 631 645 axum::http::header::CACHE_CONTROL, 632 646 axum::http::HeaderValue::from_static("no-store"), ··· 843 857 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); 844 858 } 845 859 846 - // ── AC2 — DPoP proof validation ─────────────────────────────────────────── 860 + // ── DPoP proof validation ───────────────────────────────────────────────── 847 861 848 862 #[tokio::test] 849 863 async fn missing_dpop_header_returns_invalid_dpop_proof() { ··· 936 950 assert_eq!(json["error"], "invalid_dpop_proof"); 937 951 } 938 952 939 - // ── AC3 — DPoP nonces ───────────────────────────────────────────────────── 953 + // ── DPoP nonces ─────────────────────────────────────────────────────────── 940 954 941 955 #[tokio::test] 942 956 async fn dpop_without_nonce_returns_use_dpop_nonce_with_header() { ··· 991 1005 assert_eq!(json["error"], "use_dpop_nonce"); 992 1006 } 993 1007 994 - // ── AC1 — authorization_code grant ─────────────────────────────────────── 1008 + // ── authorization_code grant ────────────────────────────────────────────── 995 1009 996 1010 #[tokio::test] 997 1011 async fn authorization_code_happy_path_returns_200_with_tokens() { ··· 1263 1277 ); 1264 1278 } 1265 1279 1266 - // ── AC4 — refresh_token grant ───────────────────────────────────────────── 1280 + // ── refresh_token grant ─────────────────────────────────────────────────── 1267 1281 1268 1282 /// Seed the DB with a client + account + fresh refresh token bound to `jkt`. 1269 1283 /// ··· 1539 1553 assert_eq!( 1540 1554 json["error"], "invalid_grant", 1541 1555 "DPoP key mismatch must return invalid_grant" 1556 + ); 1557 + } 1558 + 1559 + // ── C-1/C-2 ordering: token not consumed on validation failure ──────────── 1560 + 1561 + #[tokio::test] 1562 + async fn authorization_code_not_consumed_on_client_id_mismatch() { 1563 + // Verifies that the auth code is NOT deleted when client_id validation fails — 1564 + // i.e., the validate-before-consume ordering is in effect. 1565 + let state = test_state().await; 1566 + let key = SigningKey::random(&mut OsRng); 1567 + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // 43-char S256 verifier 1568 + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); 1569 + let code = generate_token(); 1570 + seed_auth_code(&state, &code.hash, &challenge).await; 1571 + 1572 + let nonce = issue_nonce(&state.dpop_nonces).await; 1573 + let dpop = make_dpop_proof( 1574 + &key, 1575 + "POST", 1576 + "https://test.example.com/oauth/token", 1577 + Some(&nonce), 1578 + now_secs(), 1579 + ); 1580 + 1581 + // Attempt 1: wrong client_id — must fail. 1582 + let bad_body = format!( 1583 + "grant_type=authorization_code\ 1584 + &code={raw_code}\ 1585 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 1586 + &client_id=https%3A%2F%2Fwrong.example.com%2Fclient-metadata.json\ 1587 + &code_verifier={verifier}", 1588 + raw_code = code.plaintext 1589 + ); 1590 + let bad_resp = app(state.clone()) 1591 + .oneshot(post_token_with_dpop(&bad_body, &dpop)) 1592 + .await 1593 + .unwrap(); 1594 + assert_eq!(bad_resp.status(), StatusCode::BAD_REQUEST); 1595 + let bad_json = json_body(bad_resp).await; 1596 + assert_eq!(bad_json["error"], "invalid_grant"); 1597 + 1598 + // Attempt 2: correct client_id — must succeed (code was not consumed above). 1599 + let nonce2 = issue_nonce(&state.dpop_nonces).await; 1600 + let dpop2 = make_dpop_proof( 1601 + &key, 1602 + "POST", 1603 + "https://test.example.com/oauth/token", 1604 + Some(&nonce2), 1605 + now_secs(), 1606 + ); 1607 + let good_body = format!( 1608 + "grant_type=authorization_code\ 1609 + &code={raw_code}\ 1610 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 1611 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 1612 + &code_verifier={verifier}", 1613 + raw_code = code.plaintext 1614 + ); 1615 + let good_resp = app(state) 1616 + .oneshot(post_token_with_dpop(&good_body, &dpop2)) 1617 + .await 1618 + .unwrap(); 1619 + assert_eq!( 1620 + good_resp.status(), 1621 + StatusCode::OK, 1622 + "code must still be usable after a failed attempt with wrong client_id" 1623 + ); 1624 + } 1625 + 1626 + #[tokio::test] 1627 + async fn refresh_token_not_consumed_on_client_id_mismatch() { 1628 + // Verifies that the refresh token is NOT deleted when client_id validation fails. 1629 + let state = test_state().await; 1630 + let key = SigningKey::random(&mut OsRng); 1631 + let jkt = dpop_thumbprint(&key); 1632 + let plaintext = seed_refresh_token(&state, &jkt).await; 1633 + 1634 + let nonce = issue_nonce(&state.dpop_nonces).await; 1635 + let dpop = make_dpop_proof( 1636 + &key, 1637 + "POST", 1638 + "https://test.example.com/oauth/token", 1639 + Some(&nonce), 1640 + now_secs(), 1641 + ); 1642 + 1643 + // Attempt 1: wrong client_id — must fail. 1644 + let bad_body = format!( 1645 + "grant_type=refresh_token\ 1646 + &refresh_token={plaintext}\ 1647 + &client_id=https%3A%2F%2Fwrong.example.com%2Fclient-metadata.json" 1648 + ); 1649 + let bad_resp = app(state.clone()) 1650 + .oneshot(post_token_with_dpop(&bad_body, &dpop)) 1651 + .await 1652 + .unwrap(); 1653 + assert_eq!(bad_resp.status(), StatusCode::BAD_REQUEST); 1654 + let bad_json = json_body(bad_resp).await; 1655 + assert_eq!(bad_json["error"], "invalid_grant"); 1656 + 1657 + // Attempt 2: correct client_id — must succeed (token was not consumed above). 1658 + let nonce2 = issue_nonce(&state.dpop_nonces).await; 1659 + let dpop2 = make_dpop_proof( 1660 + &key, 1661 + "POST", 1662 + "https://test.example.com/oauth/token", 1663 + Some(&nonce2), 1664 + now_secs(), 1665 + ); 1666 + let good_body = format!( 1667 + "grant_type=refresh_token\ 1668 + &refresh_token={plaintext}\ 1669 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 1670 + ); 1671 + let good_resp = app(state) 1672 + .oneshot(post_token_with_dpop(&good_body, &dpop2)) 1673 + .await 1674 + .unwrap(); 1675 + assert_eq!( 1676 + good_resp.status(), 1677 + StatusCode::OK, 1678 + "refresh token must still be usable after a failed attempt with wrong client_id" 1679 + ); 1680 + } 1681 + 1682 + // ── F3: NULL jkt rejected ───────────────────────────────────────────────── 1683 + 1684 + #[tokio::test] 1685 + async fn refresh_token_with_null_jkt_returns_invalid_grant() { 1686 + // Tokens issued before DPoP binding enforcement may have jkt = NULL. 1687 + // These must be rejected rather than silently accepting any DPoP key. 1688 + let state = test_state().await; 1689 + let key = SigningKey::random(&mut OsRng); 1690 + 1691 + // Seed client and account (FK constraints required by oauth_tokens). 1692 + register_oauth_client( 1693 + &state.db, 1694 + "https://app.example.com/client-metadata.json", 1695 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 1696 + ) 1697 + .await 1698 + .unwrap(); 1699 + sqlx::query( 1700 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 1701 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 1702 + datetime('now'), datetime('now'))", 1703 + ) 1704 + .execute(&state.db) 1705 + .await 1706 + .unwrap(); 1707 + 1708 + // Insert a refresh token with jkt = NULL directly (bypasses store_oauth_refresh_token 1709 + // which always sets jkt, simulating a pre-V012 row). 1710 + let token = generate_token(); 1711 + sqlx::query( 1712 + "INSERT INTO oauth_tokens (id, client_id, did, scope, jkt, expires_at, created_at) \ 1713 + VALUES (?, ?, ?, 'com.atproto.refresh', NULL, datetime('now', '+24 hours'), datetime('now'))", 1714 + ) 1715 + .bind(&token.hash) 1716 + .bind("https://app.example.com/client-metadata.json") 1717 + .bind("did:plc:testaccount000000000000") 1718 + .execute(&state.db) 1719 + .await 1720 + .unwrap(); 1721 + 1722 + let nonce = issue_nonce(&state.dpop_nonces).await; 1723 + let dpop = make_dpop_proof( 1724 + &key, 1725 + "POST", 1726 + "https://test.example.com/oauth/token", 1727 + Some(&nonce), 1728 + now_secs(), 1729 + ); 1730 + let body = format!( 1731 + "grant_type=refresh_token\ 1732 + &refresh_token={}\ 1733 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json", 1734 + token.plaintext 1735 + ); 1736 + 1737 + let resp = app(state) 1738 + .oneshot(post_token_with_dpop(&body, &dpop)) 1739 + .await 1740 + .unwrap(); 1741 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1742 + let json = json_body(resp).await; 1743 + assert_eq!( 1744 + json["error"], "invalid_grant", 1745 + "refresh token with NULL jkt must return invalid_grant" 1746 + ); 1747 + } 1748 + 1749 + // ── C-5: multiple DPoP headers at token endpoint ────────────────────────── 1750 + 1751 + #[tokio::test] 1752 + async fn multiple_dpop_headers_at_token_endpoint_returns_invalid_dpop_proof() { 1753 + // The multiple-DPoP check fires after required field validation, so all required 1754 + // fields must be present for the check to be reached. 1755 + let state = test_state().await; 1756 + let req = Request::builder() 1757 + .method("POST") 1758 + .uri("/oauth/token") 1759 + .header("Content-Type", "application/x-www-form-urlencoded") 1760 + .header("DPoP", "first.proof.value") 1761 + .header("DPoP", "second.proof.value") 1762 + .body(Body::from( 1763 + "grant_type=authorization_code\ 1764 + &code=somerawcode\ 1765 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 1766 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 1767 + &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", 1768 + )) 1769 + .unwrap(); 1770 + let resp = app(state).oneshot(req).await.unwrap(); 1771 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1772 + let json = json_body(resp).await; 1773 + assert_eq!(json["error"], "invalid_dpop_proof"); 1774 + } 1775 + 1776 + // ── F2: code_challenge_method = "plain" rejected ────────────────────────── 1777 + 1778 + #[tokio::test] 1779 + async fn plain_code_challenge_method_returns_invalid_request() { 1780 + // An auth code with code_challenge_method = "plain" must be rejected at the 1781 + // token endpoint even if the PKCE check would otherwise pass. 1782 + let state = test_state().await; 1783 + let key = SigningKey::random(&mut OsRng); 1784 + 1785 + register_oauth_client( 1786 + &state.db, 1787 + "https://app.example.com/client-metadata.json", 1788 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 1789 + ) 1790 + .await 1791 + .unwrap(); 1792 + sqlx::query( 1793 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 1794 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 1795 + datetime('now'), datetime('now'))", 1796 + ) 1797 + .execute(&state.db) 1798 + .await 1799 + .unwrap(); 1800 + 1801 + // Seed an auth code with code_challenge_method = "plain" directly. 1802 + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; 1803 + let code = generate_token(); 1804 + crate::db::oauth::store_authorization_code( 1805 + &state.db, 1806 + &code.hash, 1807 + "https://app.example.com/client-metadata.json", 1808 + "did:plc:testaccount000000000000", 1809 + verifier, // for "plain", code_challenge == code_verifier 1810 + "plain", 1811 + "https://app.example.com/callback", 1812 + "com.atproto.access", 1813 + ) 1814 + .await 1815 + .unwrap(); 1816 + 1817 + let nonce = issue_nonce(&state.dpop_nonces).await; 1818 + let dpop = make_dpop_proof( 1819 + &key, 1820 + "POST", 1821 + "https://test.example.com/oauth/token", 1822 + Some(&nonce), 1823 + now_secs(), 1824 + ); 1825 + let body = format!( 1826 + "grant_type=authorization_code\ 1827 + &code={raw_code}\ 1828 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 1829 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 1830 + &code_verifier={verifier}", 1831 + raw_code = code.plaintext 1832 + ); 1833 + 1834 + let resp = app(state) 1835 + .oneshot(post_token_with_dpop(&body, &dpop)) 1836 + .await 1837 + .unwrap(); 1838 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1839 + let json = json_body(resp).await; 1840 + assert_eq!( 1841 + json["error"], "invalid_request", 1842 + "code_challenge_method=plain must return invalid_request (RFC 7636 §4.6)" 1843 + ); 1844 + } 1845 + 1846 + // ── F7: code_verifier length validation ─────────────────────────────────── 1847 + 1848 + #[tokio::test] 1849 + async fn short_code_verifier_returns_invalid_grant() { 1850 + // RFC 7636 §4.1 requires 43–128 characters. A verifier shorter than 43 1851 + // chars must be rejected before the PKCE hash comparison. 1852 + let state = test_state().await; 1853 + let key = SigningKey::random(&mut OsRng); 1854 + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; 1855 + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); 1856 + let code = generate_token(); 1857 + seed_auth_code(&state, &code.hash, &challenge).await; 1858 + 1859 + let nonce = issue_nonce(&state.dpop_nonces).await; 1860 + let dpop = make_dpop_proof( 1861 + &key, 1862 + "POST", 1863 + "https://test.example.com/oauth/token", 1864 + Some(&nonce), 1865 + now_secs(), 1866 + ); 1867 + let body = format!( 1868 + "grant_type=authorization_code\ 1869 + &code={raw_code}\ 1870 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 1871 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 1872 + &code_verifier=short", // < 43 chars 1873 + raw_code = code.plaintext 1874 + ); 1875 + 1876 + let resp = app(state) 1877 + .oneshot(post_token_with_dpop(&body, &dpop)) 1878 + .await 1879 + .unwrap(); 1880 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1881 + let json = json_body(resp).await; 1882 + assert_eq!( 1883 + json["error"], "invalid_grant", 1884 + "code_verifier shorter than 43 chars must return invalid_grant" 1885 + ); 1886 + } 1887 + 1888 + // ── F5: Cache-Control headers on token responses ────────────────────────── 1889 + 1890 + #[tokio::test] 1891 + async fn authorization_code_success_response_has_cache_control_no_store() { 1892 + let state = test_state().await; 1893 + let key = SigningKey::random(&mut OsRng); 1894 + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; 1895 + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); 1896 + let code = generate_token(); 1897 + seed_auth_code(&state, &code.hash, &challenge).await; 1898 + 1899 + let nonce = issue_nonce(&state.dpop_nonces).await; 1900 + let dpop = make_dpop_proof( 1901 + &key, 1902 + "POST", 1903 + "https://test.example.com/oauth/token", 1904 + Some(&nonce), 1905 + now_secs(), 1906 + ); 1907 + let body = format!( 1908 + "grant_type=authorization_code\ 1909 + &code={raw_code}\ 1910 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 1911 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 1912 + &code_verifier={verifier}", 1913 + raw_code = code.plaintext 1914 + ); 1915 + 1916 + let resp = app(state) 1917 + .oneshot(post_token_with_dpop(&body, &dpop)) 1918 + .await 1919 + .unwrap(); 1920 + assert_eq!(resp.status(), StatusCode::OK); 1921 + assert_eq!( 1922 + resp.headers() 1923 + .get("cache-control") 1924 + .and_then(|v| v.to_str().ok()), 1925 + Some("no-store"), 1926 + "token response must include Cache-Control: no-store (RFC 6749 §5.1)" 1542 1927 ); 1543 1928 } 1544 1929