commit 2a5852b120d1c3f7c6c22f1113b4edd929a9cad0
parent 8aa7ebfd6b4880a01af57b1dbdfb9a18e86744f3
Author: Antoine A <>
Date: Tue, 4 Nov 2025 14:48:52 +0100
magnet-bank: clean api client logic and improve worker
Diffstat:
16 files changed, 1086 insertions(+), 954 deletions(-)
diff --git a/taler-magnet-bank/src/bin/magnet-bank-harness.rs b/taler-magnet-bank/src/bin/magnet-bank-harness.rs
@@ -36,7 +36,10 @@ use taler_magnet_bank::{
config::{HarnessCfg, parse_db_cfg},
db::{self, TransferResult},
failure_injection::{FailureLogic, InjectedErr, set_failure_logic},
- magnet::{Account, ApiClient, AuthClient},
+ magnet_api::{
+ client::{ApiClient, AuthClient},
+ types::Account,
+ },
setup,
worker::{Worker, WorkerError},
};
diff --git a/taler-magnet-bank/src/config.rs b/taler-magnet-bank/src/config.rs
@@ -26,7 +26,7 @@ use taler_common::{
types::payto::PaytoURI,
};
-use crate::{FullHuPayto, HuIban, magnet::Token};
+use crate::{FullHuPayto, HuIban, magnet_api::oauth::Token};
pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> {
DbCfg::parse(cfg.section("magnet-bankdb-postgres"))
diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs
@@ -33,7 +33,7 @@ use taler_common::{
};
use tokio::sync::watch::{Receiver, Sender};
-use crate::{FullHuPayto, constant::CURRENCY, magnet::TxStatus};
+use crate::{FullHuPayto, constant::CURRENCY, magnet_api::types::TxStatus};
pub async fn notification_listener(
pool: PgPool,
@@ -78,7 +78,7 @@ impl Display for TxIn {
} = self;
write!(
f,
- "{value_date} {amount} {code} ({} {}) '{subject}'",
+ "{value_date} {code} {amount} ({} {}) '{subject}'",
debtor.bban(),
debtor.name
)
@@ -107,7 +107,7 @@ impl Display for TxOut {
} = self;
write!(
f,
- "{value_date} {amount} {code} ({} {}) {status:?} '{subject}'",
+ "{value_date} {code} {amount} ({} {}) {status:?} '{subject}'",
creditor.bban(),
&creditor.name
)
@@ -715,7 +715,7 @@ mod test {
TxIn, TxOut, TxOutKind, make_transfer, register_bounce_tx_in, register_tx_in,
register_tx_in_admin, register_tx_out,
},
- magnet::TxStatus,
+ magnet_api::types::TxStatus,
magnet_payto,
};
diff --git a/taler-magnet-bank/src/dev.rs b/taler-magnet-bank/src/dev.rs
@@ -26,7 +26,7 @@ use tracing::info;
use crate::{
HuPayto, TransferHuPayto,
config::WorkerCfg,
- magnet::{AuthClient, Direction},
+ magnet_api::{client::AuthClient, types::Direction},
setup,
worker::{Tx, extract_tx_info},
};
diff --git a/taler-magnet-bank/src/lib.rs b/taler-magnet-bank/src/lib.rs
@@ -29,7 +29,7 @@ pub mod config;
pub mod constant;
pub mod db;
pub mod dev;
-pub mod magnet;
+pub mod magnet_api;
pub mod setup;
pub mod worker;
pub mod failure_injection {
diff --git a/taler-magnet-bank/src/magnet.rs b/taler-magnet-bank/src/magnet.rs
@@ -1,574 +0,0 @@
-/*
- This file is part of TALER
- Copyright (C) 2025 Taler Systems SA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU Affero General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
-
-use base64::{Engine, prelude::BASE64_STANDARD};
-use error::ApiResult;
-use jiff::Timestamp;
-use p256::{
- PublicKey,
- ecdsa::{DerSignature, SigningKey, signature::Signer as _},
-};
-use serde::{Deserialize, Serialize};
-use serde_json::{Value, json};
-use spki::EncodePublicKey;
-use taler_common::types::amount;
-
-use crate::{
- HuIban,
- magnet::{error::MagnetBuilder, oauth::OAuthBuilder},
-};
-
-pub mod error;
-mod oauth;
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Token {
- #[serde(rename = "oauth_token")]
- pub key: String,
- #[serde(rename = "oauth_token_secret")]
- pub secret: String,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct TokenAuth {
- pub oauth_token: String,
- pub oauth_verifier: String,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct Consumer {
- #[serde(rename = "consumerKey")]
- pub key: String,
- #[serde(rename = "megnevezes")]
- pub name: String,
- #[serde(rename = "callbackUri")]
- pub callback_uri: String,
- #[serde(rename = "elettartam")]
- pub lifetime: u64,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct TokenInfo {
- #[serde(rename = "keszult")]
- pub created: jiff::Timestamp,
- #[serde(rename = "lejarat")]
- pub expiration: jiff::Timestamp,
- #[serde(rename = "kliensinfo")]
- pub client_info: Option<String>,
- pub consumer: Consumer,
- #[serde(rename = "hitelesitett")]
- pub authenticated: bool,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct SmsCodeSubmission {
- #[serde(rename = "csatorna")]
- pub channel: String,
- #[serde(rename = "hovaMentKi")]
- pub sent_to: Vec<String>,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct ScaResult {
- #[serde(rename = "csatorna")]
- pub channel: String,
- #[serde(rename = "hovaMentKi")]
- pub sent_to: Vec<String>,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct Partner {
- #[serde(rename = "megnevezes")]
- pub name: String,
- #[serde(rename = "kod")]
- pub code: u64,
- #[serde(rename = "adoszam")]
- pub tax_number: Option<String>,
- #[serde(rename = "ebUfallapot")]
- pub status: String, // TODO enum
-}
-
-#[derive(Debug, Deserialize)]
-pub struct AccountType {
- #[serde(rename = "kod")]
- pub code: u64,
- #[serde(rename = "megnevezes")]
- pub name: String,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct Currency {
- #[serde(rename = "jel")]
- pub symbol: amount::Currency,
- #[serde(rename = "megnevezes")]
- pub name: String,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct Account {
- #[serde(rename = "alapertelmezett")]
- pub default: bool,
- #[serde(rename = "bankszamlaTipus")]
- pub ty: AccountType,
- #[serde(rename = "deviza")]
- pub currency: Currency,
- #[serde(rename = "ibanSzamlaszam")]
- pub iban: HuIban,
- #[serde(rename = "kod")]
- pub code: u64,
- #[serde(rename = "szamlaszam")]
- pub number: String,
- #[serde(rename = "tulajdonosKod")]
- pub owner_code: u64,
- #[serde(rename = "lakossagi")]
- pub resident: bool,
- #[serde(rename = "megnevezes")]
- pub name: Option<String>,
- pub partner: Partner,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct BalanceMini {
- #[serde(rename = "pozicio")]
- pub balance: f64,
- #[serde(rename = "frissites")]
- pub last_update_time: jiff::Timestamp,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct PartnerAccounts {
- pub partner: Partner,
- #[serde(rename = "bankszamlaList")]
- pub bank_accounts: Vec<Account>,
- #[serde(rename = "kertJogosultsag")]
- pub requested_permission: u64,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct PartnerList {
- #[serde(rename = "partnerSzamlaList")]
- pub partners: Vec<PartnerAccounts>,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub enum TxStatus {
- #[serde(rename = "G")]
- ToBeRecorded,
- #[serde(rename = "1")]
- PendingFirstSignature,
- #[serde(rename = "2")]
- PendingSecondSignature,
- #[serde(rename = "F")]
- PendingProcessing,
- #[serde(rename = "L")]
- Verified,
- #[serde(rename = "R")]
- PartiallyCompleted,
- #[serde(rename = "T")]
- Completed,
- #[serde(rename = "E")]
- Rejected,
- #[serde(rename = "M")]
- Canceled,
- #[serde(rename = "P")]
- UnderReview,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub enum Direction {
- #[serde(rename = "T")]
- Outgoing,
- #[serde(rename = "J")]
- Incoming,
- #[serde(rename = "M")]
- Both,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct TxInfo {
- #[serde(rename = "alairas1idopont")]
- pub first_signature: Option<Timestamp>,
- #[serde(rename = "alairas2idopont")]
- pub second_signature: Option<Timestamp>,
- #[serde(rename = "alairo1")]
- pub first_signatory: Option<Partner>,
- #[serde(rename = "alairo2")]
- pub second_signatory: Option<Partner>,
- #[serde(rename = "reszteljesites")]
- pub partial_execution: bool,
- #[serde(rename = "sorbaallitas")]
- pub queued: bool,
- #[serde(rename = "kod")]
- pub code: u64,
- #[serde(rename = "bankszamla")]
- pub bank_account: Account,
- #[serde(rename = "deviza")]
- pub currency: Currency,
- #[serde(rename = "osszegSigned")]
- pub amount: f64,
- #[serde(rename = "kozlemeny")]
- pub subject: String,
- #[serde(rename = "statusz")]
- pub status: TxStatus,
- #[serde(rename = "tranzakcioAltipus")]
- pub kind: Option<String>,
- #[serde(rename = "eredetiErteknap")]
- pub tx_date: jiff::civil::Date,
- #[serde(rename = "erteknap")]
- pub value_date: jiff::civil::Date,
- #[serde(rename = "eszamla")]
- pub counter_account: String,
- #[serde(rename = "epartner")]
- pub counter_name: String,
- #[serde(rename = "tranzakcioTipus")]
- pub ty: Option<String>,
- pub eam: Option<u64>,
-}
-
-#[derive(Debug, Deserialize)]
-struct TxInfoWrapper {
- #[serde(rename = "tranzakcio")]
- info: TxInfo,
-}
-
-#[derive(Debug, Deserialize)]
-struct AccountWrapper {
- #[serde(rename = "bankszamla")]
- account: Account,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct Transaction {
- #[serde(rename = "kod")]
- pub code: u64,
- #[serde(rename = "bankszamla")]
- pub bank_account: String,
- #[serde(rename = "bankszamlaTulajdonos")]
- pub bank_account_owner: String,
- #[serde(rename = "deviza")]
- pub currency: amount::Currency,
- #[serde(rename = "osszeg")]
- pub amount: f64,
- #[serde(rename = "kozlemeny")]
- pub subject: String,
- #[serde(rename = "statusz")]
- pub status: TxStatus,
- #[serde(rename = "tranzakcioAltipus")]
- pub kind: Option<String>,
- #[serde(rename = "eredetiErteknap")]
- pub tx_date: jiff::civil::Date,
- #[serde(rename = "erteknap")]
- pub value_date: jiff::civil::Date,
- #[serde(rename = "eszamla")]
- pub counter_account: String,
- #[serde(rename = "epartner")]
- pub counter_name: String,
- #[serde(rename = "tranzakcioTipus")]
- pub ty: Option<String>,
- pub eam: Option<u64>,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct Next {
- #[serde(rename = "next")]
- pub next_id: u64,
- #[serde(rename = "nextTipus")]
- pub next_type: String,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct TransactionPage {
- #[serde(flatten)]
- pub next: Option<Next>,
- #[serde(rename = "tranzakcioList", default)]
- pub list: Vec<TransactionWrapper>,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct TransactionWrapper {
- #[serde(rename = "tranzakcioDto")]
- pub tx: Transaction,
-}
-
-pub struct AuthClient<'a> {
- client: &'a reqwest::Client,
- api_url: &'a reqwest::Url,
- consumer: &'a Token,
-}
-
-impl<'a> AuthClient<'a> {
- pub fn new(
- client: &'a reqwest::Client,
- api_url: &'a reqwest::Url,
- consumer: &'a Token,
- ) -> Self {
- Self {
- client,
- api_url,
- consumer,
- }
- }
-
- pub fn join(&self, path: &str) -> reqwest::Url {
- self.api_url.join(path).unwrap()
- }
-
- pub async fn token_request(&self) -> ApiResult<Token> {
- self.client
- .get(self.join("/NetBankOAuth/token/request"))
- .query(&[("oauth_callback", "oob")])
- .oauth(self.consumer, None, None)
- .await
- .magnet_call_encoded()
- .await
- }
-
- pub async fn token_access(
- &self,
- token_request: &Token,
- token_auth: &TokenAuth,
- ) -> ApiResult<Token> {
- self.client
- .get(self.join("/NetBankOAuth/token/access"))
- .oauth(
- self.consumer,
- Some(token_request),
- Some(&token_auth.oauth_verifier),
- )
- .await
- .magnet_call_encoded()
- .await
- }
-
- pub fn upgrade(self, access: &'a Token) -> ApiClient<'a> {
- ApiClient {
- client: self.client,
- api_url: self.api_url,
- consumer: self.consumer,
- access,
- }
- }
-}
-
-pub struct ApiClient<'a> {
- client: &'a reqwest::Client,
- api_url: &'a reqwest::Url,
- consumer: &'a Token,
- access: &'a Token,
-}
-
-impl ApiClient<'_> {
- fn join(&self, path: &str) -> reqwest::Url {
- self.api_url.join(path).unwrap()
- }
-
- pub async fn token_info(&self) -> ApiResult<TokenInfo> {
- self.client
- .get(self.join("/RESTApi/resources/v2/token"))
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_json()
- .await
- }
-
- pub async fn request_sms_code(&self) -> ApiResult<SmsCodeSubmission> {
- self.client
- .get(self.join("/RESTApi/resources/v2/kodszo/sms/token"))
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_json()
- .await
- }
-
- pub async fn perform_sca(&self, code: &str) -> ApiResult<()> {
- self.client
- .put(self.join("/RESTApi/resources/v2/token/SCA"))
- .json(&json!({
- "kodszo": code
- }))
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_empty()
- .await
- }
-
- pub async fn upload_public_key(&self, key: &SigningKey) -> ApiResult<Value> {
- let public_key = PublicKey::from_secret_scalar(key.as_nonzero_scalar());
- let der = public_key.to_public_key_der().unwrap().to_vec();
- self.client
- .post(self.join("/RESTApi/resources/v2/token/public-key"))
- .json(&json!({
- "keyData": BASE64_STANDARD.encode(der)
- }))
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_json()
- .await
- }
-
- pub async fn list_accounts(&self) -> ApiResult<PartnerList> {
- self.client
- .get(self.join("/RESTApi/resources/v2/partnerszamla/0"))
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_json()
- .await
- }
-
- pub async fn account(&self, bban: &str) -> ApiResult<Account> {
- Ok(self
- .client
- .get(self.join(&format!("/RESTApi/resources/v2/bankszamla/{bban}")))
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_json::<AccountWrapper>()
- .await?
- .account)
- }
-
- pub async fn balance_mini(&self, bban: &str) -> ApiResult<BalanceMini> {
- self.client
- .get(self.join(&format!("/RESTApi/resources/v2/egyenleg/{bban}/szukitett")))
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_json()
- .await
- }
-
- pub async fn page_tx(
- &self,
- direction: Direction,
- limit: u16,
- bban: &str,
- next: &Option<Next>,
- status: &Option<TxStatus>,
- ) -> ApiResult<TransactionPage> {
- let mut req = self.client.get(self.join(&format!(
- "/RESTApi/resources/v2/tranzakcio/paginator/{bban}/{limit}"
- )));
- if let Some(next) = next {
- req = req
- .query(&[("nextId", next.next_id)])
- .query(&[("nextTipus", &next.next_type)]);
- }
- if let Some(status) = status {
- req = req.query(&[("statusz", status)]);
- }
- req.query(&[("terheles", direction)])
- .query(&[("tranzakciofrissites", true), ("ascending", true)])
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_call()
- .await
- }
-
- pub async fn init_tx(
- &self,
- account_code: u64,
- amount: f64,
- subject: &str,
- date: &jiff::civil::Date,
- creditor_name: &str,
- creditor_bban: &str,
- ) -> ApiResult<TxInfo> {
- #[derive(Serialize)]
- struct Req<'a> {
- #[serde(rename = "bankszamlaKod")]
- account_code: u64,
- #[serde(rename = "osszeg")]
- amount: f64,
- #[serde(rename = "kozlemeny")]
- subject: &'a str,
- #[serde(rename = "ertekNap")]
- date: &'a jiff::civil::Date,
- #[serde(rename = "ellenpartner")]
- creditor_name: &'a str,
- #[serde(rename = "ellenszamla")]
- creditor_account: &'a str,
- }
-
- Ok(self
- .client
- .post(self.join("/RESTApi/resources/v2/esetiatutalas"))
- .json(&Req {
- account_code,
- amount,
- subject,
- date,
- creditor_name,
- creditor_account: creditor_bban,
- })
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_call::<TxInfoWrapper>()
- .await?
- .info)
- }
-
- pub async fn submit_tx(
- &self,
- signing_key: &SigningKey,
- bban: &str,
- tx_code: u64,
- amount: f64,
- date: &jiff::civil::Date,
- creditor_bban: &str,
- ) -> ApiResult<TxInfo> {
- #[derive(Serialize)]
- struct Req<'a> {
- #[serde(rename = "tranzakcioKod")]
- tx_code: u64,
- #[serde(rename = "forrasszamla")]
- debtor: &'a str,
- #[serde(rename = "ellenszamla")]
- creditor: &'a str,
- #[serde(rename = "osszeg")]
- amount: f64,
- #[serde(rename = "ertekNap")]
- date: &'a jiff::civil::Date,
- signature: &'a str,
- }
-
- let content: String = format!("{tx_code};{bban};{creditor_bban};{amount};{date};");
- let signature: DerSignature = signing_key.sign(content.as_bytes());
- let encoded = BASE64_STANDARD.encode(signature.as_bytes());
- Ok(self
- .client
- .put(self.join("/RESTApi/resources/v2/tranzakcio/alairas"))
- .json(&Req {
- tx_code,
- debtor: bban,
- creditor: creditor_bban,
- amount,
- date,
- signature: &encoded,
- })
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_call::<TxInfoWrapper>()
- .await?
- .info)
- }
-
- pub async fn delete_tx(&self, tx_code: u64) -> ApiResult<()> {
- self.client
- .delete(self.join(&format!("/RESTApi/resources/v2/tranzakcio/{tx_code}")))
- .oauth(self.consumer, Some(self.access), None)
- .await
- .magnet_empty()
- .await
- }
-}
diff --git a/taler-magnet-bank/src/magnet/error.rs b/taler-magnet-bank/src/magnet/error.rs
@@ -1,169 +0,0 @@
-/*
- This file is part of TALER
- Copyright (C) 2025 Taler Systems SA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU Affero General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
-
-use reqwest::{Response, StatusCode, header};
-use serde::{Deserialize, de::DeserializeOwned};
-use thiserror::Error;
-use tracing::{Level, error};
-
-#[derive(Deserialize, Debug)]
-struct Header {
- #[serde(rename = "errorCode")]
- pub error_code: Option<u16>,
-}
-
-#[derive(Deserialize, Debug)]
-struct Empty {}
-
-#[derive(Deserialize, Error, Debug)]
-#[error("{error_code} {short_message} '{long_message}'")]
-pub struct MagnetError {
- #[serde(rename = "errorCode")]
- pub error_code: u16,
- #[serde(rename = "shortMessage")]
- pub short_message: String,
- #[serde(rename = "longMessage")]
- pub long_message: String,
-}
-
-#[derive(Error, Debug)]
-pub enum ApiError {
- #[error("transport: {0}")]
- Transport(FmtSource<reqwest::Error>),
- #[error("magnet {0}")]
- Magnet(#[from] MagnetError),
- #[error("JSON body: {0}")]
- Json(#[from] serde_path_to_error::Error<serde_json::Error>),
- #[error("form body: {0}")]
- Form(#[from] serde_urlencoded::de::Error),
- #[error("status {0}")]
- Status(StatusCode),
- #[error("status {0} '{1}'")]
- StatusCause(StatusCode, String),
-}
-
-#[derive(Debug)]
-pub struct FmtSource<E: std::error::Error>(E);
-
-fn fmt_with_source(
- f: &mut std::fmt::Formatter<'_>,
- mut e: &dyn std::error::Error,
-) -> std::fmt::Result {
- loop {
- write!(f, "{}", &e)?;
- if let Some(source) = e.source() {
- write!(f, ": ")?;
- e = source;
- } else {
- return Ok(());
- }
- }
-}
-
-impl<E: std::error::Error> std::fmt::Display for FmtSource<E> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- fmt_with_source(f, &self.0)
- }
-}
-
-impl<E: std::error::Error> From<E> for FmtSource<E> {
- fn from(value: E) -> Self {
- Self(value)
- }
-}
-
-impl From<reqwest::Error> for ApiError {
- fn from(value: reqwest::Error) -> Self {
- Self::Transport(FmtSource(value))
- }
-}
-
-pub type ApiResult<R> = std::result::Result<R, ApiError>;
-
-/** Handle error from magnet API calls */
-async fn error_handling(res: reqwest::Result<Response>) -> ApiResult<String> {
- let res = res?;
- let status = res.status();
- match status {
- StatusCode::OK => Ok(res.text().await?),
- StatusCode::BAD_REQUEST => Err(ApiError::Status(status)),
- StatusCode::FORBIDDEN => {
- let cause = res
- .headers()
- .get(header::WWW_AUTHENTICATE)
- .map(|s| s.to_str().unwrap_or_default())
- .unwrap_or_default();
- Err(ApiError::StatusCause(status, cause.to_string()))
- }
- _ => {
- if tracing::enabled!(Level::DEBUG) {
- tracing::debug!("unexpected error: {:?}", &res);
- let body = res.text().await;
- tracing::debug!("unexpected error body: {:?}", body);
- }
- Err(ApiError::Status(status))
- }
- }
-}
-
-/** Parse JSON and track error path */
-fn parse<'de, T: Deserialize<'de>>(str: &'de str) -> ApiResult<T> {
- let deserializer = &mut serde_json::Deserializer::from_str(str);
- serde_path_to_error::deserialize(deserializer).map_err(ApiError::Json)
-}
-
-/** Parse magnet JSON response */
-async fn magnet_json<T: DeserializeOwned>(res: reqwest::Result<Response>) -> ApiResult<T> {
- let body = error_handling(res).await?;
- let header: Header = parse(&body)?;
- if header.error_code.unwrap_or(200) == 200 {
- parse(&body)
- } else {
- Err(ApiError::Magnet(parse(&body)?))
- }
-}
-
-/** Parse magnet URL encoded response into our own type */
-async fn magnet_url<T: DeserializeOwned>(response: reqwest::Result<Response>) -> ApiResult<T> {
- let body = error_handling(response).await?;
- serde_urlencoded::from_str(&body).map_err(ApiError::Form)
-}
-
-pub(crate) trait MagnetBuilder {
- async fn magnet_call_encoded<T: DeserializeOwned>(self) -> ApiResult<T>;
- async fn magnet_call<T: DeserializeOwned>(self) -> ApiResult<T>;
- async fn magnet_empty(self) -> ApiResult<()>;
- async fn magnet_json<R: DeserializeOwned>(self) -> ApiResult<R>;
-}
-
-impl MagnetBuilder for reqwest::Result<Response> {
- async fn magnet_call_encoded<T: DeserializeOwned>(self) -> ApiResult<T> {
- magnet_url(self).await
- }
-
- async fn magnet_call<T: DeserializeOwned>(self) -> ApiResult<T> {
- magnet_json(self).await
- }
-
- async fn magnet_empty(self) -> ApiResult<()> {
- magnet_json::<Empty>(self).await?;
- Ok(())
- }
-
- async fn magnet_json<R: DeserializeOwned>(self) -> ApiResult<R> {
- magnet_json(self).await
- }
-}
diff --git a/taler-magnet-bank/src/magnet/oauth.rs b/taler-magnet-bank/src/magnet/oauth.rs
@@ -1,161 +0,0 @@
-/*
- This file is part of TALER
- Copyright (C) 2025 Taler Systems SA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU Affero General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
-
-use std::{borrow::Cow, time::SystemTime};
-
-use base64::{Engine as _, prelude::BASE64_STANDARD};
-use hmac::{Hmac, Mac};
-use percent_encoding::NON_ALPHANUMERIC;
-use rand_core::RngCore;
-use reqwest::header::HeaderValue;
-use sha1::Sha1;
-
-use super::Token;
-
-type HmacSha1 = Hmac<Sha1>;
-
-/** Generate a secure OAuth nonce */
-fn oauth_nonce() -> String {
- // Generate 8 secure random bytes
- let mut buf = [0u8; 8];
- rand_core::OsRng.fill_bytes(&mut buf);
- // Encode as base64 string
- BASE64_STANDARD.encode(buf)
-}
-
-/** Generate an OAuth timestamp */
-fn oauth_timestamp() -> u64 {
- let start = SystemTime::now();
- let since_the_epoch = start
- .duration_since(std::time::UNIX_EPOCH)
- .expect("Time went backwards");
-
- since_the_epoch.as_secs()
-}
-
-/** Generate a valid OAuth Authorization header */
-fn oauth_header(
- method: &reqwest::Method,
- url: &reqwest::Url,
- consumer: &Token,
- access: Option<&Token>,
- verifier: Option<&str>,
-) -> String {
- // Per request value
- let oauth_nonce = oauth_nonce();
- let oauth_timestamp = oauth_timestamp().to_string();
-
- // Base string
- let base_string = {
- let oauth_data = {
- let mut oauth_query: Vec<(&str, &str)> = vec![
- ("oauth_consumer_key", &consumer.key),
- ("oauth_nonce", &oauth_nonce),
- ("oauth_signature_method", "HMAC-SHA1"),
- ("oauth_timestamp", &oauth_timestamp),
- ];
- if let Some(token) = &access {
- oauth_query.push(("oauth_token", &token.key));
- }
- if let Some(verifier) = &verifier {
- oauth_query.push(("oauth_verifier", verifier));
- }
- oauth_query.push(("oauth_version", "1.0"));
- let mut all_query: Vec<_> = oauth_query
- .into_iter()
- .map(|(a, b)| (Cow::Borrowed(a), Cow::Borrowed(b)))
- .chain(url.query_pairs())
- .collect();
- all_query.sort_unstable();
-
- let mut tmp: form_urlencoded::Serializer<'_, String> =
- form_urlencoded::Serializer::new(String::new());
- for (k, v) in all_query {
- tmp.append_pair(&k, &v);
- }
- tmp.finish()
- };
- let mut stripped = url.clone();
- stripped.set_query(None);
- form_urlencoded::Serializer::new(String::new())
- .append_key_only(method.as_str())
- .append_key_only(stripped.as_str())
- .append_key_only(&oauth_data)
- .finish()
- };
-
- // Signature
- let key = {
- let mut buf = consumer.secret.clone();
- buf.push('&');
- if let Some(token) = access {
- buf.push_str(&token.secret);
- }
- buf
- };
- let signature = HmacSha1::new_from_slice(key.as_bytes())
- .expect("HMAC can take key of any size")
- .chain_update(base_string.as_bytes())
- .finalize()
- .into_bytes();
- let signature_encoded = BASE64_STANDARD.encode(signature);
-
- // Authorization header
- {
- let mut buf = "OAuth ".to_string();
- let mut append = |key: &str, value: &str| {
- buf.push_str(key);
- buf.push_str("=\"");
- for part in percent_encoding::percent_encode(value.as_bytes(), NON_ALPHANUMERIC) {
- buf.push_str(part);
- }
- buf.push_str("\",");
- };
- append("oauth_consumer_key", &consumer.key);
- append("oauth_nonce", &oauth_nonce);
- append("oauth_signature_method", "HMAC-SHA1");
- append("oauth_timestamp", &oauth_timestamp);
- if let Some(token) = &access {
- append("oauth_token", &token.key);
- }
- if let Some(verifier) = &verifier {
- append("oauth_verifier", verifier);
- }
- append("oauth_version", "1.0");
- append("oauth_signature", &signature_encoded);
- buf
- }
-}
-
-pub trait OAuthBuilder<T> {
- async fn oauth(self, consumer: &Token, access: Option<&Token>, verifier: Option<&str>) -> T;
-}
-
-impl OAuthBuilder<reqwest::Result<reqwest::Response>> for reqwest::RequestBuilder {
- async fn oauth(
- self,
- consumer: &Token,
- access: Option<&Token>,
- verifier: Option<&str>,
- ) -> reqwest::Result<reqwest::Response> {
- let (client, req) = self.build_split();
- let mut req = req?;
- let header = oauth_header(req.method(), req.url(), consumer, access, verifier);
- req.headers_mut()
- .append("Authorization", HeaderValue::from_str(&header).unwrap());
- client.execute(req).await
- }
-}
diff --git a/taler-magnet-bank/src/magnet_api.rs b/taler-magnet-bank/src/magnet_api.rs
@@ -0,0 +1,20 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+pub mod api;
+pub mod client;
+pub mod oauth;
+pub mod types;
diff --git a/taler-magnet-bank/src/magnet_api/api.rs b/taler-magnet-bank/src/magnet_api/api.rs
@@ -0,0 +1,245 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+use std::borrow::Cow;
+
+use reqwest::{Client, Method, RequestBuilder, Response, StatusCode, Url, header};
+use serde::{Deserialize, Serialize, de::DeserializeOwned};
+use thiserror::Error;
+use tracing::{Level, error};
+
+use crate::magnet_api::oauth::{Token, oauth};
+
+#[derive(Deserialize, Debug)]
+struct Header {
+ #[serde(rename = "errorCode")]
+ pub error_code: Option<u16>,
+}
+
+#[derive(Deserialize, Debug)]
+struct Empty {}
+
+#[derive(Deserialize, Error, Debug)]
+#[error("{error_code} {short_message} '{long_message}'")]
+pub struct MagnetError {
+ #[serde(rename = "errorCode")]
+ pub error_code: u16,
+ #[serde(rename = "shortMessage")]
+ pub short_message: String,
+ #[serde(rename = "longMessage")]
+ pub long_message: String,
+}
+
+#[derive(Error, Debug)]
+#[error("{method} {path} {kind}")]
+pub struct ApiErr {
+ pub path: Cow<'static, str>,
+ pub method: Method,
+ pub kind: ErrKind,
+}
+
+#[derive(Error, Debug)]
+pub enum ErrKind {
+ #[error("transport: {0}")]
+ Transport(FmtSource<reqwest::Error>),
+ #[error("magnet {0}")]
+ Magnet(#[from] MagnetError),
+ #[error("JSON body: {0}")]
+ Json(#[from] serde_path_to_error::Error<serde_json::Error>),
+ #[error("form body: {0}")]
+ Form(#[from] serde_urlencoded::de::Error),
+ #[error("status {0}")]
+ Status(StatusCode),
+ #[error("status {0} '{1}'")]
+ StatusCause(StatusCode, String),
+}
+
+#[derive(Debug)]
+pub struct FmtSource<E: std::error::Error>(E);
+
+fn fmt_with_source(
+ f: &mut std::fmt::Formatter<'_>,
+ mut e: &dyn std::error::Error,
+) -> std::fmt::Result {
+ loop {
+ write!(f, "{}", &e)?;
+ if let Some(source) = e.source() {
+ write!(f, ": ")?;
+ e = source;
+ } else {
+ return Ok(());
+ }
+ }
+}
+
+impl<E: std::error::Error> std::fmt::Display for FmtSource<E> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ fmt_with_source(f, &self.0)
+ }
+}
+
+impl<E: std::error::Error> From<E> for FmtSource<E> {
+ fn from(value: E) -> Self {
+ Self(value)
+ }
+}
+
+impl From<reqwest::Error> for ErrKind {
+ fn from(value: reqwest::Error) -> Self {
+ Self::Transport(FmtSource(value))
+ }
+}
+
+pub type ApiResult<R> = std::result::Result<R, ApiErr>;
+
+/** Handle error from magnet API calls */
+async fn error_handling(res: reqwest::Result<Response>) -> Result<String, ErrKind> {
+ let res = res?;
+ let status = res.status();
+ match status {
+ StatusCode::OK => Ok(res.text().await?),
+ StatusCode::BAD_REQUEST => Err(ErrKind::Status(status)),
+ StatusCode::FORBIDDEN => {
+ let cause = res
+ .headers()
+ .get(header::WWW_AUTHENTICATE)
+ .map(|s| s.to_str().unwrap_or_default())
+ .unwrap_or_default();
+ Err(ErrKind::StatusCause(status, cause.to_string()))
+ }
+ _ => {
+ if tracing::enabled!(Level::DEBUG) {
+ tracing::debug!("unexpected error: {:?}", &res);
+ let body = res.text().await;
+ tracing::debug!("unexpected error body: {:?}", body);
+ }
+ Err(ErrKind::Status(status))
+ }
+ }
+}
+
+/** Parse JSON and track error path */
+fn parse<'de, T: Deserialize<'de>>(str: &'de str) -> Result<T, ErrKind> {
+ let deserializer = &mut serde_json::Deserializer::from_str(str);
+ serde_path_to_error::deserialize(deserializer).map_err(ErrKind::Json)
+}
+
+/** Parse magnet JSON response */
+async fn magnet_json<T: DeserializeOwned>(res: reqwest::Result<Response>) -> Result<T, ErrKind> {
+ let body = error_handling(res).await?;
+ let header: Header = parse(&body)?;
+ if header.error_code.unwrap_or(200) == 200 {
+ parse(&body)
+ } else {
+ Err(ErrKind::Magnet(parse(&body)?))
+ }
+}
+
+/** Parse magnet URL encoded response into our own type */
+async fn magnet_url<T: DeserializeOwned>(
+ response: reqwest::Result<Response>,
+) -> Result<T, ErrKind> {
+ let body = error_handling(response).await?;
+ serde_urlencoded::from_str(&body).map_err(ErrKind::Form)
+}
+
+pub struct MagnetRequest<'a> {
+ path: Cow<'static, str>,
+ method: Method,
+ builder: RequestBuilder,
+ consumer: &'a Token,
+ access: Option<&'a Token>,
+ verifier: Option<&'a str>,
+}
+
+impl<'a> MagnetRequest<'a> {
+ pub fn new(
+ client: &Client,
+ method: Method,
+ base_url: &Url,
+ path: impl Into<Cow<'static, str>>,
+ consumer: &'a Token,
+ access: Option<&'a Token>,
+ verifier: Option<&'a str>,
+ ) -> Self {
+ let path = path.into();
+ let url = base_url.join(&path).unwrap();
+ let builder = client.request(method.clone(), url);
+ Self {
+ path,
+ method,
+ builder,
+ consumer,
+ access,
+ verifier,
+ }
+ }
+
+ pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> Self {
+ self.builder = self.builder.query(query);
+ self
+ }
+
+ pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> Self {
+ self.builder = self.builder.json(json);
+ self
+ }
+
+ pub async fn parse_url<T: DeserializeOwned>(self) -> ApiResult<T> {
+ let Self {
+ path,
+ builder,
+ method,
+ consumer,
+ access,
+ verifier,
+ } = self;
+ let (client, req) = builder.build_split();
+ async {
+ let mut req = req?;
+ oauth(&mut req, consumer, access, verifier);
+ let res = client.execute(req).await;
+ magnet_url(res).await
+ }
+ .await
+ .map_err(|kind| ApiErr { path, method, kind })
+ }
+
+ pub async fn parse_json<T: DeserializeOwned>(self) -> ApiResult<T> {
+ let Self {
+ path,
+ builder,
+ method,
+ consumer,
+ access,
+ verifier,
+ } = self;
+ let (client, req) = builder.build_split();
+ async {
+ let mut req = req?;
+ oauth(&mut req, consumer, access, verifier);
+ let res = client.execute(req).await;
+ magnet_json(res).await
+ }
+ .await
+ .map_err(|kind| ApiErr { path, method, kind })
+ }
+
+ pub async fn parse_empty(self) -> ApiResult<()> {
+ self.parse_json::<Empty>().await?;
+ Ok(())
+ }
+}
diff --git a/taler-magnet-bank/src/magnet_api/client.rs b/taler-magnet-bank/src/magnet_api/client.rs
@@ -0,0 +1,324 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+use std::borrow::Cow;
+
+use base64::{Engine as _, prelude::BASE64_STANDARD};
+use p256::{
+ PublicKey,
+ ecdsa::{DerSignature, SigningKey, signature::Signer as _},
+ pkcs8::EncodePublicKey,
+};
+use reqwest::Method;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+use crate::magnet_api::{
+ api::{ApiResult, MagnetRequest},
+ oauth::{Token, TokenAuth},
+ types::{
+ Account, BalanceMini, Direction, Next, PartnerList, SmsCodeSubmission, TokenInfo,
+ TransactionPage, Tx, TxStatus,
+ },
+};
+
+#[derive(Debug, Deserialize)]
+struct TxWrapper {
+ #[serde(rename = "tranzakcio")]
+ tx: Tx,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AccountWrapper {
+ #[serde(rename = "bankszamla")]
+ account: Account,
+}
+
+pub struct AuthClient<'a> {
+ client: &'a reqwest::Client,
+ pub api_url: &'a reqwest::Url,
+ consumer: &'a Token,
+}
+
+impl<'a> AuthClient<'a> {
+ pub fn new(
+ client: &'a reqwest::Client,
+ api_url: &'a reqwest::Url,
+ consumer: &'a Token,
+ ) -> Self {
+ Self {
+ client,
+ api_url,
+ consumer,
+ }
+ }
+
+ fn request(
+ &self,
+ method: Method,
+ path: impl Into<Cow<'static, str>>,
+ access: Option<&'a Token>,
+ verifier: Option<&'a str>,
+ ) -> MagnetRequest<'_> {
+ MagnetRequest::new(
+ self.client,
+ method,
+ self.api_url,
+ path,
+ self.consumer,
+ access,
+ verifier,
+ )
+ }
+
+ pub async fn token_request(&self) -> ApiResult<Token> {
+ self.request(Method::GET, "/NetBankOAuth/token/request", None, None)
+ .query(&[("oauth_callback", "oob")])
+ .parse_url()
+ .await
+ }
+
+ pub async fn token_access(
+ &self,
+ token_request: &Token,
+ token_auth: &TokenAuth,
+ ) -> ApiResult<Token> {
+ self.request(
+ Method::GET,
+ "/NetBankOAuth/token/access",
+ Some(token_request),
+ Some(&token_auth.oauth_verifier),
+ )
+ .parse_url()
+ .await
+ }
+
+ pub fn upgrade(self, access: &'a Token) -> ApiClient<'a> {
+ ApiClient {
+ client: self.client,
+ api_url: self.api_url,
+ consumer: self.consumer,
+ access,
+ }
+ }
+}
+
+pub struct ApiClient<'a> {
+ client: &'a reqwest::Client,
+ api_url: &'a reqwest::Url,
+ consumer: &'a Token,
+ access: &'a Token,
+}
+
+impl ApiClient<'_> {
+ fn request(&self, method: Method, path: impl Into<Cow<'static, str>>) -> MagnetRequest<'_> {
+ MagnetRequest::new(
+ self.client,
+ method,
+ self.api_url,
+ path,
+ self.consumer,
+ Some(self.access),
+ None,
+ )
+ }
+
+ pub async fn token_info(&self) -> ApiResult<TokenInfo> {
+ self.request(Method::GET, "/RESTApi/resources/v2/token")
+ .parse_json()
+ .await
+ }
+
+ pub async fn request_sms_code(&self) -> ApiResult<SmsCodeSubmission> {
+ self.request(Method::GET, "/RESTApi/resources/v2/kodszo/sms/token")
+ .parse_json()
+ .await
+ }
+
+ pub async fn perform_sca(&self, code: &str) -> ApiResult<()> {
+ self.request(Method::PUT, "/RESTApi/resources/v2/token/SCA")
+ .json(&json!({
+ "kodszo": code
+ }))
+ .parse_empty()
+ .await
+ }
+
+ pub async fn upload_public_key(&self, key: &SigningKey) -> ApiResult<Value> {
+ let public_key = PublicKey::from_secret_scalar(key.as_nonzero_scalar());
+ let der = public_key.to_public_key_der().unwrap().to_vec();
+ self.request(Method::POST, "/RESTApi/resources/v2/token/public-key")
+ .json(&json!({
+ "keyData": BASE64_STANDARD.encode(der)
+ }))
+ .parse_json()
+ .await
+ }
+
+ pub async fn list_accounts(&self) -> ApiResult<PartnerList> {
+ self.request(Method::GET, "/RESTApi/resources/v2/partnerszamla/0")
+ .parse_json()
+ .await
+ }
+
+ pub async fn account(&self, bban: &str) -> ApiResult<Account> {
+ Ok(self
+ .request(
+ Method::GET,
+ format!("/RESTApi/resources/v2/bankszamla/{bban}"),
+ )
+ .parse_json::<AccountWrapper>()
+ .await?
+ .account)
+ }
+
+ pub async fn balance_mini(&self, bban: &str) -> ApiResult<BalanceMini> {
+ self.request(
+ Method::GET,
+ format!("/RESTApi/resources/v2/egyenleg/{bban}/szukitett"),
+ )
+ .parse_json()
+ .await
+ }
+
+ pub async fn get_tx(&self, code: u64) -> ApiResult<Tx> {
+ Ok(self
+ .request(
+ Method::GET,
+ format!("/RESTApi/resources/v2/tranzakcio/{code}"),
+ )
+ .parse_json::<TxWrapper>()
+ .await?
+ .tx)
+ }
+
+ pub async fn page_tx(
+ &self,
+ direction: Direction,
+ limit: u16,
+ bban: &str,
+ next: &Option<Next>,
+ status: &Option<TxStatus>,
+ ) -> ApiResult<TransactionPage> {
+ let mut req = self.request(
+ Method::GET,
+ format!("/RESTApi/resources/v2/tranzakcio/paginator/{bban}/{limit}"),
+ );
+ if let Some(next) = next {
+ req = req
+ .query(&[("nextId", next.next_id)])
+ .query(&[("nextTipus", &next.next_type)]);
+ }
+ if let Some(status) = status {
+ req = req.query(&[("statusz", status)]);
+ }
+ req.query(&[("terheles", direction)])
+ .query(&[("tranzakciofrissites", true), ("ascending", true)])
+ .parse_json()
+ .await
+ }
+
+ pub async fn init_tx(
+ &self,
+ account_code: u64,
+ amount: f64,
+ subject: &str,
+ date: &jiff::civil::Date,
+ creditor_name: &str,
+ creditor_bban: &str,
+ ) -> ApiResult<Tx> {
+ #[derive(Serialize)]
+ struct Req<'a> {
+ #[serde(rename = "bankszamlaKod")]
+ account_code: u64,
+ #[serde(rename = "osszeg")]
+ amount: f64,
+ #[serde(rename = "kozlemeny")]
+ subject: &'a str,
+ #[serde(rename = "ertekNap")]
+ date: &'a jiff::civil::Date,
+ #[serde(rename = "ellenpartner")]
+ creditor_name: &'a str,
+ #[serde(rename = "ellenszamla")]
+ creditor_account: &'a str,
+ }
+
+ Ok(self
+ .request(Method::POST, "/RESTApi/resources/v2/esetiatutalas")
+ .json(&Req {
+ account_code,
+ amount,
+ subject,
+ date,
+ creditor_name,
+ creditor_account: creditor_bban,
+ })
+ .parse_json::<TxWrapper>()
+ .await?
+ .tx)
+ }
+
+ pub async fn submit_tx(
+ &self,
+ signing_key: &SigningKey,
+ bban: &str,
+ tx_code: u64,
+ amount: f64,
+ date: &jiff::civil::Date,
+ creditor_bban: &str,
+ ) -> ApiResult<Tx> {
+ #[derive(Serialize)]
+ struct Req<'a> {
+ #[serde(rename = "tranzakcioKod")]
+ tx_code: u64,
+ #[serde(rename = "forrasszamla")]
+ debtor: &'a str,
+ #[serde(rename = "ellenszamla")]
+ creditor: &'a str,
+ #[serde(rename = "osszeg")]
+ amount: f64,
+ #[serde(rename = "ertekNap")]
+ date: &'a jiff::civil::Date,
+ signature: &'a str,
+ }
+
+ let content: String = format!("{tx_code};{bban};{creditor_bban};{amount};{date};");
+ let signature: DerSignature = signing_key.sign(content.as_bytes());
+ let encoded = BASE64_STANDARD.encode(signature.as_bytes());
+ Ok(self
+ .request(Method::PUT, "/RESTApi/resources/v2/tranzakcio/alairas")
+ .json(&Req {
+ tx_code,
+ debtor: bban,
+ creditor: creditor_bban,
+ amount,
+ date,
+ signature: &encoded,
+ })
+ .parse_json::<TxWrapper>()
+ .await?
+ .tx)
+ }
+
+ pub async fn delete_tx(&self, tx_code: u64) -> ApiResult<()> {
+ self.request(
+ Method::DELETE,
+ format!("/RESTApi/resources/v2/tranzakcio/{tx_code}"),
+ )
+ .parse_empty()
+ .await
+ }
+}
diff --git a/taler-magnet-bank/src/magnet_api/oauth.rs b/taler-magnet-bank/src/magnet_api/oauth.rs
@@ -0,0 +1,159 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+use std::{borrow::Cow, time::SystemTime};
+
+use base64::{Engine as _, prelude::BASE64_STANDARD};
+use hmac::{Hmac, Mac};
+use percent_encoding::NON_ALPHANUMERIC;
+use rand_core::RngCore;
+use reqwest::{Request, header::HeaderValue};
+use serde::{Deserialize, Serialize};
+use sha1::Sha1;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Token {
+ #[serde(rename = "oauth_token")]
+ pub key: String,
+ #[serde(rename = "oauth_token_secret")]
+ pub secret: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct TokenAuth {
+ pub oauth_token: String,
+ pub oauth_verifier: String,
+}
+
+/** Generate a secure OAuth nonce */
+fn oauth_nonce() -> String {
+ // Generate 8 secure random bytes
+ let mut buf = [0u8; 8];
+ rand_core::OsRng.fill_bytes(&mut buf);
+ // Encode as base64 string
+ BASE64_STANDARD.encode(buf)
+}
+
+/** Generate an OAuth timestamp */
+fn oauth_timestamp() -> u64 {
+ let start = SystemTime::now();
+ let since_the_epoch = start
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("Time went backwards");
+
+ since_the_epoch.as_secs()
+}
+
+/** Generate a valid OAuth Authorization header */
+fn oauth_header(
+ method: &reqwest::Method,
+ url: &reqwest::Url,
+ consumer: &Token,
+ access: Option<&Token>,
+ verifier: Option<&str>,
+) -> String {
+ // Per request value
+ let oauth_nonce = oauth_nonce();
+ let oauth_timestamp = oauth_timestamp().to_string();
+
+ // Base string
+ let base_string = {
+ let oauth_data = {
+ let mut oauth_query: Vec<(&str, &str)> = vec![
+ ("oauth_consumer_key", &consumer.key),
+ ("oauth_nonce", &oauth_nonce),
+ ("oauth_signature_method", "HMAC-SHA1"),
+ ("oauth_timestamp", &oauth_timestamp),
+ ];
+ if let Some(token) = &access {
+ oauth_query.push(("oauth_token", &token.key));
+ }
+ if let Some(verifier) = &verifier {
+ oauth_query.push(("oauth_verifier", verifier));
+ }
+ oauth_query.push(("oauth_version", "1.0"));
+ let mut all_query: Vec<_> = oauth_query
+ .into_iter()
+ .map(|(a, b)| (Cow::Borrowed(a), Cow::Borrowed(b)))
+ .chain(url.query_pairs())
+ .collect();
+ all_query.sort_unstable();
+
+ let mut tmp: form_urlencoded::Serializer<'_, String> =
+ form_urlencoded::Serializer::new(String::new());
+ for (k, v) in all_query {
+ tmp.append_pair(&k, &v);
+ }
+ tmp.finish()
+ };
+ let mut stripped = url.clone();
+ stripped.set_query(None);
+ form_urlencoded::Serializer::new(String::new())
+ .append_key_only(method.as_str())
+ .append_key_only(stripped.as_str())
+ .append_key_only(&oauth_data)
+ .finish()
+ };
+
+ // Signature
+ let key = {
+ let mut buf = consumer.secret.clone();
+ buf.push('&');
+ if let Some(token) = access {
+ buf.push_str(&token.secret);
+ }
+ buf
+ };
+ let signature = Hmac::<Sha1>::new_from_slice(key.as_bytes())
+ .expect("HMAC can take key of any size")
+ .chain_update(base_string.as_bytes())
+ .finalize()
+ .into_bytes();
+ let signature_encoded = BASE64_STANDARD.encode(signature);
+
+ // Authorization header
+ {
+ let mut buf = "OAuth ".to_string();
+ let mut append = |key: &str, value: &str| {
+ buf.push_str(key);
+ buf.push_str("=\"");
+ for part in percent_encoding::percent_encode(value.as_bytes(), NON_ALPHANUMERIC) {
+ buf.push_str(part);
+ }
+ buf.push_str("\",");
+ };
+ append("oauth_consumer_key", &consumer.key);
+ append("oauth_nonce", &oauth_nonce);
+ append("oauth_signature_method", "HMAC-SHA1");
+ append("oauth_timestamp", &oauth_timestamp);
+ if let Some(token) = &access {
+ append("oauth_token", &token.key);
+ }
+ if let Some(verifier) = &verifier {
+ append("oauth_verifier", verifier);
+ }
+ append("oauth_version", "1.0");
+ append("oauth_signature", &signature_encoded);
+ buf
+ }
+}
+
+/** Perform OAuth on an HTTP request */
+pub fn oauth(req: &mut Request, consumer: &Token, access: Option<&Token>, verifier: Option<&str>) {
+ let header = oauth_header(req.method(), req.url(), consumer, access, verifier);
+ req.headers_mut()
+ .append("Authorization", HeaderValue::from_str(&header).unwrap());
+}
diff --git a/taler-magnet-bank/src/magnet_api/types.rs b/taler-magnet-bank/src/magnet_api/types.rs
@@ -0,0 +1,265 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+use jiff::Timestamp;
+use serde::{Deserialize, Serialize};
+use taler_common::types::amount;
+
+use crate::HuIban;
+
+#[derive(Debug, Deserialize)]
+pub struct Consumer {
+ #[serde(rename = "consumerKey")]
+ pub key: String,
+ #[serde(rename = "megnevezes")]
+ pub name: String,
+ #[serde(rename = "callbackUri")]
+ pub callback_uri: String,
+ #[serde(rename = "elettartam")]
+ pub lifetime: u64,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct TokenInfo {
+ #[serde(rename = "keszult")]
+ pub created: jiff::Timestamp,
+ #[serde(rename = "lejarat")]
+ pub expiration: jiff::Timestamp,
+ #[serde(rename = "kliensinfo")]
+ pub client_info: Option<String>,
+ pub consumer: Consumer,
+ #[serde(rename = "hitelesitett")]
+ pub authenticated: bool,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct SmsCodeSubmission {
+ #[serde(rename = "csatorna")]
+ pub channel: String,
+ #[serde(rename = "hovaMentKi")]
+ pub sent_to: Vec<String>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ScaResult {
+ #[serde(rename = "csatorna")]
+ pub channel: String,
+ #[serde(rename = "hovaMentKi")]
+ pub sent_to: Vec<String>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Partner {
+ #[serde(rename = "megnevezes")]
+ pub name: String,
+ #[serde(rename = "kod")]
+ pub code: u64,
+ #[serde(rename = "adoszam")]
+ pub tax_number: Option<String>,
+ #[serde(rename = "ebUfallapot")]
+ pub status: String, // TODO enum
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AccountType {
+ #[serde(rename = "kod")]
+ pub code: u64,
+ #[serde(rename = "megnevezes")]
+ pub name: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Currency {
+ #[serde(rename = "jel")]
+ pub symbol: amount::Currency,
+ #[serde(rename = "megnevezes")]
+ pub name: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Account {
+ #[serde(rename = "alapertelmezett")]
+ pub default: bool,
+ #[serde(rename = "bankszamlaTipus")]
+ pub ty: AccountType,
+ #[serde(rename = "deviza")]
+ pub currency: Currency,
+ #[serde(rename = "ibanSzamlaszam")]
+ pub iban: HuIban,
+ #[serde(rename = "kod")]
+ pub code: u64,
+ #[serde(rename = "szamlaszam")]
+ pub number: String,
+ #[serde(rename = "tulajdonosKod")]
+ pub owner_code: u64,
+ #[serde(rename = "lakossagi")]
+ pub resident: bool,
+ #[serde(rename = "megnevezes")]
+ pub name: Option<String>,
+ pub partner: Partner,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct BalanceMini {
+ #[serde(rename = "pozicio")]
+ pub balance: f64,
+ #[serde(rename = "frissites")]
+ pub last_update_time: jiff::Timestamp,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct PartnerAccounts {
+ pub partner: Partner,
+ #[serde(rename = "bankszamlaList")]
+ pub bank_accounts: Vec<Account>,
+ #[serde(rename = "kertJogosultsag")]
+ pub requested_permission: u64,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct PartnerList {
+ #[serde(rename = "partnerSzamlaList")]
+ pub partners: Vec<PartnerAccounts>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum TxStatus {
+ #[serde(rename = "G")]
+ ToBeRecorded,
+ #[serde(rename = "1")]
+ PendingFirstSignature,
+ #[serde(rename = "2")]
+ PendingSecondSignature,
+ #[serde(rename = "F")]
+ PendingProcessing,
+ #[serde(rename = "L")]
+ Verified,
+ #[serde(rename = "R")]
+ PartiallyCompleted,
+ #[serde(rename = "T")]
+ Completed,
+ #[serde(rename = "E")]
+ Rejected,
+ #[serde(rename = "M")]
+ Canceled,
+ #[serde(rename = "P")]
+ UnderReview,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Direction {
+ #[serde(rename = "T")]
+ Outgoing,
+ #[serde(rename = "J")]
+ Incoming,
+ #[serde(rename = "M")]
+ Both,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Tx {
+ #[serde(rename = "alairas1idopont")]
+ pub first_signature: Option<Timestamp>,
+ #[serde(rename = "alairas2idopont")]
+ pub second_signature: Option<Timestamp>,
+ #[serde(rename = "alairo1")]
+ pub first_signatory: Option<Partner>,
+ #[serde(rename = "alairo2")]
+ pub second_signatory: Option<Partner>,
+ #[serde(rename = "reszteljesites")]
+ pub partial_execution: bool,
+ #[serde(rename = "sorbaallitas")]
+ pub queued: bool,
+ #[serde(rename = "kod")]
+ pub code: u64,
+ #[serde(rename = "bankszamla")]
+ pub bank_account: Account,
+ #[serde(rename = "deviza")]
+ pub currency: Currency,
+ #[serde(rename = "osszegSigned")]
+ pub amount: f64,
+ #[serde(rename = "kozlemeny")]
+ pub subject: String,
+ #[serde(rename = "statusz")]
+ pub status: TxStatus,
+ #[serde(rename = "tranzakcioAltipus")]
+ pub kind: Option<String>,
+ #[serde(rename = "eredetiErteknap")]
+ pub tx_date: jiff::civil::Date,
+ #[serde(rename = "erteknap")]
+ pub value_date: jiff::civil::Date,
+ #[serde(rename = "eszamla")]
+ pub counter_account: String,
+ #[serde(rename = "epartner")]
+ pub counter_name: String,
+ #[serde(rename = "tranzakcioTipus")]
+ pub ty: Option<String>,
+ pub eam: Option<u64>,
+ pub partner: Partner,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Transaction {
+ #[serde(rename = "kod")]
+ pub code: u64,
+ #[serde(rename = "bankszamla")]
+ pub bank_account: String,
+ #[serde(rename = "bankszamlaTulajdonos")]
+ pub bank_account_owner: String,
+ #[serde(rename = "deviza")]
+ pub currency: amount::Currency,
+ #[serde(rename = "osszeg")]
+ pub amount: f64,
+ #[serde(rename = "kozlemeny")]
+ pub subject: String,
+ #[serde(rename = "statusz")]
+ pub status: TxStatus,
+ #[serde(rename = "tranzakcioAltipus")]
+ pub kind: Option<String>,
+ #[serde(rename = "eredetiErteknap")]
+ pub tx_date: jiff::civil::Date,
+ #[serde(rename = "erteknap")]
+ pub value_date: jiff::civil::Date,
+ #[serde(rename = "eszamla")]
+ pub counter_account: String,
+ #[serde(rename = "epartner")]
+ pub counter_name: String,
+ #[serde(rename = "tranzakcioTipus")]
+ pub ty: Option<String>,
+ pub eam: Option<u64>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Next {
+ #[serde(rename = "next")]
+ pub next_id: u64,
+ #[serde(rename = "nextTipus")]
+ pub next_type: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct TransactionPage {
+ #[serde(flatten)]
+ pub next: Option<Next>,
+ #[serde(rename = "tranzakcioList", default)]
+ pub list: Vec<TransactionWrapper>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct TransactionWrapper {
+ #[serde(rename = "tranzakcioDto")]
+ pub tx: Transaction,
+}
diff --git a/taler-magnet-bank/src/main.rs b/taler-magnet-bank/src/main.rs
@@ -30,7 +30,7 @@ use taler_magnet_bank::{
api::MagnetApi,
config::{ServeCfg, WorkerCfg, parse_db_cfg},
dev::{self, DevCmd},
- magnet::AuthClient,
+ magnet_api::client::AuthClient,
setup,
worker::Worker,
};
diff --git a/taler-magnet-bank/src/setup.rs b/taler-magnet-bank/src/setup.rs
@@ -20,12 +20,10 @@ use p256::ecdsa::SigningKey;
use taler_common::{json_file, types::base32::Base32};
use tracing::{info, warn};
+use crate::magnet_api::{api::ErrKind, client::AuthClient, oauth::Token};
use crate::{
config::WorkerCfg,
- magnet::{
- AuthClient, Token, TokenAuth,
- error::{ApiError, MagnetError},
- },
+ magnet_api::{api::MagnetError, oauth::TokenAuth},
};
#[derive(Default, Debug, serde::Deserialize, serde::Serialize)]
@@ -94,7 +92,10 @@ pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> {
// TODO Ask MagnetBank if they could support out-of-band configuration
println!(
"Login at {}?oauth_token={}",
- client.join("/NetBankOAuth/authtoken.xhtml"),
+ client
+ .api_url
+ .join("/NetBankOAuth/authtoken.xhtml")
+ .unwrap(),
token_request.key
);
let auth_url = passterm::prompt_password_tty(Some("Enter the result URL>"))?;
@@ -121,7 +122,7 @@ pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> {
let sca_code = passterm::prompt_password_tty(Some("Enter the code>"))?;
if let Err(e) = client.perform_sca(&sca_code).await {
// Ignore error if SCA already performed
- if !matches!(e, ApiError::Magnet(MagnetError { ref short_message, .. }) if short_message == "TOKEN_SCA_HITELESITETT")
+ if !matches!(e.kind, ErrKind::Magnet(MagnetError { ref short_message, .. }) if short_message == "TOKEN_SCA_HITELESITETT")
{
return Err(e.into());
}
@@ -142,7 +143,7 @@ pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> {
};
if let Err(e) = client.upload_public_key(&signing_key).await {
// Ignore error if public key already uploaded
- if !matches!(e, ApiError::Magnet(MagnetError { ref short_message, .. }) if short_message== "KULCS_MAR_HASZNALATBAN")
+ if !matches!(e.kind, ErrKind::Magnet(MagnetError { ref short_message, .. }) if short_message== "KULCS_MAR_HASZNALATBAN")
{
return Err(e.into());
}
diff --git a/taler-magnet-bank/src/worker.rs b/taler-magnet-bank/src/worker.rs
@@ -32,9 +32,10 @@ use crate::{
config::AccountType,
db::{self, AddIncomingResult, Initiated, TxIn, TxOut, TxOutKind},
failure_injection::{InjectedErr, fail_point},
- magnet::{
- ApiClient, Direction, Transaction, TxStatus,
- error::{ApiError, MagnetError},
+ magnet_api::{
+ api::{ApiErr, ErrKind},
+ client::ApiClient,
+ types::{Direction, Transaction, TxStatus},
},
};
@@ -43,7 +44,7 @@ pub enum WorkerError {
#[error(transparent)]
Db(#[from] sqlx::Error),
#[error(transparent)]
- Api(#[from] ApiError),
+ Api(#[from] ApiErr),
#[error(transparent)]
Injected(#[from] InjectedErr),
}
@@ -307,33 +308,31 @@ impl Worker<'_> {
.await?;
info
}
- // Check if error is permanent
- Err(ApiError::Magnet(MagnetError {
- error_code,
- short_message,
- long_message,
- })) if matches!(
- (error_code, short_message.as_str()),
- (404, "BSZLA_NEM_TALALHATO") // Unknown account
- | (409, "FORRAS_SZAMLA_ESZAMLA_EGYEZIK") // Same account
- ) =>
- {
- let e = MagnetError {
- error_code,
- short_message,
- long_message,
- };
- db::initiated_submit_permanent_failure(
- &mut *self.db,
- tx.id,
- &Timestamp::now(),
- &e.to_string(),
- )
- .await?;
- error!(target: "worker", "initiated tx {tx} failed: {e}");
- return WorkerResult::Ok(());
+ Err(e) => {
+ if let ApiErr {
+ kind: ErrKind::Magnet(e),
+ ..
+ } = &e
+ {
+ // Check if error is permanent
+ if matches!(
+ (e.error_code, e.short_message.as_str()),
+ (404, "BSZLA_NEM_TALALHATO") // Unknown account
+ | (409, "FORRAS_SZAMLA_ESZAMLA_EGYEZIK") // Same account
+ ) {
+ db::initiated_submit_permanent_failure(
+ &mut *self.db,
+ tx.id,
+ &Timestamp::now(),
+ &e.to_string(),
+ )
+ .await?;
+ error!(target: "worker", "initiated tx {tx} failed: {e}");
+ return WorkerResult::Ok(());
+ }
+ }
+ return Err(e.into());
}
- Err(e) => return WorkerResult::Err(WorkerError::Api(e)),
};
trace!(target: "worker", "init tx {}", info.code);
@@ -353,8 +352,9 @@ impl Worker<'_> {
) -> WorkerResult {
debug!(target: "worker", "submit tx {tx_code}");
fail_point("submit-tx")?;
- // Sign initiated transaction, on failure we will retry
- self.client
+ // Submit an initiated transaction, on failure we will retry
+ match self
+ .client
.submit_tx(
self.key,
self.account_number,
@@ -363,8 +363,27 @@ impl Worker<'_> {
date,
creditor,
)
- .await?;
- Ok(())
+ .await
+ {
+ Ok(_) => Ok(()),
+ Err(e) => {
+ if let ApiErr {
+ kind: ErrKind::Magnet(e),
+ ..
+ } = &e
+ {
+ // Check if soft failure
+ if matches!(
+ (e.error_code, e.short_message.as_str()),
+ (409, "TRANZAKCIO_ROSSZ_STATUS") // Already summited or cannot be signed
+ ) {
+ warn!(target: "worker", "submit tx {tx_code}: {e}");
+ return Ok(());
+ }
+ }
+ Err(e.into())
+ }
+ }
}
}