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:
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(¶ms.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(¶ms.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;
}