use std::{str::FromStr, sync::LazyLock};
use actix_web::{error, post, App, Error, HttpResponse, HttpServer};
use anyhow::anyhow;
use chrono::{serde::ts_seconds, DateTime, Utc};
use jsonwebtoken::{decode_header, jwk::JwkSet, Algorithm, DecodingKey, TokenData, Validation};
use serde::{Deserialize, Serialize};
use url::Url;
static JWT_VERIFIER: LazyLock<JwtVerifier> = LazyLock::new(|| JwtVerifier::new().unwrap());
#[post("/")]
async fn index(token: String) -> Result<HttpResponse, Error> {
let token_data = JWT_VERIFIER
.validate_via_kid(&token)
.await
.map_err(error::ErrorBadRequest)?;
// Do something with the payload
let notification = token_data.claims.payload;
println!("Got Kraken Pay notification: {notification:#?}");
Ok(HttpResponse::Ok().json(CallbackResponse {
code: PayResponseCode::Success,
}))
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum PayResponseCode {
Success,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct CallbackResponse {
pub code: PayResponseCode,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(index))
.bind(("127.0.0.1", 8080))?
.run()
.await
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct Claims {
/// Issuer.
iss: String,
/// Audience.
aud: Option<String>,
/// Time beyond which the JWT is no longer valid
#[serde(with = "ts_seconds")]
exp: DateTime<Utc>,
/// Notification payload
payload: Notification,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
struct Notification {
external_id: String,
status: PayStatus,
customer_kraktag: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum PayStatus {
Success,
Failed,
Cancelled,
Expired,
Declined,
}
struct JwtVerifier {
validation: Validation,
jwks: JwkSet,
jwks_url: Url,
http: reqwest::Client,
}
impl JwtVerifier {
fn new() -> anyhow::Result<Self> {
let mut validation = Validation::new(Algorithm::ES256);
validation.set_issuer(&["kraken-pay"]);
let jwks = jwk_set();
let http = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0")
.timeout(std::time::Duration::from_secs(10))
.build()?;
let jwks_url = Url::from_str("https://www.kraken.com/.well-known/pay-callback-keys.json")?;
Ok(Self {
validation,
jwks,
jwks_url,
http,
})
}
/// Validate JWT via `kid`. If the key is present in the local JWKS then read from it,
/// otherwise fetch the remote JWKS.
async fn validate_via_kid(&self, token: &str) -> anyhow::Result<TokenData<Claims>> {
let header = decode_header(token)?;
let kid = header.kid.as_deref().ok_or(anyhow!("JWT has no kid"))?;
let decoding_key = {
if let Some(jwk) = self.jwks.find(kid) {
DecodingKey::from_jwk(jwk)?
} else {
let jwks: JwkSet = self
.http
.get(self.jwks_url.clone())
.send()
.await?
.json()
.await?;
let jwk = jwks.find(kid).ok_or(anyhow!("Cannot find key in jwks"))?;
DecodingKey::from_jwk(jwk)?
}
};
Ok(jsonwebtoken::decode(
token,
&decoding_key,
&self.validation,
)?)
}
}
fn jwk_set() -> JwkSet {
let jwk_text = r#"{
"keys": [
{
"alg": "ES256",
"kty": "EC",
"kid": "test-pay-callback-1",
"crv": "P-256",
"x": "bZTKem2HYASLhJ92_7ZiEZyejuvmX_TYSqbij3u1Y-8",
"y": "pW_maiG81zNwzSxaOjo4RDqOeDMPnAxNYiFe9FzJG04"
}
]
}"#;
serde_json::from_str(jwk_text).unwrap()
}