Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/lib/invites/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 20 additions & 1 deletion src/lib/invites/verify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading