- 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)
1340 lines
39 KiB
Markdown
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)
|