diff --git a/apps/api/src/pages/collab-token.e2e.db.test.ts b/apps/api/src/pages/collab-token.e2e.db.test.ts new file mode 100644 index 0000000..c6c190c --- /dev/null +++ b/apps/api/src/pages/collab-token.e2e.db.test.ts @@ -0,0 +1,128 @@ +import { INestApplication } from '@nestjs/common'; +import { collabTokenClaimsSchema } from '@dorfteich/shared'; +import { verifyCollabToken } from '@dorfteich/shared/token-crypto'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { AuthTokensService } from '../auth/auth-tokens.service'; +import { AppConfig } from '../config/app-config.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +describe.skipIf(!hasTestDb)('collab token (e2e, issue #34)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let secret: string; + const suffix = uniqueSuffix(); + const password = 'tokens fuer teiche 1'; + + const owner = { username: `tom-token-${suffix}`, displayName: `Tom Token ${suffix}` }; + const outsider = { username: `oona-out-${suffix}`, displayName: `Oona Out ${suffix}` }; + let ownerCookie: string; + let outsiderCookie: string; + let pageId: string; + + const api = () => request(app.getHttpServer()); + + async function loginOf(username: string): Promise { + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200); + return sessionCookieOf(res); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + secret = app.get(AppConfig).env.COLLAB_TOKEN_SECRET; + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + + const ownerUser = await users.createUser({ + username: owner.username, + email: `${owner.username}@example.org`, + displayName: owner.displayName, + password, + locale: 'en', + }); + const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); + await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204); + ownerCookie = await loginOf(owner.username); + + const outsiderUser = await users.createUser({ + username: outsider.username, + email: `${outsider.username}@example.org`, + displayName: outsider.displayName, + password, + locale: 'en', + }); + await users.markEmailVerified(outsiderUser.id); + outsiderCookie = await loginOf(outsider.username); + + const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); + const pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id; + const page = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: 'Collaborative page' }) + .expect(201); + pageId = page.body.id; + }); + + afterAll(async () => { + const users = await prisma.user.findMany({ + where: { username: { contains: suffix } }, + select: { id: true }, + }); + await prisma.page.deleteMany({ + where: { pond: { owner: { username: { contains: suffix } } } }, + }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } } }); + await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('requires authentication', async () => { + await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(401); + }); + + it('issues a valid rw token to a member and encodes the right claims', async () => { + const res = await api() + .get(`/api/v1/pages/${pageId}/collab-token`) + .set('Cookie', ownerCookie) + .expect(200); + + expect(res.body.mode).toBe('rw'); + expect(res.body.expiresInSeconds).toBe(60); + + const verified = verifyCollabToken(res.body.token, secret); + expect(verified.valid).toBe(true); + if (verified.valid) { + expect(collabTokenClaimsSchema.parse(verified.claims)).toEqual({ + userId: expect.any(String), + pageId, + mode: 'rw', + }); + } + }); + + it('hides the page from a non-member (404, not 403)', async () => { + await api() + .get(`/api/v1/pages/${pageId}/collab-token`) + .set('Cookie', outsiderCookie) + .expect(404); + }); + + it('404s for an unknown page id', async () => { + await api() + .get('/api/v1/pages/00000000-0000-4000-8000-0000000000ff/collab-token') + .set('Cookie', ownerCookie) + .expect(404); + }); +}); diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index b0219c9..c349790 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -12,6 +12,7 @@ import { Res, } from '@nestjs/common'; import { + CollabTokenResponse, CreatePageInput, PageStateView, PageView, @@ -51,6 +52,15 @@ export class PagesController { return this.pages.getState(request.user!, id); } + /** Short-lived collaboration token for the collab server (issue #34). */ + @Get('pages/:id/collab-token') + async collabToken( + @Param('id') id: string, + @Req() request: AuthedRequest, + ): Promise { + return this.pages.issueCollabToken(request.user!, id); + } + /** Markdown export (issue #30) — downloads `.md`. */ @Get('pages/:id/export/markdown') async exportMarkdown( diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 085e545..eafa47c 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -6,6 +6,7 @@ import { PayloadTooLargeException, } from '@nestjs/common'; import { + CollabTokenResponse, CreatePageInput, MAX_PAGE_DOCUMENT_BYTES, PageStateView, @@ -16,10 +17,12 @@ import { pondSettingsSchema, slugify, } from '@dorfteich/shared'; +import { signCollabToken } from '@dorfteich/shared/token-crypto'; import { Page, Prisma, User } from '@prisma/client'; import { generateKeyBetween } from 'fractional-indexing'; import { PinoLogger } from 'nestjs-pino'; +import { AppConfig } from '../config/app-config.service'; import { InterimAccessService } from '../ponds/interim-access.service'; import { PrismaService } from '../prisma/prisma.service'; import { @@ -41,12 +44,17 @@ function contentCacheData( }; } +/** Collaboration tokens are short-lived; the client re-fetches on reconnect + * (realtime-collaboration.md). 60 s is the ceiling the story specifies. */ +const COLLAB_TOKEN_TTL_SECONDS = 60; + @Injectable() export class PagesService { constructor( private readonly prisma: PrismaService, private readonly access: InterimAccessService, private readonly logger: PinoLogger, + private readonly config: AppConfig, ) { this.logger.setContext(PagesService.name); } @@ -161,6 +169,33 @@ export class PagesService { return this.stateViewOf(page); } + /** + * Mint a collaboration token for a page (issue #34). The permission check + * runs here, in the api — the collab server never sees session cookies + * (ADR 0003). `mode` is `rw` for anyone who may modify the pond and `ro` + * otherwise; under the interim access model seeing and modifying coincide, + * so callers who can see a page currently always get `rw` (real read-only + * grants arrive with #53, which this endpoint is already shaped for). + */ + async issueCollabToken(user: User, id: string): Promise { + const page = await this.prisma.page.findFirst({ + where: { id, deletedAt: null }, + include: { pond: true }, + }); + if (!page) throw new NotFoundException(); + this.access.assertCanSee(user, page.pond); + + const mode = this.access.canModifyPond(user, page.pond) ? 'rw' : 'ro'; + const token = signCollabToken( + { userId: user.id, pageId: page.id, mode }, + this.config.env.COLLAB_TOKEN_SECRET, + COLLAB_TOKEN_TTL_SECONDS, + ); + // Debug level, and deliberately without the token value (issue #34). + this.logger.debug({ pageId: page.id, userId: user.id, mode }, 'issued collab token'); + return { token, mode, expiresInSeconds: COLLAB_TOKEN_TTL_SECONDS }; + } + async getStateBySlug(user: User, pondId: string, slug: string): Promise { const page = await this.prisma.page.findFirst({ where: { pondId, slug }, diff --git a/apps/collab/package.json b/apps/collab/package.json index e06c7aa..37fbb5b 100644 --- a/apps/collab/package.json +++ b/apps/collab/package.json @@ -20,9 +20,11 @@ "pino": "^9.6.0" }, "devDependencies": { + "@hocuspocus/provider": "^4.3.0", "@types/node": "^26.1.0", "@types/pg": "^8.11.0", "tsx": "^4.19.0", - "vitest": "^3.0.0" + "vitest": "^3.0.0", + "yjs": "^13.6.0" } } diff --git a/apps/collab/src/auth.test.ts b/apps/collab/src/auth.test.ts new file mode 100644 index 0000000..0096666 --- /dev/null +++ b/apps/collab/src/auth.test.ts @@ -0,0 +1,164 @@ +import { HocuspocusProvider } from '@hocuspocus/provider'; +import type { CollabTokenClaims } from '@dorfteich/shared'; +import { signCollabToken } from '@dorfteich/shared/token-crypto'; +import { pino } from 'pino'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; + +import { createCollabServer } from './server.js'; +import { freePort } from './testing/free-port.js'; + +const secret = 'integration-test-secret-32-chars!!'; +const logger = pino({ enabled: false }); + +/** Poll `predicate` until it is true or the timeout elapses. */ +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error('timed out waiting for condition'); +} + +describe('collab authentication', () => { + let server: ReturnType; + let url: string; + + beforeAll(async () => { + server = createCollabServer({ + version: 'test', + logger, + tokenSecret: secret, + pingDatabase: async () => ({ ok: true }), + }); + const port = await freePort(); + await server.listen(port); + url = `ws://127.0.0.1:${port}`; + }); + + afterAll(async () => { + await server.destroy(); + }); + + function connect( + name: string, + token: string, + ): { + provider: HocuspocusProvider; + doc: Y.Doc; + authenticated: () => boolean; + failed: () => boolean; + synced: () => boolean; + } { + const doc = new Y.Doc(); + let authenticated = false; + let failed = false; + let synced = false; + // No WebSocketPolyfill: the provider falls back to Node's global WebSocket. + const provider = new HocuspocusProvider({ + url, + name, + token, + document: doc, + onAuthenticated: () => { + authenticated = true; + }, + onAuthenticationFailed: () => { + failed = true; + }, + onSynced: () => { + synced = true; + }, + }); + return { + provider, + doc, + authenticated: () => authenticated, + failed: () => failed, + synced: () => synced, + }; + } + + it('accepts a valid token whose page matches the document', async () => { + const token = signCollabToken({ userId: 'u1', pageId: 'page-ok', mode: 'rw' }, secret, 60); + const client = connect('page-ok', token); + await waitFor(client.authenticated); + expect(client.authenticated()).toBe(true); + expect(client.failed()).toBe(false); + client.provider.destroy(); + client.doc.destroy(); + }); + + const claims: CollabTokenClaims = { userId: 'u1', pageId: 'page-x', mode: 'rw' }; + const rejectionCases: { label: string; name: string; token: () => string }[] = [ + { + label: 'an expired token', + name: 'page-x', + token: () => signCollabToken(claims, secret, -60), + }, + { + label: 'a tampered token', + name: 'page-x', + token: () => { + const [h, p, s = ''] = signCollabToken(claims, secret, 60).split('.'); + // Flip the last character of the signature. + const flipped = s.slice(0, -1) + (s.endsWith('A') ? 'B' : 'A'); + return `${h}.${p}.${flipped}`; + }, + }, + { + label: 'a token minted for a different page', + name: 'page-y', + token: () => signCollabToken(claims, secret, 60), + }, + { + label: 'a token signed with the wrong secret', + name: 'page-x', + token: () => signCollabToken(claims, 'a-completely-different-secret-32c', 60), + }, + ]; + + it.each(rejectionCases)('rejects $label', async ({ name, token }) => { + const client = connect(name, token()); + await waitFor(client.failed); + expect(client.failed()).toBe(true); + expect(client.authenticated()).toBe(false); + client.provider.destroy(); + client.doc.destroy(); + }); + + it('lets a read-only client receive updates but discards its writes server-side', async () => { + const page = 'page-shared'; + const rw = connect( + page, + signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60), + ); + const ro = connect( + page, + signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60), + ); + await waitFor(() => rw.synced() && ro.synced()); + + const rwMap = rw.doc.getMap('m'); + const roMap = ro.doc.getMap('m'); + + // A write from the read-write client reaches the read-only client. + rw.doc.transact(() => rwMap.set('fromRw', 'a')); + await waitFor(() => roMap.get('fromRw') === 'a'); + + // The read-only client writes; the server must drop it. + ro.doc.transact(() => roMap.set('fromRo', 'b')); + // Barrier: a second rw write round-trips to ro; by the time it arrives the + // server has already processed (and dropped) the ro write that preceded it. + rw.doc.transact(() => rwMap.set('barrier', 'c')); + await waitFor(() => roMap.get('barrier') === 'c'); + + expect(rwMap.get('fromRo')).toBeUndefined(); + + rw.provider.destroy(); + ro.provider.destroy(); + rw.doc.destroy(); + ro.doc.destroy(); + }); +}); diff --git a/apps/collab/src/index.ts b/apps/collab/src/index.ts index 24756bf..2f63504 100644 --- a/apps/collab/src/index.ts +++ b/apps/collab/src/index.ts @@ -18,6 +18,7 @@ async function bootstrap(): Promise { const server = createCollabServer({ version: env.APP_VERSION, logger, + tokenSecret: env.COLLAB_TOKEN_SECRET, pingDatabase: () => pingDatabase(pool), }); diff --git a/apps/collab/src/server.test.ts b/apps/collab/src/server.test.ts index f8c8af5..3199375 100644 --- a/apps/collab/src/server.test.ts +++ b/apps/collab/src/server.test.ts @@ -2,6 +2,7 @@ import { pino } from 'pino'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { createCollabServer } from './server.js'; +import { freePort } from './testing/free-port.js'; import type { CollabHealthReport, DatabaseProbe } from './health.js'; // A silent logger; these tests assert behaviour, not log output. @@ -14,10 +15,16 @@ describe('collab server', () => { const probe = vi.fn<() => Promise>(); beforeAll(async () => { - server = createCollabServer({ version: 'test-version', logger, pingDatabase: probe }); - // Port 0 binds an ephemeral free port. - await server.listen(0); - const { port } = server.address; + server = createCollabServer({ + version: 'test-version', + logger, + tokenSecret: 'server-test-secret-32-characters!', + pingDatabase: probe, + }); + // Hocuspocus' listen(port) ignores a falsy port (0 → default 80), so bind + // an explicit OS-assigned free port instead. + const port = await freePort(); + await server.listen(port); baseUrl = `http://127.0.0.1:${port}`; wsUrl = `ws://127.0.0.1:${port}`; }); diff --git a/apps/collab/src/server.ts b/apps/collab/src/server.ts index 7de6cea..dfe134b 100644 --- a/apps/collab/src/server.ts +++ b/apps/collab/src/server.ts @@ -1,12 +1,21 @@ import { Server } from '@hocuspocus/server'; +import { verifyCollabToken } from '@dorfteich/shared/token-crypto'; import type { Logger } from 'pino'; import { buildHealthReport, isHealthRequest, type DatabaseProbe } from './health.js'; +/** Per-connection context returned by onAuthenticate and used by later hooks. */ +export interface CollabContext { + userId: string; + mode: 'rw' | 'ro'; +} + export interface CollabServerDeps { /** Version string surfaced in health responses (image build arg). */ version: string; logger: Logger; + /** Secret shared with the api that signs the collaboration tokens (#34). */ + tokenSecret: string; /** Probe used by the `/healthz` endpoint. Injected so it is easy to test. */ pingDatabase: () => Promise; } @@ -18,12 +27,49 @@ export interface CollabServerDeps { * so any WebSocket handshake is currently accepted. */ export function createCollabServer(deps: CollabServerDeps): Server { - const { version, logger, pingDatabase } = deps; + const { version, logger, tokenSecret, pingDatabase } = deps; return new Server({ name: 'dorfteich-collab', // Suppress Hocuspocus' ASCII start screen; we emit our own pino logs. quiet: true, + // index.ts owns graceful shutdown (it also closes the db pool); don't let + // Hocuspocus install its own signal handlers that would call process.exit. + stopOnSignals: false, + + /** + * Authorize every connection with the token the api minted after its own + * permission check (#34, ADR 0003). Rejecting (throwing) closes the socket. + * The document name is the page id, so a token issued for one page cannot + * open another. A `ro` token connects but its inbound document updates are + * dropped server-side via Hocuspocus' read-only connection flag. + */ + async onAuthenticate({ documentName, token, connectionConfig }): Promise { + const result = verifyCollabToken(token, tokenSecret); + if (!result.valid) { + logger.info( + { event: 'auth.rejected', documentName, reason: result.reason }, + 'collab authentication rejected', + ); + throw new Error('unauthorized'); + } + if (result.claims.pageId !== documentName) { + logger.info( + { event: 'auth.rejected', documentName, reason: 'page_mismatch' }, + 'collab authentication rejected', + ); + throw new Error('unauthorized'); + } + + if (result.claims.mode === 'ro') { + connectionConfig.readOnly = true; + } + logger.debug( + { event: 'auth.ok', documentName, userId: result.claims.userId, mode: result.claims.mode }, + 'collab connection authenticated', + ); + return { userId: result.claims.userId, mode: result.claims.mode }; + }, async onConnect({ documentName, socketId }) { logger.info( diff --git a/apps/collab/src/testing/free-port.ts b/apps/collab/src/testing/free-port.ts new file mode 100644 index 0000000..2dc10b6 --- /dev/null +++ b/apps/collab/src/testing/free-port.ts @@ -0,0 +1,19 @@ +import { createServer } from 'node:net'; + +/** + * Ask the OS for a free TCP port. Used by tests because Hocuspocus' + * `listen(port)` treats a falsy port (0) as "unset" and falls back to its + * default (80), so we cannot rely on port-0 ephemeral binding. + */ +export function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 535b5ec..dceb9b0 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -5,6 +5,11 @@ # PostgreSQL password for the `dorfteich` database user. POSTGRES_PASSWORD=change-me +# Secret that signs/verifies the short-lived collaboration tokens (issue #34). +# The api and collab services share this one value; use a long random string +# (e.g. `openssl rand -base64 32`). Min length 16. +COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string + # --- images ----------------------------------------------------------------- # Image name prefix. Stages pull from the Gitea registry, e.g. # gitea.101010.cloud/stwaidele/dorfteich — local builds use the default. diff --git a/deploy/compose/compose.dev.yml b/deploy/compose/compose.dev.yml index 520eb5d..3c504d5 100644 --- a/deploy/compose/compose.dev.yml +++ b/deploy/compose/compose.dev.yml @@ -41,6 +41,8 @@ services: NODE_ENV: development PORT: '3000' DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:-dorfteich}@db:5432/dorfteich + # Dev default; overrides the base stack's required form (issue #34). + COLLAB_TOKEN_SECRET: ${COLLAB_TOKEN_SECRET:-dev-insecure-collab-token-secret-change-me} ports: !override - '127.0.0.1:3001:3000' volumes: @@ -59,6 +61,8 @@ services: NODE_ENV: development PORT: '3000' DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:-dorfteich}@db:5432/dorfteich + # Must match the api dev default so tokens verify across the two services. + COLLAB_TOKEN_SECRET: ${COLLAB_TOKEN_SECRET:-dev-insecure-collab-token-secret-change-me} ports: !override - '127.0.0.1:3002:3000' volumes: diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index b8e152f..b4bb35b 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -45,6 +45,9 @@ services: PORT: '3000' LOG_LEVEL: ${LOG_LEVEL:-info} DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich + # Signs the short-lived collaboration tokens; the collab service below + # verifies them, so both MUST carry the same value (issue #34). + COLLAB_TOKEN_SECRET: ${COLLAB_TOKEN_SECRET:?set in .env} # Public URL of this stage — e-mail links and the CSRF origin check # depend on it matching what browsers actually use. APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173} @@ -80,6 +83,8 @@ services: PORT: '3000' LOG_LEVEL: ${LOG_LEVEL:-info} DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich + # Must match the api's value — this service verifies the tokens it signs. + COLLAB_TOKEN_SECRET: ${COLLAB_TOKEN_SECRET:?set in .env} ports: # The host reverse proxy routes /collab here with WebSocket upgrade # (deployment.md, deploy/stages.md). diff --git a/deploy/stages.md b/deploy/stages.md index ae2a21b..304d0cb 100644 --- a/deploy/stages.md +++ b/deploy/stages.md @@ -25,6 +25,9 @@ Copy `deploy/compose/docker-compose.yml` and `.env.example` → `.env` into each stage directory. Set per stage in `.env` (mode 600): - `POSTGRES_PASSWORD`: unique random value per stage +- `COLLAB_TOKEN_SECRET`: long random value per stage (`openssl rand -base64 32`); + signs/verifies the collaboration tokens (issue #34). The api and collab + services read the same value from this one variable. - `COMPOSE_PROJECT_NAME`: `dorfteich-test` / `dorfteich-int` - `WEB_PORT`/`API_PORT`/`COLLAB_PORT`: 8100/8101/8102 (test), 8110/8111/8112 (int). **`COLLAB_PORT` must be set per stage** — both diff --git a/packages/shared/package.json b/packages/shared/package.json index bd3b662..a5fc8a9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -13,14 +13,27 @@ "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./token-crypto": { + "types": "./dist/token-crypto.d.ts", + "import": "./dist/token-crypto.mjs", + "require": "./dist/token-crypto.js" + }, "./i18n/*": "./i18n/*" }, + "//": "typesVersions maps the ./token-crypto subpath for the api, which uses classic (node10) module resolution that ignores the exports field.", + "typesVersions": { + "*": { + "token-crypto": [ + "./dist/token-crypto.d.ts" + ] + } + }, "files": [ "dist", "i18n" ], "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "build": "tsup src/index.ts src/token-crypto.ts --format esm,cjs --dts --clean", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests" }, diff --git a/packages/shared/src/collab-token.test.ts b/packages/shared/src/collab-token.test.ts new file mode 100644 index 0000000..5d6889c --- /dev/null +++ b/packages/shared/src/collab-token.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import type { CollabTokenClaims } from './collab-token'; +import { signCollabToken, verifyCollabToken } from './token-crypto'; + +const secret = 'test-secret-at-least-16-chars-long'; +const claims: CollabTokenClaims = { userId: 'user-1', pageId: 'page-1', mode: 'rw' }; + +describe('collab token', () => { + it('round-trips valid claims', () => { + const token = signCollabToken(claims, secret, 60); + const result = verifyCollabToken(token, secret); + expect(result).toEqual({ valid: true, claims }); + }); + + it('rejects a token signed with a different secret', () => { + const token = signCollabToken(claims, secret, 60); + expect(verifyCollabToken(token, 'another-secret-16-chars')).toEqual({ + valid: false, + reason: 'bad_signature', + }); + }); + + it('rejects a tampered payload', () => { + const token = signCollabToken(claims, secret, 60); + const [header, , signature] = token.split('.'); + const forgedPayload = Buffer.from( + JSON.stringify({ ...claims, mode: 'rw', iat: 0, exp: 9999999999 }), + 'utf8', + ).toString('base64url'); + const forged = `${header}.${forgedPayload}.${signature}`; + expect(verifyCollabToken(forged, secret).valid).toBe(false); + }); + + it('rejects an expired token', () => { + const token = signCollabToken(claims, secret, 60); + // 61 seconds later the 60 s token is past its expiry. + const later = Date.now() + 61_000; + expect(verifyCollabToken(token, secret, later)).toEqual({ valid: false, reason: 'expired' }); + }); + + it('rejects a malformed token', () => { + expect(verifyCollabToken('not-a-jwt', secret)).toEqual({ valid: false, reason: 'malformed' }); + expect(verifyCollabToken('a.b', secret).valid).toBe(false); + }); + + it('rejects an alg:none token even if the signature check is bypassed', () => { + // Craft a header advertising `none` and an empty signature; the empty + // signature cannot match a real HMAC, so this is caught as bad_signature, + // but the algorithm guard is the intended second line of defense. + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString( + 'base64url', + ); + const result = verifyCollabToken(`${header}.${payload}.`, secret); + expect(result.valid).toBe(false); + }); + + it('preserves the read-only mode claim', () => { + const roToken = signCollabToken({ ...claims, mode: 'ro' }, secret, 60); + const result = verifyCollabToken(roToken, secret); + expect(result).toEqual({ valid: true, claims: { ...claims, mode: 'ro' } }); + }); +}); diff --git a/packages/shared/src/collab-token.ts b/packages/shared/src/collab-token.ts new file mode 100644 index 0000000..e0299d6 --- /dev/null +++ b/packages/shared/src/collab-token.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Types and schemas for the short-lived collaboration tokens (issue #34, + * ADR 0003/0007). These are browser-safe (no Node built-ins) so the web app + * can import them from the package barrel. The signing/verifying helpers live + * in `./token-crypto` (Node `crypto`) and are imported only by the api and the + * collab server. + */ + +export const collabTokenModeSchema = z.enum(['rw', 'ro']); +export type CollabTokenMode = z.infer; + +/** The application claims carried by a collaboration token. */ +export const collabTokenClaimsSchema = z.object({ + userId: z.string().min(1), + pageId: z.string().min(1), + mode: collabTokenModeSchema, +}); +export type CollabTokenClaims = z.infer; + +/** Response of `GET /pages/:id/collab-token`. */ +export interface CollabTokenResponse { + token: string; + mode: CollabTokenMode; + expiresInSeconds: number; +} + +export type CollabTokenVerification = + | { valid: true; claims: CollabTokenClaims } + | { + valid: false; + reason: 'malformed' | 'bad_algorithm' | 'bad_signature' | 'expired' | 'invalid_claims'; + }; diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index b286ed8..2d61ea0 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -18,6 +18,14 @@ const databaseUrl = z .refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), { message: 'must be a postgresql:// connection string', }); +/** + * Symmetric secret shared by the api (which signs) and the collab server + * (which verifies) for the short-lived collaboration tokens (issue #34, + * ADR 0007). The dev default only keeps native dev/test/CI running without + * extra setup; every real instance MUST set its own identical value in the + * `.env` of both services (the stage setup and the M8 wizard do this). + */ +const collabTokenSecret = z.string().min(16).default('dev-insecure-collab-token-secret-change-me'); export const apiEnvSchema = z.object({ NODE_ENV: nodeEnv, @@ -25,6 +33,7 @@ export const apiEnvSchema = z.object({ LOG_LEVEL: logLevel, APP_VERSION: appVersion, DATABASE_URL: databaseUrl, + COLLAB_TOKEN_SECRET: collabTokenSecret, /** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */ MIGRATE_ON_START: z .enum(['true', 'false']) @@ -68,6 +77,7 @@ export const collabEnvSchema = z.object({ LOG_LEVEL: logLevel, APP_VERSION: appVersion, DATABASE_URL: databaseUrl, + COLLAB_TOKEN_SECRET: collabTokenSecret, }); export type CollabEnv = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index de17541..8e91c43 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,6 @@ export * from './api-error'; export * from './auth'; +export * from './collab-token'; export * from './editor-schema'; export * from './env'; export * from './files'; diff --git a/packages/shared/src/token-crypto.ts b/packages/shared/src/token-crypto.ts new file mode 100644 index 0000000..cab3aa9 --- /dev/null +++ b/packages/shared/src/token-crypto.ts @@ -0,0 +1,107 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import { z } from 'zod'; + +import { + collabTokenClaimsSchema, + type CollabTokenClaims, + type CollabTokenVerification, +} from './collab-token'; + +/** + * Sign and verify collaboration tokens (issue #34, ADR 0007). These are the + * ONLY JWTs in the system. Implemented with `node:crypto` rather than a library + * so the exact same code runs in the CommonJS api and the ESM collab server + * without module-interop or dependency-version drift. This module pulls in a + * Node built-in, so it lives outside the browser-safe package barrel and is + * imported via `@dorfteich/shared/token-crypto`. + * + * The token is a standard compact HS256 JWT. Only HS256 is ever produced or + * accepted; the signature is checked in constant time before any untrusted + * field is read. + */ + +const HEADER = { alg: 'HS256', typ: 'JWT' } as const; + +/** Full JWT payload: the claims plus standard `iat`/`exp` (seconds since epoch). */ +const payloadSchema = collabTokenClaimsSchema.extend({ + iat: z.number().int().nonnegative(), + exp: z.number().int().nonnegative(), +}); + +function b64url(value: string): string { + return Buffer.from(value, 'utf8').toString('base64url'); +} + +function hmac(signingInput: string, secret: string): string { + return createHmac('sha256', secret).update(signingInput).digest('base64url'); +} + +/** Sign a collaboration token that expires `ttlSeconds` from now. */ +export function signCollabToken( + claims: CollabTokenClaims, + secret: string, + ttlSeconds: number, +): string { + const now = Math.floor(Date.now() / 1000); + const payload = { ...collabTokenClaimsSchema.parse(claims), iat: now, exp: now + ttlSeconds }; + const signingInput = `${b64url(JSON.stringify(HEADER))}.${b64url(JSON.stringify(payload))}`; + return `${signingInput}.${hmac(signingInput, secret)}`; +} + +/** Verify signature, algorithm, claims, and expiry. Never throws. */ +export function verifyCollabToken( + token: string, + secret: string, + now: number = Date.now(), +): CollabTokenVerification { + const parts = token.split('.'); + const [headerPart, payloadPart, signaturePart] = parts; + if ( + parts.length !== 3 || + headerPart === undefined || + payloadPart === undefined || + signaturePart === undefined + ) { + return { valid: false, reason: 'malformed' }; + } + const signingInput = `${headerPart}.${payloadPart}`; + + // Verify the signature (recomputed with HS256, ignoring the header's claimed + // algorithm) before reading any field — defeats alg-confusion and forgery. + const expected = Buffer.from(hmac(signingInput, secret), 'utf8'); + const provided = Buffer.from(signaturePart, 'utf8'); + if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) { + return { valid: false, reason: 'bad_signature' }; + } + + let header: unknown; + let payload: unknown; + try { + header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf8')); + payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')); + } catch { + return { valid: false, reason: 'malformed' }; + } + + // Defense in depth: even though the signature already pinned HS256, reject a + // token whose header advertises anything else (e.g. `none`). + if ( + typeof header !== 'object' || + header === null || + (header as { alg?: unknown }).alg !== 'HS256' + ) { + return { valid: false, reason: 'bad_algorithm' }; + } + + const parsed = payloadSchema.safeParse(payload); + if (!parsed.success) { + return { valid: false, reason: 'invalid_claims' }; + } + if (parsed.data.exp * 1000 <= now) { + return { valid: false, reason: 'expired' }; + } + + const { userId, pageId, mode } = parsed.data; + return { valid: true, claims: { userId, pageId, mode } }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e78196..f828853 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,9 @@ importers: specifier: ^9.6.0 version: 9.14.0 devDependencies: + '@hocuspocus/provider': + specifier: ^4.3.0 + version: 4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@types/node': specifier: ^26.1.0 version: 26.1.0 @@ -169,6 +172,9 @@ importers: vitest: specifier: ^3.0.0 version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) + yjs: + specifier: ^13.6.0 + version: 13.6.31 apps/web: dependencies: @@ -971,6 +977,12 @@ packages: '@hocuspocus/common@4.3.0': resolution: {integrity: sha512-8USsMvso01aMKOSSyvb8oTAlfES/nBM+Y74XjW5Fy5ovmOaVpoRRBrktIrEVwydrp8Cr6C66ivc0orcW/3P+Kg==} + '@hocuspocus/provider@4.3.0': + resolution: {integrity: sha512-eS5dECLnJDgELI2AfZNvr5jS0B6IWFoo7eAUgwwOzy1bf7KdPagXAYCtSWqSXmLMwjABlubZJQg8FVrTefU0cA==} + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + '@hocuspocus/server@4.3.0': resolution: {integrity: sha512-GX9ohUJAr6aIa2ewS0EPeyf3w12wLCBBSGfc76ZMOuZJZPUzDH6BJWFjkVlaVW3OmwZ5+jxdXXFUf6sAi4EIrg==} engines: {node: '>=22'} @@ -1165,6 +1177,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lifeomic/attempt@3.1.0': + resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -4599,6 +4614,14 @@ snapshots: dependencies: lib0: 0.2.117 + '@hocuspocus/provider@4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + '@hocuspocus/common': 4.3.0 + '@lifeomic/attempt': 3.1.0 + lib0: 0.2.117 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + '@hocuspocus/server@4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': dependencies: '@hocuspocus/common': 4.3.0 @@ -4796,6 +4819,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lifeomic/attempt@3.1.0': {} + '@lukeed/csprng@1.1.0': {} '@nestjs/cli@11.0.23(@swc/core@1.15.43)(@types/node@26.1.0)(prettier@3.9.4)':