JWT Architecture - Архитектура аутентификации токенов¶
Architectural Overview¶
Saga использует dual token system с разделением админских и пользовательских токенов для четкого разграничения прав доступа.
Ключевые принципы JWT системы:¶
- ✅ Dual Token System: AdminClaims (admin endpoints) vs JWTClaims (user endpoints)
- ✅ Stateless Authentication: JWT токены самодостаточны, не требуют session storage
- ✅ HS256 Algorithm: HMAC with SHA-256 для подписи токенов
- ✅ Clean Architecture: Сервисы изолированы, легко тестируемы
- ✅ UnifiedConfig Integration: Все настройки через централизованную конфигурацию
Components Architecture¶
Backend Components¶
JWT System Architecture:
┌─────────────────────────────────────────────────────────┐
│ JWT Generation Layer │
│ ┌───────────────────────────────────────────────────┐ │
│ │ jwt_mvp.go │ │
│ │ - GenerateJWTToken(email, isAdmin) │ │
│ │ - CreateAdminToken(email, permissions) │ │
│ │ - CreateUserToken(userID, email, wallet) │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ JWT Validation Layer │
│ ┌───────────────────────────────────────────────────┐ │
│ │ jwt_validator.go │ │
│ │ - ValidateJWTToken(tokenString) │ │
│ │ - ExtractClaims(token) │ │
│ │ - VerifySignature(token, secret) │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Middleware Layer │
│ ┌───────────────────────────────────────────────────┐ │
│ │ admin_auth_middleware.go │ │
│ │ - RequireAdminAuth() - checks AdminClaims │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ auth_middleware.go │ │
│ │ - RequireAuth() - checks JWTClaims │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Protected Endpoints │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ /api/admin/* │ │ /api/user/* │ │
│ │ AdminClaims required│ │ JWTClaims required │ │
│ └──────────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
ile Structure¶
Core JWT Files:
backend/auth/service/jwt_mvp.go- Token generation service (311 lines)backend/shared/http/jwt_validator.go- Token validation logic (188 lines)backend/auth/service/token_manager.go- Claims management (245 lines)backend/cmd/jwt-helper/main.go- CLI utility for token generation (567 lines)
Middleware Files:
backend/shared/middleware/admin_auth_middleware.go- Admin authentication (156 lines)backend/shared/middleware/auth_middleware.go- User authentication (143 lines)
Configuration:
config/auth.yaml- JWT settings (secret, expiration, issuer).env- JWT_SECRET environment variable
Token Types and Claims¶
1. AdminClaims (Admin Tokens)¶
Structure:
type AdminClaims struct {
jwt.StandardClaims
Email string `json:"email"`
Permissions []string `json:"permissions,omitempty"`
Roles []string `json:"roles,omitempty"`
}
JWT Payload Example:
{
"email": "admin@saga-test.com",
"permissions": [
"approve_withdrawals",
"view_all_users",
"manage_investments"
],
"roles": ["admin", "super_admin"],
"exp": 1696531200,
"iat": 1696444800,
"iss": "saga-defi-platform"
}
Usage:
- Protected endpoints:
/api/admin/* - Withdrawal approvals:
POST /api/admin/withdrawals/:id/approve - User management:
GET /api/admin/users - Investment oversight:
GET /api/admin/investments
Generation:
func (s *JWTService) CreateAdminToken(email string) (string, error) {
claims := AdminClaims{
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
IssuedAt: time.Now().Unix(),
Issuer: cfg.GetJWTIssuer(),
},
Email: email,
Permissions: []string{"approve_withdrawals", "view_all_users"},
Roles: []string{"admin"},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.GetJWTSecret()))
}
2. JWTClaims (User Tokens)¶
Structure:
type JWTClaims struct {
jwt.StandardClaims
UserID int64 `json:"user_id"`
Email string `json:"email"`
WalletAddr string `json:"wallet_address,omitempty"`
}
JWT Payload Example:
{
"user_id": 123,
"email": "user@saga-test.com",
"wallet_address": "0x742d35Cc9Cf3C4C3a3F5d7B5f",
"exp": 1696531200,
"iat": 1696444800,
"iss": "saga-defi-platform"
}
Usage:
- Protected endpoints:
/api/user/* - Balance queries:
GET /api/user/balance - Investments:
POST /api/user/investments - Withdrawals:
POST /api/user/withdrawals
Generation:
func (s *JWTService) CreateUserToken(userID int64, email, walletAddr string) (string, error) {
claims := JWTClaims{
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
IssuedAt: time.Now().Unix(),
Issuer: cfg.GetJWTIssuer(),
},
UserID: userID,
Email: email,
WalletAddr: walletAddr,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.GetJWTSecret()))
}
Security Architecture¶
1. Token Signing (HS256)¶
Algorithm: HMAC with SHA-256
Why HS256:
- ✅ Symmetric encryption (fast, efficient)
- ✅ Sufficient security for internal APIs
- ✅ No public/private key management
- ✅ Widely supported and battle-tested
Signature Process:
1. Create header:
{
"alg": "HS256",
"typ": "JWT"
}
2. Create payload (claims):
{
"email": "admin@saga-test.com",
"exp": 1696531200,
...
}
3. Encode header and payload:
base64url(header).base64url(payload)
4. Sign with secret:
HMACSHA256(
base64url(header) + "." + base64url(payload),
JWT_SECRET
)
5. Final token:
header.payload.signature
Secret Management:
# config/auth.yaml
jwt:
secret: "${JWT_SECRET}" # From environment
expiration_hours: 24
issuer: "saga-defi-platform"
Security Requirements:
- ✅
JWT_SECRETминимум 32 символа - ✅ Хранится в
.envфайле (not in code) - ✅ Different secrets для dev/staging/production
- ✅ Regular rotation (recommended: quarterly)
2. Token Expiration¶
Lifetime: 24 hours (configurable via UnifiedConfig)
Expiration Flow:
Token Creation (t=0):
iat: 1696444800 (issued at)
exp: 1696531200 (expires at = iat + 24h)
↓
Valid Period (0-24h):
✅ Token accepted
✅ User can make requests
↓
Expiration (t=24h):
❌ Token rejected
❌ User must re-authenticate
↓
Grace Period (NONE):
❌ No refresh tokens
❌ Hard cutoff at expiration
Implementation:
func ValidateExpiration(claims jwt.StandardClaims) error {
now := time.Now().Unix()
if claims.ExpiresAt < now {
return errors.New("token expired")
}
// Optional: Reject tokens issued in future
if claims.IssuedAt > now + 60 {
return errors.New("token issued in future")
}
return nil
}
3. Token Validation Process¶
Comprehensive Validation:
func (v *JWTValidator) ValidateToken(tokenString string) (*jwt.Token, error) {
// 1. Parse token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 2. Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
// 3. Return secret for signature verification
return []byte(cfg.GetJWTSecret()), nil
})
if err != nil {
return nil, fmt.Errorf("token parse failed: %w", err)
}
// 4. Verify token is valid
if !token.Valid {
return nil, errors.New("invalid token")
}
// 5. Extract and validate claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid claims format")
}
// 6. Verify expiration
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
return nil, errors.New("token expired")
}
}
// 7. Verify issuer
if iss, ok := claims["iss"].(string); ok {
if iss != cfg.GetJWTIssuer() {
return nil, errors.New("invalid issuer")
}
}
return token, nil
}
Middleware Implementation¶
Admin Authentication Middleware¶
File: backend/shared/middleware/admin_auth_middleware.go
func RequireAdminAuth(cfg *config.UnifiedConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Extract token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http_helpers.SendError(w, http.StatusUnauthorized, "Missing authorization header")
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
// 2. Validate token
validator := jwt_validator.NewJWTValidator(cfg)
token, err := validator.ValidateToken(tokenString)
if err != nil {
http_helpers.SendError(w, http.StatusUnauthorized, "Invalid token: "+err.Error())
return
}
// 3. Extract AdminClaims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http_helpers.SendError(w, http.StatusUnauthorized, "Invalid claims")
return
}
adminEmail, ok := claims["email"].(string)
if !ok {
http_helpers.SendError(w, http.StatusUnauthorized, "Missing email claim")
return
}
// 4. Verify admin status (from UnifiedConfig)
if !cfg.IsAdmin(adminEmail) {
http_helpers.SendError(w, http.StatusForbidden, "Not an admin")
return
}
// 5. Add admin email to request context
ctx := context.WithValue(r.Context(), "admin_email", adminEmail)
// 6. Continue to handler
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Protected Routes:
// In routing setup
adminRouter := chi.NewRouter()
adminRouter.Use(middleware.RequireAdminAuth(cfg))
// All routes below require AdminClaims
adminRouter.Get("/users", adminHandler.ListUsers)
adminRouter.Post("/withdrawals/{id}/approve", adminHandler.ApproveWithdrawal)
adminRouter.Get("/investments", adminHandler.ListInvestments)
User Authentication Middleware¶
File: backend/shared/middleware/auth_middleware.go
func RequireAuth(cfg *config.UnifiedConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Extract token
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http_helpers.SendError(w, http.StatusUnauthorized, "Missing authorization header")
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
// 2. Validate token
validator := jwt_validator.NewJWTValidator(cfg)
token, err := validator.ValidateToken(tokenString)
if err != nil {
http_helpers.SendError(w, http.StatusUnauthorized, "Invalid token: "+err.Error())
return
}
// 3. Extract JWTClaims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http_helpers.SendError(w, http.StatusUnauthorized, "Invalid claims")
return
}
// 4. Extract user data
userID, ok := claims["user_id"].(float64)
if !ok {
http_helpers.SendError(w, http.StatusUnauthorized, "Missing user_id claim")
return
}
email, _ := claims["email"].(string)
walletAddr, _ := claims["wallet_address"].(string)
// 5. Add user data to context
ctx := context.WithValue(r.Context(), "user_id", int64(userID))
ctx = context.WithValue(ctx, "email", email)
ctx = context.WithValue(ctx, "wallet_address", walletAddr)
// 6. Continue to handler
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Protected Routes:
// In routing setup
userRouter := chi.NewRouter()
userRouter.Use(middleware.RequireAuth(cfg))
// All routes below require JWTClaims
userRouter.Get("/balance", userHandler.GetBalance)
userRouter.Post("/investments", userHandler.CreateInvestment)
userRouter.Post("/withdrawals", userHandler.CreateWithdrawal)
Testing JWT System¶
1. CLI Utility (jwt-helper)¶
File: backend/cmd/jwt-helper/main.go
Generate Admin Token:
# Using Makefile (recommended)
make jwt-admin EMAIL=admin@saga-test.com
# Direct CLI (after build)
cd backend && ./bin/jwt-helper admin admin@saga-test.com
Generate User Token:
# Using Makefile (recommended)
make jwt-user EMAIL=user@saga-test.com
# Direct CLI (requires database)
cd backend && ./bin/jwt-helper user user@saga-test.com
Validate Token:
# Using Makefile
make jwt-validate TOKEN=<jwt_token>
# Direct CLI
cd backend && ./bin/jwt-helper validate <jwt_token>
Introspect Token:
# Decode and display token contents
cd backend && ./bin/jwt-helper introspect <jwt_token>
# Output:
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"email": "admin@saga-test.com",
"permissions": ["approve_withdrawals"],
"exp": 1696531200,
...
},
"signature": "valid"
}
2. Integration Tests¶
Test Admin Authentication:
func TestAdminAuthMiddleware(t *testing.T) {
cfg := config.LoadTestConfig()
// Create admin token
jwtService := jwt_service.NewJWTService(cfg)
adminToken, err := jwtService.CreateAdminToken("admin@saga-test.com")
require.NoError(t, err)
// Test protected endpoint
req := httptest.NewRequest("GET", "/api/admin/users", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rr := httptest.NewRecorder()
handler := middleware.RequireAdminAuth(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
adminEmail := r.Context().Value("admin_email").(string)
assert.Equal(t, "admin@saga-test.com", adminEmail)
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
Test User Authentication:
func TestUserAuthMiddleware(t *testing.T) {
cfg := config.LoadTestConfig()
// Create user token
jwtService := jwt_service.NewJWTService(cfg)
userToken, err := jwtService.CreateUserToken(123, "user@saga-test.com", "0x742d35...")
require.NoError(t, err)
// Test protected endpoint
req := httptest.NewRequest("GET", "/api/user/balance", nil)
req.Header.Set("Authorization", "Bearer "+userToken)
rr := httptest.NewRecorder()
handler := middleware.RequireAuth(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("user_id").(int64)
assert.Equal(t, int64(123), userID)
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
Test Token Expiration:
func TestTokenExpiration(t *testing.T) {
cfg := config.LoadTestConfig()
jwtService := jwt_service.NewJWTService(cfg)
// Create token with short expiration
claims := jwt_service.AdminClaims{
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(1 * time.Second).Unix(),
IssuedAt: time.Now().Unix(),
},
Email: "admin@saga-test.com",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(cfg.GetJWTSecret()))
require.NoError(t, err)
// Validate immediately (should pass)
validator := jwt_validator.NewJWTValidator(cfg)
_, err = validator.ValidateToken(tokenString)
assert.NoError(t, err)
// Wait for expiration
time.Sleep(2 * time.Second)
// Validate after expiration (should fail)
_, err = validator.ValidateToken(tokenString)
assert.Error(t, err)
assert.Contains(t, err.Error(), "expired")
}
3. E2E Tests (Playwright)¶
Admin E2E Authentication:
import { test, expect } from '@playwright/test';
import { generateAdminJWT } from '../utils/admin-jwt-helper';
test('Admin can access protected endpoints with JWT', async ({ page }) => {
// Generate admin token
const adminToken = await generateAdminJWT('admin@saga-test.com');
// Set Authorization header
await page.setExtraHTTPHeaders({
'Authorization': `Bearer ${adminToken}`
});
// Navigate to admin page
await page.goto('http://admin.saga.local:8080/');
// Verify admin access
await expect(page.locator('text=Admin Dashboard')).toBeVisible();
// Test protected API call
const response = await page.request.get('http://localhost:8080/api/admin/users', {
headers: {
'Authorization': `Bearer ${adminToken}`
}
});
expect(response.status()).toBe(200);
});
User E2E Authentication:
test('User can access user endpoints with JWT', async ({ page }) => {
// Create user and get token
const userToken = await createTestUserAndGetToken('user@saga-test.com');
// Set Authorization header
await page.setExtraHTTPHeaders({
'Authorization': `Bearer ${userToken}`
});
// Navigate to user page
await page.goto('http://app.saga.local:8080/');
// Verify user access
await expect(page.locator('text=My Balance')).toBeVisible();
// Test protected API call
const response = await page.request.get('http://localhost:8080/api/user/balance', {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
expect(response.status()).toBe(200);
});
Configuration Management¶
UnifiedConfig Integration¶
JWT Configuration Structure:
# config/auth.yaml
jwt:
secret: "${JWT_SECRET}"
expiration_hours: 24
issuer: "saga-defi-platform"
algorithm: "HS256"
admins:
- email: "admin@saga-test.com"
permissions:
- "approve_withdrawals"
- "view_all_users"
- "manage_investments"
- email: "super@saga-test.com"
permissions:
- "*" # All permissions
Go Configuration Access:
type UnifiedConfig interface {
// JWT settings
GetJWTSecret() string
GetJWTExpirationHours() int
GetJWTIssuer() string
// Admin management
IsAdmin(email string) bool
GetAdminPermissions(email string) []string
}
// Usage
func (s *JWTService) NewJWTService(cfg *config.UnifiedConfig) *JWTService {
return &JWTService{
secret: cfg.GetJWTSecret(),
expiration: time.Duration(cfg.GetJWTExpirationHours()) * time.Hour,
issuer: cfg.GetJWTIssuer(),
}
}
nvironment Variables¶
Required Variables:
Development Setup:
# Generate random secret (recommended)
openssl rand -base64 32 > .jwt-secret
export JWT_SECRET=$(cat .jwt-secret)
# Or use test secret (dev only!)
export JWT_SECRET="test-jwt-secret-for-smoke-tests-minimum-32-chars-required"
Production Requirements:
- ✅ Strong random secret (32+ bytes)
- ✅ Stored in secure vault (AWS Secrets Manager, HashiCorp Vault)
- ✅ Regular rotation schedule
- ✅ Different secrets per environment
rontend Integration¶
React Context (Web3AuthContext)¶
Token Storage:
// frontend/user-app/src/lib/web3-auth-context.tsx
export const Web3AuthContext = createContext<Web3AuthContextType | undefined>(undefined);
export const Web3AuthProvider: React.FC<Props> = ({ children }) => {
const [authToken, setAuthToken] = useState<string | null>(() => {
// Load from localStorage on init
return localStorage.getItem('authToken');
});
const login = async (walletAddress: string, signature: string) => {
// Authenticate with backend
const response = await fetch('/api/auth/wallet/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: walletAddress, signature })
});
const { token } = await response.json();
// Store token
localStorage.setItem('authToken', token);
setAuthToken(token);
};
const logout = () => {
localStorage.removeItem('authToken');
setAuthToken(null);
};
return (
<Web3AuthContext.Provider value={{ authToken, login, logout }}>
{children}
</Web3AuthContext.Provider>
);
};
API Requests with JWT¶
Authenticated Fetch:
// frontend/user-app/src/lib/services/auth-service.ts
export const authenticatedFetch = async (url: string, options: RequestInit = {}) => {
const authToken = localStorage.getItem('authToken');
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': authToken ? `Bearer ${authToken}` : '',
'Content-Type': 'application/json',
},
});
// Handle 401 (expired token)
if (response.status === 401) {
localStorage.removeItem('authToken');
window.location.href = '/login';
throw new Error('Authentication expired');
}
return response;
};
// Usage
const getUserBalance = async () => {
const response = await authenticatedFetch('/api/user/balance');
return response.json();
};
Monitoring and Logging¶
JWT Event Logging¶
Structured Logging:
import "github.com/aiseeq/saga/backend/shared/logging"
func (s *JWTService) CreateUserToken(userID int64, email string) (string, error) {
logger := logging.GetGlobalCanonicalLogger()
token, err := s.generateToken(userID, email)
if err != nil {
logger.ErrorStructured("JWT generation failed",
"user_id", userID,
"email", email,
"error", err.Error(),
)
return "", err
}
logger.InfoStructured("JWT token created",
"user_id", userID,
"email", email,
"expires_at", time.Now().Add(s.expiration).Unix(),
)
return token, nil
}
Validation Logging:
func (v *JWTValidator) ValidateToken(tokenString string) error {
logger := logging.GetGlobalCanonicalLogger()
token, err := jwt.Parse(tokenString, v.keyFunc)
if err != nil {
logger.WarnStructured("JWT validation failed",
"error", err.Error(),
"token_prefix", tokenString[:20], // First 20 chars only
)
return err
}
claims := token.Claims.(jwt.MapClaims)
logger.InfoStructured("JWT validated successfully",
"email", claims["email"],
"expires_at", claims["exp"],
)
return nil
}
Metrics Collection¶
Key Metrics:
- Total tokens generated (counter)
- Token validation success/failure rate (gauge)
- Token expiration events (counter)
- Average token lifetime (histogram)
Prometheus Example:
var (
tokensGenerated = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jwt_tokens_generated_total",
Help: "Total number of JWT tokens generated",
},
[]string{"type"}, // admin or user
)
tokenValidations = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jwt_validations_total",
Help: "Total number of JWT validations",
},
[]string{"result"}, // success or failure
)
)
func (s *JWTService) CreateAdminToken(email string) (string, error) {
token, err := s.generateToken(email, true)
if err != nil {
tokensGenerated.WithLabelValues("admin").Inc()
}
return token, err
}
Related Documentation¶
Architecture:¶
- Authentication Flow - Complete Web3 authentication flow
- Security Architecture - Overall security design
- Backend Architecture - System overview
Development:¶
- JWT Helper Guide - CLI utility usage
- API Authentication - API-level auth documentation
- Testing Guide - Authentication testing
Configuration:¶
- UnifiedConfig System - Configuration management
- Environment Setup - Dev environment configuration
Security Best Practices¶
Token Security Checklist:¶
- ✅ JWT_SECRET минимум 32 символа
- ✅ Secret хранится в environment variables (not code)
- ✅ Different secrets для dev/staging/production
- ✅ Token expiration установлен (24 hours)
- ✅ HTTPS enforced для production
- ✅ Tokens not logged (только prefixes)
- ✅ Signature algorithm verified (HS256 only)
- ✅ Claims validated (exp, iss, email)
- ✅ Admin status checked против UnifiedConfig
- ✅ Monitoring и alerting настроены
Common Pitfalls:¶
❌ Hardcoding JWT_SECRET → Use environment variables
❌ No expiration → Always set exp claim
❌ Logging full tokens → Only log token prefixes
❌ Accepting any algorithm → Verify alg header
❌ No admin validation → Check against UnifiedConfig.IsAdmin()
❌ Storing tokens in cookies without HttpOnly → Use localStorage or HttpOnly cookies
📋 Метаданные¶
Версия: 2.4.82
Обновлено: 2025-10-21
Статус: Published