Skip to content

Commit

Permalink
feat: add VerificationToken abstract class
Browse files Browse the repository at this point in the history
  • Loading branch information
thetutlage committed Dec 5, 2024
1 parent befb276 commit ec313c2
Show file tree
Hide file tree
Showing 4 changed files with 327 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"sinon": "^19.0.2",
"supertest": "^7.0.0",
"test-console": "^2.0.0",
"timekeeper": "^2.3.1",
"ts-node-maintained": "^10.9.4",
"typescript": "^5.7.2"
},
Expand Down
1 change: 1 addition & 0 deletions src/helpers/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ export {
fsImportAll,
MessageBuilder,
} from '@poppinss/utils'
export { VerificationToken } from './verification_token.js'
export { parseBindingReference } from './parse_binding_reference.js'
147 changes: 147 additions & 0 deletions src/helpers/verification_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* @adonisjs/core
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { createHash } from 'node:crypto'
import string from '@poppinss/utils/string'
import { base64, safeEqual, Secret } from '@poppinss/utils'

/**
* Verification token class can be used to create tokens publicly
* shareable tokens while storing the token hash within the database.
*
* This class is used by the Auth and the Persona packages to manage
* tokens
*/
export abstract class VerificationToken {
/**
* Decodes a publicly shared token and return the series
* and the token value from it.
*
* Returns null when unable to decode the token because of
* invalid format or encoding.
*/
static decode(value: string): null | { identifier: string; secret: Secret<string> } {
/**
* Ensure value is a string and starts with the prefix.
*/
if (typeof value !== 'string') {
return null
}

/**
* Remove prefix from the rest of the token.
*/
if (!value) {
return null
}

const [identifier, ...tokenValue] = value.split('.')
if (!identifier || tokenValue.length === 0) {
return null
}

const decodedIdentifier = base64.urlDecode(identifier)
const decodedSecret = base64.urlDecode(tokenValue.join('.'))
if (!decodedIdentifier || !decodedSecret) {
return null
}

return {
identifier: decodedIdentifier,
secret: new Secret(decodedSecret),
}
}

/**
* Creates a transient token that can be shared with the persistence
* layer.
*/
static createTransientToken(
userId: string | number | BigInt,
size: number,
expiresIn: string | number
) {
const expiresAt = new Date()
expiresAt.setSeconds(expiresAt.getSeconds() + string.seconds.parse(expiresIn))

return {
userId,
expiresAt,
...this.seed(size),
}
}

/**
* Creates a secret opaque token and its hash.
*/
static seed(size: number) {
const seed = string.random(size)
const secret = new Secret(seed)
const hash = createHash('sha256').update(secret.release()).digest('hex')
return { secret, hash }
}

/**
* Identifer is a unique sequence to identify the
* token within database. It should be the
* primary/unique key
*/
declare identifier: string | number | BigInt

/**
* Reference to the user id for whom the token
* is generated.
*/
declare tokenableId: string | number | BigInt

/**
* Hash is computed from the seed to later verify the validity
* of seed
*/
declare hash: string

/**
* Timestamp at which the token will expire
*/
declare expiresAt: Date

/**
* The value is a public representation of a token. It is created
* by combining the "identifier"."secret" via the "computeValue"
* method
*/
declare value?: Secret<string>

/**
* Compute the value property using the given secret. You can
* get secret via the static "createTransientToken" method.
*/
protected computeValue(secret: Secret<string>) {
this.value = new Secret(
`${base64.urlEncode(String(this.identifier))}.${base64.urlEncode(secret.release())}`
)
}

/**
* Check if the token has been expired. Verifies
* the "expiresAt" timestamp with the current
* date.
*/
isExpired() {
return this.expiresAt < new Date()
}

/**
* Verifies the value of a token against the pre-defined hash
*/
verify(secret: Secret<string>): boolean {
const newHash = createHash('sha256').update(secret.release()).digest('hex')
return safeEqual(this.hash, newHash)
}
}
178 changes: 178 additions & 0 deletions tests/verification_token.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* @adonisjs/persona
*
* (C) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import timekeeper from 'timekeeper'
import { Secret, base64 } from '@poppinss/utils'
import { getActiveTest, test } from '@japa/runner'

import { VerificationToken } from '../src/helpers/verification_token.js'

function freezeTime() {
const t = getActiveTest()
if (!t) {
throw new Error('Cannot use "freezeTime" outside of a Japa test')
}

timekeeper.reset()

const date = new Date()
timekeeper.freeze(date)

t.cleanup(() => {
timekeeper.reset()
})
}

class EmailVerificationToken extends VerificationToken {
constructor(props: {
identifier: number
tokenableId: number
hash: string
expiresAt: Date
secret?: Secret<string>
}) {
super()
this.identifier = props.identifier
this.tokenableId = props.tokenableId
this.hash = props.hash
this.expiresAt = props.expiresAt
if (props.secret) {
this.computeValue(props.secret)
}
}
}

test.group('VerificationToken token | decode', () => {
test('decode "{input}" as token')
.with([
{
input: null,
output: null,
},
{
input: '',
output: null,
},
{
input: '..',
output: null,
},
{
input: 'foobar',
output: null,
},
{
input: 'foo.baz',
output: null,
},
{
input: `bar.${base64.urlEncode('baz')}`,
output: null,
},
{
input: `${base64.urlEncode('baz')}.bar`,
output: null,
},
{
input: `${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`,
output: {
identifier: 'bar',
secret: 'baz',
},
},
])
.run(({ assert }, { input, output }) => {
const decoded = VerificationToken.decode(input as string)
if (!decoded) {
assert.deepEqual(decoded, output)
} else {
assert.deepEqual(
{ identifier: decoded.identifier, secret: decoded.secret.release() },
output
)
}
})
})

test.group('VerificationToken token | create', () => {
test('create a transient token', ({ assert }) => {
freezeTime()
const date = new Date()
const expiresAt = new Date()
expiresAt.setSeconds(date.getSeconds() + 60 * 20)

const token = VerificationToken.createTransientToken(1, 40, '20 mins')
assert.equal(token.userId, 1)
assert.exists(token.hash)
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime())
assert.instanceOf(token.secret, Secret)
})

test('create token from persisted information', ({ assert }) => {
const createdAt = new Date()
const expiresAt = new Date()
expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20)

const token = new EmailVerificationToken({
identifier: 12,
tokenableId: 1,
hash: '1234',
expiresAt,
})

assert.equal(token.identifier, 12)
assert.equal(token.hash, '1234')
assert.equal(token.tokenableId, 1)
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime())

assert.isUndefined(token.value)
assert.isFalse(token.isExpired())
})

test('create token with a secret', ({ assert }) => {
const createdAt = new Date()
const expiresAt = new Date()
expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20)

const transientToken = EmailVerificationToken.createTransientToken(1, 40, '20 mins')

const token = new EmailVerificationToken({
identifier: 12,
tokenableId: 1,
hash: transientToken.hash,
expiresAt,
secret: transientToken.secret,
})

const decoded = EmailVerificationToken.decode(token.value!.release())

assert.equal(token.identifier, 12)
assert.equal(token.tokenableId, 1)
assert.equal(token.hash, transientToken.hash)
assert.instanceOf(token.value, Secret)
assert.isTrue(token.verify(transientToken.secret))
assert.isTrue(token.verify(decoded!.secret))
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime())
assert.isFalse(token.isExpired())
})

test('verify token hash', ({ assert }) => {
const transientToken = EmailVerificationToken.createTransientToken(1, 40, '20 mins')

const token = new EmailVerificationToken({
identifier: 12,
tokenableId: 1,
hash: transientToken.hash,
expiresAt: new Date(),
secret: transientToken.secret,
})

assert.isTrue(token.verify(transientToken.secret))
})
})

0 comments on commit ec313c2

Please sign in to comment.