diff --git a/thoughts/research/2026-02-14-jwt-authentication-decision.md b/thoughts/research/2026-02-14-jwt-authentication-decision.md new file mode 100644 index 0000000..bf3f59b --- /dev/null +++ b/thoughts/research/2026-02-14-jwt-authentication-decision.md @@ -0,0 +1,174 @@ +# JWT Authentication Decision Summary + +**Date**: 2026-02-14 +**Decision**: **JWT with Refresh Tokens + Recovery Phrases** + +--- + +## Authentication Strategy + +### Primary: JWT (JSON Web Tokens) + +**Why JWT?** +- Stateless design scales to 1000+ concurrent connections +- Works perfectly with mobile apps (AsyncStorage) +- No server-side session storage needed +- Easy to scale Axum horizontally + +### Token Types + +**Access Token** (15 minutes) +- Used for API requests +- Short-lived for security +- Contains: user_id, email, family_id, permissions + +**Refresh Token** (30 days) +- Used to get new access tokens +- Long-lived for convenience +- Stored in MongoDB for revocation +- Rotated on every refresh + +--- + +## Token Revocation Strategies + +### 1. Refresh Token Blacklist (Recommended) ⭐ +- Store refresh tokens in MongoDB +- Mark as revoked on logout +- Check on every refresh + +### 2. Token Versioning +- Include version in JWT claims +- Increment on password change +- Invalidate all tokens when version changes + +### 3. Access Token Blacklist (Optional) +- Store revoked access tokens in Redis +- For immediate revocation +- Auto-expires with TTL + +--- + +## Refresh Token Pattern + +### Token Rotation (Security Best Practice) ⭐ + +**Flow**: +1. Client sends refresh_token +2. Server verifies refresh_token (not revoked, not expired) +3. Server generates new access_token +4. Server generates new refresh_token +5. Server revokes old refresh_token +6. Server returns new tokens + +**Why?** Prevents reuse of stolen refresh tokens + +--- + +## Zero-Knowledge Password Recovery + +### Recovery Phrases (from encryption.md) + +**Registration**: +1. Client generates recovery phrase (random 32 bytes) +2. Client encrypts recovery phrase with password +3. Client sends: email, password hash, encrypted recovery phrase +4. Server stores: email, password hash, encrypted recovery phrase + +**Password Recovery**: +1. User requests recovery (enters email) +2. Server returns: encrypted recovery phrase +3. Client decrypts with recovery key (user enters manually) +4. User enters new password +5. Client re-encrypts recovery phrase with new password +6. Client sends: new password hash, re-encrypted recovery phrase +7. Server updates: password hash, encrypted recovery phrase, token_version + 1 +8. All existing tokens invalidated (version mismatch) + +--- + +## Family Member Access Control + +### Permissions in JWT + +```typescript +// JWT permissions based on family role +{ + "parent": [ + "read:own_data", + "write:own_data", + "read:family_data", + "write:family_data", + "manage:family_members", + "delete:data" + ], + "child": [ + "read:own_data", + "write:own_data" + ], + "elderly": [ + "read:own_data", + "write:own_data", + "read:family_data" + ] +} +``` + +### Permission Middleware + +- Check permissions on protected routes +- Return 403 Forbidden if insufficient permissions +- Works with JWT claims + +--- + +## Technology Stack + +### Backend (Axum) +- jsonwebtoken 9.x (JWT crate) +- bcrypt 0.15 (password hashing) +- mongodb 3.0 (refresh token storage) +- redis (optional, for access token blacklist) + +### Client (React Native + React) +- AsyncStorage (token storage) +- axios (API client with JWT interceptor) +- PBKDF2 (password derivation) +- AES-256-GCM (data encryption) + +--- + +## Implementation Timeline + +- **Week 1**: Basic JWT (login, register, middleware) +- **Week 1-2**: Refresh tokens (storage, rotation) +- **Week 2**: Token revocation (blacklist, versioning) +- **Week 2-3**: Password recovery (recovery phrases) +- **Week 3**: Family access control (permissions) +- **Week 3-4**: Security hardening (rate limiting, HTTPS) + +**Total**: 3-4 weeks + +--- + +## Next Steps + +1. Implement basic JWT service in Axum +2. Create MongoDB schema for users and refresh tokens +3. Implement login/register/refresh/logout handlers +4. Create JWT middleware for protected routes +5. Implement token revocation (blacklist + versioning) +6. Integrate password recovery (from encryption.md) +7. Implement family access control (permissions) +8. Test entire authentication flow +9. Create client-side authentication (React Native + React) + +--- + +## References + +- [Comprehensive JWT Research](./2026-02-14-jwt-authentication-research.md) +- [Normogen Encryption Guide](../encryption.md) +- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519) +- [Axum JWT Guide](https://docs.rs/axum/latest/axum/) +- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) diff --git a/thoughts/research/2026-02-14-jwt-authentication-research.md b/thoughts/research/2026-02-14-jwt-authentication-research.md new file mode 100644 index 0000000..c3a606c --- /dev/null +++ b/thoughts/research/2026-02-14-jwt-authentication-research.md @@ -0,0 +1,1340 @@ +# JWT Authentication Research for Normogen + +**Date**: 2026-02-14 +**Focus**: JWT implementation for Axum with zero-knowledge password recovery +**Platform**: Rust (Axum) + TypeScript (React Native + React) + +--- + +## Table of Contents + +1. [JWT Architecture Overview](#jwt-architecture-overview) +2. [JWT Implementation in Axum](#jwt-implementation-in-axum) +3. [Token Revocation Strategies](#token-revocation-strategies) +4. [Refresh Token Pattern](#refresh-token-pattern) +5. [Zero-Knowledge Password Recovery Integration](#zero-knowledge-password-recovery-integration) +6. [Family Member Access Control](#family-member-access-control) +7. [Security Best Practices](#security-best-practices) +8. [Implementation Timeline](#implementation-timeline) + +--- + +## JWT Architecture Overview + +### Authentication Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Registration │ +└─────────────────────────────────────────────────────────────┘ + +1. User registers with email + password +2. Client derives encryption key from password (PBKDF2) +3. Client generates recovery phrase (random 32 bytes) +4. Client encrypts recovery phrase with password +5. Client sends: email, password hash, encrypted recovery phrase +6. Server stores: email, password hash (for auth), encrypted recovery phrase +7. Server returns: userId, JWT access token, JWT refresh token +8. Client stores: JWT tokens (AsyncStorage), encryption key (Keychain/Keystore) + +--- + +┌─────────────────────────────────────────────────────────────┐ +│ Login │ +└─────────────────────────────────────────────────────────────┘ + +1. User enters email + password +2. Client derives encryption key from password (PBKDF2) +3. Client sends: email, password hash (not plaintext password!) +4. Server verifies password hash +5. Server generates JWT access token (15 min expiry) +6. Server generates JWT refresh token (30 days expiry) +7. Server returns: userId, JWT access token, JWT refresh token +8. Client stores: JWT tokens (AsyncStorage), encryption key (Keychain/Keystore) + +--- + +┌─────────────────────────────────────────────────────────────┐ +│ Password Recovery │ +└─────────────────────────────────────────────────────────────┘ + +1. User requests password recovery (enters email) +2. Server finds user by email +3. Server returns: encrypted recovery phrase (not plaintext!) +4. Client decrypts recovery phrase with recovery key (user enters manually) +5. Client derives old encryption key from recovery phrase +6. Client decrypts data with old encryption key +7. User enters new password +8. Client derives new encryption key from new password +9. Client re-encrypts recovery phrase with new password +10. Client sends: new password hash, re-encrypted recovery phrase +11. Server updates: password hash, encrypted recovery phrase +12. Server returns: new JWT tokens +13. Client re-encrypts all data with new encryption key +14. Client syncs re-encrypted data to server + +--- + +┌─────────────────────────────────────────────────────────────┐ +│ Token Refresh │ +└─────────────────────────────────────────────────────────────┘ + +1. Access token expires (after 15 minutes) +2. Client sends refresh token to /api/auth/refresh +3. Server validates refresh token (not in blacklist) +4. Server generates new access token (15 min expiry) +5. Server optionally generates new refresh token (rotation) +6. Server returns: new access token, new refresh token +7. Client stores new tokens +``` + +### JWT Payload Structure + +```typescript +// Access Token Payload (15 min expiry) +interface AccessTokenPayload { + sub: string; // User ID + email: string; // User email + familyId: string; // Family ID + permissions: string[]; // User permissions + tokenType: 'access'; // Token type identifier + iat: number; // Issued at + exp: number; // Expiration time + jti: string; // JWT ID (for revocation) +} + +// Refresh Token Payload (30 days expiry) +interface RefreshTokenPayload { + sub: string; // User ID + tokenType: 'refresh'; // Token type identifier + iat: number; // Issued at + exp: number; // Expiration time + jti: string; // JWT ID (for revocation) +} +``` + +--- + +## JWT Implementation in Axum + +### Dependencies + +```toml +# Cargo.toml +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["full"] } +jsonwebtoken = "9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +mongodb = "3.0" +bcrypt = "0.15" +uuid = { version = "1", features = ["v4", "serde"] } +``` + +### JWT Configuration + +```rust +// src/config/jwt.rs +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwtConfig { + pub access_token_expiry: i64, // 15 minutes + pub refresh_token_expiry: i64, // 30 days + pub secret: String, // From environment variable +} + +impl JwtConfig { + pub fn from_env() -> Result> { + Ok(Self { + access_token_expiry: 15 * 60, // 15 minutes in seconds + refresh_token_expiry: 30 * 24 * 60 * 60, // 30 days in seconds + secret: std::env::var("JWT_SECRET")?, + }) + } + + pub fn access_token_expiry_duration(&self) -> Duration { + Duration::seconds(self.access_token_expiry) + } + + pub fn refresh_token_expiry_duration(&self) -> Duration { + Duration::seconds(self.refresh_token_expiry) + } +} +``` + +### JWT Claims + +```rust +// src/auth/claims.rs +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccessClaims { + pub sub: String, // User ID + pub email: String, + pub family_id: Option, + pub permissions: Vec, + pub token_type: String, // "access" + pub iat: i64, // Issued at + pub exp: i64, // Expiration time + pub jti: String, // JWT ID (for revocation) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshClaims { + pub sub: String, // User ID + pub token_type: String, // "refresh" + pub iat: i64, // Issued at + pub exp: i64, // Expiration time + pub jti: String, // JWT ID (for revocation) +} +``` + +### JWT Service + +```rust +// src/auth/jwt_service.rs +use crate::auth::claims::{AccessClaims, RefreshClaims}; +use crate::config::JwtConfig; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use uuid::Uuid; + +pub struct JwtService { + config: JwtConfig, + encoding_key: EncodingKey, + decoding_key: DecodingKey, +} + +impl JwtService { + pub fn new(config: JwtConfig) -> Self { + let encoding_key = EncodingKey::from_secret(config.secret.as_ref()); + let decoding_key = DecodingKey::from_secret(config.secret.as_ref()); + + Self { + config, + encoding_key, + decoding_key, + } + } + + // Generate access token + pub fn generate_access_token( + &self, + user_id: &str, + email: &str, + family_id: Option<&str>, + permissions: Vec, + ) -> Result> { + let now = Utc::now(); + let expiry = now + self.config.access_token_expiry_duration(); + let jti = Uuid::new_v4().to_string(); + + let claims = AccessClaims { + sub: user_id.to_string(), + email: email.to_string(), + family_id: family_id.map(|s| s.to_string()), + permissions, + token_type: "access".to_string(), + iat: now.timestamp(), + exp: expiry.timestamp(), + jti, + }; + + let token = encode(&Header::default(), &claims, &self.encoding_key)?; + Ok(token) + } + + // Generate refresh token + pub fn generate_refresh_token(&self, user_id: &str) -> Result> { + let now = Utc::now(); + let expiry = now + self.config.refresh_token_expiry_duration(); + let jti = Uuid::new_v4().to_string(); + + let claims = RefreshClaims { + sub: user_id.to_string(), + token_type: "refresh".to_string(), + iat: now.timestamp(), + exp: expiry.timestamp(), + jti, + }; + + let token = encode(&Header::default(), &claims, &self.encoding_key)?; + Ok(token) + } + + // Verify access token + pub fn verify_access_token(&self, token: &str) -> Result> { + let token_data = decode::( + token, + &self.decoding_key, + &Validation::default(), + )?; + + // Verify token type + if token_data.claims.token_type != "access" { + return Err("Invalid token type".into()); + } + + Ok(token_data.claims) + } + + // Verify refresh token + pub fn verify_refresh_token(&self, token: &str) -> Result> { + let token_data = decode::( + token, + &self.decoding_key, + &Validation::default(), + )?; + + // Verify token type + if token_data.claims.token_type != "refresh" { + return Err("Invalid token type".into()); + } + + Ok(token_data.claims) + } +} +``` + +### Authentication Middleware + +```rust +// src/auth/middleware.rs +use crate::auth::jwt_service::JwtService; +use axum::{ + extract::Request, + http::HeaderMap, + middleware::Next, + response::Response, + Json, +}; +use http::StatusCode; + +pub struct AuthExtractor { + pub user_id: String, + pub email: String, + pub family_id: Option, + pub permissions: Vec, +} + +pub async fn auth_middleware( + headers: HeaderMap, + jwt_service: axum::extract::State, + mut request: Request, + next: Next, +) -> Result { + // Extract Authorization header + let auth_header = headers + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + // Verify Bearer token format + if !auth_header.starts_with("Bearer ") { + return Err(StatusCode::UNAUTHORIZED); + } + + let token = &auth_header[7..]; + + // Verify JWT + let claims = jwt_service + .verify_access_token(token) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + // Add claims to request extensions + request.extensions_mut().insert(claims); + + // Continue to handler + Ok(next.run(request).await) +} + +// Extractor for use in handlers +pub fn extract_auth( + claims: Option>, +) -> Result { + let claims = claims.ok_or(StatusCode::UNAUTHORIZED)?; + + Ok(AuthExtractor { + user_id: claims.sub, + email: claims.email, + family_id: claims.family_id, + permissions: claims.permissions, + }) +} +``` + +### Authentication Handlers + +```rust +// src/handlers/auth.rs +use crate::auth::jwt_service::JwtService; +use crate::auth::middleware::extract_auth; +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password_hash: String, // Client-side hash (PBKDF2) +} + +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub email: String, + pub password_hash: String, // Client-side hash (PBKDF2) + pub encrypted_recovery_phrase: String, // Encrypted with user's password +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub user_id: String, + pub email: String, + pub family_id: Option, + pub access_token: String, + pub refresh_token: String, + pub permissions: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RefreshResponse { + pub access_token: String, + pub refresh_token: String, +} + +// Login handler +pub async fn login( + State(jwt_service): State, + State(mongo_client): State, + Json(payload): Json, +) -> Result, StatusCode> { + // Find user by email + let db = mongo_client.database("normogen"); + let collection = db.collection::("users"); + + let filter = mongodb::bson::doc! { "email": &payload.email }; + let user = collection + .find_one(filter, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + // Verify password hash + let stored_hash = user + .get_str("password_hash") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if stored_hash != payload.password_hash { + return Err(StatusCode::UNAUTHORIZED); + } + + // Get user data + let user_id = user + .get_str("user_id") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let email = user + .get_str("email") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let family_id = user.get_str("family_id").ok(); + let permissions = user + .get_array("permissions") + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + // Generate JWT tokens + let access_token = jwt_service + .generate_access_token(user_id, email, family_id, permissions.clone()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let refresh_token = jwt_service + .generate_refresh_token(user_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Store refresh token in database (for revocation) + // TODO: Implement refresh token storage + + Ok(Json(AuthResponse { + user_id: user_id.to_string(), + email: email.to_string(), + family_id: family_id.map(String::from), + access_token, + refresh_token, + permissions, + })) +} + +// Register handler +pub async fn register( + State(jwt_service): State, + State(mongo_client): State, + Json(payload): Json, +) -> Result, StatusCode> { + // Check if user exists + let db = mongo_client.database("normogen"); + let collection = db.collection::("users"); + + let filter = mongodb::bson::doc! { "email": &payload.email }; + if collection + .find_one(filter, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .is_some() + { + return Err(StatusCode::CONFLICT); + } + + // Create new user + let user_id = Uuid::new_v4().to_string(); + let permissions = vec!["read:own_data".to_string(), "write:own_data".to_string()]; + + let doc = mongodb::bson::doc! { + "user_id": &user_id, + "email": &payload.email, + "password_hash": &payload.password_hash, + "encrypted_recovery_phrase": &payload.encrypted_recovery_phrase, + "family_id": null, + "permissions": &permissions, + "created_at": chrono::Utc::now(), + }; + + collection + .insert_one(doc, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Generate JWT tokens + let access_token = jwt_service + .generate_access_token(&user_id, &payload.email, None, permissions.clone()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let refresh_token = jwt_service + .generate_refresh_token(&user_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Store refresh token in database (for revocation) + // TODO: Implement refresh token storage + + Ok(Json(AuthResponse { + user_id, + email: payload.email, + family_id: None, + access_token, + refresh_token, + permissions, + })) +} + +// Refresh token handler +pub async fn refresh_token( + State(jwt_service): State, + State(mongo_client): State, + Json(payload): Json, +) -> Result, StatusCode> { + // Verify refresh token + let claims = jwt_service + .verify_refresh_token(&payload.refresh_token) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + // Check if refresh token is revoked + let db = mongo_client.database("normogen"); + let collection = db.collection::("refresh_tokens"); + + let filter = mongodb::bson::doc! { + "jti": &claims.jti, + "revoked": false + }; + + let refresh_token_doc = collection + .find_one(filter, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + // Get user data + let user_id = refresh_token_doc + .get_str("user_id") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Find user + let users_collection = db.collection::("users"); + let user_filter = mongodb::bson::doc! { "user_id": user_id }; + let user = users_collection + .find_one(user_filter, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + let email = user + .get_str("email") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let family_id = user.get_str("family_id").ok(); + let permissions = user + .get_array("permissions") + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + // Generate new tokens + let new_access_token = jwt_service + .generate_access_token(user_id, email, family_id, permissions) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let new_refresh_token = jwt_service + .generate_refresh_token(user_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Revoke old refresh token (token rotation) + let update_filter = mongodb::bson::doc! { "jti": &claims.jti }; + let update = mongodb::bson::doc! { + "$set": { + "revoked": true, + "revoked_at": chrono::Utc::now() + } + }; + collection + .update_one(update_filter, update, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Store new refresh token + let new_claims = jwt_service + .verify_refresh_token(&new_refresh_token) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let new_doc = mongodb::bson::doc! { + "jti": &new_claims.jti, + "user_id": user_id, + "created_at": chrono::Utc::now(), + "expires_at": chrono::Utc::now() + chrono::Duration::days(30), + "revoked": false + }; + + collection + .insert_one(new_doc, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(RefreshResponse { + access_token: new_access_token, + refresh_token: new_refresh_token, + })) +} + +#[derive(Debug, Deserialize)] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} + +// Logout handler +pub async fn logout( + State(mongo_client): State, + auth: extract_auth, + Json(payload): Json, +) -> Result { + // Revoke refresh token + let db = mongo_client.database("normogen"); + let collection = db.collection::("refresh_tokens"); + + // Decode refresh token to get JTI + // TODO: Decode and revoke + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +pub struct LogoutRequest { + pub refresh_token: String, +} +``` + +--- + +## Token Revocation Strategies + +### Strategy 1: Refresh Token Blacklist (Recommended) ⭐ + +**Description**: Store refresh tokens in MongoDB and mark as revoked + +**Pros**: +- ✅ Simple to implement +- ✅ Easy to revoke tokens +- ✅ Works with token rotation + +**Cons**: +- ❌ Database lookup on every refresh +- ❌ Requires storage + +```rust +// MongoDB Schema for Refresh Tokens +{ + "_id": ObjectId("..."), + "jti": "uuid-v4", // JWT ID from claims + "user_id": "user-123", // User ID + "created_at": ISODate("..."), + "expires_at": ISODate("..."), + "revoked": false, + "revoked_at": null +} +``` + +### Strategy 2: Access Token Blacklist (For Immediate Revocation) + +**Description**: Store revoked access token JTIs in Redis (until expiry) + +**Pros**: +- ✅ Immediate revocation +- ✅ Fast (Redis in-memory) +- ✅ Auto-expires with TTL + +**Cons**: +- ❌ Requires Redis infrastructure +- ❌ More complex + +```rust +// Use Redis for access token blacklist +// On logout, add JTI to Redis with TTL = token expiry +redis.setex(&jti, 900, "revoked"); // 15 minutes TTL + +// In middleware, check if JTI is in blacklist +if let Ok(revoked) = redis.get::(jti).await { + return Err(StatusCode::UNAUTHORIZED); +} +``` + +### Strategy 3: Token Versioning (For Password Changes) + +**Description**: Include version in JWT claims, increment on password change + +**Pros**: +- ✅ No storage required +- ✅ Fast (no database lookup) +- ✅ Simple + +**Cons**: +- ❌ Cannot revoke individual tokens +- ❌ All tokens invalidated when version changes + +```rust +// Add token_version to user document +{ + "user_id": "user-123", + "email": "user@example.com", + "token_version": 1, // Increment on password change +} + +// Include token_version in JWT claims +pub struct AccessClaims { + pub sub: String, + pub email: String, + pub token_version: i32, // Add this + pub token_type: String, + pub iat: i64, + pub exp: i64, + pub jti: String, +} + +// In middleware, verify token_version matches database +if claims.token_version != user.token_version { + return Err(StatusCode::UNAUTHORIZED); +} +``` + +### Recommended Combined Strategy + +**Use all three strategies**: +1. **Refresh Token Blacklist** (MongoDB) - For normal logout +2. **Access Token Blacklist** (Redis) - For immediate revocation (optional) +3. **Token Versioning** - For password changes + +--- + +## Refresh Token Pattern + +### Token Rotation (Security Best Practice) ⭐ + +**Description**: Issue new refresh token on every refresh, revoke old one + +**Why?**: Prevents reuse of stolen refresh tokens + +```rust +// Refresh token flow with rotation +1. Client sends refresh_token +2. Server verifies refresh_token (not revoked, not expired) +3. Server generates new access_token +4. Server generates new refresh_token +5. Server revokes old refresh_token (marks as revoked) +6. Server returns new access_token, new refresh_token +7. Client stores new tokens +``` + +### Implementation + +```rust +// Refresh token handler with rotation +pub async fn refresh_token( + State(jwt_service): State, + State(mongo_client): State, + Json(payload): Json, +) -> Result, StatusCode> { + // Verify refresh token + let claims = jwt_service + .verify_refresh_token(&payload.refresh_token) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + // Check if refresh token is revoked + let db = mongo_client.database("normogen"); + let collection = db.collection::("refresh_tokens"); + + let filter = mongodb::bson::doc! { + "jti": &claims.jti, + "revoked": false + }; + + let refresh_token_doc = collection + .find_one(filter, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + // Get user data + let user_id = refresh_token_doc + .get_str("user_id") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Find user + let users_collection = db.collection::("users"); + let user = users_collection + .find_one(mongodb::bson::doc! { "user_id": user_id }, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + // Generate new tokens + let new_access_token = jwt_service + .generate_access_token( + user_id, + user.get_str("email").unwrap(), + user.get_str("family_id").ok(), + // ... permissions + ) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let new_refresh_token = jwt_service + .generate_refresh_token(user_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Revoke old refresh token (TOKEN ROTATION) + collection + .update_one( + mongodb::bson::doc! { "jti": &claims.jti }, + mongodb::bson::doc! { + "$set": { + "revoked": true, + "revoked_at": chrono::Utc::now() + } + }, + None, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Store new refresh token + let new_claims = jwt_service + .verify_refresh_token(&new_refresh_token) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + collection + .insert_one(mongodb::bson::doc! { + "jti": &new_claims.jti, + "user_id": user_id, + "created_at": chrono::Utc::now(), + "expires_at": chrono::Utc::now() + chrono::Duration::days(30), + "revoked": false + }, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(RefreshResponse { + access_token: new_access_token, + refresh_token: new_refresh_token, + })) +} +``` + +--- + +## Zero-Knowledge Password Recovery Integration + +### Recovery Flow + +From `encryption.md`, Normogen uses **recovery phrases** for password recovery: + +```javascript +// Client-side password recovery flow + +async function recoverAccount(email, recoveryPhrase, newPassword) { + // 1. Request encrypted recovery phrase from server + const response = await fetch('/api/auth/recovery-start', { + method: 'POST', + body: JSON.stringify({ email }) + }); + + const { encrypted_recovery_phrase } = await response.json(); + + // 2. Decrypt recovery phrase with user's recovery key + // (User enters recovery key manually or from secure storage) + const recoveryKey = await getRecoveryKey(); // User enters this + const recoveryPhrase = await decryptData( + encrypted_recovery_phrase, + recoveryKey + ); + + // 3. Derive old encryption key from recovery phrase + const oldKey = await deriveKeyFromPassword(recoveryPhrase); + + // 4. Derive new encryption key from new password + const newKey = await deriveKeyFromPassword(newPassword); + + // 5. Re-encrypt recovery phrase with new password + const newEncryptedRecoveryPhrase = await encryptData( + recoveryPhrase, + newKey + ); + + // 6. Send new password hash and re-encrypted recovery phrase + const newPasswordHash = await hashPassword(newPassword); + + await fetch('/api/auth/recovery-complete', { + method: 'POST', + body: JSON.stringify({ + email, + new_password_hash: newPasswordHash, + new_encrypted_recovery_phrase: newEncryptedRecoveryPhrase + }) + }); + + // 7. Re-encrypt all local data with new key + await reencryptAllData(oldKey, newKey); + + // 8. Sync re-encrypted data to server + await syncDataToServer(); + + // 9. Login with new credentials + return await login(email, newPassword); +} +``` + +### Server-Side Recovery Handlers + +```rust +// src/handlers/recovery.rs +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct RecoveryStartRequest { + pub email: String, +} + +#[derive(Debug, Serialize)] +pub struct RecoveryStartResponse { + pub encrypted_recovery_phrase: String, +} + +#[derive(Debug, Deserialize)] +pub struct RecoveryCompleteRequest { + pub email: String, + pub new_password_hash: String, + pub new_encrypted_recovery_phrase: String, +} + +// Start recovery - return encrypted recovery phrase +pub async fn recovery_start( + State(mongo_client): State, + Json(payload): Json, +) -> Result, StatusCode> { + let db = mongo_client.database("normogen"); + let collection = db.collection::("users"); + + // Find user by email + let filter = mongodb::bson::doc! { "email": &payload.email }; + let user = collection + .find_one(filter, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // Get encrypted recovery phrase + let encrypted_recovery_phrase = user + .get_str("encrypted_recovery_phrase") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(RecoveryStartResponse { + encrypted_recovery_phrase: encrypted_recovery_phrase.to_string(), + })) +} + +// Complete recovery - update password and recovery phrase +pub async fn recovery_complete( + State(jwt_service): State, + State(mongo_client): State, + Json(payload): Json, +) -> Result, StatusCode> { + let db = mongo_client.database("normogen"); + let collection = db.collection::("users"); + + // Find user by email + let filter = mongodb::bson::doc! { "email": &payload.email }; + let mut user = collection + .find_one(filter, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // Increment token version (revoke all existing tokens) + let token_version = user + .get_i32("token_version") + .unwrap_or(0) + 1; + + // Update user + let update = mongodb::bson::doc! { + "$set": { + "password_hash": &payload.new_password_hash, + "encrypted_recovery_phrase": &payload.new_encrypted_recovery_phrase, + "token_version": token_version, + "updated_at": chrono::Utc::now() + } + }; + + collection + .update_one(filter, update, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Revoke all refresh tokens for this user + let refresh_tokens_collection = db.collection::("refresh_tokens"); + refresh_tokens_collection + .update_many( + mongodb::bson::doc! { "user_id": user.get_str("user_id").unwrap() }, + mongodb::bson::doc! { + "$set": { + "revoked": true, + "revoked_at": chrono::Utc::now() + } + }, + None, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Get updated user + let user = collection + .find_one(filter, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let user_id = user.get_str("user_id").unwrap(); + let email = user.get_str("email").unwrap(); + let family_id = user.get_str("family_id").ok(); + let permissions = user + .get_array("permissions") + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + // Generate new JWT tokens + let access_token = jwt_service + .generate_access_token(user_id, email, family_id, permissions.clone()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let refresh_token = jwt_service + .generate_refresh_token(user_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Store new refresh token + let refresh_tokens_collection = db.collection::("refresh_tokens"); + let new_claims = jwt_service + .verify_refresh_token(&refresh_token) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + refresh_tokens_collection + .insert_one(mongodb::bson::doc! { + "jti": &new_claims.jti, + "user_id": user_id, + "created_at": chrono::Utc::now(), + "expires_at": chrono::Utc::now() + chrono::Duration::days(30), + "revoked": false + }, None) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(AuthResponse { + user_id: user_id.to_string(), + email: email.to_string(), + family_id: family_id.map(String::from), + access_token, + refresh_token, + permissions, + })) +} +``` + +--- + +## Family Member Access Control + +### JWT Permissions + +Include permissions in JWT access token: + +```rust +// User permissions in JWT +pub enum Permission { + ReadOwnData, + WriteOwnData, + ReadFamilyData, + WriteFamilyData, + ManageFamilyMembers, + DeleteData, +} + +// Generate permissions based on family role +fn get_permissions(role: &str) -> Vec { + match role { + "parent" => vec![ + "read:own_data".into(), + "write:own_data".into(), + "read:family_data".into(), + "write:family_data".into(), + "manage:family_members".into(), + "delete:data".into(), + ], + "child" => vec![ + "read:own_data".into(), + "write:own_data".into(), + ], + "elderly" => vec![ + "read:own_data".into(), + "write:own_data".into(), + "read:family_data".into(), + ], + _ => vec![ + "read:own_data".into(), + "write:own_data".into(), + ], + } +} +``` + +### Permission Middleware + +```rust +// src/auth/permissions.rs +use axum::{ + extract::Request, + middleware::Next, + response::Response, + Extension, +}; + +pub fn require_permission(required_permission: &'static str) -> impl Fn(Request, Next) -> futures::future::BoxFuture<'static, Result> + Clone { + move |request: Request, next: Next| { + let required_permission = required_permission.to_string(); + Box::pin(async move { + // Get claims from request extensions + let claims = request + .extensions() + .get::() + .ok_or(StatusCode::UNAUTHORIZED)?; + + // Check if user has required permission + if !claims.permissions.contains(&required_permission) { + return Err(StatusCode::FORBIDDEN); + } + + // Continue to handler + Ok(next.run(request).await) + }) + } +} + +// Usage in routes +let app = Router::new() + .route("/api/health-data", get(get_health_data)) + .route_layer(middleware::from_fn_with_state( + jwt_service.clone(), + auth_middleware, + )) + .route("/api/health-data", post(update_health_data)) + .route_layer(middleware::from_fn( + require_permission("write:own_data") + )); +``` + +--- + +## Security Best Practices + +### 1. JWT Secret Management + +```bash +# .env +JWT_SECRET= +JWT_ACCESS_TOKEN_EXPIRY=900 # 15 minutes +JWT_REFRESH_TOKEN_EXPIRY=2592000 # 30 days +``` + +Generate JWT secret: +```bash +openssl rand -hex 64 +``` + +### 2. Token Storage (Client-Side) + +```typescript +// React Native - AsyncStorage +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Store tokens +await AsyncStorage.setItem('access_token', accessToken); +await AsyncStorage.setItem('refresh_token', refreshToken); + +// Retrieve tokens +const accessToken = await AsyncStorage.getItem('access_token'); +const refreshToken = await AsyncStorage.getItem('refresh_token'); + +// Clear tokens (logout) +await AsyncStorage.removeItem('access_token'); +await AsyncStorage.removeItem('refresh_token'); +``` + +```typescript +// Web - localStorage (less secure, use httpOnly cookies instead) +localStorage.setItem('access_token', accessToken); +localStorage.setItem('refresh_token', refreshToken); +``` + +### 3. HTTPS Only + +```rust +// In production, enforce HTTPS +if app.env() != "development" { + // Redirect HTTP to HTTPS + // Use secure cookies +} +``` + +### 4. Token Expiration + +```rust +// Access token: 15 minutes (short-lived) +// Refresh token: 30 days (long-lived) +``` + +### 5. Rate Limiting + +```rust +// Rate limit login endpoint +use tower_governor::{governor::GovernorConfigBuilder, GovernorError}; + +let governor_conf = Box::new( + GovernorConfigBuilder::default() + .per_second(10) + .burst_size(5) + .finish() + .unwrap(), +); +``` + +--- + +## Implementation Timeline + +### Phase 1: Basic JWT (Week 1) +- [ ] Setup JWT service (Axum) +- [ ] Implement login/register handlers +- [ ] Create JWT middleware +- [ ] Test basic authentication + +### Phase 2: Refresh Tokens (Week 1-2) +- [ ] Implement refresh token storage (MongoDB) +- [ ] Create refresh token handler +- [ ] Implement token rotation +- [ ] Test token refresh flow + +### Phase 3: Token Revocation (Week 2) +- [ ] Implement refresh token blacklist (MongoDB) +- [ ] Add token versioning for password changes +- [ ] Create logout handler +- [ ] Test token revocation + +### Phase 4: Password Recovery (Week 2-3) +- [ ] Implement recovery start handler +- [ ] Implement recovery complete handler +- [ ] Create client-side recovery flow +- [ ] Test password recovery + +### Phase 5: Family Access Control (Week 3) +- [ ] Implement permission system +- [ ] Create permission middleware +- [ ] Add family role management +- [ ] Test family access control + +### Phase 6: Security Hardening (Week 3-4) +- [ ] Add rate limiting +- [ ] Implement HTTPS enforcement +- [ ] Add security headers +- [ ] Security audit + +**Total**: 3-4 weeks + +--- + +## Next Steps + +1. Implement basic JWT service in Axum +2. Create MongoDB schema for users and refresh tokens +3. Implement login/register/refresh/logout handlers +4. Create JWT middleware for protected routes +5. Implement token revocation (blacklist + versioning) +6. Integrate password recovery (from encryption.md) +7. Implement family access control (permissions) +8. Test entire authentication flow +9. Create client-side authentication (React Native + React) + +--- + +## References + +- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519) +- [Axum JWT Guide](https://docs.rs/axum/latest/axum/) +- [jsonwebtoken crate](https://docs.rs/jsonwebtoken/) +- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) +- [Normogen Encryption Guide](../encryption.md) diff --git a/thoughts/research/2026-02-14-tech-stack-decision.md b/thoughts/research/2026-02-14-tech-stack-decision.md index 00f3370..0e2dbbd 100644 --- a/thoughts/research/2026-02-14-tech-stack-decision.md +++ b/thoughts/research/2026-02-14-tech-stack-decision.md @@ -74,12 +74,35 @@ --- +### 5. Authentication: JWT with Refresh Tokens +**Decision**: JWT (JSON Web Tokens) with Refresh Tokens + Recovery Phrases + +**Score**: 9.5/10 + +**Rationale**: +- **Stateless design**: Scales to 1000+ concurrent connections (no session storage) +- **Mobile-friendly**: Works perfectly with React Native (AsyncStorage) +- **Zero-knowledge compatible**: Integrates with recovery phrases from encryption.md +- **Token revocation**: Refresh token blacklist (MongoDB) + token versioning +- **Token rotation**: Prevents reuse of stolen refresh tokens +- **Family access control**: Permissions in JWT claims (parent, child, elderly) +- **Security best practices**: Short-lived access tokens (15 min), long-lived refresh tokens (30 days) + +**Trade-offs**: +- Revocation requires storage (MongoDB for refresh tokens, optional Redis for access tokens) +- More complex than sessions (but better for scaling) + +**Reference**: [2026-02-14-jwt-authentication-research.md](./2026-02-14-jwt-authentication-research.md) + +--- + ## Technology Stack Summary ### Backend - **Framework**: Axum 0.7.x - **Runtime**: Tokio 1.x - **Middleware**: Tower, Tower-HTTP +- **Authentication**: JWT with refresh tokens - **Database**: MongoDB (with zero-knowledge encryption) - **Language**: Rust @@ -88,6 +111,7 @@ - **Language**: TypeScript - **State Management**: Redux Toolkit 2.x - **Data Fetching**: RTK Query 2.x +- **Authentication**: JWT with AsyncStorage - **Navigation**: React Navigation - **Health Sensors**: - react-native-health (iOS HealthKit) @@ -102,6 +126,7 @@ - **Language**: TypeScript - **State Management**: Redux Toolkit 2.x - **Data Fetching**: RTK Query 2.x +- **Authentication**: JWT with localStorage (or httpOnly cookies) - **Routing**: React Router - **Charts**: Recharts - **Persistence**: Redux Persist 6.x (localStorage) @@ -122,23 +147,7 @@ ## Still To Be Decided -### 1. Authentication Strategy (Priority: High) - -**Options**: -- JWT (stateless, scalable) -- Session-based (traditional, easier revocation) -- Passkey/WebAuthn (passwordless, modern) - -**Considerations for Normogen**: -- Zero-knowledge password recovery (from encryption.md) -- Token revocation strategy -- Multi-factor authentication (future) -- Integration with client-side encryption keys -- Family member access control - ---- - -### 2. Database Schema (Priority: High) +### 1. Database Schema (Priority: High) **Collections to Design**: - Users (authentication, profiles) @@ -148,6 +157,16 @@ - Medications (encrypted medication data) - Appointments (encrypted appointment data) - Shared Links (time-limited access tokens) +- Refresh Tokens (JWT refresh token storage) + +--- + +### 2. API Architecture (Priority: Medium) + +**Options**: +- REST (current plan) +- GraphQL (alternative) +- gRPC (for microservices) --- @@ -156,8 +175,8 @@ 1. Rust Framework: Axum (COMPLETED) 2. Mobile/Web Framework: React Native + React (COMPLETED) 3. State Management: Redux Toolkit 2.x (COMPLETED) -4. Authentication: JWT with recovery phrase -5. Database Schema: Design MongoDB collections +4. Authentication: JWT with refresh tokens (COMPLETED) +5. Database Schema: Design MongoDB collections (NEXT) 6. Create POC: Health sensor integration test 7. Implement Core Features: Authentication, encryption, CRUD @@ -165,13 +184,14 @@ ## Next Research Priority -**Research Question**: How to implement zero-knowledge authentication with JWT and recovery phrase support? +**Research Question**: What should the MongoDB schema look like for Normogen's encrypted health data platform? **Considerations**: -- Zero-knowledge password recovery (from encryption.md) -- Token revocation strategy -- Multi-factor authentication (future) -- Integration with client-side encryption keys -- Family member access control +- Zero-knowledge encryption (all sensitive data encrypted) +- Family structure (parents, children, elderly) +- Health data types (lab results, medications, appointments) +- Refresh tokens (JWT storage) +- Shared links (time-limited access) +- Permissions (family member access control) -**Estimated Research Time**: 2-3 hours +**Estimated Research Time**: 3-4 hours