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