Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8056a3e
refactor(auth): pluggable provider architecture via providers/index.ts
exactlyallan Mar 5, 2026
b800a65
fix(auth): ephemeral secret fallback, aligned lifetimes, configurable…
exactlyallan Mar 5, 2026
f8f6b30
fix(ui): add missing HITL respond route, fix auth token, guard URL pa…
exactlyallan Mar 5, 2026
1e3ab74
refactor(ui): remove unused NEXT_PUBLIC_WORKFLOW_ID / workflowId plum…
exactlyallan Mar 5, 2026
6da0b69
fix(auth): type-safe providers, rename internal-auth to auth-example,…
exactlyallan Mar 5, 2026
a71c13e
fix(auth): use node:crypto import for test compat, fix stale file ref…
exactlyallan Mar 6, 2026
b2039b6
fix(auth): address PR #126 review comments
exactlyallan Mar 6, 2026
085a353
fix(auth): isolate server-only auth config from client imports
exactlyallan Mar 9, 2026
a253205
fix(ui): clear stale deep research state after missing jobs
exactlyallan Mar 9, 2026
b485940
fix(test): stub server-only for vitest auth imports
exactlyallan Mar 10, 2026
79c0b74
fix(ui): address remaining PR #126 review comments
exactlyallan Mar 10, 2026
37a31bd
fix(auth): avoid unknown-expiry refresh churn
exactlyallan Mar 11, 2026
889dd50
fix(test): satisfy auth config callback types
exactlyallan Mar 11, 2026
6c88405
fix(ui): remove workflowId references from README and schemas
AjayThorve Mar 12, 2026
a20aff4
fix(chat): enhance file update handling in useDeepResearch hook
AjayThorve Mar 12, 2026
82aa887
Merge pull request #1 from AjayThorve/aiq_UI-auth-isolation
exactlyallan Mar 12, 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
11 changes: 11 additions & 0 deletions frontends/ui/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@
# Optional: Explicit cookie security override (rarely needed)
# SECURE_COOKIES=true

# Session and token lifetime tuning (optional)
#
# Session max age in hours (applies to both NextAuth session and idToken cookie).
# Default: 24. Both MUST stay aligned to prevent stale-credential bugs.
# SESSION_MAX_AGE_HOURS=24
#
# Minutes before token expiry to trigger proactive refresh.
# Default: 5. For deployments with long-running jobs (deep research runs
# 20-40+ minutes), set to 30 to prevent mid-job 401 errors.
# TOKEN_REFRESH_BUFFER_MINUTES=5

# =============================================================================
# OAuth Provider (required when REQUIRE_AUTH=true)
# =============================================================================
Expand Down
146 changes: 123 additions & 23 deletions frontends/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,20 +297,16 @@ All environment variables are **runtime configurable** - no container rebuild ne
| `REQUIRE_AUTH` | `false` | Set to `true` to require OAuth login |
| `NEXTAUTH_SECRET` | - | Session encryption secret (required if auth enabled) |
| `NEXTAUTH_URL` | - | Public URL where app is hosted (required if auth enabled) |
| `SESSION_MAX_AGE_HOURS` | `24` | Session and idToken cookie lifetime in hours |
| `TOKEN_REFRESH_BUFFER_MINUTES` | `5` | Minutes before token expiry to trigger refresh (set to 30 for long-running jobs) |

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

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

| Variable | Default | Description |
|----------|---------|-------------|
| `OAUTH_CLIENT_ID` | - | OAuth client ID from your OIDC provider |
| `OAUTH_CLIENT_SECRET` | - | OAuth client secret |
| `OAUTH_ISSUER` | - | OIDC issuer URL (enables auto-discovery of endpoints) |

> **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.
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.


## API Communication
Expand All @@ -325,7 +321,7 @@ OpenAI-compatible chat completions via `/chat/stream`:
import { streamChat } from '@/adapters/api'

await streamChat(
{ messages, sessionId, workflowId },
{ messages, sessionId },
{
onChunk: (content) => console.log(content),
onComplete: () => console.log('Done'),
Expand All @@ -343,7 +339,6 @@ import { createWebSocketClient } from '@/adapters/api'

const ws = createWebSocketClient({
sessionId: 'abc123',
workflowId: 'researcher',
callbacks: {
onAgentText: (content, isFinal) => {},
onStatus: (status, message) => {},
Expand All @@ -358,23 +353,133 @@ ws.sendMessage('Hello!')

## Authentication

Authentication is **disabled by default**. All users are assigned a "Default User" identity with no login required.
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.

### Architecture

```
src/adapters/auth/
├── providers/
│ ├── types.ts # AuthProviderConfig interface (contract)
│ ├── index.ts # SWAP-POINT: returns null (disabled) or a real provider
│ └── auth-example.ts # Provider template/checklist (not imported by default)
├── config.ts # NextAuth config (provider-agnostic, never needs editing)
├── session.ts # useAuth() hook (provider-agnostic)
├── types.ts # NextAuth type extensions
└── index.ts # Re-exports
```

- `providers/index.ts` exports `getAuthProviderConfig()` which returns the active provider configuration. By default it returns `{ provider: null }` (auth disabled).
- `config.ts` imports from `providers/index.ts` and wires the provider into NextAuth. It never needs to be edited when adding a new provider.
- `session.ts` provides the `useAuth()` hook that components use. It reads `authProviderId` from `AppConfig` and adapts dynamically.

### Provider Contract

Every auth provider must conform to the `AuthProviderConfig` interface defined in `providers/types.ts`:

```typescript
interface AuthProviderConfig {
provider: Record<string, unknown> | null // NextAuth-compatible provider object, or null
providerId: string // ID used in signIn(providerId) -- must match provider.id
refreshToken: (refreshToken: string) => Promise<TokenRefreshResult>
}

interface TokenRefreshResult {
access_token: string
id_token?: string
expires_in: number
refresh_token?: string
}
```

### Enabling Authentication (step-by-step)

To enable OAuth/OIDC authentication, follow these steps:

#### Step 1: Create a provider file

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:

1. A NextAuth-compatible provider object (OAuth/OIDC config)
2. A token refresh function matching the `TokenRefreshResult` return type

Example minimal provider:

To enable OAuth authentication:
```typescript
// src/adapters/auth/providers/my-sso.ts
import type { TokenRefreshResult } from './types'

export const MySSOProvider = {
id: 'my-sso',
name: 'My SSO',
type: 'oauth' as const,
wellKnown: `${process.env.MY_SSO_ISSUER}/.well-known/openid-configuration`,
authorization: {
params: { scope: 'openid profile email', response_type: 'code' },
},
clientId: process.env.MY_SSO_CLIENT_ID,
clientSecret: process.env.MY_SSO_CLIENT_SECRET || '',
checks: ['pkce', 'state'] as ('pkce' | 'state' | 'nonce')[],
idToken: true,
profile(profile: { sub: string; email: string; name: string; picture?: string }) {
return { id: profile.sub, email: profile.email, name: profile.name, image: profile.picture }
},
}

export const refreshMySSOToken = async (refreshToken: string): Promise<TokenRefreshResult> => {
const response = await fetch(process.env.MY_SSO_TOKEN_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.MY_SSO_CLIENT_ID || '',
}),
})
const tokens = await response.json()
if (!response.ok) throw tokens
return tokens
}
```

#### Step 2: Wire it into providers/index.ts

Replace the default `getAuthProviderConfig()` to return your provider:

```typescript
// src/adapters/auth/providers/index.ts
import type { AuthProviderConfig } from './types'
import { MySSOProvider, refreshMySSOToken } from './my-sso'

export type { AuthProviderConfig, TokenRefreshResult } from './types'

export const getAuthProviderConfig = (): AuthProviderConfig => ({
provider: MySSOProvider,
providerId: 'my-sso',
refreshToken: refreshMySSOToken,
})
```

1. Set `REQUIRE_AUTH=true`
2. Configure your OIDC provider credentials:
#### Step 3: Set environment variables

```bash
# .env
REQUIRE_AUTH=true
NEXTAUTH_SECRET=<generate-with-openssl-rand-base64-32>
NEXTAUTH_URL=http://localhost:3000
OAUTH_CLIENT_ID=<your-client-id>
OAUTH_CLIENT_SECRET=<your-client-secret>
OAUTH_ISSUER=<your-oidc-issuer-url>

# Provider-specific (names depend on your provider file)
MY_SSO_ISSUER=https://sso.example.com
MY_SSO_CLIENT_ID=<your-client-id>
MY_SSO_CLIENT_SECRET=<your-client-secret>
MY_SSO_TOKEN_URL=https://sso.example.com/token
```

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()`.

### Disabling Authentication

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.

### Using the Auth Hook

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

// Use idToken for backend API calls
await fetch('/api/data', {
headers: { 'Authorization': `Bearer ${idToken}` }
})

return <Text>Welcome, {user?.name}</Text>
}
```

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


## Development
Expand Down
2 changes: 2 additions & 0 deletions frontends/ui/config/vitest/mocks/server-only.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Test-only shim for Next.js server boundary marker.
export {}
17 changes: 15 additions & 2 deletions frontends/ui/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,14 @@ const startServer = async () => {
req.socket.setKeepAlive?.(true, 15000)
req.socket.setTimeout?.(0)

const parsedUrl = parse(req.url, true)
let parsedUrl
try {
parsedUrl = parse(req.url, true)
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain' })
res.end('Bad Request')
return
}

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

const parsedUrl = parse(req.url, true)
let parsedUrl
try {
parsedUrl = parse(req.url, true)
} catch {
socket.destroy()
return
}
const pathname = parsedUrl.pathname || '/'

// Proxy /websocket to backend
Expand Down
9 changes: 2 additions & 7 deletions frontends/ui/src/adapters/api/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {

export interface StreamChatOptions {
messages: Message[]
workflowId?: string
sessionId?: string
model?: string
temperature?: number
Expand All @@ -45,14 +44,13 @@ export const streamChat = async (
options: StreamChatOptions,
callbacks: StreamCallbacks
): Promise<void> => {
const { messages, workflowId, sessionId, model, temperature, maxTokens, signal, authToken } =
const { messages, sessionId, model, temperature, maxTokens, signal, authToken } =
options
const { onChunk, onComplete, onError } = callbacks

const requestBody: ChatCompletionRequest = {
messages,
stream: true,
workflow_id: workflowId,
session_id: sessionId,
model,
temperature,
Expand Down Expand Up @@ -221,8 +219,6 @@ export interface GenerateStreamMessage {
export interface StreamGenerateOptions {
/** User's input message */
inputMessage: string
/** Workflow ID for the backend */
workflowId?: string
/** Session ID for conversation tracking */
sessionId?: string
/** Abort signal */
Expand Down Expand Up @@ -299,12 +295,11 @@ export const streamGenerate = async (
options: StreamGenerateOptions,
callbacks: GenerateStreamCallbacks
): Promise<void> => {
const { inputMessage, workflowId, sessionId, signal, authToken } = options
const { inputMessage, sessionId, signal, authToken } = options
const { onThinking, onStatus, onPrompt, onReport, onComplete, onError } = callbacks

const requestBody = {
input_message: inputMessage,
workflow_id: workflowId,
session_id: sessionId,
stream: true,
}
Expand Down
2 changes: 0 additions & 2 deletions frontends/ui/src/adapters/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const ChatCompletionRequestSchema = z.object({
temperature: z.number().optional(),
max_tokens: z.number().optional(),
stream: z.boolean().optional(),
workflow_id: z.string().optional(),
session_id: z.string().optional(),
})

Expand Down Expand Up @@ -256,7 +255,6 @@ export const NATIncomingMessageSchema = z.discriminatedUnion('type', [
export const WebSocketConnectMessageSchema = z.object({
type: z.literal('connect'),
session_id: z.string(),
workflow_id: z.string(),
/** Auth token for backend authentication */
auth_token: z.string().optional(),
})
Expand Down
Loading
Loading