diff --git a/commands/test.ts b/commands/test.ts index 1611cd40..f68864bc 100644 --- a/commands/test.ts +++ b/commands/test.ts @@ -146,6 +146,8 @@ export default class Test extends BaseCommand { * Runs tests */ async run() { + process.env.NODE_ENV = 'test' + const assembler = await importAssembler(this.app) if (!assembler) { this.#logMissingDevelopmentDependency('@adonisjs/assembler') @@ -175,6 +177,9 @@ export default class Test extends BaseCommand { files: suite.files, } }), + env: { + NODE_ENV: 'test', + }, metaFiles: this.app.rcFile.metaFiles, }) diff --git a/eslint.config.js b/eslint.config.js index e4cad4b9..6c99b74d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,2 +1,4 @@ import { configPkg } from '@adonisjs/eslint-config' -export default configPkg() +export default configPkg({ + ignores: ['coverage'], +}) diff --git a/package.json b/package.json index 4f9c898e..27d36258 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@adonisjs/core", "description": "Core of AdonisJS", - "version": "6.15.2", + "version": "6.16.0", "engines": { "node": ">=20.6.0" }, @@ -94,7 +94,7 @@ "@japa/runner": "^3.1.4", "@japa/snapshot": "^2.0.6", "@release-it/conventional-changelog": "^9.0.3", - "@swc/core": "1.9.3", + "@swc/core": "1.10.0", "@types/node": "^22.10.1", "@types/pretty-hrtime": "^1.0.3", "@types/sinon": "^17.0.3", @@ -113,11 +113,12 @@ "get-port": "^7.1.0", "github-label-sync": "^2.3.1", "husky": "^9.1.7", - "prettier": "^3.4.1", + "prettier": "^3.4.2", "release-it": "^17.10.0", "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" }, diff --git a/src/helpers/main.ts b/src/helpers/main.ts index 2e84fef4..1b8d453a 100644 --- a/src/helpers/main.ts +++ b/src/helpers/main.ts @@ -22,4 +22,5 @@ export { fsImportAll, MessageBuilder, } from '@poppinss/utils' +export { VerificationToken } from './verification_token.js' export { parseBindingReference } from './parse_binding_reference.js' diff --git a/src/helpers/verification_token.ts b/src/helpers/verification_token.ts new file mode 100644 index 00000000..d3bea1a2 --- /dev/null +++ b/src/helpers/verification_token.ts @@ -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 } { + /** + * 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 + + /** + * Compute the value property using the given secret. You can + * get secret via the static "createTransientToken" method. + */ + protected computeValue(secret: Secret) { + 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): boolean { + const newHash = createHash('sha256').update(secret.release()).digest('hex') + return safeEqual(this.hash, newHash) + } +} diff --git a/tests/verification_token.spec.ts b/tests/verification_token.spec.ts new file mode 100644 index 00000000..c7de3f93 --- /dev/null +++ b/tests/verification_token.spec.ts @@ -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 + }) { + 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)) + }) +})