You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
882 lines
28 KiB
Markdown
882 lines
28 KiB
Markdown
# Know Foolery - Zitadel Integration Architecture
|
|
|
|
## Overview
|
|
|
|
Zitadel serves as the self-hosted OAuth 2.0/OpenID Connect authentication provider for Know Foolery, providing secure authentication for both players and administrators while maintaining complete control over user data and authentication flows.
|
|
|
|
## Architecture Integration
|
|
|
|
### Zitadel Deployment Architecture
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Client Applications │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
│ │ Web App │ │ Mobile iOS │ │Mobile Android│ │Desktop Wails│ │
|
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
OAuth 2.0/OIDC Flows
|
|
│
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ API Gateway │
|
|
│ ┌────────────────────────────────────────────────────────────────┐ │
|
|
│ │ JWT Validation Middleware │ │
|
|
│ │ • Token signature verification │ │
|
|
│ │ • Claims validation & role extraction │ │
|
|
│ │ • Token refresh handling │ │
|
|
│ └────────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
Validated Requests
|
|
│
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Microservices Layer │
|
|
│ (Receives user context from JWT claims) │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
Zitadel Admin API
|
|
│
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Zitadel Instance │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
│ │ OAuth │ │ Admin API │ │ PostgreSQL │ │ OIDC │ │
|
|
│ │ Provider │ │ │ │ Database │ │ Discovery │ │
|
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Zitadel Configuration
|
|
|
|
### Project and Application Setup
|
|
```yaml
|
|
# Zitadel Project Configuration
|
|
project:
|
|
name: "Know Foolery Quiz Game"
|
|
description: "Cross-platform quiz game authentication"
|
|
|
|
applications:
|
|
web_app:
|
|
name: "Know Foolery Web"
|
|
type: "WEB"
|
|
auth_method: "PKCE"
|
|
redirect_uris:
|
|
- "http://localhost:3000/auth/callback" # Development
|
|
- "https://app.knowfoolery.com/auth/callback" # Production
|
|
post_logout_redirect_uris:
|
|
- "http://localhost:3000/"
|
|
- "https://app.knowfoolery.com/"
|
|
|
|
mobile_app:
|
|
name: "Know Foolery Mobile"
|
|
type: "NATIVE"
|
|
auth_method: "PKCE"
|
|
redirect_uris:
|
|
- "knowfoolery://auth/callback"
|
|
|
|
admin_app:
|
|
name: "Know Foolery Admin"
|
|
type: "WEB"
|
|
auth_method: "PKCE"
|
|
redirect_uris:
|
|
- "http://localhost:3001/admin/auth/callback"
|
|
- "https://admin.knowfoolery.com/auth/callback"
|
|
|
|
roles:
|
|
- name: "player"
|
|
display_name: "Quiz Player"
|
|
description: "Can play quiz games and view leaderboards"
|
|
- name: "admin"
|
|
display_name: "Game Administrator"
|
|
description: "Can manage questions, users, and system settings"
|
|
|
|
policies:
|
|
password_policy:
|
|
min_length: 8
|
|
has_uppercase: true
|
|
has_lowercase: true
|
|
has_number: true
|
|
has_symbol: false
|
|
|
|
lockout_policy:
|
|
max_password_attempts: 5
|
|
show_lockout_failure: true
|
|
|
|
mfa_policy:
|
|
force_mfa: true # Required for admin role
|
|
force_mfa_local_only: false
|
|
```
|
|
|
|
### Zitadel Repository Pattern Implementation
|
|
|
|
```go
|
|
// ZitadelRepository interface for external integration
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
type ZitadelRepository interface {
|
|
ValidateToken(ctx context.Context, token string) (*AuthClaims, error)
|
|
RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error)
|
|
GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error)
|
|
RevokeToken(ctx context.Context, token string) error
|
|
CreateUser(ctx context.Context, user *CreateUserRequest) (*UserResponse, error)
|
|
GetUserByID(ctx context.Context, userID string) (*UserInfo, error)
|
|
UpdateUserRoles(ctx context.Context, userID string, roles []string) error
|
|
}
|
|
|
|
type zitadelRepository struct {
|
|
client *http.Client
|
|
baseURL string
|
|
projectID string
|
|
adminToken string
|
|
publicKeys map[string]*PublicKey
|
|
publicKeysExpiry time.Time
|
|
circuitBreaker *CircuitBreaker
|
|
}
|
|
|
|
type AuthClaims struct {
|
|
Subject string `json:"sub"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Roles []string `json:"urn:zitadel:iam:org:project:roles"`
|
|
Audience []string `json:"aud"`
|
|
Issuer string `json:"iss"`
|
|
IssuedAt int64 `json:"iat"`
|
|
ExpiresAt int64 `json:"exp"`
|
|
MFAVerified bool `json:"amr"`
|
|
}
|
|
|
|
type TokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
type UserInfo struct {
|
|
ID string `json:"sub"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Verified bool `json:"email_verified"`
|
|
Roles []string
|
|
}
|
|
|
|
func NewZitadelRepository(config ZitadelConfig) ZitadelRepository {
|
|
return &zitadelRepository{
|
|
client: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 10,
|
|
IdleConnTimeout: 30 * time.Second,
|
|
DisableCompression: true,
|
|
},
|
|
},
|
|
baseURL: config.BaseURL,
|
|
projectID: config.ProjectID,
|
|
adminToken: config.AdminToken,
|
|
publicKeys: make(map[string]*PublicKey),
|
|
circuitBreaker: NewCircuitBreaker(),
|
|
}
|
|
}
|
|
|
|
func (r *zitadelRepository) ValidateToken(ctx context.Context, token string) (*AuthClaims, error) {
|
|
// Circuit breaker pattern for resilience
|
|
result, err := r.circuitBreaker.Execute(func() (interface{}, error) {
|
|
return r.validateTokenInternal(ctx, token)
|
|
})
|
|
|
|
if err == ErrCircuitOpen {
|
|
// Fallback: validate token locally if Zitadel is unavailable
|
|
return r.validateTokenLocally(token)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token validation failed: %w", err)
|
|
}
|
|
|
|
return result.(*AuthClaims), nil
|
|
}
|
|
|
|
func (r *zitadelRepository) validateTokenInternal(ctx context.Context, token string) (*AuthClaims, error) {
|
|
// Parse JWT token
|
|
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
|
|
// Verify signing algorithm
|
|
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
|
|
// Get public key from Zitadel JWKS endpoint
|
|
keyID := token.Header["kid"].(string)
|
|
return r.getPublicKey(ctx, keyID)
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse token: %w", err)
|
|
}
|
|
|
|
if !parsedToken.Valid {
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
// Extract claims
|
|
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid token claims")
|
|
}
|
|
|
|
return r.extractAuthClaims(claims)
|
|
}
|
|
|
|
func (r *zitadelRepository) getPublicKey(ctx context.Context, keyID string) (*rsa.PublicKey, error) {
|
|
// Check cache first
|
|
if key, exists := r.publicKeys[keyID]; exists && time.Now().Before(r.publicKeysExpiry) {
|
|
return key.RSAPublicKey, nil
|
|
}
|
|
|
|
// Fetch from Zitadel JWKS endpoint
|
|
jwksURL := fmt.Sprintf("%s/.well-known/openid_configuration", r.baseURL)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var jwks JWKS
|
|
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update cache
|
|
r.updatePublicKeysCache(jwks)
|
|
|
|
key, exists := r.publicKeys[keyID]
|
|
if !exists {
|
|
return nil, fmt.Errorf("key ID %s not found", keyID)
|
|
}
|
|
|
|
return key.RSAPublicKey, nil
|
|
}
|
|
|
|
func (r *zitadelRepository) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
|
|
data := url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"refresh_token": {refreshToken},
|
|
"client_id": {r.projectID},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST",
|
|
fmt.Sprintf("%s/oauth/v2/token", r.baseURL),
|
|
strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("token refresh failed with status: %d", resp.StatusCode)
|
|
}
|
|
|
|
var tokenResp TokenResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &tokenResp, nil
|
|
}
|
|
|
|
func (r *zitadelRepository) CreateUser(ctx context.Context, user *CreateUserRequest) (*UserResponse, error) {
|
|
reqBody := map[string]interface{}{
|
|
"userName": user.Email,
|
|
"profile": map[string]interface{}{
|
|
"firstName": user.FirstName,
|
|
"lastName": user.LastName,
|
|
"displayName": user.DisplayName,
|
|
},
|
|
"email": map[string]interface{}{
|
|
"email": user.Email,
|
|
"isEmailVerified": false,
|
|
},
|
|
}
|
|
|
|
jsonBody, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST",
|
|
fmt.Sprintf("%s/management/v1/users/human", r.baseURL),
|
|
bytes.NewBuffer(jsonBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.adminToken))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var userResp UserResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&userResp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &userResp, nil
|
|
}
|
|
|
|
// Circuit breaker implementation for resilience
|
|
type CircuitBreaker struct {
|
|
state CircuitState
|
|
failureCount int
|
|
threshold int
|
|
timeout time.Duration
|
|
lastFailTime time.Time
|
|
mutex sync.RWMutex
|
|
}
|
|
|
|
type CircuitState int
|
|
|
|
const (
|
|
Closed CircuitState = iota
|
|
Open
|
|
HalfOpen
|
|
)
|
|
|
|
var ErrCircuitOpen = errors.New("circuit breaker is open")
|
|
|
|
func NewCircuitBreaker() *CircuitBreaker {
|
|
return &CircuitBreaker{
|
|
state: Closed,
|
|
threshold: 5,
|
|
timeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
func (cb *CircuitBreaker) Execute(fn func() (interface{}, error)) (interface{}, error) {
|
|
cb.mutex.RLock()
|
|
state := cb.state
|
|
cb.mutex.RUnlock()
|
|
|
|
switch state {
|
|
case Open:
|
|
if time.Since(cb.lastFailTime) > cb.timeout {
|
|
cb.setState(HalfOpen)
|
|
return cb.tryExecute(fn)
|
|
}
|
|
return nil, ErrCircuitOpen
|
|
|
|
case HalfOpen:
|
|
return cb.tryExecute(fn)
|
|
|
|
default: // Closed
|
|
return cb.tryExecute(fn)
|
|
}
|
|
}
|
|
|
|
func (cb *CircuitBreaker) tryExecute(fn func() (interface{}, error)) (interface{}, error) {
|
|
result, err := fn()
|
|
|
|
cb.mutex.Lock()
|
|
defer cb.mutex.Unlock()
|
|
|
|
if err != nil {
|
|
cb.failureCount++
|
|
cb.lastFailTime = time.Now()
|
|
|
|
if cb.failureCount >= cb.threshold {
|
|
cb.state = Open
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Success - reset circuit breaker
|
|
cb.failureCount = 0
|
|
cb.state = Closed
|
|
return result, nil
|
|
}
|
|
|
|
func (cb *CircuitBreaker) setState(state CircuitState) {
|
|
cb.mutex.Lock()
|
|
defer cb.mutex.Unlock()
|
|
cb.state = state
|
|
}
|
|
```
|
|
|
|
## Authentication Flows
|
|
|
|
### Player Authentication Flow (Web/Mobile)
|
|
```typescript
|
|
// Frontend authentication service
|
|
export class AuthService {
|
|
private zitadelConfig: ZitadelConfig
|
|
|
|
constructor(config: ZitadelConfig) {
|
|
this.zitadelConfig = config
|
|
}
|
|
|
|
async loginPlayer(): Promise<AuthResult> {
|
|
// PKCE flow for security
|
|
const codeVerifier = this.generateCodeVerifier()
|
|
const codeChallenge = await this.generateCodeChallenge(codeVerifier)
|
|
|
|
// Store code verifier securely
|
|
await this.secureStorage.store('code_verifier', codeVerifier)
|
|
|
|
// Redirect to Zitadel authorization endpoint
|
|
const authURL = new URL(`${this.zitadelConfig.baseURL}/oauth/v2/authorize`)
|
|
authURL.searchParams.set('client_id', this.zitadelConfig.clientId)
|
|
authURL.searchParams.set('response_type', 'code')
|
|
authURL.searchParams.set('scope', 'openid profile email urn:zitadel:iam:org:project:roles')
|
|
authURL.searchParams.set('redirect_uri', this.zitadelConfig.redirectUri)
|
|
authURL.searchParams.set('code_challenge', codeChallenge)
|
|
authURL.searchParams.set('code_challenge_method', 'S256')
|
|
authURL.searchParams.set('state', this.generateState())
|
|
|
|
// Platform-specific redirect
|
|
if (this.isPlatform('web')) {
|
|
window.location.href = authURL.toString()
|
|
} else if (this.isPlatform('mobile')) {
|
|
await this.openAuthSession(authURL.toString())
|
|
}
|
|
}
|
|
|
|
async handleAuthCallback(code: string, state: string): Promise<TokenResult> {
|
|
// Verify state parameter
|
|
const storedState = await this.secureStorage.get('auth_state')
|
|
if (state !== storedState) {
|
|
throw new Error('Invalid state parameter')
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
const codeVerifier = await this.secureStorage.get('code_verifier')
|
|
|
|
const tokenResponse = await fetch(`${this.zitadelConfig.baseURL}/oauth/v2/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: this.zitadelConfig.clientId,
|
|
code,
|
|
redirect_uri: this.zitadelConfig.redirectUri,
|
|
code_verifier: codeVerifier,
|
|
}),
|
|
})
|
|
|
|
if (!tokenResponse.ok) {
|
|
throw new Error('Token exchange failed')
|
|
}
|
|
|
|
const tokens = await tokenResponse.json()
|
|
|
|
// Store tokens securely
|
|
await this.storeTokens(tokens)
|
|
|
|
// Decode and validate JWT claims
|
|
const userInfo = await this.getUserInfo(tokens.access_token)
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token,
|
|
userInfo,
|
|
}
|
|
}
|
|
|
|
async refreshAccessToken(): Promise<string> {
|
|
const refreshToken = await this.secureStorage.get('refresh_token')
|
|
if (!refreshToken) {
|
|
throw new Error('No refresh token available')
|
|
}
|
|
|
|
const response = await fetch(`${this.zitadelConfig.baseURL}/oauth/v2/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: this.zitadelConfig.clientId,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
// Refresh token expired, need to re-authenticate
|
|
await this.logout()
|
|
throw new Error('Refresh token expired')
|
|
}
|
|
|
|
const tokens = await response.json()
|
|
await this.storeTokens(tokens)
|
|
|
|
return tokens.access_token
|
|
}
|
|
|
|
private async storeTokens(tokens: TokenResponse): Promise<void> {
|
|
// Platform-specific secure storage
|
|
if (this.isPlatform('web')) {
|
|
// Use secure HttpOnly cookies for web
|
|
await this.apiClient.post('/auth/store-tokens', { tokens })
|
|
} else if (this.isPlatform('mobile')) {
|
|
// Use Keychain/Keystore for mobile
|
|
await this.secureStorage.store('access_token', tokens.access_token)
|
|
await this.secureStorage.store('refresh_token', tokens.refresh_token)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Admin Authentication Flow with MFA
|
|
```typescript
|
|
export class AdminAuthService extends AuthService {
|
|
async loginAdmin(): Promise<AuthResult> {
|
|
// Standard OAuth flow first
|
|
const authResult = await super.loginPlayer()
|
|
|
|
// Check if user has admin role
|
|
if (!authResult.userInfo.roles.includes('admin')) {
|
|
throw new Error('Insufficient permissions')
|
|
}
|
|
|
|
// Verify MFA is completed
|
|
if (!authResult.userInfo.mfaVerified) {
|
|
return this.redirectToMFA()
|
|
}
|
|
|
|
return authResult
|
|
}
|
|
|
|
private async redirectToMFA(): Promise<AuthResult> {
|
|
// Redirect to Zitadel MFA setup/verification
|
|
const mfaURL = new URL(`${this.zitadelConfig.baseURL}/ui/login/mfa/init`)
|
|
|
|
if (this.isPlatform('web')) {
|
|
window.location.href = mfaURL.toString()
|
|
}
|
|
|
|
// Handle MFA callback
|
|
return this.handleMFACallback()
|
|
}
|
|
|
|
private async handleMFACallback(): Promise<AuthResult> {
|
|
// After MFA completion, refresh token to get updated claims
|
|
const newAccessToken = await this.refreshAccessToken()
|
|
const userInfo = await this.getUserInfo(newAccessToken)
|
|
|
|
if (!userInfo.mfaVerified) {
|
|
throw new Error('MFA verification failed')
|
|
}
|
|
|
|
return {
|
|
accessToken: newAccessToken,
|
|
userInfo,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Backend JWT Validation
|
|
|
|
### JWT Middleware Implementation
|
|
```go
|
|
// JWT validation middleware for API Gateway
|
|
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
type JWTMiddleware struct {
|
|
zitadelRepo auth.ZitadelRepository
|
|
config JWTConfig
|
|
}
|
|
|
|
type JWTConfig struct {
|
|
Issuer string
|
|
Audience string
|
|
RequiredClaims []string
|
|
AdminEndpoints []string
|
|
RateLimitByUserID bool
|
|
}
|
|
|
|
func NewJWTMiddleware(zitadelRepo auth.ZitadelRepository, config JWTConfig) *JWTMiddleware {
|
|
return &JWTMiddleware{
|
|
zitadelRepo: zitadelRepo,
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
func (m *JWTMiddleware) ValidateToken() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
// Extract token from Authorization header
|
|
authHeader := c.Get("Authorization")
|
|
if authHeader == "" {
|
|
return c.Status(401).JSON(fiber.Map{"error": "Authorization header required"})
|
|
}
|
|
|
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
|
if tokenString == authHeader {
|
|
return c.Status(401).JSON(fiber.Map{"error": "Invalid authorization header format"})
|
|
}
|
|
|
|
// Validate token with Zitadel
|
|
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := m.zitadelRepo.ValidateToken(ctx, tokenString)
|
|
if err != nil {
|
|
return c.Status(401).JSON(fiber.Map{"error": "Invalid token", "details": err.Error()})
|
|
}
|
|
|
|
// Validate claims
|
|
if err := m.validateClaims(claims); err != nil {
|
|
return c.Status(401).JSON(fiber.Map{"error": "Invalid token claims", "details": err.Error()})
|
|
}
|
|
|
|
// Set user context
|
|
c.Locals("user_id", claims.Subject)
|
|
c.Locals("user_email", claims.Email)
|
|
c.Locals("user_name", claims.Name)
|
|
c.Locals("user_roles", claims.Roles)
|
|
c.Locals("mfa_verified", claims.MFAVerified)
|
|
|
|
// Check admin access for admin endpoints
|
|
if m.isAdminEndpoint(c.Path()) {
|
|
if !m.hasAdminRole(claims.Roles) {
|
|
return c.Status(403).JSON(fiber.Map{"error": "Admin access required"})
|
|
}
|
|
|
|
if !claims.MFAVerified {
|
|
return c.Status(403).JSON(fiber.Map{"error": "MFA verification required for admin access"})
|
|
}
|
|
}
|
|
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
func (m *JWTMiddleware) validateClaims(claims *auth.AuthClaims) error {
|
|
// Validate issuer
|
|
if claims.Issuer != m.config.Issuer {
|
|
return fmt.Errorf("invalid issuer: expected %s, got %s", m.config.Issuer, claims.Issuer)
|
|
}
|
|
|
|
// Validate audience
|
|
validAudience := false
|
|
for _, aud := range claims.Audience {
|
|
if aud == m.config.Audience {
|
|
validAudience = true
|
|
break
|
|
}
|
|
}
|
|
if !validAudience {
|
|
return fmt.Errorf("invalid audience")
|
|
}
|
|
|
|
// Validate expiration
|
|
if time.Unix(claims.ExpiresAt, 0).Before(time.Now()) {
|
|
return fmt.Errorf("token expired")
|
|
}
|
|
|
|
// Validate required claims
|
|
for _, requiredClaim := range m.config.RequiredClaims {
|
|
switch requiredClaim {
|
|
case "email":
|
|
if claims.Email == "" {
|
|
return fmt.Errorf("email claim required")
|
|
}
|
|
case "name":
|
|
if claims.Name == "" {
|
|
return fmt.Errorf("name claim required")
|
|
}
|
|
case "roles":
|
|
if len(claims.Roles) == 0 {
|
|
return fmt.Errorf("roles claim required")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *JWTMiddleware) isAdminEndpoint(path string) bool {
|
|
for _, adminPath := range m.config.AdminEndpoints {
|
|
if strings.HasPrefix(path, adminPath) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *JWTMiddleware) hasAdminRole(roles []string) bool {
|
|
for _, role := range roles {
|
|
if role == "admin" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Token refresh middleware
|
|
func (m *JWTMiddleware) RefreshTokenIfNeeded() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
userID := c.Locals("user_id")
|
|
if userID == nil {
|
|
return c.Next()
|
|
}
|
|
|
|
// Check if token is close to expiration (within 5 minutes)
|
|
tokenString := strings.TrimPrefix(c.Get("Authorization"), "Bearer ")
|
|
|
|
token, err := jwt.Parse(tokenString, nil)
|
|
if err != nil {
|
|
// Token parsing failed, but that's handled by ValidateToken middleware
|
|
return c.Next()
|
|
}
|
|
|
|
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
|
if exp, ok := claims["exp"].(float64); ok {
|
|
expirationTime := time.Unix(int64(exp), 0)
|
|
if time.Until(expirationTime) < 5*time.Minute {
|
|
// Signal frontend to refresh token
|
|
c.Set("X-Token-Refresh-Required", "true")
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.Next()
|
|
}
|
|
}
|
|
```
|
|
|
|
## Monitoring and Metrics
|
|
|
|
### Zitadel Integration Metrics
|
|
```go
|
|
// Metrics for Zitadel integration monitoring
|
|
package metrics
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
)
|
|
|
|
var (
|
|
zitadelRequestsTotal = promauto.NewCounterVec(
|
|
prometheus.CounterOpts{
|
|
Name: "zitadel_requests_total",
|
|
Help: "Total number of requests to Zitadel",
|
|
},
|
|
[]string{"operation", "status"},
|
|
)
|
|
|
|
zitadelRequestDuration = promauto.NewHistogramVec(
|
|
prometheus.HistogramOpts{
|
|
Name: "zitadel_request_duration_seconds",
|
|
Help: "Duration of Zitadel requests",
|
|
Buckets: prometheus.DefBuckets,
|
|
},
|
|
[]string{"operation"},
|
|
)
|
|
|
|
authenticationAttempts = promauto.NewCounterVec(
|
|
prometheus.CounterOpts{
|
|
Name: "authentication_attempts_total",
|
|
Help: "Total number of authentication attempts",
|
|
},
|
|
[]string{"method", "result"},
|
|
)
|
|
|
|
circuitBreakerState = promauto.NewGaugeVec(
|
|
prometheus.GaugeOpts{
|
|
Name: "circuit_breaker_state",
|
|
Help: "Circuit breaker state (0=closed, 1=open, 2=half-open)",
|
|
},
|
|
[]string{"service"},
|
|
)
|
|
)
|
|
|
|
// Instrumented Zitadel repository
|
|
type InstrumentedZitadelRepository struct {
|
|
repo auth.ZitadelRepository
|
|
}
|
|
|
|
func NewInstrumentedZitadelRepository(repo auth.ZitadelRepository) auth.ZitadelRepository {
|
|
return &InstrumentedZitadelRepository{repo: repo}
|
|
}
|
|
|
|
func (r *InstrumentedZitadelRepository) ValidateToken(ctx context.Context, token string) (*auth.AuthClaims, error) {
|
|
start := time.Now()
|
|
|
|
claims, err := r.repo.ValidateToken(ctx, token)
|
|
|
|
duration := time.Since(start).Seconds()
|
|
status := "success"
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
|
|
zitadelRequestsTotal.WithLabelValues("validate_token", status).Inc()
|
|
zitadelRequestDuration.WithLabelValues("validate_token").Observe(duration)
|
|
|
|
if err == nil {
|
|
authenticationAttempts.WithLabelValues("jwt", "success").Inc()
|
|
} else {
|
|
authenticationAttempts.WithLabelValues("jwt", "failure").Inc()
|
|
}
|
|
|
|
return claims, err
|
|
}
|
|
|
|
func (r *InstrumentedZitadelRepository) RefreshToken(ctx context.Context, refreshToken string) (*auth.TokenResponse, error) {
|
|
start := time.Now()
|
|
|
|
tokenResp, err := r.repo.RefreshToken(ctx, refreshToken)
|
|
|
|
duration := time.Since(start).Seconds()
|
|
status := "success"
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
|
|
zitadelRequestsTotal.WithLabelValues("refresh_token", status).Inc()
|
|
zitadelRequestDuration.WithLabelValues("refresh_token").Observe(duration)
|
|
|
|
return tokenResp, err
|
|
}
|
|
```
|
|
|
|
This Zitadel integration architecture provides secure, scalable authentication with proper monitoring and resilience patterns for the Know Foolery quiz game. |