handlers.rs (49052B)
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 use std::collections::HashSet; 11 12 use crate::{crypto, db::sessions::SessionStatus, models::*, state::AppState}; 13 14 const HTML_CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"; 15 16 fn is_safe_url(url: &str) -> bool { 17 url.to_lowercase().starts_with("https://") 18 } 19 20 fn is_safe_deeplink(url: &str) -> bool { 21 if url.is_empty() { 22 return true; 23 } 24 let url_lower = url.to_lowercase(); 25 url_lower.starts_with("swiyu-verify://") || url_lower.starts_with("https://") 26 } 27 28 fn json_encode_string(s: &str) -> String { 29 serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()) 30 } 31 32 fn parse_accepted_issuer_dids(raw: &str) -> Result<Vec<String>, &'static str> { 33 let trimmed = raw.trim(); 34 if trimmed.is_empty() { 35 return Err("ACCEPTED_ISSUER_DIDS must contain at least one DID"); 36 } 37 38 let trimmed = trimmed.strip_prefix('{').unwrap_or(trimmed); 39 let trimmed = trimmed.strip_suffix('}').unwrap_or(trimmed); 40 41 let dids: Vec<String> = trimmed 42 .split(',') 43 .map(|s| s.trim()) 44 .filter(|s| !s.is_empty()) 45 .map(|s| s.to_string()) 46 .collect(); 47 48 if dids.is_empty() { 49 return Err("ACCEPTED_ISSUER_DIDS must contain at least one DID"); 50 } 51 52 Ok(dids) 53 } 54 55 fn validate_scope_claims(scope: &str, valid_claims: &HashSet<String>) -> Result<(), String> { 56 for claim in scope.split_whitespace() { 57 if !valid_claims.contains(claim) { 58 return Err(format!("invalid claim in scope: {}", claim)); 59 } 60 } 61 Ok(()) 62 } 63 64 pub async fn config( 65 State(state): State<AppState>, 66 ) -> impl IntoResponse { 67 tracing::info!("Config request received"); 68 69 let mut claims: Vec<String> = state.config.vc.vc_claims.iter().cloned().collect(); 70 claims.sort(); 71 72 PrettyJson(ConfigResponse { 73 name: "kych-oauth2-gateway".to_string(), 74 version: env!("CARGO_PKG_VERSION").to_string(), 75 status: "healthy".to_string(), 76 vc_type: state.config.vc.vc_type.clone(), 77 vc_format: state.config.vc.vc_format.clone(), 78 vc_algorithms: state.config.vc.vc_algorithms.clone(), 79 vc_claims: claims, 80 }) 81 } 82 83 // POST /setup/{clientId} 84 pub async fn setup( 85 State(state): State<AppState>, 86 Path(client_id): Path<String>, 87 headers: axum::http::HeaderMap, 88 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 89 tracing::info!("Setup request for client: {}", client_id); 90 91 let auth_header = headers 92 .get(header::AUTHORIZATION) 93 .and_then(|h| h.to_str().ok()); 94 95 let bearer_token = match auth_header { 96 Some(h) if h.starts_with("Bearer ") => &h[7..], 97 _ => { 98 tracing::warn!( 99 "Missing or malformed Authorization header for client: {}", 100 client_id 101 ); 102 return Err(( 103 StatusCode::UNAUTHORIZED, 104 Json(ErrorResponse::new("unauthorized")), 105 )); 106 } 107 }; 108 109 let secret_hash = crate::db::clients::get_client_secret_hash(&state.pool, &client_id) 110 .await 111 .map_err(|e| { 112 tracing::error!("DB error fetching client secret: {}", e); 113 ( 114 StatusCode::INTERNAL_SERVER_ERROR, 115 Json(ErrorResponse::new("internal_error")), 116 ) 117 })?; 118 119 let secret_hash = match secret_hash { 120 Some(hash) => hash, 121 None => { 122 tracing::warn!("Client not found: {}", client_id); 123 return Err(( 124 StatusCode::UNAUTHORIZED, 125 Json(ErrorResponse::new("unauthorized")), 126 )); 127 } 128 }; 129 130 let is_valid = bcrypt::verify(bearer_token, &secret_hash).map_err(|e| { 131 tracing::error!("Bcrypt verification error: {}", e); 132 ( 133 StatusCode::INTERNAL_SERVER_ERROR, 134 Json(ErrorResponse::new("internal_error")), 135 ) 136 })?; 137 138 if !is_valid { 139 tracing::warn!("Invalid bearer token for client: {}", client_id); 140 return Err(( 141 StatusCode::UNAUTHORIZED, 142 Json(ErrorResponse::new("unauthorized")), 143 )); 144 } 145 146 let nonce = crypto::generate_nonce(state.config.crypto.nonce_bytes); 147 148 tracing::debug!("Generated nonce: {}", nonce); 149 150 let session = crate::db::sessions::create_session(&state.pool, &client_id, &nonce, 15) 151 .await 152 .map_err(|e| { 153 tracing::error!("Failed to create session: {}", e); 154 ( 155 StatusCode::INTERNAL_SERVER_ERROR, 156 Json(ErrorResponse::new("internal_error")), 157 ) 158 })?; 159 160 let session = match session { 161 Some(s) => s, 162 None => { 163 tracing::warn!("Client not found: {}", client_id); 164 return Err(( 165 StatusCode::NOT_FOUND, 166 Json(ErrorResponse::new("client_not_found")), 167 )); 168 } 169 }; 170 171 tracing::info!( 172 "Created session {} for client {} with nonce {}", 173 session.id, 174 client_id, 175 nonce 176 ); 177 178 Ok((StatusCode::OK, Json(SetupResponse { nonce }))) 179 } 180 181 // GET /authorize/{nonce}?response_type=code&client_id={client_id}&redirect_uri={uri}&state={state} 182 pub async fn authorize( 183 State(state): State<AppState>, 184 Path(nonce): Path<String>, 185 Query(params): Query<AuthorizeQuery>, 186 headers: axum::http::HeaderMap, 187 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 188 tracing::info!( 189 "Authorize request for client: {}, nonce: {}, state: {}, redirect_uri: {}, scope: {}", 190 params.client_id, 191 nonce, 192 params.state, 193 params.redirect_uri, 194 params.scope 195 ); 196 197 if params.response_type != "code" { 198 return Err(( 199 StatusCode::BAD_REQUEST, 200 Json(ErrorResponse::new("invalid_request")), 201 )); 202 } 203 204 let session_data = crate::db::sessions::get_session_for_authorize( 205 &state.pool, 206 &nonce, 207 ¶ms.client_id, 208 ¶ms.scope, 209 ¶ms.redirect_uri, 210 ¶ms.state, 211 ) 212 .await 213 .map_err(|e| { 214 tracing::error!("DB error in authorize: {}", e); 215 ( 216 StatusCode::INTERNAL_SERVER_ERROR, 217 Json(ErrorResponse::new("internal_error")), 218 ) 219 })?; 220 221 let data = match session_data { 222 Some(d) => d, 223 None => { 224 tracing::warn!("Session not found for nonce: {}", nonce); 225 return Err(( 226 StatusCode::NOT_FOUND, 227 Json(ErrorResponse::new("session_not_found")), 228 )); 229 } 230 }; 231 232 // Validate redirect_uri against client's registered URIs 233 let redirect_uri_valid = data 234 .allowed_redirect_uris 235 .as_ref() 236 .map(|uris| { 237 uris.split(',') 238 .map(|s| s.trim()) 239 .any(|uri| uri == params.redirect_uri) 240 }) 241 .unwrap_or(false); 242 243 if !redirect_uri_valid { 244 tracing::warn!( 245 "Invalid redirect_uri for client {}: {}", 246 params.client_id, 247 params.redirect_uri 248 ); 249 return Err(( 250 StatusCode::BAD_REQUEST, 251 Json(ErrorResponse::new("invalid_redirect_uri")), 252 )); 253 } 254 255 // Backend validation 256 if data.expires_at < Utc::now() { 257 tracing::warn!("Session expired: {}", data.session_id); 258 return Err(( 259 StatusCode::GONE, 260 Json(ErrorResponse::new("session_expired")), 261 )); 262 } 263 264 // Check status for idempotency 265 match data.status { 266 SessionStatus::Authorized => { 267 tracing::info!( 268 "Session {} already authorized, returning cached response", 269 data.session_id 270 ); 271 272 let verification_id = data 273 .request_id 274 .and_then(|id| uuid::Uuid::parse_str(&id).ok()) 275 .unwrap_or(uuid::Uuid::nil()); 276 277 let accept_html = headers 278 .get(header::ACCEPT) 279 .and_then(|h| h.to_str().ok()) 280 .map_or(false, |v| v.contains("text/html")); 281 282 if accept_html { 283 use askama::Template; 284 285 #[derive(Template)] 286 #[template(path = "authorize.html")] 287 struct AuthorizeTemplate { 288 verification_url: String, 289 verification_deeplink: String, 290 verification_id_json: String, 291 verification_url_json: String, 292 state_json: String, 293 } 294 295 let verification_url = data.verification_url.clone().unwrap_or_default(); 296 let verification_deeplink = data.verification_deeplink.clone().unwrap_or_default(); 297 298 if !is_safe_url(&verification_url) { 299 tracing::error!("Invalid verification_url scheme: {}", verification_url); 300 return Err(( 301 StatusCode::BAD_GATEWAY, 302 Json(ErrorResponse::new("invalid_verification_url")), 303 )); 304 } 305 306 if !is_safe_deeplink(&verification_deeplink) { 307 tracing::error!("Invalid verification_deeplink scheme: {}", verification_deeplink); 308 return Err(( 309 StatusCode::BAD_GATEWAY, 310 Json(ErrorResponse::new("invalid_verification_deeplink")), 311 )); 312 } 313 314 let template = AuthorizeTemplate { 315 verification_url: verification_url.clone(), 316 verification_deeplink, 317 verification_id_json: json_encode_string(&verification_id.to_string()), 318 verification_url_json: json_encode_string(&verification_url), 319 state_json: json_encode_string(¶ms.state), 320 }; 321 322 let html = template.render().map_err(|e| { 323 tracing::error!("Template render error: {}", e); 324 ( 325 StatusCode::INTERNAL_SERVER_ERROR, 326 Json(ErrorResponse::new("internal_error")), 327 ) 328 })?; 329 330 return Ok(( 331 StatusCode::OK, 332 [ 333 (header::CONTENT_TYPE, "text/html; charset=utf-8"), 334 (header::CONTENT_SECURITY_POLICY, HTML_CSP), 335 ], 336 html, 337 ).into_response()); 338 } 339 340 return Ok(PrettyJson(AuthorizeResponse { 341 verification_id, 342 verification_url: data.verification_url.clone().unwrap_or_default(), 343 verification_deeplink: data.verification_deeplink, 344 state: params.state.clone() 345 }).into_response()); 346 } 347 348 SessionStatus::Pending => { 349 // Proceed with authorization 350 } 351 352 _ => { 353 tracing::warn!( 354 "Session {} in invalid status: {:?}", 355 data.session_id, 356 data.status 357 ); 358 return Err(( 359 StatusCode::CONFLICT, 360 Json(ErrorResponse::new("invalid_session_status")), 361 )); 362 } 363 } 364 365 if let Some(allowed_scopes) = state.config.allowed_scopes.as_ref() { 366 let allowed_set: std::collections::HashSet<&str> = 367 allowed_scopes.iter().map(String::as_str).collect(); 368 let invalid_scopes: Vec<&str> = data 369 .scope 370 .split_whitespace() 371 .filter(|scope| !allowed_set.contains(*scope)) 372 .collect(); 373 374 if !invalid_scopes.is_empty() { 375 tracing::warn!( 376 "Rejected invalid scopes for client {}: {:?}", 377 params.client_id, 378 invalid_scopes 379 ); 380 return Err(( 381 StatusCode::BAD_REQUEST, 382 Json(ErrorResponse::new("invalid_scope")), 383 )); 384 } 385 } 386 387 if let Err(e) = validate_scope_claims(&data.scope, &state.config.vc.vc_claims) { 388 tracing::warn!( 389 "Rejected invalid scope claims for client {}: {}", 390 params.client_id, 391 e 392 ); 393 return Err(( 394 StatusCode::BAD_REQUEST, 395 Json(ErrorResponse::new(&e)), 396 )); 397 } 398 399 let dcql_query = build_dcql_query( 400 &data.scope, 401 &state.config.vc.vc_type, 402 &state.config.vc.vc_format, 403 ); 404 405 // Call Swiyu Verifier 406 let verifier_url = format!("{}{}", data.verifier_url, data.verifier_management_api_path); 407 408 let accepted_issuer_dids = match data.accepted_issuer_dids.as_deref() { 409 Some(raw) => parse_accepted_issuer_dids(raw).map_err(|message| { 410 tracing::error!("Invalid accepted issuer DIDs for client {}: {}", params.client_id, message); 411 ( 412 StatusCode::INTERNAL_SERVER_ERROR, 413 Json(ErrorResponse::new("invalid_accepted_issuer_dids")), 414 ) 415 })?, 416 None => { 417 tracing::error!( 418 "Accepted issuer DIDs not configured for client {}", 419 params.client_id 420 ); 421 return Err(( 422 StatusCode::INTERNAL_SERVER_ERROR, 423 Json(ErrorResponse::new("accepted_issuer_dids_not_configured")), 424 )); 425 } 426 }; 427 428 let verifier_request = SwiyuCreateVerificationRequest { 429 accepted_issuer_dids, 430 jwt_secured_authorization_request: Some(true), 431 response_mode: ResponseMode::DirectPost, 432 dcql_query, 433 configuration_override: ConfigurationOverride::default(), 434 }; 435 436 tracing::debug!( 437 "Swiyu verifier request: {}", 438 serde_json::to_string_pretty(&verifier_request).unwrap() 439 ); 440 tracing::debug!("Calling Swiyu verifier at: {}", verifier_url); 441 442 let verifier_response = state 443 .http_client 444 .post(&verifier_url) 445 .json(&verifier_request) 446 .send() 447 .await 448 .map_err(|e| { 449 tracing::error!("Failed to call Swiyu verifier: {}", e); 450 ( 451 StatusCode::BAD_GATEWAY, 452 Json(ErrorResponse::new("verifier_unavailable")), 453 ) 454 })?; 455 456 if !verifier_response.status().is_success() { 457 let status = verifier_response.status(); 458 let body = verifier_response.text().await.unwrap_or_default(); 459 tracing::error!("Swiyu verifier returned error {}: {}", status, body); 460 return Err(( 461 StatusCode::BAD_GATEWAY, 462 Json(ErrorResponse::new("verifier_error")), 463 )); 464 } 465 466 let swiyu_response: SwiyuManagementResponse = verifier_response.json().await.map_err(|e| { 467 tracing::error!("Failed to parse Swiyu response: {}", e); 468 ( 469 StatusCode::BAD_GATEWAY, 470 Json(ErrorResponse::new("verifier_invalid_response")), 471 ) 472 })?; 473 474 // Update session with verifier data 475 let result = crate::db::sessions::update_session_authorized( 476 &state.pool, 477 data.session_id, 478 &swiyu_response.verification_url, 479 swiyu_response.verification_deeplink.as_deref(), 480 &swiyu_response.id.to_string(), 481 swiyu_response.request_nonce.as_deref(), 482 ) 483 .await 484 .map_err(|e| { 485 tracing::error!("Failed to update session: {}", e); 486 ( 487 StatusCode::INTERNAL_SERVER_ERROR, 488 Json(ErrorResponse::new("internal_error")), 489 ) 490 })?; 491 492 tracing::info!( 493 "Session {} authorized, verification_id: {}", 494 data.session_id, 495 swiyu_response.id 496 ); 497 498 let accept_html = headers 499 .get(header::ACCEPT) 500 .and_then(|h| h.to_str().ok()) 501 .map_or(false, |v| v.contains("text/html")); 502 503 let verification_url = result.verification_url.clone(); 504 let verification_deeplink = swiyu_response.verification_deeplink.clone().unwrap_or_default(); 505 506 if !is_safe_url(&verification_url) { 507 tracing::error!("Invalid verification_url scheme: {}", verification_url); 508 return Err(( 509 StatusCode::BAD_GATEWAY, 510 Json(ErrorResponse::new("invalid_verification_url")), 511 )); 512 } 513 514 if !is_safe_deeplink(&verification_deeplink) { 515 tracing::error!("Invalid verification_deeplink scheme: {}", verification_deeplink); 516 return Err(( 517 StatusCode::BAD_GATEWAY, 518 Json(ErrorResponse::new("invalid_verification_deeplink")), 519 )); 520 } 521 522 if accept_html { 523 use askama::Template; 524 525 #[derive(Template)] 526 #[template(path = "authorize.html")] 527 struct AuthorizeTemplate { 528 verification_url: String, 529 verification_deeplink: String, 530 verification_id_json: String, 531 verification_url_json: String, 532 state_json: String, 533 } 534 535 let template = AuthorizeTemplate { 536 verification_url: verification_url.clone(), 537 verification_deeplink: verification_deeplink.clone(), 538 verification_id_json: json_encode_string(&swiyu_response.id.to_string()), 539 verification_url_json: json_encode_string(&verification_url), 540 state_json: json_encode_string(¶ms.state), 541 }; 542 543 let html = template.render().map_err(|e| { 544 tracing::error!("Template render error: {}", e); 545 ( 546 StatusCode::INTERNAL_SERVER_ERROR, 547 Json(ErrorResponse::new("internal_error")), 548 ) 549 })?; 550 551 return Ok(( 552 StatusCode::OK, 553 [ 554 (header::CONTENT_TYPE, "text/html; charset=utf-8"), 555 (header::CONTENT_SECURITY_POLICY, HTML_CSP), 556 ], 557 html, 558 ).into_response()); 559 } 560 561 Ok(PrettyJson(AuthorizeResponse { 562 verification_id: swiyu_response.id, 563 verification_url, 564 verification_deeplink: swiyu_response.verification_deeplink, 565 state: params.state.clone() 566 }).into_response()) 567 } 568 569 /// Build the DCQL query for a verification. 570 /// 571 /// The OAuth 2.0 scope is the list of claims to disclose: one claims query per 572 /// scope token, each a single-element claims path pointer, since every claim of 573 /// the Beta-ID credential is top-level. `vc_type` constrains the credential's 574 /// `vct`, so a wallet holding several credentials can tell which one is asked 575 /// for. 576 /// 577 /// A scope that names no claim at all yields a credential query without a 578 /// `claims` member -- the verifier rejects an empty `claims` array, and leaving 579 /// it out means "no claim in particular", which is what an empty scope asks 580 /// for. 581 fn build_dcql_query(scope: &str, vc_type: &str, vc_format: &str) -> DcqlQuery { 582 let attributes: Vec<&str> = scope.split_whitespace().collect(); 583 584 tracing::debug!("Building DCQL query for attributes: {:?}", attributes); 585 586 let claims: Option<Vec<DcqlClaim>> = if attributes.is_empty() { 587 None 588 } else { 589 Some( 590 attributes 591 .iter() 592 .map(|attr| DcqlClaim { 593 path: vec![(*attr).to_string()], 594 }) 595 .collect(), 596 ) 597 }; 598 599 DcqlQuery { 600 credentials: vec![DcqlCredential { 601 id: DCQL_CREDENTIAL_ID.to_string(), 602 format: vc_format.to_string(), 603 meta: DcqlCredentialMeta { 604 vct_values: vec![vc_type.to_string()], 605 }, 606 claims, 607 require_cryptographic_holder_binding: true, 608 }], 609 } 610 } 611 612 /// Pick the disclosed claims out of the verifier's `wallet_response` and return 613 /// them as the flat `{claim: value}` object that `GET /info` is documented to 614 /// serve (see `core/api-kych.rst`). 615 /// 616 /// Since 5.0.0 the claims arrive under `dcql_response`, keyed first by the 617 /// credential query id and then by claim id or claims path, with each value 618 /// wrapped in an array. The deprecated `credential_subject_data` is still sent 619 /// by default and is read as a fallback; it is keyed the same way in recent 620 /// releases but was a flat map of claims in older ones, so both shapes are 621 /// accepted. 622 /// 623 /// `None` means there was nothing to extract -- a failed verification, for 624 /// instance, where `wallet_response` carries `error_code` instead of claims. 625 /// The caller then stores the response unchanged, so that the reason survives 626 /// in the session for the operator to look at. 627 fn extract_disclosed_claims(wallet_response: &serde_json::Value) -> Option<serde_json::Value> { 628 let per_credential = wallet_response 629 .get("dcql_response") 630 .and_then(|v| v.as_object()) 631 .or_else(|| { 632 wallet_response 633 .get("credential_subject_data") 634 .and_then(|v| v.as_object()) 635 })?; 636 637 // Our own credential query first; fall back to the sole entry, so that a 638 // verifier which keys the map differently still works. 639 let claims = per_credential 640 .get(DCQL_CREDENTIAL_ID) 641 .or_else(|| { 642 if per_credential.len() == 1 { 643 per_credential.values().next() 644 } else { 645 None 646 } 647 }) 648 .and_then(|v| v.as_object()); 649 650 let claims = match claims { 651 Some(claims) => claims, 652 // Older verifiers put the claims directly into 653 // `credential_subject_data` without a credential-query level. That is 654 // this map, as long as it holds no nested objects. 655 None if per_credential.values().all(|v| !v.is_object()) => per_credential, 656 None => { 657 tracing::warn!( 658 "No claims for credential query '{}' in verifier response", 659 DCQL_CREDENTIAL_ID 660 ); 661 return None; 662 } 663 }; 664 665 let flattened: serde_json::Map<String, serde_json::Value> = claims 666 .iter() 667 .map(|(claim, value)| { 668 // DCQL reports every value as an array, since a claim may occur 669 // more than once; a single value is unwrapped to keep the /info 670 // contract unchanged. 671 let value = match value.as_array() { 672 Some(values) if values.len() == 1 => values[0].clone(), 673 _ => value.clone(), 674 }; 675 (claim.clone(), value) 676 }) 677 .collect(); 678 679 if flattened.is_empty() { 680 return None; 681 } 682 683 Some(serde_json::Value::Object(flattened)) 684 } 685 686 // POST /token 687 pub async fn token( 688 State(state): State<AppState>, 689 Form(request): Form<TokenRequest>, 690 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 691 tracing::info!("Token request for code: {}", request.code); 692 693 // Validate grant_type 694 if request.grant_type != "authorization_code" { 695 return Err(( 696 StatusCode::BAD_REQUEST, 697 Json(ErrorResponse::new("unsupported_grant_type")), 698 )); 699 } 700 701 // Authenticate client 702 let client = crate::db::clients::authenticate_client( 703 &state.pool, 704 &request.client_id, 705 &request.client_secret, 706 ) 707 .await 708 .map_err(|e| { 709 tracing::error!("DB error during client authentication: {}", e); 710 ( 711 StatusCode::INTERNAL_SERVER_ERROR, 712 Json(ErrorResponse::new("internal_error")), 713 ) 714 })?; 715 716 let client = match client { 717 Some(c) => c, 718 None => { 719 tracing::warn!("Client authentication failed for {}", request.client_id); 720 return Err(( 721 StatusCode::UNAUTHORIZED, 722 Json(ErrorResponse::new("invalid_client")), 723 )); 724 } 725 }; 726 727 // Fetch code (idempotent) 728 let code_data = 729 crate::db::authorization_codes::get_code_for_token_exchange(&state.pool, &request.code) 730 .await 731 .map_err(|e| { 732 tracing::error!("DB error in token exchange: {}", e); 733 ( 734 StatusCode::INTERNAL_SERVER_ERROR, 735 Json(ErrorResponse::new("internal_error")), 736 ) 737 })?; 738 739 let data = match code_data { 740 Some(d) => d, 741 None => { 742 tracing::warn!("Authorization code not found or expired: {}", request.code); 743 return Err(( 744 StatusCode::BAD_REQUEST, 745 Json(ErrorResponse::new("invalid_grant")), 746 )); 747 } 748 }; 749 750 // Verify the authorization code belongs to the client 751 if data.client_id != client.id { 752 tracing::warn!( 753 "Authorization code {} does not belong to the client {}", 754 request.code, 755 request.client_id 756 ); 757 758 return Err(( 759 StatusCode::BAD_REQUEST, 760 Json(ErrorResponse::new("invalid_grant")), 761 )); 762 } 763 764 // RFC 6749 Section 4.1.3: If redirect_uri was included in the authorization request, 765 // it MUST be present and match exactly in the token request 766 match &data.redirect_uri { 767 Some(stored_uri) if stored_uri != &request.redirect_uri => { 768 tracing::warn!( 769 "redirect_uri mismatch for code {}: expected '{}', got '{}'", 770 request.code, 771 stored_uri, 772 request.redirect_uri 773 ); 774 return Err(( 775 StatusCode::BAD_REQUEST, 776 Json(ErrorResponse::new("invalid_grant")), 777 )); 778 } 779 None => { 780 tracing::warn!( 781 "redirect_uri provided in token request but was not stored during authorization for code {}", 782 request.code 783 ); 784 return Err(( 785 StatusCode::BAD_REQUEST, 786 Json(ErrorResponse::new("invalid_grant")), 787 )); 788 } 789 Some(_) => {} 790 } 791 792 // Check for existing token 793 if let Some(existing_token) = data.existing_token { 794 tracing::info!( 795 "Token already exists for session {}, returning cached response", 796 data.session_id 797 ); 798 return Ok(( 799 StatusCode::OK, 800 Json(TokenResponse { 801 access_token: existing_token, 802 token_type: "Bearer".to_string(), 803 expires_in: 3600, 804 }), 805 )); 806 } 807 808 // Check if code was already used 809 if data.was_already_used { 810 tracing::warn!("Authorization code {} was already used", request.code); 811 return Err(( 812 StatusCode::BAD_REQUEST, 813 Json(ErrorResponse::new("invalid_grant")), 814 )); 815 } 816 817 // Validate session status 818 if data.session_status != SessionStatus::Verified { 819 tracing::warn!( 820 "Session {} not in verified status: {:?}", 821 data.session_id, 822 data.session_status 823 ); 824 return Err(( 825 StatusCode::BAD_REQUEST, 826 Json(ErrorResponse::new("invalid_grant")), 827 )); 828 } 829 830 // Generate new token and complete session 831 let access_token = crypto::generate_token(state.config.crypto.token_bytes); 832 let token = crate::db::tokens::create_token_and_complete_session( 833 &state.pool, 834 data.session_id, 835 &access_token, 836 3600, // 1 hour 837 ) 838 .await 839 .map_err(|e| { 840 tracing::error!("Failed to create token: {}", e); 841 ( 842 StatusCode::INTERNAL_SERVER_ERROR, 843 Json(ErrorResponse::new("internal_error")), 844 ) 845 })?; 846 847 tracing::info!("Token created for session {}", data.session_id); 848 849 Ok(( 850 StatusCode::OK, 851 Json(TokenResponse { 852 access_token: token.token, 853 token_type: "Bearer".to_string(), 854 expires_in: 3600, 855 }), 856 )) 857 } 858 859 // GET /info 860 pub async fn info( 861 State(state): State<AppState>, 862 headers: axum::http::HeaderMap, 863 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 864 tracing::info!("Info request received"); 865 866 // Extract token from Authorization header 867 let auth_header = headers 868 .get(header::AUTHORIZATION) 869 .and_then(|h| h.to_str().ok()); 870 871 let token = match auth_header { 872 Some(h) if h.starts_with("Bearer ") => &h[7..], 873 _ => { 874 tracing::warn!("Missing or malformed Authorization header"); 875 return Err(( 876 StatusCode::UNAUTHORIZED, 877 Json(ErrorResponse::new("invalid_token")), 878 )); 879 } 880 }; 881 882 // Fetch token with session data (idempotent) 883 let token_data = crate::db::tokens::get_token_with_session(&state.pool, token) 884 .await 885 .map_err(|e| { 886 tracing::error!("DB error in info: {}", e); 887 ( 888 StatusCode::INTERNAL_SERVER_ERROR, 889 Json(ErrorResponse::new("internal_error")), 890 ) 891 })?; 892 893 let data = match token_data { 894 Some(d) => d, 895 None => { 896 tracing::warn!("Token not found or expired"); 897 return Err(( 898 StatusCode::UNAUTHORIZED, 899 Json(ErrorResponse::new("invalid_token")), 900 )); 901 } 902 }; 903 904 // Validate token 905 if data.revoked { 906 tracing::warn!("Token {} is revoked", data.token_id); 907 return Err(( 908 StatusCode::UNAUTHORIZED, 909 Json(ErrorResponse::new("invalid_token")), 910 )); 911 } 912 913 if data.session_status != SessionStatus::Completed { 914 tracing::warn!("Session not completed: {:?}", data.session_status); 915 return Err(( 916 StatusCode::UNAUTHORIZED, 917 Json(ErrorResponse::new("invalid_token")), 918 )); 919 } 920 921 // Return verifiable credential 922 let credential = VerifiableCredential { 923 data: data.verifiable_credential.unwrap_or(json!({})), 924 }; 925 926 tracing::info!("Returning credential for token {}", data.token_id); 927 928 Ok((StatusCode::OK, Json(credential))) 929 } 930 931 // POST /notification 932 // Always returns 200 OK to Swiyu - errors are logged internally 933 pub async fn notification_webhook( 934 State(state): State<AppState>, 935 Json(webhook): Json<NotificationRequest>, 936 ) -> impl IntoResponse { 937 tracing::info!( 938 "Webhook received from Swiyu: verification_id={}, timestamp={}", 939 webhook.verification_id, 940 webhook.timestamp 941 ); 942 943 // Lookup session by request_id (verification_id) 944 let session_data = match crate::db::sessions::get_session_for_notification( 945 &state.pool, 946 &webhook.verification_id.to_string(), 947 ) 948 .await 949 { 950 Ok(Some(data)) => data, 951 Ok(None) => { 952 tracing::warn!( 953 "Session not found for verification_id: {}", 954 webhook.verification_id 955 ); 956 return StatusCode::OK; 957 } 958 Err(e) => { 959 tracing::error!("DB error looking up session: {}", e); 960 return StatusCode::OK; 961 } 962 }; 963 964 // Validate session status 965 if session_data.status != SessionStatus::Authorized { 966 tracing::warn!( 967 "Session {} not in authorized status: {:?}", 968 session_data.session_id, 969 session_data.status 970 ); 971 return StatusCode::OK; 972 } 973 974 // Call Swiyu verifier to get verification result 975 let verifier_url = format!( 976 "{}{}/{}", 977 session_data.verifier_url, 978 session_data.verifier_management_api_path, 979 webhook.verification_id 980 ); 981 982 tracing::debug!("Fetching verification result from: {}", verifier_url); 983 984 let verifier_response = match state.http_client.get(&verifier_url).send().await { 985 Ok(resp) => resp, 986 Err(e) => { 987 tracing::error!("Failed to call Swiyu verifier: {}", e); 988 return StatusCode::OK; 989 } 990 }; 991 992 if !verifier_response.status().is_success() { 993 let status = verifier_response.status(); 994 tracing::error!("Swiyu verifier returned error: {}", status); 995 return StatusCode::OK; 996 } 997 998 let swiyu_result: SwiyuManagementResponse = match verifier_response.json().await { 999 Ok(r) => r, 1000 Err(e) => { 1001 tracing::error!("Failed to parse Swiyu response: {}", e); 1002 return StatusCode::OK; 1003 } 1004 }; 1005 1006 // Determine status based on verification result 1007 let (new_status, status_str) = match swiyu_result.state { 1008 SwiyuVerificationStatus::Success => (SessionStatus::Verified, "verified"), 1009 SwiyuVerificationStatus::Failed => (SessionStatus::Failed, "failed"), 1010 SwiyuVerificationStatus::Pending => { 1011 tracing::info!( 1012 "Verification {} still pending, ignoring webhook", 1013 webhook.verification_id 1014 ); 1015 return StatusCode::OK; 1016 } 1017 }; 1018 1019 // What ends up in the session as the verifiable credential, and is handed 1020 // to the client by /info: the disclosed claims on success, and otherwise 1021 // the verifier's response unchanged, which is where its error_code is. 1022 let credential = swiyu_result.wallet_response.as_ref().map(|response| { 1023 match extract_disclosed_claims(response) { 1024 Some(claims) => claims, 1025 None => { 1026 tracing::warn!( 1027 "No disclosed claims in verifier response for verification {}, storing it unchanged", 1028 webhook.verification_id 1029 ); 1030 response.clone() 1031 } 1032 } 1033 }); 1034 1035 // Generate authorization code 1036 let authorization_code = crypto::generate_authorization_code(state.config.crypto.authorization_code_bytes); 1037 1038 // Construct GET request URL: redirect_uri?code=XXX&state=YYY 1039 let auth_code_ttl = state.config.crypto.authorization_code_ttl_minutes; 1040 1041 // Update session and create auth code 1042 match crate::db::sessions::verify_session_and_issue_code( 1043 &state.pool, 1044 session_data.session_id, 1045 new_status, 1046 &authorization_code, 1047 auth_code_ttl, 1048 session_data.client_id, 1049 "", // Empty body for GET request 1050 credential.as_ref(), 1051 ) 1052 .await 1053 { 1054 Ok(code) => { 1055 tracing::info!( 1056 "Session {} updated to {}, auth code created", 1057 session_data.session_id, 1058 status_str 1059 ); 1060 tracing::debug!("Generated authorization code: {}", code); 1061 } 1062 Err(e) => { 1063 tracing::error!("Failed to update session with authorization code: {}", e); 1064 } 1065 } 1066 1067 StatusCode::OK 1068 } 1069 1070 pub async fn status( 1071 State(state): State<AppState>, 1072 Path(verification_id): Path<String>, 1073 Query(params): Query<crate::models::StatusQuery>, 1074 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 1075 tracing::info!( 1076 "Status check for verification_id: {}, state: {}", 1077 verification_id, 1078 params.state 1079 ); 1080 1081 let session_data = crate::db::sessions::get_session_for_status( 1082 &state.pool, 1083 &verification_id, 1084 ) 1085 .await 1086 .map_err(|e| { 1087 tracing::error!("DB error in status: {}", e); 1088 ( 1089 StatusCode::INTERNAL_SERVER_ERROR, 1090 Json(ErrorResponse::new("internal_error")), 1091 ) 1092 })?; 1093 1094 let data = match session_data { 1095 Some(d) => d, 1096 None => { 1097 tracing::warn!("Session not found for verification_id: {}", verification_id); 1098 return Err(( 1099 StatusCode::NOT_FOUND, 1100 Json(ErrorResponse::new("session_not_found")), 1101 )); 1102 } 1103 }; 1104 1105 if data.state.as_deref() != Some(¶ms.state) { 1106 tracing::warn!( 1107 "State mismatch for verification_id: {} (expected: {:?}, got: {})", 1108 verification_id, 1109 data.state, 1110 params.state 1111 ); 1112 return Err(( 1113 StatusCode::FORBIDDEN, 1114 Json(ErrorResponse::new("invalid_state")), 1115 )); 1116 } 1117 1118 let status_str = match data.status { 1119 crate::db::sessions::SessionStatus::Pending => "pending", 1120 crate::db::sessions::SessionStatus::Authorized => "authorized", 1121 crate::db::sessions::SessionStatus::Verified => "verified", 1122 crate::db::sessions::SessionStatus::Failed => "failed", 1123 crate::db::sessions::SessionStatus::Expired => "expired", 1124 crate::db::sessions::SessionStatus::Completed => "completed", 1125 }; 1126 1127 let response = crate::models::StatusResponse { 1128 status: status_str.to_string(), 1129 }; 1130 1131 tracing::info!( 1132 "Status check for verification_id {} returned: {}", 1133 verification_id, 1134 status_str 1135 ); 1136 1137 Ok((StatusCode::OK, Json(response))) 1138 } 1139 1140 pub async fn finalize( 1141 State(state): State<AppState>, 1142 Path(verification_id): Path<String>, 1143 Query(params): Query<crate::models::StatusQuery>, 1144 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 1145 tracing::info!( 1146 "Finalize request for verification_id: {}, state: {}", 1147 verification_id, 1148 params.state 1149 ); 1150 1151 let session_data = crate::db::sessions::get_session_for_status(&state.pool, &verification_id) 1152 .await 1153 .map_err(|e| { 1154 tracing::error!("DB error in finalize: {}", e); 1155 ( 1156 StatusCode::INTERNAL_SERVER_ERROR, 1157 Json(ErrorResponse::new("internal_error")), 1158 ) 1159 })?; 1160 1161 let data = match session_data { 1162 Some(d) => d, 1163 None => { 1164 tracing::warn!( 1165 "Session not found for verification_id: {}", 1166 verification_id 1167 ); 1168 return Err(( 1169 StatusCode::NOT_FOUND, 1170 Json(ErrorResponse::new("session_not_found")), 1171 )); 1172 } 1173 }; 1174 1175 if data.state.as_deref() != Some(¶ms.state) { 1176 tracing::warn!( 1177 "State mismatch for verification_id: {} (expected: {:?}, got: {})", 1178 verification_id, 1179 data.state, 1180 params.state 1181 ); 1182 return Err(( 1183 StatusCode::FORBIDDEN, 1184 Json(ErrorResponse::new("invalid_state")), 1185 )); 1186 } 1187 1188 if data.status != crate::db::sessions::SessionStatus::Verified { 1189 tracing::warn!( 1190 "Session {} not verified, status: {:?}", 1191 verification_id, 1192 data.status 1193 ); 1194 return Err(( 1195 StatusCode::BAD_REQUEST, 1196 Json(ErrorResponse::new("not_verified")), 1197 )); 1198 } 1199 1200 let authorization_code = match data.authorization_code { 1201 Some(code) => code, 1202 None => { 1203 tracing::error!( 1204 "Session {} verified but no authorization code found", 1205 verification_id 1206 ); 1207 return Err(( 1208 StatusCode::INTERNAL_SERVER_ERROR, 1209 Json(ErrorResponse::new("internal_error")), 1210 )); 1211 } 1212 }; 1213 1214 let redirect_uri = match data.redirect_uri { 1215 Some(uri) => uri, 1216 None => { 1217 tracing::error!( 1218 "Session {} has no redirect_uri", 1219 verification_id 1220 ); 1221 return Err(( 1222 StatusCode::INTERNAL_SERVER_ERROR, 1223 Json(ErrorResponse::new("internal_error")), 1224 )); 1225 } 1226 }; 1227 1228 let separator = if redirect_uri.contains('?') { '&' } else { '?' }; 1229 let redirect_url = format!( 1230 "{}{}code={}&state={}", 1231 redirect_uri, 1232 separator, 1233 authorization_code, 1234 urlencoding::encode(¶ms.state) 1235 ); 1236 1237 tracing::info!( 1238 "Finalize: redirecting to {} for verification_id {}", 1239 redirect_uri, 1240 verification_id 1241 ); 1242 1243 Ok(( 1244 StatusCode::FOUND, 1245 [(header::LOCATION, redirect_url)], 1246 "", 1247 )) 1248 } 1249 1250 #[cfg(test)] 1251 mod tests { 1252 use super::*; 1253 1254 #[test] 1255 fn test_is_safe_url() { 1256 assert!(is_safe_url("https://example.com")); 1257 assert!(is_safe_url("HTTPS://EXAMPLE.COM")); 1258 assert!(!is_safe_url("http://example.com")); 1259 assert!(!is_safe_url("javascript:alert(1)")); 1260 } 1261 1262 #[test] 1263 fn test_is_safe_deeplink() { 1264 assert!(is_safe_deeplink("")); 1265 assert!(is_safe_deeplink("swiyu-verify://wallet/open")); 1266 assert!(is_safe_deeplink("https://example.com/callback")); 1267 assert!(!is_safe_deeplink("http://example.com")); 1268 assert!(!is_safe_deeplink("file:///etc/passwd")); 1269 } 1270 1271 #[test] 1272 fn test_json_encode_string() { 1273 let raw = "a\"b"; 1274 let encoded = json_encode_string(raw); 1275 let expected = serde_json::to_string(raw).unwrap(); 1276 assert_eq!(encoded, expected); 1277 } 1278 1279 #[test] 1280 fn test_parse_accepted_issuer_dids() { 1281 let dids = parse_accepted_issuer_dids("{did:example:1, did:example:2}").unwrap(); 1282 assert_eq!(dids, vec!["did:example:1", "did:example:2"]); 1283 1284 assert!(parse_accepted_issuer_dids("").is_err()); 1285 assert!(parse_accepted_issuer_dids(" ").is_err()); 1286 assert!(parse_accepted_issuer_dids("{ }").is_err()); 1287 } 1288 1289 #[test] 1290 fn test_validate_scope_claims_valid() { 1291 let valid_claims: HashSet<String> = ["family_name", "age_over_18"] 1292 .iter() 1293 .map(|s| s.to_string()) 1294 .collect(); 1295 assert!(validate_scope_claims("family_name age_over_18", &valid_claims).is_ok()); 1296 } 1297 1298 #[test] 1299 fn test_validate_scope_claims_invalid() { 1300 let valid_claims: HashSet<String> = ["family_name", "age_over_18"] 1301 .iter() 1302 .map(|s| s.to_string()) 1303 .collect(); 1304 let result = validate_scope_claims("invalid_claim", &valid_claims); 1305 assert!(result.is_err()); 1306 assert!(result.unwrap_err().contains("invalid_claim")); 1307 } 1308 1309 #[test] 1310 fn test_validate_scope_claims_mixed() { 1311 let valid_claims: HashSet<String> = ["family_name", "age_over_18"] 1312 .iter() 1313 .map(|s| s.to_string()) 1314 .collect(); 1315 let result = validate_scope_claims("family_name bogus", &valid_claims); 1316 assert!(result.is_err()); 1317 assert!(result.unwrap_err().contains("bogus")); 1318 } 1319 1320 #[test] 1321 fn test_validate_scope_claims_empty() { 1322 let valid_claims: HashSet<String> = ["family_name"] 1323 .iter() 1324 .map(|s| s.to_string()) 1325 .collect(); 1326 assert!(validate_scope_claims("", &valid_claims).is_ok()); 1327 } 1328 1329 #[test] 1330 fn test_build_dcql_query_single_attribute() { 1331 let query = build_dcql_query("age_over_18", "betaid-sdjwt", "dc+sd-jwt"); 1332 1333 assert_eq!(query.credentials.len(), 1); 1334 let credential = &query.credentials[0]; 1335 assert_eq!(credential.id, DCQL_CREDENTIAL_ID); 1336 assert_eq!(credential.format, "dc+sd-jwt"); 1337 assert_eq!(credential.meta.vct_values, vec!["betaid-sdjwt"]); 1338 assert!(credential.require_cryptographic_holder_binding); 1339 1340 let claims = credential.claims.as_ref().unwrap(); 1341 assert_eq!(claims.len(), 1); 1342 assert_eq!(claims[0].path, vec!["age_over_18"]); 1343 } 1344 1345 #[test] 1346 fn test_build_dcql_query_multiple_attributes() { 1347 let query = build_dcql_query( 1348 "first_name last_name date_of_birth", 1349 "betaid-sdjwt", 1350 "dc+sd-jwt", 1351 ); 1352 1353 let claims = query.credentials[0].claims.as_ref().unwrap(); 1354 assert_eq!(claims.len(), 3); 1355 assert_eq!(claims[0].path, vec!["first_name"]); 1356 assert_eq!(claims[1].path, vec!["last_name"]); 1357 assert_eq!(claims[2].path, vec!["date_of_birth"]); 1358 } 1359 1360 #[test] 1361 fn test_build_dcql_query_extra_whitespace() { 1362 let query = build_dcql_query("first_name last_name", "betaid-sdjwt", "dc+sd-jwt"); 1363 1364 let claims = query.credentials[0].claims.as_ref().unwrap(); 1365 assert_eq!(claims.len(), 2); 1366 assert_eq!(claims[0].path, vec!["first_name"]); 1367 assert_eq!(claims[1].path, vec!["last_name"]); 1368 } 1369 1370 /// An empty scope must not produce `"claims": []`, which the verifier 1371 /// rejects; the member is left out instead. 1372 #[test] 1373 fn test_build_dcql_query_empty_scope() { 1374 let query = build_dcql_query("", "betaid-sdjwt", "dc+sd-jwt"); 1375 1376 assert_eq!(query.credentials.len(), 1); 1377 assert!(query.credentials[0].claims.is_none()); 1378 let json = serde_json::to_value(&query).unwrap(); 1379 assert!(json["credentials"][0].get("claims").is_none()); 1380 } 1381 1382 #[test] 1383 fn test_build_dcql_query_custom_vc_config() { 1384 let query = build_dcql_query("age_over_18", "custom-type", "vc+sd-jwt"); 1385 1386 let credential = &query.credentials[0]; 1387 assert_eq!(credential.format, "vc+sd-jwt"); 1388 assert_eq!(credential.meta.vct_values, vec!["custom-type"]); 1389 } 1390 1391 /// The wire format the verifier expects, as in 1392 /// `swiyu-verifier/api_requests/request_over_18_dcql.json`. 1393 #[test] 1394 fn test_build_dcql_query_serialises_to_expected_json() { 1395 let query = build_dcql_query("age_over_18", "betaid-sdjwt", "dc+sd-jwt"); 1396 1397 assert_eq!( 1398 serde_json::to_value(&query).unwrap(), 1399 serde_json::json!({ 1400 "credentials": [{ 1401 "id": "kych_verification", 1402 "format": "dc+sd-jwt", 1403 "meta": { "vct_values": ["betaid-sdjwt"] }, 1404 "claims": [{ "path": ["age_over_18"] }], 1405 "require_cryptographic_holder_binding": true 1406 }] 1407 }) 1408 ); 1409 } 1410 1411 #[test] 1412 fn test_extract_disclosed_claims_from_dcql_response() { 1413 let wallet_response = serde_json::json!({ 1414 "dcql_response": { 1415 "kych_verification": { 1416 "age_over_18": [true], 1417 "given_name": ["Max"] 1418 } 1419 } 1420 }); 1421 1422 let claims = extract_disclosed_claims(&wallet_response).unwrap(); 1423 assert_eq!(claims["age_over_18"], serde_json::json!(true)); 1424 assert_eq!(claims["given_name"], serde_json::json!("Max")); 1425 } 1426 1427 /// A claim disclosed more than once keeps its array. 1428 #[test] 1429 fn test_extract_disclosed_claims_keeps_multiple_values() { 1430 let wallet_response = serde_json::json!({ 1431 "dcql_response": { 1432 "kych_verification": { "nationality": ["CH", "DE"] } 1433 } 1434 }); 1435 1436 let claims = extract_disclosed_claims(&wallet_response).unwrap(); 1437 assert_eq!(claims["nationality"], serde_json::json!(["CH", "DE"])); 1438 } 1439 1440 /// A response keyed by some other credential-query id is still read, as 1441 /// long as it is the only one. 1442 #[test] 1443 fn test_extract_disclosed_claims_from_single_other_credential() { 1444 let wallet_response = serde_json::json!({ 1445 "dcql_response": { 1446 "some_other_query": { "age_over_18": [true] } 1447 } 1448 }); 1449 1450 let claims = extract_disclosed_claims(&wallet_response).unwrap(); 1451 assert_eq!(claims["age_over_18"], serde_json::json!(true)); 1452 } 1453 1454 #[test] 1455 fn test_extract_disclosed_claims_falls_back_to_credential_subject_data() { 1456 let wallet_response = serde_json::json!({ 1457 "credential_subject_data": { 1458 "kych_verification": { "age_over_18": [true] } 1459 } 1460 }); 1461 1462 let claims = extract_disclosed_claims(&wallet_response).unwrap(); 1463 assert_eq!(claims["age_over_18"], serde_json::json!(true)); 1464 } 1465 1466 /// Older verifiers put the claims straight into `credential_subject_data`, 1467 /// without a credential-query level. 1468 #[test] 1469 fn test_extract_disclosed_claims_flat_credential_subject_data() { 1470 let wallet_response = serde_json::json!({ 1471 "credential_subject_data": { 1472 "age_over_18": true, 1473 "given_name": "Max" 1474 } 1475 }); 1476 1477 let claims = extract_disclosed_claims(&wallet_response).unwrap(); 1478 assert_eq!(claims["age_over_18"], serde_json::json!(true)); 1479 assert_eq!(claims["given_name"], serde_json::json!("Max")); 1480 } 1481 1482 /// A failed verification carries an error instead of claims, and must not 1483 /// be mistaken for a disclosure. 1484 #[test] 1485 fn test_extract_disclosed_claims_none_on_error_response() { 1486 let wallet_response = serde_json::json!({ 1487 "error_code": "authorization_request_object_not_found", 1488 "error_description": "no such request" 1489 }); 1490 1491 assert!(extract_disclosed_claims(&wallet_response).is_none()); 1492 } 1493 1494 #[test] 1495 fn test_extract_disclosed_claims_none_when_empty() { 1496 assert!(extract_disclosed_claims(&serde_json::json!({})).is_none()); 1497 assert!( 1498 extract_disclosed_claims(&serde_json::json!({ "dcql_response": {} })).is_none() 1499 ); 1500 assert!( 1501 extract_disclosed_claims( 1502 &serde_json::json!({ "dcql_response": { "kych_verification": {} } }) 1503 ) 1504 .is_none() 1505 ); 1506 } 1507 }