kych

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

handlers.rs (43785B)


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