Skip to content
Draft
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
29 changes: 29 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Editor configuration, see https://editorconfig.org
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

[*.ts]
quote_type = single

[*.md]
max_line_length = off
trim_trailing_whitespace = false

[*.svelte]
quote_type = single

# Go files
[*.go]
indent_style = tab
indent_size = 4

# Go mod and sum files
[go.{mod,sum}]
indent_style = tab
indent_size = 4
4 changes: 4 additions & 0 deletions .env.with-auth
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VITE_TEMPORAL_PORT="7233"
VITE_API="http://localhost:8081"
VITE_MODE="development"
VITE_TEMPORAL_UI_BUILD_TARGET="local"
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pnpm 8.15.7
golang 1.21.0
87 changes: 87 additions & 0 deletions TESTING_TOKEN_REFRESH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Testing Token Refresh Flow

The UI now implements automatic OAuth2 token refresh to prevent session interruptions when access tokens expire.

## Quick Test

1. **Start the dev server with auth**:

```bash
pnpm dev:with-auth
```

2. **Login**:

- Navigate to http://localhost:3000
- Login with any username (e.g., `testuser`)
- You should see the UI load successfully

3. **Verify refresh token cookie**:

- Open browser DevTools β†’ Application tab β†’ Cookies
- Check for `refresh` cookie (HttpOnly, 30 day expiry)
- Check for `user0` cookie (1 minute expiry, contains access token)

4. **Wait for token expiration**:

- Tokens expire after 60 seconds
- Open DevTools β†’ Network tab
- Wait 60+ seconds

5. **Trigger a request**:

- Navigate to a different page or trigger any API call
- Watch the Network tab

6. **Observe automatic refresh**:
- First request β†’ `401 Unauthorized` (expired token)
- `/auth/refresh` β†’ `200 OK` (exchanging refresh token)
- Original request retries β†’ `200 OK` (with new token)
- UI continues working seamlessly

## What to Look For

### Success Case (Refresh Works)

- First API call after expiration: `401 Unauthorized`
- `/auth/refresh` call: `200 OK`
- Original request automatically retries: `200 OK`
- New cookies are set with fresh tokens
- UI continues without interruption or login redirect

### Failure Case (Refresh Token Expired)

- First API call: `401 Unauthorized`
- `/auth/refresh` call: `401 Unauthorized`
- Browser redirects to login page (`/auth/sso`)

## Configuration

### Token Expiration Times

- **Access token**: 60 seconds (`utilities/oidc-server/support/configuration.ts:7`)
- **ID token**: 60 seconds (`utilities/oidc-server/support/configuration.ts:8`)
- **Refresh token**: 1 day (`utilities/oidc-server/support/configuration.ts:9`)
- **User cookie**: 1 minute (`server/server/auth/auth.go:80`)
- **Refresh cookie**: 30 days (`server/server/auth/auth.go:94`)

### Required OIDC Configuration

- **Grant types**: `authorization_code`, `refresh_token` (`utilities/oidc-server/support/configuration.ts:19`)
- **Scopes**: `openid`, `profile`, `email`, `offline_access` (`server/config/with-auth.yaml:33-37`)
- **issueRefreshToken**: Must return `true` (`utilities/oidc-server/support/configuration.ts:12-14`)

The `offline_access` scope and `issueRefreshToken` callback are critical for refresh tokens to be issued.

## How It Works

1. User logs in via OAuth2 authorization code flow
2. OIDC server issues access token, ID token, and refresh token
3. UI server stores refresh token in HttpOnly cookie (secure, not accessible to JavaScript)
4. Frontend stores access/ID tokens in short-lived cookies
5. When tokens expire (after 60s), API requests return 401
6. Frontend automatically calls `/auth/refresh` with refresh token cookie
7. Server exchanges refresh token for new access/ID tokens
8. New tokens are stored in cookies
9. Original request retries with new tokens
10. User session continues seamlessly
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@
"dev:local-temporal": ". ./.env.local-temporal && vite dev --mode local-temporal",
"dev:temporal-cli": "vite dev --mode temporal-server",
"dev:docker": ". ./.env && VITE_API=http://localhost:8080 vite dev --mode docker",
"dev:with-auth": "vite dev --mode with-auth",
"build:local": "vite build",
"build:docker": "VITE_API=http://localhost:8080 vite build",
"build:server": "VITE_API= BUILD_PATH=server/ui/assets/local vite build",
"temporal-server": "esno scripts/start-temporal-server.ts --codecEndpoint http://127.0.0.1:8888",
"codec-server": "esno ./scripts/start-codec-server.ts --port 8888",
"oidc-server": "esno ./scripts/start-oidc-server.ts --port 8889",
"serve:playwright:e2e": "vite build && vite preview --mode test.e2e",
"serve:playwright:integration": "vite build && vite preview --mode test.integration --port 3333",
"test": "TZ=UTC vitest",
Expand Down Expand Up @@ -167,6 +169,9 @@
"chalk": "^4.1.2",
"cors": "^2.8.5",
"cssnano": "^5.1.14",
"desm": "^1.3.1",
"ejs": "^3.1.10",
"esbuild": "^0.25.0",
"eslint": "^8.47.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.28.1",
Expand All @@ -178,11 +183,17 @@
"express": "^4.18.2",
"fast-glob": "^3.3.1",
"google-protobuf": "^3.21.2",
"helmet": "^8.1.0",
"husky": "^8.0.3",
"jsdom": "^20.0.3",
"lint-staged": "^13.1.0",
"lodash": "^4.17.21",
"mkdirp": "^2.1.3",
"mock-socket": "^9.1.5",
"nanoid": "^5.1.5",
"node-fetch": "^3.3.0",
"npm-run-all": "^4.1.5",
"oidc-provider": "^9.0.1",
"postcss": "^8.4.31",
"postcss-cli": "^9.1.0",
"postcss-html": "^1.5.0",
Expand Down
76 changes: 76 additions & 0 deletions plugins/vite-plugin-oidc-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Plugin } from 'vite';
import type { ViteDevServer } from 'vite';
import waitForPort from 'wait-port';
import { chalk } from 'zx';

import {
Account,
getConfig,
OIDCServer,
providerConfiguration,
routes,
} from '../utilities/oidc-server';

const { blue } = chalk;

let oidcServer: OIDCServer;

function log(...message: string[]): void {
const [first, ...rest] = message;
console.log(blue(first), ...rest);
}

/**
* Determine whether to skip starting the OIDC server.
*/
const shouldSkip = (server: ViteDevServer): boolean => {
if (process.env.VERCEL) return true;
if (process.env.VITEST) return true;
if (process.env.CI) return true;
// only run in oidc-server mode
if (server.config.mode !== 'with-auth') return true;
return false;
};

/**
* Vite plugin to manage the lifecycle of the OIDC server during dev.
*/
export function oidcServerPlugin(): Plugin {
const { PORT, ISSUER, VIEWS_PATH } = getConfig();

return {
name: 'vite-plugin-oidc-server',
enforce: 'post',
apply: 'serve',
async configureServer(server) {
if (shouldSkip(server)) return;

log(`Starting OIDC Server on port ${PORT}…`);

oidcServer = new OIDCServer({
issuer: ISSUER,
port: PORT,
viewsPath: VIEWS_PATH,
providerConfiguration,
accountModel: Account,
routes,
});
// start and wait for readiness
await oidcServer.start();
await waitForPort({ port: PORT, output: 'silent' });

log(`OIDC Server is running on port ${PORT}.`);
},
async closeBundle() {
if (oidcServer) {
oidcServer.stop();
log('πŸ”ͺ killed OIDC Server');
}
},
};
}

// ensure shutdown on process exit
process.on('beforeExit', () => {
if (oidcServer) oidcServer.stop();
});
13 changes: 8 additions & 5 deletions plugins/vite-plugin-temporal-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type TemporalServer,
} from '../utilities/temporal-server';

const { cyan, magenta } = chalk;
const { magenta } = chalk;

let temporal: TemporalServer;

Expand All @@ -16,7 +16,9 @@ const shouldSkip = (server: ViteDevServer): boolean => {
if (process.env.VITEST) return true;
if (temporal) return true;
if (process.platform === 'win32') return true;
if (!['temporal-server', 'ui-server'].includes(server.config.mode))
if (
!['temporal-server', 'ui-server', 'with-auth'].includes(server.config.mode)
)
return true;

return false;
Expand All @@ -38,7 +40,10 @@ const persistentDB = (server: ViteDevServer): string | undefined => {
return filename;
};

const getPortFromApiEndpoint = (endpoint: string, fallback = 8233): number => {
export const getPortFromApiEndpoint = (
endpoint: string,
fallback = 8233,
): number => {
return validatePort(
endpoint.slice(endpoint.lastIndexOf(':') + 1, endpoint.length),
fallback,
Expand Down Expand Up @@ -79,7 +84,6 @@ export function temporalServer(): Plugin {
const uiPort = getPortFromApiEndpoint(server.config.env.VITE_API);

console.log(magenta(`Starting Temporal Server on Port ${port}…`));
console.log(cyan(`Starting Temporal UI Server on Port ${uiPort}…`));

temporal = await createTemporalServer({
port,
Expand All @@ -90,7 +94,6 @@ export function temporalServer(): Plugin {
await temporal.ready();

console.log(magenta(`Temporal Server is running on Port ${port}.`));
console.log(cyan(`Temporal UI Server is running on Port ${uiPort}.`));
},
async closeBundle() {
await temporal?.shutdown();
Expand Down
26 changes: 23 additions & 3 deletions plugins/vite-plugin-ui-server.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,49 @@
import type { Plugin } from 'vite';
import type { ViteDevServer } from 'vite';
import { chalk } from 'zx';

import { createUIServer, type UIServer } from '../utilities/ui-server';
import { getPortFromApiEndpoint } from './vite-plugin-temporal-server';
import {
createUIServer,
type UIServer,
type ValidEnv,
} from '../utilities/ui-server';

const { cyan } = chalk;

let uiServer: UIServer;

const shouldSkip = (server: ViteDevServer): boolean => {
if (process.env.VERCEL) return true;
if (process.env.VITEST) return true;
if (process.env.CI) return true;
if (['ui-server', 'local-temporal'].includes(server.config.mode))
if (['ui-server', 'local-temporal', 'with-auth'].includes(server.config.mode))
return false;

return true;
};

function serverEnv(server: ViteDevServer): ValidEnv {
if (server.config.mode === 'with-auth') return 'with-auth';
if (server.config.mode === 'e2e') return 'e2e';
return 'development';
}

export function uiServerPlugin(): Plugin {
return {
name: 'vite-plugin-ui-server',
enforce: 'post',
apply: 'serve',
async configureServer(server) {
if (shouldSkip(server)) return;
uiServer = await createUIServer();

const uiPort = getPortFromApiEndpoint(server.config.env.VITE_API);
console.log(cyan(`Starting Temporal UI Server on Port ${uiPort}…`));

uiServer = await createUIServer(serverEnv(server));
await uiServer.ready();

console.log(cyan(`Temporal UI Server is running on Port ${uiPort}.`));
},
async closeBundle() {
await uiServer?.shutdown();
Expand Down
Loading