Files
keysat/licensing-service/src/api/btcpay_authorize.rs
T
Grant ec2b21d8f7 v0.2.0:3 — durable payment-provider switching (Option B)
Closes the gap from :2 where Connect Zaprite swapped the
in-memory provider but BTCPay would silently re-take active on
the next daemon restart (because the boot-time loader picked
BTCPay first whenever btcpay_config was present, regardless of
operator intent).

What changed:

**New settings key `active_payment_provider`** in the existing
settings table. Records the operator's last explicit choice
('btcpay' | 'zaprite' | NULL = no preference). Both
btcpay_config and zaprite_config can coexist; the flag is what
determines which one the daemon loads.

**Boot-time loader respects the preference.** main.rs now reads
the flag at startup. If set to 'zaprite', Zaprite wins; if set to
'btcpay', BTCPay wins; if unset (legacy installs), falls back to
the previous BTCPay-first ordering. Cross-load fallbacks log a
WARN and try the other provider — operators with a stale flag
pointing at a wiped config don't boot unconfigured.

**Connect endpoints write the preference.**
- finish_connect (BTCPay) now sets the flag to 'btcpay' on
  successful authorize-callback completion.
- ZapriteAuthorize::connect now sets the flag to 'zaprite' on
  successful API-key validation.
- Both Disconnect endpoints clear the flag IF it pointed at the
  provider being disconnected — but leave it alone if it pointed
  at the OTHER provider (different operator intent).

**New endpoints for fast switching without re-Connect:**
- GET /v1/admin/payment-provider/status — both configs' state +
  current preference + runtime active provider, in one call.
- POST /v1/admin/payment-provider/activate { provider: "btcpay" |
  "zaprite" } — flips the active provider and the flag together,
  without going through the full Connect flow. 400 if the named
  provider isn't configured (operator must run Connect first).

**New StartOS Actions** under existing groups:
- "Activate BTCPay" (in BTCPay group)
- "Activate Zaprite" (in Zaprite group)
Both call the new activate endpoint. Operators with both
providers configured can flip back and forth in one click.

**Test:** payment_provider_preference_round_trip pre-seeds both
configs, walks through Activate-Zaprite → Activate-BTCPay →
attempt-Activate-on-wiped-config → bad-provider-name → manual
write/read of the preference key. Pins the contract.

Test count: 42 (was 41; +1).

Migration not needed — settings table from 0005 already has the
key/value/updated_at shape we need.
2026-05-08 16:51:15 -05:00

444 lines
16 KiB
Rust

//! BTCPay one-click authorize flow.
//!
//! Instead of making the operator generate an API key by hand and paste it
//! into a form, we use BTCPay's "authorize" redirect flow:
//!
//! 1. Operator clicks "Connect BTCPay" in StartOS — the wrapper action
//! calls `POST /v1/admin/btcpay/connect` (with the admin bearer token)
//! and gets back a BTCPay URL to open in the operator's browser.
//! 2. The operator, already logged into BTCPay on the same box, sees a
//! consent page listing the permissions this service is requesting. They
//! click **Authorize**.
//! 3. BTCPay POSTs back to our `/v1/btcpay/authorize/callback` with the
//! newly-minted API key and the store(s) it was scoped to.
//! 4. We persist the key, pick the target store, register the webhook (with
//! a freshly-generated secret), and save everything in `btcpay_config`.
//! 5. From that moment on, the `BtcpayProvider` (held as an `Arc<dyn
//! PaymentProvider>` in `AppState.payment`) is populated
//! and purchase / webhook endpoints work.
//!
//! If the callback fails for any reason, the operator is shown an error page
//! and can retry. The admin endpoint requires the admin bearer token; the
//! callback path uses the CSRF `state` token to tie a callback back to the
//! issuing operator session.
use crate::api::{admin::require_admin, AppState};
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
use crate::btcpay::config as btcpay_cfg;
use crate::error::{AppError, AppResult};
use crate::payment::btcpay::BtcpayProvider;
use std::sync::Arc;
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
Form, Json,
};
use data_encoding::BASE32_NOPAD;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Permissions we request on the authorize page. Each is namespaced by
/// `btcpay.store.*` which means BTCPay will prompt the operator to pick
/// which store(s) to grant.
const REQUESTED_PERMISSIONS: &[&str] = &[
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifystoresettings", // to register the webhook
"btcpay.store.canviewinvoices",
"btcpay.store.cancreateinvoice",
"btcpay.store.canmodifyinvoices",
];
#[derive(Debug, Serialize)]
pub struct ConnectResp {
/// URL the operator should open in their browser to authorize.
pub authorize_url: String,
/// CSRF state token tied to this round trip.
pub state: String,
}
/// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize
/// URL for the StartOS wrapper action to open in the operator's browser.
pub async fn start_connect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<ConnectResp>> {
require_admin(&state, &headers)?;
// Idempotency: if BTCPay is already connected, refuse to issue a new
// authorize URL. Re-clicking Connect today produces a duplicate
// webhook subscription on BTCPay, which results in every payment
// event being delivered to Keysat twice. Make the operator go
// through Disconnect first if they really want to re-authorize.
if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await {
return Err(AppError::Conflict(format!(
"BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.",
existing.store_id,
)));
}
// Random 20-byte token, base32-encoded, for the CSRF `state` parameter.
let mut raw = [0u8; 20];
rand::thread_rng().fill_bytes(&mut raw);
let state_token = BASE32_NOPAD.encode(&raw);
btcpay_cfg::record_authorize_state(&state.db, &state_token)
.await
.map_err(AppError::Internal)?;
// Construct the authorize URL per BTCPay's docs.
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
//
// CSRF state must travel inside the `redirect` URL itself, NOT as a
// separate query param on the outer authorize URL. Empirical
// observation against BTCPay: arbitrary query params on the
// authorize URL are NOT forwarded to the redirect target. The
// redirect URL is preserved verbatim, so any params we encode INTO
// it survive the round-trip.
let redirect = format!(
"{}/v1/btcpay/authorize/callback?state={}",
state.config.public_base_url,
urlencoding::encode(&state_token),
);
let perm_params = REQUESTED_PERMISSIONS
.iter()
.map(|p| format!("permissions={}", urlencoding::encode(p)))
.collect::<Vec<_>>()
.join("&");
// The authorize URL is followed by the operator's BROWSER, so the host
// must be reachable from outside the container. Use the explicit
// `btcpay_browser_url` if the wrapper provided it; fall back to
// `btcpay_url` only for dev/local setups (where they're the same).
let authorize_base = state
.config
.btcpay_browser_url
.as_deref()
.unwrap_or(&state.config.btcpay_url);
let authorize_url = format!(
"{}/api-keys/authorize?applicationName={}&applicationIdentifier={}&strict=true&selectiveStores=true&redirect={}&{perm_params}",
authorize_base,
urlencoding::encode("Keysat"),
urlencoding::encode("keysat"),
urlencoding::encode(&redirect),
);
Ok(Json(ConnectResp {
authorize_url,
state: state_token,
}))
}
/// Fields BTCPay sends back on the callback. BTCPay POSTs `apiKey`,
/// `userId`, and `permissions[]` as a form body. It also preserves any
/// query-string parameters on the redirect URL — we use that for `state`.
#[derive(Debug, Deserialize)]
pub struct CallbackForm {
#[serde(rename = "apiKey")]
pub api_key: String,
#[serde(rename = "userId")]
pub user_id: Option<String>,
// BTCPay posts `permissions` one-per-occurrence; serde_urlencoded turns
// that into a repeated string. We don't actually need to parse them
// individually — we just re-verify via list_stores.
#[serde(default)]
pub permissions: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
pub state: String,
}
/// The real callback endpoint — POST form-encoded.
pub async fn callback(
State(state): State<AppState>,
Query(q): Query<CallbackQuery>,
Form(form): Form<CallbackForm>,
) -> AppResult<Response> {
finish_connect(&state, &q.state, &form.api_key).await?;
Ok(success_page("BTCPay connected successfully. You can close this tab and return to StartOS."))
}
/// Some BTCPay deployments send the apiKey back as a query string on a GET.
/// Handle that too for robustness.
#[derive(Debug, Deserialize)]
pub struct CallbackGetQuery {
pub state: String,
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
/// Error message if BTCPay declined / operator clicked "Deny".
pub error: Option<String>,
}
pub async fn callback_get(
State(state): State<AppState>,
Query(q): Query<CallbackGetQuery>,
) -> Response {
if let Some(err) = q.error {
return Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&err)
))
.into_response();
}
let Some(api_key) = q.api_key else {
// Some installs POST; in that case a bare GET with no apiKey is
// possible if the operator refreshes the tab. Redirect to root.
return Redirect::to("/").into_response();
};
match finish_connect(&state, &q.state, &api_key).await {
Ok(()) => success_page(
"BTCPay connected successfully. You can close this tab and return to StartOS.",
),
Err(e) => Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&e.to_string())
))
.into_response(),
}
}
/// Admin endpoint: list payment methods configured on the connected
/// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`.
/// Used by the wrapper / future web UI to surface a "no wallet
/// configured" state.
pub async fn payment_methods(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?
.ok_or(AppError::BtcpayNotConfigured)?;
let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?;
// Return both the raw array for callers that want detail, and a
// boolean summary for the common "is anything configured?" check.
let count = methods.len();
Ok(Json(json!({
"store_id": cfg.store_id,
"count": count,
"methods": methods,
})))
}
/// Admin endpoint: report current BTCPay connection status.
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?;
Ok(Json(match cfg {
None => json!({ "connected": false }),
Some(c) => json!({
"connected": true,
"store_id": c.store_id,
"webhook_id": c.webhook_id,
"base_url": c.base_url,
}),
}))
}
// --- internals ---
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
btcpay_cfg::consume_authorize_state(&state.db, state_token)
.await
.map_err(|_| AppError::Unauthorized)?;
let base_url = &state.config.btcpay_url;
// Enumerate stores the key has access to. With `selectiveStores=true`
// the operator picked specific stores during authorize; we pick the
// first one that the key can see.
let stores = btcpay_client::list_stores(base_url, api_key)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?;
let store = stores
.into_iter()
.next()
.ok_or_else(|| AppError::BadRequest(
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
))?;
// Generate a strong webhook secret, then register the webhook on BTCPay.
let mut raw_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw_secret);
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url);
let created_webhook = btcpay_client::create_webhook(
base_url,
api_key,
&store.id,
&callback_url,
&webhook_secret,
)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?;
// Persist.
let cfg = btcpay_cfg::BtcpayConfig {
base_url: base_url.clone(),
api_key: api_key.to_string(),
store_id: store.id.clone(),
webhook_id: Some(created_webhook.id.clone()),
webhook_secret: webhook_secret.clone(),
};
btcpay_cfg::save(&state.db, &cfg)
.await
.map_err(AppError::Internal)?;
// Swap runtime — wrap a fresh BtcpayProvider into the
// PaymentProvider trait object held by AppState. Pass the
// public-facing BTCPay URL too so that checkout URLs returned to
// buyers get rewritten from the internal Docker hostname to a
// browser-reachable host.
let client = BtcpayClient::new(base_url, api_key, &store.id);
let provider = Arc::new(
BtcpayProvider::new(client, webhook_secret)
.with_public_base(state.config.btcpay_public_url.clone()),
);
state.set_payment_provider(provider).await;
// Persist active-provider preference so the boot-time loader
// picks BTCPay on next restart even if Zaprite's config row
// is also still in the DB. Failure here is non-fatal (BTCPay
// is the historical default, so the fallback loader picks it
// anyway) but logged.
if let Err(e) = crate::payment::write_active_provider_preference(
&state.db,
crate::payment::ProviderKind::Btcpay,
)
.await
{
tracing::warn!(error = %e, "failed to record BTCPay as active payment provider");
}
tracing::info!(
store = %store.id,
store_name = %store.name,
webhook_id = %created_webhook.id,
"BTCPay connected via authorize flow"
);
Ok(())
}
fn success_page(msg: &str) -> Response {
let body = format!(
r#"<!doctype html><html><head><meta charset="utf-8"><title>BTCPay connected</title>
<style>body{{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:1rem;line-height:1.5}}
h2{{color:#0a7}}</style></head>
<body><h2>✓ {msg}</h2></body></html>"#,
msg = html_escape::encode_text(msg)
);
(StatusCode::OK, Html(body)).into_response()
}
/// Admin endpoint: disconnect BTCPay. Best-effort revocation of the
/// webhook + API key on BTCPay's side, then unconditional clear of the
/// local config row. If BTCPay is unreachable, the local state is still
/// cleared and the operator gets a warning to clean up BTCPay manually.
pub async fn disconnect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = crate::api::admin::request_context(&headers);
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?;
let Some(cfg) = cfg else {
return Ok(Json(json!({
"ok": true,
"noop": true,
"message": "BTCPay was not connected; nothing to do.",
})));
};
// Capture metadata for the response BEFORE we clear local state.
let store_id = cfg.store_id.clone();
let webhook_id = cfg.webhook_id.clone();
// Best-effort remote cleanup. We DON'T short-circuit if either of
// these calls fails — the operator's intent is to disconnect, and
// leaving local state pointing at a remote we no longer trust is
// worse than leaving orphan state on the BTCPay side. Any failures
// are surfaced in the response so the operator can manually clean
// up on BTCPay if needed.
let mut warnings: Vec<String> = Vec::new();
if let Some(webhook_id) = webhook_id.as_deref() {
if let Err(e) = btcpay_client::delete_webhook(
&cfg.base_url,
&cfg.api_key,
&cfg.store_id,
webhook_id,
)
.await
{
warnings.push(format!(
"Could not delete BTCPay webhook {webhook_id}: {e}. \
You may want to manually delete it in BTCPay's store webhook settings."
));
}
}
if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await {
warnings.push(format!(
"Could not revoke BTCPay API key: {e}. \
You may want to manually revoke it in BTCPay's account API-keys page."
));
}
btcpay_cfg::clear(&state.db)
.await
.map_err(AppError::Internal)?;
// Replace the runtime payment provider so subsequent purchase
// attempts return BtcpayNotConfigured cleanly.
state.clear_payment_provider().await;
// If BTCPay was the recorded active-provider preference, clear
// it. Don't blindly clear if it was Zaprite — different operator
// intent.
if matches!(
crate::payment::read_active_provider_preference(&state.db).await,
Some(crate::payment::ProviderKind::Btcpay)
) {
let _ = crate::db::repo::settings_set(
&state.db,
crate::payment::SETTING_ACTIVE_PROVIDER,
None,
)
.await;
}
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"btcpay.disconnect",
Some("btcpay_config"),
None,
ip.as_deref(),
ua.as_deref(),
&json!({ "store_id": store_id, "webhook_id": webhook_id }),
)
.await;
Ok(Json(json!({
"ok": true,
"noop": false,
"store_id": store_id,
"webhook_id": webhook_id,
"warnings": warnings,
})))
}