Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 2 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};

Expand Down Expand Up @@ -51,6 +55,20 @@ pub fn build_revocation_service(store: Arc<dyn RevocationStore>) -> Arc<Revocati
Arc::new(RevocationService::new(store))
}

pub async fn build_refresh_store(config: &Config) -> Result<Arc<dyn RefreshStore>, 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<dyn RefreshStore>) -> Arc<RefreshService> {
Arc::new(RefreshService::new(store, config.auth.clone()))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
16 changes: 16 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String> {
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions src/routes/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ pub struct SessionResponse {
#[serde(rename = "walletAddress")]
pub wallet_address: String,
pub scopes: Vec<String>,
#[serde(rename = "refreshToken", skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
}

#[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(
Expand Down Expand Up @@ -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<AppState>,
Json(body): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, 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,
}))
}

Expand Down
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/services/nonce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
193 changes: 193 additions & 0 deletions src/services/refresh.rs
Original file line number Diff line number Diff line change
@@ -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<dyn RefreshStore>,
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<dyn RefreshStore>, 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<IssuedRefresh, AppError> {
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<RotatedRefresh, AppError> {
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<RefreshLookup, AppError> {
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<String, AppError> {
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(_)));
}
}
Loading
Loading