normogen/thoughts/research/2026-02-14-jwt-authentication-research.md
goose 203c0b4331 Research: JWT authentication selected
- Comprehensive JWT research completed
- JWT with refresh tokens selected (9.5/10 score)
- Token revocation strategies (blacklist + versioning)
- Refresh token pattern (token rotation)
- Zero-knowledge password recovery integration
- Family member access control (permissions in JWT)

Key decisions:
- Access tokens: 15 minutes (short-lived)
- Refresh tokens: 30 days (long-lived, stored in MongoDB)
- Token rotation: Prevents reuse of stolen tokens
- Token versioning: Invalidate all tokens on password change
- Recovery phrases: Zero-knowledge password recovery from encryption.md
- Family permissions: parent, child, elderly roles

Updated tech stack decisions

Next: Database schema design (MongoDB collections)
2026-02-14 12:44:33 -03:00

1340 lines
39 KiB
Markdown

# 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<Self, Box<dyn std::error::Error>> {
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<String>,
pub permissions: Vec<String>,
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<String>,
) -> Result<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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<AccessClaims, Box<dyn std::error::Error>> {
let token_data = decode::<AccessClaims>(
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<RefreshClaims, Box<dyn std::error::Error>> {
let token_data = decode::<RefreshClaims>(
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<String>,
pub permissions: Vec<String>,
}
pub async fn auth_middleware(
headers: HeaderMap,
jwt_service: axum::extract::State<JwtService>,
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
// 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<axum::extract::Extension<crate::auth::claims::AccessClaims>>,
) -> Result<AuthExtractor, StatusCode> {
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<String>,
pub access_token: String,
pub refresh_token: String,
pub permissions: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct RefreshResponse {
pub access_token: String,
pub refresh_token: String,
}
// Login handler
pub async fn login(
State(jwt_service): State<JwtService>,
State(mongo_client): State<mongodb::Client>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, StatusCode> {
// Find user by email
let db = mongo_client.database("normogen");
let collection = db.collection::<mongodb::bson::Document>("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<JwtService>,
State(mongo_client): State<mongodb::Client>,
Json(payload): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, StatusCode> {
// Check if user exists
let db = mongo_client.database("normogen");
let collection = db.collection::<mongodb::bson::Document>("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<JwtService>,
State(mongo_client): State<mongodb::Client>,
Json(payload): Json<RefreshTokenRequest>,
) -> Result<Json<RefreshResponse>, 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::<mongodb::bson::Document>("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::<mongodb::bson::Document>("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<mongodb::Client>,
auth: extract_auth,
Json(payload): Json<LogoutRequest>,
) -> Result<StatusCode, StatusCode> {
// Revoke refresh token
let db = mongo_client.database("normogen");
let collection = db.collection::<mongodb::bson::Document>("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::<String, String>(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<JwtService>,
State(mongo_client): State<mongodb::Client>,
Json(payload): Json<RefreshTokenRequest>,
) -> Result<Json<RefreshResponse>, 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::<mongodb::bson::Document>("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::<mongodb::bson::Document>("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<mongodb::Client>,
Json(payload): Json<RecoveryStartRequest>,
) -> Result<Json<RecoveryStartResponse>, StatusCode> {
let db = mongo_client.database("normogen");
let collection = db.collection::<mongodb::bson::Document>("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<JwtService>,
State(mongo_client): State<mongodb::Client>,
Json(payload): Json<RecoveryCompleteRequest>,
) -> Result<Json<AuthResponse>, StatusCode> {
let db = mongo_client.database("normogen");
let collection = db.collection::<mongodb::bson::Document>("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::<mongodb::bson::Document>("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::<mongodb::bson::Document>("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<String> {
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<Response, StatusCode>> + 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::<crate::auth::claims::AccessClaims>()
.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=<random-64-character-hex-key>
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)