-
Notifications
You must be signed in to change notification settings - Fork 0
fix(observer-dashboard): mint scoped observer token for the live stream #240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1ea5845
2ef3686
61e4956
d0f98e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Switching a browser from one workspace-key login to another can leave the prior workspace's stream token active until its 30-day expiry. This revocation uses the new workspace's Prompt for AI agents |
||
| } | ||
| // Remember the minted token id so logout can revoke it on the engine. | ||
| if (wsTokenId) { | ||
| cookieStore.set(WS_TOKEN_ID_COOKIE_NAME, wsTokenId, cookieOptions); | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| } 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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Logout does not revoke the minted observer token on the engine The logout route ( Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| cookieStore.delete(WS_TOKEN_ID_COOKIE_NAME); | ||
| cookieStore.delete(ENGINE_COOKIE_NAME); | ||
| return NextResponse.json({ success: true }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response | Error>) { | ||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Network errors during stream-token minting crash the entire login instead of falling back gracefully
The token-minting helper can throw on network errors (
fetchatobserver-token.ts:127,:146, or:157), and the login route calls it without its own try/catch (route.ts:71), so the exception propagates to the outer handler and returns a 500 — blocking the operator's login entirely instead of falling back to a REST-only session.Impact: A transient network hiccup between the dashboard edge and the engine prevents the operator from logging in at all, even though the credential was already validated.
Mechanism: mintObserverStreamToken promises null-on-failure but can throw
The function's JSDoc (
packages/observer-dashboard/src/lib/observer-token.ts:113-114) says "Returns null if minting failed; callers should let REST-only login proceed." The login route's comment (packages/observer-dashboard/src/app/api/auth/login/route.ts:64-68) relies on this: "On mint failure, fall back to the login credential so REST-only login still succeeds."However,
mintObserverStreamTokenhas no top-level try/catch. Any of its threefetchcalls (lines 127, 146, 157) can throw aTypeErroron DNS failure, connection refused, or other network-level errors. When that happens, the exception bubbles up through the login handler's outer catch (route.ts:112-117), which returns{ success: false, error: 'Login failed' }with status 500 — even though the API key was already successfully validated against the engine.The fix is either to wrap the
mintObserverStreamTokencall in the login route with its own try/catch that falls back towsToken = apiKey, or to add a top-level try/catch insidemintObserverStreamTokenitself that returnsnullon any unexpected error.Was this helpful? React with 👍 or 👎 to provide feedback.