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.
This commit is contained in:
28
custom-server/Dockerfile
Normal file
28
custom-server/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
# Multi-stage build for custom Temporal server with authorization
|
||||
FROM golang:1.21 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY main.go ./
|
||||
|
||||
# Build the server
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o temporal-server main.go
|
||||
|
||||
# Final stage
|
||||
FROM temporalio/auto-setup:1.29.0
|
||||
|
||||
# Copy the custom server binary
|
||||
COPY --from=builder /app/temporal-server /usr/local/bin/temporal-server
|
||||
|
||||
# Copy configuration
|
||||
COPY config/ /etc/temporal/config/
|
||||
|
||||
# The auto-setup image's entrypoint will handle initialization
|
||||
# We'll override the command to use our custom binary
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["temporal-server", "start"]
|
||||
271
custom-server/README.md
Normal file
271
custom-server/README.md
Normal file
@ -0,0 +1,271 @@
|
||||
# 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('<namespace>:<role>');
|
||||
```
|
||||
|
||||
Where:
|
||||
- `<namespace>` is your Temporal namespace name
|
||||
- `<role>` 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)
|
||||
99
custom-server/config/development.yaml
Normal file
99
custom-server/config/development.yaml
Normal file
@ -0,0 +1,99 @@
|
||||
# Temporal Server Configuration with JWT Authorization
|
||||
|
||||
global:
|
||||
authorization:
|
||||
# JWT token validation settings
|
||||
jwtKeyProvider:
|
||||
# JWKS endpoint from your Keycloak - this provides the public keys
|
||||
# Format: https://your-keycloak.com/realms/yourRealm/protocol/openid-connect/certs
|
||||
keySourceURIs:
|
||||
- ${TEMPORAL_AUTH_ISSUER_URL}/.well-known/jwks.json
|
||||
# How often to refresh the keys (in Go duration format)
|
||||
refreshInterval: 1h
|
||||
|
||||
# The claim name in the JWT that contains permissions
|
||||
# Default is "permissions" but you can customize this based on your Keycloak setup
|
||||
permissionsClaimName: "permissions"
|
||||
|
||||
# Database configuration
|
||||
persistence:
|
||||
defaultStore: default
|
||||
visibilityStore: visibility
|
||||
numHistoryShards: 4
|
||||
datastores:
|
||||
default:
|
||||
sql:
|
||||
pluginName: "postgres12"
|
||||
databaseName: "temporal"
|
||||
connectAddr: "db:5432"
|
||||
connectProtocol: "tcp"
|
||||
user: "temporal"
|
||||
password: "${POSTGRES_PWD}"
|
||||
maxConns: 20
|
||||
maxIdleConns: 20
|
||||
maxConnLifetime: "1h"
|
||||
visibility:
|
||||
sql:
|
||||
pluginName: "postgres12"
|
||||
databaseName: "temporal_visibility"
|
||||
connectAddr: "db:5432"
|
||||
connectProtocol: "tcp"
|
||||
user: "temporal"
|
||||
password: "${POSTGRES_PWD}"
|
||||
maxConns: 10
|
||||
maxIdleConns: 10
|
||||
maxConnLifetime: "1h"
|
||||
|
||||
# Services configuration
|
||||
services:
|
||||
frontend:
|
||||
rpc:
|
||||
grpcPort: 7233
|
||||
membershipPort: 6933
|
||||
bindOnIP: "0.0.0.0"
|
||||
|
||||
matching:
|
||||
rpc:
|
||||
grpcPort: 7235
|
||||
membershipPort: 6935
|
||||
bindOnIP: "0.0.0.0"
|
||||
|
||||
history:
|
||||
rpc:
|
||||
grpcPort: 7234
|
||||
membershipPort: 6934
|
||||
bindOnIP: "0.0.0.0"
|
||||
|
||||
worker:
|
||||
rpc:
|
||||
grpcPort: 7239
|
||||
membershipPort: 6939
|
||||
bindOnIP: "0.0.0.0"
|
||||
|
||||
clusterMetadata:
|
||||
enableGlobalNamespace: false
|
||||
failoverVersionIncrement: 10
|
||||
masterClusterName: "active"
|
||||
currentClusterName: "active"
|
||||
clusterInformation:
|
||||
active:
|
||||
enabled: true
|
||||
initialFailoverVersion: 1
|
||||
rpcName: "frontend"
|
||||
rpcAddress: "127.0.0.1:7233"
|
||||
|
||||
dcRedirectionPolicy:
|
||||
policy: "noop"
|
||||
|
||||
archival:
|
||||
history:
|
||||
state: "disabled"
|
||||
visibility:
|
||||
state: "disabled"
|
||||
|
||||
publicClient:
|
||||
hostPort: "127.0.0.1:7233"
|
||||
|
||||
dynamicConfigClient:
|
||||
filepath: "/etc/temporal/config/dynamicconfig/development-sql.yaml"
|
||||
pollInterval: "10s"
|
||||
13
custom-server/example-keycloak-mapper.json
Normal file
13
custom-server/example-keycloak-mapper.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "temporal-permissions-mapper",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-script-based-protocol-mapper",
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"multivalued": "true",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "permissions",
|
||||
"script": "/**\n * This script maps Keycloak groups to Temporal permissions.\n * \n * Temporal expects permissions in the format: 'namespace:role'\n * where role can be: read, write, worker, admin\n * \n * Customize the mapping below based on your Keycloak groups.\n */\n\nvar ArrayList = Java.type('java.util.ArrayList');\nvar permissions = new ArrayList();\n\n// Get user's groups\nvar groups = user.getGroups();\n\n// Iterate through groups and map to Temporal permissions\nfor each (var group in groups) {\n var groupName = group.getName();\n \n // System administrators - full access to everything\n if (groupName === 'temporal-admins' || groupName === '/temporal-admins') {\n permissions.add('temporal-system:admin');\n }\n \n // Development team - write access to dev/staging, read to production\n else if (groupName === 'dev-team' || groupName === '/dev-team') {\n permissions.add('development:write');\n permissions.add('staging:write');\n permissions.add('production:read');\n }\n \n // Operations team - full access to production\n else if (groupName === 'ops-team' || groupName === '/ops-team') {\n permissions.add('production:admin');\n permissions.add('staging:write');\n permissions.add('development:read');\n }\n \n // QA team - read access everywhere\n else if (groupName === 'qa-team' || groupName === '/qa-team') {\n permissions.add('development:read');\n permissions.add('staging:read');\n permissions.add('production:read');\n }\n \n // Service accounts for workers\n else if (groupName === 'temporal-workers' || groupName === '/temporal-workers') {\n permissions.add('production:worker');\n permissions.add('staging:worker');\n permissions.add('development:worker');\n }\n \n // Support team - read only access\n else if (groupName === 'support-team' || groupName === '/support-team') {\n permissions.add('production:read');\n }\n}\n\n// Return the permissions array\npermissions;"
|
||||
}
|
||||
}
|
||||
7
custom-server/go.mod
Normal file
7
custom-server/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module custom-temporal-server
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
go.temporal.io/server v1.29.0
|
||||
)
|
||||
51
custom-server/main.go
Normal file
51
custom-server/main.go
Normal file
@ -0,0 +1,51 @@
|
||||
// Custom Temporal Server with JWT Authorization
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"go.temporal.io/server/common/authorization"
|
||||
"go.temporal.io/server/common/config"
|
||||
"go.temporal.io/server/temporal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load Temporal configuration
|
||||
cfg, err := config.LoadConfig("development", "./config", "")
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load config:", err)
|
||||
}
|
||||
|
||||
// Create Temporal server with authorization
|
||||
s, err := temporal.NewServer(
|
||||
temporal.ForServices(temporal.DefaultServices),
|
||||
temporal.WithConfig(cfg),
|
||||
temporal.InterruptOn(temporal.InterruptCh()),
|
||||
|
||||
// Configure JWT ClaimMapper
|
||||
temporal.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper {
|
||||
return authorization.NewDefaultJWTClaimMapper(
|
||||
// Token key provider - fetches public keys from your OIDC provider
|
||||
authorization.NewDefaultTokenKeyProvider(cfg, log.Default()),
|
||||
cfg,
|
||||
log.Default(),
|
||||
)
|
||||
}),
|
||||
|
||||
// Configure Authorizer
|
||||
temporal.WithAuthorizer(authorization.NewDefaultAuthorizer()),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create server:", err)
|
||||
}
|
||||
|
||||
// Start the server
|
||||
log.Println("Starting Temporal Server with JWT Authorization...")
|
||||
err = s.Start()
|
||||
if err != nil {
|
||||
log.Fatal("Server failed:", err)
|
||||
}
|
||||
|
||||
log.Println("Server stopped.")
|
||||
}
|
||||
Reference in New Issue
Block a user