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:
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(¤t.webhook_url);
let new_verifier_url = verifier_url.unwrap_or(¤t.verifier_url);
let new_verifier_api_path = verifier_management_api_path
.unwrap_or(¤t.verifier_management_api_path);
- let new_redirect_uri = redirect_uri.or(current.redirect_uri.as_deref());
+ let new_redirect_uri = redirect_uri.unwrap_or(¤t.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;
-}