Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9fcdd8f
Try some refresh logic
Alex-Tideman Oct 21, 2025
61c21f3
Better naming
Alex-Tideman Oct 21, 2025
6243153
feat(oidc-server): implement OIDC provider server with CLI script
rossnelson Apr 21, 2025
fe13b26
feat(auth): implement OAuth2 token refresh with JWT validation
rossnelson Oct 21, 2025
42ad856
style: run go fmt on server code
rossnelson Oct 21, 2025
3c64cd2
remove tgz
rossnelson Oct 21, 2025
02e2d68
revert script
rossnelson Oct 21, 2025
482aa6d
fix(auth): address PR review feedback for token refresh
rossnelson Nov 17, 2025
0a68e2b
fix(auth): validate returnUrl against CORS allowed origins
rossnelson Jan 13, 2026
905ff8b
feat(oidc-server): require email for login and use as username
rossnelson Jan 13, 2026
9c58ded
fix(server): pass publicPath to SetRenderRoute
rossnelson Jan 13, 2026
5237e3b
fix(auth): validate JWT signatures using OIDC provider
rossnelson Jan 13, 2026
1838311
fix(types): resolve strict TypeScript errors in request-from-api
rossnelson Jan 13, 2026
c41c1eb
test(auth): update handle-error tests for SSO redirect
rossnelson Jan 13, 2026
a416639
fix(types): handle optional accessToken and idToken with nullish coal…
rossnelson Jan 13, 2026
210106e
style: format auth-testing skill doc with prettier
rossnelson Jan 13, 2026
1aad340
fix(auth): align refresh token lifecycle with IdP and add logout
rossnelson Feb 5, 2026
5b4befc
fix(types): resolve strict TypeScript errors in utilities and OIDC se…
rossnelson Feb 5, 2026
535fa47
feat(auth): add configurable max session duration (#3091)
rossnelson Feb 5, 2026
c273877
fix(auth): validateJWT should fail when verifier not configured
rossnelson Feb 5, 2026
ee7cae2
docs(auth): add comprehensive authentication documentation
rossnelson Feb 5, 2026
67bf8ea
docs(security): create team-ready security refactor plan
rossnelson Feb 5, 2026
b164b8f
Merge branch 'main' into refresh-token
rossnelson Feb 5, 2026
fbff22f
fix(auth): prevent breaking changes in token validation and error han…
rossnelson Feb 6, 2026
ab15db8
fix(auth): fix nil deref in SetUser, harden refresh endpoint security
rossnelson Feb 6, 2026
3ea5727
fix(auth): clear session_start on logout, remove dead routeForSsoRedi…
rossnelson Feb 6, 2026
d8d4a86
chore: stop tracking .claude/settings.local.json
rossnelson Feb 6, 2026
7a24d86
chore: remove planning docs from repo, moved to local docs/
rossnelson Feb 6, 2026
492fe40
fix(auth): restore dynamic refresh token cookie lifetime
rossnelson Feb 6, 2026
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
129 changes: 129 additions & 0 deletions .claude/skills/auth-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
name: auth-testing
description: Test OAuth2 token refresh and session expiry locally. Use when working on auth, tokens, SSO, OIDC, or session management features.
---

# Auth Testing Skill

Test OAuth2 authentication flows locally using the built-in OIDC server.

## Quick Start

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

This starts:

- **Vite dev server**: http://localhost:3000
- **UI Server** (Go): http://localhost:8081
- **OIDC Server**: http://localhost:8889
- **Temporal Server**: http://localhost:7233

## Configuration Files

| File | Purpose |
| ------------------------------------------------ | ------------------------------------------------------- |
| `server/config/with-auth.yaml` | UI server auth settings (maxSessionDuration, providers) |
| `utilities/oidc-server/support/configuration.ts` | OIDC server TTLs (token expiry, session duration) |

## Testing Scenarios

### 1. Token Refresh

Test that tokens refresh automatically before expiry.

**Config**: AccessToken TTL (60s) < maxSessionDuration (2m)

**Steps**:

1. Login at http://localhost:3000
2. Open browser DevTools > Network tab
3. Wait ~60 seconds
4. Observe `/auth/refresh` request that renews tokens

### 2. Session Expiry

Test that sessions expire and force re-login.

**Config**: maxSessionDuration = Session TTL (both 2m)

**Steps**:

1. Login at http://localhost:3000
2. Wait 2 minutes
3. Make any API request (navigate, refresh)
4. Should redirect to OIDC login page

### 3. Unlimited Session

Test long-lived sessions with only token refresh.

**Config changes**:

```yaml
# server/config/with-auth.yaml
auth:
maxSessionDuration: 0 # Disable session limit
```

```typescript
// utilities/oidc-server/support/configuration.ts
ttl: {
Session: 60 * 60 * 24, // 1 day
}
```

## Key Relationships

```
AccessToken TTL < maxSessionDuration β†’ Enables token refresh
Session TTL = maxSessionDuration β†’ Forces re-auth at OIDC on expiry
RefreshToken TTL > Session TTL β†’ Allows refresh within session
```

## Current Default Values

| Setting | Value | Location |
| -------------------- | ----- | ---------------- |
| Access Token TTL | 60s | OIDC config |
| ID Token TTL | 60s | OIDC config |
| Refresh Token TTL | 1 day | OIDC config |
| OIDC Session TTL | 2m | OIDC config |
| Max Session Duration | 2m | UI server config |

## Debugging

### View auth logs

The Go server logs token validation:

```
[Auth] Setting refresh token cookie (length: X)
[JWT Validation] Token valid, expires at X (time remaining: X)
```

### Check cookies

In browser DevTools > Application > Cookies:

- `user0`, `user1`... - Base64 encoded user data (short-lived)
- `refresh` - HttpOnly refresh token (long-lived)
- `session_start` - Session start timestamp (HttpOnly)

### Test endpoints

```bash
# Get OIDC discovery
curl http://localhost:8889/.well-known/openid-configuration

# Manual token refresh (requires valid refresh cookie)
curl -X GET http://localhost:8081/auth/refresh --cookie "refresh=<token>"
```

## Related Files

- `server/server/route/auth.go` - Auth routes and callbacks
- `server/server/auth/auth.go` - Token validation and session management
- `server/server/config/config.go` - Auth config struct
- `src/lib/utilities/auth-refresh.ts` - Client-side refresh logic
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"
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
10 changes: 10 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 @@ -168,6 +170,8 @@
"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",
Expand All @@ -180,11 +184,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();
});
Loading
Loading