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),
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::{
4854use tokio:: sync:: Mutex ;
4955use 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 ) ]
130136pub 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}
0 commit comments