Skip to content

Commit ec313c2

Browse files
committed
feat: add VerificationToken abstract class
1 parent befb276 commit ec313c2

File tree

4 files changed

+327
-0
lines changed

4 files changed

+327
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
"sinon": "^19.0.2",
119119
"supertest": "^7.0.0",
120120
"test-console": "^2.0.0",
121+
"timekeeper": "^2.3.1",
121122
"ts-node-maintained": "^10.9.4",
122123
"typescript": "^5.7.2"
123124
},

src/helpers/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ export {
2222
fsImportAll,
2323
MessageBuilder,
2424
} from '@poppinss/utils'
25+
export { VerificationToken } from './verification_token.js'
2526
export { parseBindingReference } from './parse_binding_reference.js'

src/helpers/verification_token.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* @adonisjs/core
3+
*
4+
* (c) AdonisJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { createHash } from 'node:crypto'
11+
import string from '@poppinss/utils/string'
12+
import { base64, safeEqual, Secret } from '@poppinss/utils'
13+
14+
/**
15+
* Verification token class can be used to create tokens publicly
16+
* shareable tokens while storing the token hash within the database.
17+
*
18+
* This class is used by the Auth and the Persona packages to manage
19+
* tokens
20+
*/
21+
export abstract class VerificationToken {
22+
/**
23+
* Decodes a publicly shared token and return the series
24+
* and the token value from it.
25+
*
26+
* Returns null when unable to decode the token because of
27+
* invalid format or encoding.
28+
*/
29+
static decode(value: string): null | { identifier: string; secret: Secret<string> } {
30+
/**
31+
* Ensure value is a string and starts with the prefix.
32+
*/
33+
if (typeof value !== 'string') {
34+
return null
35+
}
36+
37+
/**
38+
* Remove prefix from the rest of the token.
39+
*/
40+
if (!value) {
41+
return null
42+
}
43+
44+
const [identifier, ...tokenValue] = value.split('.')
45+
if (!identifier || tokenValue.length === 0) {
46+
return null
47+
}
48+
49+
const decodedIdentifier = base64.urlDecode(identifier)
50+
const decodedSecret = base64.urlDecode(tokenValue.join('.'))
51+
if (!decodedIdentifier || !decodedSecret) {
52+
return null
53+
}
54+
55+
return {
56+
identifier: decodedIdentifier,
57+
secret: new Secret(decodedSecret),
58+
}
59+
}
60+
61+
/**
62+
* Creates a transient token that can be shared with the persistence
63+
* layer.
64+
*/
65+
static createTransientToken(
66+
userId: string | number | BigInt,
67+
size: number,
68+
expiresIn: string | number
69+
) {
70+
const expiresAt = new Date()
71+
expiresAt.setSeconds(expiresAt.getSeconds() + string.seconds.parse(expiresIn))
72+
73+
return {
74+
userId,
75+
expiresAt,
76+
...this.seed(size),
77+
}
78+
}
79+
80+
/**
81+
* Creates a secret opaque token and its hash.
82+
*/
83+
static seed(size: number) {
84+
const seed = string.random(size)
85+
const secret = new Secret(seed)
86+
const hash = createHash('sha256').update(secret.release()).digest('hex')
87+
return { secret, hash }
88+
}
89+
90+
/**
91+
* Identifer is a unique sequence to identify the
92+
* token within database. It should be the
93+
* primary/unique key
94+
*/
95+
declare identifier: string | number | BigInt
96+
97+
/**
98+
* Reference to the user id for whom the token
99+
* is generated.
100+
*/
101+
declare tokenableId: string | number | BigInt
102+
103+
/**
104+
* Hash is computed from the seed to later verify the validity
105+
* of seed
106+
*/
107+
declare hash: string
108+
109+
/**
110+
* Timestamp at which the token will expire
111+
*/
112+
declare expiresAt: Date
113+
114+
/**
115+
* The value is a public representation of a token. It is created
116+
* by combining the "identifier"."secret" via the "computeValue"
117+
* method
118+
*/
119+
declare value?: Secret<string>
120+
121+
/**
122+
* Compute the value property using the given secret. You can
123+
* get secret via the static "createTransientToken" method.
124+
*/
125+
protected computeValue(secret: Secret<string>) {
126+
this.value = new Secret(
127+
`${base64.urlEncode(String(this.identifier))}.${base64.urlEncode(secret.release())}`
128+
)
129+
}
130+
131+
/**
132+
* Check if the token has been expired. Verifies
133+
* the "expiresAt" timestamp with the current
134+
* date.
135+
*/
136+
isExpired() {
137+
return this.expiresAt < new Date()
138+
}
139+
140+
/**
141+
* Verifies the value of a token against the pre-defined hash
142+
*/
143+
verify(secret: Secret<string>): boolean {
144+
const newHash = createHash('sha256').update(secret.release()).digest('hex')
145+
return safeEqual(this.hash, newHash)
146+
}
147+
}

tests/verification_token.spec.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* @adonisjs/persona
3+
*
4+
* (C) AdonisJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import timekeeper from 'timekeeper'
11+
import { Secret, base64 } from '@poppinss/utils'
12+
import { getActiveTest, test } from '@japa/runner'
13+
14+
import { VerificationToken } from '../src/helpers/verification_token.js'
15+
16+
function freezeTime() {
17+
const t = getActiveTest()
18+
if (!t) {
19+
throw new Error('Cannot use "freezeTime" outside of a Japa test')
20+
}
21+
22+
timekeeper.reset()
23+
24+
const date = new Date()
25+
timekeeper.freeze(date)
26+
27+
t.cleanup(() => {
28+
timekeeper.reset()
29+
})
30+
}
31+
32+
class EmailVerificationToken extends VerificationToken {
33+
constructor(props: {
34+
identifier: number
35+
tokenableId: number
36+
hash: string
37+
expiresAt: Date
38+
secret?: Secret<string>
39+
}) {
40+
super()
41+
this.identifier = props.identifier
42+
this.tokenableId = props.tokenableId
43+
this.hash = props.hash
44+
this.expiresAt = props.expiresAt
45+
if (props.secret) {
46+
this.computeValue(props.secret)
47+
}
48+
}
49+
}
50+
51+
test.group('VerificationToken token | decode', () => {
52+
test('decode "{input}" as token')
53+
.with([
54+
{
55+
input: null,
56+
output: null,
57+
},
58+
{
59+
input: '',
60+
output: null,
61+
},
62+
{
63+
input: '..',
64+
output: null,
65+
},
66+
{
67+
input: 'foobar',
68+
output: null,
69+
},
70+
{
71+
input: 'foo.baz',
72+
output: null,
73+
},
74+
{
75+
input: `bar.${base64.urlEncode('baz')}`,
76+
output: null,
77+
},
78+
{
79+
input: `${base64.urlEncode('baz')}.bar`,
80+
output: null,
81+
},
82+
{
83+
input: `${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`,
84+
output: {
85+
identifier: 'bar',
86+
secret: 'baz',
87+
},
88+
},
89+
])
90+
.run(({ assert }, { input, output }) => {
91+
const decoded = VerificationToken.decode(input as string)
92+
if (!decoded) {
93+
assert.deepEqual(decoded, output)
94+
} else {
95+
assert.deepEqual(
96+
{ identifier: decoded.identifier, secret: decoded.secret.release() },
97+
output
98+
)
99+
}
100+
})
101+
})
102+
103+
test.group('VerificationToken token | create', () => {
104+
test('create a transient token', ({ assert }) => {
105+
freezeTime()
106+
const date = new Date()
107+
const expiresAt = new Date()
108+
expiresAt.setSeconds(date.getSeconds() + 60 * 20)
109+
110+
const token = VerificationToken.createTransientToken(1, 40, '20 mins')
111+
assert.equal(token.userId, 1)
112+
assert.exists(token.hash)
113+
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime())
114+
assert.instanceOf(token.secret, Secret)
115+
})
116+
117+
test('create token from persisted information', ({ assert }) => {
118+
const createdAt = new Date()
119+
const expiresAt = new Date()
120+
expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20)
121+
122+
const token = new EmailVerificationToken({
123+
identifier: 12,
124+
tokenableId: 1,
125+
hash: '1234',
126+
expiresAt,
127+
})
128+
129+
assert.equal(token.identifier, 12)
130+
assert.equal(token.hash, '1234')
131+
assert.equal(token.tokenableId, 1)
132+
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime())
133+
134+
assert.isUndefined(token.value)
135+
assert.isFalse(token.isExpired())
136+
})
137+
138+
test('create token with a secret', ({ assert }) => {
139+
const createdAt = new Date()
140+
const expiresAt = new Date()
141+
expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20)
142+
143+
const transientToken = EmailVerificationToken.createTransientToken(1, 40, '20 mins')
144+
145+
const token = new EmailVerificationToken({
146+
identifier: 12,
147+
tokenableId: 1,
148+
hash: transientToken.hash,
149+
expiresAt,
150+
secret: transientToken.secret,
151+
})
152+
153+
const decoded = EmailVerificationToken.decode(token.value!.release())
154+
155+
assert.equal(token.identifier, 12)
156+
assert.equal(token.tokenableId, 1)
157+
assert.equal(token.hash, transientToken.hash)
158+
assert.instanceOf(token.value, Secret)
159+
assert.isTrue(token.verify(transientToken.secret))
160+
assert.isTrue(token.verify(decoded!.secret))
161+
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime())
162+
assert.isFalse(token.isExpired())
163+
})
164+
165+
test('verify token hash', ({ assert }) => {
166+
const transientToken = EmailVerificationToken.createTransientToken(1, 40, '20 mins')
167+
168+
const token = new EmailVerificationToken({
169+
identifier: 12,
170+
tokenableId: 1,
171+
hash: transientToken.hash,
172+
expiresAt: new Date(),
173+
secret: transientToken.secret,
174+
})
175+
176+
assert.isTrue(token.verify(transientToken.secret))
177+
})
178+
})

0 commit comments

Comments
 (0)