mirror of
https://github.com/hanzoai/jose.git
synced 2026-09-24 17:00:01 +00:00
Match the protected header of each resolver callback to the value supplied by its operation. Expose the members attached to local and remote JWKS resolvers through named callable interfaces. Allow `JWTHeaderParameters.b64` to be boolean. A false value is inert unless "b64" is listed in "crit", so signing and verification accept it. Signed-off-by: Filip Skokan <[email protected]>
573 lines
16 KiB
TypeScript
573 lines
16 KiB
TypeScript
import test from 'ava'
|
|
import timekeeper from 'timekeeper'
|
|
|
|
import { base64url, SignJWT, jwtVerify, CompactSign } from '../../src/index.js'
|
|
|
|
const now = 1604416038
|
|
|
|
test.before(async (t) => {
|
|
t.context.secret = new Uint8Array(32)
|
|
t.context.payload = { 'urn:example:claim': true }
|
|
|
|
timekeeper.freeze(now * 1000)
|
|
})
|
|
|
|
test.after(timekeeper.reset)
|
|
|
|
test('Basic JWT Claims Set verification', async (t) => {
|
|
const issuer = 'urn:example:issuer'
|
|
const subject = 'urn:example:subject'
|
|
const audience = 'urn:example:audience'
|
|
const jti = 'urn:example:jti'
|
|
const nbf = now - 10
|
|
const iat = now - 20
|
|
const exp = now + 10
|
|
const typ = 'urn:example:typ'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256', typ })
|
|
.setIssuer(issuer)
|
|
.setSubject(subject)
|
|
.setAudience(audience)
|
|
.setJti(jti)
|
|
.setNotBefore(nbf)
|
|
.setExpirationTime(exp)
|
|
.setIssuedAt(iat)
|
|
.sign(t.context.secret)
|
|
|
|
t.deepEqual(
|
|
await jwtVerify(jwt, t.context.secret, {
|
|
issuer,
|
|
subject,
|
|
audience,
|
|
jti,
|
|
typ,
|
|
maxTokenAge: '30s',
|
|
}),
|
|
{
|
|
payload: {
|
|
aud: 'urn:example:audience',
|
|
exp: 1604416048,
|
|
iat: 1604416018,
|
|
iss: 'urn:example:issuer',
|
|
jti: 'urn:example:jti',
|
|
nbf: 1604416028,
|
|
sub: 'urn:example:subject',
|
|
'urn:example:claim': true,
|
|
},
|
|
protectedHeader: {
|
|
alg: 'HS256',
|
|
typ: 'urn:example:typ',
|
|
},
|
|
},
|
|
)
|
|
await t.notThrowsAsync(jwtVerify(new TextEncoder().encode(jwt), t.context.secret))
|
|
})
|
|
|
|
test('Payload must be an object', async (t) => {
|
|
const encode = TextEncoder.prototype.encode.bind(new TextEncoder())
|
|
for (const value of [0, 1, -1, true, false, null, [], '']) {
|
|
const token = await new CompactSign(encode(JSON.stringify(value)))
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
await t.throwsAsync(jwtVerify(token, t.context.secret), {
|
|
code: 'ERR_JWT_INVALID',
|
|
message: 'JWT Claims Set must be a top-level JSON object',
|
|
})
|
|
}
|
|
})
|
|
|
|
test('incorrect hmac signature lengths', async (t) => {
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(jwtVerify(jwt.slice(0, -3), t.context.secret), {
|
|
code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED',
|
|
message: 'signature verification failed',
|
|
})
|
|
})
|
|
|
|
test('Payload must JSON parseable', async (t) => {
|
|
const encode = TextEncoder.prototype.encode.bind(new TextEncoder())
|
|
const token = await new CompactSign(encode('{'))
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
await t.throwsAsync(jwtVerify(token, t.context.secret), {
|
|
code: 'ERR_JWT_INVALID',
|
|
message: 'JWT Claims Set must be a top-level JSON object',
|
|
})
|
|
})
|
|
|
|
test('algorithms options', async (t) => {
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
algorithms: ['PS256'],
|
|
}),
|
|
{
|
|
code: 'ERR_JOSE_ALG_NOT_ALLOWED',
|
|
message: '"alg" (Algorithm) Header Parameter value not allowed',
|
|
},
|
|
)
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
algorithms: [null],
|
|
}),
|
|
{
|
|
instanceOf: TypeError,
|
|
message: '"algorithms" option must be an array of strings',
|
|
},
|
|
)
|
|
})
|
|
|
|
test('typ verification', async (t) => {
|
|
{
|
|
const typ = 'urn:example:typ'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256', typ })
|
|
.sign(t.context.secret)
|
|
|
|
await t.notThrowsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
typ: 'application/urn:example:typ',
|
|
}),
|
|
)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
typ: 'urn:example:typ:2',
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
|
|
)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
typ: 'application/urn:example:typ:2',
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
|
|
)
|
|
}
|
|
{
|
|
const typ = 'application/urn:example:typ'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256', typ })
|
|
.sign(t.context.secret)
|
|
|
|
await t.notThrowsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
typ: 'urn:example:typ',
|
|
}),
|
|
)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
typ: 'application/urn:example:typ:2',
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
|
|
)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
typ: 'urn:example:typ:2',
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
|
|
)
|
|
}
|
|
{
|
|
const typ = 'text/plain'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256', typ })
|
|
.sign(t.context.secret)
|
|
|
|
await t.notThrowsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
typ: 'text/plain',
|
|
}),
|
|
)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
typ: 'application/text/plain',
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
|
|
)
|
|
}
|
|
})
|
|
|
|
test('Issuer[] verification', async (t) => {
|
|
const issuer = 'urn:example:issuer'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuer(issuer)
|
|
.sign(t.context.secret)
|
|
|
|
await t.notThrowsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
issuer: [issuer],
|
|
}),
|
|
)
|
|
})
|
|
|
|
test('Issuer[] verification failed', async (t) => {
|
|
const issuer = 'urn:example:issuer'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuer(issuer)
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
issuer: [],
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "iss" claim value' },
|
|
)
|
|
})
|
|
|
|
test('Issuer[] verification failed []', async (t) => {
|
|
const issuer = 'urn:example:issuer'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuer([issuer])
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
issuer: [],
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "iss" claim value' },
|
|
)
|
|
})
|
|
|
|
test('Audience[] verification', async (t) => {
|
|
const audience = 'urn:example:audience'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setAudience(audience)
|
|
.sign(t.context.secret)
|
|
|
|
await t.notThrowsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
audience: [audience],
|
|
}),
|
|
)
|
|
})
|
|
|
|
test('Audience[] verification failed', async (t) => {
|
|
const audience = 'urn:example:audience'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setAudience(audience)
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
audience: [],
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "aud" claim value' },
|
|
)
|
|
})
|
|
|
|
test('Audience[] verification failed []', async (t) => {
|
|
const audience = 'urn:example:audience'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setAudience([audience])
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
audience: [],
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "aud" claim value' },
|
|
)
|
|
})
|
|
|
|
test('Subject verification failed', async (t) => {
|
|
const subject = 'urn:example:subject'
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setSubject(subject)
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(
|
|
jwtVerify(jwt, t.context.secret, {
|
|
subject: 'urn:example:subject:2',
|
|
}),
|
|
{ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "sub" claim value' },
|
|
)
|
|
})
|
|
|
|
async function numericDateNumber(t, claim) {
|
|
const jwt = await new SignJWT({ [claim]: null })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret), {
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: `"${claim}" claim must be a number`,
|
|
})
|
|
}
|
|
numericDateNumber.title = (t, claim) => `${claim} must be a number`
|
|
|
|
test('clockTolerance num', async (t) => {
|
|
const jwt = await new SignJWT({ exp: now })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
|
|
await t.notThrowsAsync(jwtVerify(jwt, t.context.secret, { clockTolerance: 1 }))
|
|
await t.notThrowsAsync(jwtVerify(jwt, t.context.secret, { clockTolerance: '1s' }))
|
|
})
|
|
|
|
async function failingNumericDate(t, claims, assertion, verifyOptions) {
|
|
const jwt = await new SignJWT({ ...claims })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { ...verifyOptions }), assertion)
|
|
}
|
|
|
|
test(
|
|
'exp must be in the future',
|
|
failingNumericDate,
|
|
{ exp: now },
|
|
{
|
|
code: 'ERR_JWT_EXPIRED',
|
|
message: '"exp" claim timestamp check failed',
|
|
},
|
|
)
|
|
|
|
test(
|
|
'nbf must be at least now',
|
|
failingNumericDate,
|
|
{ nbf: now + 1 },
|
|
{
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: '"nbf" claim timestamp check failed',
|
|
},
|
|
)
|
|
|
|
test(
|
|
'iat must be in the past (maxTokenAge, no exp)',
|
|
failingNumericDate,
|
|
{ iat: now + 1 },
|
|
{
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: '"iat" claim timestamp check failed (it should be in the past)',
|
|
},
|
|
{
|
|
maxTokenAge: 5,
|
|
},
|
|
)
|
|
|
|
test(
|
|
'iat must be in the past (maxTokenAge, with exp)',
|
|
failingNumericDate,
|
|
{ iat: now + 1, exp: now + 10 },
|
|
{
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: '"iat" claim timestamp check failed (it should be in the past)',
|
|
},
|
|
{
|
|
maxTokenAge: 5,
|
|
},
|
|
)
|
|
|
|
test(
|
|
'iat must be in the past (maxTokenAge, with exp, as a string)',
|
|
failingNumericDate,
|
|
{ iat: now + 1, exp: now + 10 },
|
|
{
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: '"iat" claim timestamp check failed (it should be in the past)',
|
|
},
|
|
{
|
|
maxTokenAge: '5s',
|
|
},
|
|
)
|
|
|
|
test(
|
|
'maxTokenAge option',
|
|
failingNumericDate,
|
|
{ iat: now - 31 },
|
|
{
|
|
code: 'ERR_JWT_EXPIRED',
|
|
message: '"iat" claim timestamp check failed (too far in the past)',
|
|
},
|
|
{
|
|
maxTokenAge: '30s',
|
|
},
|
|
)
|
|
|
|
for (const claim of ['iat', 'nbf', 'exp']) {
|
|
test(numericDateNumber, claim)
|
|
}
|
|
|
|
test('Signed JWTs cannot use unencoded payload', async (t) => {
|
|
await t.throwsAsync(
|
|
jwtVerify(
|
|
'eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19.foo.VklKdp4tVYD61VNPDBTqxqdEQcUL3JK-D4dGXu9NvWs',
|
|
t.context.secret,
|
|
),
|
|
{ code: 'ERR_JWT_INVALID', message: 'JWTs MUST NOT use unencoded payload' },
|
|
)
|
|
})
|
|
|
|
test('"b64" is ignored when "crit" does not list it', async (t) => {
|
|
// RFC 7797 requires a producer to list "b64" in "crit" for the unencoded payload option to be in
|
|
// effect. Absent from "crit" the parameter is inert, the payload is base64url encoded like any
|
|
// other, and the JWT is ordinary - so this is not the case RFC 7797 Section 7 forbids.
|
|
const jwt = await new CompactSign(new TextEncoder().encode(JSON.stringify(t.context.payload)))
|
|
.setProtectedHeader({ alg: 'HS256', b64: false })
|
|
.sign(t.context.secret)
|
|
|
|
const { payload, protectedHeader } = await jwtVerify(jwt, t.context.secret)
|
|
t.deepEqual(protectedHeader, { alg: 'HS256', b64: false })
|
|
t.deepEqual(payload, t.context.payload)
|
|
})
|
|
|
|
test('signatures are compared before claim set', async (t) => {
|
|
// https://github.com/panva/jose/discussions/447
|
|
const jwt = await new SignJWT({ exp: 0 })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
|
|
// with valid secret should throw exp failing to verify
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret), { code: 'ERR_JWT_EXPIRED' })
|
|
|
|
// with invalid secret should throw signature failing to verify
|
|
await t.throwsAsync(jwtVerify(jwt, new Uint8Array([0x00, 0x01])), {
|
|
code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED',
|
|
})
|
|
})
|
|
|
|
test('requiredClaims claims check', async (t) => {
|
|
const jwt = await new SignJWT({
|
|
...t.context.payload,
|
|
})
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.sign(t.context.secret)
|
|
|
|
const requiredClaims = ['nbf']
|
|
|
|
for (const [claim, option] of [
|
|
['iss', 'issuer'],
|
|
['aud', 'audience'],
|
|
['iat', 'maxTokenAge'],
|
|
['sub', 'subject'],
|
|
]) {
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { [option]: 'foo' }), {
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: `missing required "${claim}" claim`,
|
|
})
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { [option]: 'foo', requiredClaims }), {
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: `missing required "${claim}" claim`,
|
|
})
|
|
}
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { requiredClaims }), {
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: `missing required "nbf" claim`,
|
|
})
|
|
|
|
t.deepEqual(requiredClaims, ['nbf'])
|
|
})
|
|
|
|
test('empty string validation options compare the claim value', async (t) => {
|
|
const jwt = await new SignJWT({ iss: 'urn:example:issuer', sub: 'urn:example:subject' })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setAudience('urn:example:audience')
|
|
.sign(t.context.secret)
|
|
|
|
for (const [claim, option] of [
|
|
['iss', 'issuer'],
|
|
['sub', 'subject'],
|
|
['aud', 'audience'],
|
|
]) {
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { [option]: '' }), {
|
|
code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
|
|
message: `unexpected "${claim}" claim value`,
|
|
})
|
|
}
|
|
})
|
|
|
|
test('maxTokenAge of 0 is enforced', async (t) => {
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt(now - 30)
|
|
.sign(t.context.secret)
|
|
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { maxTokenAge: 0 }), {
|
|
code: 'ERR_JWT_EXPIRED',
|
|
message: '"iat" claim timestamp check failed (too far in the past)',
|
|
})
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { maxTokenAge: '0 seconds' }), {
|
|
code: 'ERR_JWT_EXPIRED',
|
|
})
|
|
|
|
const fresh = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt(now)
|
|
.sign(t.context.secret)
|
|
await t.notThrowsAsync(jwtVerify(fresh, t.context.secret, { maxTokenAge: 0 }))
|
|
})
|
|
|
|
test('clockTolerance and currentDate must be finite', async (t) => {
|
|
const jwt = await new SignJWT(t.context.payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setExpirationTime(now - 30)
|
|
.sign(t.context.secret)
|
|
|
|
for (const clockTolerance of [NaN, Infinity, -Infinity]) {
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { clockTolerance }), {
|
|
instanceOf: TypeError,
|
|
message: 'Invalid clockTolerance option input',
|
|
})
|
|
}
|
|
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { currentDate: new Date('nope') }), {
|
|
instanceOf: TypeError,
|
|
message: 'Invalid currentDate option input',
|
|
})
|
|
|
|
// The token is genuinely expired, so a valid tolerance still rejects it.
|
|
await t.throwsAsync(jwtVerify(jwt, t.context.secret, { clockTolerance: 0 }), {
|
|
code: 'ERR_JWT_EXPIRED',
|
|
})
|
|
})
|
|
|
|
test('invalid UTF-8 in the Claims Set is rejected', async (t) => {
|
|
// RFC 7519 Section 7.2 step 10 requires verifying the octets are a UTF-8 encoding.
|
|
// C0 AF is an overlong encoding of "/".
|
|
const header = base64url.encode(JSON.stringify({ alg: 'HS256' }))
|
|
const claims = new Uint8Array([
|
|
...new TextEncoder().encode('{"sub":"a'),
|
|
0xc0,
|
|
0xaf,
|
|
...new TextEncoder().encode('b"}'),
|
|
])
|
|
const payload = base64url.encode(claims)
|
|
|
|
const signature = base64url.encode(
|
|
new Uint8Array(
|
|
await crypto.subtle.sign(
|
|
'HMAC',
|
|
await crypto.subtle.importKey(
|
|
'raw',
|
|
t.context.secret,
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign'],
|
|
),
|
|
new TextEncoder().encode(`${header}.${payload}`),
|
|
),
|
|
),
|
|
)
|
|
|
|
await t.throwsAsync(jwtVerify(`${header}.${payload}.${signature}`, t.context.secret), {
|
|
code: 'ERR_JWT_INVALID',
|
|
})
|
|
})
|