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

feat(relay): refresh_token grant — rotation, DPoP binding check

+142 -4
+142 -4
crates/relay/src/routes/oauth_token.rs
··· 21 21 use crate::auth::{ 22 22 cleanup_expired_nonces, issue_nonce, validate_dpop_for_token_endpoint, DpopTokenEndpointError, 23 23 }; 24 - use crate::db::oauth::store_oauth_refresh_token; 24 + use crate::db::oauth::{ 25 + consume_oauth_refresh_token, store_oauth_refresh_token, 26 + }; 25 27 use crate::routes::token::generate_token; 26 28 27 29 // ── Request / response types ────────────────────────────────────────────────── ··· 182 184 match grant_type { 183 185 "authorization_code" => handle_authorization_code(&state, &headers, form).await, 184 186 "refresh_token" => { 185 - // Implemented in Phase 6. 186 - OAuthTokenError::new("invalid_grant", "refresh_token grant not yet implemented") 187 - .into_response() 187 + handle_refresh_token(&state, &headers, form).await 188 188 } 189 189 _ => OAuthTokenError::new( 190 190 "unsupported_grant_type", ··· 349 349 expires_in: 300, 350 350 refresh_token: refresh.plaintext, 351 351 scope: auth_code.scope, 352 + }), 353 + ) 354 + .into_response() 355 + } 356 + 357 + async fn handle_refresh_token( 358 + state: &AppState, 359 + headers: &HeaderMap, 360 + form: TokenRequestForm, 361 + ) -> Response { 362 + // Prune stale nonces on every request. 363 + cleanup_expired_nonces(&state.dpop_nonces).await; 364 + 365 + // Required fields. 366 + let refresh_token_plaintext = match form.refresh_token.as_deref() { 367 + Some(t) if !t.is_empty() => t.to_string(), 368 + _ => { 369 + return OAuthTokenError::new("invalid_request", "missing parameter: refresh_token") 370 + .into_response(); 371 + } 372 + }; 373 + let client_id = match form.client_id.as_deref() { 374 + Some(id) if !id.is_empty() => id.to_string(), 375 + _ => { 376 + return OAuthTokenError::new("invalid_request", "missing parameter: client_id") 377 + .into_response(); 378 + } 379 + }; 380 + 381 + // Validate DPoP proof — must be present, structurally valid, and carry a valid server nonce. 382 + let dpop_token = match headers.get("DPoP").and_then(|v| v.to_str().ok()) { 383 + Some(t) => t.to_string(), 384 + None => { 385 + return OAuthTokenError::new("invalid_dpop_proof", "DPoP header required") 386 + .into_response(); 387 + } 388 + }; 389 + 390 + let token_url = format!( 391 + "{}/oauth/token", 392 + state.config.public_url.trim_end_matches('/') 393 + ); 394 + 395 + let jkt = match validate_dpop_for_token_endpoint( 396 + &dpop_token, 397 + "POST", 398 + &token_url, 399 + &state.dpop_nonces, 400 + ) 401 + .await 402 + { 403 + Ok(jkt) => jkt, 404 + Err(DpopTokenEndpointError::MissingHeader) => { 405 + return OAuthTokenError::new("invalid_dpop_proof", "DPoP header required") 406 + .into_response(); 407 + } 408 + Err(DpopTokenEndpointError::InvalidProof(msg)) => { 409 + return OAuthTokenError::new("invalid_dpop_proof", msg).into_response(); 410 + } 411 + Err(DpopTokenEndpointError::UseNonce(fresh_nonce)) => { 412 + return OAuthTokenError::with_nonce( 413 + "use_dpop_nonce", 414 + "DPoP nonce required", 415 + fresh_nonce, 416 + ) 417 + .into_response(); 418 + } 419 + }; 420 + 421 + // Hash the presented refresh token for DB lookup. 422 + let token_hash = crate::routes::token::sha256_hex( 423 + &URL_SAFE_NO_PAD 424 + .decode(refresh_token_plaintext.as_str()) 425 + .unwrap_or_else(|_| refresh_token_plaintext.as_bytes().to_vec()), 426 + ); 427 + 428 + // Atomically consume the refresh token (SELECT + DELETE). 429 + let stored = match consume_oauth_refresh_token(&state.db, &token_hash).await { 430 + Ok(Some(row)) => row, 431 + Ok(None) => { 432 + return OAuthTokenError::new("invalid_grant", "refresh token not found or expired") 433 + .into_response(); 434 + } 435 + Err(e) => { 436 + tracing::error!(error = %e, "failed to consume refresh token"); 437 + return OAuthTokenError::new("server_error", "database error").into_response(); 438 + } 439 + }; 440 + 441 + // Verify client_id matches the stored value. 442 + if stored.client_id != client_id { 443 + return OAuthTokenError::new("invalid_grant", "client_id mismatch").into_response(); 444 + } 445 + 446 + // DPoP binding check: if the refresh token was bound to a specific key, the same key must be used. 447 + if let Some(ref stored_jkt) = stored.jkt { 448 + if *stored_jkt != jkt { 449 + return OAuthTokenError::new("invalid_grant", "DPoP key mismatch").into_response(); 450 + } 451 + } 452 + 453 + // Issue new ES256 access token. 454 + let access_token = 455 + match issue_access_token(&state.oauth_signing_keypair, &stored.did, &stored.scope, &jkt) { 456 + Ok(t) => t, 457 + Err(e) => return e.into_response(), 458 + }; 459 + 460 + // Generate and store new refresh token (rotation: old token already deleted above). 461 + let new_refresh = generate_token(); 462 + if let Err(e) = store_oauth_refresh_token( 463 + &state.db, 464 + &new_refresh.hash, 465 + &stored.client_id, 466 + &stored.did, 467 + &jkt, 468 + ) 469 + .await 470 + { 471 + tracing::error!(error = %e, "failed to store rotated refresh token"); 472 + return OAuthTokenError::new("server_error", "database error").into_response(); 473 + } 474 + 475 + // Issue fresh DPoP nonce for the next request. 476 + let fresh_nonce = issue_nonce(&state.dpop_nonces).await; 477 + 478 + let mut response_headers = axum::http::HeaderMap::new(); 479 + response_headers.insert("DPoP-Nonce", fresh_nonce.parse().unwrap()); 480 + 481 + ( 482 + StatusCode::OK, 483 + response_headers, 484 + Json(TokenResponse { 485 + access_token, 486 + token_type: "DPoP", 487 + expires_in: 300, 488 + refresh_token: new_refresh.plaintext, 489 + scope: stored.scope, 352 490 }), 353 491 ) 354 492 .into_response()