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

test(relay): refresh_token grant integration tests (AC4.1–AC4.5)

+284 -1
+284 -1
crates/relay/src/routes/oauth_token.rs
··· 507 507 508 508 use crate::app::{app, test_state, AppState}; 509 509 use crate::auth::issue_nonce; 510 - use crate::db::oauth::{register_oauth_client, store_authorization_code}; 510 + use crate::db::oauth::{register_oauth_client, store_authorization_code, store_oauth_refresh_token}; 511 + use crate::routes::token::generate_token; 511 512 512 513 // ── DPoP proof test helpers ─────────────────────────────────────────────── 513 514 ··· 1116 1117 assert_eq!( 1117 1118 json["error"], "invalid_grant", 1118 1119 "redirect_uri mismatch must return invalid_grant (AC1.8)" 1120 + ); 1121 + } 1122 + 1123 + // ── AC4 — refresh_token grant ───────────────────────────────────────────── 1124 + 1125 + /// Seed the DB with a client + account + fresh refresh token bound to `jkt`. 1126 + /// 1127 + /// Returns the base64url plaintext of the seeded refresh token. 1128 + async fn seed_refresh_token(state: &AppState, jkt: &str) -> String { 1129 + register_oauth_client( 1130 + &state.db, 1131 + "https://app.example.com/client-metadata.json", 1132 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 1133 + ) 1134 + .await 1135 + .unwrap(); 1136 + sqlx::query( 1137 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 1138 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 1139 + datetime('now'), datetime('now'))", 1140 + ) 1141 + .execute(&state.db) 1142 + .await 1143 + .unwrap(); 1144 + 1145 + let token = generate_token(); 1146 + store_oauth_refresh_token( 1147 + &state.db, 1148 + &token.hash, 1149 + "https://app.example.com/client-metadata.json", 1150 + "did:plc:testaccount000000000000", 1151 + jkt, 1152 + ) 1153 + .await 1154 + .unwrap(); 1155 + token.plaintext 1156 + } 1157 + 1158 + /// Seed the DB with an already-expired refresh token (bypasses store_oauth_refresh_token's +24h). 1159 + /// 1160 + /// Returns the base64url plaintext. 1161 + async fn seed_expired_refresh_token(state: &AppState, jkt: &str) -> String { 1162 + register_oauth_client( 1163 + &state.db, 1164 + "https://app.example.com/client-metadata.json", 1165 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 1166 + ) 1167 + .await 1168 + .unwrap(); 1169 + sqlx::query( 1170 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 1171 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 1172 + datetime('now'), datetime('now'))", 1173 + ) 1174 + .execute(&state.db) 1175 + .await 1176 + .unwrap(); 1177 + 1178 + let token = generate_token(); 1179 + sqlx::query( 1180 + "INSERT INTO oauth_tokens (id, client_id, did, scope, jkt, expires_at, created_at) \ 1181 + VALUES (?, ?, ?, 'com.atproto.refresh', ?, datetime('now', '-1 seconds'), datetime('now'))", 1182 + ) 1183 + .bind(&token.hash) 1184 + .bind("https://app.example.com/client-metadata.json") 1185 + .bind("did:plc:testaccount000000000000") 1186 + .bind(jkt) 1187 + .execute(&state.db) 1188 + .await 1189 + .unwrap(); 1190 + token.plaintext 1191 + } 1192 + 1193 + #[tokio::test] 1194 + async fn refresh_token_happy_path_returns_200_with_new_tokens() { 1195 + // AC4.1 — valid rotation returns 200 with fresh token pair. 1196 + let state = test_state().await; 1197 + let key = SigningKey::random(&mut OsRng); 1198 + let jkt = dpop_thumbprint(&key); 1199 + 1200 + let plaintext = seed_refresh_token(&state, &jkt).await; 1201 + let nonce = issue_nonce(&state.dpop_nonces).await; 1202 + let dpop = make_dpop_proof( 1203 + &key, 1204 + "POST", 1205 + "https://test.example.com/oauth/token", 1206 + Some(&nonce), 1207 + now_secs(), 1208 + ); 1209 + 1210 + let body = format!( 1211 + "grant_type=refresh_token\ 1212 + &refresh_token={plaintext}\ 1213 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 1214 + ); 1215 + 1216 + let resp = app(state) 1217 + .oneshot(post_token_with_dpop(&body, &dpop)) 1218 + .await 1219 + .unwrap(); 1220 + 1221 + assert_eq!(resp.status(), StatusCode::OK, "valid rotation must return 200"); 1222 + assert!( 1223 + resp.headers().contains_key("DPoP-Nonce"), 1224 + "success response must include DPoP-Nonce header" 1225 + ); 1226 + 1227 + let json = json_body(resp).await; 1228 + assert!(json["access_token"].is_string(), "access_token must be present"); 1229 + assert_eq!(json["token_type"], "DPoP"); 1230 + assert_eq!(json["expires_in"], 300); 1231 + assert!(json["refresh_token"].is_string(), "rotated refresh_token must be present"); 1232 + 1233 + // Rotated token must differ from the original. 1234 + let new_rt = json["refresh_token"].as_str().unwrap(); 1235 + assert_ne!( 1236 + new_rt, plaintext.as_str(), 1237 + "rotated refresh token must differ from original" 1238 + ); 1239 + } 1240 + 1241 + #[tokio::test] 1242 + async fn refresh_token_second_use_returns_invalid_grant() { 1243 + // AC4.2 — after rotation the original token is deleted; second use must fail. 1244 + let state = test_state().await; 1245 + let key = SigningKey::random(&mut OsRng); 1246 + let jkt = dpop_thumbprint(&key); 1247 + 1248 + let plaintext = seed_refresh_token(&state, &jkt).await; 1249 + let body = format!( 1250 + "grant_type=refresh_token\ 1251 + &refresh_token={plaintext}\ 1252 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 1253 + ); 1254 + 1255 + // First use: succeeds. Clone state so the second request shares the same DB. 1256 + let nonce1 = issue_nonce(&state.dpop_nonces).await; 1257 + let dpop1 = make_dpop_proof( 1258 + &key, 1259 + "POST", 1260 + "https://test.example.com/oauth/token", 1261 + Some(&nonce1), 1262 + now_secs(), 1263 + ); 1264 + let first_resp = app(state.clone()) 1265 + .oneshot(post_token_with_dpop(&body, &dpop1)) 1266 + .await 1267 + .unwrap(); 1268 + assert_eq!(first_resp.status(), StatusCode::OK, "first use must succeed"); 1269 + 1270 + // Second use of the same original token: must return invalid_grant. 1271 + let nonce2 = issue_nonce(&state.dpop_nonces).await; 1272 + let dpop2 = make_dpop_proof( 1273 + &key, 1274 + "POST", 1275 + "https://test.example.com/oauth/token", 1276 + Some(&nonce2), 1277 + now_secs(), 1278 + ); 1279 + let resp2 = app(state) 1280 + .oneshot(post_token_with_dpop(&body, &dpop2)) 1281 + .await 1282 + .unwrap(); 1283 + 1284 + assert_eq!(resp2.status(), StatusCode::BAD_REQUEST, "second use must return 400"); 1285 + let json = json_body(resp2).await; 1286 + assert_eq!( 1287 + json["error"], "invalid_grant", 1288 + "second use of consumed token must return invalid_grant (AC4.2)" 1289 + ); 1290 + } 1291 + 1292 + #[tokio::test] 1293 + async fn refresh_token_expired_returns_invalid_grant() { 1294 + // AC4.3 — expired refresh tokens are rejected. 1295 + let state = test_state().await; 1296 + let key = SigningKey::random(&mut OsRng); 1297 + let jkt = dpop_thumbprint(&key); 1298 + 1299 + let plaintext = seed_expired_refresh_token(&state, &jkt).await; 1300 + let nonce = issue_nonce(&state.dpop_nonces).await; 1301 + let dpop = make_dpop_proof( 1302 + &key, 1303 + "POST", 1304 + "https://test.example.com/oauth/token", 1305 + Some(&nonce), 1306 + now_secs(), 1307 + ); 1308 + 1309 + let body = format!( 1310 + "grant_type=refresh_token\ 1311 + &refresh_token={plaintext}\ 1312 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 1313 + ); 1314 + 1315 + let resp = app(state) 1316 + .oneshot(post_token_with_dpop(&body, &dpop)) 1317 + .await 1318 + .unwrap(); 1319 + 1320 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1321 + let json = json_body(resp).await; 1322 + assert_eq!( 1323 + json["error"], "invalid_grant", 1324 + "expired refresh token must return invalid_grant (AC4.3)" 1325 + ); 1326 + } 1327 + 1328 + #[tokio::test] 1329 + async fn refresh_token_jkt_mismatch_returns_invalid_grant() { 1330 + // AC4.4 — DPoP key in proof must match the thumbprint bound to the refresh token. 1331 + let state = test_state().await; 1332 + let stored_key = SigningKey::random(&mut OsRng); 1333 + let stored_jkt = dpop_thumbprint(&stored_key); 1334 + 1335 + // Seed token bound to stored_key's thumbprint. 1336 + let plaintext = seed_refresh_token(&state, &stored_jkt).await; 1337 + 1338 + // Build proof with a DIFFERENT key — thumbprint will not match stored_jkt. 1339 + let different_key = SigningKey::random(&mut OsRng); 1340 + let nonce = issue_nonce(&state.dpop_nonces).await; 1341 + let dpop = make_dpop_proof( 1342 + &different_key, 1343 + "POST", 1344 + "https://test.example.com/oauth/token", 1345 + Some(&nonce), 1346 + now_secs(), 1347 + ); 1348 + 1349 + let body = format!( 1350 + "grant_type=refresh_token\ 1351 + &refresh_token={plaintext}\ 1352 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 1353 + ); 1354 + 1355 + let resp = app(state) 1356 + .oneshot(post_token_with_dpop(&body, &dpop)) 1357 + .await 1358 + .unwrap(); 1359 + 1360 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1361 + let json = json_body(resp).await; 1362 + assert_eq!( 1363 + json["error"], "invalid_grant", 1364 + "DPoP key mismatch must return invalid_grant (AC4.4)" 1365 + ); 1366 + } 1367 + 1368 + #[tokio::test] 1369 + async fn refresh_token_client_id_mismatch_returns_invalid_grant() { 1370 + // AC4.5 — client_id in the request must match the stored client_id. 1371 + let state = test_state().await; 1372 + let key = SigningKey::random(&mut OsRng); 1373 + let jkt = dpop_thumbprint(&key); 1374 + 1375 + let plaintext = seed_refresh_token(&state, &jkt).await; 1376 + let nonce = issue_nonce(&state.dpop_nonces).await; 1377 + let dpop = make_dpop_proof( 1378 + &key, 1379 + "POST", 1380 + "https://test.example.com/oauth/token", 1381 + Some(&nonce), 1382 + now_secs(), 1383 + ); 1384 + 1385 + // Wrong client_id — does not match stored "https://app.example.com/client-metadata.json". 1386 + let body = format!( 1387 + "grant_type=refresh_token\ 1388 + &refresh_token={plaintext}\ 1389 + &client_id=https%3A%2F%2Fother.example.com%2Fclient-metadata.json" 1390 + ); 1391 + 1392 + let resp = app(state) 1393 + .oneshot(post_token_with_dpop(&body, &dpop)) 1394 + .await 1395 + .unwrap(); 1396 + 1397 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1398 + let json = json_body(resp).await; 1399 + assert_eq!( 1400 + json["error"], "invalid_grant", 1401 + "client_id mismatch must return invalid_grant (AC4.5)" 1119 1402 ); 1120 1403 } 1121 1404 }