Skip to content

Commit d72b8e6

Browse files
Frontend changes to enable faster upstream to internal repo (#126)
* refactor(auth): pluggable provider architecture via providers/index.ts Replace inline commented-out providers and hardcoded signIn('oauth') calls with a clean plugin pattern where providers/index.ts is the sole swap-point for enabling authentication. - Add providers/types.ts with AuthProviderConfig interface contract - Rewrite providers/index.ts as the auth registry (returns null by default) - Uncomment internal-auth.ts as a working, type-checked reference - Refactor config.ts to import from providers and derive AUTH_PROVIDER_ID - Add authProviderId to AppConfig so session.ts and signin/page.tsx adapt dynamically to any provider (no more hardcoded 'oauth') - Expand README.md Authentication section with step-by-step enablement guide targeting AI agent consumption Default-user (no auth) flow is unchanged: getAuthProviderConfig() returns null, session.ts returns DEFAULT_USER, proxy.ts clears cookies. Made-with: Cursor * fix(auth): ephemeral secret fallback, aligned lifetimes, configurable refresh buffer Address three security/correctness issues from PR #121 review: 1. Replace hardcoded 'disabled-auth-secret' with crypto.randomUUID() so the disabled-auth fallback is ephemeral and unpredictable. When auth is required with a real provider, undefined is passed so NextAuth throws a loud error if NEXTAUTH_SECRET is missing. 2. Create SESSION_MAX_AGE_SECONDS (configurable via SESSION_MAX_AGE_HOURS, default 24h) and use it in both config.ts session.maxAge and proxy.ts cookie maxAge. Eliminates the 30-day cookie / 24-hour session mismatch that left stale idToken cookies after session expiry. 3. Make TOKEN_REFRESH_BUFFER_SECONDS configurable via TOKEN_REFRESH_BUFFER_MINUTES env var (default 5 min). Operators with long-running deep research jobs set TOKEN_REFRESH_BUFFER_MINUTES=30 to prevent mid-job 401 errors. Both new env vars documented in .env.example and README. Made-with: Cursor * fix(ui): add missing HITL respond route, fix auth token, guard URL parsing Three P0 bug fixes: 1. Add /api/generate/respond route handler so HITL prompt responses (approve/reject) no longer silently 404. Mirrors the existing generate route's auth and error patterns as a non-streaming REST proxy. 2. Change accessToken to idToken in use-load-job-data.ts (13 sites). Every other hook already uses idToken; the mismatch caused 401s when loading deep research job data with OIDC providers where accessToken and idToken differ. 3. Wrap parse(req.url) in server.js with try/catch in both the HTTP handler (returns 400) and WebSocket upgrade handler (destroys socket) to prevent crashes on malformed request URLs. Made-with: Cursor * refactor(ui): remove unused NEXT_PUBLIC_WORKFLOW_ID / workflowId plumbing Multi-workflow routing is not used. Remove the workflowId prop chain (page → MainLayout → InputArea → useChat → chat-client) and the workflow_id field from ChatCompletionRequestSchema. Made-with: Cursor * fix(auth): type-safe providers, rename internal-auth to auth-example, fix test fixtures - Add type assertion for providers ternary to satisfy AuthOptions type - Rename internal-auth.ts to auth-example.ts (doc-only reference) - Add authProviderId to test AppConfig fixtures Made-with: Cursor * fix(auth): use node:crypto import for test compat, fix stale file references Replace global crypto.randomUUID() with an explicit node:crypto import so the ephemeral secret works in vitest jsdom/happy-dom environments where the Web Crypto global does not expose randomUUID(). Update four references from the renamed internal-auth.ts to auth-example.ts (config.ts, providers/index.ts, README.md). Made-with: Cursor * fix(auth): address PR #126 review comments - Fix perpetual token refresh when OAuth provider omits expires_at: guard refresh logic so undefined expiresAt no longer falls back to 0, which caused refreshAccessToken on every JWT callback invocation - Fix stale internal-auth.ts references → auth-example.ts in types.ts and auth-example.ts docstrings - Align client-side session polling interval with configurable TOKEN_REFRESH_BUFFER_SECONDS via new AppConfig.sessionRefreshIntervalSeconds, replacing the hardcoded 4-minute setInterval in useAuth - Update test fixtures with sessionRefreshIntervalSeconds field Made-with: Cursor * fix(auth): isolate server-only auth config from client imports Prevent the auth barrel from pulling NextAuth config into the client bundle so Turbopack no longer evaluates server-only crypto code in browser paths. Made-with: Cursor * fix(ui): clear stale deep research state after missing jobs Sync persisted deep research tracking state when jobs fail, expire, or disappear so sessions stop showing as active and archived report loads resolve to a terminal failure instead of lingering in progress. Made-with: Cursor * fix(test): stub server-only for vitest auth imports Teach the Vitest resolver to treat Next's server-only marker as a no-op shim so auth config specs can import server-only modules without breaking the UI unit test suite. Made-with: Cursor * fix(ui): address remaining PR #126 review comments Clarify the auth provider example as a template, harden auth timing env parsing, remove dead chat hook options, and deduplicate deep research missing-job error handling while keeping the full UI test suite green. Made-with: Cursor * fix(auth): avoid unknown-expiry refresh churn Keep tokens stable when providers omit expires_at, align callback idToken cookie lifetime with the shared session max age, and make cancelled deep research recovery map explicitly to interrupted status. Made-with: Cursor * fix(test): satisfy auth config callback types Pass a minimal typed user object into the auth config JWT callback test so the UI type-check stays green in CI. Made-with: Cursor * fix(ui): remove workflowId references from README and schemas Eliminate workflowId from the chat streaming example in README.md and the WebSocket connection schema in schemas.ts to streamline the API and reduce unnecessary complexity. * fix(chat): enhance file update handling in useDeepResearch hook Add tests to verify that the status is set to 'writing' when 'report.md' is received, and ensure that non-report files do not trigger this status. Remove unused reference to allTodosCompletedRef to streamline the hook's logic. Update the onFileUpdate function to set the writing status based on the filename. --------- Co-authored-by: Ajay Thorve <athorve@nvidia.com>
1 parent 4732164 commit d72b8e6

34 files changed

+1095
-276
lines changed

frontends/ui/.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@
3636
# Optional: Explicit cookie security override (rarely needed)
3737
# SECURE_COOKIES=true
3838

39+
# Session and token lifetime tuning (optional)
40+
#
41+
# Session max age in hours (applies to both NextAuth session and idToken cookie).
42+
# Default: 24. Both MUST stay aligned to prevent stale-credential bugs.
43+
# SESSION_MAX_AGE_HOURS=24
44+
#
45+
# Minutes before token expiry to trigger proactive refresh.
46+
# Default: 5. For deployments with long-running jobs (deep research runs
47+
# 20-40+ minutes), set to 30 to prevent mid-job 401 errors.
48+
# TOKEN_REFRESH_BUFFER_MINUTES=5
49+
3950
# =============================================================================
4051
# OAuth Provider (required when REQUIRE_AUTH=true)
4152
# =============================================================================

frontends/ui/README.md

Lines changed: 123 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -297,20 +297,16 @@ All environment variables are **runtime configurable** - no container rebuild ne
297297
| `REQUIRE_AUTH` | `false` | Set to `true` to require OAuth login |
298298
| `NEXTAUTH_SECRET` | - | Session encryption secret (required if auth enabled) |
299299
| `NEXTAUTH_URL` | - | Public URL where app is hosted (required if auth enabled) |
300+
| `SESSION_MAX_AGE_HOURS` | `24` | Session and idToken cookie lifetime in hours |
301+
| `TOKEN_REFRESH_BUFFER_MINUTES` | `5` | Minutes before token expiry to trigger refresh (set to 30 for long-running jobs) |
300302

301303
> **Cookie Security:** `NEXTAUTH_URL` determines cookie security:
302304
> - `http://...` -> non-secure cookies (local dev over HTTP)
303305
> - `https://...` -> secure cookies (production over HTTPS)
304306

305307
### OAuth (required when `REQUIRE_AUTH=true`)
306308

307-
| Variable | Default | Description |
308-
|----------|---------|-------------|
309-
| `OAUTH_CLIENT_ID` | - | OAuth client ID from your OIDC provider |
310-
| `OAUTH_CLIENT_SECRET` | - | OAuth client secret |
311-
| `OAUTH_ISSUER` | - | OIDC issuer URL (enables auto-discovery of endpoints) |
312-
313-
> **Note:** When `OAUTH_ISSUER` is set, the app uses OIDC auto-discovery to resolve authorization, token, and userinfo endpoints automatically. No additional endpoint URLs are needed for standard OIDC providers.
309+
Provider-specific env vars depend on your provider implementation. See `src/adapters/auth/providers/auth-example.ts` for a template/checklist and the [Authentication](#authentication) section for setup steps.
314310

315311

316312
## API Communication
@@ -325,7 +321,7 @@ OpenAI-compatible chat completions via `/chat/stream`:
325321
import { streamChat } from '@/adapters/api'
326322
327323
await streamChat(
328-
{ messages, sessionId, workflowId },
324+
{ messages, sessionId },
329325
{
330326
onChunk: (content) => console.log(content),
331327
onComplete: () => console.log('Done'),
@@ -343,7 +339,6 @@ import { createWebSocketClient } from '@/adapters/api'
343339
344340
const ws = createWebSocketClient({
345341
sessionId: 'abc123',
346-
workflowId: 'researcher',
347342
callbacks: {
348343
onAgentText: (content, isFinal) => {},
349344
onStatus: (status, message) => {},
@@ -358,23 +353,133 @@ ws.sendMessage('Hello!')
358353

359354
## Authentication
360355

361-
Authentication is **disabled by default**. All users are assigned a "Default User" identity with no login required.
356+
Authentication is **disabled by default**. All users are assigned a "Default User" identity with no login required. The auth system uses a **plugin architecture** where `src/adapters/auth/providers/index.ts` is the sole file that controls whether auth is enabled and which provider is active.
357+
358+
### Architecture
359+
360+
```
361+
src/adapters/auth/
362+
├── providers/
363+
│ ├── types.ts # AuthProviderConfig interface (contract)
364+
│ ├── index.ts # SWAP-POINT: returns null (disabled) or a real provider
365+
│ └── auth-example.ts # Provider template/checklist (not imported by default)
366+
├── config.ts # NextAuth config (provider-agnostic, never needs editing)
367+
├── session.ts # useAuth() hook (provider-agnostic)
368+
├── types.ts # NextAuth type extensions
369+
└── index.ts # Re-exports
370+
```
371+
372+
- `providers/index.ts` exports `getAuthProviderConfig()` which returns the active provider configuration. By default it returns `{ provider: null }` (auth disabled).
373+
- `config.ts` imports from `providers/index.ts` and wires the provider into NextAuth. It never needs to be edited when adding a new provider.
374+
- `session.ts` provides the `useAuth()` hook that components use. It reads `authProviderId` from `AppConfig` and adapts dynamically.
375+
376+
### Provider Contract
377+
378+
Every auth provider must conform to the `AuthProviderConfig` interface defined in `providers/types.ts`:
379+
380+
```typescript
381+
interface AuthProviderConfig {
382+
provider: Record<string, unknown> | null // NextAuth-compatible provider object, or null
383+
providerId: string // ID used in signIn(providerId) -- must match provider.id
384+
refreshToken: (refreshToken: string) => Promise<TokenRefreshResult>
385+
}
386+
387+
interface TokenRefreshResult {
388+
access_token: string
389+
id_token?: string
390+
expires_in: number
391+
refresh_token?: string
392+
}
393+
```
394+
395+
### Enabling Authentication (step-by-step)
396+
397+
To enable OAuth/OIDC authentication, follow these steps:
398+
399+
#### Step 1: Create a provider file
400+
401+
Create a new file in `src/adapters/auth/providers/` (e.g. `my-sso.ts`). See `auth-example.ts` in the same directory for a template/checklist. Your file should export:
402+
403+
1. A NextAuth-compatible provider object (OAuth/OIDC config)
404+
2. A token refresh function matching the `TokenRefreshResult` return type
405+
406+
Example minimal provider:
362407

363-
To enable OAuth authentication:
408+
```typescript
409+
// src/adapters/auth/providers/my-sso.ts
410+
import type { TokenRefreshResult } from './types'
411+
412+
export const MySSOProvider = {
413+
id: 'my-sso',
414+
name: 'My SSO',
415+
type: 'oauth' as const,
416+
wellKnown: `${process.env.MY_SSO_ISSUER}/.well-known/openid-configuration`,
417+
authorization: {
418+
params: { scope: 'openid profile email', response_type: 'code' },
419+
},
420+
clientId: process.env.MY_SSO_CLIENT_ID,
421+
clientSecret: process.env.MY_SSO_CLIENT_SECRET || '',
422+
checks: ['pkce', 'state'] as ('pkce' | 'state' | 'nonce')[],
423+
idToken: true,
424+
profile(profile: { sub: string; email: string; name: string; picture?: string }) {
425+
return { id: profile.sub, email: profile.email, name: profile.name, image: profile.picture }
426+
},
427+
}
428+
429+
export const refreshMySSOToken = async (refreshToken: string): Promise<TokenRefreshResult> => {
430+
const response = await fetch(process.env.MY_SSO_TOKEN_URL!, {
431+
method: 'POST',
432+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
433+
body: new URLSearchParams({
434+
grant_type: 'refresh_token',
435+
refresh_token: refreshToken,
436+
client_id: process.env.MY_SSO_CLIENT_ID || '',
437+
}),
438+
})
439+
const tokens = await response.json()
440+
if (!response.ok) throw tokens
441+
return tokens
442+
}
443+
```
444+
445+
#### Step 2: Wire it into providers/index.ts
446+
447+
Replace the default `getAuthProviderConfig()` to return your provider:
448+
449+
```typescript
450+
// src/adapters/auth/providers/index.ts
451+
import type { AuthProviderConfig } from './types'
452+
import { MySSOProvider, refreshMySSOToken } from './my-sso'
453+
454+
export type { AuthProviderConfig, TokenRefreshResult } from './types'
455+
456+
export const getAuthProviderConfig = (): AuthProviderConfig => ({
457+
provider: MySSOProvider,
458+
providerId: 'my-sso',
459+
refreshToken: refreshMySSOToken,
460+
})
461+
```
364462

365-
1. Set `REQUIRE_AUTH=true`
366-
2. Configure your OIDC provider credentials:
463+
#### Step 3: Set environment variables
367464

368465
```bash
369-
# .env
370466
REQUIRE_AUTH=true
371467
NEXTAUTH_SECRET=<generate-with-openssl-rand-base64-32>
372468
NEXTAUTH_URL=http://localhost:3000
373-
OAUTH_CLIENT_ID=<your-client-id>
374-
OAUTH_CLIENT_SECRET=<your-client-secret>
375-
OAUTH_ISSUER=<your-oidc-issuer-url>
469+
470+
# Provider-specific (names depend on your provider file)
471+
MY_SSO_ISSUER=https://sso.example.com
472+
MY_SSO_CLIENT_ID=<your-client-id>
473+
MY_SSO_CLIENT_SECRET=<your-client-secret>
474+
MY_SSO_TOKEN_URL=https://sso.example.com/token
376475
```
377476

477+
That's it. No other files need to change -- `config.ts`, `session.ts`, `proxy.ts`, and all components automatically adapt to the new provider via `getAuthProviderConfig()`.
478+
479+
### Disabling Authentication
480+
481+
To disable auth (the default), ensure `providers/index.ts` returns `{ provider: null }` and either unset `REQUIRE_AUTH` or set `REQUIRE_AUTH=false`. The app will use a "Default User" identity with no login required.
482+
378483
### Using the Auth Hook
379484

380485
```typescript
@@ -386,16 +491,11 @@ const MyComponent = () => {
386491
if (isLoading) return <Spinner />
387492
if (!isAuthenticated) return <Button onClick={signIn}>Sign In</Button>
388493

389-
// Use idToken for backend API calls
390-
await fetch('/api/data', {
391-
headers: { 'Authorization': `Bearer ${idToken}` }
392-
})
393-
394494
return <Text>Welcome, {user?.name}</Text>
395495
}
396496
```
397497

398-
>**NOTE:** Above Authentication docs are reference only and implementation depends on environment specifics.
498+
When auth is disabled, `useAuth()` returns `isAuthenticated: true` with a default user -- no sign-in flow is triggered.
399499

400500

401501
## Development
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Test-only shim for Next.js server boundary marker.
2+
export {}

frontends/ui/server.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,14 @@ const startServer = async () => {
119119
req.socket.setKeepAlive?.(true, 15000)
120120
req.socket.setTimeout?.(0)
121121

122-
const parsedUrl = parse(req.url, true)
122+
let parsedUrl
123+
try {
124+
parsedUrl = parse(req.url, true)
125+
} catch {
126+
res.writeHead(400, { 'Content-Type': 'text/plain' })
127+
res.end('Bad Request')
128+
return
129+
}
123130

124131
if (dev) {
125132
// Development: proxy everything to Next.js dev server
@@ -141,7 +148,13 @@ const startServer = async () => {
141148
socket.setKeepAlive?.(true, 15000)
142149
socket.setTimeout?.(0)
143150

144-
const parsedUrl = parse(req.url, true)
151+
let parsedUrl
152+
try {
153+
parsedUrl = parse(req.url, true)
154+
} catch {
155+
socket.destroy()
156+
return
157+
}
145158
const pathname = parsedUrl.pathname || '/'
146159

147160
// Proxy /websocket to backend

frontends/ui/src/adapters/api/chat-client.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919

2020
export interface StreamChatOptions {
2121
messages: Message[]
22-
workflowId?: string
2322
sessionId?: string
2423
model?: string
2524
temperature?: number
@@ -45,14 +44,13 @@ export const streamChat = async (
4544
options: StreamChatOptions,
4645
callbacks: StreamCallbacks
4746
): Promise<void> => {
48-
const { messages, workflowId, sessionId, model, temperature, maxTokens, signal, authToken } =
47+
const { messages, sessionId, model, temperature, maxTokens, signal, authToken } =
4948
options
5049
const { onChunk, onComplete, onError } = callbacks
5150

5251
const requestBody: ChatCompletionRequest = {
5352
messages,
5453
stream: true,
55-
workflow_id: workflowId,
5654
session_id: sessionId,
5755
model,
5856
temperature,
@@ -221,8 +219,6 @@ export interface GenerateStreamMessage {
221219
export interface StreamGenerateOptions {
222220
/** User's input message */
223221
inputMessage: string
224-
/** Workflow ID for the backend */
225-
workflowId?: string
226222
/** Session ID for conversation tracking */
227223
sessionId?: string
228224
/** Abort signal */
@@ -299,12 +295,11 @@ export const streamGenerate = async (
299295
options: StreamGenerateOptions,
300296
callbacks: GenerateStreamCallbacks
301297
): Promise<void> => {
302-
const { inputMessage, workflowId, sessionId, signal, authToken } = options
298+
const { inputMessage, sessionId, signal, authToken } = options
303299
const { onThinking, onStatus, onPrompt, onReport, onComplete, onError } = callbacks
304300

305301
const requestBody = {
306302
input_message: inputMessage,
307-
workflow_id: workflowId,
308303
session_id: sessionId,
309304
stream: true,
310305
}

frontends/ui/src/adapters/api/schemas.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export const ChatCompletionRequestSchema = z.object({
2626
temperature: z.number().optional(),
2727
max_tokens: z.number().optional(),
2828
stream: z.boolean().optional(),
29-
workflow_id: z.string().optional(),
3029
session_id: z.string().optional(),
3130
})
3231

@@ -256,7 +255,6 @@ export const NATIncomingMessageSchema = z.discriminatedUnion('type', [
256255
export const WebSocketConnectMessageSchema = z.object({
257256
type: z.literal('connect'),
258257
session_id: z.string(),
259-
workflow_id: z.string(),
260258
/** Auth token for backend authentication */
261259
auth_token: z.string().optional(),
262260
})

0 commit comments

Comments
 (0)