Files
temporal/custom-server
Christian Galo 02b4ec9ee3 Add JWT-based authorization support for Temporal server with Keycloak integration
- Create QUICK_REFERENCE.md for a concise guide on setting up temporal authorization.
- Add README_AUTHORIZATION.md detailing the implementation steps and common issues.
- Introduce REVERSE_PROXY_APPROACH.md as an alternative method for authorization using a reverse proxy.
- Implement Dockerfile for building a custom Temporal server with authorization features.
- Add main.go to initialize the custom Temporal server with JWT authorization.
- Create example-keycloak-mapper.json for mapping Keycloak groups to Temporal permissions.
- Add development.yaml for configuring the Temporal server with JWT settings.
- Implement test-authorization.sh script to verify JWT token claims and Temporal server access.
- Include go.mod for managing Go dependencies in the custom server.
- Document troubleshooting steps and customization options in README.md.
2025-10-24 02:10:54 +00:00
..

Custom Temporal Server with OIDC Authorization

This directory contains the code to build a custom Temporal server with JWT-based authorization support for your Keycloak/OIDC setup.

Quick Start

1. Update your Docker Compose

Edit your compose.yaml to build and use the custom server:

services:
  temporal:
    build:
      context: ./custom-server
      dockerfile: Dockerfile
    depends_on:
      - db
    configs:
      - source: entrypoint
        target: /entrypoint.sh
        mode: 0555
      - source: dynamicconfig
        target: /etc/temporal/config/dynamicconfig/development-sql.yaml
    entrypoint: /entrypoint.sh
    command: "autosetup"
    environment:
      - DB=postgres12
      - DB_PORT=5432
      - POSTGRES_USER=temporal
      - POSTGRES_PWD_FILE=/run/secrets/db_password
      - POSTGRES_SEEDS=db
      - DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development-sql.yaml
      # Add this new environment variable for JWKS endpoint
      - TEMPORAL_AUTH_ISSUER_URL=${TEMPORAL_AUTH_ISSUER_URL}
    networks:
      - internal
    secrets:
      - db_password

2. Set Environment Variables

Add to your .env file:

# Your Keycloak realm issuer URL (without .well-known/openid-configuration)
TEMPORAL_AUTH_ISSUER_URL=https://your-keycloak.com/realms/your-realm

3. Configure Keycloak

Import the Protocol Mapper

  1. Go to Keycloak Admin Console
  2. Navigate to: Clients → [your temporal-ui client] → Client scopes → [client-scope] → Mappers
  3. Click "Add mapper" → "By configuration" → "Script Mapper"
  4. Import settings from example-keycloak-mapper.json or create manually

Create Groups

Create the following groups in Keycloak (or customize the mapper script):

  • temporal-admins - Full system access
  • dev-team - Development namespace access
  • ops-team - Production namespace access
  • qa-team - Read-only access
  • temporal-workers - Worker service accounts

Assign Users to Groups

  1. Go to Users → [user] → Groups
  2. Add users to appropriate groups

4. Build and Deploy

# Build the custom server
docker-compose build temporal

# Deploy
docker stack deploy temporal --detach=true -c compose.yaml

# Or with docker-compose
docker-compose up -d

5. Test

  1. Log in to Temporal UI as a user in the dev-team group
  2. Try to access the development namespace - should work
  3. Try to start a workflow - should work (write access)
  4. Try to access production namespace - should only be able to view (read access)

Log in as a user in the qa-team group:

  1. Should only be able to view workflows
  2. Should not be able to start or terminate workflows

Customization

Modify Permission Mappings

Edit the script in example-keycloak-mapper.json to match your organization's structure. The format is:

permissions.add('<namespace>:<role>');

Where:

  • <namespace> is your Temporal namespace name
  • <role> is one of: read, write, worker, admin

Examples:

permissions.add('production:admin');      // Full access to production
permissions.add('development:write');     // Read and write to development
permissions.add('staging:read');          // Read-only access to staging
permissions.add('temporal-system:admin'); // Cluster admin

Use Different Claim Names

If you want to use a different claim name instead of permissions, edit config/development.yaml:

global:
  authorization:
    permissionsClaimName: "temporal_perms"  # Use this claim from JWT

Advanced: Custom ClaimMapper

If you need more complex logic (like mapping based on email domains, user attributes, or multiple claims), you can implement a custom ClaimMapper.

Create a new file custom-server/custom_claim_mapper.go:

package main

import (
    "encoding/json"
    "strings"
    
    "go.temporal.io/server/common/authorization"
)

type customClaimMapper struct{}

func newCustomClaimMapper() authorization.ClaimMapper {
    return &customClaimMapper{}
}

func (c *customClaimMapper) GetClaims(authInfo *authorization.AuthInfo) (*authorization.Claims, error) {
    claims := &authorization.Claims{}
    
    if authInfo.AuthToken == "" {
        return claims, nil
    }
    
    // Parse JWT - simple example, use a proper JWT library in production
    parts := strings.Split(authInfo.AuthToken, ".")
    if len(parts) != 3 {
        return claims, nil
    }
    
    // Decode payload (you should use a proper JWT library)
    // This is just for illustration
    var payload map[string]interface{}
    // ... decode base64 and unmarshal JSON
    
    // Extract groups from JWT
    groups, ok := payload["groups"].([]interface{})
    if !ok {
        return claims, nil
    }
    
    // Map groups to Temporal roles
    claims.Namespaces = make(map[string]authorization.Role)
    
    for _, g := range groups {
        group := g.(string)
        switch group {
        case "temporal-admins":
            claims.System = authorization.RoleAdmin
        case "dev-team":
            claims.Namespaces["development"] = authorization.RoleWriter
            claims.Namespaces["staging"] = authorization.RoleWriter
            claims.Namespaces["production"] = authorization.RoleReader
        case "ops-team":
            claims.Namespaces["production"] = authorization.RoleAdmin
        }
    }
    
    return claims, nil
}

Then update main.go to use it:

temporal.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper {
    return newCustomClaimMapper()
})

Troubleshooting

Enable Debug Logging

Add to your environment variables:

environment:
  - LOG_LEVEL=debug

Check JWT Token

Use this to decode and inspect your JWT token:

# Get token from browser (F12 → Network → find request with Authorization header)
TOKEN="your.jwt.token"

# Decode (install jq first: apt-get install jq)
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq .

Look for the permissions array.

Common Issues

Error: "PermissionDenied"

  • Check that the JWT contains the permissions claim
  • Verify the permissions are in the correct format: "namespace:role"
  • Ensure the JWKS URL is accessible from the Temporal server container

Error: "InvalidToken" or "TokenExpired"

  • Check that TEMPORAL_AUTH_ISSUER_URL is correct
  • Verify the JWKS endpoint is reachable: https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs
  • Ensure token hasn't expired (check exp claim)

Server won't start

  • Check server logs: docker logs temporal_temporal_1
  • Verify go.mod dependencies: cd custom-server && go mod tidy
  • Ensure config file syntax is correct: yamllint config/development.yaml

View Server Logs

# Docker Compose
docker-compose logs -f temporal

# Docker Swarm
docker service logs -f temporal_temporal

Files in This Directory

  • main.go - Custom Temporal server entrypoint with authorization
  • go.mod - Go module dependencies
  • config/development.yaml - Temporal configuration with JWT settings
  • Dockerfile - Multi-stage build for custom server
  • example-keycloak-mapper.json - Keycloak protocol mapper configuration
  • README.md - This file

References