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.

23 KiB

Know Foolery - Zitadel Integration Guidelines

Zitadel Configuration

Project and Application Setup

# 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

// 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)

// 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

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

// 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

// 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.