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>
108 lines
3.7 KiB
TypeScript
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 } };
|
|
}
|