kych

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

commit 934160a4cda12497115e3ea7bf2d1e27a520dc59
parent 44ffe5abaefd13a0b6ec7bf6715579bfb74844e3
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Mon, 19 Jan 2026 22:47:44 +0100

Merge branch 'bug_fix'

Diffstat:
Mkych_oauth2_gateway/Cargo.toml | 10----------
Dkych_oauth2_gateway/env.example | 8--------
Akych_oauth2_gateway/kych.conf.example | 29+++++++++++++++++++++++++++++
Mkych_oauth2_gateway/oauth2_gatewaydb/oauth2gw-0001.sql | 57++-------------------------------------------------------
Mkych_oauth2_gateway/src/bin/client_management_cli.rs | 39++++++++++-----------------------------
Dkych_oauth2_gateway/src/bin/webhook_worker.rs | 102-------------------------------------------------------------------------------
Mkych_oauth2_gateway/src/config.rs | 42+++++++++++++++++++++++++++++++++++-------
Mkych_oauth2_gateway/src/db/clients.rs | 39++++++++++++++++-----------------------
Mkych_oauth2_gateway/src/db/mod.rs | 1-
Dkych_oauth2_gateway/src/db/notification_webhooks.rs | 220-------------------------------------------------------------------------------
Mkych_oauth2_gateway/src/db/sessions.rs | 16+++++++++-------
Mkych_oauth2_gateway/src/handlers.rs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mkych_oauth2_gateway/src/models.rs | 6------
Dkych_oauth2_gateway/src/worker.rs | 258-------------------------------------------------------------------------------
Dkych_oauth2_gateway/tests/client_cli.rs | 271-------------------------------------------------------------------------------
Dkych_oauth2_gateway/tests/db.rs | 443-------------------------------------------------------------------------------
16 files changed, 183 insertions(+), 1459 deletions(-)

diff --git a/kych_oauth2_gateway/Cargo.toml b/kych_oauth2_gateway/Cargo.toml @@ -19,9 +19,7 @@ path = "src/bin/client_management_cli.rs" [dependencies] # Web framework axum = "0.8.6" -axum-test = "18.1.0" tokio = { version = "1.48.0", features = ["full"] } -tower = "0.5" tower-http = { version = "0.6.6", features = ["trace", "fs"] } # Serialization @@ -46,9 +44,6 @@ tracing-subscriber = { version = "0.3.20", features = ["env-filter", "local-time # Error handling anyhow = "1.0.100" -# Environment -dotenvy = "0.15" - # Cryptography rand = "0.8.5" bcrypt = "0.15" @@ -62,8 +57,3 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chro # Templates askama = "0.12" - -[dev-dependencies] -tempfile = "3.8" -wiremock = "0.6" -serial_test = "3.2.0" diff --git a/kych_oauth2_gateway/env.example b/kych_oauth2_gateway/env.example @@ -1,8 +0,0 @@ -DB_PORT="" -DB_NAME="" -DB_USER="" -DB_PASS="" - -DATABASE_URL="" -TEST_DATABASE_URL="" - diff --git a/kych_oauth2_gateway/kych.conf.example b/kych_oauth2_gateway/kych.conf.example @@ -0,0 +1,29 @@ +[kych-oauth2-gateway] +#HOST = +#PORT = +UNIXPATH = +UNIXPATH_MODE = 666 +DATABASE = +NONCE_BYTES = 32 +TOKEN_BYTES = 32 +AUTH_CODE_BYTES = 32 +AUTH_CODE_TTL_MINUTES = 10 +#ALLOWED_SCOPES = {family_name, given_name, birth_date} + +# ---- Clients (one section per client) ---- + +[client_example] +CLIENT_ID = 1 +CLIENT_SECRET = secret +VERIFIER_URL = https://swiyu-verifier9999.ch +VERIFIER_MANAGEMENT_API_PATH = /management/api/verifications +REDIRECT_URI = https://kych-oauth2-gateway-client.com/kych-providers/kych-redirect +ACCEPTED_ISSUER_DIDS = {did:tdw:trust_this_issuer} + +# [client_2] +# CLIENT_ID = client_staging_01 +# CLIENT_SECRET = another_secret +# VERIFIER_URL = https://verifier-staging.example.com +# VERIFIER_MANAGEMENT_API_PATH = /api/v1/verifications +# REDIRECT_URI = https://staging.example.com/callback +# ACCEPTED_ISSUER_DIDS = {did:key:staging1} diff --git a/kych_oauth2_gateway/oauth2_gatewaydb/oauth2gw-0001.sql b/kych_oauth2_gateway/oauth2_gatewaydb/oauth2gw-0001.sql @@ -13,10 +13,9 @@ CREATE TABLE IF NOT EXISTS clients ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), client_id VARCHAR(255) UNIQUE NOT NULL, secret_hash VARCHAR(255) NOT NULL, - webhook_url TEXT NOT NULL, verifier_url TEXT NOT NULL, verifier_management_api_path VARCHAR(255) DEFAULT '/management/api/verifications', - redirect_uri TEXT, + redirect_uri TEXT NOT NULL, accepted_issuer_dids TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP @@ -31,10 +30,8 @@ COMMENT ON COLUMN clients.redirect_uri IS 'Default OAuth2 redirect URI for this client'; COMMENT ON COLUMN clients.accepted_issuer_dids IS 'Comma-separated list of accepted DID issuers for credential verification'; -COMMENT ON COLUMN clients.webhook_url - IS 'Client URL where oauth2 gateway will callback'; COMMENT ON COLUMN clients.verifier_url - IS 'Client URL where oauth2 gateway will callback'; + IS 'Base URL of the Swiyu verifier'; COMMENT ON COLUMN clients.verifier_management_api_path IS 'Swiyu verifier api endpoint to create verification requests'; @@ -136,55 +133,5 @@ COMMENT ON COLUMN authorization_codes.code COMMENT ON COLUMN authorization_codes.used IS 'Whether code has been exchanged for an access token'; -CREATE TABLE IF NOT EXISTS notification_pending_webhooks ( - webhook_pending_serial BIGSERIAL PRIMARY KEY, - session_id UUID NOT NULL REFERENCES verification_sessions(id) ON DELETE CASCADE, - client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE, - next_attempt INT8 NOT NULL DEFAULT 0, - retries INT4 NOT NULL DEFAULT 0, - url TEXT NOT NULL, - http_method TEXT NOT NULL DEFAULT 'POST', - header TEXT, - body TEXT NOT NULL -); - -CREATE INDEX idx_notification_pending_webhooks_next_attempt - ON notification_pending_webhooks(next_attempt); -CREATE INDEX idx_notification_pending_webhooks_session_id - ON notification_pending_webhooks(session_id); - -COMMENT ON TABLE notification_pending_webhooks - IS 'Pending client notifications to be sent by background worker'; -COMMENT ON COLUMN notification_pending_webhooks.next_attempt - IS 'Unix timestamp when to attempt sending (0 = execute now, max value = never retry)'; -COMMENT ON COLUMN notification_pending_webhooks.retries - IS 'Number of failed delivery attempts'; -COMMENT ON COLUMN notification_pending_webhooks.url - IS 'Client webhook URL to POST notification'; -COMMENT ON COLUMN notification_pending_webhooks.http_method - IS 'HTTP method for webhook (always POST for notifications)'; -COMMENT ON COLUMN notification_pending_webhooks.header - IS 'Newline-separated HTTP headers for the webhook request'; -COMMENT ON COLUMN notification_pending_webhooks.body - IS 'JSON body to send (stringified, contains nonce, status, code, verification_id)'; - --- Trigger function to notify webhook worker when new webhooks are queued -CREATE OR REPLACE FUNCTION notify_webhook_pending() -RETURNS TRIGGER AS $$ -BEGIN - -- Notify the webhook worker daemon - PERFORM pg_notify('oauth2gw_webhook_pending', NEW.webhook_pending_serial::TEXT); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_webhook_pending - AFTER INSERT ON notification_pending_webhooks - FOR EACH ROW - EXECUTE FUNCTION notify_webhook_pending(); - -COMMENT ON FUNCTION notify_webhook_pending() - IS 'Sends PostgreSQL NOTIFY to wake up webhook worker when new webhooks are queued'; - -- Complete transaction COMMIT; diff --git a/kych_oauth2_gateway/src/bin/client_management_cli.rs b/kych_oauth2_gateway/src/bin/client_management_cli.rs @@ -6,7 +6,7 @@ //! kych-client-management --config kych.conf list //! kych-client-management --config kych.conf show <client_id> //! kych-client-management --config kych.conf create --client-id <id> --secret <secret> ... -//! kych-client-management --config kych.conf update <client_id> --webhook-url <url> +//! kych-client-management --config kych.conf update <client_id> --redirect-uri <url> //! kych-client-management --config kych.conf sync //! kych-client-management --config kych.conf delete <client_id> @@ -45,10 +45,6 @@ enum Commands { #[arg(long)] secret: String, - /// Webhook URL for notifications - #[arg(long)] - webhook_url: String, - /// Swiyu verifier base URL #[arg(long)] verifier_url: String, @@ -59,9 +55,9 @@ enum Commands { /// Default redirect URI for OAuth2 flow #[arg(long)] - redirect_uri: Option<String>, + redirect_uri: String, - /// Comma-separated list of accepted issuer DIDs + /// Accepted issuer DIDs in braces, e.g. {did1, did2} #[arg(long)] accepted_issuer_dids: Option<String>, }, @@ -70,9 +66,6 @@ enum Commands { client_id: String, #[arg(long)] - webhook_url: Option<String>, - - #[arg(long)] verifier_url: Option<String>, #[arg(long)] @@ -118,7 +111,6 @@ async fn main() -> Result<()> { Commands::Create { client_id, secret, - webhook_url, verifier_url, verifier_api_path, redirect_uri, @@ -128,17 +120,15 @@ async fn main() -> Result<()> { &pool, &client_id, &secret, - &webhook_url, &verifier_url, verifier_api_path.as_deref(), - redirect_uri.as_deref(), + &redirect_uri, accepted_issuer_dids.as_deref(), ) .await? } Commands::Update { client_id, - webhook_url, verifier_url, verifier_api_path, redirect_uri, @@ -147,7 +137,6 @@ async fn main() -> Result<()> { cmd_update_client( &pool, &client_id, - webhook_url.as_deref(), verifier_url.as_deref(), verifier_api_path.as_deref(), redirect_uri.as_deref(), @@ -173,10 +162,9 @@ fn print_client_details(client: &db::clients::Client) { println!("UUID: {}", client.id); println!("Client ID: {}", client.client_id); println!("Secret Hash: {}...", &client.secret_hash[..20.min(client.secret_hash.len())]); - println!("Webhook URL: {}", client.webhook_url); println!("Verifier URL: {}", client.verifier_url); println!("Verifier API Path: {}", client.verifier_management_api_path); - println!("Redirect URI: {}", client.redirect_uri.as_deref().unwrap_or("(not set)")); + println!("Redirect URI(s): {}", client.redirect_uri); println!("Accepted Issuer DIDs: {}", client.accepted_issuer_dids.as_deref().unwrap_or("(not set)")); println!("Created: {}", client.created_at.with_timezone(&Local)); println!("Updated: {}", client.updated_at.with_timezone(&Local)); @@ -220,17 +208,15 @@ async fn cmd_create_client( pool: &sqlx::PgPool, client_id: &str, secret: &str, - webhook_url: &str, verifier_url: &str, verifier_api_path: Option<&str>, - redirect_uri: Option<&str>, + redirect_uri: &str, accepted_issuer_dids: Option<&str>, ) -> Result<()> { let client = db::clients::register_client( pool, client_id, secret, - webhook_url, verifier_url, verifier_api_path, redirect_uri, @@ -249,15 +235,14 @@ async fn cmd_create_client( async fn cmd_update_client( pool: &sqlx::PgPool, client_id: &str, - webhook_url: Option<&str>, verifier_url: Option<&str>, verifier_api_path: Option<&str>, redirect_uri: Option<&str>, accepted_issuer_dids: Option<&str>, ) -> Result<()> { - if webhook_url.is_none() && verifier_url.is_none() && verifier_api_path.is_none() + if verifier_url.is_none() && verifier_api_path.is_none() && redirect_uri.is_none() && accepted_issuer_dids.is_none() { - anyhow::bail!("No fields to update. Specify at least one of: --webhook-url, --verifier-url, --verifier-api-path, --redirect-uri, --accepted-issuer-dids"); + anyhow::bail!("No fields to update. Specify at least one of: --verifier-url, --verifier-api-path, --redirect-uri, --accepted-issuer-dids"); } let client = db::clients::get_client_by_id(pool, client_id) @@ -267,7 +252,6 @@ async fn cmd_update_client( let updated = db::clients::update_client( pool, client.id, - webhook_url, verifier_url, verifier_api_path, redirect_uri, @@ -292,7 +276,6 @@ async fn cmd_delete_client(pool: &sqlx::PgPool, client_id: &str, skip_confirm: b println!("WARNING: This will delete client '{}' and ALL associated data:", client_id); println!(" - All sessions"); println!(" - All tokens"); - println!(" - All pending webhooks"); println!(); print!("Type 'yes' to confirm: "); @@ -339,10 +322,9 @@ async fn cmd_sync_clients(pool: &sqlx::PgPool, config: &Config, prune: bool) -> db::clients::update_client( pool, existing.id, - Some(&client_config.webhook_url), Some(&client_config.verifier_url), Some(&client_config.verifier_management_api_path), - client_config.redirect_uri.as_deref(), + Some(&client_config.redirect_uri), client_config.accepted_issuer_dids.as_deref(), ) .await @@ -356,10 +338,9 @@ async fn cmd_sync_clients(pool: &sqlx::PgPool, config: &Config, prune: bool) -> pool, &client_config.client_id, &client_config.client_secret, - &client_config.webhook_url, &client_config.verifier_url, Some(&client_config.verifier_management_api_path), - client_config.redirect_uri.as_deref(), + &client_config.redirect_uri, client_config.accepted_issuer_dids.as_deref(), ) .await diff --git a/kych_oauth2_gateway/src/bin/webhook_worker.rs b/kych_oauth2_gateway/src/bin/webhook_worker.rs @@ -1,102 +0,0 @@ -//! Webhook worker daemon binary -//! -//! Background process that delivers webhooks to client endpoints. -//! Uses PostgreSQL NOTIFY/LISTEN for event-driven wake-up. -//! -//! Usage: -//! webhook-worker -c config.ini # Run normally -//! webhook-worker -c config.ini -t # Test mode (exit when idle) - -use kych_oauth2_gateway_lib::{config::Config, db, worker}; -use anyhow::Result; -use clap::Parser; -use tokio::signal; -use tokio::sync::watch; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -#[derive(Parser, Debug)] -#[command(name = "webhook-worker")] -#[command(version)] -#[command(about = "Background process that executes webhooks")] -struct Args { - #[arg(short = 'c', long = "config", value_name = "FILE")] - config: String, - - #[arg(short = 't', long = "test")] - test_mode: bool, - - #[arg(short = 'L', long = "log-level", value_name = "LEVEL", default_value = "INFO")] - log_level: String, -} - -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); - - let level = args.log_level.to_lowercase(); - let filter = format!( - "kych_webhook_worker={},kych_oauth2_gateway_lib={},tower_http={},sqlx=warn", - level, level, level - ); - - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| filter.into()), - ) - .with( - tracing_subscriber::fmt::layer() - .compact() - .with_ansi(false) - .with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()), - ) - .init(); - - tracing::info!("Starting webhook worker v{}", env!("CARGO_PKG_VERSION")); - tracing::info!("Loading configuration from: {}", args.config); - - let config = Config::from_file(&args.config)?; - - tracing::info!("Connecting to database: {}", config.database.url); - let pool = db::create_pool(&config.database.url).await?; - - // Set up shutdown signal handling - let (shutdown_tx, shutdown_rx) = watch::channel(false); - - // Spawn signal handler task - tokio::spawn(async move { - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("Failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("Failed to install SIGTERM handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => { - tracing::info!("Received Ctrl+C, initiating shutdown"); - } - _ = terminate => { - tracing::info!("Received SIGTERM, initiating shutdown"); - } - } - - let _ = shutdown_tx.send(true); - }); - - // Run the worker - worker::run_worker(pool, &config.webhook_worker, args.test_mode, shutdown_rx).await?; - - tracing::info!("Webhook worker exited cleanly"); - Ok(()) -} diff --git a/kych_oauth2_gateway/src/config.rs b/kych_oauth2_gateway/src/config.rs @@ -10,6 +10,7 @@ pub struct Config { pub server: ServerConfig, pub database: DatabaseConfig, pub crypto: CryptoConfig, + pub allowed_scopes: Option<Vec<String>>, pub clients: Vec<ClientConfig>, } @@ -56,6 +57,7 @@ pub struct CryptoConfig { pub nonce_bytes: usize, pub token_bytes: usize, pub authorization_code_bytes: usize, + pub authorization_code_ttl_minutes: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -63,10 +65,9 @@ pub struct ClientConfig { pub section_name: String, pub client_id: String, pub client_secret: String, - pub webhook_url: String, pub verifier_url: String, pub verifier_management_api_path: String, - pub redirect_uri: Option<String>, + pub redirect_uri: String, pub accepted_issuer_dids: Option<String>, } @@ -131,6 +132,16 @@ impl Config { .context("Missing AUTH_CODE_BYTES")? .parse() .context("Invalid AUTH_CODE_BYTES")?, + authorization_code_ttl_minutes: main_section + .get("AUTH_CODE_TTL_MINUTES") + .unwrap_or("10") + .parse() + .context("Invalid AUTH_CODE_TTL_MINUTES")?, + }; + + let allowed_scopes = match main_section.get("ALLOWED_SCOPES") { + Some(raw) if !raw.trim().is_empty() => Some(parse_allowed_scopes(raw)?), + _ => None, }; let mut clients = Vec::new(); @@ -146,9 +157,6 @@ impl Config { let client_secret = properties.get("CLIENT_SECRET") .context(format!("Missing CLIENT_SECRET in section [{}]", section_name))? .to_string(); - let webhook_url = properties.get("WEBHOOK_URL") - .context(format!("Missing WEBHOOK_URL in section [{}]", section_name))? - .to_string(); let verifier_url = properties.get("VERIFIER_URL") .context(format!("Missing VERIFIER_URL in section [{}]", section_name))? .to_string(); @@ -157,7 +165,8 @@ impl Config { .to_string(); let redirect_uri = properties.get("REDIRECT_URI") .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); + .context(format!("Missing REDIRECT_URI in section [{}]", section_name))? + .to_string(); let accepted_issuer_dids = properties.get("ACCEPTED_ISSUER_DIDS") .filter(|s| !s.is_empty()) .map(|s| s.to_string()); @@ -166,7 +175,6 @@ impl Config { section_name: section_name.to_string(), client_id, client_secret, - webhook_url, verifier_url, verifier_management_api_path, redirect_uri, @@ -178,7 +186,27 @@ impl Config { server, database, crypto, + allowed_scopes, clients, }) } } + +fn parse_allowed_scopes(raw: &str) -> Result<Vec<String>> { + let trimmed = raw.trim(); + let trimmed = trimmed.strip_prefix('{').unwrap_or(trimmed); + let trimmed = trimmed.strip_suffix('}').unwrap_or(trimmed); + + let scopes: Vec<String> = trimmed + .split(|c: char| c == ',' || c.is_whitespace()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + if scopes.is_empty() { + anyhow::bail!("ALLOWED_SCOPES must contain at least one scope"); + } + + Ok(scopes) +} diff --git a/kych_oauth2_gateway/src/db/clients.rs b/kych_oauth2_gateway/src/db/clients.rs @@ -11,10 +11,9 @@ pub struct Client { pub id: Uuid, pub client_id: String, pub secret_hash: String, - pub webhook_url: String, pub verifier_url: String, pub verifier_management_api_path: String, - pub redirect_uri: Option<String>, + pub redirect_uri: String, pub accepted_issuer_dids: Option<String>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -25,10 +24,9 @@ pub async fn register_client( pool: &PgPool, client_id: &str, client_secret: &str, - webhook_url: &str, verifier_url: &str, verifier_management_api_path: Option<&str>, - redirect_uri: Option<&str>, + redirect_uri: &str, accepted_issuer_dids: Option<&str>, ) -> Result<Client> { let api_path = verifier_management_api_path @@ -39,15 +37,14 @@ pub async fn register_client( let client = sqlx::query_as::<_, Client>( r#" INSERT INTO oauth2gw.clients - (client_id, secret_hash, webhook_url, verifier_url, verifier_management_api_path, redirect_uri, accepted_issuer_dids) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, client_id, secret_hash, webhook_url, verifier_url, + (client_id, secret_hash, verifier_url, verifier_management_api_path, redirect_uri, accepted_issuer_dids) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, client_id, secret_hash, verifier_url, verifier_management_api_path, redirect_uri, accepted_issuer_dids, created_at, updated_at "# ) .bind(client_id) .bind(secret_hash) - .bind(webhook_url) .bind(verifier_url) .bind(api_path) .bind(redirect_uri) @@ -65,7 +62,7 @@ pub async fn get_client_by_id( ) -> Result<Option<Client>> { let client = sqlx::query_as::<_, Client>( r#" - SELECT id, client_id, secret_hash, webhook_url, verifier_url, + SELECT id, client_id, secret_hash, verifier_url, verifier_management_api_path, redirect_uri, accepted_issuer_dids, created_at, updated_at FROM oauth2gw.clients WHERE client_id = $1 @@ -85,7 +82,7 @@ pub async fn get_client_by_uuid( ) -> Result<Option<Client>> { let client = sqlx::query_as::<_, Client>( r#" - SELECT id, client_id, secret_hash, webhook_url, verifier_url, + SELECT id, client_id, secret_hash, verifier_url, verifier_management_api_path, redirect_uri, accepted_issuer_dids, created_at, updated_at FROM oauth2gw.clients WHERE id = $1 @@ -129,7 +126,7 @@ pub async fn authenticate_client( ) -> Result<Option<Client>> { let client = sqlx::query_as::<_, Client>( r#" - SELECT id, client_id, secret_hash, webhook_url, verifier_url, + SELECT id, client_id, secret_hash, verifier_url, verifier_management_api_path, redirect_uri, accepted_issuer_dids, created_at, updated_at FROM oauth2gw.clients WHERE client_id = $1 @@ -155,7 +152,6 @@ pub async fn authenticate_client( pub async fn update_client( pool: &PgPool, id: Uuid, - webhook_url: Option<&str>, verifier_url: Option<&str>, verifier_management_api_path: Option<&str>, redirect_uri: Option<&str>, @@ -164,29 +160,26 @@ pub async fn update_client( let current = get_client_by_uuid(pool, id).await? .ok_or_else(|| anyhow::anyhow!("Client not found"))?; - let new_webhook_url = webhook_url.unwrap_or(&current.webhook_url); let new_verifier_url = verifier_url.unwrap_or(&current.verifier_url); let new_verifier_api_path = verifier_management_api_path .unwrap_or(&current.verifier_management_api_path); - let new_redirect_uri = redirect_uri.or(current.redirect_uri.as_deref()); + let new_redirect_uri = redirect_uri.unwrap_or(&current.redirect_uri); let new_accepted_issuer_dids = accepted_issuer_dids.or(current.accepted_issuer_dids.as_deref()); let client = sqlx::query_as::<_, Client>( r#" UPDATE oauth2gw.clients SET - webhook_url = $1, - verifier_url = $2, - verifier_management_api_path = $3, - redirect_uri = $4, - accepted_issuer_dids = $5, + verifier_url = $1, + verifier_management_api_path = $2, + redirect_uri = $3, + accepted_issuer_dids = $4, updated_at = CURRENT_TIMESTAMP - WHERE id = $6 - RETURNING id, client_id, secret_hash, webhook_url, verifier_url, + WHERE id = $5 + RETURNING id, client_id, secret_hash, verifier_url, verifier_management_api_path, redirect_uri, accepted_issuer_dids, created_at, updated_at "# ) - .bind(new_webhook_url) .bind(new_verifier_url) .bind(new_verifier_api_path) .bind(new_redirect_uri) @@ -222,7 +215,7 @@ pub async fn delete_client( pub async fn list_clients(pool: &PgPool) -> Result<Vec<Client>> { let clients = sqlx::query_as::<_, Client>( r#" - SELECT id, client_id, secret_hash, webhook_url, verifier_url, + SELECT id, client_id, secret_hash, verifier_url, verifier_management_api_path, redirect_uri, accepted_issuer_dids, created_at, updated_at FROM oauth2gw.clients ORDER BY created_at DESC diff --git a/kych_oauth2_gateway/src/db/mod.rs b/kych_oauth2_gateway/src/db/mod.rs @@ -8,7 +8,6 @@ pub mod sessions; pub mod tokens; pub mod clients; pub mod authorization_codes; -pub mod notification_webhooks; /// Create a PostgreSQL connection pool /// diff --git a/kych_oauth2_gateway/src/db/notification_webhooks.rs b/kych_oauth2_gateway/src/db/notification_webhooks.rs @@ -1,219 +0,0 @@ -// Database operations for notification_pending_webhooks table - -use sqlx::PgPool; -use anyhow::Result; -use uuid::Uuid; - -/// Pending webhook record with authorization code for client notification -#[derive(Debug, Clone)] -pub struct PendingWebhook { - pub webhook_pending_serial: i64, - pub session_id: Uuid, - pub client_id: Uuid, - pub url: String, - pub http_method: String, - pub header: Option<String>, - pub body: String, - pub retries: i32, - /// Authorization code to include in the webhook payload - pub code: String, -} - -/// Fetch pending webhooks ready to be sent -/// -/// Only returns webhooks where next_attempt <= current epoch time. -/// -/// Used by the background worker -pub async fn get_pending_webhooks( - pool: &PgPool, - limit: i64, -) -> Result<Vec<PendingWebhook>> { - let webhooks = sqlx::query( - r#" - SELECT - npw.webhook_pending_serial, - npw.session_id, - npw.client_id, - npw.url, - npw.http_method, - npw.header, - npw.body, - npw.retries, - ac.code - FROM oauth2gw.notification_pending_webhooks npw - INNER JOIN oauth2gw.authorization_codes ac ON ac.session_id = npw.session_id - WHERE npw.next_attempt <= EXTRACT(EPOCH FROM NOW()) - ORDER BY npw.webhook_pending_serial - LIMIT $1 - "# - ) - .bind(limit) - .fetch_all(pool) - .await?; - - Ok(webhooks.into_iter().map(|row: sqlx::postgres::PgRow| { - use sqlx::Row; - PendingWebhook { - webhook_pending_serial: row.get("webhook_pending_serial"), - session_id: row.get("session_id"), - client_id: row.get("client_id"), - url: row.get("url"), - http_method: row.get("http_method"), - header: row.get("header"), - body: row.get("body"), - retries: row.get("retries"), - code: row.get("code"), - } - }).collect()) -} - -/// Delete a webhook after successful delivery -pub async fn delete_webhook( - pool: &PgPool, - webhook_pending_serial: i64, -) -> Result<bool> { - let result = sqlx::query( - r#" - DELETE FROM oauth2gw.notification_pending_webhooks - WHERE webhook_pending_serial = $1 - "# - ) - .bind(webhook_pending_serial) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Disable a webhook (client error - no retry) -/// -/// Sets next_attempt to max i64 value so it never gets picked up again -pub async fn disable_webhook( - pool: &PgPool, - webhook_pending_serial: i64, -) -> Result<bool> { - let result = sqlx::query( - r#" - UPDATE oauth2gw.notification_pending_webhooks - SET next_attempt = 9223372036854775807 - WHERE webhook_pending_serial = $1 - "# - ) - .bind(webhook_pending_serial) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Schedule retry with exponential backoff -/// -/// - Server errors (5xx): retry after delay_seconds (default 60) -/// - Network/timeout: retry after longer delay (default 3600) -pub async fn schedule_retry( - pool: &PgPool, - webhook_pending_serial: i64, - delay_seconds: i64, -) -> Result<bool> { - let result = sqlx::query( - r#" - UPDATE oauth2gw.notification_pending_webhooks - SET next_attempt = EXTRACT(EPOCH FROM NOW()) + $1, - retries = retries + 1 - WHERE webhook_pending_serial = $2 - "# - ) - .bind(delay_seconds) - .bind(webhook_pending_serial) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Insert a new pending webhook notification -/// -/// Triggered by the /notification handler after verification is complete -pub async fn insert_pending_webhook( - pool: &PgPool, - session_id: Uuid, - client_id: Uuid, - url: &str, - body: &str, -) -> Result<i64> { - let result = sqlx::query_scalar::<_, i64>( - r#" - INSERT INTO oauth2gw.notification_pending_webhooks - (session_id, client_id, url, body, next_attempt) - VALUES ($1, $2, $3, $4, 0) - RETURNING webhook_pending_serial - "# - ) - .bind(session_id) - .bind(client_id) - .bind(url) - .bind(body) - .fetch_one(pool) - .await?; - - Ok(result) -} - -/// Delete old disabled webhooks (garbage collection) -/// -/// Removes webhooks that have been disabled (next_attempt = MAX) for cleanup -pub async fn delete_disabled_webhooks(pool: &PgPool) -> Result<u64> { - let result = sqlx::query( - r#" - DELETE FROM oauth2gw.notification_pending_webhooks - WHERE next_attempt = 9223372036854775807 - "# - ) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -/// Delete webhooks that have exceeded max retries -pub async fn delete_max_retries( - pool: &PgPool, - max_retries: i32, -) -> Result<u64> { - let result = sqlx::query( - r#" - DELETE FROM oauth2gw.notification_pending_webhooks - WHERE retries >= $1 - "# - ) - .bind(max_retries) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -/// Get the next scheduled webhook attempt time -/// -/// Returns the earliest `next_attempt` timestamp for webhooks that are: -/// - Not disabled (next_attempt < MAX_INT8) -/// - Scheduled for the future (next_attempt > NOW) -/// -/// Used by worker to schedule precise wake-up for retries. -/// Returns None if no future webhooks are scheduled. -pub async fn get_next_scheduled_webhook(pool: &PgPool) -> Result<Option<i64>> { - let result = sqlx::query_scalar::<_, i64>( - r#" - SELECT next_attempt - FROM oauth2gw.notification_pending_webhooks - WHERE next_attempt > EXTRACT(EPOCH FROM NOW()) - AND next_attempt < 9223372036854775807 - ORDER BY next_attempt ASC - LIMIT 1 - "# - ) - .fetch_optional(pool) - .await?; - - Ok(result) -} -\ No newline at end of file diff --git a/kych_oauth2_gateway/src/db/sessions.rs b/kych_oauth2_gateway/src/db/sessions.rs @@ -62,6 +62,7 @@ pub struct AuthorizeSessionData { pub verifier_url: String, pub verifier_management_api_path: String, pub allowed_redirect_uris: Option<String>, + pub accepted_issuer_dids: Option<String>, } /// Notification record data used in /notification webhook endpoint @@ -75,7 +76,7 @@ pub struct NotificationSessionData { pub state: Option<String>, // Client fields pub client_id: Uuid, - pub webhook_url: String, + pub allowed_redirect_uris: Option<String>, pub verifier_url: String, pub verifier_management_api_path: String, } @@ -158,7 +159,8 @@ pub async fn get_session_for_authorize( s.verifier_nonce, c.verifier_url, c.verifier_management_api_path, - c.redirect_uri AS allowed_redirect_uris + c.redirect_uri AS allowed_redirect_uris, + c.accepted_issuer_dids "# ) .bind(nonce) @@ -185,6 +187,7 @@ pub async fn get_session_for_authorize( verifier_url: row.get("verifier_url"), verifier_management_api_path: row.get("verifier_management_api_path"), allowed_redirect_uris: row.get("allowed_redirect_uris"), + accepted_issuer_dids: row.get("accepted_issuer_dids"), } })) } @@ -212,7 +215,7 @@ pub async fn get_session_for_notification( s.redirect_uri, s.state, c.id AS client_id, - c.webhook_url, + c.redirect_uri AS allowed_redirect_uris, c.verifier_url, c.verifier_management_api_path "# @@ -230,7 +233,7 @@ pub async fn get_session_for_notification( redirect_uri: row.get("redirect_uri"), state: row.get("state"), client_id: row.get("client_id"), - webhook_url: row.get("webhook_url"), + allowed_redirect_uris: row.get("allowed_redirect_uris"), verifier_url: row.get("verifier_url"), verifier_management_api_path: row.get("verifier_management_api_path"), } @@ -324,15 +327,14 @@ pub async fn update_session_authorized( /// Atomically update session to verified and create authorization code /// /// Returns the generated authorization code on success. -pub async fn verify_session_and_queue_notification( +pub async fn verify_session_and_issue_code( pool: &PgPool, session_id: Uuid, status: SessionStatus, authorization_code: &str, code_expires_in_minutes: i64, _client_id: Uuid, - _webhook_url: &str, - _webhook_body: &str, + _callback_body: &str, verifiable_credential: Option<&serde_json::Value>, ) -> Result<String> { let timestamp_field = match status { diff --git a/kych_oauth2_gateway/src/handlers.rs b/kych_oauth2_gateway/src/handlers.rs @@ -10,6 +10,8 @@ use serde_json::json; use crate::{crypto, db::sessions::SessionStatus, models::*, state::AppState}; +const HTML_CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"; + fn is_safe_url(url: &str) -> bool { url.to_lowercase().starts_with("https://") } @@ -26,6 +28,29 @@ fn json_encode_string(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()) } +fn parse_accepted_issuer_dids(raw: &str) -> Result<Vec<String>, &'static str> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("ACCEPTED_ISSUER_DIDS must contain at least one DID"); + } + + let trimmed = trimmed.strip_prefix('{').unwrap_or(trimmed); + let trimmed = trimmed.strip_suffix('}').unwrap_or(trimmed); + + let dids: Vec<String> = trimmed + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + if dids.is_empty() { + return Err("ACCEPTED_ISSUER_DIDS must contain at least one DID"); + } + + Ok(dids) +} + // Health check endpoint pub async fn health_check() -> impl IntoResponse { tracing::info!("Received Health Request"); @@ -284,7 +309,10 @@ pub async fn authorize( return Ok(( StatusCode::OK, - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CONTENT_SECURITY_POLICY, HTML_CSP), + ], html, ).into_response()); } @@ -314,14 +342,56 @@ pub async fn authorize( } } + if let Some(allowed_scopes) = state.config.allowed_scopes.as_ref() { + let allowed_set: std::collections::HashSet<&str> = + allowed_scopes.iter().map(String::as_str).collect(); + let invalid_scopes: Vec<&str> = data + .scope + .split_whitespace() + .filter(|scope| !allowed_set.contains(*scope)) + .collect(); + + if !invalid_scopes.is_empty() { + tracing::warn!( + "Rejected invalid scopes for client {}: {:?}", + params.client_id, + invalid_scopes + ); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("invalid_scope")), + )); + } + } + // Build presentation definition from scope let presentation_definition = build_presentation_definition(&data.scope); // Call Swiyu Verifier let verifier_url = format!("{}{}", data.verifier_url, data.verifier_management_api_path); + let accepted_issuer_dids = match data.accepted_issuer_dids.as_deref() { + Some(raw) => parse_accepted_issuer_dids(raw).map_err(|message| { + tracing::error!("Invalid accepted issuer DIDs for client {}: {}", params.client_id, message); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("invalid_accepted_issuer_dids")), + ) + })?, + None => { + tracing::error!( + "Accepted issuer DIDs not configured for client {}", + params.client_id + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("accepted_issuer_dids_not_configured")), + )); + } + }; + let verifier_request = SwiyuCreateVerificationRequest { - accepted_issuer_dids: default_accepted_issuer_dids(), + accepted_issuer_dids, trust_anchors: None, jwt_secured_authorization_request: Some(true), response_mode: ResponseMode::DirectPost, @@ -448,7 +518,10 @@ pub async fn authorize( return Ok(( StatusCode::OK, - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CONTENT_SECURITY_POLICY, HTML_CSP), + ], html, ).into_response()); } @@ -862,26 +935,16 @@ pub async fn notification_webhook( let authorization_code = crypto::generate_authorization_code(state.config.crypto.authorization_code_bytes); // Construct GET request URL: redirect_uri?code=XXX&state=YYY - let redirect_uri = session_data.redirect_uri.as_ref() - .unwrap_or(&session_data.webhook_url); - let oauth_state = session_data.state.as_deref().unwrap_or(""); - - let webhook_url = format!( - "{}?code={}&state={}", - redirect_uri, - authorization_code, - oauth_state - ); + let auth_code_ttl = state.config.crypto.authorization_code_ttl_minutes; - // Update session, create auth code, and queue webhook (GET request, empty body) - match crate::db::sessions::verify_session_and_queue_notification( + // Update session and create auth code + match crate::db::sessions::verify_session_and_issue_code( &state.pool, session_data.session_id, new_status, &authorization_code, - 10, // 10 minutes for auth code expiry + auth_code_ttl, session_data.client_id, - &webhook_url, "", // Empty body for GET request swiyu_result.wallet_response.as_ref(), ) @@ -889,14 +952,14 @@ pub async fn notification_webhook( { Ok(code) => { tracing::info!( - "Session {} updated to {}, auth code created, webhook queued", + "Session {} updated to {}, auth code created", session_data.session_id, status_str ); tracing::debug!("Generated authorization code: {}", code); } Err(e) => { - tracing::error!("Failed to update session and queue notification: {}", e); + tracing::error!("Failed to update session with authorization code: {}", e); } } diff --git a/kych_oauth2_gateway/src/models.rs b/kych_oauth2_gateway/src/models.rs @@ -118,16 +118,10 @@ impl ErrorResponse { // Swiyu Verifier API models -/// Default issuer DID for Swiyu verification -pub fn default_accepted_issuer_dids() -> Vec<String> { - vec!["did:tdw:QmPEZPhDFR4nEYSFK5bMnvECqdpf1tPTPJuWs9QrMjCumw:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:9a5559f0-b81c-4368-a170-e7b4ae424527".to_string()] -} - /// Request body for creating a verification with Swiyu Verifier /// POST /management/api/verifications #[derive(Debug, Serialize, Deserialize)] pub struct SwiyuCreateVerificationRequest { - #[serde(default = "default_accepted_issuer_dids")] pub accepted_issuer_dids: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] pub trust_anchors: Option<Vec<TrustAnchor>>, diff --git a/kych_oauth2_gateway/src/worker.rs b/kych_oauth2_gateway/src/worker.rs @@ -1,258 +0,0 @@ -//! Webhook worker daemon -//! -//! Background process that delivers webhooks to client endpoints. -//! Uses PostgreSQL NOTIFY/LISTEN for event-driven wake-up. - -use anyhow::Result; -use sqlx::postgres::PgListener; -use sqlx::PgPool; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::watch; -use tracing::{debug, error, info, warn}; - -use crate::{ - config::WebhookWorkerConfig, - db::notification_webhooks::{ - delete_webhook, disable_webhook, get_next_scheduled_webhook, get_pending_webhooks, - schedule_retry, PendingWebhook, - }, -}; - -/// Run the webhook worker -/// -/// This function blocks until shutdown signal is received. -/// 1. Fetch pending webhooks (next_attempt <= NOW) -/// 2. Execute HTTP requests, wait for all to complete -/// 3. If no pending, check for future webhooks and schedule wake-up -/// 4. Fallback poll configured interval -pub async fn run_worker( - pool: PgPool, - config: &WebhookWorkerConfig, - test_mode: bool, - mut shutdown: watch::Receiver<bool>, -) -> Result<()> { - info!("Starting webhook worker (test_mode={})", test_mode); - - let http_client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::limited(5)) - .build()?; - - // Set up psql LISTEN - let mut listener = PgListener::connect_with(&pool).await?; - listener.listen("oauth2gw_webhook_pending").await?; - info!("Listening on channel 'oauth2gw_webhook_pending'"); - - loop { - // select work: process pending webhooks (keep looping while there's work) - loop { - let has_work = select_work(&pool, &http_client, config).await; - - if !has_work { - if test_mode { - info!("Test mode: no pending webhooks, exiting"); - return Ok(()); - } - break; // Exit inner loop, wait for NOTIFY/timer - } - // If has_work, loop again to check for more pending webhooks - } - - // Determine how long to sleep - let sleep_duration = match get_next_scheduled_webhook(&pool).await { - Ok(Some(next_attempt)) => { - let now = chrono::Utc::now().timestamp(); - let delay = (next_attempt - now).max(0) as u64; - debug!("Next webhook scheduled in {} seconds", delay); - Duration::from_secs(delay) - } - Ok(None) => { - debug!("No future webhooks, fallback poll in {} seconds", config.fallback_poll_secs); - Duration::from_secs(config.fallback_poll_secs) - } - Err(e) => { - error!("Failed to get next scheduled webhook: {}", e); - Duration::from_secs(config.fallback_poll_secs) - } - }; - - tokio::select! { - // Shutdown signal - _ = shutdown.changed() => { - if *shutdown.borrow() { - info!("Shutdown signal received"); - break; - } - } - - // psql NOTIFY received - immediately process - notification = listener.recv() => { - match notification { - Ok(n) => { - debug!("NOTIFY received: {}", n.payload()); - // Don't sleep, go straight to select_work - continue; - } - Err(e) => { - error!("LISTEN error: {}", e); - tokio::time::sleep(Duration::from_secs(1)).await; - } - } - } - - // Sleep until next scheduled webhook or fallback poll - _ = tokio::time::sleep(sleep_duration) => { - debug!("Timer expired, checking for work"); - } - } - } - - info!("Webhook worker stopped"); - Ok(()) -} - -/// Process all pending webhooks -/// -/// Returns true if any webhooks were processed. -async fn select_work(pool: &PgPool, http_client: &reqwest::Client, config: &WebhookWorkerConfig) -> bool { - let webhooks = match get_pending_webhooks(pool, config.batch_size).await { - Ok(w) => w, - Err(e) => { - error!("Failed to fetch pending webhooks: {}", e); - return false; - } - }; - - if webhooks.is_empty() { - debug!("No pending webhooks"); - return false; - } - - info!("Processing {} pending webhooks", webhooks.len()); - - // Track in-flight jobs - let in_flight = Arc::new(AtomicUsize::new(webhooks.len())); - let mut handles = Vec::with_capacity(webhooks.len()); - - for webhook in webhooks { - let pool = pool.clone(); - let client = http_client.clone(); - let counter = in_flight.clone(); - let cfg = config.clone(); - - // Fire HTTP request - let handle = tokio::spawn(async move { - execute_webhook(&pool, &client, webhook, &cfg).await; - counter.fetch_sub(1, Ordering::SeqCst); - }); - - handles.push(handle); - } - - // Wait for all jobs to complete - for handle in handles { - if let Err(e) = handle.await { - error!("Webhook task panicked: {}", e); - } - } - - true -} - -/// Execute a single webhook HTTP request -async fn execute_webhook(pool: &PgPool, client: &reqwest::Client, webhook: PendingWebhook, config: &WebhookWorkerConfig) { - let serial = webhook.webhook_pending_serial; - - debug!( - "Webhook {}: {} {} (retry #{})", - serial, webhook.http_method, webhook.url, webhook.retries - ); - - // Build request based on http_method - let mut request = match webhook.http_method.to_uppercase().as_str() { - "POST" => client.post(&webhook.url), - "PUT" => client.put(&webhook.url), - "GET" => client.get(&webhook.url), - "DELETE" => client.delete(&webhook.url), - method => { - error!("Unsupported HTTP method '{}' for webhook {}", method, serial); - let _ = disable_webhook(pool, serial).await; - return; - } - }; - - // Add headers if present - if let Some(headers) = &webhook.header { - for line in headers.lines() { - if let Some((name, value)) = line.split_once(':') { - request = request.header(name.trim(), value.trim()); - } - } - } - - // Add body for POST/PUT - if matches!(webhook.http_method.to_uppercase().as_str(), "POST" | "PUT") { - request = request - .header("Content-Type", "application/json") - .body(webhook.body.clone()); - } - - // Execute request - let response = match request.send().await { - Ok(r) => r, - Err(e) => { - warn!("Network error for webhook {}: {}", serial, e); - handle_webhook_response(pool, serial, 0, config).await; - return; - } - }; - - let status_code = response.status().as_u16(); - handle_webhook_response(pool, serial, status_code.into(), config).await; -} - -/// Handle webhook response -/// -/// - 2xx: delete pending webhook (success) -/// - 400: next_attempt = FOREVER (never retry) -/// - 500: next_attempt = NOW + configured delay -/// - 403: next_attempt = NOW + configured delay -/// - other: next_attempt = NOW + configured delay -async fn handle_webhook_response(pool: &PgPool, serial: i64, response_code: i64, config: &WebhookWorkerConfig) { - info!("Webhook {} returned with status {}", serial, response_code); - - // 200 success - delete from queue - if response_code >= 200 && response_code < 300 { - match delete_webhook(pool, serial).await { - Ok(true) => { - debug!("Webhook {} deleted successfully", serial); - } - Ok(false) => { - warn!("Webhook {} not found for deletion", serial); - } - Err(e) => { - error!("Failed to delete webhook {}: {}", serial, e); - } - } - return; - } - - // Determine retry delay based on response code - let delay = match response_code { - 400 => { - warn!("Webhook {} got 400, disabling permanently", serial); - let _ = disable_webhook(pool, serial).await; - return; - } - 500 => config.retry_delay_server_error, - 403 => config.retry_delay_forbidden, - _ => config.retry_delay_other, - }; - - debug!("Scheduling webhook {} retry in {} seconds", serial, delay); - if let Err(e) = schedule_retry(pool, serial, delay).await { - error!("Failed to schedule retry for webhook {}: {}", serial, e); - } -} diff --git a/kych_oauth2_gateway/tests/client_cli.rs b/kych_oauth2_gateway/tests/client_cli.rs @@ -1,271 +0,0 @@ -//! Integration tests for client-mgmt CLI - -use oauth2_gateway::db; -use sqlx::PgPool; - -async fn setup_pool() -> PgPool { - let database_url = std::env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgresql://oauth2gw:password@localhost:5432/oauth2gw".to_string()); - db::create_pool(&database_url).await.unwrap() -} - -async fn cleanup_clients(pool: &PgPool) { - sqlx::query("DELETE FROM oauth2gw.clients") - .execute(pool) - .await - .unwrap(); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_client_create_and_list() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - // Create a client - let client = db::clients::register_client( - &pool, - "test-cli-client", - "secret123", - "https://example.com/webhook", - "https://verifier.example.com", - None, - ) - .await - .unwrap(); - - assert_eq!(client.client_id, "test-cli-client"); - assert_eq!(client.webhook_url, "https://example.com/webhook"); - - // List clients - let clients = db::clients::list_clients(&pool).await.unwrap(); - assert_eq!(clients.len(), 1); - assert_eq!(clients[0].client_id, "test-cli-client"); - - cleanup_clients(&pool).await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_client_show() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - // Create a client - let created = db::clients::register_client( - &pool, - "show-test-client", - "secret456", - "https://example.com/hook", - "https://verifier.example.com", - Some("/custom/api/path"), - ) - .await - .unwrap(); - - // Show client by client_id - let found = db::clients::get_client_by_id(&pool, "show-test-client") - .await - .unwrap() - .unwrap(); - - assert_eq!(found.id, created.id); - assert_eq!(found.client_id, "show-test-client"); - assert_eq!(found.verifier_management_api_path, "/custom/api/path"); - - cleanup_clients(&pool).await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_client_update() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - // Create a client - let created = db::clients::register_client( - &pool, - "update-test-client", - "secret789", - "https://old-webhook.com", - "https://old-verifier.com", - None, - ) - .await - .unwrap(); - - // Update the client - let updated = db::clients::update_client( - &pool, - created.id, - Some("https://new-webhook.com"), - Some("https://new-verifier.com"), - Some("/new/api/path"), - ) - .await - .unwrap(); - - assert_eq!(updated.webhook_url, "https://new-webhook.com"); - assert_eq!(updated.verifier_url, "https://new-verifier.com"); - assert_eq!(updated.verifier_management_api_path, "/new/api/path"); - - cleanup_clients(&pool).await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_client_update_partial() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - // Create a client - let created = db::clients::register_client( - &pool, - "partial-update-client", - "secret", - "https://webhook.com", - "https://verifier.com", - None, - ) - .await - .unwrap(); - - // Update only webhook_url - let updated = db::clients::update_client( - &pool, - created.id, - Some("https://updated-webhook.com"), - None, - None, - ) - .await - .unwrap(); - - assert_eq!(updated.webhook_url, "https://updated-webhook.com"); - assert_eq!(updated.verifier_url, "https://verifier.com"); // unchanged - assert_eq!(updated.verifier_management_api_path, "/management/api/verifications"); // default - - cleanup_clients(&pool).await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_client_delete() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - // Create a client - let created = db::clients::register_client( - &pool, - "delete-test-client", - "secret", - "https://webhook.com", - "https://verifier.com", - None, - ) - .await - .unwrap(); - - // Verify it exists - let found = db::clients::get_client_by_id(&pool, "delete-test-client") - .await - .unwrap(); - assert!(found.is_some()); - - // Delete the client - let deleted = db::clients::delete_client(&pool, created.id).await.unwrap(); - assert!(deleted); - - // Verify it's gone - let not_found = db::clients::get_client_by_id(&pool, "delete-test-client") - .await - .unwrap(); - assert!(not_found.is_none()); - - cleanup_clients(&pool).await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_client_not_found() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - // Try to find non-existent client - let not_found = db::clients::get_client_by_id(&pool, "nonexistent-client") - .await - .unwrap(); - assert!(not_found.is_none()); - - cleanup_clients(&pool).await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_client_duplicate_id() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - // Create first client - db::clients::register_client( - &pool, - "duplicate-client", - "secret1", - "https://webhook1.com", - "https://verifier1.com", - None, - ) - .await - .unwrap(); - - // Try to create duplicate - let result = db::clients::register_client( - &pool, - "duplicate-client", - "secret2", - "https://webhook2.com", - "https://verifier2.com", - None, - ) - .await; - - assert!(result.is_err()); - - cleanup_clients(&pool).await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_list_empty() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - let clients = db::clients::list_clients(&pool).await.unwrap(); - assert!(clients.is_empty()); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_cli_list_multiple_clients() { - let pool = setup_pool().await; - cleanup_clients(&pool).await; - - // Create multiple clients - for i in 1..=3 { - db::clients::register_client( - &pool, - &format!("client-{}", i), - &format!("secret-{}", i), - &format!("https://webhook{}.com", i), - &format!("https://verifier{}.com", i), - None, - ) - .await - .unwrap(); - } - - let clients = db::clients::list_clients(&pool).await.unwrap(); - assert_eq!(clients.len(), 3); - - cleanup_clients(&pool).await; -} diff --git a/kych_oauth2_gateway/tests/db.rs b/kych_oauth2_gateway/tests/db.rs @@ -1,443 +0,0 @@ -// Database tests for OAuth2 Gateway -// Requires TEST_DATABASE_URL environment variable or uses default connection. - -use oauth2_gateway::db; -use sqlx::PgPool; -use serial_test::serial; - -fn get_test_database_url() -> String { - std::env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgresql://oauth2gw:password@localhost:5432/oauth2gw".to_string()) -} - -async fn setup_test_db() -> PgPool { - let pool = db::create_pool(&get_test_database_url()) - .await - .expect("Failed to connect to test database"); - clean_test_data(&pool).await; - pool -} - -async fn clean_test_data(pool: &PgPool) { - let _ = sqlx::query("DELETE FROM oauth2gw.notification_pending_webhooks").execute(pool).await; - let _ = sqlx::query("DELETE FROM oauth2gw.authorization_codes").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; -} - -async fn teardown_test_db(pool: &PgPool) { - clean_test_data(pool).await; -} - -#[tokio::test] -#[serial] -async fn test_client_registration() { - let pool = setup_test_db().await; - - let client = db::clients::register_client( - &pool, - "test-exchange-1", - "secret123", - "https://exchange.example.com/kyc/webhook", - "https://verifier.swiyu.io", - None, - ) - .await - .expect("Failed to register client"); - - assert_eq!(client.client_id, "test-exchange-1"); - assert_eq!(client.webhook_url, "https://exchange.example.com/kyc/webhook"); - assert_eq!(client.verifier_url, "https://verifier.swiyu.io"); - assert_eq!(client.verifier_management_api_path, "/management/api/verifications"); - - teardown_test_db(&pool).await; -} - -#[tokio::test] -#[serial] -async fn test_client_lookup_by_client_id() { - let pool = setup_test_db().await; - - let registered = db::clients::register_client( - &pool, - "lookup-test", - "secret456", - "https://example.com/webhook", - "https://verifier.example.com", - Some("/custom/path"), - ) - .await - .unwrap(); - - let found = db::clients::get_client_by_id(&pool, "lookup-test") - .await - .unwrap() - .expect("Client not found"); - - assert_eq!(found.id, registered.id); - assert_eq!(found.verifier_management_api_path, "/custom/path"); - - teardown_test_db(&pool).await; -} - -#[tokio::test] -#[serial] -async fn test_client_not_found() { - let pool = setup_test_db().await; - - let result = db::clients::get_client_by_id(&pool, "nonexistent") - .await - .unwrap(); - - assert!(result.is_none()); - - teardown_test_db(&pool).await; -} - -#[tokio::test] -#[serial] -async fn test_session_creation() { - let pool = setup_test_db().await; - - let _client = db::clients::register_client( - &pool, - "session-client", - "secret", - "https://example.com/webhook", - "https://verifier.example.com", - None, - ) - .await - .unwrap(); - - let session = db::sessions::create_session( - &pool, - "session-client", - "nonce-abc123", - "first_name last_name age_over_18", - 15, - ) - .await - .expect("Failed to create session") - .expect("Session should be created"); - - assert_eq!(session.nonce, "nonce-abc123"); - 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()); - assert!(session.verifier_nonce.is_none()); - - teardown_test_db(&pool).await; -} - -#[tokio::test] -#[serial] -async fn test_get_session_for_authorize() { - let pool = setup_test_db().await; - - let _client = db::clients::register_client( - &pool, - "authorize-client", - "secret", - "https://example.com/webhook", - "https://verifier.example.com", - Some("/custom/api/path"), - ) - .await - .unwrap(); - - let session = db::sessions::create_session( - &pool, - "authorize-client", - "authorize-test-nonce", - "first_name last_name", - 15, - ) - .await - .unwrap() - .unwrap(); - - // Fetch session with client data - let data = db::sessions::get_session_for_authorize( - &pool, - "authorize-test-nonce", - "authorize-client", - ) - .await - .unwrap() - .expect("Session should be found"); - - assert_eq!(data.session_id, session.id); - assert_eq!(data.status, db::sessions::SessionStatus::Pending); - assert_eq!(data.scope, "first_name last_name"); - assert_eq!(data.verifier_url, "https://verifier.example.com"); - assert_eq!(data.verifier_management_api_path, "/custom/api/path"); - assert!(data.verification_url.is_none()); - - // Wrong client_id should return None - let not_found = db::sessions::get_session_for_authorize( - &pool, - "authorize-test-nonce", - "wrong-client", - ) - .await - .unwrap(); - assert!(not_found.is_none()); - - teardown_test_db(&pool).await; -} - -#[tokio::test] -#[serial] -async fn test_authorization_code_creation_and_exchange() { - let pool = setup_test_db().await; - - let _client = db::clients::register_client( - &pool, - "code-client", - "secret", - "https://example.com/webhook", - "https://verifier.example.com", - None, - ) - .await - .unwrap(); - - let session = db::sessions::create_session( - &pool, - "code-client", - "code-nonce", - "scope", - 15, - ) - .await - .unwrap() - .unwrap(); - - // Create authorization code - let code = db::authorization_codes::create_authorization_code( - &pool, - session.id, - "auth-code-xyz123", - 10, - ) - .await - .unwrap(); - - assert_eq!(code.code, "auth-code-xyz123"); - assert_eq!(code.session_id, session.id); - assert!(!code.used); - - // Exchange code - first time should mark as used - let exchange1 = db::authorization_codes::get_code_for_token_exchange( - &pool, - "auth-code-xyz123", - ) - .await - .unwrap() - .expect("Code should be found"); - - assert!(!exchange1.was_already_used); // First use - assert_eq!(exchange1.session_id, session.id); - assert!(exchange1.existing_token.is_none()); - - // Exchange code - second time should show as already used - let exchange2 = db::authorization_codes::get_code_for_token_exchange( - &pool, - "auth-code-xyz123", - ) - .await - .unwrap() - .expect("Code should still be found"); - - assert!(exchange2.was_already_used); // Already used - - teardown_test_db(&pool).await; -} - -#[tokio::test] -#[serial] -async fn test_create_token_and_complete_session() { - let pool = setup_test_db().await; - - let _client = db::clients::register_client( - &pool, - "complete-client", - "secret", - "https://example.com/webhook", - "https://verifier.example.com", - None, - ) - .await - .unwrap(); - - let session = db::sessions::create_session( - &pool, - "complete-client", - "complete-nonce", - "scope", - 15, - ) - .await - .unwrap() - .unwrap(); - - // Create token and complete session atomically - let token = db::tokens::create_token_and_complete_session( - &pool, - session.id, - "atomic-token-abc", - 3600, - ) - .await - .unwrap(); - - assert_eq!(token.token, "atomic-token-abc"); - assert_eq!(token.session_id, session.id); - - // Verify session was updated to completed - let data = db::sessions::get_session_for_authorize( - &pool, - "complete-nonce", - "complete-client", - ) - .await - .unwrap() - .unwrap(); - - assert_eq!(data.status, db::sessions::SessionStatus::Completed); - - teardown_test_db(&pool).await; -} - -#[tokio::test] -#[serial] -async fn test_notification_webhook_queue() { - let pool = setup_test_db().await; - - let client = db::clients::register_client( - &pool, - "webhook-client", - "secret", - "https://example.com/webhook", - "https://verifier.example.com", - None, - ) - .await - .unwrap(); - - let session = db::sessions::create_session( - &pool, - "webhook-client", - "webhook-nonce", - "scope", - 15, - ) - .await - .unwrap() - .unwrap(); - - // Create authorization code first (needed for webhook join) - db::authorization_codes::create_authorization_code( - &pool, - session.id, - "webhook-code", - 10, - ) - .await - .unwrap(); - - // Insert pending webhook - let serial = db::notification_webhooks::insert_pending_webhook( - &pool, - session.id, - client.id, - "https://example.com/webhook", - r#"{"nonce":"webhook-nonce","status":"verified"}"#, - ) - .await - .unwrap(); - - assert!(serial > 0); - - // Fetch pending webhooks - let pending = db::notification_webhooks::get_pending_webhooks(&pool, 100) - .await - .unwrap(); - - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].session_id, session.id); - assert_eq!(pending[0].code, "webhook-code"); - assert_eq!(pending[0].url, "https://example.com/webhook"); - - // Delete webhook after "successful delivery" - let deleted = db::notification_webhooks::delete_webhook(&pool, serial) - .await - .unwrap(); - assert!(deleted); - - // Should be empty now - let pending_after = db::notification_webhooks::get_pending_webhooks(&pool, 100) - .await - .unwrap(); - assert!(pending_after.is_empty()); - - teardown_test_db(&pool).await; -} - -#[tokio::test] -#[serial] -async fn test_get_token_with_session() { - let pool = setup_test_db().await; - - let _client = db::clients::register_client( - &pool, - "info-client", - "secret", - "https://example.com/webhook", - "https://verifier.example.com", - None, - ) - .await - .unwrap(); - - let session = db::sessions::create_session( - &pool, - "info-client", - "info-nonce", - "scope", - 15, - ) - .await - .unwrap() - .unwrap(); - - // Create token - db::tokens::create_token_and_complete_session( - &pool, - session.id, - "info-token-xyz", - 3600, - ) - .await - .unwrap(); - - // Fetch token with session data - let data = db::tokens::get_token_with_session(&pool, "info-token-xyz") - .await - .unwrap() - .expect("Token should be found"); - - assert!(!data.revoked); - assert_eq!(data.session_status, db::sessions::SessionStatus::Completed); - - // Non-existent token returns None - let not_found = db::tokens::get_token_with_session(&pool, "nonexistent-token") - .await - .unwrap(); - assert!(not_found.is_none()); - - teardown_test_db(&pool).await; -}