From 1b18fc10f442f09ca4a59ebd68887657e4449070 Mon Sep 17 00:00:00 2001 From: aiirvizionz Date: Sun, 12 Jul 2026 17:08:44 -0600 Subject: [PATCH] Validate invite token payload shape --- src/lib/invites/verify.js | 25 ++++++++++++++++++++++++- src/lib/invites/verify.test.js | 21 ++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/lib/invites/verify.js b/src/lib/invites/verify.js index c5cf74ca..4e9074de 100644 --- a/src/lib/invites/verify.js +++ b/src/lib/invites/verify.js @@ -45,6 +45,14 @@ function b64urlDecode(value) { return Buffer.from(value, 'base64url'); } +/** + * @param {number} value + * @returns {boolean} + */ +function isUnixSecond(value) { + return Number.isSafeInteger(value) && value >= 0; +} + /** * Wrap a raw 32-byte Ed25519 public key (base64) into a Node KeyObject by * prepending the SPKI DER header and importing as DER/SPKI. @@ -114,9 +122,24 @@ export async function verifyInviteToken(token, getIssuerPublicKey) { if (typeof payload.jti !== 'string' || !payload.jti) { throw new InviteVerificationError('bad_format', 'invite payload missing jti'); } - if (typeof payload.exp !== 'number') { + if (!/^[0-9a-f]{32}$/u.test(payload.jti)) { + throw new InviteVerificationError('bad_format', 'invite payload has invalid jti'); + } + if (typeof payload.tier !== 'string' || !payload.tier) { + throw new InviteVerificationError('bad_format', 'invite payload missing tier'); + } + if (!isUnixSecond(payload.iat)) { + throw new InviteVerificationError('bad_format', 'invite payload missing iat'); + } + if (!isUnixSecond(payload.exp)) { throw new InviteVerificationError('bad_format', 'invite payload missing exp'); } + if (payload.exp <= payload.iat) { + throw new InviteVerificationError('bad_format', 'invite payload expiry must be after iat'); + } + if (!Number.isSafeInteger(payload.uses) || payload.uses !== 1) { + throw new InviteVerificationError('bad_format', 'invite payload uses must be 1'); + } const issuerPublicKey = await getIssuerPublicKey(payload.iss); if (!issuerPublicKey) { diff --git a/src/lib/invites/verify.test.js b/src/lib/invites/verify.test.js index e8618535..2f872b8c 100644 --- a/src/lib/invites/verify.test.js +++ b/src/lib/invites/verify.test.js @@ -91,12 +91,31 @@ describe('verifyInviteToken', () => { }); it('rejects an expired token', async () => { - const token = mintToken(privateKey, basePayload({ exp: nowSec() - 60 })); + const token = mintToken(privateKey, basePayload({ iat: nowSec() - 3600, exp: nowSec() - 60 })); await expect(verifyInviteToken(token, getIssuerPublicKey)).rejects.toMatchObject({ code: 'expired', }); }); + it('rejects signed tokens with invalid payload shape', async () => { + const cases = [ + { jti: 'not-hex' }, + { tier: '' }, + { iat: undefined }, + { iat: nowSec() + 10, exp: nowSec() }, + { exp: `${nowSec() + 3600}` }, + { uses: 0 }, + { uses: 2 }, + ]; + + for (const overrides of cases) { + const token = mintToken(privateKey, basePayload(overrides)); + await expect(verifyInviteToken(token, getIssuerPublicKey)).rejects.toMatchObject({ + code: 'bad_format', + }); + } + }); + it('rejects a token signed by the wrong issuer key', async () => { const { privateKey: otherPriv } = generateKeyPairSync('ed25519'); const token = mintToken(otherPriv, basePayload());