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

feat(relay): authorization_code grant — DPoP, PKCE, ES256 JWT + refresh token

+769 -32
+769 -32
crates/relay/src/routes/oauth_token.rs
··· 5 5 // Returns: JSON TokenResponse + DPoP-Nonce header on success; 6 6 // JSON OAuthTokenError on all failure paths 7 7 8 + use std::time::{SystemTime, UNIX_EPOCH}; 9 + 8 10 use axum::{ 9 11 extract::State, 10 12 http::{HeaderMap, StatusCode}, 11 13 response::{IntoResponse, Response}, 12 14 Form, Json, 13 15 }; 16 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 14 17 use serde::{Deserialize, Serialize}; 18 + use sha2::{Digest, Sha256}; 15 19 16 20 use crate::app::AppState; 21 + use crate::auth::{ 22 + cleanup_expired_nonces, issue_nonce, validate_dpop_for_token_endpoint, DpopTokenEndpointError, 23 + }; 24 + use crate::db::oauth::store_oauth_refresh_token; 25 + use crate::routes::token::generate_token; 17 26 18 27 // ── Request / response types ────────────────────────────────────────────────── 19 28 ··· 98 107 } 99 108 } 100 109 110 + // ── Helper functions ──────────────────────────────────────────────────────────── 111 + 112 + /// Claims for an OAuth 2.0 AT+JWT access token (RFC 9068). 113 + #[derive(Serialize)] 114 + struct AccessTokenClaims { 115 + sub: String, 116 + iat: u64, 117 + exp: u64, 118 + scope: String, 119 + /// DPoP confirmation claim (RFC 9449 §4.3): binds the token to the client's keypair. 120 + cnf: CnfClaim, 121 + } 122 + 123 + #[derive(Serialize)] 124 + struct CnfClaim { 125 + jkt: String, 126 + } 127 + 128 + fn issue_access_token( 129 + signing_key: &crate::auth::OAuthSigningKey, 130 + did: &str, 131 + scope: &str, 132 + jkt: &str, 133 + ) -> Result<String, OAuthTokenError> { 134 + let now = SystemTime::now() 135 + .duration_since(UNIX_EPOCH) 136 + .map_err(|_| OAuthTokenError::new("server_error", "system clock error"))? 137 + .as_secs(); 138 + 139 + let claims = AccessTokenClaims { 140 + sub: did.to_string(), 141 + iat: now, 142 + exp: now + 300, 143 + scope: scope.to_string(), 144 + cnf: CnfClaim { 145 + jkt: jkt.to_string(), 146 + }, 147 + }; 148 + 149 + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); 150 + header.typ = Some("at+jwt".to_string()); 151 + header.kid = Some(signing_key.key_id.clone()); 152 + 153 + jsonwebtoken::encode(&header, &claims, &signing_key.encoding_key).map_err(|e| { 154 + tracing::error!(error = %e, "failed to sign access token"); 155 + OAuthTokenError::new("server_error", "token signing failed") 156 + }) 157 + } 158 + 159 + /// Verify the PKCE S256 code challenge. 160 + fn verify_pkce_s256(code_verifier: &str, stored_challenge: &str) -> bool { 161 + let hash = Sha256::digest(code_verifier.as_bytes()); 162 + let computed = URL_SAFE_NO_PAD.encode(hash); 163 + // Constant-time comparison to prevent timing oracle. 164 + subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), stored_challenge.as_bytes()).into() 165 + } 166 + 101 167 // ── Handler ─────────────────────────────────────────────────────────────────── 102 168 103 169 /// `POST /oauth/token` — OAuth 2.0 token endpoint (RFC 6749 §3.2). 104 170 /// 105 - /// Phase 4 stub: validates grant_type, returns correct errors for unknown or 106 - /// missing grant_type. Full grant logic is added in Phases 5 and 6. 171 + /// Dispatches to grant-specific handlers based on grant_type parameter. 107 172 pub async fn post_token( 108 - State(_state): State<AppState>, 109 - _headers: HeaderMap, 173 + State(state): State<AppState>, 174 + headers: HeaderMap, 110 175 Form(form): Form<TokenRequestForm>, 111 176 ) -> Response { 112 177 let grant_type = match form.grant_type.as_deref() { ··· 121 186 }; 122 187 123 188 match grant_type { 124 - "authorization_code" => { 125 - // Implemented in Phase 5. 126 - OAuthTokenError::new( 127 - "invalid_grant", 128 - "authorization_code grant not yet implemented", 129 - ) 130 - .into_response() 131 - } 189 + "authorization_code" => handle_authorization_code(&state, &headers, form).await, 132 190 "refresh_token" => { 133 191 // Implemented in Phase 6. 134 192 OAuthTokenError::new("invalid_grant", "refresh_token grant not yet implemented") ··· 142 200 } 143 201 } 144 202 203 + async fn handle_authorization_code( 204 + state: &AppState, 205 + headers: &HeaderMap, 206 + form: TokenRequestForm, 207 + ) -> Response { 208 + // Prune stale nonces on every request. 209 + cleanup_expired_nonces(&state.dpop_nonces).await; 210 + 211 + // Required fields: code, redirect_uri, client_id, code_verifier. 212 + let code = match form.code.as_deref() { 213 + Some(c) if !c.is_empty() => c, 214 + _ => { 215 + return OAuthTokenError::new("invalid_request", "missing parameter: code") 216 + .into_response() 217 + } 218 + }; 219 + let redirect_uri = match form.redirect_uri.as_deref() { 220 + Some(u) if !u.is_empty() => u, 221 + _ => { 222 + return OAuthTokenError::new("invalid_request", "missing parameter: redirect_uri") 223 + .into_response() 224 + } 225 + }; 226 + let client_id = match form.client_id.as_deref() { 227 + Some(id) if !id.is_empty() => id, 228 + _ => { 229 + return OAuthTokenError::new("invalid_request", "missing parameter: client_id") 230 + .into_response() 231 + } 232 + }; 233 + let code_verifier = match form.code_verifier.as_deref() { 234 + Some(v) if !v.is_empty() => v, 235 + _ => { 236 + return OAuthTokenError::new("invalid_request", "missing parameter: code_verifier") 237 + .into_response() 238 + } 239 + }; 240 + 241 + // Validate DPoP proof. 242 + let dpop_token = match headers.get("DPoP").and_then(|v| v.to_str().ok()) { 243 + Some(t) => t.to_string(), 244 + None => { 245 + return OAuthTokenError::new("invalid_dpop_proof", "DPoP header required") 246 + .into_response(); 247 + } 248 + }; 249 + 250 + let token_url = format!( 251 + "{}/oauth/token", 252 + state.config.public_url.trim_end_matches('/') 253 + ); 254 + 255 + let jkt = 256 + match validate_dpop_for_token_endpoint(&dpop_token, "POST", &token_url, &state.dpop_nonces) 257 + .await 258 + { 259 + Ok(jkt) => jkt, 260 + Err(DpopTokenEndpointError::MissingHeader) => { 261 + return OAuthTokenError::new("invalid_dpop_proof", "DPoP header required") 262 + .into_response(); 263 + } 264 + Err(DpopTokenEndpointError::InvalidProof(msg)) => { 265 + return OAuthTokenError::new("invalid_dpop_proof", msg).into_response(); 266 + } 267 + Err(DpopTokenEndpointError::UseNonce(fresh_nonce)) => { 268 + return OAuthTokenError::with_nonce( 269 + "use_dpop_nonce", 270 + "DPoP nonce required", 271 + fresh_nonce, 272 + ) 273 + .into_response(); 274 + } 275 + }; 276 + 277 + // Hash the presented code for DB lookup. 278 + let code_hash = crate::routes::token::sha256_hex( 279 + &URL_SAFE_NO_PAD 280 + .decode(code) 281 + .unwrap_or_else(|_| code.as_bytes().to_vec()), 282 + ); 283 + 284 + // Atomically consume the authorization code. 285 + let auth_code = match crate::db::oauth::consume_authorization_code(&state.db, &code_hash).await 286 + { 287 + Ok(Some(row)) => row, 288 + Ok(None) => { 289 + return OAuthTokenError::new("invalid_grant", "authorization code invalid or expired") 290 + .into_response(); 291 + } 292 + Err(e) => { 293 + tracing::error!(error = %e, "failed to consume authorization code"); 294 + return OAuthTokenError::new("server_error", "database error").into_response(); 295 + } 296 + }; 297 + 298 + // Verify client_id matches. 299 + if auth_code.client_id != client_id { 300 + return OAuthTokenError::new("invalid_grant", "client_id mismatch").into_response(); 301 + } 302 + 303 + // Verify redirect_uri matches. 304 + if auth_code.redirect_uri != redirect_uri { 305 + return OAuthTokenError::new("invalid_grant", "redirect_uri mismatch").into_response(); 306 + } 307 + 308 + // Verify PKCE S256 challenge. 309 + if !verify_pkce_s256(code_verifier, &auth_code.code_challenge) { 310 + return OAuthTokenError::new( 311 + "invalid_grant", 312 + "code_verifier does not match code_challenge", 313 + ) 314 + .into_response(); 315 + } 316 + 317 + // Issue ES256 access token. 318 + let access_token = match issue_access_token( 319 + &state.oauth_signing_keypair, 320 + &auth_code.did, 321 + &auth_code.scope, 322 + &jkt, 323 + ) { 324 + Ok(t) => t, 325 + Err(e) => return e.into_response(), 326 + }; 327 + 328 + // Generate and store refresh token. 329 + let refresh = generate_token(); 330 + if let Err(e) = store_oauth_refresh_token( 331 + &state.db, 332 + &refresh.hash, 333 + &auth_code.client_id, 334 + &auth_code.did, 335 + &jkt, 336 + ) 337 + .await 338 + { 339 + tracing::error!(error = %e, "failed to store refresh token"); 340 + return OAuthTokenError::new("server_error", "database error").into_response(); 341 + } 342 + 343 + // Issue a fresh DPoP nonce for the next request. 344 + let fresh_nonce = issue_nonce(&state.dpop_nonces).await; 345 + 346 + let mut response_headers = axum::http::HeaderMap::new(); 347 + response_headers.insert("DPoP-Nonce", fresh_nonce.parse().unwrap()); 348 + 349 + ( 350 + StatusCode::OK, 351 + response_headers, 352 + Json(TokenResponse { 353 + access_token, 354 + token_type: "DPoP", 355 + expires_in: 300, 356 + refresh_token: refresh.plaintext, 357 + scope: auth_code.scope, 358 + }), 359 + ) 360 + .into_response() 361 + } 362 + 145 363 #[cfg(test)] 146 364 mod tests { 147 365 use axum::{ 148 366 body::Body, 149 367 http::{Request, StatusCode}, 150 368 }; 369 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 370 + use p256::ecdsa::{signature::Signer, Signature, SigningKey}; 371 + use rand_core::OsRng; 372 + use sha2::{Digest, Sha256}; 151 373 use tower::ServiceExt; 374 + use uuid::Uuid; 152 375 153 - use crate::app::{app, test_state}; 376 + use crate::app::{app, test_state, AppState}; 377 + use crate::auth::issue_nonce; 378 + use crate::db::oauth::{register_oauth_client, store_authorization_code}; 379 + 380 + // ── DPoP proof test helpers ─────────────────────────────────────────────── 381 + 382 + fn now_secs() -> i64 { 383 + std::time::SystemTime::now() 384 + .duration_since(std::time::UNIX_EPOCH) 385 + .unwrap() 386 + .as_secs() as i64 387 + } 388 + 389 + fn dpop_key_to_jwk(key: &SigningKey) -> serde_json::Value { 390 + let vk = key.verifying_key(); 391 + let point = vk.to_encoded_point(false); 392 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 393 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 394 + serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x, "y": y }) 395 + } 396 + 397 + fn dpop_thumbprint(key: &SigningKey) -> String { 398 + let jwk = dpop_key_to_jwk(key); 399 + let canonical = serde_json::to_string(&serde_json::json!({ 400 + "crv": jwk["crv"], 401 + "kty": jwk["kty"], 402 + "x": jwk["x"], 403 + "y": jwk["y"], 404 + })) 405 + .unwrap(); 406 + let hash = Sha256::digest(canonical.as_bytes()); 407 + URL_SAFE_NO_PAD.encode(hash) 408 + } 409 + 410 + fn make_dpop_proof( 411 + key: &SigningKey, 412 + htm: &str, 413 + htu: &str, 414 + nonce: Option<&str>, 415 + iat: i64, 416 + ) -> String { 417 + let jwk = dpop_key_to_jwk(key); 418 + let header = serde_json::json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": jwk }); 419 + let mut payload = serde_json::json!({ "htm": htm, "htu": htu, "iat": iat, "jti": Uuid::new_v4().to_string() }); 420 + if let Some(n) = nonce { 421 + payload["nonce"] = serde_json::Value::String(n.to_string()); 422 + } 423 + let hdr = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 424 + let pay = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 425 + let sig_input = format!("{hdr}.{pay}"); 426 + let sig: Signature = key.sign(sig_input.as_bytes()); 427 + let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8]); 428 + format!("{hdr}.{pay}.{sig_b64}") 429 + } 430 + 431 + /// Seed the DB with a test client + account + authorization code. 432 + async fn seed_auth_code(state: &AppState, code_hash: &str, code_challenge: &str) { 433 + register_oauth_client( 434 + &state.db, 435 + "https://app.example.com/client-metadata.json", 436 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 437 + ) 438 + .await 439 + .unwrap(); 440 + 441 + sqlx::query( 442 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 443 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 444 + datetime('now'), datetime('now'))", 445 + ) 446 + .execute(&state.db) 447 + .await 448 + .unwrap(); 449 + 450 + store_authorization_code( 451 + &state.db, 452 + code_hash, 453 + "https://app.example.com/client-metadata.json", 454 + "did:plc:testaccount000000000000", 455 + code_challenge, 456 + "S256", 457 + "https://app.example.com/callback", 458 + "atproto", 459 + ) 460 + .await 461 + .unwrap(); 462 + } 154 463 155 464 fn post_token(body: &str) -> Request<Body> { 156 465 Request::builder() ··· 161 470 .unwrap() 162 471 } 163 472 473 + fn post_token_with_dpop(body: &str, dpop: &str) -> Request<Body> { 474 + Request::builder() 475 + .method("POST") 476 + .uri("/oauth/token") 477 + .header("Content-Type", "application/x-www-form-urlencoded") 478 + .header("DPoP", dpop) 479 + .body(Body::from(body.to_string())) 480 + .unwrap() 481 + } 482 + 164 483 async fn json_body(resp: axum::response::Response) -> serde_json::Value { 165 484 let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 166 485 serde_json::from_slice(&bytes).unwrap() 167 486 } 168 487 169 - // AC5.2 — unknown grant_type 488 + // ── Phase 4 tests (retained) ────────────────────────────────────────────── 489 + 170 490 #[tokio::test] 171 491 async fn unknown_grant_type_returns_400_unsupported() { 172 492 let resp = app(test_state().await) 173 493 .oneshot(post_token("grant_type=client_credentials")) 174 494 .await 175 495 .unwrap(); 176 - 177 496 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 178 497 let json = json_body(resp).await; 179 498 assert_eq!(json["error"], "unsupported_grant_type"); 180 499 } 181 500 182 - // AC5.3 — missing grant_type 183 501 #[tokio::test] 184 502 async fn missing_grant_type_returns_400_invalid_request() { 185 503 let resp = app(test_state().await) 186 504 .oneshot(post_token("code=abc123")) 187 505 .await 188 506 .unwrap(); 189 - 190 507 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 191 508 let json = json_body(resp).await; 192 509 assert_eq!(json["error"], "invalid_request"); 193 510 } 194 511 195 - // AC5.4 — errors must be JSON, not HTML 196 512 #[tokio::test] 197 513 async fn error_response_content_type_is_json() { 198 514 let resp = app(test_state().await) 199 515 .oneshot(post_token("grant_type=bad")) 200 516 .await 201 517 .unwrap(); 202 - 203 518 let ct = resp 204 519 .headers() 205 520 .get("content-type") 206 521 .unwrap() 207 522 .to_str() 208 523 .unwrap(); 209 - assert!( 210 - ct.contains("application/json"), 211 - "content-type must be application/json" 212 - ); 524 + assert!(ct.contains("application/json")); 213 525 } 214 526 215 - // AC5.1 partial — errors have expected field shape 216 527 #[tokio::test] 217 528 async fn error_response_has_error_and_error_description_fields() { 218 529 let resp = app(test_state().await) 219 530 .oneshot(post_token("grant_type=bad")) 220 531 .await 221 532 .unwrap(); 222 - 223 533 let json = json_body(resp).await; 224 - assert!(json["error"].is_string(), "error field must be a string"); 225 - assert!( 226 - json["error_description"].is_string(), 227 - "error_description field must be a string" 228 - ); 534 + assert!(json["error"].is_string()); 535 + assert!(json["error_description"].is_string()); 229 536 } 230 537 231 - // GET to /oauth/token should return 405 Method Not Allowed. 232 538 #[tokio::test] 233 539 async fn get_token_endpoint_returns_405() { 234 540 let resp = app(test_state().await) ··· 241 547 ) 242 548 .await 243 549 .unwrap(); 550 + assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); 551 + } 244 552 245 - assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); 553 + // ── AC2 — DPoP proof validation ─────────────────────────────────────────── 554 + 555 + #[tokio::test] 556 + async fn missing_dpop_header_returns_invalid_dpop_proof() { 557 + // AC2.3 558 + let resp = app(test_state().await) 559 + .oneshot(post_token( 560 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 561 + )) 562 + .await 563 + .unwrap(); 564 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 565 + let json = json_body(resp).await; 566 + assert_eq!(json["error"], "invalid_dpop_proof"); 567 + } 568 + 569 + #[tokio::test] 570 + async fn dpop_wrong_htm_returns_invalid_dpop_proof() { 571 + // AC2.4 572 + let state = test_state().await; 573 + let key = SigningKey::random(&mut OsRng); 574 + let nonce = issue_nonce(&state.dpop_nonces).await; 575 + let dpop = make_dpop_proof( 576 + &key, 577 + "GET", // wrong — must be POST 578 + "https://test.example.com/oauth/token", 579 + Some(&nonce), 580 + now_secs(), 581 + ); 582 + 583 + let resp = app(state) 584 + .oneshot(post_token_with_dpop( 585 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 586 + &dpop, 587 + )) 588 + .await 589 + .unwrap(); 590 + 591 + let json = json_body(resp).await; 592 + assert_eq!( 593 + json["error"], "invalid_dpop_proof", 594 + "wrong htm must return invalid_dpop_proof" 595 + ); 596 + } 597 + 598 + #[tokio::test] 599 + async fn dpop_wrong_htu_returns_invalid_dpop_proof() { 600 + // AC2.5 601 + let state = test_state().await; 602 + let key = SigningKey::random(&mut OsRng); 603 + let nonce = issue_nonce(&state.dpop_nonces).await; 604 + let dpop = make_dpop_proof( 605 + &key, 606 + "POST", 607 + "https://wrong-url.example.com/oauth/token", 608 + Some(&nonce), 609 + now_secs(), 610 + ); 611 + 612 + let resp = app(state) 613 + .oneshot(post_token_with_dpop( 614 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 615 + &dpop, 616 + )) 617 + .await 618 + .unwrap(); 619 + 620 + let json = json_body(resp).await; 621 + assert_eq!(json["error"], "invalid_dpop_proof"); 622 + } 623 + 624 + #[tokio::test] 625 + async fn dpop_stale_iat_returns_invalid_dpop_proof() { 626 + // AC2.6 627 + let state = test_state().await; 628 + let key = SigningKey::random(&mut OsRng); 629 + let nonce = issue_nonce(&state.dpop_nonces).await; 630 + let dpop = make_dpop_proof( 631 + &key, 632 + "POST", 633 + "https://test.example.com/oauth/token", 634 + Some(&nonce), 635 + now_secs() - 120, // 2 minutes ago — stale 636 + ); 637 + 638 + let resp = app(state) 639 + .oneshot(post_token_with_dpop( 640 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 641 + &dpop, 642 + )) 643 + .await 644 + .unwrap(); 645 + 646 + let json = json_body(resp).await; 647 + assert_eq!(json["error"], "invalid_dpop_proof"); 648 + } 649 + 650 + // ── AC3 — DPoP nonces ───────────────────────────────────────────────────── 651 + 652 + #[tokio::test] 653 + async fn dpop_without_nonce_returns_use_dpop_nonce_with_header() { 654 + // AC3.2 655 + let state = test_state().await; 656 + let key = SigningKey::random(&mut OsRng); 657 + let dpop = make_dpop_proof( 658 + &key, 659 + "POST", 660 + "https://test.example.com/oauth/token", 661 + None, // no nonce 662 + now_secs(), 663 + ); 664 + 665 + let resp = app(state) 666 + .oneshot(post_token_with_dpop( 667 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 668 + &dpop, 669 + )) 670 + .await 671 + .unwrap(); 672 + 673 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 674 + assert!( 675 + resp.headers().contains_key("DPoP-Nonce"), 676 + "use_dpop_nonce response must include DPoP-Nonce header" 677 + ); 678 + let json = json_body(resp).await; 679 + assert_eq!(json["error"], "use_dpop_nonce"); 680 + } 681 + 682 + #[tokio::test] 683 + async fn dpop_with_unknown_nonce_returns_use_dpop_nonce() { 684 + // AC3.4 685 + let state = test_state().await; 686 + let key = SigningKey::random(&mut OsRng); 687 + let dpop = make_dpop_proof( 688 + &key, 689 + "POST", 690 + "https://test.example.com/oauth/token", 691 + Some("fabricated-nonce-that-was-never-issued"), 692 + now_secs(), 693 + ); 694 + 695 + let resp = app(state) 696 + .oneshot(post_token_with_dpop( 697 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 698 + &dpop, 699 + )) 700 + .await 701 + .unwrap(); 702 + 703 + let json = json_body(resp).await; 704 + assert_eq!(json["error"], "use_dpop_nonce"); 705 + } 706 + 707 + // ── AC1 — authorization_code grant ─────────────────────────────────────── 708 + 709 + #[tokio::test] 710 + async fn authorization_code_happy_path_returns_200_with_tokens() { 711 + // AC1.1, AC1.2, AC1.3, AC2.1, AC2.2, AC3.5, AC6.3 712 + let state = test_state().await; 713 + let key = SigningKey::random(&mut OsRng); 714 + 715 + // Build PKCE S256 challenge. 716 + let code_verifier = "testcodeverifier1234567890abcdefghijklmnopqr"; 717 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 718 + 719 + // Raw code (43-char base64url) and its SHA-256 hex hash for DB storage. 720 + let raw_code = "dGVzdGF1dGhvcml6YXRpb25jb2RlMTIzNDU2Nzg5MDEyMw"; 721 + let code_hash = { 722 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 723 + let hash = Sha256::digest(&bytes); 724 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 725 + }; 726 + 727 + seed_auth_code(&state, &code_hash, &code_challenge).await; 728 + let nonce = issue_nonce(&state.dpop_nonces).await; 729 + 730 + let dpop = make_dpop_proof( 731 + &key, 732 + "POST", 733 + "https://test.example.com/oauth/token", 734 + Some(&nonce), 735 + now_secs(), 736 + ); 737 + 738 + let body = format!( 739 + "grant_type=authorization_code\ 740 + &code={raw_code}\ 741 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 742 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 743 + &code_verifier={code_verifier}" 744 + ); 745 + 746 + let resp = app(state) 747 + .oneshot(post_token_with_dpop(&body, &dpop)) 748 + .await 749 + .unwrap(); 750 + 751 + assert_eq!(resp.status(), StatusCode::OK, "happy path must return 200"); 752 + 753 + // AC3.5 — DPoP-Nonce header in success response. 754 + assert!( 755 + resp.headers().contains_key("DPoP-Nonce"), 756 + "success response must include fresh DPoP-Nonce header" 757 + ); 758 + 759 + let json = json_body(resp).await; 760 + 761 + // AC1.1 — TokenResponse fields. 762 + assert!( 763 + json["access_token"].is_string(), 764 + "access_token must be present" 765 + ); 766 + assert_eq!(json["token_type"], "DPoP", "token_type must be DPoP"); 767 + assert_eq!(json["expires_in"], 300); 768 + assert!( 769 + json["refresh_token"].is_string(), 770 + "refresh_token must be present" 771 + ); 772 + assert!(json["scope"].is_string(), "scope must be present"); 773 + 774 + // AC1.3 — refresh token is 43-char base64url. 775 + let rt = json["refresh_token"].as_str().unwrap(); 776 + assert_eq!(rt.len(), 43, "refresh_token must be 43 chars (AC1.3)"); 777 + 778 + // AC1.2 + AC6.3 — access token is ES256 JWT with typ=at+jwt. 779 + let at = json["access_token"].as_str().unwrap(); 780 + let header_b64 = at.split('.').next().unwrap(); 781 + let header_json = String::from_utf8(URL_SAFE_NO_PAD.decode(header_b64).unwrap()).unwrap(); 782 + let header: serde_json::Value = serde_json::from_str(&header_json).unwrap(); 783 + assert_eq!( 784 + header["typ"], "at+jwt", 785 + "access token typ must be at+jwt (AC1.2)" 786 + ); 787 + assert_eq!( 788 + header["alg"], "ES256", 789 + "access token alg must be ES256 (AC6.3)" 790 + ); 791 + 792 + // AC2.2 — cnf.jkt in access token matches DPoP key thumbprint. 793 + let payload_b64 = at.split('.').nth(1).unwrap(); 794 + let payload_json = String::from_utf8(URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap(); 795 + let payload: serde_json::Value = serde_json::from_str(&payload_json).unwrap(); 796 + let cnf_jkt = payload["cnf"]["jkt"].as_str().unwrap(); 797 + let expected_jkt = dpop_thumbprint(&key); 798 + assert_eq!( 799 + cnf_jkt, expected_jkt, 800 + "cnf.jkt must match DPoP key thumbprint (AC2.2)" 801 + ); 802 + } 803 + 804 + #[tokio::test] 805 + async fn wrong_code_verifier_returns_invalid_grant() { 806 + // AC1.4 807 + let state = test_state().await; 808 + let key = SigningKey::random(&mut OsRng); 809 + 810 + let code_verifier = "correctverifier1234567890abcdefghijklmnopqr"; 811 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 812 + let raw_code = "dGVzdGF1dGhvcml6YXRpb25jb2RlMTIzNDU2Nzg5MDEyMw"; 813 + let code_hash = { 814 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 815 + let hash = Sha256::digest(&bytes); 816 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 817 + }; 818 + seed_auth_code(&state, &code_hash, &code_challenge).await; 819 + let nonce = issue_nonce(&state.dpop_nonces).await; 820 + let dpop = make_dpop_proof( 821 + &key, 822 + "POST", 823 + "https://test.example.com/oauth/token", 824 + Some(&nonce), 825 + now_secs(), 826 + ); 827 + 828 + let resp = app(state) 829 + .oneshot(post_token_with_dpop( 830 + &format!( 831 + "grant_type=authorization_code&code={raw_code}&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json&code_verifier=wrong-verifier" 832 + ), 833 + &dpop, 834 + )) 835 + .await 836 + .unwrap(); 837 + 838 + let json = json_body(resp).await; 839 + assert_eq!( 840 + json["error"], "invalid_grant", 841 + "wrong code_verifier must return invalid_grant (AC1.4)" 842 + ); 843 + } 844 + 845 + #[tokio::test] 846 + async fn consumed_code_returns_invalid_grant() { 847 + // AC1.6 848 + let state = test_state().await; 849 + let key = SigningKey::random(&mut OsRng); 850 + let code_verifier = "testcodeverifier1234567890abcdefghijklmnopqr"; 851 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 852 + let raw_code = "dGVzdGF1dGhvcml6YXRpb25jb2RlMTIzNDU2Nzg5MDEyMw"; 853 + let code_hash = { 854 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 855 + let hash = Sha256::digest(&bytes); 856 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 857 + }; 858 + seed_auth_code(&state, &code_hash, &code_challenge).await; 859 + 860 + let body = format!( 861 + "grant_type=authorization_code&code={raw_code}&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json&code_verifier={code_verifier}" 862 + ); 863 + 864 + // First use — should succeed. 865 + let nonce1 = issue_nonce(&state.dpop_nonces).await; 866 + let dpop1 = make_dpop_proof( 867 + &key, 868 + "POST", 869 + "https://test.example.com/oauth/token", 870 + Some(&nonce1), 871 + now_secs(), 872 + ); 873 + let state_arc = std::sync::Arc::new(state); 874 + 875 + // Build the app twice using different oneshot calls on the same state. 876 + // Clone state so the DB pool is shared across both calls. 877 + let state1 = (*state_arc).clone(); 878 + let resp1 = app(state1) 879 + .oneshot(post_token_with_dpop(&body, &dpop1)) 880 + .await 881 + .unwrap(); 882 + assert_eq!(resp1.status(), StatusCode::OK, "first use must succeed"); 883 + 884 + // Second use — code was consumed. 885 + let state2 = (*state_arc).clone(); 886 + let nonce2 = issue_nonce(&state2.dpop_nonces).await; 887 + let dpop2 = make_dpop_proof( 888 + &key, 889 + "POST", 890 + "https://test.example.com/oauth/token", 891 + Some(&nonce2), 892 + now_secs(), 893 + ); 894 + let resp2 = app(state2) 895 + .oneshot(post_token_with_dpop(&body, &dpop2)) 896 + .await 897 + .unwrap(); 898 + let json2 = json_body(resp2).await; 899 + assert_eq!( 900 + json2["error"], "invalid_grant", 901 + "second use must return invalid_grant (AC1.6)" 902 + ); 903 + } 904 + 905 + #[tokio::test] 906 + async fn client_id_mismatch_returns_invalid_grant() { 907 + // AC1.7 908 + let state = test_state().await; 909 + let key = SigningKey::random(&mut OsRng); 910 + let code_verifier = "testcodeverifier1234567890abcdefghijklmnopqr"; 911 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 912 + let raw_code = "dGVzdGF1dGhvcml6YXRpb25jb2RlMTIzNDU2Nzg5MDEyMw"; 913 + let code_hash = { 914 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 915 + let hash = Sha256::digest(&bytes); 916 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 917 + }; 918 + seed_auth_code(&state, &code_hash, &code_challenge).await; 919 + let nonce = issue_nonce(&state.dpop_nonces).await; 920 + let dpop = make_dpop_proof( 921 + &key, 922 + "POST", 923 + "https://test.example.com/oauth/token", 924 + Some(&nonce), 925 + now_secs(), 926 + ); 927 + 928 + let resp = app(state) 929 + .oneshot(post_token_with_dpop( 930 + &format!( 931 + "grant_type=authorization_code&code={raw_code}&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&client_id=https%3A%2F%2Fwrong-client.example.com%2F&code_verifier={code_verifier}" 932 + ), 933 + &dpop, 934 + )) 935 + .await 936 + .unwrap(); 937 + 938 + let json = json_body(resp).await; 939 + assert_eq!( 940 + json["error"], "invalid_grant", 941 + "client_id mismatch must return invalid_grant (AC1.7)" 942 + ); 943 + } 944 + 945 + #[tokio::test] 946 + async fn redirect_uri_mismatch_returns_invalid_grant() { 947 + // AC1.8 948 + let state = test_state().await; 949 + let key = SigningKey::random(&mut OsRng); 950 + let code_verifier = "testcodeverifier1234567890abcdefghijklmnopqr"; 951 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 952 + let raw_code = "dGVzdGF1dGhvcml6YXRpb25jb2RlMTIzNDU2Nzg5MDEyMw"; 953 + let code_hash = { 954 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 955 + let hash = Sha256::digest(&bytes); 956 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 957 + }; 958 + seed_auth_code(&state, &code_hash, &code_challenge).await; 959 + let nonce = issue_nonce(&state.dpop_nonces).await; 960 + let dpop = make_dpop_proof( 961 + &key, 962 + "POST", 963 + "https://test.example.com/oauth/token", 964 + Some(&nonce), 965 + now_secs(), 966 + ); 967 + 968 + let resp = app(state) 969 + .oneshot(post_token_with_dpop( 970 + &format!( 971 + "grant_type=authorization_code&code={raw_code}&redirect_uri=https%3A%2F%2Fwrong.example.com%2Fcallback&client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json&code_verifier={code_verifier}" 972 + ), 973 + &dpop, 974 + )) 975 + .await 976 + .unwrap(); 977 + 978 + let json = json_body(resp).await; 979 + assert_eq!( 980 + json["error"], "invalid_grant", 981 + "redirect_uri mismatch must return invalid_grant (AC1.8)" 982 + ); 246 983 } 247 984 }