diff --git a/packages/observer-dashboard/src/app/api/auth/login/route.ts b/packages/observer-dashboard/src/app/api/auth/login/route.ts index a936edb6..1cee23bb 100644 --- a/packages/observer-dashboard/src/app/api/auth/login/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/login/route.ts @@ -4,11 +4,17 @@ import { resolveRelayServerCandidatesFromRequest, selectEngineForKey, } from '../../../../lib/relay-server'; +import { + mintObserverStreamToken, + revokeObserverStreamToken, +} from '../../../../lib/observer-token'; export const runtime = 'edge'; const COOKIE_NAME = 'relaycast_key'; const AGENT_COOKIE_NAME = 'relaycast_agent_token'; +const WS_TOKEN_COOKIE_NAME = 'relaycast_ws_token'; +const WS_TOKEN_ID_COOKIE_NAME = 'relaycast_ws_token_id'; const ENGINE_COOKIE_NAME = 'relaycast_engine'; const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days @@ -18,13 +24,16 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days * fallback), remembers the resolved engine, and sets httpOnly cookies. * * Accepts either a full workspace key (`rk_live_...`) or a scoped, read-only - * observer token (`ot_live_...`). Both are treated identically here — the - * engine enforces the actual read-only scope restrictions for observer - * tokens on every request, this route just validates the credential and - * remembers which engine it belongs to. The WebSocket stream authenticates - * directly with whichever credential was used to log in (workspace key or - * observer token) — the engine's realtime endpoint (`GET /v1/ws`) already - * requires an observer token with `stream:read` scope. + * observer token (`ot_live_...`). The credential is used for read-only REST + * (the engine enforces observer scope restrictions on every request), but the + * realtime endpoint (`GET /v1/ws`) accepts *only* an observer token with + * `stream:read`. So when the login credential is a workspace key we mint a + * scoped observer token here and use that as the stream credential; an + * `ot_live_` login already works on the stream directly. + * + * The workspace admin key is never used as the stream credential: if minting + * fails, the stream token is left unset (the realtime socket simply does not + * connect) rather than handing an admin key to a long-lived browser socket. */ export async function POST(request: NextRequest) { try { @@ -60,6 +69,26 @@ export async function POST(request: NextRequest) { ); } + // Resolve the stream credential. An observer-token login is already a valid + // stream credential; a workspace-key login mints a scoped observer token. + // The admin key is never used for the stream — on mint failure the stream + // token stays unset (null) and the realtime socket does not connect. + let wsToken: string | null = null; + let wsTokenId: string | null = null; + if (apiKey.startsWith('ot_live_')) { + wsToken = apiKey; + } else { + const minted = await mintObserverStreamToken(relayServer, apiKey); + if (minted) { + wsToken = minted.token; + wsTokenId = minted.id; + } else { + console.error( + '[api/auth/login] Failed to mint observer stream token for workspace key' + ); + } + } + const cookieStore = await cookies(); const cookieOptions = { httpOnly: true, @@ -76,6 +105,34 @@ export async function POST(request: NextRequest) { // endpoints used in this dashboard. cookieStore.set(AGENT_COOKIE_NAME, apiKey, cookieOptions); + // Stream credential (observer token) kept separate from the REST key so the + // realtime socket never carries a workspace admin key. When there is no + // stream token, clear any stale cookies rather than leaving an old one. + if (wsToken) { + cookieStore.set(WS_TOKEN_COOKIE_NAME, wsToken, cookieOptions); + } else { + cookieStore.delete(WS_TOKEN_COOKIE_NAME); + } + + // Revoke the observer token minted for this browser's previous session + // before overwriting its id — otherwise repeated workspace-key logins + // orphan tokens that stay active until their 30-day expiry. Best effort; + // only a workspace key can revoke, and a different key just 404s harmlessly. + const previousWsTokenId = cookieStore.get(WS_TOKEN_ID_COOKIE_NAME)?.value; + if ( + previousWsTokenId && + previousWsTokenId !== wsTokenId && + apiKey.startsWith('rk_live_') + ) { + await revokeObserverStreamToken(relayServer, apiKey, previousWsTokenId); + } + // Remember the minted token id so logout can revoke it on the engine. + if (wsTokenId) { + cookieStore.set(WS_TOKEN_ID_COOKIE_NAME, wsTokenId, cookieOptions); + } else { + cookieStore.delete(WS_TOKEN_ID_COOKIE_NAME); + } + // Remember the resolved engine so session/check/logout target it directly // without re-probing both engines on every request. cookieStore.set(ENGINE_COOKIE_NAME, relayServer, cookieOptions); @@ -84,7 +141,7 @@ export async function POST(request: NextRequest) { success: true, apiKey, agentToken: apiKey, - wsToken: apiKey, + wsToken, baseUrl: relayServer, }); } catch (error) { diff --git a/packages/observer-dashboard/src/app/api/auth/logout/route.ts b/packages/observer-dashboard/src/app/api/auth/logout/route.ts index cefccace..aa92f3cf 100644 --- a/packages/observer-dashboard/src/app/api/auth/logout/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/logout/route.ts @@ -1,21 +1,47 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; +import { + pickRememberedEngine, + resolveRelayServerCandidatesFromRequest, +} from '../../../../lib/relay-server'; +import { revokeObserverStreamToken } from '../../../../lib/observer-token'; export const runtime = 'edge'; const COOKIE_NAME = 'relaycast_key'; const AGENT_COOKIE_NAME = 'relaycast_agent_token'; +const WS_TOKEN_COOKIE_NAME = 'relaycast_ws_token'; +const WS_TOKEN_ID_COOKIE_NAME = 'relaycast_ws_token_id'; const ENGINE_COOKIE_NAME = 'relaycast_engine'; /** * POST /api/auth/logout - * Clears auth cookies. + * Revokes the observer token minted for this session (best effort) and clears + * auth cookies. */ -export async function POST(_request: NextRequest) { +export async function POST(request: NextRequest) { const cookieStore = await cookies(); + // Best-effort: revoke the minted observer token so a leaked ws cookie value + // stops working immediately instead of lingering until its 30-day expiry. + // Only workspace-key sessions mint a token (and store its id + the admin key + // needed to revoke it); `ot_live_` logins have no id cookie and are skipped. + const wsTokenId = cookieStore.get(WS_TOKEN_ID_COOKIE_NAME)?.value; + const adminKey = cookieStore.get(COOKIE_NAME)?.value; + if (wsTokenId && adminKey?.startsWith('rk_live_')) { + const baseUrl = pickRememberedEngine( + cookieStore.get(ENGINE_COOKIE_NAME)?.value, + resolveRelayServerCandidatesFromRequest(request) + ); + if (baseUrl) { + await revokeObserverStreamToken(baseUrl, adminKey, wsTokenId); + } + } + cookieStore.delete(COOKIE_NAME); cookieStore.delete(AGENT_COOKIE_NAME); + cookieStore.delete(WS_TOKEN_COOKIE_NAME); + cookieStore.delete(WS_TOKEN_ID_COOKIE_NAME); cookieStore.delete(ENGINE_COOKIE_NAME); return NextResponse.json({ success: true }); } diff --git a/packages/observer-dashboard/src/app/api/auth/session/route.ts b/packages/observer-dashboard/src/app/api/auth/session/route.ts index 376ce44b..228d929d 100644 --- a/packages/observer-dashboard/src/app/api/auth/session/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/session/route.ts @@ -11,6 +11,7 @@ export const runtime = 'edge'; const COOKIE_NAME = 'relaycast_key'; const AGENT_COOKIE_NAME = 'relaycast_agent_token'; +const WS_TOKEN_COOKIE_NAME = 'relaycast_ws_token'; const ENGINE_COOKIE_NAME = 'relaycast_engine'; const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days @@ -24,6 +25,7 @@ export async function GET(request: NextRequest) { const cookieStore = await cookies(); const apiKey = cookieStore.get(COOKIE_NAME)?.value; const agentToken = cookieStore.get(AGENT_COOKIE_NAME)?.value; + const wsToken = cookieStore.get(WS_TOKEN_COOKIE_NAME)?.value; if (!apiKey) { return NextResponse.json( @@ -74,7 +76,12 @@ export async function GET(request: NextRequest) { authenticated: true, apiKey, agentToken: agentToken ?? apiKey, - wsToken: apiKey, + // The stream credential is the observer token minted at login (or an + // `ot_live_` login credential). It is intentionally NOT backfilled from the + // workspace admin key: the realtime socket must never carry an admin key. + // A workspace-key session with no ws token (mint failed, or a session + // created before this cookie existed) has no live stream until next login. + wsToken: wsToken ?? null, baseUrl, }); } diff --git a/packages/observer-dashboard/src/components/RelaySessionProvider.tsx b/packages/observer-dashboard/src/components/RelaySessionProvider.tsx index e14f7d2a..95707f78 100644 --- a/packages/observer-dashboard/src/components/RelaySessionProvider.tsx +++ b/packages/observer-dashboard/src/components/RelaySessionProvider.tsx @@ -9,7 +9,7 @@ import { resetActivityIfWorkspaceChanged } from '../lib/activity-store'; interface Session { apiKey: string; agentToken: string; - wsToken: string; + wsToken: string | null; baseUrl: string; } @@ -53,7 +53,9 @@ export function RelaySessionProvider({ children }: { children: React.ReactNode } setSession({ apiKey: data.apiKey, agentToken: data.agentToken, - wsToken: data.wsToken ?? data.apiKey, + // Never fall back to the REST/admin credential for the socket; a + // missing stream token means the realtime stream stays offline. + wsToken: data.wsToken ?? null, baseUrl: data.baseUrl, }); if (keyParam) router.replace('/'); @@ -86,8 +88,13 @@ export function RelaySessionProvider({ children }: { children: React.ReactNode } return ( diff --git a/packages/observer-dashboard/src/lib/observer-token.test.ts b/packages/observer-dashboard/src/lib/observer-token.test.ts new file mode 100644 index 00000000..b637c130 --- /dev/null +++ b/packages/observer-dashboard/src/lib/observer-token.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + mintObserverStreamToken, + revokeObserverStreamToken, +} from './observer-token'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function mockFetch(...responses: Array) { + const fetchMock = vi.fn(); + for (const response of responses) { + if (response instanceof Error) { + fetchMock.mockRejectedValueOnce(response); + } else { + fetchMock.mockResolvedValueOnce(response); + } + } + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('mintObserverStreamToken', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('creates a fresh, uniquely-named scoped token and returns token + id', async () => { + const fetchMock = mockFetch( + jsonResponse({ ok: true, data: { id: 'ot_1', token: 'ot_live_new' } }, 201) + ); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toEqual({ token: 'ot_live_new', id: 'ot_1' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://cast.agentrelay.com/v1/observer-tokens'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe('Bearer rk_live_admin'); + const body = JSON.parse(init.body); + expect(body.name).toMatch(/^observer-dashboard-/); + expect(body.scopes).toContain('stream:read'); + expect(body.scopes).toContain('dms:read'); + expect(body.filters).toEqual({ include_dms: true }); + expect(typeof body.expires_at).toBe('string'); + }); + + it('mints a distinct token name on each call', async () => { + const fetchMock = mockFetch( + jsonResponse({ ok: true, data: { id: 'ot_1', token: 'ot_live_a' } }, 201), + jsonResponse({ ok: true, data: { id: 'ot_2', token: 'ot_live_b' } }, 201) + ); + + await mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin'); + await mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin'); + + const nameA = JSON.parse(fetchMock.mock.calls[0][1].body).name; + const nameB = JSON.parse(fetchMock.mock.calls[1][1].body).name; + expect(nameA).not.toBe(nameB); + }); + + it('returns null when creation fails', async () => { + mockFetch(jsonResponse({ ok: false }, 401)); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBeNull(); + }); + + it('fails soft to null when the network request rejects', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch(new Error('dns failed')); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBeNull(); + }); + + it('rejects a non-observer-token in the response', async () => { + mockFetch( + jsonResponse({ ok: true, data: { id: 'ot_1', token: 'rk_live_leaked' } }, 201) + ); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBeNull(); + }); +}); + +describe('revokeObserverStreamToken', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('sends DELETE for the token id with the admin key', async () => { + const fetchMock = mockFetch(new Response(null, { status: 204 })); + + await revokeObserverStreamToken( + 'https://cast.agentrelay.com', + 'rk_live_admin', + 'ot_1' + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://cast.agentrelay.com/v1/observer-tokens/ot_1', + expect.objectContaining({ + method: 'DELETE', + headers: { Authorization: 'Bearer rk_live_admin' }, + }) + ); + }); + + it('swallows network errors so logout can still clear cookies', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch(new Error('unreachable')); + + await expect( + revokeObserverStreamToken( + 'https://cast.agentrelay.com', + 'rk_live_admin', + 'ot_1' + ) + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/observer-dashboard/src/lib/observer-token.ts b/packages/observer-dashboard/src/lib/observer-token.ts new file mode 100644 index 00000000..ddec19c5 --- /dev/null +++ b/packages/observer-dashboard/src/lib/observer-token.ts @@ -0,0 +1,148 @@ +/** + * Observer-token minting for workspace-key logins. + * + * The engine rejects the root workspace key (`rk_live_`) on the realtime + * endpoint (`GET /v1/ws`) — only a scoped observer token (`ot_live_`) with + * `stream:read` may open the workspace stream. So when an operator logs into + * the dashboard with a workspace admin key, we mint a read-only observer token + * on their behalf and hand *that* to the stream, so the socket never carries a + * workspace admin key. + * + * Each login mints a fresh, uniquely-named token (revoked on logout, expiring + * after 30 days). This deliberately avoids reusing a single fixed-name token: + * a shared token would have to be rotated to hand out usable material (token + * secrets are never retrievable after creation), which invalidates other live + * dashboard sessions, and a revoked fixed name would permanently block + * re-minting under the engine's unique (workspace_id, name) constraint. + */ + +/** Name prefix for tokens this dashboard mints; each mint appends a unique id. */ +const DASHBOARD_OBSERVER_TOKEN_PREFIX = 'observer-dashboard'; + +/** + * Token lifetime, matched to the dashboard auth cookie (30 days) so the minted + * token never expires out from under an otherwise-valid session. + */ +const DASHBOARD_OBSERVER_TOKEN_TTL_MS = 60 * 60 * 24 * 30 * 1000; + +/** + * Full read scope set, mirroring the engine's `OBSERVER_SCOPES` + * (`packages/engine/src/engine/observerToken.ts`). The dashboard is a firehose + * observer, so it needs every read scope plus `stream:read` for the stream to + * deliver all event types the operator could already see with the workspace + * key. Kept as a local constant to avoid coupling this edge route to the + * `@relaycast/types` build. + */ +const DASHBOARD_OBSERVER_SCOPES = [ + 'stream:read', + 'messages:read', + 'threads:read', + 'dms:read', + 'channels:read', + 'search:read', + 'agents:read', + 'nodes:read', + 'deliveries:read', + 'activity:read', + 'files:read', + 'reactions:read', +] as const; + +export interface MintedObserverToken { + /** Raw `ot_live_` token material for the workspace stream. */ + token: string; + /** Observer-token id (`ot_...`), used to revoke the token on logout. */ + id: string; +} + +/** + * Mint a fresh, uniquely-named scoped observer token using a workspace admin + * key, for the workspace stream. + * + * Returns `{ token, id }`, or `null` if minting failed (network error or a + * non-2xx response). Callers should let REST-only login proceed on `null` + * rather than block the operator — and must NOT fall back to the workspace + * admin key for the stream credential. + */ +export async function mintObserverStreamToken( + baseUrl: string, + adminKey: string +): Promise { + // Fail soft: any network rejection (DNS, timeout, unreachable engine) returns + // null so the caller can fall back to a REST-only login instead of surfacing + // a hard 500. + try { + const payload = { + name: `${DASHBOARD_OBSERVER_TOKEN_PREFIX}-${crypto.randomUUID()}`, + description: 'Auto-minted for the Relaycast observer dashboard live stream', + scopes: DASHBOARD_OBSERVER_SCOPES, + // The operator holds the workspace key, so DM visibility is not an + // escalation — keep the dashboard's existing firehose view intact. + filters: { include_dms: true }, + expires_at: new Date( + Date.now() + DASHBOARD_OBSERVER_TOKEN_TTL_MS + ).toISOString(), + }; + + const res = await fetch(new URL('/v1/observer-tokens', baseUrl).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${adminKey}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + body: JSON.stringify(payload), + }); + if (!res.ok) return null; + + const body = await res.json(); + const token = body?.data?.token; + const id = body?.data?.id; + if ( + typeof token === 'string' && + token.startsWith('ot_live_') && + typeof id === 'string' && + id.length > 0 + ) { + return { token, id }; + } + return null; + } catch (error) { + console.error( + '[mintObserverStreamToken] Failed to mint observer token:', + error + ); + return null; + } +} + +/** + * Best-effort revocation of a minted observer token on logout, so a leaked ws + * cookie value stops working immediately instead of lingering until expiry. + * Requires the workspace admin key (only it may revoke observer tokens). Any + * failure is swallowed — logout must clear cookies regardless. + */ +export async function revokeObserverStreamToken( + baseUrl: string, + adminKey: string, + tokenId: string +): Promise { + try { + await fetch( + new URL( + `/v1/observer-tokens/${encodeURIComponent(tokenId)}`, + baseUrl + ).toString(), + { + method: 'DELETE', + headers: { Authorization: `Bearer ${adminKey}` }, + cache: 'no-store', + } + ); + } catch (error) { + console.error( + '[revokeObserverStreamToken] Failed to revoke observer token:', + error + ); + } +}