kych

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

commit d9edb3f48a3e48244654baf0dfbe100fb65f531b
parent 8a510da2132106af7dbf1cfdd675fa549bf2c5bb
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Sun, 19 Oct 2025 14:34:05 +0200

oauth2_gateway: base implementation

Diffstat:
Aoauth2_gateway/.gitignore | 9+++++++++
Aoauth2_gateway/Cargo.toml | 33+++++++++++++++++++++++++++++++++
Aoauth2_gateway/config.example.ini | 11+++++++++++
Aoauth2_gateway/src/config.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aoauth2_gateway/src/handlers.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aoauth2_gateway/src/main.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aoauth2_gateway/src/models.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aoauth2_gateway/src/state.rs | 15+++++++++++++++
8 files changed, 423 insertions(+), 0 deletions(-)

diff --git a/oauth2_gateway/.gitignore b/oauth2_gateway/.gitignore @@ -0,0 +1,8 @@ +# Rust +/target/ +Cargo.lock +.idea/ +.vscode/ +.DS_Store +*.log +*.tmp +\ No newline at end of file diff --git a/oauth2_gateway/Cargo.toml b/oauth2_gateway/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "oauth2-gateway" +version = "0.0.1" +edition = "2024" + +[dependencies] +# Web framework +axum = "0.8.6" +tokio = { version = "1.48.0", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6.6", features = ["trace"] } + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" + +# HTTP client needed for performing requests +reqwest = { version = "0.12", features = ["json"] } + +# Configuration +rust-ini = "0.21.3" +clap = { version = "4.5.49", features = ["derive"] } + +# Utilities +uuid = { version = "1.18.1", features = ["v4", "serde"] } +chrono = { version = "0.4.42", features = ["serde"] } + +# Logging +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } + +# Error handling +anyhow = "1.0.100" diff --git a/oauth2_gateway/config.example.ini b/oauth2_gateway/config.example.ini @@ -0,0 +1,11 @@ +[server] +host = 127.0.0.1 +port = 8080 + +[exchange] +base_url = https://exchange.example.com +notification_endpoint = /oauth2gw/kyc/notify + +[verifier] +base_url = https://verifier.example.com +management_api_path = /management/api/verifications diff --git a/oauth2_gateway/src/config.rs b/oauth2_gateway/src/config.rs @@ -0,0 +1,91 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use ini::Ini; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub server: ServerConfig, + pub exchange: ExchangeConfig, + pub verifier: VerifierConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExchangeConfig { + pub base_url: String, + pub notification_endpoint: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifierConfig { + pub base_url: String, + pub management_api_path: String, +} + +impl Config { + pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> { + let ini = Ini::load_from_file(path.as_ref()) + .context("Failed to load config file")?; + + // Server section + let server_section = ini + .section(Some("server")) + .context("Missing [server] section")?; + + let server = ServerConfig { + host: server_section + .get("host") + .unwrap_or("127.0.0.1") + .to_string(), + port: server_section + .get("port") + .unwrap_or("8080") + .parse() + .context("Invalid port")?, + }; + + // Exchange section + let exchange_section = ini + .section(Some("exchange")) + .context("Missing [exchange] section")?; + + let exchange = ExchangeConfig { + base_url: exchange_section + .get("base_url") + .context("Missing exchange.base_url")? + .to_string(), + notification_endpoint: exchange_section + .get("notification_endpoint") + .unwrap_or("/oauth2gw/kyc/notify") + .to_string(), + }; + + // Verifier section + let verifier_section = ini + .section(Some("verifier")) + .context("Missing [verifier] section")?; + + let verifier = VerifierConfig { + base_url: verifier_section + .get("base_url") + .context("Missing verifier.base_url")? + .to_string(), + management_api_path: verifier_section + .get("management_api_path") + .unwrap_or("/management/api/verifications") + .to_string(), + }; + + Ok(Config { + server, + exchange, + verifier, + }) + } +} diff --git a/oauth2_gateway/src/handlers.rs b/oauth2_gateway/src/handlers.rs @@ -0,0 +1,132 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde_json::json; + +use crate::{ + models::*, + state::AppState, +}; + +// Health check endpoint +pub async fn health_check() -> impl IntoResponse { + Json(json!({ + "status": "healthy", + "service": "oauth2-gateway", + // "version": env!("CARGO_PKG_VERSION") // leak version info? + })) +} + +// POST /setup/{clientId} +pub async fn setup( + State(_state): State<AppState>, + Path(client_id): Path<String>, + Json(request): Json<SetupRequest>, +) -> impl IntoResponse { + tracing::info!("Setup request for client: {}, scope: {}", client_id, request.scope); + + // Generate a simple nonce for now + // TODO: Change to cyptographic nonce + let nonce = uuid::Uuid::new_v4().to_string(); + + tracing::info!("Generated nonce: {}", nonce); + + ( + StatusCode::OK, + Json(SetupResponse { nonce }) + ) +} + +// GET /authorize/{nonce} +pub async fn authorize( + State(_state): State<AppState>, + Path(nonce): Path<String>, +) -> impl IntoResponse { + tracing::info!("Authorize request for nonce: {}", nonce); + + // TODO: Call the SwiyuVerifier to generate the QR code/verification URL + + // For now, return a mock response + let response = AuthorizeResponse { + verification_id: uuid::Uuid::new_v4(), + verification_url: format!("swiyu://verify?nonce={}", nonce), + }; + + (StatusCode::OK, Json(response)) +} + +// POST /token +pub async fn token( + State(_state): State<AppState>, + Json(request): Json<TokenRequest>, +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { + tracing::info!("Token request for code: {}", request.code); + + if request.grant_type != "authorization_code" { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("invalid_grant_type")) + )); + } + + // TODO: Validate nonce/code + // TODO: Change to cyptographic token + + // Generate a simple access token + let access_token = uuid::Uuid::new_v4().to_string(); + + let response = TokenResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: 3600, + }; + + Ok((StatusCode::OK, Json(response))) +} + +// GET /info +pub async fn info( + State(_state): State<AppState>, +) -> impl IntoResponse { + tracing::info!("Info request received"); + + // TODO: Validate access token + // TODO: Call the SwiyuVerifier to retrieve the VC data + + let credential = VerifiableCredential { + data: json!({ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "credentialSubject": { + "givenName": "John", + "familyName": "Doe", + "dateOfBirth": "1990-01-01", + "age_over_18": true, + } + }), + }; + + (StatusCode::OK, Json(credential)) +} + +// POST /notification/{request_id} +pub async fn notification_webhook( + State(_state): State<AppState>, + Path(request_id): Path<String>, + Json(request): Json<NotificationRequest>, +) -> impl IntoResponse { + tracing::info!( + "Webhook notification received for request_id: {}, nonce: {}, complete: {}", + request_id, + request.nonce, + request.verification_complete + ); + + // TODO: When verification is complete, post the Exchange at + // TODO: {exchange_base_url}/oauth2gw/kyc/notify/{clientId} + + StatusCode::OK +} diff --git a/oauth2_gateway/src/main.rs b/oauth2_gateway/src/main.rs @@ -0,0 +1,65 @@ +use anyhow::Result; +use axum::{ + routing::{get, post}, + Router, +}; +use clap::Parser; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod config; +mod handlers; +mod models; +mod state; + +use config::Config; +use state::AppState; + +#[derive(Parser, Debug)] +#[command(version)] +struct Args { + #[arg(short, value_name = "FILE")] + config: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Init logging, tracing + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "oauth2_gateway=info,tower_http=info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let args = Args::parse(); + + tracing::info!("Starting OAuth2 Gateway v{}", env!("CARGO_PKG_VERSION")); + tracing::info!("Loading configuration from: {}", args.config); + + let config = Config::from_file(&args.config)?; + + // Share config between modules + let state = AppState::new(config.clone()); + + let app = Router::new() + .route("/health", get(handlers::health_check)) + .route("/setup/{client_id}", post(handlers::setup)) + .route("/authorize/{nonce}", get(handlers::authorize)) + .route("/token", post(handlers::token)) + .route("/info", get(handlers::info)) + .route("/notify/{client_id}", post(handlers::notification_webhook)) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let addr = format!("{}:{}", config.server.host, config.server.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + tracing::info!("Server listening on {}", addr); + tracing::info!("Health check available at: http://{}/health", addr); + + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/oauth2_gateway/src/models.rs b/oauth2_gateway/src/models.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// Setup endpoint +#[derive(Debug, Deserialize, Serialize)] +pub struct SetupRequest { + pub scope: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SetupResponse { + pub nonce: String, +} + +// Authorize endpoint +#[derive(Debug, Deserialize, Serialize)] +pub struct AuthorizeResponse { + #[serde(rename = "verificationId")] + pub verification_id: Uuid, // Does the browser need this? + pub verification_url: String, +} + +// Token endpoint +// WARING: RFC 6749 also requires: +// - redirect_uri +// - client_id +#[derive(Debug, Deserialize, Serialize)] +pub struct TokenRequest { + pub grant_type: String, + pub code: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, +} + +// Info endpoint +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct VerifiableCredential { + #[serde(flatten)] + pub data: serde_json::Value, +} + +// Notification webhook +#[derive(Debug, Deserialize, Serialize)] +pub struct NotificationRequest { + pub nonce: String, + #[serde(default)] + pub verification_complete: bool, +} + +// Error response +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: String, +} + +impl ErrorResponse { + pub fn new(error: impl Into<String>) -> Self { + Self { + error: error.into(), + } + } +} diff --git a/oauth2_gateway/src/state.rs b/oauth2_gateway/src/state.rs @@ -0,0 +1,15 @@ +use crate::config::Config; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub config: Arc<Config>, +} + +impl AppState { + pub fn new(config: Config) -> Self { + Self { + config: Arc::new(config), + } + } +}