Add collaboration token issuance and connection authentication (#34)
All checks were successful
CD / Build and push images (push) Successful in 2m45s
CI / Lint, typecheck, test (push) Successful in 1m56s
CI / Auth e2e pack (push) Successful in 2m1s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 12s

The api mints a short-lived (60 s) HS256 JWT per page open after an interim
permission check; the collab server authenticates every connection with it
(ADR 0003/0007 — the only JWTs in the system).

- packages/shared: browser-safe token schema/types in `collab-token`, and the
  Node `crypto` sign/verify in `token-crypto` behind its own subpath export
  (`@dorfteich/shared/token-crypto`) so the web bundle never pulls in
  `node:crypto`. Only HS256 is produced/accepted; the signature is checked in
  constant time before any untrusted field is read.
- api: `GET /pages/:id/collab-token` (auth-required) returns
  {token, mode, expiresInSeconds}; `mode` is rw/ro via the interim access
  service; issuance is logged at debug level without the token value.
- collab: `onAuthenticate` verifies the token, checks the pageId matches the
  document name, stores {userId, mode} context, and enforces `ro` via
  Hocuspocus' read-only connection flag. Hocuspocus' own signal handling is
  disabled so index.ts remains the single shutdown owner.
- Shared COLLAB_TOKEN_SECRET env for api + collab (compose, dev overlay,
  .env.example, stage docs); a dev default keeps native dev/test/CI running.

Tests: shared token round-trip/rejection; api endpoint e2e (auth required,
claims, 404 for non-members/unknown ids); collab integration via
HocuspocusProvider (valid token connects; expired/tampered/mismatched-page/
wrong-secret rejected; read-only writes dropped, verified with two clients).

Closes #34

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.8 2026-07-08 15:52:19 +02:00
parent 8316c617d2
commit d4ebcfcfbe
20 changed files with 690 additions and 7 deletions

View File

@ -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<string> {
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);
});
});

View File

@ -12,6 +12,7 @@ import {
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
CollabTokenResponse,
CreatePageInput, CreatePageInput,
PageStateView, PageStateView,
PageView, PageView,
@ -51,6 +52,15 @@ export class PagesController {
return this.pages.getState(request.user!, id); 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<CollabTokenResponse> {
return this.pages.issueCollabToken(request.user!, id);
}
/** Markdown export (issue #30) — downloads `<slug>.md`. */ /** Markdown export (issue #30) — downloads `<slug>.md`. */
@Get('pages/:id/export/markdown') @Get('pages/:id/export/markdown')
async exportMarkdown( async exportMarkdown(

View File

@ -6,6 +6,7 @@ import {
PayloadTooLargeException, PayloadTooLargeException,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
CollabTokenResponse,
CreatePageInput, CreatePageInput,
MAX_PAGE_DOCUMENT_BYTES, MAX_PAGE_DOCUMENT_BYTES,
PageStateView, PageStateView,
@ -16,10 +17,12 @@ import {
pondSettingsSchema, pondSettingsSchema,
slugify, slugify,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { signCollabToken } from '@dorfteich/shared/token-crypto';
import { Page, Prisma, User } from '@prisma/client'; import { Page, Prisma, User } from '@prisma/client';
import { generateKeyBetween } from 'fractional-indexing'; import { generateKeyBetween } from 'fractional-indexing';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { InterimAccessService } from '../ponds/interim-access.service'; import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { 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() @Injectable()
export class PagesService { export class PagesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService, private readonly access: InterimAccessService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
private readonly config: AppConfig,
) { ) {
this.logger.setContext(PagesService.name); this.logger.setContext(PagesService.name);
} }
@ -161,6 +169,33 @@ export class PagesService {
return this.stateViewOf(page); 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<CollabTokenResponse> {
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<PageStateView> { async getStateBySlug(user: User, pondId: string, slug: string): Promise<PageStateView> {
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { pondId, slug }, where: { pondId, slug },

View File

@ -20,9 +20,11 @@
"pino": "^9.6.0" "pino": "^9.6.0"
}, },
"devDependencies": { "devDependencies": {
"@hocuspocus/provider": "^4.3.0",
"@types/node": "^26.1.0", "@types/node": "^26.1.0",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"vitest": "^3.0.0" "vitest": "^3.0.0",
"yjs": "^13.6.0"
} }
} }

View File

@ -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<void> {
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<typeof createCollabServer>;
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<string>('m');
const roMap = ro.doc.getMap<string>('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();
});
});

View File

@ -18,6 +18,7 @@ async function bootstrap(): Promise<void> {
const server = createCollabServer({ const server = createCollabServer({
version: env.APP_VERSION, version: env.APP_VERSION,
logger, logger,
tokenSecret: env.COLLAB_TOKEN_SECRET,
pingDatabase: () => pingDatabase(pool), pingDatabase: () => pingDatabase(pool),
}); });

View File

@ -2,6 +2,7 @@ import { pino } from 'pino';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { createCollabServer } from './server.js'; import { createCollabServer } from './server.js';
import { freePort } from './testing/free-port.js';
import type { CollabHealthReport, DatabaseProbe } from './health.js'; import type { CollabHealthReport, DatabaseProbe } from './health.js';
// A silent logger; these tests assert behaviour, not log output. // A silent logger; these tests assert behaviour, not log output.
@ -14,10 +15,16 @@ describe('collab server', () => {
const probe = vi.fn<() => Promise<DatabaseProbe>>(); const probe = vi.fn<() => Promise<DatabaseProbe>>();
beforeAll(async () => { beforeAll(async () => {
server = createCollabServer({ version: 'test-version', logger, pingDatabase: probe }); server = createCollabServer({
// Port 0 binds an ephemeral free port. version: 'test-version',
await server.listen(0); logger,
const { port } = server.address; 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}`; baseUrl = `http://127.0.0.1:${port}`;
wsUrl = `ws://127.0.0.1:${port}`; wsUrl = `ws://127.0.0.1:${port}`;
}); });

View File

@ -1,12 +1,21 @@
import { Server } from '@hocuspocus/server'; import { Server } from '@hocuspocus/server';
import { verifyCollabToken } from '@dorfteich/shared/token-crypto';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import { buildHealthReport, isHealthRequest, type DatabaseProbe } from './health.js'; 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 { export interface CollabServerDeps {
/** Version string surfaced in health responses (image build arg). */ /** Version string surfaced in health responses (image build arg). */
version: string; version: string;
logger: Logger; 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. */ /** Probe used by the `/healthz` endpoint. Injected so it is easy to test. */
pingDatabase: () => Promise<DatabaseProbe>; pingDatabase: () => Promise<DatabaseProbe>;
} }
@ -18,12 +27,49 @@ export interface CollabServerDeps {
* so any WebSocket handshake is currently accepted. * so any WebSocket handshake is currently accepted.
*/ */
export function createCollabServer(deps: CollabServerDeps): Server { export function createCollabServer(deps: CollabServerDeps): Server {
const { version, logger, pingDatabase } = deps; const { version, logger, tokenSecret, pingDatabase } = deps;
return new Server({ return new Server({
name: 'dorfteich-collab', name: 'dorfteich-collab',
// Suppress Hocuspocus' ASCII start screen; we emit our own pino logs. // Suppress Hocuspocus' ASCII start screen; we emit our own pino logs.
quiet: true, 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<CollabContext> {
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 }) { async onConnect({ documentName, socketId }) {
logger.info( logger.info(

View File

@ -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<number> {
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));
});
});
}

View File

@ -5,6 +5,11 @@
# PostgreSQL password for the `dorfteich` database user. # PostgreSQL password for the `dorfteich` database user.
POSTGRES_PASSWORD=change-me 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 ----------------------------------------------------------------- # --- images -----------------------------------------------------------------
# Image name prefix. Stages pull from the Gitea registry, e.g. # Image name prefix. Stages pull from the Gitea registry, e.g.
# gitea.101010.cloud/stwaidele/dorfteich — local builds use the default. # gitea.101010.cloud/stwaidele/dorfteich — local builds use the default.

View File

@ -41,6 +41,8 @@ services:
NODE_ENV: development NODE_ENV: development
PORT: '3000' PORT: '3000'
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:-dorfteich}@db:5432/dorfteich 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 ports: !override
- '127.0.0.1:3001:3000' - '127.0.0.1:3001:3000'
volumes: volumes:
@ -59,6 +61,8 @@ services:
NODE_ENV: development NODE_ENV: development
PORT: '3000' PORT: '3000'
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:-dorfteich}@db:5432/dorfteich 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 ports: !override
- '127.0.0.1:3002:3000' - '127.0.0.1:3002:3000'
volumes: volumes:

View File

@ -45,6 +45,9 @@ services:
PORT: '3000' PORT: '3000'
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich 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 # Public URL of this stage — e-mail links and the CSRF origin check
# depend on it matching what browsers actually use. # depend on it matching what browsers actually use.
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173} APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173}
@ -80,6 +83,8 @@ services:
PORT: '3000' PORT: '3000'
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich 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: ports:
# The host reverse proxy routes /collab here with WebSocket upgrade # The host reverse proxy routes /collab here with WebSocket upgrade
# (deployment.md, deploy/stages.md). # (deployment.md, deploy/stages.md).

View File

@ -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): each stage directory. Set per stage in `.env` (mode 600):
- `POSTGRES_PASSWORD`: unique random value per stage - `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` - `COMPOSE_PROJECT_NAME`: `dorfteich-test` / `dorfteich-int`
- `WEB_PORT`/`API_PORT`/`COLLAB_PORT`: 8100/8101/8102 (test), - `WEB_PORT`/`API_PORT`/`COLLAB_PORT`: 8100/8101/8102 (test),
8110/8111/8112 (int). **`COLLAB_PORT` must be set per stage** — both 8110/8111/8112 (int). **`COLLAB_PORT` must be set per stage** — both

View File

@ -13,14 +13,27 @@
"import": "./dist/index.mjs", "import": "./dist/index.mjs",
"require": "./dist/index.js" "require": "./dist/index.js"
}, },
"./token-crypto": {
"types": "./dist/token-crypto.d.ts",
"import": "./dist/token-crypto.mjs",
"require": "./dist/token-crypto.js"
},
"./i18n/*": "./i18n/*" "./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": [ "files": [
"dist", "dist",
"i18n" "i18n"
], ],
"scripts": { "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", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },

View File

@ -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' } });
});
});

View File

@ -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<typeof collabTokenModeSchema>;
/** 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<typeof collabTokenClaimsSchema>;
/** 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';
};

View File

@ -18,6 +18,14 @@ const databaseUrl = z
.refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), { .refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), {
message: 'must be a postgresql:// connection string', 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({ export const apiEnvSchema = z.object({
NODE_ENV: nodeEnv, NODE_ENV: nodeEnv,
@ -25,6 +33,7 @@ export const apiEnvSchema = z.object({
LOG_LEVEL: logLevel, LOG_LEVEL: logLevel,
APP_VERSION: appVersion, APP_VERSION: appVersion,
DATABASE_URL: databaseUrl, DATABASE_URL: databaseUrl,
COLLAB_TOKEN_SECRET: collabTokenSecret,
/** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */ /** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */
MIGRATE_ON_START: z MIGRATE_ON_START: z
.enum(['true', 'false']) .enum(['true', 'false'])
@ -68,6 +77,7 @@ export const collabEnvSchema = z.object({
LOG_LEVEL: logLevel, LOG_LEVEL: logLevel,
APP_VERSION: appVersion, APP_VERSION: appVersion,
DATABASE_URL: databaseUrl, DATABASE_URL: databaseUrl,
COLLAB_TOKEN_SECRET: collabTokenSecret,
}); });
export type CollabEnv = z.infer<typeof collabEnvSchema>; export type CollabEnv = z.infer<typeof collabEnvSchema>;

View File

@ -1,5 +1,6 @@
export * from './api-error'; export * from './api-error';
export * from './auth'; export * from './auth';
export * from './collab-token';
export * from './editor-schema'; export * from './editor-schema';
export * from './env'; export * from './env';
export * from './files'; export * from './files';

View File

@ -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 } };
}

25
pnpm-lock.yaml generated
View File

@ -157,6 +157,9 @@ importers:
specifier: ^9.6.0 specifier: ^9.6.0
version: 9.14.0 version: 9.14.0
devDependencies: 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': '@types/node':
specifier: ^26.1.0 specifier: ^26.1.0
version: 26.1.0 version: 26.1.0
@ -169,6 +172,9 @@ importers:
vitest: vitest:
specifier: ^3.0.0 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) 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: apps/web:
dependencies: dependencies:
@ -971,6 +977,12 @@ packages:
'@hocuspocus/common@4.3.0': '@hocuspocus/common@4.3.0':
resolution: {integrity: sha512-8USsMvso01aMKOSSyvb8oTAlfES/nBM+Y74XjW5Fy5ovmOaVpoRRBrktIrEVwydrp8Cr6C66ivc0orcW/3P+Kg==} 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': '@hocuspocus/server@4.3.0':
resolution: {integrity: sha512-GX9ohUJAr6aIa2ewS0EPeyf3w12wLCBBSGfc76ZMOuZJZPUzDH6BJWFjkVlaVW3OmwZ5+jxdXXFUf6sAi4EIrg==} resolution: {integrity: sha512-GX9ohUJAr6aIa2ewS0EPeyf3w12wLCBBSGfc76ZMOuZJZPUzDH6BJWFjkVlaVW3OmwZ5+jxdXXFUf6sAi4EIrg==}
engines: {node: '>=22'} engines: {node: '>=22'}
@ -1165,6 +1177,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lifeomic/attempt@3.1.0':
resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==}
'@lukeed/csprng@1.1.0': '@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -4599,6 +4614,14 @@ snapshots:
dependencies: dependencies:
lib0: 0.2.117 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)': '@hocuspocus/server@4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)':
dependencies: dependencies:
'@hocuspocus/common': 4.3.0 '@hocuspocus/common': 4.3.0
@ -4796,6 +4819,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@lifeomic/attempt@3.1.0': {}
'@lukeed/csprng@1.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)': '@nestjs/cli@11.0.23(@swc/core@1.15.43)(@types/node@26.1.0)(prettier@3.9.4)':