kych

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

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         &params.client_id,
    208         &params.scope,
    209         &params.redirect_uri,
    210         &params.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(&params.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(&params.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(&params.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(&params.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(&params.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 }