l402_middleware
A middleware library for rust that uses L402, formerly known as LSAT (a protocol standard for authentication and paid APIs) and provides handler functions to accept microtransactions before serving ad-free content or any paid APIs. It supports Lightning Network Daemon (LND), Lightning Node Connect (LNC), Core Lightning (CLN), Eclair, Lightning URL (LNURL), Nostr Wallet Connect (NWC), and BOLT12 for generating invoices.
Check out the Go version here:
https://github.com/getAlby/lsat-middleware
The middleware:-
- Checks the preference of the user whether they need paid content or free content.
- Verify the L402 before serving paid content.
- Send macaroon and invoice if the user prefers paid content and fails to present a valid L402.

L402 Header Specifications
| Header |
Description |
Usage |
Example |
| Accept-Authenticate |
Sent by the client to show interest in using L402 for authentication. |
Used when the client wants to explore authentication options under L402. |
Accept-Authenticate: L402 |
| WWW-Authenticate |
Sent by the server to request L402 authentication, providing a macaroon and a payment invoice. |
Used when the client must pay or authenticate to access a resource. |
WWW-Authenticate: L402 macaroon="MDAxM...", invoice="lnbc1..." |
| Authorization |
Sent by the client to provide the macaroon and preimage (proof of payment) to access the resource. |
Used by the client after payment or authentication to prove access rights. |
Authorization: L402 <macaroon>:<preimage> |
Installation
Add the crate to your Cargo.toml:
[dependencies]
l402_middleware = "2.1.0"
By using the no-accept-authenticate-required feature, the check for the Accept-Authenticate header can be bypassed, allowing L402 to be treated as the default authentication option.
[dependencies]
l402_middleware = { version = "2.1.0", features = ["no-accept-authenticate-required"] }
Ensure that you create a .env file based on the provided .env_example and configure all the necessary environment variables.
Example
#[macro_use] extern crate rocket;
use rocket::serde::json::Json;
use rocket::serde::Serialize;
use rocket::http::Status;
use rocket::Request;
use dotenv::dotenv;
use std::env;
use std::sync::Arc;
use reqwest::Client;
use l402_middleware::{l402, lnclient, lnd, lnurl, nwc, cln, bolt12, eclair, middleware};
const SATS_PER_BTC: i64 = 100_000_000;
const MIN_SATS_TO_BE_PAID: i64 = 1;
const MSAT_PER_SAT: i64 = 1000;
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct FiatRateConfig {
pub currency: String,
pub amount: f64,
}
impl FiatRateConfig {
pub async fn fiat_to_btc_amount_func(&self) -> i64 {
if self.amount <= 0.0 {
return MIN_SATS_TO_BE_PAID * MSAT_PER_SAT;
}
let url = format!(
"https://blockchain.info/tobtc?currency={}&value={}",
self.currency, self.amount
);
match Client::new().get(&url).send().await {
Ok(res) => {
let body = res.text().await.unwrap_or_else(|_| MIN_SATS_TO_BE_PAID.to_string());
match body.parse::<f64>() {
Ok(amount_in_btc) => ((SATS_PER_BTC as f64 * amount_in_btc) * MSAT_PER_SAT as f64) as i64,
Err(_) => MIN_SATS_TO_BE_PAID * MSAT_PER_SAT,
}
}
Err(_) => MIN_SATS_TO_BE_PAID * MSAT_PER_SAT,
}
}
}
fn path_caveat(req: &Request<'_>) -> Vec<String> {
vec![
format!("RequestPath = {}", req.uri().path()),
]
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
struct Response {
code: u16,
message: String
}
#[get("/")]
fn free() -> (Status, Json<Response>) {
let response = Response {
code: Status::Ok.code,
message: String::from("Free content"),
};
(Status::Ok, Json(response))
}
#[get("/protected")]
fn protected(l402_info: l402::L402Info) -> (Status, Json<Response>) {
let (status, message) = match l402_info.l402_type.as_str() {
l402::L402_TYPE_FREE => (Status::Ok, String::from("Free content")),
l402::L402_TYPE_PAYMENT_REQUIRED => (Status::PaymentRequired, String::from("Pay the invoice attached in response header")),
l402::L402_TYPE_PAID => (Status::Ok, String::from("Protected content")),
l402::L402_TYPE_ERROR => (
Status::InternalServerError,
l402_info.error.clone().unwrap_or_else(|| String::from("An error occurred")),
),
_ => (Status::InternalServerError, String::from("Unknown type")),
};
let response = Response {
code: status.code,
message,
};
(status, Json(response))
}
#[launch]
pub async fn rocket() -> rocket::Rocket<rocket::Build> {
dotenv().ok();
let ln_client_type = env::var("LN_CLIENT_TYPE").expect("LN_CLIENT_TYPE not found in .env");
let ln_client_config = match ln_client_type.as_str() {
"LNURL" => lnclient::LNClientConfig {
ln_client_type,
lnd_config: None,
lnurl_config: Some(lnurl::LNURLOptions {
address: env::var("LNURL_ADDRESS").expect("LNURL_ADDRESS not found in .env"),
}),
nwc_config: None,
cln_config: None,
bolt12_config: None,
root_key: env::var("ROOT_KEY")
.expect("ROOT_KEY not found in .env")
.as_bytes()
.to_vec(),
},
"LND" => {
let lnc_pairing_phrase = env::var("LNC_PAIRING_PHRASE").ok();
let lnc_mailbox_server = env::var("LNC_MAILBOX_SERVER").ok();
let lnd_options = if lnc_pairing_phrase.is_some() {
lnd::LNDOptions {
address: None,
macaroon_file: None,
cert_file: None,
socks5_proxy: None,
lnc_pairing_phrase,
lnc_mailbox_server,
}
} else {
lnd::LNDOptions {
address: Some(env::var("LND_ADDRESS").expect("LND_ADDRESS not found in .env")),
macaroon_file: Some(env::var("MACAROON_FILE_PATH").expect("MACAROON_FILE_PATH not found in .env")),
cert_file: Some(env::var("CERT_FILE_PATH").expect("CERT_FILE_PATH not found in .env")),
socks5_proxy: env::var("SOCKS5_PROXY").ok(), lnc_pairing_phrase: None,
lnc_mailbox_server: None,
}
};
lnclient::LNClientConfig {
ln_client_type,
lnd_config: Some(lnd_options),
lnurl_config: None,
nwc_config: None,
cln_config: None,
bolt12_config: None,
eclair_config: None,
root_key: env::var("ROOT_KEY")
.expect("ROOT_KEY not found in .env")
.as_bytes()
.to_vec(),
}
},
"NWC" => lnclient::LNClientConfig {
ln_client_type,
lnd_config: None,
lnurl_config: None,
cln_config: None,
bolt12_config: None,
eclair_config: None,
nwc_config: Some(nwc::NWCOptions {
uri: env::var("NWC_URI").expect("NWC_URI not found in .env"),
}),
root_key: env::var("ROOT_KEY")
.expect("ROOT_KEY not found in .env")
.as_bytes()
.to_vec(),
},
"CLN" => lnclient::LNClientConfig {
ln_client_type,
lnd_config: None,
lnurl_config: None,
nwc_config: None,
bolt12_config: None,
eclair_config: None,
cln_config: Some(cln::CLNOptions {
lightning_dir: env::var("CLN_LIGHTNING_RPC_FILE_PATH").expect("CLN_LIGHTNING_RPC_FILE_PATH not found in .env"),
}),
root_key: env::var("ROOT_KEY")
.expect("ROOT_KEY not found in .env")
.as_bytes()
.to_vec(),
},
"BOLT12" => lnclient::LNClientConfig {
ln_client_type,
lnd_config: None,
lnurl_config: None,
nwc_config: None,
cln_config: None,
eclair_config: None,
bolt12_config: Some(bolt12::Bolt12Options {
lightning_dir: env::var("CLN_LIGHTNING_RPC_FILE_PATH").expect("CLN_LIGHTNING_RPC_FILE_PATH not found in .env"),
offer: env::var("BOLT12_LN_OFFER").expect("BOLT12_LN_OFFER not found in .env"),
}),
root_key: env::var("ROOT_KEY")
.expect("ROOT_KEY not found in .env")
.as_bytes()
.to_vec(),
},
"ECLAIR" => lnclient::LNClientConfig {
ln_client_type,
lnd_config: None,
lnurl_config: None,
nwc_config: None,
cln_config: None,
bolt12_config: None,
eclair_config: Some(eclair::EclairOptions {
api_url: env::var("ECLAIR_API_URL").expect("ECLAIR_API_URL not found in .env"),
password: env::var("ECLAIR_PASSWORD").expect("ECLAIR_PASSWORD not found in .env"),
}),
root_key: env::var("ROOT_KEY")
.expect("ROOT_KEY not found in .env")
.as_bytes()
.to_vec(),
},
_ => panic!("Invalid LN_CLIENT_TYPE. Expected 'LNURL', 'LND', 'NWC', 'CLN', 'BOLT12', or 'ECLAIR'."),
};
let fiat_rate_config = Arc::new(FiatRateConfig {
currency: "USD".to_string(),
amount: 0.01,
});
let l402_middleware = middleware::L402Middleware::new_l402_middleware(
ln_client_config.clone(),
Arc::new(move |_req: &Request<'_>| {
let fiat_rate_config = Arc::clone(&fiat_rate_config);
Box::pin(async move {
fiat_rate_config.fiat_to_btc_amount_func().await
})
}),
Arc::new(move |req: &Request<'_>| {
path_caveat(req)
}),
).await.unwrap();
rocket::build()
.attach(l402_middleware)
.mount("/", routes![free, protected])
}
Testing
Run tests with:
cargo test --verbose for standard tests
cargo test --verbose --features "no-accept-authenticate-required" to run tests with accept-authenticate header requirements disabled