//! A local stub of the Enable Banking API (`/aspsps`, `/auth`, `/sessions`, `/accounts/{uid}/transactions`, //! `DELETE /sessions/{id}`), shaped after K0's field-presence findings (D-179): at most 100 transactions per response //! and no continuation key unless a test asks for one. Plus a throwaway RSA key generated per test (no key is //! committed) and the public key to verify the JWTs it signs. use std::collections::VecDeque; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use aws_lc_rs::encoding::AsDer; use aws_lc_rs::rsa::{KeyPair, KeySize}; use aws_lc_rs::signature::KeyPair as _; use axum::Router; use axum::body::Bytes; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post}; use base64::Engine; use chrono::NaiveDate; use serde_json::{Value, json}; use std::collections::HashMap; /// The SJ Prio bank as K0 found it in `GET /aspsps?country=SE`. pub fn sj_prio_aspsp(required: &[&str]) -> Value { json!({"name": "SJ Prio Mastercard", "country": "SE", "beta": false, "maximum_consent_validity": 15552000, "psu_types": ["personal"], "auth_methods": [{"approach": "REDIRECT"}], "required_psu_headers": required}) } /// One request the stub received. #[derive(Debug, Clone)] pub struct Received { pub method: String, pub path: String, pub query: HashMap, pub headers: HeaderMap, pub body: Bytes, } impl Received { pub fn json(&self) -> Value { serde_json::from_slice(&self.body).unwrap_or(Value::Null) } pub fn header(&self, name: &str) -> Option { self.headers .get(name) .map(|v| v.to_str().unwrap().to_owned()) } /// The PSU headers sent, lower case, sorted. pub fn psu_headers(&self) -> Vec { let mut names: Vec = self .headers .keys() .map(|k| k.as_str().to_owned()) .filter(|k| k.starts_with("psu-")) .collect(); names.sort(); names } } pub struct StubState { pub requests: Mutex>, pub aspsps: Mutex, /// The session `POST /sessions` returns. pub session: Mutex, /// The bank's transactions; each may carry `_bank_date`, the date the stub filters on (default: booking or /// value date), removed before answering. pub transactions: Mutex>, /// Answer windows with a continuation key, 100 rows per page, instead of cutting at 100. pub paginate: Mutex, /// `WRONG_TRANSACTIONS_PERIOD` for a window whose `date_to` is before this day. pub history_from: Mutex>, /// Scripted answers of `GET /accounts/{uid}/transactions`, used in order before the data. pub transaction_script: Mutex>, pub auth_script: Mutex>, pub session_script: Mutex>, pub delete_script: Mutex>, /// Delay of each transactions answer, in milliseconds. pub delay_ms: Mutex, } impl Default for StubState { fn default() -> Self { Self { requests: Mutex::new(Vec::new()), aspsps: Mutex::new(json!({"aspsps": [sj_prio_aspsp(&["psu-ip-address"])]})), session: Mutex::new(json!({ "session_id": "sess-secret-1", "accounts": [{"uid": "acc-secret-1", "cash_account_type": "CARD", "currency": "SEK", "account_id": {"other": {"identification": "5299 **** **** 4711", "scheme_name": "CPAN"}}}], "aspsp": {"name": "SJ Prio Mastercard", "country": "SE"}, "psu_type": "personal", "access": {"valid_until": "2027-04-15T11:55:00.000000+00:00"} })), transactions: Mutex::new(Vec::new()), paginate: Mutex::new(false), history_from: Mutex::new(None), transaction_script: Mutex::new(VecDeque::new()), auth_script: Mutex::new(VecDeque::new()), session_script: Mutex::new(VecDeque::new()), delete_script: Mutex::new(VecDeque::new()), delay_ms: Mutex::new(0), } } } pub struct Stub { pub addr: SocketAddr, pub state: Arc, } impl Stub { pub async fn start() -> Self { let state = Arc::new(StubState::default()); let app = Router::new() .route("/aspsps", get(aspsps)) .route("/auth", post(auth)) .route("/sessions", post(sessions)) .route("/sessions/{id}", delete(delete_session)) .route("/accounts/{uid}/transactions", get(transactions)) .with_state(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); Self { addr, state } } pub fn url(&self) -> String { format!("http://{}", self.addr) } pub fn requests(&self) -> Vec { self.state.requests.lock().unwrap().clone() } pub fn requests_to(&self, path_prefix: &str) -> Vec { self.requests() .into_iter() .filter(|r| r.path.starts_with(path_prefix)) .collect() } pub fn transaction_requests(&self) -> Vec { self.requests_to("/accounts/") } pub fn clear(&self) { self.state.requests.lock().unwrap().clear(); } pub fn set_transactions(&self, txs: Vec) { *self.state.transactions.lock().unwrap() = txs; } pub fn script_transactions(&self, answers: &[(u16, Value)]) { self.state .transaction_script .lock() .unwrap() .extend(answers.iter().cloned()); } /// The `state` the server sent with its last `POST /auth`. pub fn last_state(&self) -> String { self.requests_to("/auth").last().unwrap().json()["state"] .as_str() .unwrap() .to_owned() } } fn record( state: &StubState, method: &str, path: String, query: HashMap, headers: HeaderMap, body: Bytes, ) { state.requests.lock().unwrap().push(Received { method: method.to_owned(), path, query, headers, body, }); } fn answer(status: u16, body: Value) -> Response { (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response() } fn error(status: u16, code: &str) -> Value { json!({"code": status, "error": code, "message": "stub", "detail": "a body the server must never log"}) } pub fn eb_error(status: u16, code: &str) -> (u16, Value) { (status, error(status, code)) } async fn aspsps( State(s): State>, Query(q): Query>, headers: HeaderMap, ) -> Response { record(&s, "GET", "/aspsps".into(), q, headers, Bytes::new()); let body = s.aspsps.lock().unwrap().clone(); answer(200, body) } async fn auth(State(s): State>, headers: HeaderMap, body: Bytes) -> Response { record(&s, "POST", "/auth".into(), HashMap::new(), headers, body); if let Some((status, body)) = s.auth_script.lock().unwrap().pop_front() { return answer(status, body); } answer( 200, json!({"url": "https://bank.example.invalid/authorize?session=example", "authorization_id": "auth-1", "psu_id_hash": "h"}), ) } async fn sessions(State(s): State>, headers: HeaderMap, body: Bytes) -> Response { record( &s, "POST", "/sessions".into(), HashMap::new(), headers, body, ); if let Some((status, body)) = s.session_script.lock().unwrap().pop_front() { return answer(status, body); } let body = s.session.lock().unwrap().clone(); answer(200, body) } async fn delete_session( State(s): State>, Path(id): Path, headers: HeaderMap, ) -> Response { record( &s, "DELETE", format!("/sessions/{id}"), HashMap::new(), headers, Bytes::new(), ); if let Some((status, body)) = s.delete_script.lock().unwrap().pop_front() { return answer(status, body); } answer(200, json!({"message": "OK"})) } fn bank_date(t: &Value) -> NaiveDate { let text = t["_bank_date"] .as_str() .or_else(|| t["booking_date"].as_str()) .or_else(|| t["value_date"].as_str()) .unwrap(); NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap() } async fn transactions( State(s): State>, Path(uid): Path, Query(q): Query>, headers: HeaderMap, ) -> Response { record( &s, "GET", format!("/accounts/{uid}/transactions"), q.clone(), headers, Bytes::new(), ); let delay = *s.delay_ms.lock().unwrap(); if delay > 0 { tokio::time::sleep(std::time::Duration::from_millis(delay)).await; } if let Some((status, body)) = s.transaction_script.lock().unwrap().pop_front() { return answer(status, body); } let date = |key: &str| { q.get(key) .map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap()) }; let (Some(from), Some(to)) = (date("date_from"), date("date_to")) else { return answer(422, error(422, "WRONG_REQUEST_PARAMETERS")); }; if let Some(first) = *s.history_from.lock().unwrap() && to < first { return answer(422, error(422, "WRONG_TRANSACTIONS_PERIOD")); } let mut rows: Vec = s .transactions .lock() .unwrap() .iter() .filter(|t| (from..=to).contains(&bank_date(t))) .cloned() .collect(); rows.sort_by_key(|t| std::cmp::Reverse(bank_date(t))); for row in &mut rows { row.as_object_mut().unwrap().remove("_bank_date"); } if *s.paginate.lock().unwrap() { let start: usize = q.get("continuation_key").map_or(0, |k| k.parse().unwrap()); let end = (start + 100).min(rows.len()); let next = (end < rows.len()).then(|| end.to_string()); return answer( 200, json!({"transactions": rows[start..end], "continuation_key": next}), ); } rows.truncate(100); answer(200, json!({"transactions": rows, "continuation_key": null})) } /// A generated RSA key as PEM (PKCS#8) and the public key (DER) to verify the JWTs. pub struct TestKey { pub pem: String, pub public_der: Vec, } pub fn test_key() -> TestKey { let pair = KeyPair::generate(KeySize::Rsa2048).unwrap(); let der = AsDer::::as_der(&pair).unwrap(); let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref()); let lines: Vec<&str> = b64 .as_bytes() .chunks(64) .map(|c| std::str::from_utf8(c).unwrap()) .collect(); TestKey { pem: format!( "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", lines.join("\n") ), public_der: pair.public_key().as_ref().to_vec(), } }