dorfteich/packages/shared/src/token-crypto.ts
Claude Opus 4.8 d4ebcfcfbe
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
Add collaboration token issuance and connection authentication (#34)
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>
2026-07-08 15:52:19 +02:00

108 lines
3.7 KiB
TypeScript

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