fix(backend): refresh-token jti + code cleanup (#15,#28,#29,#30)

Refresh-token rotation bug (found via Solaria smoke test):
* RefreshClaims now carries a unique random jti (UUID v4). Without it, two
  refresh tokens issued in the same second (e.g. on rotation) were byte-identical
  — breaking rotation, colliding on the tokenHash unique index, and making a
  stolen old token indistinguishable from the new one. Every refresh token is
  now unique regardless of issue time. Added a unit test asserting consecutive
  tokens differ.

Code cleanup (review items):
* #15: removed unused deps tower_governor, slog, thiserror (zero refs in src/).
* #30: deleted leftover src/main.rs.restore (a stale git-error-message backup).
* #28: removed the 9 single-line-comment stub files under src/db/ (appointment,
  family, health_data, lab_result, medication, profile, permission, share, user)
  and their mod declarations; nothing referenced them, real logic lives in
  mongodb_impl.rs/init.rs.
* #29: stripped 61 broad module-level #![allow(...)] suppressions across src/.
  Fixed the surfaced warnings instead: 4 unused 'claims' extractor bindings ->
  _claims; removed dead OpenFDAService.client/base_url fields + the never-called
  query_drug_events method + unused HashMap/reqwest imports; applied clippy's
  mechanical fixes (Ok(?) -> ?, Copy ObjectId clone, redundant closures,
  useless conversions); rewrote 'if let Ok(_) = x' -> 'if x.is_ok()'. Result:
  cargo build + clippy --all-targets are warning-free with no blanket allows.

Verified: fmt clean, build clean, clippy 0 warnings, 19 unit tests pass
(was 18; +1 jti-uniqueness test).
This commit is contained in:
goose 2026-06-27 20:57:24 -03:00
parent e9f524671f
commit cf6fcd4ef2
45 changed files with 54 additions and 297 deletions

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
use anyhow::Result;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
@ -41,12 +40,17 @@ pub struct RefreshClaims {
pub sub: String,
pub exp: usize,
pub iat: usize,
/// Unique per-token id. Without this, two refresh tokens for the same user
/// issued in the same second (e.g. on rotation) would be byte-identical,
/// breaking rotation/reuse-detection. The jti makes every token unique.
pub jti: String,
pub user_id: String,
pub token_version: i32,
}
impl RefreshClaims {
/// Build refresh-token claims. `expiry` is taken from `JwtConfig` by callers.
/// A fresh random `jti` is generated so each token is unique.
pub fn new(user_id: String, token_version: i32, expiry: Duration) -> Self {
let now = Utc::now();
let exp = now + expiry;
@ -55,6 +59,7 @@ impl RefreshClaims {
sub: user_id.clone(),
exp: exp.timestamp() as usize,
iat: now.timestamp() as usize,
jti: uuid::Uuid::new_v4().to_string(),
user_id,
token_version,
}
@ -175,4 +180,30 @@ mod tests {
let refresh_claims = svc.validate_refresh_token(&refresh).unwrap();
assert_eq!(refresh_claims.token_version, 3);
}
/// Two refresh tokens issued back-to-back for the same user must be distinct,
/// even though everything else (sub, user_id, token_version, iat, exp) is
/// identical. The jti claim guarantees this — without it, rotation would
/// mint a byte-identical token and break reuse detection.
#[test]
fn refresh_tokens_are_unique_even_in_same_second() {
let svc = JwtService::new(test_config());
let claims = Claims::new(
"user-3".to_string(),
"u3@example.com".to_string(),
0,
Duration::minutes(15),
);
let (_, refresh_a) = svc.generate_tokens(claims.clone()).unwrap();
let (_, refresh_b) = svc.generate_tokens(claims).unwrap();
assert_ne!(
refresh_a, refresh_b,
"consecutive refresh tokens must differ (jti)"
);
// And the jti values themselves differ.
let jti_a = svc.validate_refresh_token(&refresh_a).unwrap().jti;
let jti_b = svc.validate_refresh_token(&refresh_b).unwrap().jti;
assert_ne!(jti_a, jti_b);
}
}

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
pub mod jwt;
pub mod password;
pub mod token_version_cache;

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use anyhow::Result;
use std::sync::Arc;

View file

@ -1 +0,0 @@
// Stub for future appointment operations

View file

@ -1 +0,0 @@
// Stub for future family operations

View file

@ -1 +0,0 @@
// Stub for future health_data operations

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
use mongodb::{bson::doc, options::IndexOptions, Collection, IndexModel};
use anyhow::Result;

View file

@ -1 +0,0 @@
// Stub for future lab_result operations

View file

@ -1 +0,0 @@
// Stub for future medication operations

View file

@ -1,19 +1,7 @@
#![allow(dead_code)]
#![allow(clippy::useless_conversion)]
use anyhow::Result;
use mongodb::{Client, Database};
use std::env;
pub mod appointment;
pub mod family;
pub mod health_data;
pub mod lab_result;
pub mod medication;
pub mod permission;
pub mod profile;
pub mod share;
pub mod user;
pub mod init; // Database initialization module
mod mongodb_impl;

View file

@ -1,4 +1,3 @@
#![allow(clippy::needless_question_mark)]
use anyhow::Result;
use mongodb::bson::oid::ObjectId;
use mongodb::{bson::doc, options::ClientOptions, Client, Collection, Database};
@ -286,10 +285,9 @@ impl MongoDb {
pub async fn get_medication(&self, id: &str) -> Result<Option<Medication>> {
let object_id = ObjectId::parse_str(id)?;
let repo = MedicationRepository::new(self.medications.clone());
Ok(repo
.find_by_id(&object_id)
repo.find_by_id(&object_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to get medication: {}", e))?)
.map_err(|e| anyhow::anyhow!("Failed to get medication: {}", e))
}
pub async fn list_medications(
@ -318,19 +316,17 @@ impl MongoDb {
) -> Result<Option<Medication>> {
let object_id = ObjectId::parse_str(id)?;
let repo = MedicationRepository::new(self.medications.clone());
Ok(repo
.update(&object_id, updates)
repo.update(&object_id, updates)
.await
.map_err(|e| anyhow::anyhow!("Failed to update medication: {}", e))?)
.map_err(|e| anyhow::anyhow!("Failed to update medication: {}", e))
}
pub async fn delete_medication(&self, id: &str) -> Result<bool> {
let object_id = ObjectId::parse_str(id)?;
let repo = MedicationRepository::new(self.medications.clone());
Ok(repo
.delete(&object_id)
repo.delete(&object_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to delete medication: {}", e))?)
.map_err(|e| anyhow::anyhow!("Failed to delete medication: {}", e))
}
pub async fn log_medication_dose(&self, dose: &MedicationDose) -> Result<Option<ObjectId>> {
@ -349,9 +345,8 @@ impl MongoDb {
days: i64,
) -> Result<crate::models::medication::AdherenceStats> {
let repo = MedicationRepository::new(self.medications.clone());
Ok(repo
.calculate_adherence(medication_id, days)
repo.calculate_adherence(medication_id, days)
.await
.map_err(|e| anyhow::anyhow!("Failed to calculate adherence: {}", e))?)
.map_err(|e| anyhow::anyhow!("Failed to calculate adherence: {}", e))
}
}

View file

@ -1,2 +0,0 @@
// Permission-related database operations are in MongoDb struct
// This file exists for module organization

View file

@ -1 +0,0 @@
// Stub for future profile operations

View file

@ -1,2 +0,0 @@
// Share-related database operations are in MongoDb struct
// This file exists for module organization

View file

@ -1,2 +0,0 @@
// User-related database operations are in MongoDb struct
// This file exists for module organization

View file

@ -1,12 +1,9 @@
#![allow(dead_code)]
#![allow(unused_imports)]
#![allow(clippy::redundant_pattern_matching)]
use crate::config::AppState;
use axum::{extract::State, response::Json};
use serde_json::{json, Value};
pub async fn health_check(State(state): State<AppState>) -> Json<Value> {
let status = if let Ok(_) = state.db.health_check().await {
let status = if state.db.health_check().await.is_ok() {
"connected"
} else {
"error"

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
use crate::auth::jwt::Claims;
use crate::config::AppState;
use crate::models::health_stats::HealthStatistic;

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Drug Interaction Handlers (Phase 2.8)
use axum::{

View file

@ -1,6 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
#![allow(unused_variables)]
use axum::{
extract::{Extension, Json, Path, Query, State},
http::StatusCode,
@ -103,7 +100,7 @@ pub async fn list_medications(
pub async fn get_medication(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<Json<Medication>, StatusCode> {
let database = state.db.get_database();
@ -121,7 +118,7 @@ pub async fn get_medication(
pub async fn update_medication(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
Json(req): Json<UpdateMedicationRequest>,
) -> Result<Json<Medication>, StatusCode> {
@ -140,7 +137,7 @@ pub async fn update_medication(
pub async fn delete_medication(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
let database = state.db.get_database();
@ -188,7 +185,7 @@ pub async fn log_dose(
pub async fn get_adherence(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<Json<crate::models::medication::AdherenceStats>, StatusCode> {
let database = state.db.get_database();

View file

@ -1,8 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
#![allow(clippy::redundant_closure)]
#![allow(clippy::useless_conversion)]
#![allow(clippy::clone_on_copy)]
use axum::{
extract::{Path, State},
http::StatusCode,
@ -171,7 +166,7 @@ pub async fn create_share(
});
let share = Share::new(
owner_id.clone(),
owner_id,
target_user_id,
req.resource_type,
resource_id,

View file

@ -1,2 +0,0 @@
fatal: path 'backend/src/main.rs' exists, but not 'src/main.rs'
hint: Did you mean 'a316699:backend/src/main.rs' aka 'a316699:./src/main.rs'?

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use crate::auth::jwt::Claims;
use crate::config::AppState;
use axum::{

View file

@ -8,7 +8,6 @@
//! 3. The peer address from Axum's `ConnectInfo<SocketAddr>` (direct connection).
//! 4. `"0.0.0.0"` only if none of the above are present.
#![allow(dead_code)]
use axum::{
extract::ConnectInfo, extract::Request, http::HeaderMap, middleware::Next, response::Response,
};

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
/// Middleware for general rate limiting

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use mongodb::bson::{oid::ObjectId, DateTime};
use super::health_data::EncryptedField;

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use anyhow::Result;
use futures::stream::TryStreamExt;
use mongodb::{

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use mongodb::{
bson::{doc, oid::ObjectId, DateTime},
Collection,

View file

@ -1,6 +1,3 @@
#![allow(dead_code)]
#![allow(clippy::useless_conversion)]
#![allow(unused_imports)]
use mongodb::bson::{oid::ObjectId, DateTime};
use serde::{Deserialize, Serialize};

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
use futures::stream::TryStreamExt;
use mongodb::Collection;
use mongodb::{

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Interaction Models
//!
//! Database models for drug interactions

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use futures::stream::TryStreamExt;
use mongodb::{bson::oid::ObjectId, Collection};
use serde::{Deserialize, Serialize};

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use super::health_data::EncryptedField;
use futures::stream::StreamExt;
use mongodb::bson::{doc, oid::ObjectId, DateTime};

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::fmt;

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use mongodb::{
bson::{doc, oid::ObjectId, DateTime},
Collection,

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
use anyhow::Result;
use base64::Engine;
use mongodb::{

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use anyhow::Result;
use futures::stream::TryStreamExt;
use mongodb::{

View file

@ -1,7 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
#![allow(clippy::redundant_closure)]
#![allow(clippy::useless_conversion)]
use mongodb::bson::DateTime;
use mongodb::bson::{doc, oid::ObjectId};
use mongodb::Collection;
@ -91,7 +87,6 @@ impl ShareRepository {
.await?
.try_collect()
.await
.map_err(|e| mongodb::error::Error::from(e))
}
pub async fn find_by_target(
@ -108,7 +103,6 @@ impl ShareRepository {
.await?
.try_collect()
.await
.map_err(|e| mongodb::error::Error::from(e))
}
pub async fn update(&self, share: &Share) -> mongodb::error::Result<()> {

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use mongodb::bson::{doc, oid::ObjectId};
use mongodb::Collection;
use serde::{Deserialize, Serialize};

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use crate::models::audit_log::{AuditEventType, AuditLog, AuditLogRepository};
use anyhow::Result;
use mongodb::bson::oid::ObjectId;

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use crate::models::session::{DeviceInfo, Session, SessionRepository};
use anyhow::Result;
use mongodb::bson::oid::ObjectId;

View file

@ -1,5 +1,3 @@
#![allow(dead_code)]
#![allow(unused_imports)]
//! Ingredient Mapper Service
//!
//! Maps EU drug names to US drug names for interaction checking

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Interaction Service
//!
//! Combines ingredient mapping and OpenFDA interaction checking

View file

@ -1,8 +1,10 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// NOTE: the real OpenFDA API query (reqwest -> api.fda.gov) was a dead-code
// stub (`query_drug_events`, never called) and has been removed for now.
// `check_interactions` currently uses a built-in table of known interactions.
// When the live OpenFDA integration is wired up, re-add reqwest + the query
// method here.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
@ -21,17 +23,11 @@ pub struct DrugInteraction {
pub description: String,
}
pub struct OpenFDAService {
client: Client,
base_url: String,
}
pub struct OpenFDAService;
impl OpenFDAService {
pub fn new() -> Self {
Self {
client: Client::new(),
base_url: "https://api.fda.gov/drug/event.json".to_string(),
}
Self
}
/// Check for interactions between multiple drugs
@ -110,27 +106,6 @@ impl OpenFDAService {
None
}
/// Query OpenFDA for drug event reports
async fn query_drug_events(
&self,
drug_name: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let query = format!(
"{}?search=patient.drug.medicinalproduct:{}&limit=10",
self.base_url, drug_name
);
let response = self
.client
.get(&query)
.send()
.await?
.json::<serde_json::Value>()
.await?;
Ok(response)
}
}
impl Default for OpenFDAService {