Skip to content

Commit

Permalink
feat: extend JWT NumericDate setter syntax
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Dec 20, 2023
1 parent c739a59 commit ae363c3
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 59 deletions.
7 changes: 6 additions & 1 deletion src/jwt/produce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,16 @@ export class ProduceJWT {
* @param input "iat" (Issued At) Claim value to set on the JWT Claims Set. Default is current
* timestamp.
*/
setIssuedAt(input?: number | Date) {
setIssuedAt(input?: number | string | Date) {
if (typeof input === 'undefined') {
this._payload = { ...this._payload, iat: epoch(new Date()) }
} else if (input instanceof Date) {
this._payload = { ...this._payload, iat: validateInput('setIssuedAt', epoch(input)) }
} else if (typeof input === 'string') {
this._payload = {
...this._payload,
iat: validateInput('setIssuedAt', epoch(new Date()) + secs(input)),
}
} else {
this._payload = { ...this._payload, iat: validateInput('setIssuedAt', input) }
}
Expand Down
34 changes: 24 additions & 10 deletions src/lib/secs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,61 @@ const week = day * 7
const year = day * 365.25

const REGEX =
/^(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)$/i
/^(\+|\-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i

export default (str: string): number => {
const matched = REGEX.exec(str)

if (!matched) {
if (!matched || (matched[4] && matched[1])) {
throw new TypeError('Invalid time period format')
}

const value = parseFloat(matched[1])
const unit = matched[2].toLowerCase()
const value = parseFloat(matched[2])
const unit = matched[3].toLowerCase()

let numericDate: number

switch (unit) {
case 'sec':
case 'secs':
case 'second':
case 'seconds':
case 's':
return Math.round(value)
numericDate = Math.round(value)
break
case 'minute':
case 'minutes':
case 'min':
case 'mins':
case 'm':
return Math.round(value * minute)
numericDate = Math.round(value * minute)
break
case 'hour':
case 'hours':
case 'hr':
case 'hrs':
case 'h':
return Math.round(value * hour)
numericDate = Math.round(value * hour)
break
case 'day':
case 'days':
case 'd':
return Math.round(value * day)
numericDate = Math.round(value * day)
break
case 'week':
case 'weeks':
case 'w':
return Math.round(value * week)
numericDate = Math.round(value * week)
break
// years matched
default:
return Math.round(value * year)
numericDate = Math.round(value * year)
break
}

if (matched[1] === '-' || matched[4] === 'ago') {
return -numericDate
}

return numericDate
}
21 changes: 10 additions & 11 deletions test/jwt/encrypt.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import test from 'ava'
import timekeeper from 'timekeeper'
import { setters } from './time_setters.mjs'

const { EncryptJWT, compactDecrypt, jwtDecrypt } = await import('#dist')

Expand Down Expand Up @@ -102,21 +103,19 @@ async function testJWTsetFunction(t, method, claim, value, duplicate = false, ex
}
}
testJWTsetFunction.title = (title, method, claim, value) =>
`EncryptJWT.prototype.${method} called with ${value}${title ? ` (${title})` : ''}`
`EncryptJWT.prototype.${method} called with ${
value?.constructor?.name || typeof value
} (${value}) ${title ? ` (${title})` : ''}`

for (const [method, claim, vectors] of setters(now)) {
for (const [input, output = input] of vectors) {
test(testJWTsetFunction, method, claim, input, false, output)
}
}

test(testJWTsetFunction, 'setIssuer', 'iss', 'urn:example:issuer')
test('duplicated', testJWTsetFunction, 'setIssuer', 'iss', 'urn:example:issuer', true)
test(testJWTsetFunction, 'setSubject', 'sub', 'urn:example:subject')
test('duplicated', testJWTsetFunction, 'setSubject', 'sub', 'urn:example:subject', true)
test(testJWTsetFunction, 'setAudience', 'aud', 'urn:example:audience')
test('duplicated', testJWTsetFunction, 'setAudience', 'aud', 'urn:example:audience', true)
test(testJWTsetFunction, 'setJti', 'jti', 'urn:example:jti')
test(testJWTsetFunction, 'setIssuedAt', 'iat', 0)
test(testJWTsetFunction, 'setIssuedAt', 'iat', undefined, undefined, now)
test(testJWTsetFunction, 'setExpirationTime', 'exp', 0)
test(testJWTsetFunction, 'setExpirationTime', 'exp', '10s', undefined, now + 10)
test(testJWTsetFunction, 'setNotBefore', 'nbf', 0)
test(testJWTsetFunction, 'setNotBefore', 'nbf', '10s', undefined, now + 10)

test('EncryptJWT.prototype.setProtectedHeader', (t) => {
t.throws(() => new EncryptJWT(t.context.payload).setProtectedHeader({}).setProtectedHeader({}), {
Expand Down
21 changes: 7 additions & 14 deletions test/jwt/sign.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import test from 'ava'
import timekeeper from 'timekeeper'
import { setters } from './time_setters.mjs'

const { SignJWT, compactVerify, jwtVerify } = await import('#dist')

Expand Down Expand Up @@ -99,18 +100,10 @@ async function testJWTsetFunction(t, method, claim, value, expected = value) {
t.is(claims[claim], expected)
}
testJWTsetFunction.title = (title, method, claim, value) =>
`SignJWT.prototype.${method} called with ${value?.constructor?.name || typeof value}`
`SignJWT.prototype.${method} called with ${value?.constructor?.name || typeof value} (${value})`

test(testJWTsetFunction, 'setIssuer', 'iss', 'urn:example:issuer')
test(testJWTsetFunction, 'setSubject', 'sub', 'urn:example:subject')
test(testJWTsetFunction, 'setAudience', 'aud', 'urn:example:audience')
test(testJWTsetFunction, 'setJti', 'jti', 'urn:example:jti')
test(testJWTsetFunction, 'setIssuedAt', 'iat', 0)
test(testJWTsetFunction, 'setIssuedAt', 'iat', undefined, now)
test(testJWTsetFunction, 'setIssuedAt', 'iat', new Date(now * 1000), now)
test(testJWTsetFunction, 'setExpirationTime', 'exp', 0)
test(testJWTsetFunction, 'setExpirationTime', 'exp', '10s', now + 10)
test(testJWTsetFunction, 'setExpirationTime', 'exp', new Date(now * 1000), now)
test(testJWTsetFunction, 'setNotBefore', 'nbf', 0)
test(testJWTsetFunction, 'setNotBefore', 'nbf', '10s', now + 10)
test(testJWTsetFunction, 'setNotBefore', 'nbf', new Date(now * 1000), now)
for (const [method, claim, vectors] of setters(now)) {
for (const [input, output = input] of vectors) {
test(testJWTsetFunction, method, claim, input, output)
}
}
23 changes: 23 additions & 0 deletions test/jwt/time_setters.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export function setters(now) {
const timeSetters = [
[0],
[new Date(now * 1000), now],
['10s', now + 10],
['+10s', now + 10],
['-10s', now - 10],
['+ 10s', now + 10],
['- 10s', now - 10],
['10s from now', now + 10],
['10s ago', now - 10],
]

return [
['setIssuer', 'iss', [['urn:example:issuer']]],
['setSubject', 'sub', [['urn:example:subject']]],
['setAudience', 'aud', [['urn:example:audience']]],
['setJti', 'jti', [['urn:example:jti']]],
['setIssuedAt', 'iat', [[undefined, now], ...timeSetters]],
['setExpirationTime', 'exp', timeSetters],
['setNotBefore', 'nbf', timeSetters],
]
}
22 changes: 11 additions & 11 deletions test/jwt/unsecured.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import test from 'ava'
import timekeeper from 'timekeeper'
import { setters } from './time_setters.mjs'

const { UnsecuredJWT } = await import('#dist')
const { UnsecuredJWT, decodeJwt } = await import('#dist')

const now = 1604416038

Expand Down Expand Up @@ -47,18 +48,17 @@ test('new UnsecuredJWT()', (t) => {

async function testJWTsetFunction(t, method, claim, value, expected = value) {
const jwt = new UnsecuredJWT()[method](value).encode()
const { payload: claims } = UnsecuredJWT.decode(jwt)
const claims = decodeJwt(jwt)
t.true(claim in claims)
t.is(claims[claim], expected)
}
testJWTsetFunction.title = (title, method, claim, value) =>
`UnsecuredJWT.prototype.${method} called with ${value}`
`UnsecuredJWT.prototype.${method} called with ${
value?.constructor?.name || typeof value
} (${value})`

test(testJWTsetFunction, 'setIssuer', 'iss', 'urn:example:issuer')
test(testJWTsetFunction, 'setSubject', 'sub', 'urn:example:subject')
test(testJWTsetFunction, 'setAudience', 'aud', 'urn:example:audience')
test(testJWTsetFunction, 'setJti', 'jti', 'urn:example:jti')
test(testJWTsetFunction, 'setIssuedAt', 'iat', 0)
test(testJWTsetFunction, 'setIssuedAt', 'iat', undefined, now)
test(testJWTsetFunction, 'setExpirationTime', 'exp', '10s', now + 10)
test(testJWTsetFunction, 'setNotBefore', 'nbf', 0)
for (const [method, claim, vectors] of setters(now)) {
for (const [input, output = input] of vectors) {
test(testJWTsetFunction, method, claim, input, output)
}
}
100 changes: 88 additions & 12 deletions test/unit/secs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,109 @@ import test from 'ava'
const { default: secs } = await import('#dist/lib/secs')

test('lib/secs.ts', (t) => {
for (const sign of ['+', '+ ', '']) {
for (const v of ['sec', 'secs', 'second', 'seconds', 's']) {
t.is(secs(`${sign}1${v}`), 1)
t.is(secs(`${sign}1 ${v}`), 1)
}
for (const v of ['minute', 'minutes', 'min', 'mins', 'm']) {
t.is(secs(`${sign}1${v}`), 60)
t.is(secs(`${sign}1 ${v}`), 60)
}
for (const v of ['hour', 'hours', 'hr', 'hrs', 'h']) {
t.is(secs(`${sign}1${v}`), 3600)
t.is(secs(`${sign}1 ${v}`), 3600)
}
for (const v of ['day', 'days', 'd']) {
t.is(secs(`${sign}1${v}`), 86400)
t.is(secs(`${sign}1 ${v}`), 86400)
}
for (const v of ['week', 'weeks', 'w']) {
t.is(secs(`${sign}1${v}`), 604800)
t.is(secs(`${sign}1 ${v}`), 604800)
}
for (const v of ['years', 'year', 'yrs', 'yr', 'y']) {
t.is(secs(`${sign}1${v}`), 31557600)
t.is(secs(`${sign}1 ${v}`), 31557600)
}
}

for (const sign of ['-', '- ']) {
for (const v of ['sec', 'secs', 'second', 'seconds', 's']) {
t.is(secs(`${sign}1${v}`), -1)
t.is(secs(`${sign}1 ${v}`), -1)
}
for (const v of ['minute', 'minutes', 'min', 'mins', 'm']) {
t.is(secs(`${sign}1${v}`), -60)
t.is(secs(`${sign}1 ${v}`), -60)
}
for (const v of ['hour', 'hours', 'hr', 'hrs', 'h']) {
t.is(secs(`${sign}1${v}`), -3600)
t.is(secs(`${sign}1 ${v}`), -3600)
}
for (const v of ['day', 'days', 'd']) {
t.is(secs(`${sign}1${v}`), -86400)
t.is(secs(`${sign}1 ${v}`), -86400)
}
for (const v of ['week', 'weeks', 'w']) {
t.is(secs(`${sign}1${v}`), -604800)
t.is(secs(`${sign}1 ${v}`), -604800)
}
for (const v of ['years', 'year', 'yrs', 'yr', 'y']) {
t.is(secs(`${sign}1${v}`), -31557600)
t.is(secs(`${sign}1 ${v}`), -31557600)
}
}

for (const v of ['sec', 'secs', 'second', 'seconds', 's']) {
t.is(secs(`1${v}`), 1)
t.is(secs(`1 ${v}`), 1)
t.is(secs(`1${v} ago`), -1)
t.is(secs(`1${v} from now`), 1)
t.is(secs(`1 ${v} ago`), -1)
t.is(secs(`1 ${v} from now`), 1)
}
for (const v of ['minute', 'minutes', 'min', 'mins', 'm']) {
t.is(secs(`1${v}`), 60)
t.is(secs(`1 ${v}`), 60)
t.is(secs(`1${v} ago`), -60)
t.is(secs(`1${v} from now`), 60)
t.is(secs(`1 ${v} ago`), -60)
t.is(secs(`1 ${v} from now`), 60)
}
for (const v of ['hour', 'hours', 'hr', 'hrs', 'h']) {
t.is(secs(`1${v}`), 3600)
t.is(secs(`1 ${v}`), 3600)
t.is(secs(`1${v} ago`), -3600)
t.is(secs(`1${v} from now`), 3600)
t.is(secs(`1 ${v} ago`), -3600)
t.is(secs(`1 ${v} from now`), 3600)
}
for (const v of ['day', 'days', 'd']) {
t.is(secs(`1${v}`), 86400)
t.is(secs(`1 ${v}`), 86400)
t.is(secs(`1${v} ago`), -86400)
t.is(secs(`1${v} from now`), 86400)
t.is(secs(`1 ${v} ago`), -86400)
t.is(secs(`1 ${v} from now`), 86400)
}
for (const v of ['week', 'weeks', 'w']) {
t.is(secs(`1${v}`), 604800)
t.is(secs(`1 ${v}`), 604800)
t.is(secs(`1${v} ago`), -604800)
t.is(secs(`1${v} from now`), 604800)
t.is(secs(`1 ${v} ago`), -604800)
t.is(secs(`1 ${v} from now`), 604800)
}
for (const v of ['years', 'year', 'yrs', 'yr', 'y']) {
t.is(secs(`1${v}`), 31557600)
t.is(secs(`1 ${v}`), 31557600)
t.is(secs(`1${v} ago`), -31557600)
t.is(secs(`1${v} from now`), 31557600)
t.is(secs(`1 ${v} ago`), -31557600)
t.is(secs(`1 ${v} from now`), 31557600)
}

t.throws(() => secs('1 fortnight'), {
instanceOf: TypeError,
message: 'Invalid time period format',
})

t.throws(() => secs('= 1 second'), {
instanceOf: TypeError,
message: 'Invalid time period format',
})

t.throws(() => secs('- 1 second ago'), {
instanceOf: TypeError,
message: 'Invalid time period format',
})
})

0 comments on commit ae363c3

Please sign in to comment.