feat(openapi): document bearer auth on the 28 token-gated routes

The spec carried no security scheme, so generated clients/docs could not tell
which endpoints require a session token — e.g. /wallet/utxo/balance read as
public while its handler enforces auth via extract_user_id_from_token. Add a
`bearer_auth` HTTP/JWT scheme through a Modify addon and annotate every route
that resolves a session; auth-bootstrap, public username/key lookups, and
system routes stay open. Regenerated openapi.json (drift clean).
This commit is contained in:
jwinterm
2026-06-28 21:42:32 -04:00
parent 29efbf4494
commit 56c8dd463a
8 changed files with 236 additions and 29 deletions
+178 -29
View File
@@ -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": [
+2
View File
@@ -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,
+30
View File
@@ -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(),
),
);
}
}
+3
View File
@@ -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,
+6
View File
@@ -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,
+3
View File
@@ -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,
+6
View File
@@ -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,
+8
View File
@@ -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,