feat: core auth handlers (register, restore, pow, refresh, logout, me, nostr)

api/middleware.rs: Bearer access-token extraction; client_ip() trusts XFF only
behind config.trusted_proxies (fail-closed against spoofing).
api/auth.rs: extension_register (PoW-gated; derivation-rotation requires a
signature from the on-file key, no fingerprint hijack), check_restore (per-IP +
per-fingerprint rate limits, enumeration/timing-resistant), pow_challenge,
refresh_token (re-checks active session + revoke-then-issue rotation, race-safe),
logout, get_me, nostr_login (login-grade NIP-98, never creates a user, bound to
config public_api_url), nostr_link (JWT + state-change verify_signed_action,
fail-closed nonce stub) + DTOs + routes().
Wired into api/mod.rs + nested at /api/v1 in main.rs; all 8 paths registered in
ApiDoc so openapi.json covers the auth surface (was vacuous).

Drafted+hardened via draft->adversarial-review->revise workflow; independently
re-reviewed (no critical/high). 58 tests, all gates green.

Known LOW (tracked): concurrent first-registration of the same new pubkey_hash
can 500 instead of idempotent re-read; client-retryable.
This commit is contained in:
jwinterm
2026-06-22 17:47:20 -04:00
parent 99cfc49cb2
commit be23042c0c
6 changed files with 1882 additions and 3 deletions
+1181
View File
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
//! HTTP middleware and request-scoped extractors for the auth surface.
//!
//! Two helpers live here:
//!
//! * [`extract_user_id_from_token`] — pull a Bearer access token from the
//! `Authorization` header and resolve it to a `user_id` via the shared
//! [`SessionManager`] in [`AppState`]. The error is a literal (`Invalid or
//! expired token` / a missing-header literal) so the endpoint never becomes an
//! auth oracle.
//! * [`client_ip`] — derive the client IP used for rate-limiting and audit. It
//! trusts `X-Forwarded-For` **only** when the TCP peer is inside
//! `config.trusted_proxies`; otherwise it uses the real peer. A spoofed
//! `X-Forwarded-For` from an untrusted peer cannot evade per-IP limits.
//!
//! The session manager is read from `state.sessions` (constructed once at
//! startup) rather than rebuilt per request, so the HS256 key never has to be
//! re-derived on the hot path.
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use axum::http::HeaderMap;
use uuid::Uuid;
use crate::error::AppError;
use crate::AppState;
/// Resolve a Bearer access token in the `Authorization` header to its `user_id`.
///
/// All failure modes (missing header, wrong scheme, invalid/expired/forged JWT)
/// collapse to `AppError::AuthError` with a literal message, so a caller cannot
/// distinguish "no token" from "bad token" from "expired token".
pub async fn extract_user_id_from_token(
state: &Arc<AppState>,
headers: &HeaderMap,
) -> Result<Uuid, AppError> {
let token = bearer_token(headers)?;
let info = state.sessions.verify_access_token(token)?;
Ok(info.user_id)
}
/// Extract the raw Bearer token from an `Authorization` header, or a literal
/// `AuthError`. Split out so login-grade and state-change handlers share one
/// parser (and one error shape).
pub(crate) fn bearer_token(headers: &HeaderMap) -> Result<&str, AppError> {
let auth = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::AuthError("Missing authorization header".into()))?;
auth.strip_prefix("Bearer ")
.map(str::trim)
.filter(|t| !t.is_empty())
.ok_or_else(|| AppError::AuthError("Invalid authorization format".into()))
}
/// The client IP to use for rate-limiting and audit logging.
///
/// `peer` is the real TCP source (from axum `ConnectInfo<SocketAddr>`). We honor
/// `X-Forwarded-For` **only** when `peer` falls inside one of
/// `config.trusted_proxies`; otherwise the header is attacker-controlled and is
/// ignored. The default config has an empty trusted-proxy list, so by default
/// the real peer is always used — fail-closed against header spoofing.
///
/// When trusted, the *left-most* XFF entry (the original client, per the de-facto
/// convention) is taken; if it does not parse as an IP we fall back to the peer
/// rather than trusting an unparseable value.
pub fn client_ip(state: &AppState, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
let peer_ip = peer.ip();
let peer_is_trusted = state
.config
.trusted_proxies
.iter()
.any(|net| net.contains(peer_ip));
if !peer_is_trusted {
return peer_ip;
}
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|xff| xff.split(',').next())
.map(str::trim)
.and_then(|first| first.parse::<IpAddr>().ok())
.unwrap_or(peer_ip)
}
#[cfg(test)]
mod tests {
use super::*;
use ipnetwork::IpNetwork;
use std::str::FromStr;
fn headers_with_xff(value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", value.parse().unwrap());
h
}
fn peer(s: &str) -> SocketAddr {
SocketAddr::new(IpAddr::from_str(s).unwrap(), 12345)
}
#[test]
fn bearer_token_parses_and_rejects() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::AUTHORIZATION,
"Bearer abc.def.ghi".parse().unwrap(),
);
assert_eq!(bearer_token(&h).unwrap(), "abc.def.ghi");
let mut bad = HeaderMap::new();
bad.insert(
axum::http::header::AUTHORIZATION,
"Basic xyz".parse().unwrap(),
);
assert!(bearer_token(&bad).is_err());
assert!(bearer_token(&HeaderMap::new()).is_err());
}
/// Build a throwaway AppState is heavy; instead exercise the trust decision
/// against the proxy list directly through a tiny shim mirroring `client_ip`.
fn pick_ip(trusted: &[IpNetwork], headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
let peer_ip = peer.ip();
if !trusted.iter().any(|n| n.contains(peer_ip)) {
return peer_ip;
}
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|xff| xff.split(',').next())
.map(str::trim)
.and_then(|f| f.parse::<IpAddr>().ok())
.unwrap_or(peer_ip)
}
#[test]
fn untrusted_peer_ignores_xff() {
// No trusted proxies: the spoofed header is ignored, peer wins.
let h = headers_with_xff("1.2.3.4");
let got = pick_ip(&[], &h, peer("9.9.9.9"));
assert_eq!(got, IpAddr::from_str("9.9.9.9").unwrap());
}
#[test]
fn trusted_peer_honors_leftmost_xff() {
let trusted = vec![IpNetwork::from_str("10.0.0.0/8").unwrap()];
let h = headers_with_xff("1.2.3.4, 10.0.0.5");
let got = pick_ip(&trusted, &h, peer("10.0.0.5"));
assert_eq!(got, IpAddr::from_str("1.2.3.4").unwrap());
}
#[test]
fn trusted_peer_with_garbage_xff_falls_back_to_peer() {
let trusted = vec![IpNetwork::from_str("10.0.0.0/8").unwrap()];
let h = headers_with_xff("not-an-ip");
let got = pick_ip(&trusted, &h, peer("10.0.0.5"));
assert_eq!(got, IpAddr::from_str("10.0.0.5").unwrap());
}
}
+2
View File
@@ -1,4 +1,6 @@
//! HTTP API layer: handlers and the OpenAPI assembly.
pub mod auth;
pub mod health;
pub mod middleware;
pub mod openapi;
+29 -3
View File
@@ -14,8 +14,34 @@ use utoipa::OpenApi;
version = "0.3.0",
description = "Open, self-hostable backend for the Smirk non-custodial multi-chain wallet."
),
paths(crate::api::health::health),
components(schemas(crate::api::health::HealthResponse)),
tags((name = "system", description = "Service health and metadata."))
paths(
crate::api::health::health,
crate::api::auth::pow_challenge,
crate::api::auth::extension_register,
crate::api::auth::check_restore,
crate::api::auth::refresh_token,
crate::api::auth::logout,
crate::api::auth::get_me,
crate::api::auth::nostr_login,
crate::api::auth::nostr_link,
),
components(schemas(
crate::api::health::HealthResponse,
crate::api::auth::AuthResponse,
crate::api::auth::UserInfo,
crate::api::auth::AssetPublicKey,
crate::api::auth::ExtensionRegisterRequest,
crate::api::auth::CheckRestoreRequest,
crate::api::auth::CheckRestoreResponse,
crate::api::auth::RefreshTokenRequest,
crate::api::auth::LogoutRequest,
crate::api::auth::LogoutResponse,
crate::api::auth::NostrLinkRequest,
crate::api::auth::NostrLinkResponse,
)),
tags(
(name = "system", description = "Service health and metadata."),
(name = "auth", description = "Wallet authentication: registration, restore, refresh, sessions, Nostr identity.")
)
)]
pub struct ApiDoc;
+1
View File
@@ -45,6 +45,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new()
.route("/health", get(health::health))
.nest("/api/v1", smirk_backend_core::api::auth::routes())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&addr).await?;