Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,27 @@ auth:
| `scopes` | array | OAuth2 scopes. Include `offline_access` to enable token refresh |
| `callbackUrl` | string | OAuth2 callback URL for your deployment |
| `options` | object | Additional URL parameters for the auth redirect |
| `useIdTokenAsBearer` | boolean | Use ID token instead of access token in Authorization header |
| `useIdTokenAsBearer` | boolean | Use ID token instead of access token in Authorization header |
| `refreshTokenDuration` | duration | Lifetime of the refresh token issued by this provider (e.g., `24h`, `168h`). When unset, the server attempts to derive it from the refresh token's JWT `exp` claim. Falls back to a 7-day default for opaque tokens. Set this to match your IdP's refresh token lifetime. |

### Docker Environment Variables

When using the default `docker.yaml`, all auth settings are configurable via environment variables:

| Environment Variable | Config Field | Default |
| ------------------------------------- | ---------------------------------- | -------- |
| `TEMPORAL_AUTH_ENABLED` | `auth.enabled` | `false` |
| `TEMPORAL_MAX_SESSION_DURATION` | `auth.maxSessionDuration` | `2m` |
| `TEMPORAL_AUTH_LABEL` | `auth.providers[0].label` | `sso` |
| `TEMPORAL_AUTH_TYPE` | `auth.providers[0].type` | `oidc` |
| `TEMPORAL_AUTH_PROVIDER_URL` | `auth.providers[0].providerUrl` | β€” |
| `TEMPORAL_AUTH_ISSUER_URL` | `auth.providers[0].issuerUrl` | β€” |
| `TEMPORAL_AUTH_CLIENT_ID` | `auth.providers[0].clientId` | β€” |
| `TEMPORAL_AUTH_CLIENT_SECRET` | `auth.providers[0].clientSecret` | β€” |
| `TEMPORAL_AUTH_CALLBACK_URL` | `auth.providers[0].callbackUrl` | β€” |
| `TEMPORAL_AUTH_SCOPES` | `auth.providers[0].scopes` | β€” |
| `TEMPORAL_AUTH_USE_ID_TOKEN_AS_BEARER`| `auth.providers[0].useIdTokenAsBearer` | `false` |
| `TEMPORAL_AUTH_REFRESH_TOKEN_DURATION`| `auth.providers[0].refreshTokenDuration` | auto-detected or 7 days |

## Session Duration Management

Expand Down Expand Up @@ -219,6 +239,18 @@ Ensure:
- Refresh tokens are enabled in your IdP configuration
- The refresh token hasn't expired (check IdP settings)

If token refresh fails with 401 immediately after the access token expires, your IdP likely issues **opaque refresh tokens** (non-JWT), which means the server cannot automatically derive their lifetime. Set `refreshTokenDuration` explicitly to match your IdP's refresh token lifetime:

```yaml
auth:
providers:
- label: My IdP
# ...
refreshTokenDuration: 24h # match your IdP's refresh token TTL
```

For IdPs that issue **JWT refresh tokens** (e.g. Keycloak), the server derives the lifetime automatically from the token's `exp` claim β€” no configuration needed.

### Redirect loop after login

Verify:
Expand Down
64 changes: 64 additions & 0 deletions docker-compose.e2e-auth.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
version: '3.8'

services:
oidc-server:
image: node:22-alpine
working_dir: /app
volumes:
- .:/app
- /app/node_modules
environment:
OIDC_PORT: '8889'
OIDC_ISSUER: 'http://oidc-server:8889'
command: >
sh -c "corepack enable &&
pnpm install --frozen-lockfile &&
pnpm exec esno scripts/start-oidc-server-e2e-auth.ts"
ports:
- '8889:8889'
healthcheck:
test:
[
'CMD',
'wget',
'--quiet',
'--tries=1',
'--spider',
'http://localhost:8889/.well-known/openid-configuration',
]
interval: 5s
timeout: 5s
retries: 12
start_period: 30s

ui-server:
build:
context: ./server
dockerfile: Dockerfile
target: ui-server
environment:
TEMPORAL_ADDRESS: '127.0.0.1:7233'
TEMPORAL_UI_PORT: '8082'
TEMPORAL_AUTH_ENABLED: 'true'
TEMPORAL_MAX_SESSION_DURATION: '15s'
TEMPORAL_AUTH_PROVIDER_URL: 'http://oidc-server:8889'
TEMPORAL_AUTH_ISSUER_URL: 'http://oidc-server:8889'
TEMPORAL_AUTH_CLIENT_ID: 'temporal-ui'
TEMPORAL_AUTH_CLIENT_SECRET: 'temporal-secret'
TEMPORAL_AUTH_CALLBACK_URL: 'http://localhost:8082/auth/sso/callback'
TEMPORAL_AUTH_SCOPES: 'openid,profile,email,offline_access'
TEMPORAL_AUTH_REFRESH_TOKEN_DURATION: '30s'
TEMPORAL_CSRF_COOKIE_INSECURE: 'true'
TEMPORAL_CORS_ORIGINS: 'http://localhost:8082'
ports:
- '8082:8082'
depends_on:
oidc-server:
condition: service_healthy
healthcheck:
test:
['CMD', 'curl', '--fail', '--silent', 'http://localhost:8082/healthz']
interval: 5s
timeout: 5s
retries: 12
start_period: 30s
87 changes: 87 additions & 0 deletions docker-compose.e2e-keycloak.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
version: '3.8'

# =============================================================================
# E2E Auth Test Stack β€” Real Keycloak
# =============================================================================
#
# Starts a Keycloak 26 instance pre-loaded with the "temporal" realm and a
# Temporal UI server configured against it. Used to verify that the UI server
# correctly derives refresh cookie MaxAge from Keycloak's JWT refresh token
# `exp` claim rather than from the `expires_in` (access token) field.
#
# Token lifetimes (defined in keycloak/realm-temporal.json):
# Access token: 5s β€” forces token refresh within the first test step
# SSO session: 30s β€” controls refresh token lifetime in Keycloak
# maxSessionDuration: 25s β€” UI enforces session boundary before Keycloak's
#
# TEMPORAL_AUTH_REFRESH_TOKEN_DURATION is intentionally NOT set so that only
# the JWT exp path is exercised (no config fallback).
#
# Ports:
# 8080 β€” Keycloak admin console / OIDC endpoints
# 8083 β€” Temporal UI server
# =============================================================================

services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: ["start-dev", "--import-realm"]
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HTTP_PORT: '8080'
KC_HTTP_MANAGEMENT_PORT: '9000'
KC_HOSTNAME_URL: 'http://localhost:8080'
KC_HOSTNAME_ADMIN_URL: 'http://localhost:8080'
KC_HOSTNAME_STRICT: 'false'
KC_HOSTNAME_STRICT_HTTPS: 'false'
KC_HTTP_ENABLED: 'true'
KC_HEALTH_ENABLED: 'true'
KC_METRICS_ENABLED: 'false'
volumes:
- ./keycloak:/opt/keycloak/data/import:ro
ports:
- '8080:8080'
healthcheck:
test:
[
'CMD-SHELL',
'exec 3<>/dev/tcp/127.0.0.1/9000 && printf "GET /health/ready HTTP/1.0\r\nHost: localhost\r\n\r\n" >&3 && grep -q "UP" <&3 || exit 1',
]
interval: 10s
timeout: 10s
retries: 18
start_period: 60s

ui-server:
build:
context: ./server
dockerfile: Dockerfile
target: ui-server
environment:
TEMPORAL_ADDRESS: '127.0.0.1:7233'
TEMPORAL_UI_PORT: '8083'
TEMPORAL_AUTH_ENABLED: 'true'
TEMPORAL_MAX_SESSION_DURATION: '25s'
TEMPORAL_AUTH_PROVIDER_URL: 'http://localhost:8080/realms/temporal'
TEMPORAL_AUTH_ISSUER_URL: 'http://localhost:8080/realms/temporal'
TEMPORAL_AUTH_CLIENT_ID: 'temporal-ui'
TEMPORAL_AUTH_CLIENT_SECRET: 'temporal-secret'
TEMPORAL_AUTH_CALLBACK_URL: 'http://localhost:8083/auth/sso/callback'
TEMPORAL_AUTH_SCOPES: 'openid,profile,email'
TEMPORAL_CSRF_COOKIE_INSECURE: 'true'
TEMPORAL_CORS_ORIGINS: 'http://localhost:8083'
ports:
- '8083:8083'
extra_hosts:
- 'localhost:host-gateway'
depends_on:
keycloak:
condition: service_healthy
healthcheck:
test:
['CMD', 'curl', '--fail', '--silent', 'http://localhost:8083/healthz']
interval: 5s
timeout: 5s
retries: 12
start_period: 30s
69 changes: 69 additions & 0 deletions keycloak/realm-temporal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"realm": "temporal",
"displayName": "Temporal",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginWithEmailAllowed": true,

"accessTokenLifespan": 5,
"ssoSessionIdleTimeout": 30,
"ssoSessionMaxLifespan": 30,
"accessCodeLifespan": 60,
"accessCodeLifespanUserAction": 300,
"accessCodeLifespanLogin": 1800,

"clients": [
{
"clientId": "temporal-ui",
"name": "Temporal UI",
"enabled": true,
"publicClient": false,
"secret": "temporal-secret",
"redirectUris": ["http://localhost:8083/auth/sso/callback"],
"webOrigins": ["http://localhost:8083"],
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"fullScopeAllowed": true,
"defaultClientScopes": ["web-origins", "profile", "roles", "email"],
"optionalClientScopes": ["address", "phone", "microprofile-jwt"],
"attributes": {
"use.refresh.tokens": "true"
}
}
],

"users": [
{
"username": "user@example.com",
"email": "user@example.com",
"firstName": "Test",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"requiredActions": [],
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
]
}
],

"roles": {
"realm": [
{
"name": "user",
"description": "Default user role"
}
]
},

"scopeMappings": [],
"clientScopeMappings": {}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
"test:e2e": "PW_MODE=e2e playwright test tests/e2e",
"test:e2e:ui": "pnpm test:e2e --ui",
"test:integration": "PW_MODE=integration playwright test tests/integration",
"test:e2e:auth": "playwright test --config playwright.e2e-auth.config.ts",
"test:e2e:keycloak": "NO_PROXY=localhost,127.0.0.1 no_proxy=localhost,127.0.0.1 playwright test --config playwright.e2e-keycloak.config.ts",
"test:integration:ui": "PW_MODE=integration playwright test --ui tests/integration",
"lint": "pnpm prettier; pnpm eslint; pnpm stylelint",
"lint:ci": "pnpm prettier && pnpm eslint && pnpm stylelint",
Expand Down
33 changes: 33 additions & 0 deletions playwright.e2e-auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests/e2e-auth',
timeout: 90 * 1000,
expect: {
timeout: 15000,
},
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: [
['html', { outputFolder: 'playwright-report/e2e-auth' }],
['json', { outputFile: 'playwright-report/e2e-auth/test-results.json' }],
[process.env.CI ? 'github' : 'list'],
],
use: {
baseURL: 'http://localhost:8082',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
actionTimeout: 15000,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
],
});
36 changes: 36 additions & 0 deletions playwright.e2e-keycloak.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests/e2e-keycloak',
timeout: 90 * 1000,
expect: {
timeout: 15000,
},
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: [
['html', { outputFolder: 'playwright-report/e2e-keycloak' }],
[
'json',
{ outputFile: 'playwright-report/e2e-keycloak/test-results.json' },
],
[process.env.CI ? 'github' : 'list'],
],
use: {
baseURL: 'http://localhost:8083',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
actionTimeout: 15000,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
],
});
28 changes: 28 additions & 0 deletions scripts/start-oidc-server-e2e-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
Account,
getConfig,
OIDCServer,
routes,
} from '../utilities/oidc-server';
import providerConfiguration from '../utilities/oidc-server/support/configuration.e2e-auth';

const { PORT, ISSUER, VIEWS_PATH } = getConfig();

const server = new OIDCServer({
issuer: ISSUER,
port: PORT,
viewsPath: VIEWS_PATH,
providerConfiguration,
accountModel: Account,
routes,
});

server.start().catch(async (error) => {
console.error(error);
server.stop();
process.exit(1);
});

process.on('beforeExit', () => {
if (server) server.stop();
});
2 changes: 2 additions & 0 deletions server/config/docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ uiServerTLS:
keyFile: {{ env "TEMPORAL_UI_SERVER_TLS_KEY" | default "" }}
auth:
enabled: {{ env "TEMPORAL_AUTH_ENABLED" | default "false" }}
maxSessionDuration: {{ env "TEMPORAL_MAX_SESSION_DURATION" | default "2m" }}
providers:
- label: {{ env "TEMPORAL_AUTH_LABEL" | default "sso" }}
type: {{ env "TEMPORAL_AUTH_TYPE" | default "oidc" }}
Expand All @@ -58,6 +59,7 @@ auth:
clientSecret: {{ env "TEMPORAL_AUTH_CLIENT_SECRET" }}
callbackUrl: {{ env "TEMPORAL_AUTH_CALLBACK_URL" }}
useIdTokenAsBearer: {{ env "TEMPORAL_AUTH_USE_ID_TOKEN_AS_BEARER" | default "false" }}
refreshTokenDuration: {{ env "TEMPORAL_AUTH_REFRESH_TOKEN_DURATION" | default "" }}
scopes:
{{- if env "TEMPORAL_AUTH_SCOPES" }}
{{- range env "TEMPORAL_AUTH_SCOPES" | split "," }}
Expand Down
Loading
Loading