Skip to content

Commit ca72367

Browse files
authored
feat: Security: Add verification constraints (#1070)
1 parent f1d1f1a commit ca72367

3 files changed

Lines changed: 115 additions & 29 deletions

File tree

‎backend/src/api/auth_middleware.rs‎

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
//! Bearer <token>` headers and attaches the authenticated user to the request
77
//! extensions so downstream handlers can extract it without repeating JWT logic.
88
//!
9+
//! ## Bearer token verification via Privy JWKS
10+
//!
11+
//! Bearer tokens from Privy are verified against dynamically fetched JWKS from
12+
//! the Privy endpoint. The JWKS client handles periodic refresh and caching.
13+
//!
914
//! ## In-memory TTL token cache
1015
//!
1116
//! Validating a JWT on every single request is cheap (it's just HMAC-SHA256),
@@ -23,11 +28,12 @@
2328
//! ```rust
2429
//! // In main.rs, wrap the routes that require authentication:
2530
//! let auth_cache = AuthTokenCache::new();
31+
//! let privy_jwks = PrivyJwksClient::new(jwks_url);
2632
//!
2733
//! let protected = Router::new()
2834
//! .nest("/api/feed", feed_routes(pool.clone()))
2935
//! .layer(middleware::from_fn_with_state(
30-
//! AuthMiddlewareState { pool: pool.clone(), cache: auth_cache },
36+
//! AuthMiddlewareState { pool: pool.clone(), cache: auth_cache, privy: privy_jwks, privy_app_id },
3137
//! auth_middleware,
3238
//! ));
3339
//! ```
@@ -48,6 +54,8 @@ use std::{
4854
use tokio::sync::Mutex;
4955
use uuid::Uuid;
5056

57+
use super::privy_jwks::PrivyJwksClient;
58+
5159
// ── Cache configuration ───────────────────────────────────────────────────────
5260

5361
/// How long a successfully validated token is trusted before the cache entry
@@ -123,13 +131,13 @@ impl AuthTokenCache {
123131
}
124132
}
125133

126-
// ── Middleware state ──────────────────────────────────────────────────────────
127134

128-
/// State passed to `auth_middleware` via `middleware::from_fn_with_state`.
129135
#[derive(Clone)]
130136
pub struct AuthMiddlewareState {
131137
pub pool: sqlx::PgPool,
132138
pub cache: AuthTokenCache,
139+
pub privy: Arc<PrivyJwksClient>,
140+
pub privy_app_id: String,
133141
}
134142

135143
// ── Authenticated user extension ──────────────────────────────────────────────
@@ -149,6 +157,10 @@ pub struct AuthenticatedUser {
149157
/// request, caches the result for `TOKEN_CACHE_TTL`, and attaches an
150158
/// `AuthenticatedUser` extension for downstream handlers.
151159
///
160+
/// Supports two token types:
161+
/// - **Privy tokens**: Verified against dynamically fetched JWKS from Privy endpoint
162+
/// - **Local tokens**: Verified with our own JWT secret (for legacy/fallback)
163+
///
152164
/// Returns `401 Unauthorized` when:
153165
/// - The `Authorization` header is absent.
154166
/// - The header does not start with `Bearer `.
@@ -182,10 +194,11 @@ pub async fn auth_middleware(
182194
return next.run(request).await;
183195
}
184196

185-
// ── 3. JWT validation ────────────────────────────────────────────────────
186-
let address = match validate_jwt(&token) {
187-
Some(addr) => addr,
188-
None => {
197+
// ── 3. JWT validation (Privy JWKS or local) ──────────────────────────────
198+
let address = match validate_jwt_async(&token, &state).await {
199+
Ok(addr) => addr,
200+
Err(e) => {
201+
tracing::warn!("Bearer token validation failed: {e}");
189202
return (
190203
StatusCode::UNAUTHORIZED,
191204
Json(serde_json::json!({ "error": "Invalid or expired token" })),
@@ -239,35 +252,84 @@ fn extract_bearer_token(request: &Request<axum::body::Body>) -> Option<String> {
239252
header.strip_prefix("Bearer ").map(|t| t.to_string())
240253
}
241254

242-
/// Decode and validate a JWT, returning the `sub` claim (Stellar address) on success.
243-
///
244-
/// Falls back gracefully: if the token is the well-known `mock-jwt-token-string`
245-
/// used in tests, it returns the mock address so existing test suites keep passing.
246-
fn validate_jwt(token: &str) -> Option<String> {
255+
/// Validate JWT using Privy JWKS (async) or fall back to local secret.
256+
/// Returns the Stellar address from the `sub` claim on success.
257+
async fn validate_jwt_async(
258+
token: &str,
259+
state: &AuthMiddlewareState,
260+
) -> Result<String, String> {
247261
// Allow the mock token used across integration tests.
248262
if token == "mock-jwt-token-string" {
249-
return Some("GABC1234EXAMPLESTELLARADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string());
263+
return Ok("GABC1234EXAMPLESTELLARADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string());
264+
}
265+
266+
// Try validating with Privy JWKS first (for Privy-issued tokens)
267+
match state
268+
.privy
269+
.verify_token::<PrivyTokenClaims>(token, &state.privy_app_id)
270+
.await
271+
{
272+
Ok(claims) => {
273+
// Extract Stellar address from linked accounts in Privy token
274+
if let Some(stellar_addr) = claims.stellar_address() {
275+
tracing::debug!("Validated Privy token for Stellar address: {}", stellar_addr);
276+
return Ok(stellar_addr);
277+
}
278+
// Fall back to subject if no linked Stellar account
279+
tracing::debug!("Privy token has no linked Stellar account, using sub: {}", claims.sub);
280+
Ok(claims.sub)
281+
}
282+
Err(e) => {
283+
tracing::debug!("Privy JWKS validation failed, trying local JWT: {e}");
284+
// Fall back to local JWT validation
285+
validate_jwt_local(token)
286+
}
250287
}
288+
}
289+
290+
/// Claims from Privy JWT containing linked accounts info.
291+
#[derive(serde::Deserialize)]
292+
struct PrivyTokenClaims {
293+
#[serde(rename = "sub")]
294+
pub sub: String,
295+
#[serde(default)]
296+
pub linked_accounts: Vec<PrivyLinkedAccount>,
297+
}
298+
299+
impl PrivyTokenClaims {
300+
/// Extract Stellar address from linked accounts.
301+
fn stellar_address(&self) -> Option<String> {
302+
self.linked_accounts
303+
.iter()
304+
.filter(|acc| acc.account_type == "wallet" && acc.chain_type.as_deref() == Some("stellar"))
305+
.filter_map(|acc| acc.address.clone())
306+
.next()
307+
}
308+
}
309+
310+
#[derive(serde::Deserialize)]
311+
struct PrivyLinkedAccount {
312+
#[serde(rename = "type")]
313+
pub account_type: String,
314+
pub address: Option<String>,
315+
pub chain_type: Option<String>,
316+
}
251317

318+
/// Validate JWT using local secret (fallback for legacy tokens).
319+
fn validate_jwt_local(token: &str) -> Result<String, String> {
252320
let secret = std::env::var("JWT_SECRET")
253321
.unwrap_or_else(|_| "zaps-jwt-secret-placeholder-very-long-key".into());
254322

255323
let mut validation = jsonwebtoken::Validation::default();
256-
// Privy JWTs use the same HS256 default; adjust to RS256 if/when
257-
// verify_privy_token is upgraded to full asymmetric verification.
258324
validation.algorithms = vec![jsonwebtoken::Algorithm::HS256];
259325

260-
match jsonwebtoken::decode::<crate::api::auth::Claims>(
326+
jsonwebtoken::decode::<crate::api::auth::Claims>(
261327
token,
262328
&jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
263329
&validation,
264-
) {
265-
Ok(data) => Some(data.claims.sub),
266-
Err(e) => {
267-
tracing::debug!("JWT validation failed: {e}");
268-
None
269-
}
270-
}
330+
)
331+
.map(|data| data.claims.sub)
332+
.map_err(|e| e.to_string())
271333
}
272334

273335
/// Find or create a user row for the given Stellar address and return
@@ -448,9 +510,10 @@ mod tests {
448510
assert_eq!(extracted.as_deref(), Some("my-token-123"));
449511
}
450512

451-
#[test]
452-
fn mock_token_is_accepted() {
453-
let result = validate_jwt("mock-jwt-token-string");
454-
assert!(result.is_some());
513+
#[tokio::test]
514+
async fn mock_token_is_accepted() {
515+
// Test the local JWT validation path
516+
let result = validate_jwt_local("mock-jwt-token-string");
517+
assert!(result.is_ok());
455518
}
456519
}

‎backend/src/api/mod.rs‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,37 @@ pub fn auth_routes(pool: sqlx::PgPool) -> Router {
5757
/// round-trip on every request. See `auth_middleware::AuthTokenCache` for the
5858
/// TTL and eviction semantics.
5959
///
60+
/// Bearer tokens are verified against dynamically fetched Privy JWKS, with
61+
/// fallback to local JWT validation for legacy tokens.
62+
///
6063
/// # Usage (in main.rs)
6164
/// ```rust
6265
/// let auth_cache = api::AuthTokenCache::new();
66+
/// let privy_jwks = api::privy_jwks::PrivyJwksClient::new(jwks_url);
6367
/// let protected = api::protected_routes(
6468
/// Router::new()
6569
/// .nest("/api/feed", api::feed_routes(pool.clone()))
6670
/// .nest("/api/social", api::social_routes(pool.clone())),
6771
/// pool.clone(),
6872
/// auth_cache,
73+
/// privy_jwks,
74+
/// privy_app_id,
6975
/// );
7076
/// ```
71-
pub fn protected_routes(router: Router, pool: sqlx::PgPool, cache: AuthTokenCache) -> Router {
77+
pub fn protected_routes(
78+
router: Router,
79+
pool: sqlx::PgPool,
80+
cache: AuthTokenCache,
81+
privy: std::sync::Arc<privy_jwks::PrivyJwksClient>,
82+
privy_app_id: String,
83+
) -> Router {
7284
router.layer(middleware::from_fn_with_state(
73-
AuthMiddlewareState { pool, cache },
85+
AuthMiddlewareState {
86+
pool,
87+
cache,
88+
privy,
89+
privy_app_id,
90+
},
7491
auth_middleware,
7592
))
7693
}

‎backend/src/main.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,10 @@ async fn main() {
305305

306306
// #561 — routes that require a valid Privy JWT are wrapped with the auth
307307
// middleware so the token is validated (and cached) before any handler runs.
308+
// Bearer tokens are verified against dynamically fetched Privy JWKS.
309+
let privy_jwks = Arc::new(api::privy_jwks::PrivyJwksClient::new(
310+
config.privy_jwks_url.clone(),
311+
));
308312
let auth_required_routes = api::protected_routes(
309313
Router::new()
310314
.nest("/api/feed", api::feed_routes(pool.clone()))
@@ -321,6 +325,8 @@ async fn main() {
321325
.nest("/api/payouts", api::payout_routes(pool.clone())),
322326
pool.clone(),
323327
auth_cache.clone(),
328+
privy_jwks,
329+
config.privy_app_id.clone(),
324330
);
325331

326332
let other_routes = auth_required_routes;

0 commit comments

Comments
 (0)