handlers.rs (43785B)
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 presentation_definition = build_presentation_definition( 400 &data.scope, 401 &state.config.vc.vc_type, 402 &state.config.vc.vc_format, 403 &state.config.vc.vc_algorithms, 404 ); 405 406 // Call Swiyu Verifier 407 let verifier_url = format!("{}{}", data.verifier_url, data.verifier_management_api_path); 408 409 let accepted_issuer_dids = match data.accepted_issuer_dids.as_deref() { 410 Some(raw) => parse_accepted_issuer_dids(raw).map_err(|message| { 411 tracing::error!("Invalid accepted issuer DIDs for client {}: {}", params.client_id, message); 412 ( 413 StatusCode::INTERNAL_SERVER_ERROR, 414 Json(ErrorResponse::new("invalid_accepted_issuer_dids")), 415 ) 416 })?, 417 None => { 418 tracing::error!( 419 "Accepted issuer DIDs not configured for client {}", 420 params.client_id 421 ); 422 return Err(( 423 StatusCode::INTERNAL_SERVER_ERROR, 424 Json(ErrorResponse::new("accepted_issuer_dids_not_configured")), 425 )); 426 } 427 }; 428 429 let verifier_request = SwiyuCreateVerificationRequest { 430 accepted_issuer_dids, 431 trust_anchors: None, 432 jwt_secured_authorization_request: Some(true), 433 response_mode: ResponseMode::DirectPost, 434 response_type: "vp_token".to_string(), 435 presentation_definition, 436 configuration_override: ConfigurationOverride::default(), 437 dcql_query: None, 438 }; 439 440 tracing::debug!( 441 "Swiyu verifier request: {}", 442 serde_json::to_string_pretty(&verifier_request).unwrap() 443 ); 444 tracing::debug!("Calling Swiyu verifier at: {}", verifier_url); 445 446 let verifier_response = state 447 .http_client 448 .post(&verifier_url) 449 .json(&verifier_request) 450 .send() 451 .await 452 .map_err(|e| { 453 tracing::error!("Failed to call Swiyu verifier: {}", e); 454 ( 455 StatusCode::BAD_GATEWAY, 456 Json(ErrorResponse::new("verifier_unavailable")), 457 ) 458 })?; 459 460 if !verifier_response.status().is_success() { 461 let status = verifier_response.status(); 462 let body = verifier_response.text().await.unwrap_or_default(); 463 tracing::error!("Swiyu verifier returned error {}: {}", status, body); 464 return Err(( 465 StatusCode::BAD_GATEWAY, 466 Json(ErrorResponse::new("verifier_error")), 467 )); 468 } 469 470 let swiyu_response: SwiyuManagementResponse = verifier_response.json().await.map_err(|e| { 471 tracing::error!("Failed to parse Swiyu response: {}", e); 472 ( 473 StatusCode::BAD_GATEWAY, 474 Json(ErrorResponse::new("verifier_invalid_response")), 475 ) 476 })?; 477 478 // Update session with verifier data 479 let result = crate::db::sessions::update_session_authorized( 480 &state.pool, 481 data.session_id, 482 &swiyu_response.verification_url, 483 swiyu_response.verification_deeplink.as_deref(), 484 &swiyu_response.id.to_string(), 485 swiyu_response.request_nonce.as_deref(), 486 ) 487 .await 488 .map_err(|e| { 489 tracing::error!("Failed to update session: {}", e); 490 ( 491 StatusCode::INTERNAL_SERVER_ERROR, 492 Json(ErrorResponse::new("internal_error")), 493 ) 494 })?; 495 496 tracing::info!( 497 "Session {} authorized, verification_id: {}", 498 data.session_id, 499 swiyu_response.id 500 ); 501 502 let accept_html = headers 503 .get(header::ACCEPT) 504 .and_then(|h| h.to_str().ok()) 505 .map_or(false, |v| v.contains("text/html")); 506 507 let verification_url = result.verification_url.clone(); 508 let verification_deeplink = swiyu_response.verification_deeplink.clone().unwrap_or_default(); 509 510 if !is_safe_url(&verification_url) { 511 tracing::error!("Invalid verification_url scheme: {}", verification_url); 512 return Err(( 513 StatusCode::BAD_GATEWAY, 514 Json(ErrorResponse::new("invalid_verification_url")), 515 )); 516 } 517 518 if !is_safe_deeplink(&verification_deeplink) { 519 tracing::error!("Invalid verification_deeplink scheme: {}", verification_deeplink); 520 return Err(( 521 StatusCode::BAD_GATEWAY, 522 Json(ErrorResponse::new("invalid_verification_deeplink")), 523 )); 524 } 525 526 if accept_html { 527 use askama::Template; 528 529 #[derive(Template)] 530 #[template(path = "authorize.html")] 531 struct AuthorizeTemplate { 532 verification_url: String, 533 verification_deeplink: String, 534 verification_id_json: String, 535 verification_url_json: String, 536 state_json: String, 537 } 538 539 let template = AuthorizeTemplate { 540 verification_url: verification_url.clone(), 541 verification_deeplink: verification_deeplink.clone(), 542 verification_id_json: json_encode_string(&swiyu_response.id.to_string()), 543 verification_url_json: json_encode_string(&verification_url), 544 state_json: json_encode_string(¶ms.state), 545 }; 546 547 let html = template.render().map_err(|e| { 548 tracing::error!("Template render error: {}", e); 549 ( 550 StatusCode::INTERNAL_SERVER_ERROR, 551 Json(ErrorResponse::new("internal_error")), 552 ) 553 })?; 554 555 return Ok(( 556 StatusCode::OK, 557 [ 558 (header::CONTENT_TYPE, "text/html; charset=utf-8"), 559 (header::CONTENT_SECURITY_POLICY, HTML_CSP), 560 ], 561 html, 562 ).into_response()); 563 } 564 565 Ok(PrettyJson(AuthorizeResponse { 566 verification_id: swiyu_response.id, 567 verification_url, 568 verification_deeplink: swiyu_response.verification_deeplink, 569 state: params.state.clone() 570 }).into_response()) 571 } 572 573 fn build_presentation_definition( 574 scope: &str, 575 vc_type: &str, 576 vc_format: &str, 577 vc_algorithms: &[String], 578 ) -> PresentationDefinition { 579 use std::collections::HashMap; 580 use uuid::Uuid; 581 582 let attributes: Vec<&str> = scope.split_whitespace().collect(); 583 584 tracing::debug!( 585 "Building presentation definition for attributes: {:?}", 586 attributes 587 ); 588 589 let vct_field = Field { 590 path: vec!["$.vct".to_string()], 591 id: None, 592 name: None, 593 purpose: None, 594 filter: Some(Filter { 595 filter_type: "string".to_string(), 596 const_value: Some(vc_type.to_string()), 597 }), 598 }; 599 600 let mut fields: Vec<Field> = vec![vct_field]; 601 for attr in &attributes { 602 fields.push(Field { 603 path: vec![format!("$.{}", attr)], 604 id: None, 605 name: None, 606 purpose: None, 607 filter: None, 608 }); 609 } 610 611 let mut format = HashMap::new(); 612 format.insert( 613 vc_format.to_string(), 614 FormatAlgorithm { 615 sd_jwt_alg_values: vc_algorithms.to_vec(), 616 kb_jwt_alg_values: vc_algorithms.to_vec(), 617 }, 618 ); 619 620 let input_descriptor = InputDescriptor { 621 id: Uuid::new_v4().to_string(), 622 name: None, 623 purpose: None, 624 format: Some(format), 625 constraints: Constraint { fields }, 626 }; 627 628 PresentationDefinition { 629 id: Uuid::new_v4().to_string(), 630 name: Some("Over 18 Verification".to_string()), 631 purpose: Some("Verify age is over 18".to_string()), 632 format: None, 633 input_descriptors: vec![input_descriptor], 634 } 635 } 636 637 // POST /token 638 pub async fn token( 639 State(state): State<AppState>, 640 Form(request): Form<TokenRequest>, 641 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 642 tracing::info!("Token request for code: {}", request.code); 643 644 // Validate grant_type 645 if request.grant_type != "authorization_code" { 646 return Err(( 647 StatusCode::BAD_REQUEST, 648 Json(ErrorResponse::new("unsupported_grant_type")), 649 )); 650 } 651 652 // Authenticate client 653 let client = crate::db::clients::authenticate_client( 654 &state.pool, 655 &request.client_id, 656 &request.client_secret, 657 ) 658 .await 659 .map_err(|e| { 660 tracing::error!("DB error during client authentication: {}", e); 661 ( 662 StatusCode::INTERNAL_SERVER_ERROR, 663 Json(ErrorResponse::new("internal_error")), 664 ) 665 })?; 666 667 let client = match client { 668 Some(c) => c, 669 None => { 670 tracing::warn!("Client authentication failed for {}", request.client_id); 671 return Err(( 672 StatusCode::UNAUTHORIZED, 673 Json(ErrorResponse::new("invalid_client")), 674 )); 675 } 676 }; 677 678 // Fetch code (idempotent) 679 let code_data = 680 crate::db::authorization_codes::get_code_for_token_exchange(&state.pool, &request.code) 681 .await 682 .map_err(|e| { 683 tracing::error!("DB error in token exchange: {}", e); 684 ( 685 StatusCode::INTERNAL_SERVER_ERROR, 686 Json(ErrorResponse::new("internal_error")), 687 ) 688 })?; 689 690 let data = match code_data { 691 Some(d) => d, 692 None => { 693 tracing::warn!("Authorization code not found or expired: {}", request.code); 694 return Err(( 695 StatusCode::BAD_REQUEST, 696 Json(ErrorResponse::new("invalid_grant")), 697 )); 698 } 699 }; 700 701 // Verify the authorization code belongs to the client 702 if data.client_id != client.id { 703 tracing::warn!( 704 "Authorization code {} does not belong to the client {}", 705 request.code, 706 request.client_id 707 ); 708 709 return Err(( 710 StatusCode::BAD_REQUEST, 711 Json(ErrorResponse::new("invalid_grant")), 712 )); 713 } 714 715 // RFC 6749 Section 4.1.3: If redirect_uri was included in the authorization request, 716 // it MUST be present and match exactly in the token request 717 match &data.redirect_uri { 718 Some(stored_uri) if stored_uri != &request.redirect_uri => { 719 tracing::warn!( 720 "redirect_uri mismatch for code {}: expected '{}', got '{}'", 721 request.code, 722 stored_uri, 723 request.redirect_uri 724 ); 725 return Err(( 726 StatusCode::BAD_REQUEST, 727 Json(ErrorResponse::new("invalid_grant")), 728 )); 729 } 730 None => { 731 tracing::warn!( 732 "redirect_uri provided in token request but was not stored during authorization for code {}", 733 request.code 734 ); 735 return Err(( 736 StatusCode::BAD_REQUEST, 737 Json(ErrorResponse::new("invalid_grant")), 738 )); 739 } 740 Some(_) => {} 741 } 742 743 // Check for existing token 744 if let Some(existing_token) = data.existing_token { 745 tracing::info!( 746 "Token already exists for session {}, returning cached response", 747 data.session_id 748 ); 749 return Ok(( 750 StatusCode::OK, 751 Json(TokenResponse { 752 access_token: existing_token, 753 token_type: "Bearer".to_string(), 754 expires_in: 3600, 755 }), 756 )); 757 } 758 759 // Check if code was already used 760 if data.was_already_used { 761 tracing::warn!("Authorization code {} was already used", request.code); 762 return Err(( 763 StatusCode::BAD_REQUEST, 764 Json(ErrorResponse::new("invalid_grant")), 765 )); 766 } 767 768 // Validate session status 769 if data.session_status != SessionStatus::Verified { 770 tracing::warn!( 771 "Session {} not in verified status: {:?}", 772 data.session_id, 773 data.session_status 774 ); 775 return Err(( 776 StatusCode::BAD_REQUEST, 777 Json(ErrorResponse::new("invalid_grant")), 778 )); 779 } 780 781 // Generate new token and complete session 782 let access_token = crypto::generate_token(state.config.crypto.token_bytes); 783 let token = crate::db::tokens::create_token_and_complete_session( 784 &state.pool, 785 data.session_id, 786 &access_token, 787 3600, // 1 hour 788 ) 789 .await 790 .map_err(|e| { 791 tracing::error!("Failed to create token: {}", e); 792 ( 793 StatusCode::INTERNAL_SERVER_ERROR, 794 Json(ErrorResponse::new("internal_error")), 795 ) 796 })?; 797 798 tracing::info!("Token created for session {}", data.session_id); 799 800 Ok(( 801 StatusCode::OK, 802 Json(TokenResponse { 803 access_token: token.token, 804 token_type: "Bearer".to_string(), 805 expires_in: 3600, 806 }), 807 )) 808 } 809 810 // GET /info 811 pub async fn info( 812 State(state): State<AppState>, 813 headers: axum::http::HeaderMap, 814 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 815 tracing::info!("Info request received"); 816 817 // Extract token from Authorization header 818 let auth_header = headers 819 .get(header::AUTHORIZATION) 820 .and_then(|h| h.to_str().ok()); 821 822 let token = match auth_header { 823 Some(h) if h.starts_with("Bearer ") => &h[7..], 824 _ => { 825 tracing::warn!("Missing or malformed Authorization header"); 826 return Err(( 827 StatusCode::UNAUTHORIZED, 828 Json(ErrorResponse::new("invalid_token")), 829 )); 830 } 831 }; 832 833 // Fetch token with session data (idempotent) 834 let token_data = crate::db::tokens::get_token_with_session(&state.pool, token) 835 .await 836 .map_err(|e| { 837 tracing::error!("DB error in info: {}", e); 838 ( 839 StatusCode::INTERNAL_SERVER_ERROR, 840 Json(ErrorResponse::new("internal_error")), 841 ) 842 })?; 843 844 let data = match token_data { 845 Some(d) => d, 846 None => { 847 tracing::warn!("Token not found or expired"); 848 return Err(( 849 StatusCode::UNAUTHORIZED, 850 Json(ErrorResponse::new("invalid_token")), 851 )); 852 } 853 }; 854 855 // Validate token 856 if data.revoked { 857 tracing::warn!("Token {} is revoked", data.token_id); 858 return Err(( 859 StatusCode::UNAUTHORIZED, 860 Json(ErrorResponse::new("invalid_token")), 861 )); 862 } 863 864 if data.session_status != SessionStatus::Completed { 865 tracing::warn!("Session not completed: {:?}", data.session_status); 866 return Err(( 867 StatusCode::UNAUTHORIZED, 868 Json(ErrorResponse::new("invalid_token")), 869 )); 870 } 871 872 // Return verifiable credential 873 let credential = VerifiableCredential { 874 data: data.verifiable_credential.unwrap_or(json!({})), 875 }; 876 877 tracing::info!("Returning credential for token {}", data.token_id); 878 879 Ok((StatusCode::OK, Json(credential))) 880 } 881 882 // POST /notification 883 // Always returns 200 OK to Swiyu - errors are logged internally 884 pub async fn notification_webhook( 885 State(state): State<AppState>, 886 Json(webhook): Json<NotificationRequest>, 887 ) -> impl IntoResponse { 888 tracing::info!( 889 "Webhook received from Swiyu: verification_id={}, timestamp={}", 890 webhook.verification_id, 891 webhook.timestamp 892 ); 893 894 // Lookup session by request_id (verification_id) 895 let session_data = match crate::db::sessions::get_session_for_notification( 896 &state.pool, 897 &webhook.verification_id.to_string(), 898 ) 899 .await 900 { 901 Ok(Some(data)) => data, 902 Ok(None) => { 903 tracing::warn!( 904 "Session not found for verification_id: {}", 905 webhook.verification_id 906 ); 907 return StatusCode::OK; 908 } 909 Err(e) => { 910 tracing::error!("DB error looking up session: {}", e); 911 return StatusCode::OK; 912 } 913 }; 914 915 // Validate session status 916 if session_data.status != SessionStatus::Authorized { 917 tracing::warn!( 918 "Session {} not in authorized status: {:?}", 919 session_data.session_id, 920 session_data.status 921 ); 922 return StatusCode::OK; 923 } 924 925 // Call Swiyu verifier to get verification result 926 let verifier_url = format!( 927 "{}{}/{}", 928 session_data.verifier_url, 929 session_data.verifier_management_api_path, 930 webhook.verification_id 931 ); 932 933 tracing::debug!("Fetching verification result from: {}", verifier_url); 934 935 let verifier_response = match state.http_client.get(&verifier_url).send().await { 936 Ok(resp) => resp, 937 Err(e) => { 938 tracing::error!("Failed to call Swiyu verifier: {}", e); 939 return StatusCode::OK; 940 } 941 }; 942 943 if !verifier_response.status().is_success() { 944 let status = verifier_response.status(); 945 tracing::error!("Swiyu verifier returned error: {}", status); 946 return StatusCode::OK; 947 } 948 949 let swiyu_result: SwiyuManagementResponse = match verifier_response.json().await { 950 Ok(r) => r, 951 Err(e) => { 952 tracing::error!("Failed to parse Swiyu response: {}", e); 953 return StatusCode::OK; 954 } 955 }; 956 957 // Determine status based on verification result 958 let (new_status, status_str) = match swiyu_result.state { 959 SwiyuVerificationStatus::Success => (SessionStatus::Verified, "verified"), 960 SwiyuVerificationStatus::Failed => (SessionStatus::Failed, "failed"), 961 SwiyuVerificationStatus::Pending => { 962 tracing::info!( 963 "Verification {} still pending, ignoring webhook", 964 webhook.verification_id 965 ); 966 return StatusCode::OK; 967 } 968 }; 969 970 // Generate authorization code 971 let authorization_code = crypto::generate_authorization_code(state.config.crypto.authorization_code_bytes); 972 973 // Construct GET request URL: redirect_uri?code=XXX&state=YYY 974 let auth_code_ttl = state.config.crypto.authorization_code_ttl_minutes; 975 976 // Update session and create auth code 977 match crate::db::sessions::verify_session_and_issue_code( 978 &state.pool, 979 session_data.session_id, 980 new_status, 981 &authorization_code, 982 auth_code_ttl, 983 session_data.client_id, 984 "", // Empty body for GET request 985 swiyu_result.wallet_response.as_ref(), 986 ) 987 .await 988 { 989 Ok(code) => { 990 tracing::info!( 991 "Session {} updated to {}, auth code created", 992 session_data.session_id, 993 status_str 994 ); 995 tracing::debug!("Generated authorization code: {}", code); 996 } 997 Err(e) => { 998 tracing::error!("Failed to update session with authorization code: {}", e); 999 } 1000 } 1001 1002 StatusCode::OK 1003 } 1004 1005 pub async fn status( 1006 State(state): State<AppState>, 1007 Path(verification_id): Path<String>, 1008 Query(params): Query<crate::models::StatusQuery>, 1009 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 1010 tracing::info!( 1011 "Status check for verification_id: {}, state: {}", 1012 verification_id, 1013 params.state 1014 ); 1015 1016 let session_data = crate::db::sessions::get_session_for_status( 1017 &state.pool, 1018 &verification_id, 1019 ) 1020 .await 1021 .map_err(|e| { 1022 tracing::error!("DB error in status: {}", e); 1023 ( 1024 StatusCode::INTERNAL_SERVER_ERROR, 1025 Json(ErrorResponse::new("internal_error")), 1026 ) 1027 })?; 1028 1029 let data = match session_data { 1030 Some(d) => d, 1031 None => { 1032 tracing::warn!("Session not found for verification_id: {}", verification_id); 1033 return Err(( 1034 StatusCode::NOT_FOUND, 1035 Json(ErrorResponse::new("session_not_found")), 1036 )); 1037 } 1038 }; 1039 1040 if data.state.as_deref() != Some(¶ms.state) { 1041 tracing::warn!( 1042 "State mismatch for verification_id: {} (expected: {:?}, got: {})", 1043 verification_id, 1044 data.state, 1045 params.state 1046 ); 1047 return Err(( 1048 StatusCode::FORBIDDEN, 1049 Json(ErrorResponse::new("invalid_state")), 1050 )); 1051 } 1052 1053 let status_str = match data.status { 1054 crate::db::sessions::SessionStatus::Pending => "pending", 1055 crate::db::sessions::SessionStatus::Authorized => "authorized", 1056 crate::db::sessions::SessionStatus::Verified => "verified", 1057 crate::db::sessions::SessionStatus::Failed => "failed", 1058 crate::db::sessions::SessionStatus::Expired => "expired", 1059 crate::db::sessions::SessionStatus::Completed => "completed", 1060 }; 1061 1062 let response = crate::models::StatusResponse { 1063 status: status_str.to_string(), 1064 }; 1065 1066 tracing::info!( 1067 "Status check for verification_id {} returned: {}", 1068 verification_id, 1069 status_str 1070 ); 1071 1072 Ok((StatusCode::OK, Json(response))) 1073 } 1074 1075 pub async fn finalize( 1076 State(state): State<AppState>, 1077 Path(verification_id): Path<String>, 1078 Query(params): Query<crate::models::StatusQuery>, 1079 ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 1080 tracing::info!( 1081 "Finalize request for verification_id: {}, state: {}", 1082 verification_id, 1083 params.state 1084 ); 1085 1086 let session_data = crate::db::sessions::get_session_for_status(&state.pool, &verification_id) 1087 .await 1088 .map_err(|e| { 1089 tracing::error!("DB error in finalize: {}", e); 1090 ( 1091 StatusCode::INTERNAL_SERVER_ERROR, 1092 Json(ErrorResponse::new("internal_error")), 1093 ) 1094 })?; 1095 1096 let data = match session_data { 1097 Some(d) => d, 1098 None => { 1099 tracing::warn!( 1100 "Session not found for verification_id: {}", 1101 verification_id 1102 ); 1103 return Err(( 1104 StatusCode::NOT_FOUND, 1105 Json(ErrorResponse::new("session_not_found")), 1106 )); 1107 } 1108 }; 1109 1110 if data.state.as_deref() != Some(¶ms.state) { 1111 tracing::warn!( 1112 "State mismatch for verification_id: {} (expected: {:?}, got: {})", 1113 verification_id, 1114 data.state, 1115 params.state 1116 ); 1117 return Err(( 1118 StatusCode::FORBIDDEN, 1119 Json(ErrorResponse::new("invalid_state")), 1120 )); 1121 } 1122 1123 if data.status != crate::db::sessions::SessionStatus::Verified { 1124 tracing::warn!( 1125 "Session {} not verified, status: {:?}", 1126 verification_id, 1127 data.status 1128 ); 1129 return Err(( 1130 StatusCode::BAD_REQUEST, 1131 Json(ErrorResponse::new("not_verified")), 1132 )); 1133 } 1134 1135 let authorization_code = match data.authorization_code { 1136 Some(code) => code, 1137 None => { 1138 tracing::error!( 1139 "Session {} verified but no authorization code found", 1140 verification_id 1141 ); 1142 return Err(( 1143 StatusCode::INTERNAL_SERVER_ERROR, 1144 Json(ErrorResponse::new("internal_error")), 1145 )); 1146 } 1147 }; 1148 1149 let redirect_uri = match data.redirect_uri { 1150 Some(uri) => uri, 1151 None => { 1152 tracing::error!( 1153 "Session {} has no redirect_uri", 1154 verification_id 1155 ); 1156 return Err(( 1157 StatusCode::INTERNAL_SERVER_ERROR, 1158 Json(ErrorResponse::new("internal_error")), 1159 )); 1160 } 1161 }; 1162 1163 let separator = if redirect_uri.contains('?') { '&' } else { '?' }; 1164 let redirect_url = format!( 1165 "{}{}code={}&state={}", 1166 redirect_uri, 1167 separator, 1168 authorization_code, 1169 urlencoding::encode(¶ms.state) 1170 ); 1171 1172 tracing::info!( 1173 "Finalize: redirecting to {} for verification_id {}", 1174 redirect_uri, 1175 verification_id 1176 ); 1177 1178 Ok(( 1179 StatusCode::FOUND, 1180 [(header::LOCATION, redirect_url)], 1181 "", 1182 )) 1183 } 1184 1185 #[cfg(test)] 1186 mod tests { 1187 use super::*; 1188 1189 #[test] 1190 fn test_is_safe_url() { 1191 assert!(is_safe_url("https://example.com")); 1192 assert!(is_safe_url("HTTPS://EXAMPLE.COM")); 1193 assert!(!is_safe_url("http://example.com")); 1194 assert!(!is_safe_url("javascript:alert(1)")); 1195 } 1196 1197 #[test] 1198 fn test_is_safe_deeplink() { 1199 assert!(is_safe_deeplink("")); 1200 assert!(is_safe_deeplink("swiyu-verify://wallet/open")); 1201 assert!(is_safe_deeplink("https://example.com/callback")); 1202 assert!(!is_safe_deeplink("http://example.com")); 1203 assert!(!is_safe_deeplink("file:///etc/passwd")); 1204 } 1205 1206 #[test] 1207 fn test_json_encode_string() { 1208 let raw = "a\"b"; 1209 let encoded = json_encode_string(raw); 1210 let expected = serde_json::to_string(raw).unwrap(); 1211 assert_eq!(encoded, expected); 1212 } 1213 1214 #[test] 1215 fn test_parse_accepted_issuer_dids() { 1216 let dids = parse_accepted_issuer_dids("{did:example:1, did:example:2}").unwrap(); 1217 assert_eq!(dids, vec!["did:example:1", "did:example:2"]); 1218 1219 assert!(parse_accepted_issuer_dids("").is_err()); 1220 assert!(parse_accepted_issuer_dids(" ").is_err()); 1221 assert!(parse_accepted_issuer_dids("{ }").is_err()); 1222 } 1223 1224 #[test] 1225 fn test_validate_scope_claims_valid() { 1226 let valid_claims: HashSet<String> = ["family_name", "age_over_18"] 1227 .iter() 1228 .map(|s| s.to_string()) 1229 .collect(); 1230 assert!(validate_scope_claims("family_name age_over_18", &valid_claims).is_ok()); 1231 } 1232 1233 #[test] 1234 fn test_validate_scope_claims_invalid() { 1235 let valid_claims: HashSet<String> = ["family_name", "age_over_18"] 1236 .iter() 1237 .map(|s| s.to_string()) 1238 .collect(); 1239 let result = validate_scope_claims("invalid_claim", &valid_claims); 1240 assert!(result.is_err()); 1241 assert!(result.unwrap_err().contains("invalid_claim")); 1242 } 1243 1244 #[test] 1245 fn test_validate_scope_claims_mixed() { 1246 let valid_claims: HashSet<String> = ["family_name", "age_over_18"] 1247 .iter() 1248 .map(|s| s.to_string()) 1249 .collect(); 1250 let result = validate_scope_claims("family_name bogus", &valid_claims); 1251 assert!(result.is_err()); 1252 assert!(result.unwrap_err().contains("bogus")); 1253 } 1254 1255 #[test] 1256 fn test_validate_scope_claims_empty() { 1257 let valid_claims: HashSet<String> = ["family_name"] 1258 .iter() 1259 .map(|s| s.to_string()) 1260 .collect(); 1261 assert!(validate_scope_claims("", &valid_claims).is_ok()); 1262 } 1263 1264 #[test] 1265 fn test_build_presentation_definition_single_attribute() { 1266 let scope = "age_over_18"; 1267 let pd = build_presentation_definition( 1268 scope, 1269 "betaid-sdjwt", 1270 "vc+sd-jwt", 1271 &["ES256".to_string()], 1272 ); 1273 1274 assert!(!pd.id.is_empty()); 1275 assert_eq!(pd.name, Some("Over 18 Verification".to_string())); 1276 assert_eq!(pd.input_descriptors.len(), 1); 1277 1278 let fields = &pd.input_descriptors[0].constraints.fields; 1279 assert_eq!(fields.len(), 2); 1280 1281 assert_eq!(fields[0].path, vec!["$.vct"]); 1282 assert!(fields[0].filter.is_some()); 1283 let filter = fields[0].filter.as_ref().unwrap(); 1284 assert_eq!(filter.filter_type, "string"); 1285 assert_eq!(filter.const_value, Some("betaid-sdjwt".to_string())); 1286 1287 assert_eq!(fields[1].path, vec!["$.age_over_18"]); 1288 assert!(fields[1].filter.is_none()); 1289 } 1290 1291 #[test] 1292 fn test_build_presentation_definition_multiple_attributes() { 1293 let scope = "first_name last_name date_of_birth"; 1294 let pd = build_presentation_definition( 1295 scope, 1296 "betaid-sdjwt", 1297 "vc+sd-jwt", 1298 &["ES256".to_string()], 1299 ); 1300 1301 let fields = &pd.input_descriptors[0].constraints.fields; 1302 assert_eq!(fields.len(), 4); 1303 1304 assert_eq!(fields[0].path, vec!["$.vct"]); 1305 assert_eq!(fields[1].path, vec!["$.first_name"]); 1306 assert_eq!(fields[2].path, vec!["$.last_name"]); 1307 assert_eq!(fields[3].path, vec!["$.date_of_birth"]); 1308 } 1309 1310 #[test] 1311 fn test_build_presentation_definition_extra_whitespace() { 1312 let scope = "first_name last_name"; 1313 let pd = build_presentation_definition( 1314 scope, 1315 "betaid-sdjwt", 1316 "vc+sd-jwt", 1317 &["ES256".to_string()], 1318 ); 1319 1320 let fields = &pd.input_descriptors[0].constraints.fields; 1321 assert_eq!(fields.len(), 3); 1322 assert_eq!(fields[0].path, vec!["$.vct"]); 1323 assert_eq!(fields[1].path, vec!["$.first_name"]); 1324 assert_eq!(fields[2].path, vec!["$.last_name"]); 1325 } 1326 1327 #[test] 1328 fn test_build_presentation_definition_empty_scope() { 1329 let scope = ""; 1330 let pd = build_presentation_definition( 1331 scope, 1332 "betaid-sdjwt", 1333 "vc+sd-jwt", 1334 &["ES256".to_string()], 1335 ); 1336 1337 let fields = &pd.input_descriptors[0].constraints.fields; 1338 assert_eq!(fields.len(), 1); 1339 assert_eq!(fields[0].path, vec!["$.vct"]); 1340 } 1341 1342 #[test] 1343 fn test_build_presentation_definition_no_top_level_format() { 1344 let scope = "age_over_18"; 1345 let pd = build_presentation_definition( 1346 scope, 1347 "betaid-sdjwt", 1348 "vc+sd-jwt", 1349 &["ES256".to_string()], 1350 ); 1351 1352 assert!(pd.format.is_none()); 1353 } 1354 1355 #[test] 1356 fn test_build_presentation_definition_input_descriptor_structure() { 1357 let scope = "age_over_18"; 1358 let pd = build_presentation_definition( 1359 scope, 1360 "betaid-sdjwt", 1361 "vc+sd-jwt", 1362 &["ES256".to_string()], 1363 ); 1364 1365 let descriptor = &pd.input_descriptors[0]; 1366 1367 assert!(!descriptor.id.is_empty()); 1368 1369 assert!(descriptor.name.is_none()); 1370 assert!(descriptor.purpose.is_none()); 1371 1372 assert!(descriptor.format.is_some()); 1373 let format = descriptor.format.as_ref().unwrap(); 1374 assert!(format.contains_key("vc+sd-jwt")); 1375 let alg = &format["vc+sd-jwt"]; 1376 assert_eq!(alg.sd_jwt_alg_values, vec!["ES256"]); 1377 assert_eq!(alg.kb_jwt_alg_values, vec!["ES256"]); 1378 } 1379 1380 #[test] 1381 fn test_build_presentation_definition_custom_vc_config() { 1382 let scope = "age_over_18"; 1383 let pd = build_presentation_definition( 1384 scope, 1385 "custom-type", 1386 "custom-format", 1387 &["ES384".to_string(), "ES512".to_string()], 1388 ); 1389 1390 let filter = pd.input_descriptors[0].constraints.fields[0] 1391 .filter 1392 .as_ref() 1393 .unwrap(); 1394 assert_eq!(filter.const_value, Some("custom-type".to_string())); 1395 1396 let format = pd.input_descriptors[0].format.as_ref().unwrap(); 1397 assert!(format.contains_key("custom-format")); 1398 let alg = &format["custom-format"]; 1399 assert_eq!(alg.sd_jwt_alg_values, vec!["ES384", "ES512"]); 1400 assert_eq!(alg.kb_jwt_alg_values, vec!["ES384", "ES512"]); 1401 } 1402 }