use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde_json::json;
use crate::oid4vp::session;
use crate::oid4vp::wallet::{self, WalletAnswer};
use crate::server::AppState;
const REQUEST_OBJECT_MEDIA_TYPE: &str = "application/oauth-authz-req+jwt";
fn plausible_session_id(id: &str) -> bool {
(16..=128).contains(&id.len())
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
fn oauth_error(status: StatusCode, error: &str, description: &str) -> Response {
no_store(
(
status,
axum::Json(json!({ "error": error, "error_description": description })),
)
.into_response(),
)
}
fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
#[utoipa::path(
get, path = "/oid4vp/request/{session}", tag = "oid4vp",
params(("session" = String, Path, description = "The wallet session id")),
responses(
(status = 200, description = "The signed request object (a JAR)", content_type = "application/oauth-authz-req+jwt", body = String),
(status = 404, description = "No open session by that id, or its request object was already fetched"),
),
)]
pub async fn request_object(
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> Response {
let not_found = || {
oauth_error(
StatusCode::NOT_FOUND,
"invalid_request_uri",
"no open request at this URI",
)
};
if !plausible_session_id(&session_id) {
return not_found();
}
match session::take_request_object(&state.oid4vp_sessions_ks, &session_id, chrono::Utc::now())
.await
{
Ok(Some(jar)) => no_store(
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static(REQUEST_OBJECT_MEDIA_TYPE),
)],
jar,
)
.into_response(),
),
Ok(None) => not_found(),
Err(e) => {
tracing::error!(error = %e, "wallet session request object could not be read");
oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"the request object could not be read",
)
}
}
}
#[utoipa::path(
post, path = "/oid4vp/response/{session}", tag = "oid4vp",
params(("session" = String, Path, description = "The wallet session id")),
request_body(content = String, content_type = "application/x-www-form-urlencoded",
description = "`response=<JWE>`, or OpenID4VP error parameters"),
responses(
(status = 200, description = "Received: the presentation verified, or the wallet's error was recorded"),
(status = 400, description = "The session is unknown or closed, or the presentation was not accepted"),
(status = 413, description = "The response is larger than this community accepts"),
),
)]
pub async fn response(
State(state): State<AppState>,
Path(session_id): Path<String>,
body: Body,
) -> Response {
let closed = || {
oauth_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"this session is unknown or no longer takes a response",
)
};
if !plausible_session_id(&session_id) {
return closed();
}
let limit = state.config.read().await.oid4vp.max_response_bytes;
let bytes = match axum::body::to_bytes(body, limit).await {
Ok(b) => b,
Err(_) => {
return oauth_error(
StatusCode::PAYLOAD_TOO_LARGE,
"invalid_request",
"the response is larger than this verifier accepts",
);
}
};
match wallet::receive(&state, &session_id, &bytes, chrono::Utc::now()).await {
Ok(WalletAnswer::Presented | WalletAnswer::Declined) => {
no_store((StatusCode::OK, axum::Json(json!({}))).into_response())
}
Ok(WalletAnswer::Refused(_)) => oauth_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"the presentation was not accepted",
),
Ok(WalletAnswer::NoSession) => closed(),
Err(e) => {
tracing::error!(error = %e, "wallet response could not be processed");
oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"the response could not be processed",
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_base64url_token_of_session_length_is_looked_up() {
assert!(plausible_session_id(&session::random_token(24)));
assert!(!plausible_session_id("short"));
assert!(!plausible_session_id("../../../../etc/passwd-padding-xx"));
assert!(!plausible_session_id(&"a".repeat(129)));
}
}