diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md index ad503bb58b..935af5da08 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -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 @@ -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: diff --git a/docker-compose.e2e-auth.yaml b/docker-compose.e2e-auth.yaml new file mode 100644 index 0000000000..d28f23c91d --- /dev/null +++ b/docker-compose.e2e-auth.yaml @@ -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 diff --git a/docker-compose.e2e-keycloak.yaml b/docker-compose.e2e-keycloak.yaml new file mode 100644 index 0000000000..359b33da88 --- /dev/null +++ b/docker-compose.e2e-keycloak.yaml @@ -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 diff --git a/keycloak/realm-temporal.json b/keycloak/realm-temporal.json new file mode 100644 index 0000000000..6679ea3f27 --- /dev/null +++ b/keycloak/realm-temporal.json @@ -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": {} +} diff --git a/package.json b/package.json index b19d4d7a4a..271ea325c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.e2e-auth.config.ts b/playwright.e2e-auth.config.ts new file mode 100644 index 0000000000..5a9b38b525 --- /dev/null +++ b/playwright.e2e-auth.config.ts @@ -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 }, + }, + }, + ], +}); diff --git a/playwright.e2e-keycloak.config.ts b/playwright.e2e-keycloak.config.ts new file mode 100644 index 0000000000..ad14976add --- /dev/null +++ b/playwright.e2e-keycloak.config.ts @@ -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 }, + }, + }, + ], +}); diff --git a/scripts/start-oidc-server-e2e-auth.ts b/scripts/start-oidc-server-e2e-auth.ts new file mode 100644 index 0000000000..8305dbf0e5 --- /dev/null +++ b/scripts/start-oidc-server-e2e-auth.ts @@ -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(); +}); diff --git a/server/config/docker.yaml b/server/config/docker.yaml index 203a4d6c71..6b3b01102c 100644 --- a/server/config/docker.yaml +++ b/server/config/docker.yaml @@ -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" }} @@ -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 "," }} diff --git a/server/config/e2e-auth.yaml b/server/config/e2e-auth.yaml new file mode 100644 index 0000000000..b9c3d84d34 --- /dev/null +++ b/server/config/e2e-auth.yaml @@ -0,0 +1,53 @@ +publicPath: +port: 8082 +enableUi: true +cors: + cookieInsecure: true + allowOrigins: + - http://localhost:8082 +refreshInterval: 0s +defaultNamespace: default +showTemporalSystemNamespace: false +feedbackUrl: +disableWriteActions: false +workflowTerminateDisabled: false +workflowCancelDisabled: false +workflowSignalDisabled: false +workflowUpdateDisabled: false +workflowResetDisabled: false +batchActionsDisabled: false +startWorkflowDisabled: false +hideWorkflowQueryErrors: false +refreshWorkflowCountsDisabled: false +auth: + enabled: true + maxSessionDuration: 15s + providers: + - label: Dummy OIDC + type: oidc + providerUrl: http://localhost:8889 + issuerUrl: "" + clientId: temporal-ui + clientSecret: temporal-secret + scopes: + - openid + - profile + - email + - offline_access + callbackUrl: http://localhost:8082/auth/sso/callback + refreshTokenDuration: 30s +tls: + caFile: + certFile: + keyFile: + caData: + certData: + keyData: + enableHostVerification: false + serverName: +codec: + endpoint: + passAccessToken: false + includeCredentials: false +forwardHeaders: + - X-Forwarded-For diff --git a/server/config/e2e-keycloak.yaml b/server/config/e2e-keycloak.yaml new file mode 100644 index 0000000000..81008a1701 --- /dev/null +++ b/server/config/e2e-keycloak.yaml @@ -0,0 +1,51 @@ +publicPath: +port: 8083 +enableUi: true +cors: + cookieInsecure: true + allowOrigins: + - http://localhost:8083 +refreshInterval: 0s +defaultNamespace: default +showTemporalSystemNamespace: false +feedbackUrl: +disableWriteActions: false +workflowTerminateDisabled: false +workflowCancelDisabled: false +workflowSignalDisabled: false +workflowUpdateDisabled: false +workflowResetDisabled: false +batchActionsDisabled: false +startWorkflowDisabled: false +hideWorkflowQueryErrors: false +refreshWorkflowCountsDisabled: false +auth: + enabled: true + maxSessionDuration: 25s + providers: + - label: Keycloak + type: oidc + providerUrl: http://localhost:8080/realms/temporal + issuerUrl: "" + clientId: temporal-ui + clientSecret: temporal-secret + scopes: + - openid + - profile + - email + callbackUrl: http://localhost:8083/auth/sso/callback +tls: + caFile: + certFile: + keyFile: + caData: + certData: + keyData: + enableHostVerification: false + serverName: +codec: + endpoint: + passAccessToken: false + includeCredentials: false +forwardHeaders: + - X-Forwarded-For diff --git a/server/config/with-auth.yaml b/server/config/with-auth.yaml index 6c55876c44..64c7b173c3 100644 --- a/server/config/with-auth.yaml +++ b/server/config/with-auth.yaml @@ -50,6 +50,7 @@ auth: - email - offline_access callbackUrl: http://localhost:8081/auth/sso/callback + refreshTokenDuration: 24h # Set to match your IdP's refresh token lifetime (0 or omit = 7-day default) options: # added as URL query params when redirecting to auth provider audience: myorg-dev organization: org_xxxxxxxxxxxx diff --git a/server/server/auth/auth.go b/server/server/auth/auth.go index 13b5793e46..10835f8b2b 100644 --- a/server/server/auth/auth.go +++ b/server/server/auth/auth.go @@ -58,7 +58,7 @@ func stripBearerPrefix(token string) string { return strings.TrimPrefix(token, "Bearer ") } -func SetUser(c echo.Context, user *User) error { +func SetUser(c echo.Context, user *User, sessionExpiresAt time.Time, refreshTokenDuration time.Duration) error { if user.OAuth2Token == nil { return errors.New("no OAuth2Token") } @@ -81,6 +81,17 @@ func SetUser(c echo.Context, user *User) error { return errors.New("unable to serialize user data") } + userCookieMaxAge := int(time.Minute.Seconds()) + if !sessionExpiresAt.IsZero() { + remaining := int(time.Until(sessionExpiresAt).Seconds()) + if remaining <= 0 { + remaining = 1 + } + if remaining < userCookieMaxAge { + userCookieMaxAge = remaining + } + } + s := base64.StdEncoding.EncodeToString(b) parts := splitCookie(s) @@ -88,7 +99,7 @@ func SetUser(c echo.Context, user *User) error { cookie := &http.Cookie{ Name: "user" + strconv.Itoa(i), Value: p, - MaxAge: int(time.Minute.Seconds()), + MaxAge: userCookieMaxAge, Secure: c.Request().TLS != nil, HttpOnly: false, Path: "/", @@ -100,27 +111,19 @@ func SetUser(c echo.Context, user *User) error { if rt := user.OAuth2Token.RefreshToken; rt != "" { log.Println("[Auth] Setting refresh token cookie") - // Calculate MaxAge from OAuth2 token expiry. - // IMPORTANT: When a refresh token is issued, oauth2.Token.Expiry typically - // reflects the refresh token's lifetime, not the access token's lifetime. - // This is standard behavior in OIDC flows with offline_access scope. - // See: https://pkg.go.dev/golang.org/x/oauth2#Token - var refreshMaxAge int - if user.OAuth2Token.Expiry.IsZero() { - // Fallback: if IdP doesn't provide expiry, use 7 days - refreshMaxAge = int((7 * 24 * time.Hour).Seconds()) - log.Printf("[Auth] Warning: No refresh token expiry from IdP, using 7-day default") - } else { - // Use IdP's expiry, capped at 30 days for safety - maxAge := time.Until(user.OAuth2Token.Expiry) - if maxAge > 30*24*time.Hour { - maxAge = 30 * 24 * time.Hour - log.Printf("[Auth] Warning: IdP refresh token expiry > 30 days, capping at 30 days") + const defaultRefreshDuration = 7 * 24 * time.Hour + if exp, ok := jwtExp(rt); ok { + if remaining := time.Until(exp); remaining > 0 { + refreshTokenDuration = remaining + log.Printf("[Auth] Derived refresh cookie MaxAge from JWT exp: %d seconds (%.1f days)", int(remaining.Seconds()), remaining.Hours()/24) } - refreshMaxAge = int(maxAge.Seconds()) - log.Printf("[Auth] Setting refresh cookie MaxAge to %d seconds (%.1f days) from IdP", - refreshMaxAge, maxAge.Hours()/24) } + if refreshTokenDuration <= 0 { + refreshTokenDuration = defaultRefreshDuration + log.Printf("[Auth] No JWT exp found and no refreshTokenDuration configured, using 7-day default") + } + refreshMaxAge := int(refreshTokenDuration.Seconds()) + log.Printf("[Auth] Setting refresh cookie MaxAge to %d seconds (%.1f days)", refreshMaxAge, refreshTokenDuration.Hours()/24) refreshCookie := &http.Cookie{ Name: "refresh", @@ -261,6 +264,26 @@ func ValidateAuthHeaderExists(c echo.Context, cfgProvider *config.ConfigProvider return nil } +// jwtExp attempts to extract the exp claim from a JWT without verifying its signature. +// Returns the expiry time and true if successful, or zero and false for opaque tokens or malformed JWTs. +func jwtExp(token string) (time.Time, bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, false + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp == 0 { + return time.Time{}, false + } + return time.Unix(claims.Exp, 0), true +} + func splitCookie(val string) []string { splits := []string{} diff --git a/server/server/auth/auth_test.go b/server/server/auth/auth_test.go index f83c08039a..0bdf2279fe 100644 --- a/server/server/auth/auth_test.go +++ b/server/server/auth/auth_test.go @@ -24,8 +24,13 @@ package auth_test import ( _ "embed" + "encoding/base64" + "encoding/json" "net/http/httptest" + "strconv" + "strings" "testing" + "time" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" @@ -95,7 +100,7 @@ func TestSetUser(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - err := auth.SetUser(tt.ctx, &tt.user) + err := auth.SetUser(tt.ctx, &tt.user, time.Time{}, 0) cookies := tt.ctx.Cookies() if tt.wantErr { @@ -111,6 +116,176 @@ func TestSetUser(t *testing.T) { } } +func TestSetUserCookieMaxAge(t *testing.T) { + validUser := auth.User{ + OAuth2Token: &oauth2.Token{AccessToken: "XXX.YYY.ZZZ"}, + } + + extractMaxAge := func(header string) int { + for _, part := range strings.Split(header, ";") { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "max-age=") { + val := strings.SplitN(part, "=", 2)[1] + n, _ := strconv.Atoi(val) + return n + } + } + return -1 + } + + tests := []struct { + name string + sessionExpiresAt time.Time + wantMaxAge func(got int) bool + desc string + }{ + { + name: "no session limit uses 60s", + sessionExpiresAt: time.Time{}, + wantMaxAge: func(got int) bool { return got == 60 }, + desc: "zero sessionExpiresAt should default to 60s", + }, + { + name: "session expires in 2 minutes keeps 60s", + sessionExpiresAt: time.Now().Add(2 * time.Minute), + wantMaxAge: func(got int) bool { return got == 60 }, + desc: "remaining > 60s should still use 60s", + }, + { + name: "session expires in 30 seconds caps to ~30s", + sessionExpiresAt: time.Now().Add(30 * time.Second), + wantMaxAge: func(got int) bool { return got > 25 && got <= 30 }, + desc: "remaining < 60s should cap MaxAge to remaining", + }, + { + name: "session already expired clamps to 1", + sessionExpiresAt: time.Now().Add(-5 * time.Second), + wantMaxAge: func(got int) bool { return got == 1 }, + desc: "expired session should clamp MaxAge to 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(echo.GET, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + + err := auth.SetUser(ctx, &validUser, tt.sessionExpiresAt, 0) + assert.NoError(t, err, tt.desc) + + setCookie := rec.Header().Get(echo.HeaderSetCookie) + assert.Contains(t, setCookie, "user0", tt.desc) + + maxAge := extractMaxAge(setCookie) + assert.True(t, tt.wantMaxAge(maxAge), "%s: got MaxAge=%d", tt.desc, maxAge) + }) + } +} + +func jwtToken(exp int64) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + payload, _ := json.Marshal(map[string]int64{"exp": exp}) + return header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".sig" +} + +func TestSetUserRefreshCookieMaxAge(t *testing.T) { + extractCookieMaxAge := func(headers []string, name string) int { + for _, h := range headers { + parts := strings.Split(h, ";") + if !strings.HasPrefix(strings.TrimSpace(parts[0]), name+"=") { + continue + } + for _, part := range parts[1:] { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "max-age=") { + n, _ := strconv.Atoi(strings.SplitN(part, "=", 2)[1]) + return n + } + } + } + return -1 + } + + tests := []struct { + name string + refreshToken string + refreshTokenDuration time.Duration + wantMaxAge func(got int) bool + desc string + }{ + { + name: "opaque token without config uses 7-day default", + refreshToken: "opaque-refresh-token", + wantMaxAge: func(got int) bool { return got == int((7 * 24 * time.Hour).Seconds()) }, + desc: "opaque token with no config should fall back to 7 days", + }, + { + name: "opaque token with config uses configured duration", + refreshToken: "opaque-refresh-token", + refreshTokenDuration: 24 * time.Hour, + wantMaxAge: func(got int) bool { return got == int((24 * time.Hour).Seconds()) }, + desc: "opaque token with refreshTokenDuration=24h should set MaxAge to 86400s", + }, + { + name: "opaque token with short config duration", + refreshToken: "opaque-refresh-token", + refreshTokenDuration: 30 * time.Minute, + wantMaxAge: func(got int) bool { return got == int((30 * time.Minute).Seconds()) }, + desc: "opaque token with refreshTokenDuration=30m should set MaxAge to 1800s", + }, + { + name: "JWT exp used when no config", + refreshToken: jwtToken(time.Now().Add(12 * time.Hour).Unix()), + wantMaxAge: func(got int) bool { return got > int((11*time.Hour+55*time.Minute).Seconds()) && got <= int((12*time.Hour).Seconds()) }, + desc: "JWT exp should be used as MaxAge when refreshTokenDuration is unset", + }, + { + name: "JWT exp takes priority over configured duration", + refreshToken: jwtToken(time.Now().Add(6 * time.Hour).Unix()), + refreshTokenDuration: 24 * time.Hour, + wantMaxAge: func(got int) bool { return got > int((5*time.Hour+55*time.Minute).Seconds()) && got <= int((6*time.Hour).Seconds()) }, + desc: "JWT exp should override refreshTokenDuration when present", + }, + { + name: "expired JWT without config falls back to 7-day default", + refreshToken: jwtToken(time.Now().Add(-1 * time.Hour).Unix()), + wantMaxAge: func(got int) bool { return got == int((7 * 24 * time.Hour).Seconds()) }, + desc: "expired JWT exp with no config should fall back to 7 days", + }, + { + name: "expired JWT with config falls back to configured duration", + refreshToken: jwtToken(time.Now().Add(-1 * time.Hour).Unix()), + refreshTokenDuration: 24 * time.Hour, + wantMaxAge: func(got int) bool { return got == int((24 * time.Hour).Seconds()) }, + desc: "expired JWT exp should fall back to refreshTokenDuration config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(echo.GET, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + + user := auth.User{ + OAuth2Token: &oauth2.Token{ + AccessToken: "XXX.YYY.ZZZ", + RefreshToken: tt.refreshToken, + }, + } + + err := auth.SetUser(ctx, &user, time.Time{}, tt.refreshTokenDuration) + assert.NoError(t, err, tt.desc) + + maxAge := extractCookieMaxAge(rec.Header().Values(echo.HeaderSetCookie), "refresh") + assert.True(t, tt.wantMaxAge(maxAge), "%s: got MaxAge=%d", tt.desc, maxAge) + }) + } +} + func TestValidateAuthHeaderExists(t *testing.T) { tests := []struct { name string diff --git a/server/server/auth/jwt_test.go b/server/server/auth/jwt_test.go new file mode 100644 index 0000000000..71f9eba6ee --- /dev/null +++ b/server/server/auth/jwt_test.go @@ -0,0 +1,91 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func makeTestJWT(payload map[string]any) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + b, _ := json.Marshal(payload) + return header + "." + base64.RawURLEncoding.EncodeToString(b) + ".sig" +} + +func TestJWTExp(t *testing.T) { + future := time.Now().Add(2 * time.Hour).Unix() + past := time.Now().Add(-1 * time.Hour).Unix() + + tests := []struct { + name string + token string + wantOK bool + wantAfter time.Time + }{ + { + name: "valid JWT with future exp", + token: makeTestJWT(map[string]any{"exp": future}), + wantOK: true, + wantAfter: time.Now(), + }, + { + name: "valid JWT with past exp", + token: makeTestJWT(map[string]any{"exp": past}), + wantOK: true, + wantAfter: time.Time{}, + }, + { + name: "JWT missing exp field", + token: makeTestJWT(map[string]any{"sub": "user123"}), + wantOK: false, + }, + { + name: "JWT with exp zero", + token: makeTestJWT(map[string]any{"exp": 0}), + wantOK: false, + }, + { + name: "opaque token", + token: "opaque-refresh-token", + wantOK: false, + }, + { + name: "malformed base64 payload", + token: "header.!!!invalid!!!.sig", + wantOK: false, + }, + { + name: "only two parts", + token: "header.payload", + wantOK: false, + }, + { + name: "four parts", + token: "a.b.c.d", + wantOK: false, + }, + { + name: "empty string", + token: "", + wantOK: false, + }, + { + name: "valid base64 payload but not JSON", + token: "header." + base64.RawURLEncoding.EncodeToString([]byte("not-json")) + ".sig", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := jwtExp(tt.token) + assert.Equal(t, tt.wantOK, ok, "jwtExp ok mismatch") + if tt.wantOK && !tt.wantAfter.IsZero() { + assert.True(t, got.After(tt.wantAfter), "expected exp to be in the future, got %v", got) + } + }) + } +} diff --git a/server/server/config/config.go b/server/server/config/config.go index a569e689e9..f41c7b06a2 100644 --- a/server/server/config/config.go +++ b/server/server/config/config.go @@ -129,6 +129,11 @@ type ( Options map[string]interface{} `yaml:"options"` // UseIDTokenAsBearer - Use ID token instead of access token as Bearer in Authorization header UseIDTokenAsBearer bool `yaml:"useIdTokenAsBearer"` + // RefreshTokenDuration - optional lifetime of the refresh token issued by this provider. + // When set, the refresh cookie MaxAge is set to this value. When unset, defaults to 7 days. + // Set this to match your IdP's refresh token lifetime to avoid premature cookie expiry. + // Example values: "8h", "24h", "168h" (1 week). + RefreshTokenDuration time.Duration `yaml:"refreshTokenDuration"` } Codec struct { diff --git a/server/server/route/auth.go b/server/server/route/auth.go index fb63e6d16a..a7a3f2deec 100644 --- a/server/server/route/auth.go +++ b/server/server/route/auth.go @@ -110,9 +110,9 @@ func SetAuthRoutes(e *echo.Echo, cfgProvider *config.ConfigProviderWithRefresh) api := e.Group("/auth") api.GET("/sso", authenticate(&oauthCfg, providerCfg.Options, serverCfg.CORS.AllowOrigins)) - api.GET("/sso/callback", authenticateCb(ctx, &oauthCfg, provider, serverCfg.Auth.MaxSessionDuration)) - api.GET("/sso_callback", authenticateCb(ctx, &oauthCfg, provider, serverCfg.Auth.MaxSessionDuration)) // compatibility with UI v1 - api.GET("/refresh", refreshTokens(ctx, &oauthCfg, provider, serverCfg.Auth.MaxSessionDuration)) + api.GET("/sso/callback", authenticateCb(ctx, &oauthCfg, provider, serverCfg.Auth.MaxSessionDuration, providerCfg.RefreshTokenDuration)) + api.GET("/sso_callback", authenticateCb(ctx, &oauthCfg, provider, serverCfg.Auth.MaxSessionDuration, providerCfg.RefreshTokenDuration)) // compatibility with UI v1 + api.GET("/refresh", refreshTokens(ctx, &oauthCfg, provider, serverCfg.Auth.MaxSessionDuration, providerCfg.RefreshTokenDuration)) api.GET("/logout", logout()) } @@ -154,14 +154,19 @@ func authenticate(config *oauth2.Config, options map[string]interface{}, allowed } } -func authenticateCb(ctx context.Context, oauthCfg *oauth2.Config, provider *oidc.Provider, maxSessionDuration time.Duration) func(echo.Context) error { +func authenticateCb(ctx context.Context, oauthCfg *oauth2.Config, provider *oidc.Provider, maxSessionDuration time.Duration, refreshTokenDuration time.Duration) func(echo.Context) error { return func(c echo.Context) error { user, err := auth.ExchangeCode(ctx, c.Request(), oauthCfg, provider) if err != nil { return err } - err = auth.SetUser(c, user) + var sessionExpiresAt time.Time + if maxSessionDuration > 0 { + sessionExpiresAt = time.Now().Add(maxSessionDuration) + } + + err = auth.SetUser(c, user, sessionExpiresAt, refreshTokenDuration) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "unable to set user: "+err.Error()) } @@ -189,7 +194,7 @@ func authenticateCb(ctx context.Context, oauthCfg *oauth2.Config, provider *oidc // refreshTokens exchanges a refresh token (stored in an HttpOnly cookie) for a new access token // and optionally a new ID token. It resets the cookies using auth.SetUser and returns 200. -func refreshTokens(ctx context.Context, oauthCfg *oauth2.Config, provider *oidc.Provider, maxSessionDuration time.Duration) func(echo.Context) error { +func refreshTokens(ctx context.Context, oauthCfg *oauth2.Config, provider *oidc.Provider, maxSessionDuration time.Duration, refreshTokenDuration time.Duration) func(echo.Context) error { return func(c echo.Context) error { startTime := time.Now() clientIP := c.RealIP() @@ -239,7 +244,16 @@ func refreshTokens(ctx context.Context, oauthCfg *oauth2.Config, provider *oidc. } } - if err := auth.SetUser(c, &user); err != nil { + var sessionExpiresAt time.Time + if maxSessionDuration > 0 { + if sessionCookie, err := c.Request().Cookie("session_start"); err == nil { + if startUnix, err := strconv.ParseInt(sessionCookie.Value, 10, 64); err == nil { + sessionExpiresAt = time.Unix(startUnix, 0).Add(maxSessionDuration) + } + } + } + + if err := auth.SetUser(c, &user, sessionExpiresAt, refreshTokenDuration); err != nil { duration := time.Since(startTime).Milliseconds() log.Printf("token_refresh_failed reason=set_user_failed ip=%s error=%q duration_ms=%d", clientIP, err.Error(), duration) return echo.NewHTTPError(http.StatusInternalServerError, "unable to set refreshed user: "+err.Error()) diff --git a/tests/e2e-auth/diag.spec.ts b/tests/e2e-auth/diag.spec.ts new file mode 100644 index 0000000000..18b733a59a --- /dev/null +++ b/tests/e2e-auth/diag.spec.ts @@ -0,0 +1,36 @@ +import { test } from '@playwright/test'; + +test('diagnose full login flow', async ({ page }) => { + page.on('response', (res) => { + if (res.status() >= 300 && res.status() < 400) { + console.log( + `REDIRECT ${res.status()}: ${res.url()} -> ${res.headers()['location']}`, + ); + } + }); + + await page.goto('/'); + await page.waitForURL(/\/login/, { timeout: 15000 }); + console.log('At login page:', page.url()); + + await page.locator('[data-testid="login-button"]').click(); + console.log('Clicked login button, current URL:', page.url()); + + await page.waitForTimeout(3000); + console.log('URL after 3s:', page.url()); + + await page.waitForTimeout(3000); + console.log('URL after 6s:', page.url()); + + // try to fill form if we're on the OIDC login page + const loginInput = page.locator('input[name="login"]'); + if (await loginInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await loginInput.fill('user@example.com'); + await page.locator('input[name="password"]').fill('password'); + await page.locator('button[type="submit"]').click(); + await page.waitForTimeout(3000); + console.log('URL after form submit:', page.url()); + } else { + console.log('Login form not visible'); + } +}); diff --git a/tests/e2e-auth/helpers.ts b/tests/e2e-auth/helpers.ts new file mode 100644 index 0000000000..059bc40a84 --- /dev/null +++ b/tests/e2e-auth/helpers.ts @@ -0,0 +1,95 @@ +import type { BrowserContext, Page } from '@playwright/test'; + +const TEST_EMAIL = 'user@example.com'; +const TEST_PASSWORD = 'password'; + +/** + * Performs a full OIDC login flow against the local OIDC server. + * + * Navigates to the UI, follows the OIDC redirect, fills the login form, + * and waits until the callback redirect completes and the UI is loaded. + */ +export async function loginViaOIDC(page: Page): Promise { + await page.goto('/'); + + await page.waitForURL(/\/login|\/auth\/sso|\/interaction\//); + + if (page.url().includes('/login')) { + await page.locator('[data-testid="login-button"]').click(); + await page.waitForURL(/\/auth\/sso|\/interaction\//); + } + + if (page.url().includes('/auth/sso') && !page.url().includes('/callback')) { + await page.waitForURL(/\/interaction\//); + } + + await page.locator('input[name="login"]').fill(TEST_EMAIL); + await page.locator('input[name="password"]').fill(TEST_PASSWORD); + await page.locator('button[type="submit"]').click(); + + // Handle optional consent page (second /interaction/ step) + await page.waitForURL( + (url) => + url.pathname.includes('/interaction/') || + url.pathname.includes('/auth/sso/callback') || + url.hostname === 'localhost', + ); + if (page.url().includes('/interaction/')) { + await page.locator('button[type="submit"]').click(); + } + + // Wait until fully past callback and app is loaded + await page.waitForURL( + (url) => + !url.pathname.includes('/auth/sso/callback') && + !url.pathname.includes('/interaction/') && + url.hostname === 'localhost', + ); + + await page.waitForLoadState('load'); +} + +/** + * Returns the MaxAge of a named cookie in seconds by inspecting the browser + * context's current cookie store. + * + * Note: Playwright's `context.cookies()` does not expose MaxAge directly. + * This helper derives a lower-bound estimate by comparing the cookie's + * `expires` timestamp (Unix epoch seconds) to the current wall-clock time. + * The returned value will be slightly less than the server-set MaxAge due to + * network round-trip and processing time — use a ±3s tolerance in assertions. + * + * Returns `null` if the cookie is not found or has no expiry (session cookie). + */ +export async function getCookieMaxAge( + context: BrowserContext, + name: string, +): Promise { + const cookies = await context.cookies(); + const cookie = cookies.find((c) => c.name === name); + if (!cookie || cookie.expires === -1) return null; + const nowSeconds = Date.now() / 1000; + return Math.round(cookie.expires - nowSeconds); +} + +/** + * Parses the MaxAge value (in seconds) from a raw Set-Cookie header string. + * + * Returns `null` if the header string does not contain a Max-Age directive + * or if the named cookie is not present in the header. + * + * @param header - The raw value of a Set-Cookie response header. + * @param name - The cookie name to look for (case-sensitive). + */ +export function parseSetCookieMaxAge( + header: string, + name: string, +): number | null { + const namePart = header.split(';')[0].trim(); + const [cookieName] = namePart.split('='); + if (cookieName.trim() !== name) return null; + + const maxAgeMatch = header.match(/[Mm]ax-[Aa]ge=(\d+)/); + if (!maxAgeMatch) return null; + return parseInt(maxAgeMatch[1], 10); +} diff --git a/tests/e2e-auth/refresh-token-duration.spec.ts b/tests/e2e-auth/refresh-token-duration.spec.ts new file mode 100644 index 0000000000..02fe4ee6b3 --- /dev/null +++ b/tests/e2e-auth/refresh-token-duration.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; + +import { loginViaOIDC, parseSetCookieMaxAge } from './helpers'; + +test.describe('refresh cookie MaxAge priority: JWT exp over refreshTokenDuration config', () => { + test('refresh cookie MaxAge reflects JWT exp (~30s) not the refreshTokenDuration config value', async ({ + page, + }) => { + const setCookieHeaders: string[] = []; + + page.on('response', async (response) => { + const url = response.url(); + if ( + url.includes('/auth/sso/callback') || + url.includes('/auth/sso_callback') + ) { + const headers = await response.headersArray(); + for (const h of headers) { + if (h.name.toLowerCase() === 'set-cookie') { + setCookieHeaders.push(h.value); + } + } + } + }); + + await loginViaOIDC(page); + await page.waitForTimeout(500); + + const refreshHeader = setCookieHeaders.find((h) => + h.startsWith('refresh='), + ); + expect( + refreshHeader, + 'refresh cookie should be set after login', + ).toBeDefined(); + + const maxAge = parseSetCookieMaxAge(refreshHeader!, 'refresh'); + expect(maxAge, 'refresh cookie should carry a Max-Age').not.toBeNull(); + + expect( + maxAge!, + 'MaxAge should be ~30s (from OIDC JWT exp), within ±3s tolerance', + ).toBeGreaterThanOrEqual(27); + expect( + maxAge!, + 'MaxAge should not exceed the 30s JWT exp (JWT exp takes priority over refreshTokenDuration config)', + ).toBeLessThanOrEqual(30); + }); +}); diff --git a/tests/e2e-auth/session-expiry.spec.ts b/tests/e2e-auth/session-expiry.spec.ts new file mode 100644 index 0000000000..8de6964259 --- /dev/null +++ b/tests/e2e-auth/session-expiry.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; + +import { getCookieMaxAge, loginViaOIDC } from './helpers'; + +test.describe('token refresh and session expiry', () => { + test('token refresh succeeds at 6s (after access token expires at 5s, session still valid at 15s)', async ({ + page, + context, + }) => { + await loginViaOIDC(page); + + const userMaxAgeBefore = await getCookieMaxAge(context, 'user0'); + expect( + userMaxAgeBefore, + 'user0 cookie must exist after login', + ).not.toBeNull(); + + await page.waitForTimeout(6000); + + const refreshResponse = await page.request.get('/auth/refresh'); + expect( + refreshResponse.status(), + 'token refresh should succeed at 6s (session is still valid)', + ).toBe(200); + }); + + test('user* cookie MaxAge decreases after 6s (reflects remaining session time)', async ({ + page, + context, + }) => { + await loginViaOIDC(page); + + await page.waitForTimeout(6000); + + const refreshResponse = await page.request.get('/auth/refresh'); + expect(refreshResponse.status()).toBe(200); + + const userMaxAgeAfter = await getCookieMaxAge(context, 'user0'); + expect( + userMaxAgeAfter, + 'user0 cookie should still exist after refresh', + ).not.toBeNull(); + + expect( + userMaxAgeAfter!, + 'user* MaxAge should be less than 15 after 6s have elapsed', + ).toBeLessThan(15); + + expect( + userMaxAgeAfter!, + 'user* MaxAge should be at least 6 seconds remaining (±3s tolerance)', + ).toBeGreaterThanOrEqual(6); + }); + + test('token refresh fails at 16s (session expired at 15s)', async ({ + page, + }) => { + test.setTimeout(50000); + + await loginViaOIDC(page); + + await page.waitForTimeout(16000); + + const refreshResponse = await page.request.get('/auth/refresh'); + expect( + refreshResponse.status(), + 'token refresh should be rejected once session expires', + ).toBe(401); + }); +}); diff --git a/tests/e2e-keycloak/helpers.ts b/tests/e2e-keycloak/helpers.ts new file mode 100644 index 0000000000..8eef81504c --- /dev/null +++ b/tests/e2e-keycloak/helpers.ts @@ -0,0 +1,77 @@ +import type { BrowserContext, Page } from '@playwright/test'; + +const TEST_EMAIL = 'user@example.com'; +const TEST_PASSWORD = 'password'; + +/** + * Performs a full OIDC login flow against the local Keycloak server. + * + * Keycloak's login page uses `#username` and `#password` fields with an + * `#kc-login` submit button — different from the mock OIDC server's form. + * No consent page is shown since the temporal realm does not require it. + */ +export async function loginViaKeycloak(page: Page): Promise { + await page.goto('/'); + + await page.waitForURL(/\/login|\/auth\/sso|realms\/temporal/); + + if (page.url().includes('/login')) { + await page.locator('[data-testid="login-button"]').click(); + await page.waitForURL(/\/auth\/sso|realms\/temporal/); + } + + if (page.url().includes('/auth/sso') && !page.url().includes('/callback')) { + await page.waitForURL(/realms\/temporal/); + } + + // Keycloak login form + await page.locator('input[name="username"]').fill(TEST_EMAIL); + await page.locator('input[name="password"]').fill(TEST_PASSWORD); + await page.locator('input[type="submit"], #kc-login').click(); + + // Wait until the callback completes and the app is loaded + await page.waitForURL( + (url) => + !url.pathname.includes('/auth/sso/callback') && + !url.hostname.includes('keycloak') && + url.hostname === 'localhost', + ); + + await page.waitForLoadState('load'); +} + +/** + * Returns the remaining MaxAge of a named cookie in seconds by comparing its + * expiry timestamp (from the browser context cookie store) to the current time. + * + * Returns `null` if the cookie is not found or has no expiry (session cookie). + */ +export async function getCookieMaxAge( + context: BrowserContext, + name: string, +): Promise { + const cookies = await context.cookies(); + const cookie = cookies.find((c) => c.name === name); + if (!cookie || cookie.expires === -1) return null; + const nowSeconds = Date.now() / 1000; + return Math.round(cookie.expires - nowSeconds); +} + +/** + * Parses the MaxAge value (in seconds) from a raw Set-Cookie header string. + * + * Returns `null` if the header does not contain a Max-Age directive or if the + * named cookie is not present. + */ +export function parseSetCookieMaxAge( + header: string, + name: string, +): number | null { + const namePart = header.split(';')[0].trim(); + const [cookieName] = namePart.split('='); + if (cookieName.trim() !== name) return null; + + const maxAgeMatch = header.match(/[Mm]ax-[Aa]ge=(\d+)/); + if (!maxAgeMatch) return null; + return parseInt(maxAgeMatch[1], 10); +} diff --git a/tests/e2e-keycloak/login-cookies.spec.ts b/tests/e2e-keycloak/login-cookies.spec.ts new file mode 100644 index 0000000000..c1c76e2470 --- /dev/null +++ b/tests/e2e-keycloak/login-cookies.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from '@playwright/test'; + +import { loginViaKeycloak, parseSetCookieMaxAge } from './helpers'; + +// Keycloak realm-temporal.json settings: +// accessTokenLifespan: 5s +// ssoSessionMaxLifespan: 30s → refresh token exp +// maxSessionDuration: 25s → UI session boundary + +test.describe('Keycloak login cookie properties', () => { + test('refresh cookie MaxAge is derived from Keycloak JWT refresh token exp (~30s)', async ({ + page, + }) => { + const setCookieHeaders: string[] = []; + + page.on('response', async (response) => { + const url = response.url(); + if ( + url.includes('/auth/sso/callback') || + url.includes('/auth/sso_callback') + ) { + const headers = await response.headersArray(); + for (const h of headers) { + if (h.name.toLowerCase() === 'set-cookie') { + setCookieHeaders.push(h.value); + } + } + } + }); + + await loginViaKeycloak(page); + await page.waitForTimeout(500); + + const refreshHeader = setCookieHeaders.find((h) => + h.startsWith('refresh='), + ); + expect( + refreshHeader, + 'refresh cookie should be set on login', + ).toBeDefined(); + + // Keycloak's JWT refresh token exp = ssoSessionMaxLifespan (30s). + // No refreshTokenDuration is configured, so this purely tests the jwtExp() path. + const maxAge = parseSetCookieMaxAge(refreshHeader!, 'refresh'); + expect( + maxAge, + 'refresh cookie should carry a Max-Age directive', + ).not.toBeNull(); + expect( + maxAge, + 'refresh MaxAge should reflect JWT exp (~30s), not access token lifetime (5s)', + ).toBeGreaterThanOrEqual(27); + expect(maxAge).toBeLessThanOrEqual(30); + }); + + test('user* cookie MaxAge is capped to maxSessionDuration (25s) on login', async ({ + page, + }) => { + const setCookieHeaders: string[] = []; + + page.on('response', async (response) => { + const url = response.url(); + if ( + url.includes('/auth/sso/callback') || + url.includes('/auth/sso_callback') + ) { + const headers = await response.headersArray(); + for (const h of headers) { + if (h.name.toLowerCase() === 'set-cookie') { + setCookieHeaders.push(h.value); + } + } + } + }); + + await loginViaKeycloak(page); + await page.waitForTimeout(500); + + const userCookieHeaders = setCookieHeaders.filter((h) => + h.match(/^user\d+=/), + ); + expect( + userCookieHeaders.length, + 'at least one user* cookie should be set', + ).toBeGreaterThan(0); + + for (const header of userCookieHeaders) { + const maxAge = parseSetCookieMaxAge(header, header.split('=')[0]); + expect( + maxAge, + 'user* cookie should carry a Max-Age directive', + ).not.toBeNull(); + expect( + maxAge, + 'user* MaxAge must be capped to maxSessionDuration (25s)', + ).toBeGreaterThanOrEqual(22); + expect(maxAge).toBeLessThanOrEqual(25); + } + }); + + test('session_start cookie MaxAge equals maxSessionDuration (25s)', async ({ + page, + }) => { + const setCookieHeaders: string[] = []; + + page.on('response', async (response) => { + const url = response.url(); + if ( + url.includes('/auth/sso/callback') || + url.includes('/auth/sso_callback') + ) { + const headers = await response.headersArray(); + for (const h of headers) { + if (h.name.toLowerCase() === 'set-cookie') { + setCookieHeaders.push(h.value); + } + } + } + }); + + await loginViaKeycloak(page); + await page.waitForTimeout(500); + + const sessionHeader = setCookieHeaders.find((h) => + h.startsWith('session_start='), + ); + expect( + sessionHeader, + 'session_start cookie should be set on login', + ).toBeDefined(); + + const maxAge = parseSetCookieMaxAge(sessionHeader!, 'session_start'); + expect( + maxAge, + 'session_start should carry a Max-Age directive', + ).not.toBeNull(); + expect(maxAge).toBeGreaterThanOrEqual(22); + expect(maxAge).toBeLessThanOrEqual(25); + }); + + test('refresh cookie is HttpOnly', async ({ page }) => { + const setCookieHeaders: string[] = []; + + page.on('response', async (response) => { + const url = response.url(); + if ( + url.includes('/auth/sso/callback') || + url.includes('/auth/sso_callback') + ) { + const headers = await response.headersArray(); + for (const h of headers) { + if (h.name.toLowerCase() === 'set-cookie') { + setCookieHeaders.push(h.value); + } + } + } + }); + + await loginViaKeycloak(page); + await page.waitForTimeout(500); + + const refreshHeader = setCookieHeaders.find((h) => + h.startsWith('refresh='), + ); + expect(refreshHeader, 'refresh cookie should be set').toBeDefined(); + expect( + refreshHeader!.toLowerCase(), + 'refresh cookie must be HttpOnly', + ).toContain('httponly'); + }); + + test('user* cookies are not HttpOnly', async ({ page }) => { + const setCookieHeaders: string[] = []; + + page.on('response', async (response) => { + const url = response.url(); + if ( + url.includes('/auth/sso/callback') || + url.includes('/auth/sso_callback') + ) { + const headers = await response.headersArray(); + for (const h of headers) { + if (h.name.toLowerCase() === 'set-cookie') { + setCookieHeaders.push(h.value); + } + } + } + }); + + await loginViaKeycloak(page); + await page.waitForTimeout(500); + + const userCookieHeaders = setCookieHeaders.filter((h) => + h.match(/^user\d+=/), + ); + expect(userCookieHeaders.length).toBeGreaterThan(0); + + for (const header of userCookieHeaders) { + expect( + header.toLowerCase(), + 'user* cookie must NOT be HttpOnly (UI reads it via JS)', + ).not.toContain('httponly'); + } + }); +}); diff --git a/utilities/oidc-server/support/configuration.e2e-auth.ts b/utilities/oidc-server/support/configuration.e2e-auth.ts new file mode 100644 index 0000000000..597fd7832b --- /dev/null +++ b/utilities/oidc-server/support/configuration.e2e-auth.ts @@ -0,0 +1,104 @@ +// ============================================================================= +// FOR LOCAL DEVELOPMENT AND TESTING ONLY - Contains hardcoded secrets +// ============================================================================= +// +// E2E Auth Test Configuration +// +// Token lifetimes are intentionally very short so that E2E tests can exercise +// token refresh and session expiry within a 90-second test run. +// +// Current settings: +// - Access Token: 5 seconds — forces a refresh within the first test step +// - ID Token: 5 seconds — same as access token +// - Refresh Token: 30 seconds — matches refreshTokenDuration in e2e-auth.yaml +// - Session: 15 seconds — matches maxSessionDuration in e2e-auth.yaml +// - Interaction: 30 seconds — OIDC interaction timeout +// - Grant: 30 seconds — OIDC grant timeout +// +// Key relationships (must hold for tests to be meaningful): +// AccessToken TTL (5s) < maxSessionDuration (15s) — enables refresh testing +// Session TTL (15s) == maxSessionDuration (15s) — forces re-auth after session ends +// RefreshToken TTL (30s) > Session TTL (15s) — allows refresh within session +// ============================================================================= +const configuration: Record = { + conformIdTokenClaims: false, + ttl: { + AccessToken: 5, + IdToken: 5, + RefreshToken: 30, + Session: 15, + Interaction: 30, + Grant: 30, + }, + scopes: ['openid', 'profile', 'email', 'offline_access'], + issueRefreshToken: async () => { + return true; + }, + clients: [ + { + client_id: 'temporal-ui', + client_secret: 'temporal-secret', + grant_types: ['authorization_code', 'refresh_token'], + redirect_uris: ['http://localhost:8082/auth/sso/callback'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_basic', + }, + ], + interactions: { + url(_ctx: unknown, interaction: { uid: string }): string { + return `/interaction/${interaction.uid}`; + }, + }, + claims: { + address: ['address'], + email: ['email', 'email_verified'], + phone: ['phone_number', 'phone_number_verified'], + profile: [ + 'birthdate', + 'family_name', + 'gender', + 'given_name', + 'locale', + 'middle_name', + 'name', + 'nickname', + 'picture', + 'preferred_username', + 'profile', + 'updated_at', + 'website', + 'zoneinfo', + ], + }, + features: { + devInteractions: { enabled: false }, + deviceFlow: { enabled: true }, + revocation: { enabled: true }, + }, + jwks: { + keys: [ + { + d: 'VEZOsY07JTFzGTqv6cC2Y32vsfChind2I_TTuvV225_-0zrSej3XLRg8iE_u0-3GSgiGi4WImmTwmEgLo4Qp3uEcxCYbt4NMJC7fwT2i3dfRZjtZ4yJwFl0SIj8TgfQ8ptwZbFZUlcHGXZIr4nL8GXyQT0CK8wy4COfmymHrrUoyfZA154ql_OsoiupSUCRcKVvZj2JHL2KILsq_sh_l7g2dqAN8D7jYfJ58MkqlknBMa2-zi5I0-1JUOwztVNml_zGrp27UbEU60RqV3GHjoqwI6m01U7K0a8Q_SQAKYGqgepbAYOA-P4_TLl5KC4-WWBZu_rVfwgSENwWNEhw8oQ', + dp: 'E1Y-SN4bQqX7kP-bNgZ_gEv-pixJ5F_EGocHKfS56jtzRqQdTurrk4jIVpI-ZITA88lWAHxjD-OaoJUh9Jupd_lwD5Si80PyVxOMI2xaGQiF0lbKJfD38Sh8frRpgelZVaK_gm834B6SLfxKdNsP04DsJqGKktODF_fZeaGFPH0', + dq: 'F90JPxevQYOlAgEH0TUt1-3_hyxY6cfPRU2HQBaahyWrtCWpaOzenKZnvGFZdg-BuLVKjCchq3G_70OLE-XDP_ol0UTJmDTT-WyuJQdEMpt_WFF9yJGoeIu8yohfeLatU-67ukjghJ0s9CBzNE_LrGEV6Cup3FXywpSYZAV3iqc', + e: 'AQAB', + kty: 'RSA', + n: 'xwQ72P9z9OYshiQ-ntDYaPnnfwG6u9JAdLMZ5o0dmjlcyrvwQRdoFIKPnO65Q8mh6F_LDSxjxa2Yzo_wdjhbPZLjfUJXgCzm54cClXzT5twzo7lzoAfaJlkTsoZc2HFWqmcri0BuzmTFLZx2Q7wYBm0pXHmQKF0V-C1O6NWfd4mfBhbM-I1tHYSpAMgarSm22WDMDx-WWI7TEzy2QhaBVaENW9BKaKkJklocAZCxk18WhR0fckIGiWiSM5FcU1PY2jfGsTmX505Ub7P5Dz75Ygqrutd5tFrcqyPAtPTFDk8X1InxkkUwpP3nFU5o50DGhwQolGYKPGtQ-ZtmbOfcWQ', + p: '5wC6nY6Ev5FqcLPCqn9fC6R9KUuBej6NaAVOKW7GXiOJAq2WrileGKfMc9kIny20zW3uWkRLm-O-3Yzze1zFpxmqvsvCxZ5ERVZ6leiNXSu3tez71ZZwp0O9gys4knjrI-9w46l_vFuRtjL6XEeFfHEZFaNJpz-lcnb3w0okrbM', + q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M', + qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU', + use: 'sig', + }, + { + crv: 'P-256', + d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws', + kty: 'EC', + use: 'sig', + x: 'FWZ9rSkLt6Dx9E3pxLybhdM6xgR5obGsj5_pqmnz5J4', + y: '_n8G69C-A2Xl4xUW2lF0i8ZGZnk_KPYrhv4GbTGu5G4', + }, + ], + }, +}; + +export default configuration;