#188: purpose-bound token keys via HKDF + jose #239
72
apps/api/src/notifications/unsubscribe-token.test.ts
Normal file
72
apps/api/src/notifications/unsubscribe-token.test.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LEGACY_VERIFY_UNTIL,
|
||||||
|
signUnsubscribeToken,
|
||||||
|
verifyUnsubscribeToken,
|
||||||
|
} from './unsubscribe-token';
|
||||||
|
|
||||||
|
const secret = 'test-secret-at-least-16-chars-long';
|
||||||
|
const TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** A token as the pre-#188 implementation minted it: root secret + purpose prefix. */
|
||||||
|
function legacyToken(userId: string, now = Date.now()): string {
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({ userId, exp: Math.floor(now / 1000) + 90 * 24 * 60 * 60 }),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url');
|
||||||
|
const sig = createHmac('sha256', secret).update(`digest-unsubscribe.${body}`).digest('base64url');
|
||||||
|
return `${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('unsubscribe token', () => {
|
||||||
|
it('round-trips a user id', () => {
|
||||||
|
expect(verifyUnsubscribeToken(signUnsubscribeToken('u1', secret), secret)).toBe('u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an expired token', () => {
|
||||||
|
const token = signUnsubscribeToken('u1', secret);
|
||||||
|
expect(verifyUnsubscribeToken(token, secret, Date.now() + TTL_MS + 1000)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects garbage and a wrong secret', () => {
|
||||||
|
expect(verifyUnsubscribeToken('nonsense', secret)).toBeNull();
|
||||||
|
expect(verifyUnsubscribeToken('a.b', secret)).toBeNull();
|
||||||
|
const token = signUnsubscribeToken('u1', secret);
|
||||||
|
expect(verifyUnsubscribeToken(token, 'another-secret-16-chars')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token HMACed with a different purpose's subkey (ADR 0020)", () => {
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({ userId: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url');
|
||||||
|
const sig = createHmac('sha256', deriveTokenKey(secret, 'collab'))
|
||||||
|
.update(body)
|
||||||
|
.digest('base64url');
|
||||||
|
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a pre-separation legacy token inside the dual-verify window', () => {
|
||||||
|
const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000;
|
||||||
|
expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe(
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a legacy token once the dual-verify window has closed', () => {
|
||||||
|
const afterWindow = LEGACY_VERIFY_UNTIL + 1000;
|
||||||
|
// Unexpired on its own terms — rejected purely because the window closed.
|
||||||
|
const token = legacyToken('u1', afterWindow - 1000);
|
||||||
|
expect(verifyUnsubscribeToken(token, secret, afterWindow)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies freshly minted tokens via the subkey, independent of the window', () => {
|
||||||
|
const afterWindow = LEGACY_VERIFY_UNTIL + 24 * 60 * 60 * 1000;
|
||||||
|
const token = signUnsubscribeToken('u1', secret, afterWindow);
|
||||||
|
expect(verifyUnsubscribeToken(token, secret, afterWindow + 1000)).toBe('u1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,31 +1,54 @@
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-purpose unsubscribe tokens for the digest mails (issue #95): a
|
* Single-purpose unsubscribe tokens for the digest mails (issue #95): a
|
||||||
* signed `{userId, exp}` blob whose only power is flipping that user's
|
* signed `{userId, exp}` blob whose only power is flipping that user's
|
||||||
* digest setting to `off` — it never creates a session (ADR 0007's "signed
|
* digest setting to `off` — it never creates a session (ADR 0007's "signed
|
||||||
* tokens" pattern, purpose-bound so it cannot be replayed anywhere else).
|
* tokens" pattern). Purpose-binding is structural since #188 (ADR 0020):
|
||||||
|
* the HMAC key is the HKDF subkey for `unsubscribe`, so a token signed for
|
||||||
|
* any other purpose cannot verify here regardless of its content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PURPOSE = 'digest-unsubscribe';
|
|
||||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||||
|
|
||||||
function signature(body: string, secret: string): Buffer {
|
/**
|
||||||
return createHmac('sha256', secret).update(`${PURPOSE}.${body}`).digest();
|
* Dual-verify window (#188, ADR 0020): before the key separation, tokens
|
||||||
|
* were HMACed with the root secret over a `digest-unsubscribe.` prefix.
|
||||||
|
* Those links live in digest mails that are already sent and stay valid
|
||||||
|
* for their full 90-day TTL, so verification accepts the legacy derivation
|
||||||
|
* until every pre-separation token has expired. Tokens are only ever
|
||||||
|
* SIGNED with the new subkey; the legacy path is verify-only and goes dead
|
||||||
|
* automatically on the date below (last possible legacy expiry, rounded up).
|
||||||
|
*/
|
||||||
|
export const LEGACY_VERIFY_UNTIL = Date.parse('2026-11-01T00:00:00Z');
|
||||||
|
const LEGACY_PURPOSE = 'digest-unsubscribe';
|
||||||
|
|
||||||
|
function signature(body: string, rootSecret: string): Buffer {
|
||||||
|
return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signUnsubscribeToken(userId: string, secret: string, now = Date.now()): string {
|
function legacySignature(body: string, rootSecret: string): Buffer {
|
||||||
|
return createHmac('sha256', rootSecret).update(`${LEGACY_PURPOSE}.${body}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signUnsubscribeToken(userId: string, rootSecret: string, now = Date.now()): string {
|
||||||
const body = Buffer.from(
|
const body = Buffer.from(
|
||||||
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
|
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
|
||||||
'utf8',
|
'utf8',
|
||||||
).toString('base64url');
|
).toString('base64url');
|
||||||
return `${body}.${signature(body, secret).toString('base64url')}`;
|
return `${body}.${signature(body, rootSecret).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(provided: Buffer, expected: Buffer): boolean {
|
||||||
|
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The user id, or null for anything invalid or expired. Never throws. */
|
/** The user id, or null for anything invalid or expired. Never throws. */
|
||||||
export function verifyUnsubscribeToken(
|
export function verifyUnsubscribeToken(
|
||||||
token: string,
|
token: string,
|
||||||
secret: string,
|
rootSecret: string,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
): string | null {
|
): string | null {
|
||||||
const [body, sig] = token.split('.');
|
const [body, sig] = token.split('.');
|
||||||
@ -36,8 +59,10 @@ export function verifyUnsubscribeToken(
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const expected = signature(body, secret);
|
const current = matches(provided, signature(body, rootSecret));
|
||||||
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null;
|
const legacy =
|
||||||
|
!current && now < LEGACY_VERIFY_UNTIL && matches(provided, legacySignature(body, rootSecret));
|
||||||
|
if (!current && !legacy) return null;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
@ -104,7 +104,7 @@ describe.skipIf(!hasTestDb)('collab token (e2e, issue #34)', () => {
|
|||||||
expect(res.body.mode).toBe('rw');
|
expect(res.body.mode).toBe('rw');
|
||||||
expect(res.body.expiresInSeconds).toBe(60);
|
expect(res.body.expiresInSeconds).toBe(60);
|
||||||
|
|
||||||
const verified = verifyCollabToken(res.body.token, secret);
|
const verified = await verifyCollabToken(res.body.token, secret);
|
||||||
expect(verified.valid).toBe(true);
|
expect(verified.valid).toBe(true);
|
||||||
if (verified.valid) {
|
if (verified.valid) {
|
||||||
expect(collabTokenClaimsSchema.parse(verified.claims)).toEqual({
|
expect(collabTokenClaimsSchema.parse(verified.claims)).toEqual({
|
||||||
|
|||||||
@ -378,7 +378,7 @@ export class PagesService {
|
|||||||
const canWrite = await this.permissions.canAccessPage(user, page, 'write');
|
const canWrite = await this.permissions.canAccessPage(user, page, 'write');
|
||||||
const mode = canWrite ? 'rw' : 'ro';
|
const mode = canWrite ? 'rw' : 'ro';
|
||||||
const userId = user?.id ?? null;
|
const userId = user?.id ?? null;
|
||||||
const token = signCollabToken(
|
const token = await signCollabToken(
|
||||||
{ userId, pageId: page.id, mode },
|
{ userId, pageId: page.id, mode },
|
||||||
this.config.env.COLLAB_TOKEN_SECRET,
|
this.config.env.COLLAB_TOKEN_SECRET,
|
||||||
COLLAB_TOKEN_TTL_SECONDS,
|
COLLAB_TOKEN_TTL_SECONDS,
|
||||||
|
|||||||
@ -108,11 +108,8 @@ describe.skipIf(!url)('access listener (DB-backed)', () => {
|
|||||||
// A string reuses one token; a function is re-invoked on every (re)connect,
|
// A string reuses one token; a function is re-invoked on every (re)connect,
|
||||||
// exactly as the web client re-fetches from the api — which is how a
|
// exactly as the web client re-fetches from the api — which is how a
|
||||||
// downgrade to `ro` takes effect on reconnect (issue #53).
|
// downgrade to `ro` takes effect on reconnect (issue #53).
|
||||||
token: string | (() => string) = signCollabToken(
|
token: string | (() => Promise<string>) = () =>
|
||||||
{ userId, pageId: name, mode: 'rw' },
|
signCollabToken({ userId, pageId: name, mode: 'rw' }, secret, 60),
|
||||||
secret,
|
|
||||||
60,
|
|
||||||
),
|
|
||||||
): {
|
): {
|
||||||
provider: HocuspocusProvider;
|
provider: HocuspocusProvider;
|
||||||
doc: Y.Doc;
|
doc: Y.Doc;
|
||||||
|
|||||||
@ -83,7 +83,11 @@ describe('collab authentication', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('accepts a valid token whose page matches the document', async () => {
|
it('accepts a valid token whose page matches the document', async () => {
|
||||||
const token = signCollabToken({ userId: 'u1', pageId: 'page-ok', mode: 'rw' }, secret, 60);
|
const token = await signCollabToken(
|
||||||
|
{ userId: 'u1', pageId: 'page-ok', mode: 'rw' },
|
||||||
|
secret,
|
||||||
|
60,
|
||||||
|
);
|
||||||
const client = connect('page-ok', token);
|
const client = connect('page-ok', token);
|
||||||
await waitFor(client.authenticated);
|
await waitFor(client.authenticated);
|
||||||
expect(client.authenticated()).toBe(true);
|
expect(client.authenticated()).toBe(true);
|
||||||
@ -93,7 +97,7 @@ describe('collab authentication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const claims: CollabTokenClaims = { userId: 'u1', pageId: 'page-x', mode: 'rw' };
|
const claims: CollabTokenClaims = { userId: 'u1', pageId: 'page-x', mode: 'rw' };
|
||||||
const rejectionCases: { label: string; name: string; token: () => string }[] = [
|
const rejectionCases: { label: string; name: string; token: () => Promise<string> }[] = [
|
||||||
{
|
{
|
||||||
label: 'an expired token',
|
label: 'an expired token',
|
||||||
name: 'page-x',
|
name: 'page-x',
|
||||||
@ -102,8 +106,8 @@ describe('collab authentication', () => {
|
|||||||
{
|
{
|
||||||
label: 'a tampered token',
|
label: 'a tampered token',
|
||||||
name: 'page-x',
|
name: 'page-x',
|
||||||
token: () => {
|
token: async () => {
|
||||||
const [h, p, s = ''] = signCollabToken(claims, secret, 60).split('.');
|
const [h, p, s = ''] = (await signCollabToken(claims, secret, 60)).split('.');
|
||||||
// Flip the last character of the signature.
|
// Flip the last character of the signature.
|
||||||
const flipped = s.slice(0, -1) + (s.endsWith('A') ? 'B' : 'A');
|
const flipped = s.slice(0, -1) + (s.endsWith('A') ? 'B' : 'A');
|
||||||
return `${h}.${p}.${flipped}`;
|
return `${h}.${p}.${flipped}`;
|
||||||
@ -122,7 +126,7 @@ describe('collab authentication', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
it.each(rejectionCases)('rejects $label', async ({ name, token }) => {
|
it.each(rejectionCases)('rejects $label', async ({ name, token }) => {
|
||||||
const client = connect(name, token());
|
const client = connect(name, await token());
|
||||||
await waitFor(client.failed);
|
await waitFor(client.failed);
|
||||||
expect(client.failed()).toBe(true);
|
expect(client.failed()).toBe(true);
|
||||||
expect(client.authenticated()).toBe(false);
|
expect(client.authenticated()).toBe(false);
|
||||||
@ -134,11 +138,11 @@ describe('collab authentication', () => {
|
|||||||
const page = 'page-shared';
|
const page = 'page-shared';
|
||||||
const rw = connect(
|
const rw = connect(
|
||||||
page,
|
page,
|
||||||
signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60),
|
await signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60),
|
||||||
);
|
);
|
||||||
const ro = connect(
|
const ro = connect(
|
||||||
page,
|
page,
|
||||||
signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60),
|
await signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60),
|
||||||
);
|
);
|
||||||
await waitFor(() => rw.synced() && ro.synced());
|
await waitFor(() => rw.synced() && ro.synced());
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,8 @@ describe('collab persistence hooks', () => {
|
|||||||
|
|
||||||
function connect(url: string, pageId: string): { provider: HocuspocusProvider; doc: Y.Doc } {
|
function connect(url: string, pageId: string): { provider: HocuspocusProvider; doc: Y.Doc } {
|
||||||
const doc = new Y.Doc();
|
const doc = new Y.Doc();
|
||||||
const token = signCollabToken({ userId: `u-${pageId}`, pageId, mode: 'rw' }, secret, 60);
|
const token = (): Promise<string> =>
|
||||||
|
signCollabToken({ userId: `u-${pageId}`, pageId, mode: 'rw' }, secret, 60);
|
||||||
const provider = new HocuspocusProvider({ url, name: pageId, document: doc, token });
|
const provider = new HocuspocusProvider({ url, name: pageId, document: doc, token });
|
||||||
cleanups.push(() => provider.destroy());
|
cleanups.push(() => provider.destroy());
|
||||||
return { provider, doc };
|
return { provider, doc };
|
||||||
|
|||||||
@ -120,7 +120,7 @@ describe.skipIf(!url)('restore listener (DB-backed, issue #42)', () => {
|
|||||||
url: wsUrl,
|
url: wsUrl,
|
||||||
name: pageId,
|
name: pageId,
|
||||||
document: doc,
|
document: doc,
|
||||||
token: signCollabToken({ userId, pageId, mode: 'rw' }, secret, 60),
|
token: await signCollabToken({ userId, pageId, mode: 'rw' }, secret, 60),
|
||||||
});
|
});
|
||||||
await waitFor(() => provider.isSynced);
|
await waitFor(() => provider.isSynced);
|
||||||
|
|
||||||
|
|||||||
@ -113,7 +113,7 @@ export function createCollabServer(deps: CollabServerDeps): Server {
|
|||||||
);
|
);
|
||||||
throw new Error('maintenance');
|
throw new Error('maintenance');
|
||||||
}
|
}
|
||||||
const result = verifyCollabToken(token, tokenSecret);
|
const result = await verifyCollabToken(token, tokenSecret);
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ event: 'auth.rejected', documentName, reason: result.reason },
|
{ event: 'auth.rejected', documentName, reason: result.reason },
|
||||||
|
|||||||
@ -5,9 +5,12 @@
|
|||||||
# 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).
|
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
|
||||||
# The api and collab services share this one value; use a long random string
|
# purpose (collaboration tokens, digest unsubscribe links) derives its own
|
||||||
# (e.g. `openssl rand -base64 32`). Min length 16.
|
# HKDF subkey from this value — nothing signs with it directly. The api and
|
||||||
|
# collab services share this one value; use a long random string
|
||||||
|
# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all
|
||||||
|
# derived keys at once and invalidates outstanding tokens.
|
||||||
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
|
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
# --- images -----------------------------------------------------------------
|
# --- images -----------------------------------------------------------------
|
||||||
|
|||||||
@ -66,13 +66,27 @@ or sloppy plugin authors, compromised dependencies.
|
|||||||
|
|
||||||
## Secrets & configuration
|
## Secrets & configuration
|
||||||
|
|
||||||
- Secrets (DB password, collab signing key, SMTP credentials) live only in
|
- Secrets (DB password, token root key, SMTP credentials) live only in
|
||||||
the stage `.env` (mode 600, never in git) and container env — not in the
|
the stage `.env` (mode 600, never in git) and container env — not in the
|
||||||
database (`instance_settings` stores non-secret config; the SMTP password
|
database (`instance_settings` stores non-secret config; the SMTP password
|
||||||
entered in the setup wizard is written to the env-backed secret store,
|
entered in the setup wizard is written to the env-backed secret store,
|
||||||
not to a DB row).
|
not to a DB row).
|
||||||
- Key rotation: collab signing key and session pepper rotate via env change
|
- Token key hierarchy (ADR 0020, issue #188): `COLLAB_TOKEN_SECRET` is a
|
||||||
plus rolling restart; procedure documented in `operations.md` runbooks.
|
ROOT key. Each token purpose uses its own HKDF-SHA-256 subkey
|
||||||
|
(`deriveTokenKey` in `packages/shared/src/token-crypto.ts`): `collab`
|
||||||
|
for the collaboration JWTs (signed and verified by `jose`, HS256 as an
|
||||||
|
explicit allowlist), `unsubscribe` for the digest unsubscribe links. No
|
||||||
|
code path signs with the root key directly, so a compromise of one
|
||||||
|
purpose's tokens is not transferable to the other.
|
||||||
|
- Dual-verify window: unsubscribe links minted before the key separation
|
||||||
|
live in already-sent mail (90-day TTL). Verification accepts the legacy
|
||||||
|
derivation (root key + purpose prefix) until **2026-11-01**
|
||||||
|
(`LEGACY_VERIFY_UNTIL` in `apps/api/src/notifications/unsubscribe-token.ts`),
|
||||||
|
after which the legacy path goes dead automatically. New tokens are only
|
||||||
|
ever signed with the subkey.
|
||||||
|
- Key rotation: rotating the root key rotates every derived subkey at once
|
||||||
|
(desired: one secret to rotate) via env change plus rolling restart;
|
||||||
|
procedure documented in `operations.md` runbooks.
|
||||||
- Dependencies: lockfile-pinned; monthly update batch; images pinned to
|
- Dependencies: lockfile-pinned; monthly update batch; images pinned to
|
||||||
digests in Prod.
|
digests in Prod.
|
||||||
|
|
||||||
|
|||||||
@ -84,9 +84,9 @@ _Meilensteine: `M24 — VS-NfD: security quick wins`; die nachgezogenen
|
|||||||
Punkte (#199, #200, #201, #202) in `M25 — VS-NfD: hardening & supply
|
Punkte (#199, #200, #201, #202) in `M25 — VS-NfD: hardening & supply
|
||||||
chain`_
|
chain`_
|
||||||
|
|
||||||
- [ ] **Schlüsseltrennung `COLLAB_TOKEN_SECRET`** per HKDF (zweckgebundene
|
- [x] **Schlüsseltrennung `COLLAB_TOKEN_SECRET`** per HKDF (zweckgebundene
|
||||||
Subkeys) — echter Fund, vor allen Features · 1–2 AT · #188
|
Subkeys) — echter Fund, vor allen Features · 1–2 AT · #188
|
||||||
- [ ] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +2–3 AT · #188 ⟵ neu aus Roadmap
|
- [x] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +2–3 AT · #188 ⟵ neu aus Roadmap
|
||||||
_Gebündelt mit der Zeile darüber, weil dieselbe Datei
|
_Gebündelt mit der Zeile darüber, weil dieselbe Datei
|
||||||
(`packages/shared/src/token-crypto.ts`). Einzeln wären es 5–6 AT._
|
(`packages/shared/src/token-crypto.ts`). Einzeln wären es 5–6 AT._
|
||||||
Achtung: Unsubscribe-Tokens leben lang in versandten Mails →
|
Achtung: Unsubscribe-Tokens leben lang in versandten Mails →
|
||||||
|
|||||||
@ -46,6 +46,7 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jose": "^6.2.4",
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
"prosemirror-markdown": "^1.13.4",
|
"prosemirror-markdown": "^1.13.4",
|
||||||
"prosemirror-model": "^1.25.9",
|
"prosemirror-model": "^1.25.9",
|
||||||
|
|||||||
@ -1,64 +1,122 @@
|
|||||||
|
import { SignJWT } from 'jose';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { CollabTokenClaims } from './collab-token';
|
import type { CollabTokenClaims } from './collab-token';
|
||||||
import { signCollabToken, verifyCollabToken } from './token-crypto';
|
import { deriveTokenKey, signCollabToken, verifyCollabToken } from './token-crypto';
|
||||||
|
|
||||||
const secret = 'test-secret-at-least-16-chars-long';
|
const secret = 'test-secret-at-least-16-chars-long';
|
||||||
const claims: CollabTokenClaims = { userId: 'user-1', pageId: 'page-1', mode: 'rw' };
|
const claims: CollabTokenClaims = { userId: 'user-1', pageId: 'page-1', mode: 'rw' };
|
||||||
|
|
||||||
|
/** Sign the claims with an arbitrary key — for cross-purpose/root-key forgeries. */
|
||||||
|
async function signWithKey(key: Uint8Array): Promise<string> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return await new SignJWT({ ...claims })
|
||||||
|
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpirationTime(now + 60)
|
||||||
|
.sign(key);
|
||||||
|
}
|
||||||
|
|
||||||
describe('collab token', () => {
|
describe('collab token', () => {
|
||||||
it('round-trips valid claims', () => {
|
it('round-trips valid claims', async () => {
|
||||||
const token = signCollabToken(claims, secret, 60);
|
const token = await signCollabToken(claims, secret, 60);
|
||||||
const result = verifyCollabToken(token, secret);
|
const result = await verifyCollabToken(token, secret);
|
||||||
expect(result).toEqual({ valid: true, claims });
|
expect(result).toEqual({ valid: true, claims });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a token signed with a different secret', () => {
|
it('rejects a token signed with a different secret', async () => {
|
||||||
const token = signCollabToken(claims, secret, 60);
|
const token = await signCollabToken(claims, secret, 60);
|
||||||
expect(verifyCollabToken(token, 'another-secret-16-chars')).toEqual({
|
expect(await verifyCollabToken(token, 'another-secret-16-chars')).toEqual({
|
||||||
valid: false,
|
valid: false,
|
||||||
reason: 'bad_signature',
|
reason: 'bad_signature',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a tampered payload', () => {
|
it("rejects a token signed with a different purpose's subkey (ADR 0020)", async () => {
|
||||||
const token = signCollabToken(claims, secret, 60);
|
const forged = await signWithKey(deriveTokenKey(secret, 'unsubscribe'));
|
||||||
|
expect(await verifyCollabToken(forged, secret)).toEqual({
|
||||||
|
valid: false,
|
||||||
|
reason: 'bad_signature',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token signed with the raw root secret — no code path signs with it', async () => {
|
||||||
|
const forged = await signWithKey(new TextEncoder().encode(secret));
|
||||||
|
expect(await verifyCollabToken(forged, secret)).toEqual({
|
||||||
|
valid: false,
|
||||||
|
reason: 'bad_signature',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered payload', async () => {
|
||||||
|
const token = await signCollabToken(claims, secret, 60);
|
||||||
const [header, , signature] = token.split('.');
|
const [header, , signature] = token.split('.');
|
||||||
const forgedPayload = Buffer.from(
|
const forgedPayload = Buffer.from(
|
||||||
JSON.stringify({ ...claims, mode: 'rw', iat: 0, exp: 9999999999 }),
|
JSON.stringify({ ...claims, mode: 'rw', iat: 0, exp: 9999999999 }),
|
||||||
'utf8',
|
'utf8',
|
||||||
).toString('base64url');
|
).toString('base64url');
|
||||||
const forged = `${header}.${forgedPayload}.${signature}`;
|
const forged = `${header}.${forgedPayload}.${signature}`;
|
||||||
expect(verifyCollabToken(forged, secret).valid).toBe(false);
|
expect((await verifyCollabToken(forged, secret)).valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an expired token', () => {
|
it('rejects an expired token', async () => {
|
||||||
const token = signCollabToken(claims, secret, 60);
|
const token = await signCollabToken(claims, secret, 60);
|
||||||
// 61 seconds later the 60 s token is past its expiry.
|
// 61 seconds later the 60 s token is past its expiry.
|
||||||
const later = Date.now() + 61_000;
|
const later = Date.now() + 61_000;
|
||||||
expect(verifyCollabToken(token, secret, later)).toEqual({ valid: false, reason: 'expired' });
|
expect(await verifyCollabToken(token, secret, later)).toEqual({
|
||||||
|
valid: false,
|
||||||
|
reason: 'expired',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a malformed token', () => {
|
it('rejects a malformed token', async () => {
|
||||||
expect(verifyCollabToken('not-a-jwt', secret)).toEqual({ valid: false, reason: 'malformed' });
|
expect(await verifyCollabToken('not-a-jwt', secret)).toEqual({
|
||||||
expect(verifyCollabToken('a.b', secret).valid).toBe(false);
|
valid: false,
|
||||||
|
reason: 'malformed',
|
||||||
|
});
|
||||||
|
expect((await verifyCollabToken('a.b', secret)).valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an alg:none token even if the signature check is bypassed', () => {
|
it('rejects alg:none — HS256 is an explicit allowlist', async () => {
|
||||||
// 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 header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
|
||||||
const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString(
|
const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString(
|
||||||
'base64url',
|
'base64url',
|
||||||
);
|
);
|
||||||
const result = verifyCollabToken(`${header}.${payload}.`, secret);
|
const result = await verifyCollabToken(`${header}.${payload}.`, secret);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves the read-only mode claim', () => {
|
it('rejects an RS256 header before any signature check', async () => {
|
||||||
const roToken = signCollabToken({ ...claims, mode: 'ro' }, secret, 60);
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||||
const result = verifyCollabToken(roToken, secret);
|
const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString(
|
||||||
|
'base64url',
|
||||||
|
);
|
||||||
|
const result = await verifyCollabToken(`${header}.${payload}.AAAA`, secret);
|
||||||
|
expect(result).toEqual({ valid: false, reason: 'bad_algorithm' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a signed token without an expiry claim', async () => {
|
||||||
|
const token = await new SignJWT({ ...claims })
|
||||||
|
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.sign(deriveTokenKey(secret, 'collab'));
|
||||||
|
expect(await verifyCollabToken(token, secret)).toEqual({
|
||||||
|
valid: false,
|
||||||
|
reason: 'invalid_claims',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the read-only mode claim', async () => {
|
||||||
|
const roToken = await signCollabToken({ ...claims, mode: 'ro' }, secret, 60);
|
||||||
|
const result = await verifyCollabToken(roToken, secret);
|
||||||
expect(result).toEqual({ valid: true, claims: { ...claims, mode: 'ro' } });
|
expect(result).toEqual({ valid: true, claims: { ...claims, mode: 'ro' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives distinct, stable subkeys per purpose', () => {
|
||||||
|
const collab = deriveTokenKey(secret, 'collab');
|
||||||
|
const unsubscribe = deriveTokenKey(secret, 'unsubscribe');
|
||||||
|
expect(collab).toHaveLength(32);
|
||||||
|
expect(Buffer.from(collab).equals(Buffer.from(unsubscribe))).toBe(false);
|
||||||
|
expect(Buffer.from(collab).equals(Buffer.from(deriveTokenKey(secret, 'collab')))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import { z } from 'zod';
|
|||||||
* Types and schemas for the short-lived collaboration tokens (issue #34,
|
* 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
|
* 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
|
* 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
|
* in `./token-crypto` (HKDF subkeys + `jose`, ADR 0020) and are imported only
|
||||||
* collab server.
|
* by the api and the collab server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const collabTokenModeSchema = z.enum(['rw', 'ro']);
|
export const collabTokenModeSchema = z.enum(['rw', 'ro']);
|
||||||
|
|||||||
67
packages/shared/src/token-crypto.crossruntime.test.ts
Normal file
67
packages/shared/src/token-crypto.crossruntime.test.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reason the homegrown JWT existed was the guarantee that the identical
|
||||||
|
* code runs in the CommonJS api and the ESM collab server (ADR 0020). With
|
||||||
|
* `jose` that property must be PROVEN, not assumed — jose v6 is ESM-only
|
||||||
|
* and reaches CJS via Node's `require(esm)`. This test loads the actually
|
||||||
|
* built dist artefacts (what api and collab load at runtime) in child
|
||||||
|
* processes of both module systems and round-trips a token in each
|
||||||
|
* direction. Requires a current `pnpm --filter @dorfteich/shared build`
|
||||||
|
* (CI builds before testing).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const distCjs = path.resolve(here, '../dist/token-crypto.js');
|
||||||
|
const distEsm = path.resolve(here, '../dist/token-crypto.mjs');
|
||||||
|
|
||||||
|
const secret = 'cross-runtime-secret-32-characters';
|
||||||
|
const claims = { userId: 'user-x', pageId: 'page-x', mode: 'rw' } as const;
|
||||||
|
|
||||||
|
function run(args: string[], extraEnv: Record<string, string>): string {
|
||||||
|
return execFileSync(process.execPath, args, {
|
||||||
|
cwd: path.resolve(here, '..'),
|
||||||
|
env: { ...process.env, SECRET: secret, ...extraEnv },
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const signCjs = `require(process.env.DIST).signCollabToken(JSON.parse(process.env.CLAIMS), process.env.SECRET, 60).then((t) => process.stdout.write(t));`;
|
||||||
|
const verifyCjs = `require(process.env.DIST).verifyCollabToken(process.env.TOKEN, process.env.SECRET).then((r) => process.stdout.write(JSON.stringify(r)));`;
|
||||||
|
const signEsm = `const m = await import(process.env.DIST_URL); process.stdout.write(await m.signCollabToken(JSON.parse(process.env.CLAIMS), process.env.SECRET, 60));`;
|
||||||
|
const verifyEsm = `const m = await import(process.env.DIST_URL); process.stdout.write(JSON.stringify(await m.verifyCollabToken(process.env.TOKEN, process.env.SECRET)));`;
|
||||||
|
|
||||||
|
describe('token crypto across module systems (built dist)', () => {
|
||||||
|
it('has a built dist to test against', () => {
|
||||||
|
for (const file of [distCjs, distEsm]) {
|
||||||
|
if (!existsSync(file)) {
|
||||||
|
throw new Error(`${file} missing — run \`pnpm --filter @dorfteich/shared build\` first`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies a CommonJS-signed token in an ESM runtime', () => {
|
||||||
|
const token = run(['-e', signCjs], { DIST: distCjs, CLAIMS: JSON.stringify(claims) });
|
||||||
|
expect(token.split('.')).toHaveLength(3);
|
||||||
|
const result = run(['--input-type=module', '-e', verifyEsm], {
|
||||||
|
DIST_URL: pathToFileURL(distEsm).href,
|
||||||
|
TOKEN: token,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(result)).toEqual({ valid: true, claims });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies an ESM-signed token in a CommonJS runtime', () => {
|
||||||
|
const token = run(['--input-type=module', '-e', signEsm], {
|
||||||
|
DIST_URL: pathToFileURL(distEsm).href,
|
||||||
|
CLAIMS: JSON.stringify(claims),
|
||||||
|
});
|
||||||
|
expect(token.split('.')).toHaveLength(3);
|
||||||
|
const result = run(['-e', verifyCjs], { DIST: distCjs, TOKEN: token });
|
||||||
|
expect(JSON.parse(result)).toEqual({ valid: true, claims });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { hkdfSync } from 'node:crypto';
|
||||||
|
|
||||||
|
import { errors as joseErrors, jwtVerify, SignJWT } from 'jose';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -9,91 +10,87 @@ import {
|
|||||||
} from './collab-token';
|
} from './collab-token';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign and verify collaboration tokens (issue #34, ADR 0007). These are the
|
* Purpose-bound token keys and collaboration-token signing (issue #34,
|
||||||
* ONLY JWTs in the system. Implemented with `node:crypto` rather than a library
|
* ADR 0007; key separation and `jose` per ADR 0020, issue #188).
|
||||||
* 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
|
* The configured `COLLAB_TOKEN_SECRET` is a ROOT key: every purpose derives
|
||||||
* accepted; the signature is checked in constant time before any untrusted
|
* its own subkey via HKDF-SHA-256, and no code path signs with the root key
|
||||||
* field is read.
|
* directly. A token signed for one purpose cannot verify under another
|
||||||
|
* because the keys differ — the separation is structural, not a string
|
||||||
|
* prefix in the signed payload.
|
||||||
|
*
|
||||||
|
* Signing and verification go through `jose` (vetted, dependency-free)
|
||||||
|
* with HS256 as an explicit algorithm allowlist. The same module must work
|
||||||
|
* in the CommonJS api and the ESM collab server — proven by
|
||||||
|
* `token-crypto.crossruntime.test.ts` rather than assumed (jose v6 is
|
||||||
|
* ESM-only and reaches CJS via Node's `require(esm)`, available in the
|
||||||
|
* pinned Node 22 images). This module pulls in Node built-ins, so it lives
|
||||||
|
* outside the browser-safe package barrel and is imported via
|
||||||
|
* `@dorfteich/shared/token-crypto`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const HEADER = { alg: 'HS256', typ: 'JWT' } as const;
|
/** Every purpose a subkey is derived for. Add here — never reuse a key. */
|
||||||
|
export type TokenPurpose = 'collab' | 'unsubscribe';
|
||||||
|
|
||||||
|
/** Fixed HKDF salt: domain-separates this application's key hierarchy. */
|
||||||
|
const HKDF_SALT = 'dorfteich-token-keys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the 32-byte subkey for `purpose` from the configured root secret.
|
||||||
|
* Deterministic: rotation of the root secret rotates every subkey at once,
|
||||||
|
* which is the intended behaviour (ADR 0020).
|
||||||
|
*/
|
||||||
|
export function deriveTokenKey(rootSecret: string, purpose: TokenPurpose): Uint8Array {
|
||||||
|
return new Uint8Array(hkdfSync('sha256', rootSecret, HKDF_SALT, `dorfteich/${purpose}/v1`, 32));
|
||||||
|
}
|
||||||
|
|
||||||
/** Full JWT payload: the claims plus standard `iat`/`exp` (seconds since epoch). */
|
/** Full JWT payload: the claims plus standard `iat`/`exp` (seconds since epoch). */
|
||||||
const payloadSchema = collabTokenClaimsSchema.extend({
|
const payloadSchema = collabTokenClaimsSchema.extend({
|
||||||
iat: z.number().int().nonnegative(),
|
iat: z.number().int().nonnegative(),
|
||||||
exp: z.number().int().nonnegative(),
|
exp: z.number().int(),
|
||||||
});
|
});
|
||||||
|
|
||||||
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. */
|
/** Sign a collaboration token that expires `ttlSeconds` from now. */
|
||||||
export function signCollabToken(
|
export async function signCollabToken(
|
||||||
claims: CollabTokenClaims,
|
claims: CollabTokenClaims,
|
||||||
secret: string,
|
rootSecret: string,
|
||||||
ttlSeconds: number,
|
ttlSeconds: number,
|
||||||
): string {
|
): Promise<string> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const payload = { ...collabTokenClaimsSchema.parse(claims), iat: now, exp: now + ttlSeconds };
|
return await new SignJWT({ ...collabTokenClaimsSchema.parse(claims) })
|
||||||
const signingInput = `${b64url(JSON.stringify(HEADER))}.${b64url(JSON.stringify(payload))}`;
|
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
||||||
return `${signingInput}.${hmac(signingInput, secret)}`;
|
.setIssuedAt(now)
|
||||||
|
.setExpirationTime(now + ttlSeconds)
|
||||||
|
.sign(deriveTokenKey(rootSecret, 'collab'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a `jose` verification error onto the stable rejection reasons. */
|
||||||
|
function reasonOf(error: unknown): Exclude<CollabTokenVerification, { valid: true }>['reason'] {
|
||||||
|
if (error instanceof joseErrors.JWTExpired) return 'expired';
|
||||||
|
if (error instanceof joseErrors.JOSEAlgNotAllowed) return 'bad_algorithm';
|
||||||
|
if (error instanceof joseErrors.JWSSignatureVerificationFailed) return 'bad_signature';
|
||||||
|
if (error instanceof joseErrors.JWTClaimValidationFailed) return 'invalid_claims';
|
||||||
|
return 'malformed';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verify signature, algorithm, claims, and expiry. Never throws. */
|
/** Verify signature, algorithm, claims, and expiry. Never throws. */
|
||||||
export function verifyCollabToken(
|
export async function verifyCollabToken(
|
||||||
token: string,
|
token: string,
|
||||||
secret: string,
|
rootSecret: string,
|
||||||
now: number = Date.now(),
|
now: number = Date.now(),
|
||||||
): CollabTokenVerification {
|
): Promise<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;
|
let payload: unknown;
|
||||||
try {
|
try {
|
||||||
header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf8'));
|
({ payload } = await jwtVerify(token, deriveTokenKey(rootSecret, 'collab'), {
|
||||||
payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8'));
|
algorithms: ['HS256'],
|
||||||
} catch {
|
currentDate: new Date(now),
|
||||||
return { valid: false, reason: 'malformed' };
|
clockTolerance: 0,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, reason: reasonOf(error) };
|
||||||
}
|
}
|
||||||
|
// `jose` treats `exp` as optional; the schema makes it mandatory so a
|
||||||
// Defense in depth: even though the signature already pinned HS256, reject a
|
// token without an expiry can never verify.
|
||||||
// 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);
|
const parsed = payloadSchema.safeParse(payload);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { valid: false, reason: 'invalid_claims' };
|
return { valid: false, reason: 'invalid_claims' };
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -559,6 +559,9 @@ importers:
|
|||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
jose:
|
||||||
|
specifier: ^6.2.4
|
||||||
|
version: 6.2.4
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.3.0
|
specifier: ^14.3.0
|
||||||
version: 14.3.0
|
version: 14.3.0
|
||||||
@ -4951,6 +4954,9 @@ packages:
|
|||||||
jose@6.2.3:
|
jose@6.2.3:
|
||||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
|
jose@6.2.4:
|
||||||
|
resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==}
|
||||||
|
|
||||||
jotai-scope@0.7.2:
|
jotai-scope@0.7.2:
|
||||||
resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==}
|
resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -11482,6 +11488,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.2.3: {}
|
jose@6.2.3: {}
|
||||||
|
|
||||||
|
jose@6.2.4: {}
|
||||||
|
|
||||||
jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7):
|
jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7)
|
jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user