Skip to content
Open
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
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,66 @@ Optional local services (provided in docker-compose.yml for dev):
- NCPS (Nix cache proxy) on 8501
- Prometheus (9090), Grafana (3000), cAdvisor (8080)

## Authentication & OIDC setup (read before configuring environments)

Agyn supports two authentication modes controlled by `AUTH_MODE`:

- `single_user` (default): skips login and binds every request to the built-in `default@local` user (`00000000-0000-0000-0000-000000000001`). Use this only for air‑gapped demos—the default user owns every thread and there is no access control.
- `oidc`: enables the `/api/auth/login` → `/api/auth/oidc/callback` flow, persists users by issuer/subject, and issues signed `agyn_session` cookies per authenticated user.

### Required environment in OIDC mode

When `AUTH_MODE=oidc`, the server refuses to boot until the following are present:

| Variable | Purpose |
| --- | --- |
| `AUTH_MODE=oidc` | Opt-in to federated auth. |
| `SESSION_SECRET` | 32+ character random string used to sign session cookies; must remain stable across restarts and replicas. |
| `OIDC_ISSUER_URL` | Discovery URL (e.g., `https://login.example.com/realms/agents`). |
| `OIDC_CLIENT_ID` | OAuth client identifier registered with your IdP. |
| `OIDC_CLIENT_SECRET` | Optional; supply when your IdP requires confidential clients. Leave blank only if the provider allows public clients. |
| `OIDC_REDIRECT_URI` | Must route to `https://<api-host>/api/auth/oidc/callback`. This exact URI must also be registered with the IdP. |
| `OIDC_SCOPES` | Space/comma separated scopes (default `openid profile email`). |
| `OIDC_POST_LOGIN_REDIRECT` | Path relative to the UI origin to land on after login (default `/`). |

Example `.env` excerpt for local testing:

```
AUTH_MODE=oidc
SESSION_SECRET=dev-0123456789abcdef0123456789abcdef
OIDC_ISSUER_URL=https://auth.local/realms/dev
OIDC_CLIENT_ID=agyn-local
OIDC_CLIENT_SECRET=local-secret
OIDC_REDIRECT_URI=http://localhost:3010/api/auth/oidc/callback
OIDC_SCOPES=openid profile email offline_access
OIDC_POST_LOGIN_REDIRECT=/threads
```

### Redirect + session behavior

- The callback endpoint is always `GET /api/auth/oidc/callback`; set `OIDC_REDIRECT_URI` to this path on the API origin (`http://localhost:3010` in dev, your HTTPS hostname in prod).
- Successful callbacks create a 30-day `agyn_session` cookie (`HttpOnly`, `SameSite=Lax`, `Secure` in production). Clients must send this cookie on every request; the server verifies it using `SESSION_SECRET` and loads the user via Prisma.
- Logging out calls `POST /api/auth/logout`, deletes the server-side session row, and clears the cookie.

### Local development tips

1. **Same-origin (simplest):** Build and serve `platform-ui` through nginx (or run both services behind the same host/port). No extra CORS or credential settings are required.
2. **Cross-origin (Vite dev server → API):**
- Set `CORS_ORIGINS=http://localhost:5173` (or whatever hosts the UI)
- Ensure every UI fetch/axios call includes credentials, e.g. `fetch(url, { credentials: 'include' })` or `axios.create({ withCredentials: true })`
- Keep `VITE_API_BASE_URL` pointed at the API origin (e.g., `http://localhost:3010`)
- Update `OIDC_REDIRECT_URI` to the API origin even if the UI runs elsewhere; the IdP redirects into the API, which then forwards the browser to `OIDC_POST_LOGIN_REDIRECT`.
3. Restarting the server rotates the default (non-random) `SESSION_SECRET`; for cross-origin dev keep a stable secret in `.env` so cookies remain valid after reloads.

### Troubleshooting

- **`oidc_disabled` errors**: `AUTH_MODE` is still `single_user` or the server restarted without the OIDC env block.
- **Redirect loops or `invalid_grant`**: The IdP callback URL must exactly match `OIDC_REDIRECT_URI`, including scheme/port. Regenerate the client if needed.
- **Cookie missing in the browser**: Confirm `CORS_ORIGINS` allows the UI origin and the client sends requests with credentials. On HTTPS sites, ensure you are not hitting the API via plain HTTP because the cookie is marked `Secure` in production.
- **`Session cookie signature mismatch` warnings**: All replicas must share the same `SESSION_SECRET`; rotating it invalidates existing sessions.

---

### Setup
1) Clone and install:
```bash
Expand Down
13 changes: 13 additions & 0 deletions packages/platform-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ LLM_PROVIDER=
LITELLM_BASE_URL=http://127.0.0.1:4000
LITELLM_MASTER_KEY=sk-dev-master-1234

# Authentication. Modes: single_user (default) or oidc
AUTH_MODE=single_user
# Must be at least 32 characters. Replace in production.
SESSION_SECRET=dev-session-secret-change-me-0123456789abcdef

# OIDC (set AUTH_MODE=oidc to enable)
# OIDC_ISSUER_URL=https://your-idp/.well-known/openid-configuration
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# OIDC_REDIRECT_URI=http://localhost:3010/api/auth/oidc/callback
# OIDC_SCOPES=openid profile email
# OIDC_POST_LOGIN_REDIRECT=http://localhost:4173

# Optional: GitHub integration (App or PAT). Safe to omit for local dev.
# GITHUB_APP_ID=
# GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway';
import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager';
import { ThreadsMetricsService } from '../src/agents/threads.metrics.service';
import { PrismaService } from '../src/core/services/prisma.service';
import { ConfigService } from '../src/core/services/config.service';
import { ContainerTerminalGateway } from '../src/infra/container/terminal.gateway';
import { TerminalSessionsService, type TerminalSessionRecord } from '../src/infra/container/terminal.sessions.service';
import { AuthService } from '../src/auth/auth.service';
import {
WorkspaceProvider,
type WorkspaceKey,
Expand Down Expand Up @@ -47,18 +49,32 @@ class ThreadsMetricsServiceStub {

class PrismaServiceStub {
private readonly runEvents = new Map<string, unknown>();
private readonly threadOwners = new Map<string, string>();

setRunEvent(event: { id: string }): void {
this.runEvents.set(event.id, event);
}

setThreadOwner(threadId: string, ownerUserId: string): void {
this.threadOwners.set(threadId, ownerUserId);
}

clear(): void {
this.runEvents.clear();
this.threadOwners.clear();
}

getClient() {
return {
$queryRaw: async () => [],
thread: {
findUnique: async ({ where }: { where: { id: string } }) => {
const id = where?.id;
if (!id) return null;
const ownerUserId = this.threadOwners.get(id);
return ownerUserId ? { id, ownerUserId } : null;
},
},
runEvent: {
findUnique: async ({ where }: { where: { id: string } }) => {
const id = where?.id;
Expand Down Expand Up @@ -154,6 +170,17 @@ class WorkspaceProviderStub extends WorkspaceProvider {
}
}

class AuthServiceStub {
async resolvePrincipalFromCookieHeader(): Promise<{ userId: string }> {
return { userId: 'test-user' };
}
}

class ConfigServiceStub {
corsOrigins: string[] | null = null;
isProduction = false;
}

class TerminalSessionsServiceStub {
public connected = false;
public closed = false;
Expand Down Expand Up @@ -322,6 +349,8 @@ describe('Socket gateway real server handshakes', () => {
{ provide: WorkspaceProvider, useClass: WorkspaceProviderStub },
EventsBusService,
RunEventsService,
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: ConfigService, useClass: ConfigServiceStub },
],
}).compile();

Expand Down Expand Up @@ -389,6 +418,8 @@ describe('Socket gateway real server handshakes', () => {

const threadId = 'thread-123';
const runId = 'run-456';
const ownerUserId = 'test-user';
prismaStub.setThreadOwner(threadId, ownerUserId);

const messagePromise = new Promise<Record<string, unknown>>((resolve, reject) => {
const timer = setTimeout(() => {
Expand Down Expand Up @@ -448,7 +479,7 @@ describe('Socket gateway real server handshakes', () => {
expect(ack.rooms).toEqual(expect.arrayContaining(['threads', `thread:${threadId}`, `run:${runId}`]));

const createdAt = new Date();
graphGateway.emitMessageCreated(threadId, {
graphGateway.emitMessageCreated(threadId, ownerUserId, {
id: 'msg-1',
kind: 'assistant' as MessageKind,
text: 'hello world',
Expand All @@ -457,11 +488,15 @@ describe('Socket gateway real server handshakes', () => {
runId,
});

graphGateway.emitRunStatusChanged(threadId, {
id: runId,
status: 'running' as RunStatus,
createdAt,
updatedAt: createdAt,
graphGateway.emitRunStatusChanged({
threadId,
ownerUserId,
run: {
id: runId,
status: 'running' as RunStatus,
createdAt,
updatedAt: createdAt,
},
});

const runEventId = 'evt-1';
Expand Down Expand Up @@ -620,6 +655,7 @@ describe('Socket gateway real server handshakes', () => {

const threadId = 'thread-999';
const runId = 'run-999';
prismaStub.setThreadOwner(threadId, 'test-user');

const subscribeAck = await new Promise<{ ok: boolean; rooms?: string[]; error?: string }>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timed out waiting for subscribe ack')), 3000);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager';
import { TemplateRegistry } from '../src/graph-core/templateRegistry';
import { RemindersService } from '../src/agents/reminders.service';

const principal = { userId: 'user-1' } as any;

class StubLLMProvisioner extends LLMProvisioner {
async init(): Promise<void> {}
async getLLM(): Promise<{ call: (messages: unknown) => Promise<{ text: string; output: unknown[] }> }> {
Expand Down Expand Up @@ -95,6 +97,6 @@ describe('Fail-fast behavior', () => {
}).compile();

const ctrl = await module.resolve(AgentsThreadsController);
await expect(ctrl.listThreads({} as any)).rejects.toBeTruthy();
await expect(ctrl.listThreads({} as any, principal)).rejects.toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createPrismaStub, StubPrismaService } from './helpers/prisma.stub';
import { createRunEventsStub } from './helpers/runEvents.stub';
import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service';
import { createEventsBusStub } from './helpers/eventsBus.stub';
import { createUserServiceStub } from './helpers/userService.stub';

const metricsStub = { getThreadsMetrics: async () => ({}) } as any;
const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any;
Expand Down Expand Up @@ -39,6 +40,7 @@ const createService = (stub: any) => {
createRunEventsStub() as any,
createLinkingStub(),
eventsBusStub,
createUserServiceStub(),
);
(svc as any).__eventsBusStub = eventsBusStub;
return svc;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import type { ResponseFunctionToolCall } from 'openai/resources/responses/respon
import { createRunEventsStub } from './helpers/runEvents.stub';
import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service';
import { createEventsBusStub } from './helpers/eventsBus.stub';
import { createUserServiceStub } from './helpers/userService.stub';

const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any;
const graphRepoStub = {
Expand Down Expand Up @@ -97,6 +98,7 @@ function makeService(): InstanceType<typeof AgentsPersistenceService> {
createRunEventsStub() as any,
createLinkingStub(),
eventsBusStub,
createUserServiceStub(),
);
return svc;
}
Expand Down Expand Up @@ -155,6 +157,7 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text',
createRunEventsStub() as any,
linking,
eventsBusStub,
createUserServiceStub(),
);

// Begin run with user + system messages
Expand All @@ -179,6 +182,9 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text',
const createdRunMessages: any[] = [];
const runs: any[] = [{ id: 'run-1', threadId: 'thread-1', status: 'running' }];
const prismaMock = {
thread: {
findUnique: async ({ where }: any) => ({ id: where.id, ownerUserId: 'user-test' }),
},
run: {
findUnique: async ({ where }: any) => runs.find((x) => x.id === where.id) ?? null,
},
Expand Down Expand Up @@ -210,6 +216,7 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text',
runEventsStub as any,
linking,
eventsBusStub,
createUserServiceStub(),
);

const result = await svc.recordTransportAssistantMessage({
Expand All @@ -231,17 +238,23 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text',
role: 'assistant',
}),
);
expect(eventsBusStub.emitMessageCreated).toHaveBeenCalledWith({
threadId: 'thread-1',
message: expect.objectContaining({ id: 'm1', kind: 'assistant', text: 'final reply', runId: 'run-1' }),
});
expect(eventsBusStub.emitMessageCreated).toHaveBeenCalledWith(
expect.objectContaining({
threadId: 'thread-1',
ownerUserId: 'user-test',
message: expect.objectContaining({ id: 'm1', kind: 'assistant', text: 'final reply', runId: 'run-1' }),
}),
);
});

it('recordTransportAssistantMessage skips invocation event for send_message source', async () => {
const createdMessages: any[] = [];
const createdRunMessages: any[] = [];
const runs: any[] = [{ id: 'run-1', threadId: 'thread-1', status: 'running' }];
const prismaMock = {
thread: {
findUnique: async ({ where }: any) => ({ id: where.id, ownerUserId: 'user-test' }),
},
run: {
findUnique: async ({ where }: any) => runs.find((x) => x.id === where.id) ?? null,
},
Expand Down Expand Up @@ -273,6 +286,7 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text',
runEventsStub as any,
linking,
eventsBusStub,
createUserServiceStub(),
);

const result = await svc.recordTransportAssistantMessage({
Expand All @@ -285,9 +299,12 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text',
expect(result).toEqual({ messageId: 'm1' });
expect(createdRunMessages).toEqual([{ runId: 'run-1', messageId: 'm1', type: 'output' }]);
expect(runEventsStub.recordInvocationMessage).not.toHaveBeenCalled();
expect(eventsBusStub.emitMessageCreated).toHaveBeenCalledWith({
threadId: 'thread-1',
message: expect.objectContaining({ id: 'm1', text: 'fallback reply', runId: 'run-1' }),
});
expect(eventsBusStub.emitMessageCreated).toHaveBeenCalledWith(
expect.objectContaining({
threadId: 'thread-1',
ownerUserId: 'user-test',
message: expect.objectContaining({ id: 'm1', text: 'fallback reply', runId: 'run-1' }),
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StubPrismaService, createPrismaStub } from './helpers/prisma.stub';
import { createRunEventsStub } from './helpers/runEvents.stub';
import { createEventsBusStub } from './helpers/eventsBus.stub';
import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service';
import { createUserServiceStub } from './helpers/userService.stub';

const createLinkingStub = (overrides?: Partial<CallAgentLinkingService>) =>
({
Expand Down Expand Up @@ -53,6 +54,7 @@ function createService(
createRunEventsStub() as any,
overrides?.linking ?? createLinkingStub(),
eventsBusStub,
createUserServiceStub(),
);
return svc;
}
Expand Down
Loading