# 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: ```yaml 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: ```bash # 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 ```bash # 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: ```javascript permissions.add(':'); ``` Where: - `` is your Temporal namespace name - `` is one of: `read`, `write`, `worker`, `admin` Examples: ```javascript 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`: ```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`: ```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: ```go temporal.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper { return newCustomClaimMapper() }) ``` ## Troubleshooting ### Enable Debug Logging Add to your environment variables: ```yaml environment: - LOG_LEVEL=debug ``` ### Check JWT Token Use this to decode and inspect your JWT token: ```bash # 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 ```bash # 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 - [Temporal Authorization Documentation](https://docs.temporal.io/self-hosted-guide/security#authorizer-plugin) - [Authorization Sample](https://github.com/temporalio/samples-server/tree/main/extensibility/authorizer) - [Keycloak Script Mappers](https://www.keycloak.org/docs/latest/server_development/#_script_providers)