diff --git a/openapi.json b/openapi.json index 1a434e9..5b8db6b 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,8 @@ "email": "jw@such.software" }, "license": { - "name": "" + "name": "MIT", + "identifier": "MIT" }, "version": "0.3.0" }, @@ -162,7 +163,12 @@ "401": { "description": "Missing or invalid token" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/auth/nostr": { @@ -225,7 +231,12 @@ "409": { "description": "npub already linked to another account" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/auth/pow-challenge": { @@ -426,7 +437,12 @@ "401": { "description": "Missing or invalid token" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/prices": { @@ -507,7 +523,12 @@ "401": { "description": "Missing or invalid token" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] }, "post": { "tags": [ @@ -543,7 +564,12 @@ "409": { "description": "Username is taken or reserved" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/users/{user_id}/keys": { @@ -666,7 +692,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/grin/height": { @@ -696,7 +727,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/grin/relay/cancel": { @@ -736,7 +772,12 @@ "409": { "description": "Relay is already finalized" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/grin/relay/create": { @@ -776,7 +817,12 @@ "404": { "description": "Relay feature disabled" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/grin/relay/finalize": { @@ -819,7 +865,12 @@ "409": { "description": "Relay is not awaiting finalization" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/grin/relay/get": { @@ -856,7 +907,12 @@ "404": { "description": "Relay not found, not yours, or feature disabled" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/grin/relay/pending": { @@ -883,7 +939,12 @@ "404": { "description": "Relay feature disabled" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/grin/relay/respond": { @@ -926,7 +987,12 @@ "409": { "description": "Relay is not awaiting a recipient response" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/grin/scan": { @@ -966,7 +1032,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/lws/balance": { @@ -1006,7 +1077,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/lws/confirmations": { @@ -1046,7 +1122,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/lws/height": { @@ -1086,7 +1167,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/lws/history": { @@ -1126,7 +1212,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/lws/random_outs": { @@ -1166,7 +1257,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/lws/register": { @@ -1206,7 +1302,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/lws/submit_tx": { @@ -1246,7 +1347,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/lws/unspent_outs": { @@ -1286,7 +1392,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/utxo/balance": { @@ -1326,7 +1437,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/utxo/broadcast": { @@ -1366,7 +1482,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/utxo/fee": { @@ -1406,7 +1527,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/utxo/history": { @@ -1446,7 +1572,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/utxo/tip": { @@ -1486,7 +1617,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/wallet/utxo/utxos": { @@ -1526,7 +1662,12 @@ "503": { "description": "Upstream node unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } } }, @@ -3244,6 +3385,14 @@ } } } + }, + "securitySchemes": { + "bearer_auth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Session access token issued by the /auth/* endpoints." + } } }, "tags": [ diff --git a/src/api/auth.rs b/src/api/auth.rs index 56a1ff4..5ae3f8c 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -857,6 +857,7 @@ pub async fn logout( /// The authenticated user's own info. #[utoipa::path( + security(("bearer_auth" = [])), get, path = "/auth/me", responses( @@ -1009,6 +1010,7 @@ pub struct NostrLinkResponse { /// shape but refuses every link, because it cannot yet prove the nonce was issued /// by this server and is unused. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/auth/nostr/link", request_body = NostrLinkRequest, diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 288929e..dfa412d 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -14,6 +14,7 @@ use utoipa::OpenApi; version = "0.3.0", description = "Open, self-hostable backend for the Smirk non-custodial multi-chain wallet." ), + modifiers(&SecurityAddon), paths( crate::api::health::health, crate::api::capabilities::capabilities, @@ -144,3 +145,32 @@ use utoipa::OpenApi; ) )] pub struct ApiDoc; + +/// Registers the `bearer_auth` security scheme (HTTP Bearer / JWT). Routes that +/// resolve a session via `extract_user_id_from_token` annotate themselves with +/// `security(("bearer_auth" = []))`; public routes (auth bootstrap, lookups, +/// system) carry no `security` and are documented as open. Without this the +/// generated spec/client could not tell which endpoints require a token. +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; + let components = openapi + .components + .as_mut() + .expect("components are registered by the derive"); + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some( + "Session access token issued by the /auth/* endpoints.", + )) + .build(), + ), + ); + } +} diff --git a/src/api/users.rs b/src/api/users.rs index d8d86fe..ea19e59 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -209,6 +209,7 @@ pub struct UserKeysResponse { /// reserved list before the DB write. The UNIQUE constraint is the atomic claim: /// a collision (taken or reserved) is a 409, never a 500. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/users/me/username", request_body = SetUsernameRequest, @@ -244,6 +245,7 @@ pub async fn set_username( /// The authenticated user's own username (or `null`). #[utoipa::path( + security(("bearer_auth" = [])), get, path = "/users/me/username", responses( @@ -327,6 +329,7 @@ pub async fn lookup_username( /// Idempotent: the DB upsert keys on `(user_id, asset, key_type)`, so re-posting /// the same asset replaces the stored key material in place. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/keys", request_body = RegisterKeyRequest, diff --git a/src/api/wallet/btc_ltc.rs b/src/api/wallet/btc_ltc.rs index ba353b7..b716447 100644 --- a/src/api/wallet/btc_ltc.rs +++ b/src/api/wallet/btc_ltc.rs @@ -166,6 +166,7 @@ pub struct BroadcastResponse { /// Confirmed/unconfirmed balance for a BTC/LTC address. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/utxo/balance", request_body = AddressRequest, @@ -198,6 +199,7 @@ pub async fn balance( /// Unspent outputs for a BTC/LTC address. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/utxo/utxos", request_body = AddressRequest, @@ -237,6 +239,7 @@ pub async fn utxos( /// Confirmed + mempool transaction history for a BTC/LTC address. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/utxo/history", request_body = AddressRequest, @@ -275,6 +278,7 @@ pub async fn history( /// Best-chain tip height (for client-side confirmation counting). #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/utxo/tip", request_body = AssetRequest, @@ -300,6 +304,7 @@ pub async fn tip( /// Fee-rate estimate (sat/vB) for confirmation within `blocks` blocks. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/utxo/fee", request_body = FeeRequest, @@ -327,6 +332,7 @@ pub async fn fee( /// Broadcast a finalized, signed BTC/LTC transaction. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/utxo/broadcast", request_body = BroadcastRequest, diff --git a/src/api/wallet/grin.rs b/src/api/wallet/grin.rs index b5935ed..ad55aeb 100644 --- a/src/api/wallet/grin.rs +++ b/src/api/wallet/grin.rs @@ -103,6 +103,7 @@ fn output_dto(o: ViewWalletOutputResult) -> GrinOutput { /// View-only scan for the outputs a `rewind_hash` recognizes. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/grin/scan", request_body = GrinScanRequest, @@ -135,6 +136,7 @@ pub async fn scan( /// Current Grin chain tip height (for confirmation counting). #[utoipa::path( + security(("bearer_auth" = [])), get, path = "/wallet/grin/height", responses( @@ -157,6 +159,7 @@ pub async fn height( /// Broadcast a finalized, signed Grin transaction. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/grin/broadcast", request_body = GrinBroadcastRequest, diff --git a/src/api/wallet/grin_relay.rs b/src/api/wallet/grin_relay.rs index 55ae3db..23ab174 100644 --- a/src/api/wallet/grin_relay.rs +++ b/src/api/wallet/grin_relay.rs @@ -201,6 +201,7 @@ fn validate_slatepack(value: &str, field: &str) -> Result<(), AppError> { /// Post a slatepack addressed to a registered recipient (sender = caller). #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/grin/relay/create", request_body = CreateRelayRequest, @@ -252,6 +253,7 @@ pub async fn create( /// The caller's inbox: relays awaiting their response. #[utoipa::path( + security(("bearer_auth" = [])), get, path = "/wallet/grin/relay/pending", responses( @@ -277,6 +279,7 @@ pub async fn pending( /// Poll a relay's current state (sender or recipient). The sender uses this to /// fetch the recipient's response once status is `pending_sender`. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/grin/relay/get", request_body = SlateIdRequest, @@ -301,6 +304,7 @@ pub async fn get_relay( /// Attach the recipient's response, advancing to `pending_sender`. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/grin/relay/respond", request_body = RespondRelayRequest, @@ -339,6 +343,7 @@ pub async fn respond( /// The backend does NOT finalize or broadcast — the wallet does that locally and /// reports the resulting tx hash here. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/grin/relay/finalize", request_body = FinalizeRelayRequest, @@ -377,6 +382,7 @@ pub async fn finalize( /// Cancel a relay (sender or recipient), unless already finalized. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/grin/relay/cancel", request_body = SlateIdRequest, diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index 11f4474..fff1beb 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -274,6 +274,7 @@ fn random_dto(r: RandomOutput) -> RandomOutputDto { /// Register an account (its view key) with the LWS so it begins scanning. /// Idempotent at the LWS; pass `start_height` to scan from a wallet birthday. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/lws/register", request_body = RegisterRequest, @@ -310,6 +311,7 @@ pub async fn register( /// Balance + scan state for an account. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/lws/balance", request_body = ViewRequest, @@ -347,6 +349,7 @@ pub async fn balance( /// Transaction history (confirmed + mempool) for an account. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/lws/history", request_body = ViewRequest, @@ -379,6 +382,7 @@ pub async fn history( /// Unspent outputs for an account (for client-side spend construction). #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/lws/unspent_outs", request_body = ViewRequest, @@ -414,6 +418,7 @@ pub async fn unspent_outs( /// Random decoy outputs for ring construction. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/lws/random_outs", request_body = RandomOutsRequest, @@ -452,6 +457,7 @@ pub async fn random_outs( /// Broadcast a finalized, signed Monero/Wownero transaction. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/lws/submit_tx", request_body = SubmitRequest, @@ -480,6 +486,7 @@ pub async fn submit_tx( /// Current chain height (daemon) for confirmation counting. #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/lws/height", request_body = LwsAssetRequest, @@ -505,6 +512,7 @@ pub async fn height( /// Confirmation count for a transaction (daemon). #[utoipa::path( + security(("bearer_auth" = [])), post, path = "/wallet/lws/confirmations", request_body = ConfirmationsRequest,