kych

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

commit 759e70eb33aa67a3b7ccb453f255f699138d9d26
parent abaee00458a60140973c6ee44a9a3248e0330e60
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Mon,  3 Nov 2025 23:07:08 +0100

oauth2_gateway: implement setup and authorize endpoints with DB and Swiyu verifier integration

Diffstat:
Moauth2_gateway/src/db/sessions.rs | 2+-
Moauth2_gateway/src/handlers.rs | 312++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Moauth2_gateway/src/models.rs | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 444 insertions(+), 18 deletions(-)

diff --git a/oauth2_gateway/src/db/sessions.rs b/oauth2_gateway/src/db/sessions.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc, Duration}; /// Status of a verification session #[derive(Debug, Clone, sqlx::Type, serde::Serialize, serde::Deserialize, PartialEq)] -#[sqlx(type_name = "text")] +#[sqlx(type_name = "varchar")] pub enum SessionStatus { #[sqlx(rename = "pending")] Pending, diff --git a/oauth2_gateway/src/handlers.rs b/oauth2_gateway/src/handlers.rs @@ -23,39 +23,228 @@ pub async fn health_check() -> impl IntoResponse { // POST /setup/{clientId} pub async fn setup( - State(_state): State<AppState>, + State(state): State<AppState>, Path(client_id): Path<String>, Json(request): Json<SetupRequest>, -) -> impl IntoResponse { +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { tracing::info!("Setup request for client: {}, scope: {}", client_id, request.scope); + // Look up client in database + let client = crate::db::clients::get_client_by_id(&state.pool, &client_id) + .await + .map_err(|e| { + tracing::error!("Database error looking up client {}: {}", client_id, e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + let client = match client { + Some(c) => c, + None => { + tracing::warn!("Client not found: {}", client_id); + return Err((StatusCode::NOT_FOUND, Json(ErrorResponse::new("client_not_found")))); + } + }; + + tracing::debug!("Found client: {} (UUID: {})", client.client_id, client.id); + + // Generate cryptographically secure nonce let nonce = crypto::generate_nonce(); - tracing::info!("Generated nonce: {}", nonce); - - ( - StatusCode::OK, - Json(SetupResponse { nonce }) + + // Create verification session in database + // TODO: Should this be transactional? + let _session = crate::db::sessions::create_session( + &state.pool, + client.id, + &nonce, + &request.scope, + 15, // 15 minutes expiration ) + .await + .map_err(|e| { + tracing::error!("Failed to create session: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + tracing::info!("Created session for client {} with nonce {}", client_id, nonce); + + Ok((StatusCode::OK, Json(SetupResponse { nonce }))) } // GET /authorize/{nonce} pub async fn authorize( - State(_state): State<AppState>, + State(state): State<AppState>, Path(nonce): Path<String>, -) -> impl IntoResponse { +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { tracing::info!("Authorize request for nonce: {}", nonce); - // TODO: Validate nonce - // TODO: Call the SwiyuVerifier to generate the QR code/verification URL + // Look up session by nonce + let session = crate::db::sessions::get_session_by_nonce(&state.pool, &nonce) + .await + .map_err(|e| { + tracing::error!("Database error looking up session: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + let session = match session { + Some(js) => js, + None => { + tracing::warn!("Session not found for nonce: {}", nonce); + return Err((StatusCode::NOT_FOUND, Json(ErrorResponse::new("session_not_found")))); + } + }; + + // Validate pending state + if session.status != crate::db::sessions::SessionStatus::Pending { + tracing::warn!("Session {} is not in pending state: {:?}", session.id, session.status); + return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse::new("invalid_session_state")))); + } + + // Check if session expired + let now = chrono::Utc::now(); + if now > session.expires_at { + tracing::warn!("Session {} has expired", session.id); + return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse::new("session_expired")))); + } + + // Look up client + let client = crate::db::clients::get_client_by_uuid(&state.pool, session.client_id) + .await + .map_err(|e| { + tracing::error!("Database error looking up client: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })? + .ok_or_else(|| { + tracing::error!("Client {} not found for session {}", session.client_id, session.id); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + tracing::debug!("Found client {} for session {}", client.client_id, session.id); + + // Build presentation definition from scope + let presentation_definition = build_presentation_definition(&session.scope); + + // Build Swiyu API request + // Note: response_mode, presentation_definition, configuration_override are REQUIRED + let swiyu_request = SwiyuCreateVerificationRequest { + accepted_issuer_dids: None, // Accept all issuers by default + trust_anchors: None, // No trust anchors by default + jwt_secured_authorization_request: Some(true), // Beta requires true for JWT-signed requests + response_mode: ResponseMode::DirectPost, // REQUIRED + presentation_definition, // REQUIRED + configuration_override: ConfigurationOverride::default(), // REQUIRED (empty is OK) + dcql_query: None, // Using Presentation Exchange, not DCQL + }; + + // Call Swiyu Verifier API + let swiyu_url = format!("{}{}", client.verifier_base_url, client.verifier_management_api_path); + tracing::info!("Calling Swiyu Verifier API: {}", swiyu_url); + + let http_client = reqwest::Client::new(); + let swiyu_response = http_client + .post(&swiyu_url) + .json(&swiyu_request) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to call Swiyu Verifier API: {}", e); + (StatusCode::SERVICE_UNAVAILABLE, Json(ErrorResponse::new("verifier_unavailable"))) + })?; + + if !swiyu_response.status().is_success() { + let status = swiyu_response.status(); + let error_body = swiyu_response.text().await.unwrap_or_default(); + tracing::error!("Swiyu Verifier returned error {}: {}", status, error_body); + return Err((StatusCode::BAD_GATEWAY, Json(ErrorResponse::new("verifier_error")))); + } + + let swiyu_verification: SwiyuVerificationResponse = swiyu_response + .json() + .await + .map_err(|e| { + tracing::error!("Failed to parse Swiyu response: {}", e); + (StatusCode::BAD_GATEWAY, Json(ErrorResponse::new("verifier_invalid_response"))) + })?; + + tracing::info!( + "Created Swiyu verification: id={}, url={}", + swiyu_verification.id, + swiyu_verification.verification_url + ); + + // Update session with verification data + crate::db::sessions::update_session_authorized( + &state.pool, + session.id, + &swiyu_verification.verification_url, + &swiyu_verification.id.to_string(), + ) + .await + .map_err(|e| { + tracing::error!("Failed to update session: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + tracing::info!("Session {} updated with verification data", session.id); - // For now, return a mock response let response = AuthorizeResponse { - verification_id: uuid::Uuid::new_v4(), - verification_url: format!("swiyu://verify?nonce={}", nonce), + verification_id: swiyu_verification.id, + verification_url: swiyu_verification.verification_url, }; - - (StatusCode::OK, Json(response)) + + Ok((StatusCode::OK, Json(response))) +} + +/// Build a presentation definition from a space-delimited scope string +/// +/// Example: "first_name last_name date_of_birth" +fn build_presentation_definition(scope: &str) -> PresentationDefinition { + use uuid::Uuid; + use std::collections::HashMap; + + // Parse scope into individual attributes + let attributes: Vec<&str> = scope.split_whitespace().collect(); + + tracing::debug!("Building presentation definition for attributes: {:?}", attributes); + + // Create a field for each attribute + let fields: Vec<Field> = attributes + .iter() + .map(|attr| Field { + path: vec![format!("$.{}", attr)], + id: None, + name: Some(attr.to_string()), + purpose: None, + filter: None, + }) + .collect(); + + // Create format specification for SD-JWT with ES256 + let mut format = HashMap::new(); + format.insert( + "vc+sd-jwt".to_string(), + FormatAlgorithm { + sd_jwt_alg_values: vec!["ES256".to_string()], + kb_jwt_alg_values: vec!["ES256".to_string()], + }, + ); + + // Build input descriptor + let input_descriptor = InputDescriptor { + id: Uuid::new_v4().to_string(), + name: Some("Requested credentials".to_string()), + purpose: Some("KYC verification via OAuth2 Gateway".to_string()), + format: Some(format.clone()), + constraints: Constraint { fields }, + }; + + PresentationDefinition { + id: Uuid::new_v4().to_string(), + name: Some("OAuth2 Gateway KYC Verification".to_string()), + purpose: Some("Verify user credentials for Taler Exchange".to_string()), + format: Some(format), + input_descriptors: vec![input_descriptor], + } } // POST /token @@ -73,7 +262,6 @@ pub async fn token( } // TODO: Validate nonce/code - // TODO: Change to cryptographically secure token let access_token = crypto::generate_nonce(); @@ -128,3 +316,93 @@ pub async fn notification_webhook( StatusCode::OK } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_presentation_definition_single_attribute() { + let scope = "first_name"; + let pd = build_presentation_definition(scope); + + // Verify structure + assert!(!pd.id.is_empty()); + assert_eq!(pd.name, Some("OAuth2 Gateway KYC Verification".to_string())); + assert_eq!(pd.input_descriptors.len(), 1); + + // Verify fields + let fields = &pd.input_descriptors[0].constraints.fields; + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, vec!["$.first_name"]); + assert_eq!(fields[0].name, Some("first_name".to_string())); + } + + #[test] + fn test_build_presentation_definition_multiple_attributes() { + let scope = "first_name last_name date_of_birth"; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + assert_eq!(fields.len(), 3); + + assert_eq!(fields[0].path, vec!["$.first_name"]); + assert_eq!(fields[1].path, vec!["$.last_name"]); + assert_eq!(fields[2].path, vec!["$.date_of_birth"]); + } + + #[test] + fn test_build_presentation_definition_extra_whitespace() { + let scope = "first_name last_name"; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + // split_whitespace handles multiple spaces correctly + assert_eq!(fields.len(), 2); + assert_eq!(fields[0].path, vec!["$.first_name"]); + assert_eq!(fields[1].path, vec!["$.last_name"]); + } + + #[test] + fn test_build_presentation_definition_empty_scope() { + let scope = ""; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + assert_eq!(fields.len(), 0); + } + + #[test] + fn test_build_presentation_definition_format() { + let scope = "first_name"; + let pd = build_presentation_definition(scope); + + // Verify format is present + assert!(pd.format.is_some()); + let format = pd.format.unwrap(); + + // Verify vc+sd-jwt format + assert!(format.contains_key("vc+sd-jwt")); + let alg = &format["vc+sd-jwt"]; + assert_eq!(alg.sd_jwt_alg_values, vec!["ES256"]); + assert_eq!(alg.kb_jwt_alg_values, vec!["ES256"]); + } + + #[test] + fn test_build_presentation_definition_input_descriptor_structure() { + let scope = "first_name"; + let pd = build_presentation_definition(scope); + + let descriptor = &pd.input_descriptors[0]; + + // Verify descriptor has valid UUID + assert!(!descriptor.id.is_empty()); + + // Verify descriptor has proper metadata + assert_eq!(descriptor.name, Some("Requested credentials".to_string())); + assert_eq!(descriptor.purpose, Some("KYC verification via OAuth2 Gateway".to_string())); + + // Verify format is specified at descriptor level too + assert!(descriptor.format.is_some()); + } +} diff --git a/oauth2_gateway/src/models.rs b/oauth2_gateway/src/models.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use std::collections::HashMap; // Setup endpoint #[derive(Debug, Deserialize, Serialize)] @@ -65,3 +66,150 @@ impl ErrorResponse { } } } + +// Swiyu Verifier API models + +/// Request body for creating a verification with Swiyu Verifier +/// POST /management/api/verifications +#[derive(Debug, Serialize, Deserialize)] +pub struct SwiyuCreateVerificationRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub accepted_issuer_dids: Option<Vec<String>>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub trust_anchors: Option<Vec<TrustAnchor>>, + + /// If omitted, Swiyu defaults to true (beta requires true) + #[serde(skip_serializing_if = "Option::is_none")] + pub jwt_secured_authorization_request: Option<bool>, + + /// Response mode: how the wallet sends the response back + /// REQUIRED - will throw NullPointerException if omitted + pub response_mode: ResponseMode, + + pub presentation_definition: PresentationDefinition, + + pub configuration_override: ConfigurationOverride, + + /// Optional - (wallet migration in progress) + #[serde(skip_serializing_if = "Option::is_none")] + pub dcql_query: Option<serde_json::Value>, +} + +/// Trust anchor for credential verification +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TrustAnchor { + pub did: String, + pub trust_registry_uri: String, +} + +/// Response mode type +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ResponseMode { + /// Wallet sends a clear text response + DirectPost, + + /// Wallet sends an encrypted response + #[serde(rename = "direct_post.jwt")] + DirectPostJwt, +} + +/// Configuration override for a specific verification +/// Can be empty object with all fields set to None +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ConfigurationOverride { + /// Override for the EXTERNAL_URL - the url the wallet should call + #[serde(skip_serializing_if = "Option::is_none")] + pub external_url: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub verifier_did: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_method: Option<String>, + + /// ID of the key in the HSM + #[serde(skip_serializing_if = "Option::is_none")] + pub key_id: Option<String>, + + /// The pin which protects the key in the HSM + #[serde(skip_serializing_if = "Option::is_none")] + pub key_pin: Option<String>, +} + +/// Presentation Definition according to DIF Presentation Exchange +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PresentationDefinition { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option<HashMap<String, FormatAlgorithm>>, + pub input_descriptors: Vec<InputDescriptor>, +} + +/// Input descriptor describing required credential attributes +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct InputDescriptor { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option<HashMap<String, FormatAlgorithm>>, + pub constraints: Constraint, +} + +/// Constraints on credential fields +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Constraint { + pub fields: Vec<Field>, +} + +/// Field specification with JSONPath +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Field { + pub path: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option<Filter>, +} + +/// Filter for field constraints +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Filter { + #[serde(rename = "type")] + pub filter_type: String, + #[serde(rename = "const", skip_serializing_if = "Option::is_none")] + pub const_value: Option<String>, +} + +/// Format algorithm specification for SD-JWT +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FormatAlgorithm { + #[serde(rename = "sd-jwt_alg_values")] + pub sd_jwt_alg_values: Vec<String>, + #[serde(rename = "kb-jwt_alg_values")] + pub kb_jwt_alg_values: Vec<String>, +} + +/// Response from Swiyu Verifier after creating verification +#[derive(Debug, Serialize, Deserialize)] +pub struct SwiyuVerificationResponse { + #[serde(rename = "verificationId")] + pub id: Uuid, + pub verification_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_deeplink: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option<String>, +}