handlers.rs (25318B)
1 use axum::{ 2 Json, 3 extract::{Path, Query, State}, 4 http::{StatusCode, header}, 5 response::IntoResponse, 6 Form, 7 }; 8 use chrono::Utc; 9 use serde_json::json; 10 11 use crate::{crypto, db::sessions::SessionStatus, models::*, state::AppState}; 12 13 // Health check endpoint 14 pub async fn health_check() -> impl IntoResponse { 15 tracing::info!("Received Health Request"); 16 Json(json!({ 17 "status": "healthy", 18 "service": "oauth2-gateway", 19 })) 20 } 21 22 // POST /setup/{clientId} 23 pub async fn setup( 24 State(state): State<AppState>, 25 Path(client_id): Path<String>, 26 headers: axum::http::HeaderMap, 27 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 28 tracing::info!("Setup request for client: {}", client_id); 29 30 let auth_header = headers 31 .get(header::AUTHORIZATION) 32 .and_then(|h| h.to_str().ok()); 33 34 let bearer_token = match auth_header { 35 Some(h) if h.starts_with("Bearer ") => &h[7..], 36 _ => { 37 tracing::warn!( 38 "Missing or malformed Authorization header for client: {}", 39 client_id 40 ); 41 return Err(( 42 StatusCode::UNAUTHORIZED, 43 Json(ErrorResponse::new("unauthorized")), 44 )); 45 } 46 }; 47 48 let secret_hash = crate::db::clients::get_client_secret_hash(&state.pool, &client_id) 49 .await 50 .map_err(|e| { 51 tracing::error!("DB error fetching client secret: {}", e); 52 ( 53 StatusCode::INTERNAL_SERVER_ERROR, 54 Json(ErrorResponse::new("internal_error")), 55 ) 56 })?; 57 58 let secret_hash = match secret_hash { 59 Some(hash) => hash, 60 None => { 61 tracing::warn!("Client not found: {}", client_id); 62 return Err(( 63 StatusCode::UNAUTHORIZED, 64 Json(ErrorResponse::new("unauthorized")), 65 )); 66 } 67 }; 68 69 let is_valid = bcrypt::verify(bearer_token, &secret_hash).map_err(|e| { 70 tracing::error!("Bcrypt verification error: {}", e); 71 ( 72 StatusCode::INTERNAL_SERVER_ERROR, 73 Json(ErrorResponse::new("internal_error")), 74 ) 75 })?; 76 77 if !is_valid { 78 tracing::warn!("Invalid bearer token for client: {}", client_id); 79 return Err(( 80 StatusCode::UNAUTHORIZED, 81 Json(ErrorResponse::new("unauthorized")), 82 )); 83 } 84 85 let nonce = crypto::generate_nonce(state.config.crypto.nonce_bytes); 86 87 tracing::debug!("Generated nonce: {}", nonce); 88 89 let session = crate::db::sessions::create_session(&state.pool, &client_id, &nonce, 15) 90 .await 91 .map_err(|e| { 92 tracing::error!("Failed to create session: {}", e); 93 ( 94 StatusCode::INTERNAL_SERVER_ERROR, 95 Json(ErrorResponse::new("internal_error")), 96 ) 97 })?; 98 99 let session = match session { 100 Some(s) => s, 101 None => { 102 tracing::warn!("Client not found: {}", client_id); 103 return Err(( 104 StatusCode::NOT_FOUND, 105 Json(ErrorResponse::new("client_not_found")), 106 )); 107 } 108 }; 109 110 tracing::info!( 111 "Created session {} for client {} with nonce {}", 112 session.id, 113 client_id, 114 nonce 115 ); 116 117 Ok((StatusCode::OK, Json(SetupResponse { nonce }))) 118 } 119 120 // GET /authorize/{nonce}?response_type=code&client_id={client_id}&redirect_uri={uri}&state={state} 121 pub async fn authorize( 122 State(state): State<AppState>, 123 Path(nonce): Path<String>, 124 Query(params): Query<AuthorizeQuery>, 125 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 126 tracing::info!( 127 "Authorize request for client: {}, nonce: {}, state: {}, redirect_uri: {}, scope: {}", 128 params.client_id, 129 nonce, 130 params.state, 131 params.redirect_uri, 132 params.scope 133 ); 134 135 if params.response_type != "code" { 136 return Err(( 137 StatusCode::BAD_REQUEST, 138 Json(ErrorResponse::new("invalid_request")), 139 )); 140 } 141 142 let session_data = crate::db::sessions::get_session_for_authorize( 143 &state.pool, 144 &nonce, 145 ¶ms.client_id, 146 ¶ms.scope, 147 ¶ms.redirect_uri, 148 ¶ms.state, 149 ) 150 .await 151 .map_err(|e| { 152 tracing::error!("DB error in authorize: {}", e); 153 ( 154 StatusCode::INTERNAL_SERVER_ERROR, 155 Json(ErrorResponse::new("internal_error")), 156 ) 157 })?; 158 159 let data = match session_data { 160 Some(d) => d, 161 None => { 162 tracing::warn!("Session not found for nonce: {}", nonce); 163 return Err(( 164 StatusCode::NOT_FOUND, 165 Json(ErrorResponse::new("session_not_found")), 166 )); 167 } 168 }; 169 170 // Backend validation 171 if data.expires_at < Utc::now() { 172 tracing::warn!("Session expired: {}", data.session_id); 173 return Err(( 174 StatusCode::GONE, 175 Json(ErrorResponse::new("session_expired")), 176 )); 177 } 178 179 // Check status for idempotency 180 match data.status { 181 SessionStatus::Authorized => { 182 tracing::info!( 183 "Session {} already authorized, returning cached response", 184 data.session_id 185 ); 186 187 let verification_id = data 188 .request_id 189 .and_then(|id| uuid::Uuid::parse_str(&id).ok()) 190 .unwrap_or(uuid::Uuid::nil()); 191 192 return Ok(PrettyJson(AuthorizeResponse { 193 verification_id, 194 verification_url: data.verification_url.clone().unwrap_or_default(), 195 verification_deeplink: data.verification_deeplink, 196 state: params.state.clone() 197 })); 198 } 199 200 SessionStatus::Pending => { 201 // Proceed with authorization 202 } 203 204 _ => { 205 tracing::warn!( 206 "Session {} in invalid status: {:?}", 207 data.session_id, 208 data.status 209 ); 210 return Err(( 211 StatusCode::CONFLICT, 212 Json(ErrorResponse::new("invalid_session_status")), 213 )); 214 } 215 } 216 217 // Build presentation definition from scope 218 let presentation_definition = build_presentation_definition(&data.scope); 219 220 // Call Swiyu Verifier 221 let verifier_url = format!("{}{}", data.verifier_url, data.verifier_management_api_path); 222 223 let verifier_request = SwiyuCreateVerificationRequest { 224 accepted_issuer_dids: default_accepted_issuer_dids(), 225 trust_anchors: None, 226 jwt_secured_authorization_request: Some(true), 227 response_mode: ResponseMode::DirectPost, 228 response_type: "vp_token".to_string(), 229 presentation_definition, 230 configuration_override: ConfigurationOverride::default(), 231 dcql_query: None, 232 }; 233 234 tracing::debug!( 235 "Swiyu verifier request: {}", 236 serde_json::to_string_pretty(&verifier_request).unwrap() 237 ); 238 tracing::debug!("Calling Swiyu verifier at: {}", verifier_url); 239 240 let verifier_response = state 241 .http_client 242 .post(&verifier_url) 243 .json(&verifier_request) 244 .send() 245 .await 246 .map_err(|e| { 247 tracing::error!("Failed to call Swiyu verifier: {}", e); 248 ( 249 StatusCode::BAD_GATEWAY, 250 Json(ErrorResponse::new("verifier_unavailable")), 251 ) 252 })?; 253 254 if !verifier_response.status().is_success() { 255 let status = verifier_response.status(); 256 let body = verifier_response.text().await.unwrap_or_default(); 257 tracing::error!("Swiyu verifier returned error {}: {}", status, body); 258 return Err(( 259 StatusCode::BAD_GATEWAY, 260 Json(ErrorResponse::new("verifier_error")), 261 )); 262 } 263 264 let swiyu_response: SwiyuManagementResponse = verifier_response.json().await.map_err(|e| { 265 tracing::error!("Failed to parse Swiyu response: {}", e); 266 ( 267 StatusCode::BAD_GATEWAY, 268 Json(ErrorResponse::new("verifier_invalid_response")), 269 ) 270 })?; 271 272 // Update session with verifier data 273 let result = crate::db::sessions::update_session_authorized( 274 &state.pool, 275 data.session_id, 276 &swiyu_response.verification_url, 277 swiyu_response.verification_deeplink.as_deref(), 278 &swiyu_response.id.to_string(), 279 swiyu_response.request_nonce.as_deref(), 280 ) 281 .await 282 .map_err(|e| { 283 tracing::error!("Failed to update session: {}", e); 284 ( 285 StatusCode::INTERNAL_SERVER_ERROR, 286 Json(ErrorResponse::new("internal_error")), 287 ) 288 })?; 289 290 tracing::info!( 291 "Session {} authorized, verification_id: {}", 292 data.session_id, 293 swiyu_response.id 294 ); 295 296 Ok(PrettyJson(AuthorizeResponse { 297 verification_id: swiyu_response.id, 298 verification_url: result.verification_url, 299 verification_deeplink: swiyu_response.verification_deeplink, 300 state: params.state.clone() 301 })) 302 } 303 304 /// Build a presentation definition from a space-delimited scope string 305 /// 306 /// Example: "age_over_18" or "first_name last_name" 307 fn build_presentation_definition(scope: &str) -> PresentationDefinition { 308 use std::collections::HashMap; 309 use uuid::Uuid; 310 311 let attributes: Vec<&str> = scope.split_whitespace().collect(); 312 313 tracing::debug!( 314 "Building presentation definition for attributes: {:?}", 315 attributes 316 ); 317 318 // First field: $.vct with filter for credential type 319 let vct_field = Field { 320 path: vec!["$.vct".to_string()], 321 id: None, 322 name: None, 323 purpose: None, 324 filter: Some(Filter { 325 filter_type: "string".to_string(), 326 const_value: Some("betaid-sdjwt".to_string()), 327 }), 328 }; 329 330 // Attribute fields from scope 331 let mut fields: Vec<Field> = vec![vct_field]; 332 for attr in &attributes { 333 fields.push(Field { 334 path: vec![format!("$.{}", attr)], 335 id: None, 336 name: None, 337 purpose: None, 338 filter: None, 339 }); 340 } 341 342 let mut format = HashMap::new(); 343 format.insert( 344 "vc+sd-jwt".to_string(), 345 FormatAlgorithm { 346 sd_jwt_alg_values: vec!["ES256".to_string()], 347 kb_jwt_alg_values: vec!["ES256".to_string()], 348 }, 349 ); 350 351 let input_descriptor = InputDescriptor { 352 id: Uuid::new_v4().to_string(), 353 name: None, 354 purpose: None, 355 format: Some(format), 356 constraints: Constraint { fields }, 357 }; 358 359 PresentationDefinition { 360 id: Uuid::new_v4().to_string(), 361 name: Some("Over 18 Verification".to_string()), 362 purpose: Some("Verify age is over 18".to_string()), 363 format: None, // No format at top level 364 input_descriptors: vec![input_descriptor], 365 } 366 } 367 368 // POST /token 369 pub async fn token( 370 State(state): State<AppState>, 371 Form(request): Form<TokenRequest>, 372 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 373 tracing::info!("Token request for code: {}", request.code); 374 375 // Validate grant_type 376 if request.grant_type != "authorization_code" { 377 return Err(( 378 StatusCode::BAD_REQUEST, 379 Json(ErrorResponse::new("unsupported_grant_type")), 380 )); 381 } 382 383 // Authenticate client 384 let client = crate::db::clients::authenticate_client( 385 &state.pool, 386 &request.client_id, 387 &request.client_secret, 388 ) 389 .await 390 .map_err(|e| { 391 tracing::error!("DB error during client authentication: {}", e); 392 ( 393 StatusCode::INTERNAL_SERVER_ERROR, 394 Json(ErrorResponse::new("internal_error")), 395 ) 396 })?; 397 398 let client = match client { 399 Some(c) => c, 400 None => { 401 tracing::warn!("Client authentication failed for {}", request.client_id); 402 return Err(( 403 StatusCode::UNAUTHORIZED, 404 Json(ErrorResponse::new("invalid_client")), 405 )); 406 } 407 }; 408 409 // Fetch code (idempotent) 410 let code_data = 411 crate::db::authorization_codes::get_code_for_token_exchange(&state.pool, &request.code) 412 .await 413 .map_err(|e| { 414 tracing::error!("DB error in token exchange: {}", e); 415 ( 416 StatusCode::INTERNAL_SERVER_ERROR, 417 Json(ErrorResponse::new("internal_error")), 418 ) 419 })?; 420 421 let data = match code_data { 422 Some(d) => d, 423 None => { 424 tracing::warn!("Authorization code not found or expired: {}", request.code); 425 return Err(( 426 StatusCode::BAD_REQUEST, 427 Json(ErrorResponse::new("invalid_grant")), 428 )); 429 } 430 }; 431 432 // Verify the authorization code belongs to the client 433 if data.client_id != client.id { 434 tracing::warn!( 435 "Authorization code {} does not belong to the client {}", 436 request.code, 437 request.client_id 438 ); 439 440 return Err(( 441 StatusCode::BAD_REQUEST, 442 Json(ErrorResponse::new("invalid_grant")), 443 )); 444 } 445 446 // Check for existing token 447 if let Some(existing_token) = data.existing_token { 448 tracing::info!( 449 "Token already exists for session {}, returning cached response", 450 data.session_id 451 ); 452 return Ok(( 453 StatusCode::OK, 454 Json(TokenResponse { 455 access_token: existing_token, 456 token_type: "Bearer".to_string(), 457 expires_in: 3600, 458 }), 459 )); 460 } 461 462 // Check if code was already used 463 if data.was_already_used { 464 tracing::warn!("Authorization code {} was already used", request.code); 465 return Err(( 466 StatusCode::BAD_REQUEST, 467 Json(ErrorResponse::new("invalid_grant")), 468 )); 469 } 470 471 // Validate session status 472 if data.session_status != SessionStatus::Verified { 473 tracing::warn!( 474 "Session {} not in verified status: {:?}", 475 data.session_id, 476 data.session_status 477 ); 478 return Err(( 479 StatusCode::BAD_REQUEST, 480 Json(ErrorResponse::new("invalid_grant")), 481 )); 482 } 483 484 // Generate new token and complete session 485 let access_token = crypto::generate_token(state.config.crypto.token_bytes); 486 let token = crate::db::tokens::create_token_and_complete_session( 487 &state.pool, 488 data.session_id, 489 &access_token, 490 3600, // 1 hour 491 ) 492 .await 493 .map_err(|e| { 494 tracing::error!("Failed to create token: {}", e); 495 ( 496 StatusCode::INTERNAL_SERVER_ERROR, 497 Json(ErrorResponse::new("internal_error")), 498 ) 499 })?; 500 501 tracing::info!("Token created for session {}", data.session_id); 502 503 Ok(( 504 StatusCode::OK, 505 Json(TokenResponse { 506 access_token: token.token, 507 token_type: "Bearer".to_string(), 508 expires_in: 3600, 509 }), 510 )) 511 } 512 513 // GET /info 514 pub async fn info( 515 State(state): State<AppState>, 516 headers: axum::http::HeaderMap, 517 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 518 tracing::info!("Info request received"); 519 520 // Extract token from Authorization header 521 let auth_header = headers 522 .get(header::AUTHORIZATION) 523 .and_then(|h| h.to_str().ok()); 524 525 let token = match auth_header { 526 Some(h) if h.starts_with("Bearer ") => &h[7..], 527 _ => { 528 tracing::warn!("Missing or malformed Authorization header"); 529 return Err(( 530 StatusCode::UNAUTHORIZED, 531 Json(ErrorResponse::new("invalid_token")), 532 )); 533 } 534 }; 535 536 // Fetch token with session data (idempotent) 537 let token_data = crate::db::tokens::get_token_with_session(&state.pool, token) 538 .await 539 .map_err(|e| { 540 tracing::error!("DB error in info: {}", e); 541 ( 542 StatusCode::INTERNAL_SERVER_ERROR, 543 Json(ErrorResponse::new("internal_error")), 544 ) 545 })?; 546 547 let data = match token_data { 548 Some(d) => d, 549 None => { 550 tracing::warn!("Token not found or expired"); 551 return Err(( 552 StatusCode::UNAUTHORIZED, 553 Json(ErrorResponse::new("invalid_token")), 554 )); 555 } 556 }; 557 558 // Validate token 559 if data.revoked { 560 tracing::warn!("Token {} is revoked", data.token_id); 561 return Err(( 562 StatusCode::UNAUTHORIZED, 563 Json(ErrorResponse::new("invalid_token")), 564 )); 565 } 566 567 if data.session_status != SessionStatus::Completed { 568 tracing::warn!("Session not completed: {:?}", data.session_status); 569 return Err(( 570 StatusCode::UNAUTHORIZED, 571 Json(ErrorResponse::new("invalid_token")), 572 )); 573 } 574 575 // Return verifiable credential 576 let credential = VerifiableCredential { 577 data: data.verifiable_credential.unwrap_or(json!({})), 578 }; 579 580 tracing::info!("Returning credential for token {}", data.token_id); 581 582 Ok((StatusCode::OK, Json(credential))) 583 } 584 585 // POST /notification 586 // Always returns 200 OK to Swiyu - errors are logged internally 587 pub async fn notification_webhook( 588 State(state): State<AppState>, 589 Json(webhook): Json<NotificationRequest>, 590 ) -> impl IntoResponse { 591 tracing::info!( 592 "Webhook received from Swiyu: verification_id={}, timestamp={}", 593 webhook.verification_id, 594 webhook.timestamp 595 ); 596 597 // Lookup session by request_id (verification_id) 598 let session_data = match crate::db::sessions::get_session_for_notification( 599 &state.pool, 600 &webhook.verification_id.to_string(), 601 ) 602 .await 603 { 604 Ok(Some(data)) => data, 605 Ok(None) => { 606 tracing::warn!( 607 "Session not found for verification_id: {}", 608 webhook.verification_id 609 ); 610 return StatusCode::OK; 611 } 612 Err(e) => { 613 tracing::error!("DB error looking up session: {}", e); 614 return StatusCode::OK; 615 } 616 }; 617 618 // Validate session status 619 if session_data.status != SessionStatus::Authorized { 620 tracing::warn!( 621 "Session {} not in authorized status: {:?}", 622 session_data.session_id, 623 session_data.status 624 ); 625 return StatusCode::OK; 626 } 627 628 // Call Swiyu verifier to get verification result 629 let verifier_url = format!( 630 "{}{}/{}", 631 session_data.verifier_url, 632 session_data.verifier_management_api_path, 633 webhook.verification_id 634 ); 635 636 tracing::debug!("Fetching verification result from: {}", verifier_url); 637 638 let verifier_response = match state.http_client.get(&verifier_url).send().await { 639 Ok(resp) => resp, 640 Err(e) => { 641 tracing::error!("Failed to call Swiyu verifier: {}", e); 642 return StatusCode::OK; 643 } 644 }; 645 646 if !verifier_response.status().is_success() { 647 let status = verifier_response.status(); 648 tracing::error!("Swiyu verifier returned error: {}", status); 649 return StatusCode::OK; 650 } 651 652 let swiyu_result: SwiyuManagementResponse = match verifier_response.json().await { 653 Ok(r) => r, 654 Err(e) => { 655 tracing::error!("Failed to parse Swiyu response: {}", e); 656 return StatusCode::OK; 657 } 658 }; 659 660 // Determine status based on verification result 661 let (new_status, status_str) = match swiyu_result.state { 662 SwiyuVerificationStatus::Success => (SessionStatus::Verified, "verified"), 663 SwiyuVerificationStatus::Failed => (SessionStatus::Failed, "failed"), 664 SwiyuVerificationStatus::Pending => { 665 tracing::info!( 666 "Verification {} still pending, ignoring webhook", 667 webhook.verification_id 668 ); 669 return StatusCode::OK; 670 } 671 }; 672 673 // Generate authorization code 674 let authorization_code = crypto::generate_authorization_code(state.config.crypto.authorization_code_bytes); 675 676 // Construct GET request URL: redirect_uri?code=XXX&state=YYY 677 let redirect_uri = session_data.redirect_uri.as_ref() 678 .unwrap_or(&session_data.webhook_url); 679 let oauth_state = session_data.state.as_deref().unwrap_or(""); 680 681 let webhook_url = format!( 682 "{}?code={}&state={}", 683 redirect_uri, 684 authorization_code, 685 oauth_state 686 ); 687 688 // Update session, create auth code, and queue webhook (GET request, empty body) 689 match crate::db::sessions::verify_session_and_queue_notification( 690 &state.pool, 691 session_data.session_id, 692 new_status, 693 &authorization_code, 694 10, // 10 minutes for auth code expiry 695 session_data.client_id, 696 &webhook_url, 697 "", // Empty body for GET request 698 swiyu_result.wallet_response.as_ref(), 699 ) 700 .await 701 { 702 Ok(code) => { 703 tracing::info!( 704 "Session {} updated to {}, auth code created, webhook queued", 705 session_data.session_id, 706 status_str 707 ); 708 tracing::debug!("Generated authorization code: {}", code); 709 } 710 Err(e) => { 711 tracing::error!("Failed to update session and queue notification: {}", e); 712 } 713 } 714 715 StatusCode::OK 716 } 717 718 #[cfg(test)] 719 mod tests { 720 use super::*; 721 722 #[test] 723 fn test_build_presentation_definition_single_attribute() { 724 let scope = "age_over_18"; 725 let pd = build_presentation_definition(scope); 726 727 // Verify structure 728 assert!(!pd.id.is_empty()); 729 assert_eq!(pd.name, Some("Over 18 Verification".to_string())); 730 assert_eq!(pd.input_descriptors.len(), 1); 731 732 // Verify fields: vct filter + requested attribute 733 let fields = &pd.input_descriptors[0].constraints.fields; 734 assert_eq!(fields.len(), 2); 735 736 // First field is vct with filter 737 assert_eq!(fields[0].path, vec!["$.vct"]); 738 assert!(fields[0].filter.is_some()); 739 let filter = fields[0].filter.as_ref().unwrap(); 740 assert_eq!(filter.filter_type, "string"); 741 assert_eq!(filter.const_value, Some("betaid-sdjwt".to_string())); 742 743 // Second field is the requested attribute 744 assert_eq!(fields[1].path, vec!["$.age_over_18"]); 745 assert!(fields[1].filter.is_none()); 746 } 747 748 #[test] 749 fn test_build_presentation_definition_multiple_attributes() { 750 let scope = "first_name last_name date_of_birth"; 751 let pd = build_presentation_definition(scope); 752 753 let fields = &pd.input_descriptors[0].constraints.fields; 754 // vct + 3 attributes = 4 fields 755 assert_eq!(fields.len(), 4); 756 757 assert_eq!(fields[0].path, vec!["$.vct"]); // vct first 758 assert_eq!(fields[1].path, vec!["$.first_name"]); 759 assert_eq!(fields[2].path, vec!["$.last_name"]); 760 assert_eq!(fields[3].path, vec!["$.date_of_birth"]); 761 } 762 763 #[test] 764 fn test_build_presentation_definition_extra_whitespace() { 765 let scope = "first_name last_name"; 766 let pd = build_presentation_definition(scope); 767 768 let fields = &pd.input_descriptors[0].constraints.fields; 769 // split_whitespace handles multiple spaces correctly 770 // vct + 2 attributes = 3 fields 771 assert_eq!(fields.len(), 3); 772 assert_eq!(fields[0].path, vec!["$.vct"]); 773 assert_eq!(fields[1].path, vec!["$.first_name"]); 774 assert_eq!(fields[2].path, vec!["$.last_name"]); 775 } 776 777 #[test] 778 fn test_build_presentation_definition_empty_scope() { 779 let scope = ""; 780 let pd = build_presentation_definition(scope); 781 782 let fields = &pd.input_descriptors[0].constraints.fields; 783 // Only vct field when scope is empty 784 assert_eq!(fields.len(), 1); 785 assert_eq!(fields[0].path, vec!["$.vct"]); 786 } 787 788 #[test] 789 fn test_build_presentation_definition_no_top_level_format() { 790 let scope = "age_over_18"; 791 let pd = build_presentation_definition(scope); 792 793 // No format at top level 794 assert!(pd.format.is_none()); 795 } 796 797 #[test] 798 fn test_build_presentation_definition_input_descriptor_structure() { 799 let scope = "age_over_18"; 800 let pd = build_presentation_definition(scope); 801 802 let descriptor = &pd.input_descriptors[0]; 803 804 // Verify descriptor has valid UUID 805 assert!(!descriptor.id.is_empty()); 806 807 // Verify no name/purpose at descriptor level 808 assert!(descriptor.name.is_none()); 809 assert!(descriptor.purpose.is_none()); 810 811 // Verify format is specified at descriptor level 812 assert!(descriptor.format.is_some()); 813 let format = descriptor.format.as_ref().unwrap(); 814 assert!(format.contains_key("vc+sd-jwt")); 815 let alg = &format["vc+sd-jwt"]; 816 assert_eq!(alg.sd_jwt_alg_values, vec!["ES256"]); 817 assert_eq!(alg.kb_jwt_alg_values, vec!["ES256"]); 818 } 819 }