kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs

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         &params.client_id,
    146         &params.scope,
    147         &params.redirect_uri,
    148         &params.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 }