diff --git a/Cargo.lock b/Cargo.lock index c8d74a8..e375b74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1987,6 +1987,7 @@ dependencies = [ "redis", "serde", "serde_json", + "sha2", "sha3", "signinwithethereum", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index c6ea1de..4c321b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ rand = "0.8" # M2b — SIWE (EIP-4361) EOA verification (in-house parser; k256/sha3 ecrecover) k256 = { version = "0.13", features = ["ecdsa"] } sha3 = "0.10" +sha2 = "0.10" hex = "0.4" url = "2" diff --git a/config/default.toml b/config/default.toml index 6b29bcd..14eb672 100644 --- a/config/default.toml +++ b/config/default.toml @@ -23,6 +23,8 @@ domain = "localhost" uri = "http://localhost:3001" chain_id = 324 default_scopes = ["ai:invoke", "mint:request", "scan:submit", "profile:read"] +refresh_token_ttl_secs = 604800 +issue_refresh_tokens = true [rate_limit] nonce_per_ip_per_minute = 30 diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 8d45709..aa4e11d 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -6,8 +6,12 @@ use crate::{ config::Config, error::AppError, middleware::rate_limit::{new_nonce_rate_limiter, RateLimiter}, - services::{nonce::NonceService, revocation::RevocationService, token::TokenService}, + services::{ + nonce::NonceService, refresh::RefreshService, revocation::RevocationService, + token::TokenService, + }, store::nonce::{InMemoryNonceStore, NonceStore, RedisNonceStore}, + store::refresh::{InMemoryRefreshStore, RedisRefreshStore, RefreshStore}, store::revocation::{InMemoryRevocationStore, RedisRevocationStore, RevocationStore}, }; @@ -51,6 +55,20 @@ pub fn build_revocation_service(store: Arc) -> Arc Result, AppError> { + if config.uses_redis() { + Ok(Arc::new( + RedisRefreshStore::connect(&config.redis.url).await?, + )) + } else { + Ok(Arc::new(InMemoryRefreshStore::default())) + } +} + +pub fn build_refresh_service(config: &Config, store: Arc) -> Arc { + Arc::new(RefreshService::new(store, config.auth.clone())) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/config.rs b/src/config.rs index 71d0bec..128b3c6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,20 @@ pub struct AuthConfig { /// Scopes granted on wallet-tier `POST /v1/auth/session` (paid-scope gating is M5). #[serde(default = "default_wallet_scopes")] pub default_scopes: Vec, + /// Opaque refresh token lifetime (~7 days default). + #[serde(default = "default_refresh_token_ttl_secs")] + pub refresh_token_ttl_secs: u32, + /// When false, session responses omit `refreshToken` (access-only mode). + #[serde(default = "default_issue_refresh_tokens")] + pub issue_refresh_tokens: bool, +} + +fn default_refresh_token_ttl_secs() -> u32 { + 604_800 +} + +fn default_issue_refresh_tokens() -> bool { + true } fn default_wallet_scopes() -> Vec { @@ -113,6 +127,8 @@ mod tests { uri: "http://localhost".into(), chain_id: 1, default_scopes: default_wallet_scopes(), + refresh_token_ttl_secs: default_refresh_token_ttl_secs(), + issue_refresh_tokens: default_issue_refresh_tokens(), }, rate_limit: RateLimitConfig { nonce_per_ip_per_minute: 30, diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 33e3d4f..82f09e2 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -36,6 +36,24 @@ pub struct SessionResponse { #[serde(rename = "walletAddress")] pub wallet_address: String, pub scopes: Vec, + #[serde(rename = "refreshToken", skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, +} + +#[derive(Deserialize)] +pub struct RefreshRequest { + #[serde(rename = "refreshToken")] + pub refresh_token: String, +} + +#[derive(Serialize)] +pub struct RefreshResponse { + #[serde(rename = "accessToken")] + pub access_token: String, + #[serde(rename = "expiresIn")] + pub expires_in: u32, + #[serde(rename = "refreshToken")] + pub refresh_token: String, } pub async fn get_nonce( @@ -78,12 +96,59 @@ pub async fn post_session( state.config.auth.chain_id, )?; + let refresh_token = if state.refresh.enabled() { + Some( + state + .refresh + .issue( + &verified.wallet_address, + &scopes, + state.config.auth.chain_id, + ) + .await? + .token, + ) + } else { + None + }; + Ok(Json(SessionResponse { access_token, token_type: "Bearer", expires_in: state.config.signing.access_token_ttl_secs, wallet_address: verified.wallet_address, scopes, + refresh_token, + })) +} + +/// `POST /v1/auth/session/refresh` — rotate opaque refresh token and mint a new access JWT. +pub async fn post_session_refresh( + State(state): State, + Json(body): Json, +) -> Result, AppError> { + if !state.refresh.enabled() { + return Err(AppError::InvalidRequest( + "refresh tokens are not enabled".into(), + )); + } + let rotated = state.refresh.rotate(&body.refresh_token).await?; + state + .revocation + .ensure_wallet_active(&rotated.record.wallet) + .await?; + + let scope_refs: Vec<&str> = rotated.record.scopes.iter().map(String::as_str).collect(); + let access_token = state.token.mint_wallet_token( + &rotated.record.wallet, + &scope_refs, + rotated.record.chain_id, + )?; + + Ok(Json(RefreshResponse { + access_token, + expires_in: state.config.signing.access_token_ttl_secs, + refresh_token: rotated.token, })) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 14e8024..ca10657 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -25,6 +25,7 @@ fn api_routes(state: AppState) -> Router { Router::new() .route("/v1/auth/nonce", get(auth::get_nonce)) .route("/v1/auth/session", post(auth::post_session)) + .route("/v1/auth/session/refresh", post(auth::post_session_refresh)) .route("/v1/auth/logout", post(auth::post_logout)) .with_state(state) .layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES)) diff --git a/src/services/mod.rs b/src/services/mod.rs index ba04ec1..a8bf03f 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,6 +1,7 @@ //! Business logic (SIWE verification, token minting, quotas). pub mod nonce; +pub mod refresh; pub mod revocation; pub mod siwe; pub mod token; diff --git a/src/services/nonce.rs b/src/services/nonce.rs index 746de1a..689a3e7 100644 --- a/src/services/nonce.rs +++ b/src/services/nonce.rs @@ -77,6 +77,8 @@ mod tests { uri: "http://localhost".into(), chain_id: 324, default_scopes: vec!["ai:invoke".into()], + refresh_token_ttl_secs: 604_800, + issue_refresh_tokens: true, } } diff --git a/src/services/refresh.rs b/src/services/refresh.rs new file mode 100644 index 0000000..b849258 --- /dev/null +++ b/src/services/refresh.rs @@ -0,0 +1,193 @@ +//! Opaque refresh-token issuance, rotation, and reuse detection. + +use std::sync::Arc; + +use base64::Engine; +use rand::RngCore; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + config::AuthConfig, + error::AppError, + store::refresh::{hash_refresh_token, RefreshLookup, RefreshRecord, RefreshStore}, +}; + +pub struct RefreshService { + store: Arc, + auth: AuthConfig, +} + +#[derive(Debug)] +pub struct IssuedRefresh { + pub token: String, +} + +#[derive(Debug)] +pub struct RotatedRefresh { + pub token: String, + pub record: RefreshRecord, +} + +impl RefreshService { + pub fn new(store: Arc, auth: AuthConfig) -> Self { + Self { store, auth } + } + + pub fn enabled(&self) -> bool { + self.auth.issue_refresh_tokens + } + + /// Mint a new refresh token for a wallet session (rotation counter 0). + pub async fn issue( + &self, + wallet: &str, + scopes: &[String], + chain_id: u64, + ) -> Result { + let wallet = normalize_wallet(wallet)?; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let ttl = i64::from(self.auth.refresh_token_ttl_secs); + let record = RefreshRecord { + family_id: Uuid::new_v4().to_string(), + wallet, + rotation: 0, + scopes: scopes.to_vec(), + chain_id, + expires_at: now + ttl, + }; + let token = generate_opaque_token(); + let hash = hash_refresh_token(&token); + self.store + .put_token(&hash, &record, self.auth.refresh_token_ttl_secs as u64) + .await?; + Ok(IssuedRefresh { token }) + } + + /// Validate a refresh token and rotate it (new opaque secret, incremented rotation). + pub async fn rotate(&self, raw_token: &str) -> Result { + let raw_token = raw_token.trim(); + if raw_token.is_empty() { + return Err(AppError::InvalidRequest("refreshToken is required".into())); + } + let hash = hash_refresh_token(raw_token); + let lookup = self.lookup(&hash).await?; + + let record = match lookup { + RefreshLookup::Active(record) => { + if record.expires_at <= OffsetDateTime::now_utc().unix_timestamp() { + return Err(AppError::InvalidToken("refresh token expired".into())); + } + record + } + RefreshLookup::Reuse { family_id } => { + self.store + .revoke_family(&family_id, self.auth.refresh_token_ttl_secs as u64) + .await?; + return Err(AppError::InvalidToken( + "refresh token reuse detected; session family revoked".into(), + )); + } + RefreshLookup::Unknown => { + return Err(AppError::InvalidToken("refresh token invalid".into())); + } + }; + + self.store.delete_token(&hash).await?; + self.store + .mark_replay( + &hash, + &record.family_id, + self.auth.refresh_token_ttl_secs as u64, + ) + .await?; + + let mut next = record.clone(); + next.rotation = next.rotation.saturating_add(1); + next.expires_at = OffsetDateTime::now_utc().unix_timestamp() + + i64::from(self.auth.refresh_token_ttl_secs); + + let new_token = generate_opaque_token(); + let new_hash = hash_refresh_token(&new_token); + self.store + .put_token(&new_hash, &next, self.auth.refresh_token_ttl_secs as u64) + .await?; + + Ok(RotatedRefresh { + token: new_token, + record: next, + }) + } + + async fn lookup(&self, token_hash: &str) -> Result { + if let Some(family_id) = self.store.is_replay(token_hash).await? { + return Ok(RefreshLookup::Reuse { family_id }); + } + if let Some(record) = self.store.get_token(token_hash).await? { + if self.store.is_family_revoked(&record.family_id).await? { + return Ok(RefreshLookup::Unknown); + } + return Ok(RefreshLookup::Active(record)); + } + Ok(RefreshLookup::Unknown) + } +} + +fn generate_opaque_token() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn normalize_wallet(wallet: &str) -> Result { + let w = wallet.trim().to_ascii_lowercase(); + if !w.starts_with("0x") || w.len() != 42 { + return Err(AppError::InvalidRequest( + "wallet address must be 0x-prefixed 20-byte hex".into(), + )); + } + if !w[2..].chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AppError::InvalidRequest( + "wallet address must be hex".into(), + )); + } + Ok(w) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::refresh::InMemoryRefreshStore; + + fn test_auth() -> AuthConfig { + AuthConfig { + nonce_ttl_secs: 120, + domain: "localhost".into(), + uri: "http://localhost:3001".into(), + chain_id: 324, + default_scopes: vec!["ai:invoke".into()], + refresh_token_ttl_secs: 3600, + issue_refresh_tokens: true, + } + } + + #[tokio::test] + async fn rotate_rejects_replay_of_rotated_token() { + let store = Arc::new(InMemoryRefreshStore::default()); + let svc = RefreshService::new(store, test_auth()); + let issued = svc + .issue( + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + &["ai:invoke".into()], + 324, + ) + .await + .unwrap(); + let rotated = svc.rotate(&issued.token).await.unwrap(); + let err = svc.rotate(&issued.token).await.unwrap_err(); + assert!(matches!(err, AppError::InvalidToken(_))); + // Family revoked on reuse — the legitimately rotated token must not work either. + let err = svc.rotate(&rotated.token).await.unwrap_err(); + assert!(matches!(err, AppError::InvalidToken(_))); + } +} diff --git a/src/services/siwe/mod.rs b/src/services/siwe/mod.rs index 7da325b..bc042c4 100644 --- a/src/services/siwe/mod.rs +++ b/src/services/siwe/mod.rs @@ -57,6 +57,8 @@ mod tests { uri: "http://localhost:3001".into(), chain_id: 324, default_scopes: vec!["ai:invoke".into()], + refresh_token_ttl_secs: 604_800, + issue_refresh_tokens: true, } } diff --git a/src/state.rs b/src/state.rs index 7969223..e016f97 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,10 @@ use crate::{ config::Config, error::AppError, middleware::rate_limit::RateLimiter, - services::{nonce::NonceService, revocation::RevocationService, token::TokenService}, + services::{ + nonce::NonceService, refresh::RefreshService, revocation::RevocationService, + token::TokenService, + }, }; /// Shared state for route handlers. @@ -18,6 +21,7 @@ pub struct AppState { pub nonce_rate_limiter: Arc, pub token: Arc, pub revocation: Arc, + pub refresh: Arc, } impl AppState { @@ -28,12 +32,15 @@ impl AppState { let token = bootstrap::build_token_service(&config)?; let revocation_store = bootstrap::build_revocation_store(&config).await?; let revocation = bootstrap::build_revocation_service(revocation_store); + let refresh_store = bootstrap::build_refresh_store(&config).await?; + let refresh = bootstrap::build_refresh_service(&config, refresh_store); Ok(Self { config: Arc::new(config), nonce, nonce_rate_limiter, token, revocation, + refresh, }) } } diff --git a/src/store/mod.rs b/src/store/mod.rs index 2b34f0e..fad0b00 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -1,4 +1,5 @@ //! Redis and in-memory store implementations. pub mod nonce; +pub mod refresh; pub mod revocation; diff --git a/src/store/refresh.rs b/src/store/refresh.rs new file mode 100644 index 0000000..12a7bb4 --- /dev/null +++ b/src/store/refresh.rs @@ -0,0 +1,96 @@ +//! Opaque refresh-token persistence (rotation + replay tombstones). + +mod memory; +mod redis; + +pub use memory::InMemoryRefreshStore; +pub use redis::RedisRefreshStore; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::error::AppError; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RefreshRecord { + pub family_id: String, + pub wallet: String, + pub rotation: u32, + pub scopes: Vec, + pub chain_id: u64, + pub expires_at: i64, +} + +#[derive(Debug, Error)] +pub enum RefreshStoreError { + #[error("store unavailable")] + Unavailable(#[from] ::redis::RedisError), + #[error("{0}")] + Other(String), +} + +impl From for AppError { + fn from(err: RefreshStoreError) -> Self { + Self::Internal(err.to_string()) + } +} + +/// Result of looking up a presented refresh token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshLookup { + Active(RefreshRecord), + /// Rotated-out token presented again — caller must revoke the family. + Reuse { + family_id: String, + }, + Unknown, +} + +#[async_trait] +pub trait RefreshStore: Send + Sync { + /// Persist a new refresh token; `token_hash` is SHA-256 hex of the opaque secret. + async fn put_token( + &self, + token_hash: &str, + record: &RefreshRecord, + ttl_secs: u64, + ) -> Result<(), RefreshStoreError>; + + async fn get_token(&self, token_hash: &str) + -> Result, RefreshStoreError>; + + async fn delete_token(&self, token_hash: &str) -> Result<(), RefreshStoreError>; + + /// Tombstone a rotated-out token so reuse can be detected. + async fn mark_replay( + &self, + token_hash: &str, + family_id: &str, + ttl_secs: u64, + ) -> Result<(), RefreshStoreError>; + + async fn is_replay(&self, token_hash: &str) -> Result, RefreshStoreError>; + + async fn revoke_family(&self, family_id: &str, ttl_secs: u64) -> Result<(), RefreshStoreError>; + + async fn is_family_revoked(&self, family_id: &str) -> Result; +} + +/// SHA-256 hex digest of the opaque refresh secret (never store the raw token). +pub fn hash_refresh_token(token: &str) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(token.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_refresh_token_is_stable_hex() { + let h = hash_refresh_token("opaque-secret"); + assert_eq!(h.len(), 64); + assert_eq!(h, hash_refresh_token("opaque-secret")); + } +} diff --git a/src/store/refresh/memory.rs b/src/store/refresh/memory.rs new file mode 100644 index 0000000..4a85ba4 --- /dev/null +++ b/src/store/refresh/memory.rs @@ -0,0 +1,135 @@ +//! In-memory refresh-token store for tests and dev without Redis. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; + +use super::{RefreshRecord, RefreshStore, RefreshStoreError}; + +struct Timed { + value: T, + expires_at: Instant, +} + +#[derive(Default)] +pub struct InMemoryRefreshStore { + tokens: Mutex>>, + replays: Mutex>>, + families: Mutex>>, +} + +impl InMemoryRefreshStore { + fn retain_tokens(map: &mut HashMap>) { + map.retain(|_, e| e.expires_at > Instant::now()); + } + + fn retain_replays(map: &mut HashMap>) { + map.retain(|_, e| e.expires_at > Instant::now()); + } + + fn retain_families(map: &mut HashMap>) { + map.retain(|_, e| e.expires_at > Instant::now()); + } +} + +#[async_trait] +impl RefreshStore for InMemoryRefreshStore { + async fn put_token( + &self, + token_hash: &str, + record: &RefreshRecord, + ttl_secs: u64, + ) -> Result<(), RefreshStoreError> { + let mut guard = self + .tokens + .lock() + .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?; + Self::retain_tokens(&mut guard); + guard.insert( + token_hash.to_string(), + Timed { + value: record.clone(), + expires_at: Instant::now() + Duration::from_secs(ttl_secs.max(1)), + }, + ); + Ok(()) + } + + async fn get_token( + &self, + token_hash: &str, + ) -> Result, RefreshStoreError> { + let mut guard = self + .tokens + .lock() + .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?; + Self::retain_tokens(&mut guard); + Ok(guard.get(token_hash).map(|e| e.value.clone())) + } + + async fn delete_token(&self, token_hash: &str) -> Result<(), RefreshStoreError> { + let mut guard = self + .tokens + .lock() + .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?; + guard.remove(token_hash); + Ok(()) + } + + async fn mark_replay( + &self, + token_hash: &str, + family_id: &str, + ttl_secs: u64, + ) -> Result<(), RefreshStoreError> { + let mut guard = self + .replays + .lock() + .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?; + Self::retain_replays(&mut guard); + guard.insert( + token_hash.to_string(), + Timed { + value: family_id.to_string(), + expires_at: Instant::now() + Duration::from_secs(ttl_secs.max(1)), + }, + ); + Ok(()) + } + + async fn is_replay(&self, token_hash: &str) -> Result, RefreshStoreError> { + let mut guard = self + .replays + .lock() + .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?; + Self::retain_replays(&mut guard); + Ok(guard.get(token_hash).map(|e| e.value.clone())) + } + + async fn revoke_family(&self, family_id: &str, ttl_secs: u64) -> Result<(), RefreshStoreError> { + let mut guard = self + .families + .lock() + .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?; + Self::retain_families(&mut guard); + guard.insert( + family_id.to_string(), + Timed { + value: (), + expires_at: Instant::now() + Duration::from_secs(ttl_secs.max(1)), + }, + ); + Ok(()) + } + + async fn is_family_revoked(&self, family_id: &str) -> Result { + let mut guard = self + .families + .lock() + .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?; + Self::retain_families(&mut guard); + Ok(guard.contains_key(family_id)) + } +} diff --git a/src/store/refresh/redis.rs b/src/store/refresh/redis.rs new file mode 100644 index 0000000..51c683a --- /dev/null +++ b/src/store/refresh/redis.rs @@ -0,0 +1,107 @@ +//! Redis-backed opaque refresh tokens. + +use async_trait::async_trait; +use redis::AsyncCommands; +use serde_json; + +use super::{RefreshRecord, RefreshStore, RefreshStoreError}; + +const TOKEN_PREFIX: &str = "refresh:tok:"; +const REPLAY_PREFIX: &str = "refresh:replay:"; +const FAMILY_PREFIX: &str = "refresh:family:"; + +#[derive(Clone)] +pub struct RedisRefreshStore { + client: redis::aio::ConnectionManager, +} + +impl RedisRefreshStore { + pub async fn connect(url: &str) -> Result { + let client = redis::Client::open(url)?; + let conn = client.get_connection_manager().await?; + Ok(Self { client: conn }) + } + + fn token_key(hash: &str) -> String { + format!("{TOKEN_PREFIX}{hash}") + } + + fn replay_key(hash: &str) -> String { + format!("{REPLAY_PREFIX}{hash}") + } + + fn family_key(family_id: &str) -> String { + format!("{FAMILY_PREFIX}{family_id}") + } +} + +#[async_trait] +impl RefreshStore for RedisRefreshStore { + async fn put_token( + &self, + token_hash: &str, + record: &RefreshRecord, + ttl_secs: u64, + ) -> Result<(), RefreshStoreError> { + let payload = + serde_json::to_string(record).map_err(|e| RefreshStoreError::Other(e.to_string()))?; + let key = Self::token_key(token_hash); + let mut conn = self.client.clone(); + conn.set_ex::<_, _, ()>(key, payload, ttl_secs.max(1)) + .await?; + Ok(()) + } + + async fn get_token( + &self, + token_hash: &str, + ) -> Result, RefreshStoreError> { + let key = Self::token_key(token_hash); + let mut conn = self.client.clone(); + let raw: Option = conn.get(key).await?; + Ok(raw + .map(|s| serde_json::from_str(&s).map_err(|e| RefreshStoreError::Other(e.to_string()))) + .transpose()?) + } + + async fn delete_token(&self, token_hash: &str) -> Result<(), RefreshStoreError> { + let key = Self::token_key(token_hash); + let mut conn = self.client.clone(); + conn.del::<_, ()>(key).await?; + Ok(()) + } + + async fn mark_replay( + &self, + token_hash: &str, + family_id: &str, + ttl_secs: u64, + ) -> Result<(), RefreshStoreError> { + let key = Self::replay_key(token_hash); + let mut conn = self.client.clone(); + conn.set_ex::<_, _, ()>(key, family_id, ttl_secs.max(1)) + .await?; + Ok(()) + } + + async fn is_replay(&self, token_hash: &str) -> Result, RefreshStoreError> { + let key = Self::replay_key(token_hash); + let mut conn = self.client.clone(); + let raw: Option = conn.get(key).await?; + Ok(raw) + } + + async fn revoke_family(&self, family_id: &str, ttl_secs: u64) -> Result<(), RefreshStoreError> { + let key = Self::family_key(family_id); + let mut conn = self.client.clone(); + conn.set_ex::<_, _, ()>(key, "1", ttl_secs.max(1)).await?; + Ok(()) + } + + async fn is_family_revoked(&self, family_id: &str) -> Result { + let key = Self::family_key(family_id); + let mut conn = self.client.clone(); + let exists: bool = conn.exists(key).await?; + Ok(exists) + } +} diff --git a/tests/refresh.rs b/tests/refresh.rs new file mode 100644 index 0000000..4f1521e --- /dev/null +++ b/tests/refresh.rs @@ -0,0 +1,171 @@ +//! `POST /v1/auth/session/refresh` integration tests. + +mod common; + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use base64::Engine; +use common::{build_siwe_message, sign_siwe_message, TEST_WALLET}; +use ed25519_dalek::SigningKey; +use http_body_util::BodyExt; +use rand::rngs::OsRng; +use serde_json::json; +use tower::ServiceExt; +use trust_relay::{ + config::Config, routes::create_router, services::token::TokenService, state::AppState, +}; + +fn stable_test_config() -> Config { + let mut config = Config::load().expect("config"); + let key = SigningKey::generate(&mut OsRng); + config.signing.key_seed_b64 = base64::engine::general_purpose::STANDARD.encode(key.to_bytes()); + config +} + +async fn open_session(app: &axum::Router) -> (String, String) { + let nonce_resp = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/auth/nonce") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = nonce_resp.into_body().collect().await.unwrap().to_bytes(); + let nonce = serde_json::from_slice::(&body).unwrap()["nonce"] + .as_str() + .unwrap() + .to_string(); + let message = build_siwe_message( + "localhost", + "http://localhost:3001", + 324, + TEST_WALLET, + &nonce, + ); + let signature = sign_siwe_message(&message); + let session_resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/auth/session") + .header("content-type", "application/json") + .body(Body::from( + json!({ "message": message, "signature": signature }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(session_resp.status(), StatusCode::OK); + let body = session_resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + ( + json["refreshToken"].as_str().unwrap().to_string(), + json["accessToken"].as_str().unwrap().to_string(), + ) +} + +#[tokio::test] +async fn session_issues_refresh_token() { + let config = stable_test_config(); + let app = create_router(AppState::build(config).await.unwrap()); + let (refresh, _) = open_session(&app).await; + assert!(!refresh.is_empty()); +} + +#[tokio::test] +async fn refresh_rotates_token_and_mints_access_jwt() { + let config = stable_test_config(); + let signing = config.signing.clone(); + let app = create_router(AppState::build(config).await.unwrap()); + let (refresh, _) = open_session(&app).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/auth/session/refresh") + .header("content-type", "application/json") + .body(Body::from(json!({ "refreshToken": refresh }).to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let new_refresh = json["refreshToken"].as_str().unwrap(); + let access = json["accessToken"].as_str().unwrap(); + assert_ne!(new_refresh, refresh); + assert_eq!(json["expiresIn"], 3600); + + let claims = TokenService::new(signing).unwrap().verify(access).unwrap(); + assert_eq!(claims.sub, TEST_WALLET); +} + +#[tokio::test] +async fn refresh_rejects_replay_of_rotated_token() { + let config = stable_test_config(); + let app = create_router(AppState::build(config).await.unwrap()); + let (refresh, _) = open_session(&app).await; + + let first = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/auth/session/refresh") + .header("content-type", "application/json") + .body(Body::from(json!({ "refreshToken": refresh }).to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::OK); + let first_body = first.into_body().collect().await.unwrap().to_bytes(); + let rotated_refresh = serde_json::from_slice::(&first_body).unwrap() + ["refreshToken"] + .as_str() + .unwrap() + .to_string(); + + let replay = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/auth/session/refresh") + .header("content-type", "application/json") + .body(Body::from(json!({ "refreshToken": refresh }).to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); + let bytes = replay.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["error"], "invalid_token"); + + let family_dead = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/auth/session/refresh") + .header("content-type", "application/json") + .body(Body::from( + json!({ "refreshToken": rotated_refresh }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(family_dead.status(), StatusCode::UNAUTHORIZED); +} diff --git a/tests/refresh_redis.rs b/tests/refresh_redis.rs new file mode 100644 index 0000000..b7bc420 --- /dev/null +++ b/tests/refresh_redis.rs @@ -0,0 +1,52 @@ +//! Redis refresh store integration tests (`APP_REDIS__URL` set in CI). + +use std::time::Duration; + +use trust_relay::store::refresh::{ + hash_refresh_token, RedisRefreshStore, RefreshRecord, RefreshStore, +}; + +fn redis_url() -> String { + std::env::var("APP_REDIS__URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()) +} + +async fn try_connect_redis() -> Option { + if std::env::var("APP_REDIS__URL").is_err() { + return None; + } + let url = redis_url(); + match tokio::time::timeout(Duration::from_millis(800), RedisRefreshStore::connect(&url)).await { + Ok(Ok(store)) => Some(store), + _ => None, + } +} + +#[tokio::test] +async fn redis_refresh_token_rotation_and_replay_tombstone() { + let Some(store) = try_connect_redis().await else { + return; + }; + let record = RefreshRecord { + family_id: uuid::Uuid::new_v4().to_string(), + wallet: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".into(), + rotation: 0, + scopes: vec!["ai:invoke".into()], + chain_id: 324, + expires_at: time::OffsetDateTime::now_utc().unix_timestamp() + 3600, + }; + let token = "test-opaque-refresh-token"; + let hash = hash_refresh_token(token); + store.put_token(&hash, &record, 60).await.unwrap(); + assert!(store.get_token(&hash).await.unwrap().is_some()); + + store.delete_token(&hash).await.unwrap(); + store + .mark_replay(&hash, &record.family_id, 60) + .await + .unwrap(); + assert!(store.get_token(&hash).await.unwrap().is_none()); + assert_eq!( + store.is_replay(&hash).await.unwrap().as_deref(), + Some(record.family_id.as_str()) + ); +} diff --git a/tests/session.rs b/tests/session.rs index 165e816..363a84d 100644 --- a/tests/session.rs +++ b/tests/session.rs @@ -77,6 +77,7 @@ async fn session_mints_wallet_tier_jwt() { assert_eq!(json["walletAddress"], TEST_WALLET); assert_eq!(json["expiresIn"], 3600); assert!(!json["scopes"].as_array().unwrap().is_empty()); + assert!(json["refreshToken"].as_str().is_some()); let token = json["accessToken"].as_str().unwrap(); let token_svc = TokenService::new(signing).unwrap();