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:
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>,
+}