diff --git a/cloud-sync.js b/cloud-sync.js index 4cca476e..bae8527a 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -1,7 +1,8 @@ // Security envelope for the existing cloud client. The established planner // implementation remains in cloud-sync-core.js; this module preserves its -// exports while preventing broad session JWTs from becoming navigated document -// URLs during the staged attachment-view migration in #413. +// exports while preventing broad session JWTs from becoming browser URL +// credentials during the staged attachment-view and realtime migrations in #413. +import { installStreamGrantEventSource } from './stream-access-grant.js'; export * from './cloud-sync-core.js'; const ATTACHMENT_VIEW_PATH = /^\/api\/projects\/([1-9][0-9]*)\/attachments\/([1-9][0-9]*)\/view$/; @@ -171,4 +172,10 @@ export function installAttachmentViewGrantWindowOpen(windowLike, { return true; } -if (typeof window !== 'undefined') installAttachmentViewGrantWindowOpen(window); +if (typeof window !== 'undefined') { + // Install before app boot calls cloud-sync-core's subscribe(). The realtime + // compatibility bridge consumes the legacy token-bearing constructor string + // locally and never sends that broad credential over HTTP. + installStreamGrantEventSource(window); + installAttachmentViewGrantWindowOpen(window); +} diff --git a/docs/doctoring/stream-access-grant-runtime.md b/docs/doctoring/stream-access-grant-runtime.md new file mode 100644 index 00000000..3c16348c --- /dev/null +++ b/docs/doctoring/stream-access-grant-runtime.md @@ -0,0 +1,68 @@ +# Realtime stream access-grant runtime + +Status: **active stacked PR only — not protected-`develop` shipped truth**. This document describes the bounded implementation in PR #513, stacked on PR #512 for issue #413. The parent base observed immediately before this document was `fix/attachment-view-grant-runtime-413@d2edcb55bda0ce980f6d1f3d76e034d5ca1ca307`. Reconcile these statements against the live protected base before integration. + +## Customer decision and next action + +Customers should continue using ScopeWeave realtime collaboration through the normal web client; they should not copy, bookmark, log, or construct stream credential URLs. Operators evaluating this slice should verify that browser traffic performs an authenticated grant exchange followed by an EventSource request containing only a short-lived opaque `grant`, and that reconnect obtains a different grant. If any broad session JWT appears in an EventSource request URL, access log, browser history entry, or referrer capture, treat that as a security regression and block release. + +## Threat and causal defect + +The protected application core historically compensates for native `EventSource` lacking arbitrary request headers by constructing `/api/projects/:id/stream?token=`. A bearer credential in a URI can be copied into access logs, browser or intermediary telemetry, screenshots, incident evidence, and other URL-handling systems. RFC 6750 explicitly treats URI-query bearer transport as a method that should not be used unless the alternatives are impossible and calls out security deficiencies; RFC 9700 is the current OAuth 2.0 security Best Current Practice and strengthens the expectation that access tokens are not passed in URI query parameters. + +The problem is the credential transport, not realtime collaboration itself. A safe fix therefore must preserve the connected preamble, project update/comment/restore fan-out, direct Authorization-header access for capable API clients, tenant isolation, revocation behavior, observability, and reconnect semantics while eliminating broad browser URL credentials. + +## Bounded decision + +PR #513 introduces a 60-second, one-time, project-bound `stream` access grant using the grant domain and SQLite persistence supplied by parent PR #512. The browser uses the existing broad session credential only in an `Authorization: Bearer` header on `POST /api/projects/:id/access-grants` with the exact body `{ "purpose": "stream" }`. The response contains an opaque 43-character base64url grant in a same-origin `/api/projects/:id/stream?grant=...` URL. + +The public security gateway, `server/app.mjs`, owns the externally reachable stream route in this staged architecture. It rejects the historical `token` query parameter, mixed credentials, duplicate/extra query parameters, malformed grants, wrong-project grants, expired grants, replayed grants, and subjects whose live membership no longer authorizes the project. Direct Authorization-header stream requests remain supported and validate durable session token-version state. + +A successful one-time redemption opens an SSE response with `Cache-Control: private, no-store`, `Referrer-Policy: no-referrer`, and `X-Content-Type-Options: nosniff`. Native EventSource automatic reconnect is deliberately suppressed because it would replay an already-consumed grant. The client closes the failed native source, waits for the bounded retry delay, exchanges for a fresh grant, and only then opens a replacement EventSource. + +## Staged gateway boundary + +`server/app_core.mjs` remains historical core code during this stack and still contains the predecessor query-token stream implementation. That route is **not** the public production boundary when `server/server.mjs` boots the exported application from `server/app.mjs`; the gateway intercepts the exact public stream route before delegating all other requests to the core. + +To avoid inventing a synthetic internal credential or bypassing core authorization, the gateway delegates normal project writes to the core and relays successful project-update, revision-restore, and comment response facts into its own secure SSE controller map. The relay uses only the already-authorized successful response and the bound project path. It does not mint authority, impersonate a user, or call a privileged internal mutation endpoint. Metrics are reconciled at the gateway so `sseActive` reflects the externally reachable secure streams rather than the now-shielded predecessor stream registry. + +This boundary is intentionally transitional. A later bounded cleanup may move the shared realtime event bus below both core and gateway, but that refactor is not required to remove broad browser URL credentials safely and would materially increase this security slice's blast radius. + +## TDD and exact evidence chronology + +- `65504f82edf5f30b4f8fbd1215c50e14073eb013`: regression-first client/API contract registered before production implementation. The exact-head Server Tests failed because `stream-access-grant.js` did not yet exist, proving the client regression discriminated the absent behavior. +- `f34756b0b64c87587825e738ba923bacba8c1104`: strengthened the API regression to require a real successful project write to fan out its exact resulting optimistic-concurrency version through the secured SSE channel. +- `f711a21285c58c16d8a56a68b25ae7a25ee7af75`: production implementation added the exchange client, one-time reconnect controller, EventSource compatibility bridge, gateway stream redemption, secure event relay, static module serving, and gateway metrics reconciliation. Unit tests on this exact head passed, including the new stream client test. API execution then stopped on the predecessor smoke assertion that still expected a valid session JWT in the query string to return HTTP 200. +- `e0b123453a84cc0bd5ff6e418a6810b4277a656f`: corrected that stale smoke contract without weakening the route: legacy query-token transport must return HTTP 401, capable clients retain direct Authorization-header SSE, and unauthenticated SSE remains HTTP 401. + +Only terminal exact-current-head evidence is release evidence. Queued, pending, cancelled, skipped-required, stale, predecessor-head, synthetic-only, model-only, or status-only results are non-passing. + +## Acceptance contract + +The slice is acceptable only when all of the following remain true on the unchanged integration candidate: + +1. Browser code never sends the broad session secret in the EventSource URL; the broad secret appears only in the authenticated grant-exchange header. +2. The grant response URL is exact-origin, exact-project, fragment-free, credential-free, has one `grant` key, and contains a 43-character base64url secret. +3. A wrong-project redemption fails without consuming a grant that is otherwise still valid for its bound project. +4. A successful redemption is single-use and cannot be replayed. +5. Session revocation or membership loss prevents mint/redeem according to the access-grant domain's live membership-version contract. +6. Native reconnect never reuses a consumed grant; every retry performs a fresh authenticated exchange. +7. A normal authorized project update while connected produces an SSE `{ "type": "update", "version": }` event without exposing actor credentials. +8. Existing direct Authorization-header API streaming continues to work and an absent credential fails closed. +9. Repository-native unit/API/browser tests, dependency review, vulnerability scanning, deterministic security gates, owned coverage, and applicable protected-base rules all pass on the same unchanged head before integration. + +## Rollback + +Rollback means reverting the PR #513 semantic slice and restoring the prior protected behavior only as an emergency compatibility action; it must **not** be represented as a security-safe steady state because the predecessor browser flow places a broad bearer credential in the URL. If rollback is required operationally, disable the affected browser realtime feature or place it behind a controlled compatibility boundary while a corrected grant path is restored. Never weaken the deterministic route checks or reintroduce query-token acceptance merely to make a legacy test green. + +## Standards and primary sources + +The implementation decision is grounded in final RFC Editor publications. The RFC Editor also exposes an `RFC 10017` browser-applications publication-transition page dated July 2026 while its queue surface still describes the document as RFC-to-be/final-review; because those authoritative surfaces are not yet internally consistent, this slice does **not** depend on RFC 10017 being a final published BCP. It is supplementary evidence only. RFC 6750 and RFC 9700 are the normative final references used here. + +### APA 7 references + +Jones, M., & Hardt, D. (2012). *The OAuth 2.0 Authorization Framework: Bearer Token Usage* (RFC 6750). Internet Engineering Task Force. https://doi.org/10.17487/RFC6750 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best Current Practice for OAuth 2.0 Security* (BCP 240; RFC 9700). Internet Engineering Task Force. https://doi.org/10.17487/RFC9700 + +Parecki, A., De Ryck, P., & Waite, D. (2026). *OAuth 2.0 for Browser-Based Applications* (RFC-to-be 10017, publication-transition material). Internet Engineering Task Force / RFC Editor. Research-only supplementary reference pending consistent final-publication status across RFC Editor surfaces. diff --git a/package.json b/package.json index 9766382a..bc9a22c7 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke-orchestrator-provider.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/attachment-view-access-grant.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/access-grant-sqlite.test.mjs && node tests/unit/access-grant-sqlite-edge.test.mjs && node tests/unit/access-grant-audit-outbox.test.mjs && node tests/unit/attachment-view-client.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=cloud-sync-core.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/access_grant_sqlite.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/access-grant-sqlite.test.mjs && node tests/unit/access-grant-sqlite-edge.test.mjs && node tests/unit/access-grant-audit-outbox.test.mjs && node tests/unit/attachment-view-client.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke-orchestrator-provider.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/attachment-view-access-grant.test.mjs && node tests/api/stream-access-grant.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/access-grant-sqlite.test.mjs && node tests/unit/access-grant-sqlite-edge.test.mjs && node tests/unit/access-grant-audit-outbox.test.mjs && node tests/unit/attachment-view-client.test.mjs && node tests/unit/stream-access-grant-client.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=cloud-sync-core.js --include=stream-access-grant.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/access_grant_sqlite.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/access-grant-sqlite.test.mjs && node tests/unit/access-grant-sqlite-edge.test.mjs && node tests/unit/access-grant-audit-outbox.test.mjs && node tests/unit/attachment-view-client.test.mjs && node tests/unit/stream-access-grant-client.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/server/app.mjs b/server/app.mjs index fab5e4e9..2f7e33a7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,7 +1,7 @@ // ScopeWeave security gateway. The historical application remains in -// app_core.mjs while security-sensitive attachment viewing is migrated to +// app_core.mjs while security-sensitive browser transports are migrated to // short-lived, one-time access grants without exposing broad session JWTs in -// browser URLs. All other requests delegate to the unchanged core application. +// URLs. All other requests delegate to the unchanged core application. import { Hono } from 'hono'; import { readFile } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; @@ -22,17 +22,25 @@ import { } from './access_grant_sqlite.mjs'; const ATTACHMENT_VIEW_TTL_SECONDS = 60; +const STREAM_TTL_SECONDS = 60; const PRIVATE_VIEW_HEADERS = Object.freeze({ 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff', }); +const PRIVATE_STREAM_HEADERS = Object.freeze({ + 'cache-control': 'private, no-store', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', +}); const GRANT_RESPONSE_HEADERS = Object.freeze({ 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff', }); const ROW_ID_PATTERN = /^[1-9][0-9]*$/; +const GRANT_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const secureStreams = new Map(); function rowId(value) { const normalized = String(value ?? ''); @@ -53,6 +61,10 @@ function unauthorizedView() { return secureJson({ error: 'unauthorized' }, 401, PRIVATE_VIEW_HEADERS); } +function unauthorizedStream() { + return secureJson({ error: 'unauthorized' }, 401, PRIVATE_STREAM_HEADERS); +} + function lookupHeaderSubject(c) { const header = c.req.header('authorization') || ''; if (!header.startsWith('Bearer ')) return null; @@ -94,13 +106,40 @@ function mapMintFailure(error) { return secureJson({ error: 'access grant service unavailable' }, 503); } -async function mintAttachmentViewGrant(c) { +function exactBodyKeys(body, allowedKeys) { + return body && typeof body === 'object' && !Array.isArray(body) + && Object.keys(body).every((key) => allowedKeys.includes(key)); +} + +async function mintAccessGrant(c) { const subjectId = lookupHeaderSubject(c); if (!subjectId) return secureJson({ error: 'unauthorized' }, 401); const projectId = rowId(c.req.param('id')); const body = await c.req.json().catch(() => null); - const attachmentId = rowId(body?.attachmentId); - if (!projectId || body?.purpose !== ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW || !attachmentId) { + if (!projectId || !body) return secureJson({ error: 'invalid access grant request' }, 400); + + let purpose; + let audience; + let attachmentId = null; + let ttlSeconds; + let urlForGrant; + + if (body.purpose === ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW) { + attachmentId = rowId(body.attachmentId); + if (!attachmentId || !exactBodyKeys(body, ['purpose', 'attachmentId'])) { + return secureJson({ error: 'invalid access grant request' }, 400); + } + purpose = ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW; + audience = ACCESS_GRANT_AUDIENCES.ATTACHMENT_VIEW; + ttlSeconds = ATTACHMENT_VIEW_TTL_SECONDS; + urlForGrant = (secret) => `/api/projects/${projectId}/attachments/${attachmentId}/view?grant=${encodeURIComponent(secret)}`; + } else if (body.purpose === ACCESS_GRANT_PURPOSES.STREAM) { + if (!exactBodyKeys(body, ['purpose'])) return secureJson({ error: 'invalid access grant request' }, 400); + purpose = ACCESS_GRANT_PURPOSES.STREAM; + audience = ACCESS_GRANT_AUDIENCES.STREAM; + ttlSeconds = STREAM_TTL_SECONDS; + urlForGrant = (secret) => `/api/projects/${projectId}/stream?grant=${encodeURIComponent(secret)}`; + } else { return secureJson({ error: 'invalid access grant request' }, 400); } @@ -108,16 +147,16 @@ async function mintAttachmentViewGrant(c) { const grant = await grantService.mint({ subjectId, projectId, - purpose: ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW, - audience: ACCESS_GRANT_AUDIENCES.ATTACHMENT_VIEW, + purpose, + audience, attachmentId, - ttlSeconds: ATTACHMENT_VIEW_TTL_SECONDS, + ttlSeconds, }); return secureJson({ grantId: grant.grantId, purpose: grant.purpose, expiresAtMs: grant.expiresAtMs, - url: `/api/projects/${projectId}/attachments/${attachmentId}/view?grant=${encodeURIComponent(grant.secret)}`, + url: urlForGrant(grant.secret), }, 201); } catch (error) { return mapMintFailure(error); @@ -134,6 +173,15 @@ function attachmentForSubject(subjectId, projectId, attachmentId) { `).get(projectId, subjectId, attachmentId); } +function projectForSubject(subjectId, projectId) { + return db.prepare(` + SELECT p.id AS project_id + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ? + `).get(projectId, subjectId); +} + async function viewAttachment(c) { const projectId = rowId(c.req.param('id')); const attachmentId = rowId(c.req.param('aid')); @@ -183,9 +231,112 @@ async function viewAttachment(c) { } } -async function serveCloudSyncCore() { +function secureStreamCount() { + let count = 0; + for (const controllers of secureStreams.values()) count += controllers.size; + return count; +} + +function broadcastSecureProjectEvent(projectId, payload) { + const controllers = secureStreams.get(String(projectId)); + if (!controllers?.size) return; + const chunk = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`); + for (const controller of [...controllers]) { + try { + controller.enqueue(chunk); + } catch { + controllers.delete(controller); + } + } + if (!controllers.size) secureStreams.delete(String(projectId)); +} + +function openSecureProjectStream(projectId, requestSignal) { + const key = String(projectId); + let currentController = null; + let aborted = false; + const cleanup = (closeController = false) => { + if (!currentController) return; + secureStreams.get(key)?.delete(currentController); + if (!secureStreams.get(key)?.size) secureStreams.delete(key); + requestSignal?.removeEventListener?.('abort', onAbort); + if (closeController) { + try { currentController.close(); } catch { /* already closed */ } + } + currentController = null; + }; + const onAbort = () => { + aborted = true; + cleanup(true); + }; + + const stream = new ReadableStream({ + start(controller) { + currentController = controller; + if (!secureStreams.has(key)) secureStreams.set(key, new Set()); + secureStreams.get(key).add(controller); + controller.enqueue(new TextEncoder().encode(': connected\n\n')); + if (requestSignal?.aborted) onAbort(); + else requestSignal?.addEventListener?.('abort', onAbort, { once: true }); + }, + cancel() { + if (!aborted) cleanup(false); + }, + }); + return new Response(stream, { + headers: { + ...PRIVATE_STREAM_HEADERS, + 'content-type': 'text/event-stream; charset=utf-8', + connection: 'keep-alive', + }, + }); +} + +async function openProjectStream(c) { + const projectId = rowId(c.req.param('id')); + if (!projectId) return unauthorizedStream(); + + const url = new URL(c.req.url); + const keys = [...url.searchParams.keys()]; + const grants = url.searchParams.getAll('grant'); + const hasAuthorization = Boolean(c.req.header('authorization')); + let subjectId; + + if (grants.length > 0) { + if ( + hasAuthorization + || grants.length !== 1 + || keys.length !== 1 + || keys[0] !== 'grant' + || !GRANT_SECRET_PATTERN.test(grants[0]) + ) return unauthorizedStream(); + try { + const redeemed = await grantService.redeem({ + secret: grants[0], + purpose: ACCESS_GRANT_PURPOSES.STREAM, + audience: ACCESS_GRANT_AUDIENCES.STREAM, + projectId, + attachmentId: null, + }); + subjectId = redeemed.subjectId; + } catch { + return unauthorizedStream(); + } + } else { + if (keys.length !== 0) return unauthorizedStream(); + subjectId = lookupHeaderSubject(c); + if (!subjectId) return unauthorizedStream(); + } + + // Redemption checks live membership atomically, then this final read closes + // the small post-consume race before opening a long-lived browser channel. + if (!projectForSubject(subjectId, projectId)) return unauthorizedStream(); + return openSecureProjectStream(projectId, c.req.raw.signal); +} + +async function serveModule(relativePath) { try { - const source = await readFile(new URL('../cloud-sync-core.js', import.meta.url)); + const source = await readFile(new URL(relativePath, import.meta.url)); return new Response(source, { status: 200, headers: { @@ -199,10 +350,62 @@ async function serveCloudSyncCore() { } } -/** Public ScopeWeave HTTP application with attachment-view grant enforcement. */ +async function serveMetrics(c) { + const response = await coreApp.fetch(c.req.raw); + if (!response.ok) return response; + const active = secureStreamCount(); + if (c.req.query('format') === 'prometheus') { + const text = await response.text(); + const patched = text.replace(/^scopeweave_sse_active\s+[-+0-9.eE]+$/m, `scopeweave_sse_active ${active}`); + return new Response(patched, { status: response.status, headers: response.headers }); + } + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== 'object') return response; + return new Response(JSON.stringify({ ...payload, sseActive: active }), { + status: response.status, + headers: response.headers, + }); +} + +async function relayCoreRealtime(c, response) { + if (!response.ok) return; + const pathname = new URL(c.req.url).pathname; + const method = c.req.method.toUpperCase(); + const updateMatch = /^\/api\/projects\/([1-9][0-9]*)$/.exec(pathname); + const restoreMatch = /^\/api\/projects\/([1-9][0-9]*)\/revisions\/[1-9][0-9]*\/restore$/.exec(pathname); + const commentMatch = /^\/api\/projects\/([1-9][0-9]*)\/comments$/.exec(pathname); + + if (method === 'PUT' && updateMatch) { + const payload = await response.clone().json().catch(() => null); + if (Number.isSafeInteger(payload?.version)) { + broadcastSecureProjectEvent(updateMatch[1], { type: 'update', version: payload.version }); + } + } else if (method === 'POST' && restoreMatch) { + const payload = await response.clone().json().catch(() => null); + if (Number.isSafeInteger(payload?.version)) { + broadcastSecureProjectEvent(restoreMatch[1], { type: 'update', version: payload.version }); + } + } else if (method === 'POST' && commentMatch) { + const payload = await response.clone().json().catch(() => null); + if (Number.isSafeInteger(payload?.id)) { + broadcastSecureProjectEvent(commentMatch[1], { type: 'comment', commentId: payload.id }); + } + } +} + +async function delegateToCore(c) { + const response = await coreApp.fetch(c.req.raw); + await relayCoreRealtime(c, response); + return response; +} + +/** Public ScopeWeave HTTP application with browser access-grant enforcement. */ export const app = new Hono(); -app.post('/api/projects/:id/access-grants', mintAttachmentViewGrant); +app.post('/api/projects/:id/access-grants', mintAccessGrant); app.get('/api/projects/:id/attachments/:aid/view', viewAttachment); -app.get('/cloud-sync-core.js', serveCloudSyncCore); -app.all('*', (c) => coreApp.fetch(c.req.raw)); +app.get('/api/projects/:id/stream', openProjectStream); +app.get('/api/metrics', serveMetrics); +app.get('/cloud-sync-core.js', () => serveModule('../cloud-sync-core.js')); +app.get('/stream-access-grant.js', () => serveModule('../stream-access-grant.js')); +app.all('*', delegateToCore); diff --git a/stream-access-grant.js b/stream-access-grant.js new file mode 100644 index 00000000..2c057066 --- /dev/null +++ b/stream-access-grant.js @@ -0,0 +1,335 @@ +// Short-lived, one-time stream access-grant client for ScopeWeave realtime SSE. +// Broad session credentials are used only in Authorization headers during the +// exchange and are never sent in EventSource URLs. +const GRANT_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const PROJECT_ID_PATTERN = /^[1-9][0-9]*$/; +const installedWindows = new WeakSet(); + +function normalizedOrigin(origin) { + try { + const url = new URL(origin); + return (url.protocol === 'https:' || url.protocol === 'http:') ? url.origin : null; + } catch { + return null; + } +} + +function normalizedProjectId(value) { + const projectId = String(value ?? ''); + return PROJECT_ID_PATTERN.test(projectId) ? projectId : null; +} + +/** + * Validate the exact same-origin one-time stream grant URL issued by ScopeWeave. + * + * The returned value is a relative URL so callers cannot accidentally retain a + * caller-controlled origin. Only one `grant` parameter is accepted; fragments, + * credentials, legacy `token` parameters, and extra query keys fail closed. + * + * @param {unknown} value Candidate URL returned by the grant exchange. + * @param {object} input Validation context. + * @param {string} input.origin Trusted current-page origin. + * @param {string|number} input.projectId Exact project row identifier. + * @returns {string} Canonical same-origin stream URL. + */ +export function validateStreamGrantUrl(value, { origin, projectId } = {}) { + if (typeof value !== 'string') throw new Error('stream grant url invalid'); + const trustedOrigin = normalizedOrigin(origin); + const normalizedId = normalizedProjectId(projectId); + if (!trustedOrigin || !normalizedId) throw new Error('stream grant url invalid'); + + let url; + try { + url = new URL(value, trustedOrigin); + } catch { + throw new Error('stream grant url invalid'); + } + const grants = url.searchParams.getAll('grant'); + const expectedPath = `/api/projects/${normalizedId}/stream`; + if ( + url.origin !== trustedOrigin + || url.username + || url.password + || url.pathname !== expectedPath + || url.hash + || grants.length !== 1 + || !GRANT_PATTERN.test(grants[0]) + || [...url.searchParams.keys()].some((key) => key !== 'grant') + ) { + throw new Error('stream grant url invalid'); + } + return `${url.pathname}${url.search}`; +} + +/** + * Create a resilient SSE connection that exchanges a broad session credential + * for a fresh one-time project-bound grant before every connection attempt. + * + * Native EventSource reconnect cannot be used with one-time grants because it + * would replay an already-consumed URL. On transport failure this controller + * closes the native source, waits for the bounded retry delay, exchanges for a + * new grant, and only then opens the replacement EventSource. + * + * @param {object} input Connection dependencies and callbacks. + * @param {string|number} input.projectId Exact project row identifier. + * @param {Function} input.getSessionToken Returns the current session/PAT secret. + * @param {Function} [input.fetchImpl] Fetch-compatible grant exchange transport. + * @param {Function} [input.EventSourceImpl] Native EventSource constructor. + * @param {string} input.origin Trusted current-page origin. + * @param {Function} [input.onMessage] Receives native SSE message events. + * @param {Function} [input.onStatus] Receives `connecting`, `connected`, `retrying`, or `closed`. + * @param {Function} [input.schedule] Timeout-compatible scheduler. + * @param {Function} [input.cancelSchedule] Timeout-compatible cancellation function. + * @param {number} [input.retryDelayMs] Reconnect delay in milliseconds. + * @returns {{ready:Promise,close:Function}} Connection lifecycle handle. + */ +export function createStreamGrantConnection({ + projectId, + getSessionToken, + fetchImpl = globalThis.fetch, + EventSourceImpl = globalThis.EventSource, + origin, + onMessage = () => {}, + onStatus = () => {}, + schedule = globalThis.setTimeout, + cancelSchedule = globalThis.clearTimeout, + retryDelayMs = 1000, +} = {}) { + const normalizedId = normalizedProjectId(projectId); + if ( + !normalizedId + || typeof getSessionToken !== 'function' + || typeof fetchImpl !== 'function' + || typeof EventSourceImpl !== 'function' + || !normalizedOrigin(origin) + || typeof schedule !== 'function' + || typeof cancelSchedule !== 'function' + || !Number.isFinite(retryDelayMs) + || retryDelayMs < 0 + ) { + throw new Error('stream grant connection invalid'); + } + + let stopped = false; + let source = null; + let retryHandle = null; + let exchangeController = null; + let generation = 0; + let lastStatus = null; + + const emitStatus = (status) => { + if (lastStatus === status) return; + lastStatus = status; + onStatus(status); + }; + + const scheduleRetry = () => { + if (stopped || retryHandle !== null) return; + if (source) { + try { source.close(); } catch { /* already closed */ } + source = null; + } + emitStatus('retrying'); + retryHandle = schedule(async () => { + retryHandle = null; + await connect(); + }, retryDelayMs); + }; + + const connect = async () => { + if (stopped) return; + const sessionToken = String(getSessionToken() || ''); + if (!sessionToken) { + emitStatus('closed'); + return; + } + + const attempt = ++generation; + emitStatus('connecting'); + exchangeController?.abort(); + exchangeController = new AbortController(); + try { + const response = await fetchImpl(`/api/projects/${normalizedId}/access-grants`, { + method: 'POST', + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + signal: exchangeController.signal, + headers: { + authorization: `Bearer ${sessionToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ purpose: 'stream' }), + }); + if (stopped || attempt !== generation) return; + if (!response || response.status !== 201) throw new Error('stream grant exchange failed'); + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== 'object' || payload.purpose !== 'stream') { + throw new Error('stream grant response invalid'); + } + const url = validateStreamGrantUrl(payload.url, { origin, projectId: normalizedId }); + if (stopped || attempt !== generation) return; + + const current = new EventSourceImpl(url); + source = current; + current.onopen = () => { + if (!stopped && source === current) emitStatus('connected'); + }; + current.onmessage = (event) => { + if (!stopped && source === current) onMessage(event); + }; + current.onerror = () => { + if (stopped || source !== current) return; + try { current.close(); } catch { /* already closed */ } + source = null; + scheduleRetry(); + }; + } catch { + if (!stopped && attempt === generation) scheduleRetry(); + } finally { + if (attempt === generation) exchangeController = null; + } + }; + + const ready = connect(); + return Object.freeze({ + ready, + close() { + if (stopped) return; + stopped = true; + generation += 1; + exchangeController?.abort(); + exchangeController = null; + if (retryHandle !== null) { + cancelSchedule(retryHandle); + retryHandle = null; + } + if (source) { + try { source.close(); } catch { /* already closed */ } + source = null; + } + emitStatus('closed'); + }, + }); +} + +function parseLegacyStreamUrl(value, origin) { + if (typeof value !== 'string') return null; + const trustedOrigin = normalizedOrigin(origin); + if (!trustedOrigin) return null; + let url; + try { + url = new URL(value, trustedOrigin); + } catch { + return null; + } + const match = /^\/api\/projects\/([1-9][0-9]*)\/stream$/.exec(url.pathname); + const tokens = url.searchParams.getAll('token'); + if ( + url.origin !== trustedOrigin + || url.username + || url.password + || url.hash + || !match + || tokens.length !== 1 + || !tokens[0] + || [...url.searchParams.keys()].some((key) => key !== 'token') + ) return null; + return Object.freeze({ projectId: match[1], sessionToken: tokens[0] }); +} + +/** + * Install an EventSource compatibility bridge for ScopeWeave's legacy caller. + * + * `cloud-sync-core.js` still constructs `/stream?token=` while this + * staged migration remains stacked. The bridge consumes that string locally, + * never sends it over the network, and exposes an EventSource-shaped facade + * backed by `createStreamGrantConnection`. Non-ScopeWeave EventSource URLs are + * passed through unchanged to the native constructor. + * + * @param {Window|object} windowLike Browser window or deterministic test seam. + * @param {object} [options] Optional transport/timer seams. + * @returns {boolean} True when newly installed; false when unsupported/already installed. + */ +export function installStreamGrantEventSource(windowLike, { + fetchImpl = typeof windowLike?.fetch === 'function' ? windowLike.fetch.bind(windowLike) : globalThis.fetch, + schedule = globalThis.setTimeout, + cancelSchedule = globalThis.clearTimeout, + retryDelayMs = 1000, +} = {}) { + if (!windowLike || typeof windowLike.EventSource !== 'function' || installedWindows.has(windowLike)) return false; + const NativeEventSource = windowLike.EventSource; + const origin = windowLike.location?.origin; + if (!normalizedOrigin(origin)) return false; + + class ScopeWeaveGrantEventSource { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + + constructor(value, init) { + const parsed = parseLegacyStreamUrl(value, origin); + if (!parsed) return new NativeEventSource(value, init); + + this.CONNECTING = 0; + this.OPEN = 1; + this.CLOSED = 2; + this.readyState = 0; + this.url = `/api/projects/${parsed.projectId}/stream`; + this.withCredentials = false; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + this._listeners = new Map(); + this._connection = createStreamGrantConnection({ + projectId: parsed.projectId, + getSessionToken: () => parsed.sessionToken, + fetchImpl, + EventSourceImpl: NativeEventSource, + origin, + schedule, + cancelSchedule, + retryDelayMs, + onMessage: (event) => this._dispatch('message', event), + onStatus: (status) => { + if (status === 'connected') { + this.readyState = 1; + this._dispatch('open', { type: 'open' }); + } else if (status === 'retrying') { + this.readyState = 0; + this._dispatch('error', { type: 'error' }); + } else if (status === 'closed') { + this.readyState = 2; + } else { + this.readyState = 0; + } + }, + }); + } + + addEventListener(type, listener) { + if (typeof listener !== 'function') return; + if (!this._listeners.has(type)) this._listeners.set(type, new Set()); + this._listeners.get(type).add(listener); + } + + removeEventListener(type, listener) { + this._listeners.get(type)?.delete(listener); + } + + _dispatch(type, event) { + const handler = this[`on${type}`]; + if (typeof handler === 'function') handler.call(this, event); + for (const listener of this._listeners.get(type) || []) listener.call(this, event); + } + + close() { + this._connection.close(); + this.readyState = 2; + } + } + + windowLike.EventSource = ScopeWeaveGrantEventSource; + installedWindows.add(windowLike); + return true; +} diff --git a/tests/api/session-revocation.test.mjs b/tests/api/session-revocation.test.mjs index 0e7409ce..978cd042 100644 --- a/tests/api/session-revocation.test.mjs +++ b/tests/api/session-revocation.test.mjs @@ -1,10 +1,8 @@ // Security invariant: logout-all revocation and strict session-claim validation -// must apply uniformly to every supported JWT transport. Calendar clients and -// EventSource cannot reliably send Authorization headers, so their query-token -// routes share the same fail-closed verifier as bearer middleware. Attachment -// views no longer accept broad session JWTs in query parameters; their direct -// session path is Authorization-header only and scoped grants are covered by -// the dedicated attachment-view access-grant regression. +// must apply uniformly to every supported JWT transport. Calendar subscription +// clients retain their dedicated query-token transport; realtime SSE and +// attachment views no longer accept broad session JWTs in query parameters and +// use Authorization-header sessions plus scoped access-grant paths instead. import test from 'node:test'; import assert from 'node:assert/strict'; import { createHmac } from 'node:crypto'; @@ -49,12 +47,21 @@ function signUnsafe( async function expectStreamStatus(projectId, token, status, message) { const response = await req( - `/api/projects/${projectId}/stream?token=${encodeURIComponent(token)}`, + `/api/projects/${projectId}/stream`, + { headers: { authorization: `Bearer ${token}` } }, ); assert.equal(response.status, status, message); await response.body?.cancel?.(); } +async function expectLegacyStreamQueryRejected(projectId, token, message) { + const response = await req( + `/api/projects/${projectId}/stream?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, 401, message); + await response.body?.cancel?.(); +} + async function expectCalendarStatus(projectId, token, status, message) { const response = await req( `/api/projects/${projectId}/calendar.ics?token=${encodeURIComponent(token)}`, @@ -80,7 +87,7 @@ async function expectBearerStatus(token, status, message) { /** * Assert that one invalid session token is rejected by every supported transport. * - * @param {number} projectId - Accessible project used by URL-token routes. + * @param {number} projectId - Accessible project used by scoped transports. * @param {string} token - Invalid or revoked compact JWT. * @param {string} label - Diagnostic label for assertion messages. * @returns {Promise} Resolves after all four transport assertions. @@ -88,7 +95,7 @@ async function expectBearerStatus(token, status, message) { async function expectRejectedEverywhere(projectId, token, label) { await expectBearerStatus(token, 401, `bearer rejects ${label}`); await expectCalendarStatus(projectId, token, 401, `calendar rejects ${label}`); - await expectStreamStatus(projectId, token, 401, `SSE rejects ${label}`); + await expectStreamStatus(projectId, token, 401, `SSE bearer rejects ${label}`); await expectAttachmentViewStatus(projectId, token, 401, `attachment view rejects ${label}`); } @@ -189,8 +196,10 @@ test('logout-all and strict JWT validation cover every session transport', async await expectBearerStatus(tokenB, 200, 'bearer accepts token B before revocation'); await expectCalendarStatus(projectId, tokenA, 200, 'calendar accepts token A before revocation'); await expectCalendarStatus(projectId, tokenB, 200, 'calendar accepts token B before revocation'); - await expectStreamStatus(projectId, tokenA, 200, 'SSE accepts token A before revocation'); - await expectStreamStatus(projectId, tokenB, 200, 'SSE accepts token B before revocation'); + await expectLegacyStreamQueryRejected(projectId, tokenA, 'SSE rejects token A in the retired query transport'); + await expectLegacyStreamQueryRejected(projectId, tokenB, 'SSE rejects token B in the retired query transport'); + await expectStreamStatus(projectId, tokenA, 200, 'SSE bearer accepts token A before revocation'); + await expectStreamStatus(projectId, tokenB, 200, 'SSE bearer accepts token B before revocation'); await expectAttachmentViewStatus(projectId, tokenA, 404, 'attachment view authenticates token A before lookup'); await expectAttachmentViewStatus(projectId, tokenB, 404, 'attachment view authenticates token B before lookup'); @@ -207,6 +216,7 @@ test('logout-all and strict JWT validation cover every session transport', async await expectBearerStatus(freshToken, 200, 'bearer accepts replacement token'); await expectCalendarStatus(projectId, freshToken, 200, 'calendar accepts replacement token'); - await expectStreamStatus(projectId, freshToken, 200, 'SSE accepts replacement token'); + await expectLegacyStreamQueryRejected(projectId, freshToken, 'SSE query transport rejects even a fresh broad token'); + await expectStreamStatus(projectId, freshToken, 200, 'SSE bearer accepts replacement token'); await expectAttachmentViewStatus(projectId, freshToken, 404, 'attachment view accepts replacement token before lookup'); }); \ No newline at end of file diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index 21688173..430a4232 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -156,13 +156,17 @@ assert.equal(r.status, 403, 'cannot remove owner'); r = await req(`/api/orgs/${orgAId}/members/${vmember.id}`, { method: 'DELETE', headers: auth }); assert.equal(r.status, 200, 'remove member ok'); -// SSE stream: query-token auth (EventSource can't send headers) +// SSE stream: broad session JWTs are never accepted in browser URLs. r = await req(`/api/projects/${proj.id}/stream?token=${encodeURIComponent(token)}`); -assert.equal(r.status, 200, 'SSE with valid query token → 200'); +assert.equal(r.status, 401, 'SSE with legacy query token → 401'); +await r.body?.cancel?.(); +// Capable API clients retain direct Authorization-header access. +r = await req(`/api/projects/${proj.id}/stream`, { headers: auth }); +assert.equal(r.status, 200, 'SSE with Authorization header → 200'); assert.match(r.headers.get('content-type') || '', /text\/event-stream/, 'SSE content-type'); await r.body?.cancel(); r = await req(`/api/projects/${proj.id}/stream`); -assert.equal(r.status, 401, 'SSE without token → 401'); +assert.equal(r.status, 401, 'SSE without credential → 401'); await r.body?.cancel?.(); // Static allowlist — client files served, source/db never exposed @@ -747,4 +751,4 @@ assert.equal((await r.json()).orgs.find((o) => o.id === orgAId)?.role, 'admin', r = await req(`/api/orgs/${orgAId}/leave`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'former owner can now leave'); -console.log('✓ API smoke tests passed'); +console.log('✓ API smoke tests passed'); \ No newline at end of file diff --git a/tests/api/stream-access-grant.test.mjs b/tests/api/stream-access-grant.test.mjs new file mode 100644 index 00000000..01c74dc4 --- /dev/null +++ b/tests/api/stream-access-grant.test.mjs @@ -0,0 +1,171 @@ +// Runtime regression for the stream access-grant migration in #413. +// Exercises Hono, SQLite grant persistence, tenant authorization, session +// revocation, one-time redemption, and the actual SSE response boundary. +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const jsonBody = (value) => JSON.stringify(value); + +async function signup(email) { + const response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email, password: 'password123', name: email.split('@')[0] }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + return (await response.json()).token; +} + +async function createProject(token, name) { + const response = await jsonRequest('/api/projects', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: jsonBody({ name }), + }); + assert.equal(response.status, 200, `project creation succeeds for ${name}`); + return response.json(); +} + +async function issueStreamGrant(token, projectId) { + const response = await jsonRequest(`/api/projects/${projectId}/access-grants`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: jsonBody({ purpose: 'stream' }), + }); + assert.equal(response.status, 201, 'authorized member receives a stream grant'); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('referrer-policy'), 'no-referrer'); + const issued = await response.json(); + assert.equal(issued.purpose, 'stream'); + assert.ok(Number.isSafeInteger(issued.expiresAtMs) && issued.expiresAtMs > Date.now()); + assert.match(issued.url, new RegExp(`^/api/projects/${projectId}/stream\\?grant=[A-Za-z0-9_-]{43}$`)); + assert.equal(issued.url.includes('token='), false); + assert.equal(issued.url.includes(token), false, 'broad JWT never appears in the SSE URL'); + return issued; +} + +async function readWithTimeout(reader, label, timeoutMs = 500) { + let timeout; + try { + return await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timeout); + } +} + +async function openConnectedStream(response) { + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /^text\/event-stream\b/); + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.equal(response.headers.get('referrer-policy'), 'no-referrer'); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + const reader = response.body.getReader(); + const first = await readWithTimeout(reader, 'SSE connected preamble'); + assert.equal(new TextDecoder().decode(first.value), ': connected\n\n'); + return reader; +} + +const ownerToken = await signup('stream-owner@example.com'); +const project = await createProject(ownerToken, 'Stream grant project'); +const secondProject = await createProject(ownerToken, 'Second stream project'); + +let issued = await issueStreamGrant(ownerToken, project.id); + +// A valid broad session JWT in the old query parameter is no longer accepted. +let response = await app.request(`/api/projects/${project.id}/stream?token=${encodeURIComponent(ownerToken)}`); +assert.equal(response.status, 401, 'legacy broad JWT query transport is rejected'); +assert.equal(response.headers.get('cache-control'), 'private, no-store'); +assert.equal(response.headers.get('referrer-policy'), 'no-referrer'); + +// Wrong-resource probes do not consume the one-time grant. +const issuedUrl = new URL(issued.url, 'http://localhost'); +const grantSecret = issuedUrl.searchParams.get('grant'); +response = await app.request(`/api/projects/${secondProject.id}/stream?grant=${encodeURIComponent(grantSecret)}`); +assert.equal(response.status, 401, 'stream grant is bound to its exact project'); +assert.equal(response.headers.get('cache-control'), 'private, no-store'); + +response = await app.request(issued.url); +const grantReader = await openConnectedStream(response); + +// The security gateway must preserve the existing buyer-visible realtime +// behavior rather than merely replacing the credential transport. A normal +// project write on the delegated core route has to fan out on this secured SSE +// channel with the exact resulting optimistic-concurrency version. +const update = await jsonRequest(`/api/projects/${project.id}`, { + method: 'PUT', + headers: { authorization: `Bearer ${ownerToken}` }, + body: jsonBody({ name: 'Stream grant project updated', version: project.version }), +}); +assert.equal(update.status, 200, 'project update succeeds while secure SSE is connected'); +const updated = await update.json(); +const pushed = await readWithTimeout(grantReader, 'secure SSE project update'); +const pushedText = new TextDecoder().decode(pushed.value); +assert.match(pushedText, /^data: /); +const pushedPayload = JSON.parse(pushedText.slice('data: '.length).trim()); +assert.deepEqual(pushedPayload, { type: 'update', version: updated.version }, 'secure gateway preserves update fanout without exposing actor credentials'); +await grantReader.cancel(); + +response = await app.request(issued.url); +assert.equal(response.status, 401, 'consumed stream grant cannot be replayed'); +assert.equal(response.headers.get('cache-control'), 'private, no-store'); + +// Non-browser API clients keep Authorization-header access. The direct path +// uses the strict database-backed session check rather than the old query-only +// verifyToken shortcut. +response = await app.request(`/api/projects/${project.id}/stream`, { + headers: { authorization: `Bearer ${ownerToken}` }, +}); +const headerReader = await openConnectedStream(response); +await headerReader.cancel(); + +// The access-grant exchange remains tenant-nondisclosing. +const outsiderToken = await signup('stream-outsider@example.com'); +response = await jsonRequest(`/api/projects/${project.id}/access-grants`, { + method: 'POST', + headers: { authorization: `Bearer ${outsiderToken}` }, + body: jsonBody({ purpose: 'stream' }), +}); +assert.equal(response.status, 404, 'outsider cannot discover the project by minting a stream grant'); +assert.equal(response.headers.get('cache-control'), 'no-store'); + +// Ambiguous or malformed credential shapes fail closed without opening SSE. +for (const query of [ + `grant=${'C'.repeat(43)}&grant=${'D'.repeat(43)}`, + `grant=${'E'.repeat(43)}&next=/admin`, + 'grant=short', + `token=${encodeURIComponent(ownerToken)}&grant=${'F'.repeat(43)}`, +]) { + response = await app.request(`/api/projects/${project.id}/stream?${query}`); + assert.equal(response.status, 401, `unsafe stream query is rejected: ${query}`); + assert.equal(response.headers.get('cache-control'), 'private, no-store'); +} + +// Revocation before mint prevents a former session from obtaining fresh grants. +const revoke = await jsonRequest('/api/auth/logout-all', { + method: 'POST', + headers: { authorization: `Bearer ${ownerToken}` }, +}); +assert.equal(revoke.status, 200, 'logout-all revokes the owner session'); +response = await jsonRequest(`/api/projects/${project.id}/access-grants`, { + method: 'POST', + headers: { authorization: `Bearer ${ownerToken}` }, + body: jsonBody({ purpose: 'stream' }), +}); +assert.equal(response.status, 401, 'revoked session cannot mint a new stream grant'); + +console.log('stream access-grant runtime contract ok'); diff --git a/tests/unit/stream-access-grant-client.test.mjs b/tests/unit/stream-access-grant-client.test.mjs new file mode 100644 index 00000000..71fb799b --- /dev/null +++ b/tests/unit/stream-access-grant-client.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; + +import { + createStreamGrantConnection, + validateStreamGrantUrl, +} from '../../stream-access-grant.js'; + +const SESSION_TOKEN = 'session.jwt.with-sensitive-authority'; +const FIRST_GRANT = 'A'.repeat(43); +const SECOND_GRANT = 'B'.repeat(43); + +class FakeEventSource { + static instances = []; + + constructor(url) { + this.url = url; + this.closed = false; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + FakeEventSource.instances.push(this); + } + + close() { + this.closed = true; + } + + emitOpen() { + this.onopen?.({ type: 'open' }); + } + + emitMessage(data) { + this.onmessage?.({ data }); + } + + emitError() { + this.onerror?.({ type: 'error' }); + } +} + +const requests = []; +const scheduled = []; +const cancelled = []; +let grantIndex = 0; +const grants = [FIRST_GRANT, SECOND_GRANT]; +const messages = []; +const statuses = []; + +const fetchImpl = async (url, options) => { + requests.push({ url, options }); + const grant = grants[Math.min(grantIndex, grants.length - 1)]; + grantIndex += 1; + return new Response(JSON.stringify({ + purpose: 'stream', + url: `/api/projects/42/stream?grant=${grant}`, + }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); +}; + +const connection = createStreamGrantConnection({ + projectId: 42, + getSessionToken: () => SESSION_TOKEN, + fetchImpl, + EventSourceImpl: FakeEventSource, + origin: 'https://scopeweave.example', + onMessage: (event) => messages.push(event.data), + onStatus: (status) => statuses.push(status), + schedule: (callback, delayMs) => { + const handle = { callback, delayMs }; + scheduled.push(handle); + return handle; + }, + cancelSchedule: (handle) => cancelled.push(handle), + retryDelayMs: 750, +}); + +await connection.ready; +assert.equal(requests.length, 1, 'initial connection exchanges the broad session for a stream grant'); +assert.equal(requests[0].url, '/api/projects/42/access-grants'); +assert.equal(requests[0].options.method, 'POST'); +assert.equal(requests[0].options.headers.authorization, `Bearer ${SESSION_TOKEN}`); +assert.equal(requests[0].options.headers['content-type'], 'application/json'); +assert.deepEqual(JSON.parse(requests[0].options.body), { purpose: 'stream' }); +assert.equal(requests[0].url.includes(SESSION_TOKEN), false, 'broad session credential never enters the exchange URL'); +assert.equal(FakeEventSource.instances.length, 1); +assert.equal(FakeEventSource.instances[0].url, `/api/projects/42/stream?grant=${FIRST_GRANT}`); +assert.equal(FakeEventSource.instances[0].url.includes('token='), false, 'EventSource never receives the legacy token query parameter'); +assert.equal(FakeEventSource.instances[0].url.includes(SESSION_TOKEN), false, 'EventSource URL never contains the broad session credential'); + +FakeEventSource.instances[0].emitOpen(); +FakeEventSource.instances[0].emitMessage('{"type":"update","version":7}'); +assert.deepEqual(messages, ['{"type":"update","version":7}']); +assert.ok(statuses.includes('connected')); + +FakeEventSource.instances[0].emitError(); +assert.equal(FakeEventSource.instances[0].closed, true, 'native EventSource auto-reconnect is disabled after a one-time grant is consumed'); +assert.equal(scheduled.length, 1, 'a failed stream schedules a fresh grant exchange'); +assert.equal(scheduled[0].delayMs, 750); +assert.ok(statuses.includes('retrying')); + +await scheduled[0].callback(); +assert.equal(requests.length, 2, 'reconnect exchanges for a new one-time grant'); +assert.equal(FakeEventSource.instances.length, 2); +assert.equal(FakeEventSource.instances[1].url, `/api/projects/42/stream?grant=${SECOND_GRANT}`); +assert.notEqual(FakeEventSource.instances[1].url, FakeEventSource.instances[0].url, 'reconnect does not replay a consumed grant'); + +connection.close(); +assert.equal(FakeEventSource.instances[1].closed, true, 'closing the connection closes the active EventSource'); +assert.ok(statuses.includes('closed')); + +assert.equal( + validateStreamGrantUrl(`/api/projects/42/stream?grant=${FIRST_GRANT}`, { + origin: 'https://scopeweave.example', + projectId: 42, + }), + `/api/projects/42/stream?grant=${FIRST_GRANT}`, +); +for (const unsafeUrl of [ + `https://evil.example/api/projects/42/stream?grant=${FIRST_GRANT}`, + `/api/projects/7/stream?grant=${FIRST_GRANT}`, + `/api/projects/42/stream?grant=${FIRST_GRANT}&next=/admin`, + `/api/projects/42/stream?grant=short`, + `/api/projects/42/stream?token=${SESSION_TOKEN}`, + `/api/projects/42/stream?grant=${FIRST_GRANT}#fragment`, +]) { + assert.throws( + () => validateStreamGrantUrl(unsafeUrl, { origin: 'https://scopeweave.example', projectId: 42 }), + /stream grant url invalid/, + `reject unsafe stream URL: ${unsafeUrl}`, + ); +} + +const noTokenStatuses = []; +let noTokenFetchCalls = 0; +const noTokenConnection = createStreamGrantConnection({ + projectId: 42, + getSessionToken: () => '', + fetchImpl: async () => { noTokenFetchCalls += 1; throw new Error('should not fetch'); }, + EventSourceImpl: FakeEventSource, + origin: 'https://scopeweave.example', + onStatus: (status) => noTokenStatuses.push(status), +}); +await noTokenConnection.ready; +assert.equal(noTokenFetchCalls, 0, 'logged-out clients do not request grants'); +assert.deepEqual(noTokenStatuses, ['closed']); + +console.log('stream access-grant client contract ok');