kych

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

commit 7c2ebcc2b94552b74e4ae7283de68c6d6d774d97
parent ec8943a7c130157b55cf73dcd9643880782acd26
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Mon, 19 Jan 2026 18:07:15 +0100

Finalize OAuth flow via server-side redirect; remove code from /status

Implement two-step finalization to preserve OAuth2 security properties.
/status now returns only coarse state and no longer exposes code or
redirect_uri.
Added /finalize/{verification_id} to validate verification_id and state, enforce
verified-only completion, and issue the authorization code exclusively via an
HTTP 302 redirect to the registered redirect_uri.
Updated authorize page polling to trigger top-level navigation on verification.
Introduced URL encoding for state to ensure safe redirects.

Diffstat:
Mdocumentation/sequence_diagrams/swiyu_taler_sequence_diagram.txt | 6++++--
Mkych_oauth2_gateway/Cargo.toml | 3+++
Mkych_oauth2_gateway/src/handlers.rs | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mkych_oauth2_gateway/src/main.rs | 1+
Mkych_oauth2_gateway/src/models.rs | 4----
Mkych_oauth2_gateway/templates/authorize.html | 6++----
6 files changed, 120 insertions(+), 16 deletions(-)

diff --git a/documentation/sequence_diagrams/swiyu_taler_sequence_diagram.txt b/documentation/sequence_diagrams/swiyu_taler_sequence_diagram.txt @@ -26,7 +26,7 @@ sequenceDiagram loop Poll until status is "verified" or "failed" Browser ->> Kych oauth2 Gateway: GET /status/{verification_id}\n?state={state} - Kych oauth2 Gateway -->> Browser: {status: "authorized"} + Kych oauth2 Gateway -->> Browser: {status: "pending" | "authorized"} end Browser ->> SwiyuWallet: Open $VERIFICATION_URL\n(scan QR or open swiyu wallet deeplink) @@ -44,7 +44,9 @@ sequenceDiagram note over Browser,Kych oauth2 Gateway: Browser poll detects completion Browser ->> Kych oauth2 Gateway: GET /status/{verification_id}\n?state={state} - Kych oauth2 Gateway -->> Browser: {status: "verified",\ncode: $AUTH_CODE,\nredirect_uri} + Kych oauth2 Gateway -->> Browser: {status: "verified"} + Browser ->> Kych oauth2 Gateway: GET /finalize/{verification_id}\n?state={state} + Kych oauth2 Gateway -->> Browser: HTTP 302 Redirect\nLocation: {redirect_uri}?code={auth_code}&state={state} Browser ->> Exchange: GET {redirect_uri}\n?code={auth_code}\n&state={state} note over Exchange,Kych oauth2 Gateway: Exchange retrieves the Verifiable Credential diff --git a/kych_oauth2_gateway/Cargo.toml b/kych_oauth2_gateway/Cargo.toml @@ -57,6 +57,9 @@ rand = "0.8.5" bcrypt = "0.15" base64 = "0.22.1" +# URL encoding +urlencoding = "2.1" + # Database sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] } diff --git a/kych_oauth2_gateway/src/handlers.rs b/kych_oauth2_gateway/src/handlers.rs @@ -228,7 +228,6 @@ pub async fn authorize( verification_url: String, verification_deeplink: String, state: String, - redirect_uri: String, } let template = AuthorizeTemplate { @@ -236,7 +235,6 @@ pub async fn authorize( verification_url: data.verification_url.clone().unwrap_or_default(), verification_deeplink: data.verification_deeplink.clone().unwrap_or_default(), state: params.state.clone(), - redirect_uri: params.redirect_uri.clone(), }; let html = template.render().map_err(|e| { @@ -373,7 +371,6 @@ pub async fn authorize( verification_url: String, verification_deeplink: String, state: String, - redirect_uri: String, } let template = AuthorizeTemplate { @@ -381,7 +378,6 @@ pub async fn authorize( verification_url: result.verification_url.clone(), verification_deeplink: swiyu_response.verification_deeplink.clone().unwrap_or_default(), state: params.state.clone(), - redirect_uri: params.redirect_uri.clone(), }; let html = template.render().map_err(|e| { @@ -880,8 +876,6 @@ pub async fn status( let response = crate::models::StatusResponse { status: status_str.to_string(), - code: data.authorization_code, - redirect_uri: data.redirect_uri, }; tracing::info!( @@ -893,6 +887,116 @@ pub async fn status( Ok((StatusCode::OK, Json(response))) } +pub async fn finalize( + State(state): State<AppState>, + Path(verification_id): Path<String>, + Query(params): Query<crate::models::StatusQuery>, +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { + tracing::info!( + "Finalize request for verification_id: {}, state: {}", + verification_id, + params.state + ); + + let session_data = crate::db::sessions::get_session_for_status(&state.pool, &verification_id) + .await + .map_err(|e| { + tracing::error!("DB error in finalize: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + let data = match session_data { + Some(d) => d, + None => { + tracing::warn!( + "Session not found for verification_id: {}", + verification_id + ); + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse::new("session_not_found")), + )); + } + }; + + if data.state.as_deref() != Some(&params.state) { + tracing::warn!( + "State mismatch for verification_id: {} (expected: {:?}, got: {})", + verification_id, + data.state, + params.state + ); + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse::new("invalid_state")), + )); + } + + if data.status != crate::db::sessions::SessionStatus::Verified { + tracing::warn!( + "Session {} not verified, status: {:?}", + verification_id, + data.status + ); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("not_verified")), + )); + } + + let authorization_code = match data.authorization_code { + Some(code) => code, + None => { + tracing::error!( + "Session {} verified but no authorization code found", + verification_id + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + )); + } + }; + + let redirect_uri = match data.redirect_uri { + Some(uri) => uri, + None => { + tracing::error!( + "Session {} has no redirect_uri", + verification_id + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + )); + } + }; + + let separator = if redirect_uri.contains('?') { '&' } else { '?' }; + let redirect_url = format!( + "{}{}code={}&state={}", + redirect_uri, + separator, + authorization_code, + urlencoding::encode(&params.state) + ); + + tracing::info!( + "Finalize: redirecting to {} for verification_id {}", + redirect_uri, + verification_id + ); + + Ok(( + StatusCode::FOUND, + [(header::LOCATION, redirect_url)], + "", + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/kych_oauth2_gateway/src/main.rs b/kych_oauth2_gateway/src/main.rs @@ -60,6 +60,7 @@ async fn main() -> Result<()> { .route("/info", get(handlers::info)) .route("/notification", post(handlers::notification_webhook)) .route("/status/{verification_id}", get(handlers::status)) + .route("/finalize/{verification_id}", get(handlers::finalize)) .nest_service("/js", ServeDir::new("js")) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/kych_oauth2_gateway/src/models.rs b/kych_oauth2_gateway/src/models.rs @@ -92,10 +92,6 @@ pub struct StatusQuery { #[derive(Debug, Serialize)] pub struct StatusResponse { pub status: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub redirect_uri: Option<String>, } // Notification payload sent to Client (Exchange, etc.) diff --git a/kych_oauth2_gateway/templates/authorize.html b/kych_oauth2_gateway/templates/authorize.html @@ -116,7 +116,6 @@ <script> const verificationId = "{{ verification_id }}"; const state = "{{ state }}"; - const redirectUri = "{{ redirect_uri }}"; try { new QRCode(document.getElementById("qrcode"), { @@ -160,9 +159,8 @@ const data = await response.json(); - if (data.status === 'verified' && data.code) { - const separator = redirectUri.includes('?') ? '&' : '?'; - window.location.href = `${redirectUri}${separator}code=${data.code}&state=${encodeURIComponent(state)}`; + if (data.status === 'verified') { + window.location.href = `/finalize/${verificationId}?state=${encodeURIComponent(state)}`; return; }