commit 6193344eebe378e5203de89387d5d73b1f52e2d1
parent 1bb77bee12268b5102f1dfa5ab7ad065cf83c3b4
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date: Mon, 3 Nov 2025 23:15:10 +0100
Merge branch 'oauth2'
Diffstat:
12 files changed, 1564 insertions(+), 83 deletions(-)
diff --git a/oauth2_gateway/Cargo.toml b/oauth2_gateway/Cargo.toml
@@ -49,4 +49,6 @@ base64 = "0.22.1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
[dev-dependencies]
-tempfile = "3.8"
-\ No newline at end of file
+tempfile = "3.8"
+wiremock = "0.6"
+serial_test = "3.2"
+\ No newline at end of file
diff --git a/oauth2_gateway/config.example.ini b/oauth2_gateway/config.example.ini
@@ -5,8 +5,8 @@
[server]
host = 127.0.0.1
-port = 8080
+port = 9090
[database]
# PostgreSQL connection string
-url = postgresql://oauth2gw:password@localhost/oauth2gw
-\ No newline at end of file
+url = postgresql://oauth2gw:password@localhost/oauth2gw
diff --git a/oauth2_gateway/scripts/setup_test_db.sh b/oauth2_gateway/scripts/setup_test_db.sh
@@ -59,6 +59,14 @@ for patch_file in "$MIGRATIONS_DIR"/oauth2gw-*.sql; do
fi
done
+# Grant schema privileges (minimum required for gateway to operate)
+echo "Granting schema privileges..."
+$PSQL_CMD -d "$DB_NAME" -c "GRANT USAGE ON SCHEMA oauth2gw TO $DB_USER;"
+$PSQL_CMD -d "$DB_NAME" -c "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA oauth2gw TO $DB_USER;"
+$PSQL_CMD -d "$DB_NAME" -c "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA oauth2gw TO $DB_USER;"
+$PSQL_CMD -d "$DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA oauth2gw GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $DB_USER;"
+$PSQL_CMD -d "$DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA oauth2gw GRANT USAGE, SELECT ON SEQUENCES TO $DB_USER;"
+
# Seed test data
echo "Seeding test data..."
$PSQL_CMD -d "$DB_NAME" <<EOF
@@ -66,7 +74,7 @@ INSERT INTO oauth2gw.clients (client_id, client_secret, notification_url, verifi
VALUES (
'test-exchange-001',
'test-secret-123',
- 'http://localhost:9000/kyc/webhook',
+ 'http://localhost:9090/kyc/webhook',
'http://localhost:8080',
'/management/api/verifications'
)
diff --git a/oauth2_gateway/scripts/teardown_test_db.sh b/oauth2_gateway/scripts/teardown_test_db.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+
+DB_PORT=5432
+DB_NAME=oauth2gw
+DB_USER=oauth2gw
+DB_ADMIN=${DB_ADMIN:-}
+
+echo "Tearing down OAuth2 Gateway test database..."
+echo
+echo "WARNING: This will destroy all data!"
+echo
+
+if ! command -v psql &> /dev/null
+then
+ echo "psql could not be found, please install PostgreSQL first."
+ exit 1
+fi
+
+if ! pg_isready -h localhost -p "$DB_PORT" >/dev/null 2>&1; then
+ echo "PostgreSQL is not running."
+ exit 1
+fi
+
+PSQL_CMD="psql -h localhost -p $DB_PORT"
+if [ -n "$DB_ADMIN" ]; then
+ PSQL_CMD="$PSQL_CMD -U $DB_ADMIN"
+fi
+
+echo "Dropping oauth2gw schema and unregistering all patches..."
+MIGRATIONS_DIR="$(dirname "$0")/../migrations"
+$PSQL_CMD -d "$DB_NAME" -f "$MIGRATIONS_DIR/drop.sql"
+
+echo
+echo "Teardown completed."
+echo
+echo "Schema dropped. Database '$DB_NAME' still exists but is empty."
+echo "Run scripts/setup_test_db.sh to rebuild schema."
+echo
+echo "To completely remove the database, run:"
+echo " psql -h localhost -p $DB_PORT -d postgres -c \"DROP DATABASE $DB_NAME;\""
diff --git a/oauth2_gateway/scripts/test_integration.sh b/oauth2_gateway/scripts/test_integration.sh
@@ -0,0 +1,70 @@
+#!/bin/bash
+
+set -e
+
+GATEWAY_URL="http://localhost:9090"
+CLIENT_ID="test-exchange-001"
+SCOPE="age_over_18"
+QR_CODE_FILE="oauth2gw_qr_code.png"
+
+echo "================================================================"
+echo "OAuth2 Gateway Integration Test"
+echo "================================================================"
+echo ""
+echo "Prerequisites:"
+echo " - OAuth2 Gateway running at $GATEWAY_URL"
+echo " - Swiyu Verifier running"
+echo " - Test database seeded with test-exchange-001 client"
+echo " - qrencode installed (for QR code generation)"
+echo ""
+echo "================================================================"
+echo ""
+
+echo "[1/2] Testing /setup endpoint..."
+SETUP_RESPONSE=$(curl -s -X POST "$GATEWAY_URL/setup/$CLIENT_ID" \
+ -H "Content-Type: application/json" \
+ -d "{\"scope\": \"$SCOPE\"}")
+
+echo "Response: $SETUP_RESPONSE"
+
+NONCE=$(echo "$SETUP_RESPONSE" | jq -r '.nonce')
+if [ -z "$NONCE" ] || [ "$NONCE" = "null" ]; then
+ echo "FAILED: No nonce returned"
+ exit 1
+fi
+echo "SUCCESS: Received nonce: $NONCE"
+echo ""
+
+echo "[2/2] Testing /authorize endpoint..."
+AUTHORIZE_RESPONSE=$(curl -s -X GET "$GATEWAY_URL/authorize/$NONCE")
+
+echo "Response: $AUTHORIZE_RESPONSE"
+echo ""
+
+VERIFICATION_URL=$(echo "$AUTHORIZE_RESPONSE" | jq -r '.verification_url')
+VERIFICATION_ID=$(echo "$AUTHORIZE_RESPONSE" | jq -r '.verificationId')
+
+if [ -z "$VERIFICATION_URL" ] || [ "$VERIFICATION_URL" = "null" ]; then
+ echo "FAILED: No verification_url returned"
+ exit 1
+fi
+
+if [ -z "$VERIFICATION_ID" ] || [ "$VERIFICATION_ID" = "null" ]; then
+ echo "FAILED: No verificationId returned"
+ exit 1
+fi
+
+echo "SUCCESS: Received verification URL: $VERIFICATION_URL"
+echo "SUCCESS: Received verification ID: $VERIFICATION_ID"
+echo ""
+
+echo "Generating QR code..."
+echo "$VERIFICATION_URL" | tee /dev/tty | xargs qrencode -o "$QR_CODE_FILE"
+echo "QR code saved to: $QR_CODE_FILE"
+echo ""
+
+open "$QR_CODE_FILE"
+
+echo "================================================================"
+echo "Integration test completed successfully"
+echo "================================================================"
diff --git a/oauth2_gateway/src/config.rs b/oauth2_gateway/src/config.rs
@@ -37,7 +37,7 @@ impl Config {
.to_string(),
port: server_section
.get("port")
- .unwrap_or("8080")
+ .unwrap_or("9090")
.parse()
.context("Invalid port")?,
};
@@ -108,7 +108,7 @@ url = postgresql://localhost/oauth2gw
let config = Config::from_file(temp_file.path()).unwrap();
assert_eq!(config.server.host, "127.0.0.1");
- assert_eq!(config.server.port, 8080);
+ assert_eq!(config.server.port, 9090);
}
#[test]
@@ -136,7 +136,7 @@ url = postgresql://localhost/oauth2gw
r#"
[server]
host = 127.0.0.1
-port = 8080
+port = 9090
"#
)
.unwrap();
@@ -154,7 +154,7 @@ port = 8080
r#"
[server]
host = 127.0.0.1
-port = 8080
+port = 9090
[database]
# url is missing
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>,
+}
diff --git a/oauth2_gateway/tests/api_tests.rs b/oauth2_gateway/tests/api_tests.rs
@@ -2,25 +2,68 @@ use axum::{routing::*, Router};
use axum_test::TestServer;
use oauth2_gateway::{config::*, db, handlers, models::*, state::AppState};
use serde_json::json;
+use wiremock::{MockServer, Mock, ResponseTemplate};
+use wiremock::matchers::{method, path};
+use serial_test::serial;
-/// Create a test app with mock configuration
-/// Note: These tests use handlers that return mock data (no real DB operations yet)
-async fn create_test_app() -> Router {
+
+// API endpoint tests with Mocked Swiyu API and Database
+
+/// Helper to get test database URL
+fn get_test_database_url() -> String {
+ std::env::var("TEST_DATABASE_URL")
+ .unwrap_or_else(|_| "postgresql://oauth2gw:password@localhost:5432/oauth2gw".to_string())
+}
+
+/// Helper to setup test database and clean data
+async fn setup_test_db() -> sqlx::PgPool {
+ let pool = db::create_pool(&get_test_database_url())
+ .await
+ .expect("Failed to connect to test database");
+ clean_test_data(&pool).await;
+ pool
+}
+
+/// Clean all test data (in correct FK order)
+async fn clean_test_data(pool: &sqlx::PgPool) {
+ sqlx::query("DELETE FROM oauth2gw.notification_logs")
+ .execute(pool)
+ .await
+ .expect("Failed to clean notification_logs");
+ sqlx::query("DELETE FROM oauth2gw.webhook_logs")
+ .execute(pool)
+ .await
+ .expect("Failed to clean webhook_logs");
+ sqlx::query("DELETE FROM oauth2gw.access_tokens")
+ .execute(pool)
+ .await
+ .expect("Failed to clean access_tokens");
+ sqlx::query("DELETE FROM oauth2gw.verification_sessions")
+ .execute(pool)
+ .await
+ .expect("Failed to clean verification_sessions");
+ sqlx::query("DELETE FROM oauth2gw.clients")
+ .execute(pool)
+ .await
+ .expect("Failed to clean clients");
+}
+
+/// Helper to teardown test database
+async fn teardown_test_db(pool: &sqlx::PgPool) {
+ clean_test_data(pool).await;
+}
+
+/// Create test app with custom verifier base URL (for mock server)
+async fn create_test_app(pool: sqlx::PgPool) -> Router {
let config = Config {
server: ServerConfig {
host: "127.0.0.1".to_string(),
- port: 8080,
+ port: 9090,
},
database: DatabaseConfig {
- // Use a test db
- url: std::env::var("TEST_DATABASE_URL")
- .unwrap_or_else(|_| "postgresql://localhost/oauth2gw_test".to_string()),
+ url: get_test_database_url(),
},
};
-
- let pool = db::create_pool(&config.database.url)
- .await
- .expect("Failed to connect to test database");
let state = AppState::new(config, pool);
@@ -35,85 +78,234 @@ async fn create_test_app() -> Router {
}
#[tokio::test]
-async fn test_health_check() {
- let app = create_test_app().await;
+#[serial]
+async fn test_setup_with_real_database() {
+ let pool = setup_test_db().await;
+
+ db::clients::register_client(
+ &pool,
+ "test-client-1",
+ "secret123",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .expect("Failed to register client");
+
+ let app = create_test_app(pool.clone()).await;
let server = TestServer::new(app).unwrap();
- let response = server.get("/health").await;
+ let response = server
+ .post("/setup/test-client-1")
+ .json(&json!({"scope": "first_name last_name date_of_birth"}))
+ .await;
response.assert_status_ok();
- response.assert_json(&json!({
- "status": "healthy",
- "service": "oauth2-gateway",
- // "version": env!("CARGO_PKG_VERSION")
- }));
+ let body: SetupResponse = response.json();
+ assert!(!body.nonce.is_empty());
+
+ let session = db::sessions::get_session_by_nonce(&pool, &body.nonce)
+ .await
+ .expect("Failed to query session")
+ .expect("Session not found");
+
+ assert_eq!(session.scope, "first_name last_name date_of_birth");
+ assert_eq!(session.status, db::sessions::SessionStatus::Pending);
+
+ teardown_test_db(&pool).await;
}
#[tokio::test]
-async fn test_setup_endpoint() {
- let app = create_test_app().await;
+#[serial]
+async fn test_setup_with_nonexistent_client() {
+ let pool = setup_test_db().await;
+ let app = create_test_app(pool.clone()).await;
let server = TestServer::new(app).unwrap();
let response = server
- .post("/setup/test-client")
- .json(&json!({
- "scope": "first_name last_name age_over_18"
- }))
+ .post("/setup/nonexistent-client")
+ .json(&json!({"scope": "test"}))
.await;
- response.assert_status_ok();
+ response.assert_status(axum::http::StatusCode::NOT_FOUND);
+ let body: ErrorResponse = response.json();
+ assert_eq!(body.error, "client_not_found");
- // Check response has a nonce
- let body: SetupResponse = response.json();
- assert!(!body.nonce.is_empty());
- println!("Generated nonce: {}", body.nonce);
+ teardown_test_db(&pool).await;
}
#[tokio::test]
-async fn test_setup_different_clients() {
- let app = create_test_app().await;
+#[serial]
+async fn test_authorize_successful_flow_with_mocked_swiyu() {
+ let pool = setup_test_db().await;
+
+ let mock_server = MockServer::start().await;
+
+ db::clients::register_client(
+ &pool,
+ "test-client-2",
+ "secret123",
+ "https://client.example.com/notify",
+ &mock_server.uri(), // Use mock verifier
+ Some("/management/api/verifications"),
+ )
+ .await
+ .expect("Failed to register client");
+
+ // mock Swiyu response
+ Mock::given(method("POST"))
+ .and(path("/management/api/verifications"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
+ "verificationId": "550e8400-e29b-41d4-a716-446655440000",
+ "verification_url": "https://wallet.example.com/verify?request=abc123",
+ "verification_deeplink": "swiyu://verify/abc123",
+ "state": "PENDING"
+ })))
+ .mount(&mock_server)
+ .await;
+
+ let app = create_test_app(pool.clone()).await;
let server = TestServer::new(app).unwrap();
- let response1 = server
- .post("/setup/client-1")
- .json(&json!({"scope": "first_name"}))
+ let setup_response = server
+ .post("/setup/test-client-2")
+ .json(&json!({"scope": "first_name last_name"}))
+ .await;
+
+ setup_response.assert_status_ok();
+ let setup: SetupResponse = setup_response.json();
+
+ // Call authorize
+ let response = server
+ .get(&format!("/authorize/{}", setup.nonce))
.await;
- let response2 = server
- .post("/setup/client-2")
- .json(&json!({"scope": "last_name"}))
+ response.assert_status_ok();
+ let body: AuthorizeResponse = response.json();
+
+ assert_eq!(body.verification_url, "https://wallet.example.com/verify?request=abc123");
+ assert_eq!(body.verification_id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
+
+ let session = db::sessions::get_session_by_nonce(&pool, &setup.nonce)
+ .await
+ .expect("Failed to query session")
+ .expect("Session not found");
+
+ assert_eq!(session.status, db::sessions::SessionStatus::Authorized);
+ assert_eq!(session.verification_url, Some("https://wallet.example.com/verify?request=abc123".to_string()));
+ assert_eq!(session.request_id, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_authorize_with_invalid_nonce() {
+ let pool = setup_test_db().await;
+ let app = create_test_app(pool.clone()).await;
+ let server = TestServer::new(app).unwrap();
+
+ let response = server
+ .get("/authorize/invalid-nonce-12345")
.await;
- response1.assert_status_ok();
- response2.assert_status_ok();
+ response.assert_status(axum::http::StatusCode::NOT_FOUND);
+ let body: ErrorResponse = response.json();
+ assert_eq!(body.error, "session_not_found");
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_authorize_with_expired_session() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "test-client-3",
+ "secret123",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .expect("Failed to register client");
+
+ // Create expired session (negative expiration)
+ sqlx::query(
+ r#"
+ INSERT INTO oauth2gw.verification_sessions (client_id, nonce, scope, expires_at, status)
+ VALUES ($1, $2, $3, CURRENT_TIMESTAMP - INTERVAL '1 hour', 'pending')
+ "#
+ )
+ .bind(client.id)
+ .bind("expired-nonce")
+ .bind("test")
+ .execute(&pool)
+ .await
+ .expect("Failed to create expired session");
- let nonce1: SetupResponse = response1.json();
- let nonce2: SetupResponse = response2.json();
+ let app = create_test_app(pool.clone()).await;
+ let server = TestServer::new(app).unwrap();
- assert_ne!(nonce1.nonce, nonce2.nonce);
+ let response = server
+ .get("/authorize/expired-nonce")
+ .await;
+
+ response.assert_status(axum::http::StatusCode::BAD_REQUEST);
+ let body: ErrorResponse = response.json();
+ assert_eq!(body.error, "session_expired");
+
+ teardown_test_db(&pool).await;
}
#[tokio::test]
-async fn test_authorize_endpoint() {
- let app = create_test_app().await;
+#[serial]
+async fn test_authorize_with_swiyu_api_error() {
+ let pool = setup_test_db().await;
+ let mock_server = MockServer::start().await;
+
+
+ db::clients::register_client(
+ &pool,
+ "test-client-4",
+ "secret123",
+ "https://client.example.com/notify",
+ &mock_server.uri(),
+ Some("/management/api/verifications"),
+ )
+ .await
+ .expect("Failed to register client");
+
+ // mock Swiyu response
+ Mock::given(method("POST"))
+ .and(path("/management/api/verifications"))
+ .respond_with(ResponseTemplate::new(500).set_body_json(json!({
+ "error": "internal_server_error"
+ })))
+ .mount(&mock_server)
+ .await;
+
+ let app = create_test_app(pool.clone()).await;
let server = TestServer::new(app).unwrap();
- // Get a nonce from setup
+ // Setup and authorize
let setup_response = server
- .post("/setup/test-client")
+ .post("/setup/test-client-4")
.json(&json!({"scope": "test"}))
.await;
let setup: SetupResponse = setup_response.json();
- // Authorize with that nonce
let response = server
.get(&format!("/authorize/{}", setup.nonce))
.await;
- response.assert_status_ok();
+ response.assert_status(axum::http::StatusCode::BAD_GATEWAY);
+ let body: ErrorResponse = response.json();
+ assert_eq!(body.error, "verifier_error");
- let body: AuthorizeResponse = response.json();
- assert!(!body.verification_url.is_empty());
- println!("Verification URL: {}", body.verification_url);
+ teardown_test_db(&pool).await;
}
\ No newline at end of file
diff --git a/oauth2_gateway/tests/db_tests.rs b/oauth2_gateway/tests/db_tests.rs
@@ -0,0 +1,729 @@
+// Database integration tests for OAuth2 Gateway
+//
+// These tests require a PostgreSQL database to be running and already migrated.
+// Run scripts/setup_test_db.sh before running tests.
+// Set the TEST_DATABASE_URL environment variable or use the default.
+
+use oauth2_gateway::db;
+use sqlx::PgPool;
+use serial_test::serial;
+
+// Test database URL - can be overridden with TEST_DATABASE_URL env var
+fn get_test_database_url() -> String {
+ std::env::var("TEST_DATABASE_URL")
+ .unwrap_or_else(|_| "postgresql://oauth2gw:password@localhost/oauth2gw".to_string())
+}
+
+/// Setup test database: create pool and clean existing data
+async fn setup_test_db() -> PgPool {
+ let database_url = get_test_database_url();
+ let pool = db::create_pool(&database_url)
+ .await
+ .expect("Failed to connect to test database");
+
+ // Clean up test data (but keep schema)
+ clean_test_data(&pool).await;
+
+ pool
+}
+
+/// Clean all data from tables (but keep schema)
+async fn clean_test_data(pool: &PgPool) {
+ // Delete in order to respect foreign key constraints
+ let _ = sqlx::query("DELETE FROM oauth2gw.notification_logs").execute(pool).await;
+ let _ = sqlx::query("DELETE FROM oauth2gw.webhook_logs").execute(pool).await;
+ let _ = sqlx::query("DELETE FROM oauth2gw.access_tokens").execute(pool).await;
+ let _ = sqlx::query("DELETE FROM oauth2gw.verification_sessions").execute(pool).await;
+ let _ = sqlx::query("DELETE FROM oauth2gw.clients").execute(pool).await;
+}
+
+/// Teardown test database: clean test data
+async fn teardown_test_db(pool: &PgPool) {
+ clean_test_data(pool).await;
+}
+
+// ============================================================================
+// Test 1: Client Management
+// ============================================================================
+
+#[tokio::test]
+#[serial]
+async fn test_client_registration() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "test-client-1",
+ "secret123",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .expect("Failed to register client");
+
+ assert_eq!(client.client_id, "test-client-1");
+ assert_eq!(client.client_secret, "secret123");
+ assert_eq!(client.notification_url, "https://client.example.com/notify");
+ assert_eq!(client.verifier_base_url, "https://verifier.example.com");
+ assert_eq!(client.verifier_management_api_path, "/management/api/verifications");
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_client_lookup() {
+ let pool = setup_test_db().await;
+
+ let registered_client = db::clients::register_client(
+ &pool,
+ "lookup-test-client",
+ "secret456",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ // Lookup by client_id
+ let found_client = db::clients::get_client_by_id(&pool, "lookup-test-client")
+ .await
+ .unwrap()
+ .expect("Client not found");
+
+ assert_eq!(found_client.id, registered_client.id);
+ assert_eq!(found_client.client_id, "lookup-test-client");
+
+ // Lookup by UUID
+ let found_by_uuid = db::clients::get_client_by_uuid(&pool, registered_client.id)
+ .await
+ .unwrap()
+ .expect("Client not found by UUID");
+
+ assert_eq!(found_by_uuid.client_id, "lookup-test-client");
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_client_authentication() {
+ let pool = setup_test_db().await;
+
+ db::clients::register_client(
+ &pool,
+ "auth-test-client",
+ "correct-password",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ // Authenticate with correct password
+ let auth_success = db::clients::authenticate_client(&pool,
+ "auth-test-client",
+ "correct-password")
+ .await
+ .unwrap();
+ assert!(auth_success.is_some(), "Authentication should succeed");
+
+ // Authenticate with wrong password
+ let auth_fail = db::clients::authenticate_client(&pool,
+ "auth-test-client",
+ "wrong-password")
+ .await
+ .unwrap();
+ assert!(auth_fail.is_none(), "Authentication should fail with wrong password");
+
+ // Authenticate non-existent client
+ let auth_not_found = db::clients::authenticate_client(&pool,
+ "non-existent",
+ "password")
+ .await
+ .unwrap();
+ assert!(auth_not_found.is_none(), "Authentication should fail for non-existent client");
+
+ teardown_test_db(&pool).await;
+}
+
+// ============================================================================
+// Test 3: Verification Session Lifecycle
+// ============================================================================
+
+#[tokio::test]
+#[serial]
+async fn test_session_creation() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "session-test-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ // Create session
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "test-nonce-123",
+ "first_name last_name age_over_18",
+ 15, // 15 minutes expiration
+ )
+ .await
+ .expect("Failed to create session");
+
+ assert_eq!(session.nonce, "test-nonce-123");
+ assert_eq!(session.scope, "first_name last_name age_over_18");
+ assert_eq!(session.status, db::sessions::SessionStatus::Pending);
+ assert!(session.verification_url.is_none());
+ assert!(session.request_id.is_none());
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_session_lookup_by_nonce() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "nonce-lookup-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let created_session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "unique-nonce-456",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ // Lookup by nonce
+ let found_session = db::sessions::get_session_by_nonce(&pool,
+ "unique-nonce-456")
+ .await
+ .unwrap()
+ .expect("Session not found");
+
+ assert_eq!(found_session.id, created_session.id);
+ assert_eq!(found_session.nonce, "unique-nonce-456");
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_session_status_transitions() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "status-test-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "status-nonce-789",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ // Initial status: pending
+ assert_eq!(session.status, db::sessions::SessionStatus::Pending);
+
+ // Transition to authorized
+ db::sessions::update_session_authorized(
+ &pool,
+ session.id,
+ "https://verifier.example.com/verify?request=abc",
+ "swiyu-request-id-123",
+ )
+ .await
+ .unwrap();
+
+ let updated = db::sessions::get_session_by_nonce(&pool,
+ "status-nonce-789")
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(updated.status, db::sessions::SessionStatus::Authorized);
+ assert_eq!(updated.verification_url.unwrap(), "https://verifier.example.com/verify?request=abc");
+ assert_eq!(updated.request_id.unwrap(), "swiyu-request-id-123");
+ assert!(updated.authorized_at.is_some());
+
+ // Transition to verified
+ db::sessions::mark_session_verified(&pool, session.id)
+ .await
+ .unwrap();
+
+ let verified = db::sessions::get_session_by_nonce(&pool,
+ "status-nonce-789")
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(verified.status, db::sessions::SessionStatus::Verified);
+ assert!(verified.verified_at.is_some());
+
+ // Transition to completed
+ db::sessions::mark_session_completed(&pool, session.id)
+ .await
+ .unwrap();
+
+ let completed = db::sessions::get_session_by_nonce(&pool,
+ "status-nonce-789")
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(completed.status, db::sessions::SessionStatus::Completed);
+ assert!(completed.completed_at.is_some());
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_session_lookup_by_request_id() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "request-id-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "request-id-nonce",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ // Update with request_id
+ db::sessions::update_session_authorized(
+ &pool,
+ session.id,
+ "https://verify.url",
+ "swiyu-request-xyz",
+ )
+ .await
+ .unwrap();
+
+ // Lookup by request_id
+ let found = db::sessions::get_session_by_request_id(&pool, "swiyu-request-xyz")
+ .await
+ .unwrap()
+ .expect("Session not found by request_id");
+
+ assert_eq!(found.id, session.id);
+ assert_eq!(found.request_id.unwrap(), "swiyu-request-xyz");
+
+ teardown_test_db(&pool).await;
+}
+
+// ============================================================================
+// Test 4: Access Token Management
+// ============================================================================
+
+#[tokio::test]
+#[serial]
+async fn test_access_token_creation() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "token-test-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "token-nonce",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ // Create access token
+ let token = db::tokens::create_access_token(
+ &pool,
+ session.id,
+ "bearer-token-abc123",
+ 3600, // 1 hour
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(token.token, "bearer-token-abc123");
+ assert_eq!(token.token_type, "Bearer");
+ assert_eq!(token.session_id, session.id);
+ assert_eq!(token.revoked, false);
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_access_token_verification() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "verify-token-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "verify-nonce",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ // Create token
+ db::tokens::create_access_token(
+ &pool,
+ session.id,
+ "valid-token-xyz",
+ 3600,
+ )
+ .await
+ .unwrap();
+
+ // Verify valid token
+ let verified = db::tokens::verify_access_token(&pool,
+ "valid-token-xyz")
+ .await
+ .unwrap();
+ assert!(verified.is_some(), "Token should be valid");
+
+ // Verify non-existent token
+ let not_found = db::tokens::verify_access_token(&pool,
+ "non-existent-token")
+ .await
+ .unwrap();
+ assert!(not_found.is_none(), "Token should not be found");
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_access_token_revocation() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "revoke-token-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "revoke-nonce",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ db::tokens::create_access_token(
+ &pool,
+ session.id,
+ "token-to-revoke",
+ 3600,
+ )
+ .await
+ .unwrap();
+
+ // Token should be valid initially
+ let valid = db::tokens::verify_access_token(&pool, "token-to-revoke")
+ .await
+ .unwrap();
+ assert!(valid.is_some());
+
+ // Revoke token
+ let revoked = db::tokens::revoke_token(&pool, "token-to-revoke")
+ .await
+ .unwrap();
+ assert!(revoked, "Token should be revoked");
+
+ // Token should be invalid after revocation
+ let invalid = db::tokens::verify_access_token(&pool, "token-to-revoke")
+ .await
+ .unwrap();
+ assert!(invalid.is_none(), "Revoked token should be invalid");
+
+ teardown_test_db(&pool).await;
+}
+
+// ============================================================================
+// Test 5: Audit Logging
+// ============================================================================
+
+#[tokio::test]
+#[serial]
+async fn test_webhook_logging() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "webhook-log-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "webhook-nonce",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ let payload = serde_json::json!({
+ "nonce": "webhook-nonce",
+ "verification_complete": true
+ });
+
+ // Log webhook received
+ let log_id = db::logs::log_webhook_received(
+ &pool,
+ Some("swiyu-req-123"),
+ Some(session.id),
+ &payload,
+ )
+ .await
+ .unwrap();
+
+ // Mark as processed
+ db::logs::mark_webhook_processed(&pool, log_id, 200)
+ .await
+ .unwrap();
+
+ // Query logs
+ let logs = db::logs::get_webhook_logs_for_session(&pool, session.id)
+ .await
+ .unwrap();
+
+ assert_eq!(logs.len(), 1);
+ assert_eq!(logs[0].processed, true);
+ assert_eq!(logs[0].status_code.unwrap(), 200);
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_notification_logging() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "notif-log-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "notif-nonce",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ let payload = serde_json::json!({
+ "nonce": "notif-nonce",
+ "verification_complete": true
+ });
+
+ // Log notification sent
+ let log_id = db::logs::log_notification_sent(
+ &pool,
+ session.id,
+ client.id,
+ "https://client.example.com/notify",
+ &payload,
+ )
+ .await
+ .unwrap();
+
+ // Mark as successful
+ db::logs::mark_notification_success(&pool, log_id, 200)
+ .await
+ .unwrap();
+
+ // Query logs
+ let logs = db::logs::get_notification_logs_for_session(&pool, session.id)
+ .await
+ .unwrap();
+
+ assert_eq!(logs.len(), 1);
+ assert_eq!(logs[0].success, true);
+ assert_eq!(logs[0].status_code.unwrap(), 200);
+
+ teardown_test_db(&pool).await;
+}
+
+// ============================================================================
+// Test 6: Garbage Collection
+// ============================================================================
+
+#[tokio::test]
+#[serial]
+async fn test_expired_session_marking() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "gc-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ // Create session with negative expiration (already expired)
+ db::sessions::create_session(
+ &pool,
+ client.id,
+ "expired-nonce",
+ "scope",
+ -10, // Expired 10 minutes ago
+ )
+ .await
+ .unwrap();
+
+ // Mark expired sessions
+ let marked = db::sessions::mark_expired_sessions(&pool)
+ .await
+ .unwrap();
+
+ assert!(marked > 0, "Should mark at least one session as expired");
+
+ // Verify session is marked as expired
+ let session = db::sessions::get_session_by_nonce(&pool, "expired-nonce")
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(session.status, db::sessions::SessionStatus::Expired);
+
+ teardown_test_db(&pool).await;
+}
+
+#[tokio::test]
+#[serial]
+async fn test_old_session_deletion() {
+ let pool = setup_test_db().await;
+
+ let client = db::clients::register_client(
+ &pool,
+ "delete-gc-client",
+ "secret",
+ "https://client.example.com/notify",
+ "https://verifier.example.com",
+ None,
+ )
+ .await
+ .unwrap();
+
+ let session = db::sessions::create_session(
+ &pool,
+ client.id,
+ "old-nonce",
+ "scope",
+ 15,
+ )
+ .await
+ .unwrap();
+
+ // Mark as completed
+ db::sessions::mark_session_completed(&pool, session.id)
+ .await
+ .unwrap();
+
+ // Delete sessions older than 0 days (should delete everything)
+ let deleted = db::sessions::delete_old_sessions(&pool, 0)
+ .await
+ .unwrap();
+
+ assert!(deleted > 0, "Should delete at least one session");
+
+ // Verify session is deleted
+ let not_found = db::sessions::get_session_by_nonce(&pool, "old-nonce")
+ .await
+ .unwrap();
+ assert!(not_found.is_none(), "Session should be deleted");
+
+ teardown_test_db(&pool).await;
+}
diff --git a/swiyu-verifier/api_requests/post_management_api_verifications.sh b/swiyu-verifier/api_requests/post_management_api_verifications.sh
@@ -10,12 +10,27 @@ if [ $# -eq 0 ]; then
exit 1
fi
+# Determine the input file (add .json if not present and file doesn't exist)
+if [ -f "$1" ]; then
+ input_file="$1"
+elif [ -f "${1}.json" ]; then
+ input_file="${1}.json"
+else
+ echo "Error: File '$1' or '${1}.json' not found"
+ exit 1
+fi
+
+base_name="${input_file%.json}"
+
+response_file="${base_name}_response.json"
+qr_code_file="${base_name}_qr_code.png"
+
curl -X POST http://localhost:8080/management/api/verifications \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
- -d @"$1" \
- | jq -r '.verification_url' | tee /dev/tty | xargs qrencode -o qr_code.png
+ -d @"$input_file" \
+ | tee "$response_file" | jq -r '.verification_url' | tee /dev/tty | xargs qrencode -o "$qr_code_file"
# open .png with default image viewer app
-open qr_code.png
+open "$qr_code_file"