diff --git a/packages/security/.gitignore b/packages/security/.gitignore new file mode 100644 index 000000000..ec30f97f2 --- /dev/null +++ b/packages/security/.gitignore @@ -0,0 +1,4 @@ +index.js +index.d.ts +lib/*.js +lib/*.d.ts diff --git a/packages/security/README.md b/packages/security/README.md index ff4de7de0..23efd96c5 100644 --- a/packages/security/README.md +++ b/packages/security/README.md @@ -59,6 +59,13 @@ This option was renamed from `password` to `passwordHash`. Callers must update both methods when upgrading. The token format is unchanged, so existing reset tokens remain valid with the same stored hash, site secret, and expiry. +## Development + +Source lives in `index.ts` and `lib/*.ts`. `pnpm build` emits CommonJS JavaScript +and declarations alongside the sources; only compiled files are published. + +Run `pnpm test` for the build, type checks, runtime tests, coverage, and lint. + # Copyright & License Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/packages/security/index.js b/packages/security/index.js deleted file mode 100644 index d244283ca..000000000 --- a/packages/security/index.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - get url() { - return require('./lib/url'); - }, - - get tokens() { - return require('./lib/tokens'); - }, - - get string() { - return require('./lib/string'); - }, - - get identifier() { - return require('./lib/identifier'); - }, - - get password() { - return require('./lib/password'); - }, - - get secret() { - return require('./lib/secret'); - }, -}; diff --git a/packages/security/index.ts b/packages/security/index.ts new file mode 100644 index 000000000..0cafc4809 --- /dev/null +++ b/packages/security/index.ts @@ -0,0 +1,25 @@ +export = { + get url(): typeof import('./lib/url') { + return require('./lib/url'); + }, + + get tokens(): typeof import('./lib/tokens') { + return require('./lib/tokens'); + }, + + get string(): typeof import('./lib/string') { + return require('./lib/string'); + }, + + get identifier(): typeof import('./lib/identifier') { + return require('./lib/identifier'); + }, + + get password(): typeof import('./lib/password') { + return require('./lib/password'); + }, + + get secret(): typeof import('./lib/secret') { + return require('./lib/secret'); + }, +}; diff --git a/packages/security/lib/identifier.js b/packages/security/lib/identifier.ts similarity index 67% rename from packages/security/lib/identifier.js rename to packages/security/lib/identifier.ts index f8dcc9f00..bb9c4a7d0 100644 --- a/packages/security/lib/identifier.js +++ b/packages/security/lib/identifier.ts @@ -1,8 +1,6 @@ -let _private = {}; - -_private.getRandomInt = function (min, max) { +function getRandomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; -}; +} /** * Return a unique identifier with the given `len`. @@ -11,15 +9,15 @@ _private.getRandomInt = function (min, max) { * @param {Number} maxLength * @return {String} */ -module.exports.uid = function uid(maxLength) { - const buf = []; +export function uid(maxLength: number): string { + const buf: string[] = []; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const charLength = chars.length; let i; for (i = 0; i < maxLength; i = i + 1) { - buf.push(chars[_private.getRandomInt(0, charLength - 1)]); + buf.push(chars[getRandomInt(0, charLength - 1)]); } return buf.join(''); -}; +} diff --git a/packages/security/lib/password.js b/packages/security/lib/password.ts similarity index 56% rename from packages/security/lib/password.js rename to packages/security/lib/password.ts index 0bf906481..28ba49f7c 100644 --- a/packages/security/lib/password.js +++ b/packages/security/lib/password.ts @@ -1,4 +1,4 @@ -const bcrypt = require('bcryptjs'); +import bcrypt from 'bcryptjs'; let HASH_ROUNDS = 10; @@ -6,11 +6,11 @@ if (process.env.NODE_ENV?.startsWith('testing')) { HASH_ROUNDS = 1; } -module.exports.hash = async function hash(plainPassword) { +export async function hash(plainPassword: string): Promise { const salt = await bcrypt.genSalt(HASH_ROUNDS); return bcrypt.hash(plainPassword, salt); -}; +} -module.exports.compare = function compare(plainPassword, hashedPassword) { +export function compare(plainPassword: string, hashedPassword: string): Promise { return bcrypt.compare(plainPassword, hashedPassword); -}; +} diff --git a/packages/security/lib/secret.js b/packages/security/lib/secret.ts similarity index 76% rename from packages/security/lib/secret.js rename to packages/security/lib/secret.ts index 205e03b93..d591e4188 100644 --- a/packages/security/lib/secret.js +++ b/packages/security/lib/secret.ts @@ -1,4 +1,4 @@ -const crypto = require('crypto'); +import { randomBytes } from 'node:crypto'; /* * Uses birthday problem estimation to calculate chance of collision @@ -21,11 +21,11 @@ const crypto = require('crypto'); * @param {String|Number} [typeOrLength=64] * @returns */ -module.exports.create = (typeOrLength) => { +export function create(typeOrLength?: string | number): string { let bytes; let length; - if (Number.isInteger(typeOrLength)) { + if (typeof typeOrLength === 'number' && Number.isInteger(typeOrLength)) { bytes = Math.ceil(typeOrLength / 2); length = typeOrLength; } else if (typeOrLength === 'content') { @@ -36,5 +36,5 @@ module.exports.create = (typeOrLength) => { length = 64; } - return crypto.randomBytes(bytes).toString('hex').slice(0, length); -}; + return randomBytes(bytes).toString('hex').slice(0, length); +} diff --git a/packages/security/lib/string.js b/packages/security/lib/string.ts similarity index 53% rename from packages/security/lib/string.js rename to packages/security/lib/string.ts index 3dc1865ea..e92d2bc91 100644 --- a/packages/security/lib/string.js +++ b/packages/security/lib/string.ts @@ -1,9 +1,9 @@ -const slugify = require('@tryghost/string').slugify; +import { slugify } from '@tryghost/string'; /** * @param {string} string * @param {{importing?: boolean}} [options] */ -module.exports.safe = function safe(string, options = {}) { +export function safe(string?: string | null, options: { importing?: boolean } = {}): string { return slugify(string, { requiredChangesOnly: options.importing === true }); -}; +} diff --git a/packages/security/lib/tokens.js b/packages/security/lib/tokens.ts similarity index 56% rename from packages/security/lib/tokens.js rename to packages/security/lib/tokens.ts index d951641bf..e31290f9e 100644 --- a/packages/security/lib/tokens.js +++ b/packages/security/lib/tokens.ts @@ -1,10 +1,22 @@ -const crypto = require('crypto'); - -module.exports.generateFromContent = function generateFromContent(options) { - options = options || {}; +import { createHash, type BinaryLike } from 'node:crypto'; + +type ContentTokenOptions = { content: BinaryLike }; +type EmailTokenOptions = { expires: number | string; email: string; secret: string }; +type ResetTokenOptions = { + expires: number | string; + email: string; + dbHash: string; + passwordHash: BinaryLike; +}; +type ExtractedToken = { expires: number; email: string }; +type TokenComparison = { + correct: boolean; + reason?: 'invalid_expiry' | 'expired' | 'invalid'; +}; - const hash = crypto.createHash('sha256'); - const content = options.content; +export function generateFromContent(options: ContentTokenOptions): string { + const hash = createHash('sha256'); + const content = options?.content; let text = ''; @@ -12,15 +24,13 @@ module.exports.generateFromContent = function generateFromContent(options) { text += [content, hash.digest('base64')].join('|'); return Buffer.from(text).toString('base64'); -}; - -module.exports.generateFromEmail = function generateFromEmail(options) { - options = options || {}; +} - const hash = crypto.createHash('sha256'); - const expires = options.expires; - const email = options.email; - const secret = options.secret; +export function generateFromEmail(options: EmailTokenOptions): string { + const hash = createHash('sha256'); + const expires = options?.expires; + const email = options?.email; + const secret = options?.secret; let text = ''; @@ -30,17 +40,15 @@ module.exports.generateFromEmail = function generateFromEmail(options) { text += [expires, email, hash.digest('base64')].join('|'); return Buffer.from(text).toString('base64'); -}; - -module.exports.resetToken = { - generateHash: function generateHash(options) { - options = options || {}; - - const hash = crypto.createHash('sha256'); - const expires = options.expires; - const email = options.email; - const dbHash = options.dbHash; - const passwordHash = options.passwordHash; +} + +export const resetToken = { + generateHash: function generateHash(options: ResetTokenOptions): string { + const hash = createHash('sha256'); + const expires = options?.expires; + const email = options?.email; + const dbHash = options?.dbHash; + const passwordHash = options?.passwordHash; let text = ''; hash.update(String(expires)); @@ -53,10 +61,8 @@ module.exports.resetToken = { text += [expires, email, hash.digest('base64')].join('|'); return Buffer.from(text).toString('base64'); }, - extract: function extract(options) { - options = options || {}; - - const token = options.token; + extract: function extract(options: { token: string }): ExtractedToken | false { + const token = options?.token; const tokenText = Buffer.from(token, 'base64').toString('ascii'); let parts; let expires; @@ -77,18 +83,20 @@ module.exports.resetToken = { email: email, }; }, - compare: function compare(options) { - options = options || {}; - - const tokenToCompare = options.token; - const parts = exports.resetToken.extract({ token: tokenToCompare }); - const dbHash = options.dbHash; - const passwordHash = options.passwordHash; + compare: function compare(options: { + token: string; + dbHash: string; + passwordHash: BinaryLike; + }): TokenComparison { + const tokenToCompare = options?.token; + const parts = resetToken.extract({ token: tokenToCompare }); + const dbHash = options?.dbHash; + const passwordHash = options?.passwordHash; let generatedToken; let diff = 0; let i; - if (isNaN(parts.expires)) { + if (!parts || isNaN(parts.expires)) { return { correct: false, reason: 'invalid_expiry', @@ -103,7 +111,7 @@ module.exports.resetToken = { }; } - generatedToken = exports.resetToken.generateHash({ + generatedToken = resetToken.generateHash({ email: parts.email, expires: parts.expires, dbHash: dbHash, @@ -118,7 +126,7 @@ module.exports.resetToken = { diff |= tokenToCompare.charCodeAt(i) ^ generatedToken.charCodeAt(i); } - const result = { + const result: TokenComparison = { correct: diff === 0, }; diff --git a/packages/security/lib/url.js b/packages/security/lib/url.ts similarity index 76% rename from packages/security/lib/url.js rename to packages/security/lib/url.ts index 400403740..be7b7ffed 100644 --- a/packages/security/lib/url.js +++ b/packages/security/lib/url.ts @@ -1,14 +1,14 @@ // The token is encoded URL safe by replacing '+' with '-', '\' with '_' and removing '=' // NOTE: the token is not encoded using valid base64 anymore -module.exports.encodeBase64 = function encodeBase64(base64String) { +export function encodeBase64(base64String: string): string { return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -}; +} // Decode url safe base64 encoding and add padding ('=') -module.exports.decodeBase64 = function decodeBase64(base64String) { +export function decodeBase64(base64String: string): string { base64String = base64String.replace(/-/g, '+').replace(/_/g, '/'); while (base64String.length % 4) { base64String += '='; } return base64String; -}; +} diff --git a/packages/security/package.json b/packages/security/package.json index 7902ab1ec..ed30b0ef5 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -11,19 +11,31 @@ }, "files": [ "index.js", - "lib" + "index.d.ts", + "lib/*.js", + "lib/*.d.ts" ], "main": "index.js", + "types": "index.d.ts", "publishConfig": { "access": "public" }, "scripts": { - "dev": "echo \"Implement me!\"", - "test": "NODE_ENV=testing vitest run --config ../../vitest.config.ts --root . --coverage", - "lint": "oxlint -c ../../.oxlintrc.json ." + "dev": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "prepare": "tsc", + "pretest": "pnpm run build", + "test": "pnpm run test:types && NODE_ENV=testing vitest run --coverage", + "test:types": "tsc -p tsconfig.test.json --noEmit", + "lint": "oxlint -c ../../.oxlintrc.json .", + "posttest": "pnpm run lint" }, "dependencies": { "@tryghost/string": "^0.3.5", "bcryptjs": "^3.0.3" + }, + "devDependencies": { + "@types/node": "24.19.2", + "typescript": "catalog:" } } diff --git a/packages/security/test/commonjs.test.js b/packages/security/test/commonjs.test.js new file mode 100644 index 000000000..4e8459ac1 --- /dev/null +++ b/packages/security/test/commonjs.test.js @@ -0,0 +1,24 @@ +const { execFileSync } = require('node:child_process'); + +describe('Security CommonJS compatibility', function () { + it('keeps lazy root getters and legacy lib entry points', function () { + execFileSync(process.execPath, [ + '-e', + ` + const assert = require('node:assert/strict'); + const path = require('node:path'); + const entry = process.argv[1]; + const security = require(entry); + const names = ['url', 'tokens', 'string', 'identifier', 'password', 'secret']; + assert.deepEqual(Object.keys(security), names); + for (const name of names) { + const file = require.resolve(path.join(path.dirname(entry), 'lib', name)); + assert.equal(require.cache[file], undefined); + assert.equal(typeof Object.getOwnPropertyDescriptor(security, name).get, 'function'); + assert.equal(security[name], require(file)); + } + `, + require.resolve('../'), + ]); + }); +}); diff --git a/packages/security/test/secret.test.js b/packages/security/test/secret.test.js index 1a806582f..592c0be13 100644 --- a/packages/security/test/secret.test.js +++ b/packages/security/test/secret.test.js @@ -30,6 +30,11 @@ describe('Lib: Security - Secret', function () { assert.match(secret, /[0-9a-z]+/); }); + it('uses the default length for numeric strings and non-integer numbers', function () { + assert.equal(security.secret.create('15').length, 64); + assert.equal(security.secret.create(15.5).length, 64); + }); + it('generates a 32 byte secret by default', function () { let secret = security.secret.create(); assert.equal(typeof secret, 'string'); diff --git a/packages/security/test/tokens.test.js b/packages/security/test/tokens.test.js index abd0bfd29..9aa14a15a 100644 --- a/packages/security/test/tokens.test.js +++ b/packages/security/test/tokens.test.js @@ -225,6 +225,18 @@ describe('Utils: tokens', function () { assert.equal(result, false); }); + it('compare rejects invalid token structure with invalid_expiry', function () { + const token = Buffer.from('one|two').toString('base64'); + assert.deepEqual( + security.tokens.resetToken.compare({ + token, + dbHash: 'hash', + passwordHash: passwordHash, + }), + { correct: false, reason: 'invalid_expiry' }, + ); + }); + it('can validate an URI encoded reset token', function () { const expires = Date.now() + 60 * 1000; const email = 'test1@ghost.org'; diff --git a/packages/security/test/types.ts b/packages/security/test/types.ts new file mode 100644 index 000000000..200b7bd5e --- /dev/null +++ b/packages/security/test/types.ts @@ -0,0 +1,60 @@ +import type { BinaryLike } from 'node:crypto'; +import security = require('../'); +import * as namespace from '../'; +import { expectTypeOf } from 'vitest'; + +expectTypeOf(security.url.encodeBase64('a+b/c==')).toEqualTypeOf(); +expectTypeOf(namespace.url.decodeBase64('a-b_c')).toEqualTypeOf(); +expectTypeOf(security.password.hash('password')).toEqualTypeOf>(); +expectTypeOf(security.password.compare('password', 'hash')).toEqualTypeOf>(); +expectTypeOf(security.secret.create()).toEqualTypeOf(); +expectTypeOf(security.secret.create(13)).toEqualTypeOf(); +expectTypeOf(security.identifier.uid(24)).toEqualTypeOf(); +expectTypeOf(security.string.safe()).toEqualTypeOf(); +expectTypeOf(security.string.safe(null)).toEqualTypeOf(); +expectTypeOf( + security.tokens.generateFromContent({ content: Buffer.from('content') }), +).toEqualTypeOf(); +expectTypeOf( + security.tokens.generateFromEmail({ + email: 'test@example.com', + expires: 1700000000000, + secret: 'secret', + }), +).toEqualTypeOf(); + +const extracted = security.tokens.resetToken.extract({ token: 'token' }); +if (extracted !== false) { + expectTypeOf(extracted.email).toEqualTypeOf(); + expectTypeOf(extracted.expires).toEqualTypeOf(); +} +const comparison = security.tokens.resetToken.compare({ + token: 'token', + dbHash: 'hash', + passwordHash: 'stored-hash', +}); +expectTypeOf(comparison.correct).toEqualTypeOf(); +expectTypeOf(comparison.reason).toEqualTypeOf< + 'invalid_expiry' | 'expired' | 'invalid' | undefined +>(); +expectTypeOf(security.url.encodeBase64).parameter(0).toEqualTypeOf(); +expectTypeOf(security.identifier.uid).parameter(0).toEqualTypeOf(); +expectTypeOf(security.tokens.resetToken.generateHash).parameter(0).toEqualTypeOf<{ + expires: number | string; + email: string; + dbHash: string; + passwordHash: BinaryLike; +}>(); +expectTypeOf(security.tokens.resetToken.compare).parameter(0).toEqualTypeOf<{ + token: string; + dbHash: string; + passwordHash: BinaryLike; +}>(); +expectTypeOf( + security.tokens.resetToken.generateHash({ + expires: 4102444800000, + email: 'test@example.com', + dbHash: 'site-secret', + passwordHash: Buffer.from('stored-hash'), + }), +).toEqualTypeOf(); diff --git a/packages/security/tsconfig.json b/packages/security/tsconfig.json new file mode 100644 index 000000000..ec8f20fbe --- /dev/null +++ b/packages/security/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "sourceMap": false, + "noEmitOnError": true + }, + "include": ["index.ts", "lib/**/*.ts", "types/**/*.d.ts"] +} diff --git a/packages/security/tsconfig.test.json b/packages/security/tsconfig.test.json new file mode 100644 index 000000000..b22782e77 --- /dev/null +++ b/packages/security/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["index.ts", "lib/**/*.ts", "types/**/*.d.ts", "test/**/*.ts"] +} diff --git a/packages/security/types/tryghost-string.d.ts b/packages/security/types/tryghost-string.d.ts new file mode 100644 index 000000000..a03c42fff --- /dev/null +++ b/packages/security/types/tryghost-string.d.ts @@ -0,0 +1,7 @@ +// @tryghost/string does not publish declarations. Only its consumed API is modeled here. +declare module '@tryghost/string' { + export function slugify( + value: string | null | undefined, + options?: { requiredChangesOnly?: boolean }, + ): string; +} diff --git a/packages/security/vitest.config.ts b/packages/security/vitest.config.ts new file mode 100644 index 000000000..10013bd29 --- /dev/null +++ b/packages/security/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import rootConfig from '../../vitest.config'; + +export default mergeConfig( + rootConfig, + defineConfig({ + test: { + coverage: { + include: ['lib/**/*.js'], + exclude: ['**/*.ts', '**/*.d.ts'], + }, + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 736b39bcc..6d6529fe0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -772,6 +772,13 @@ importers: bcryptjs: specifier: ^3.0.3 version: 3.0.3 + devDependencies: + '@types/node': + specifier: 24.19.2 + version: 24.19.2 + typescript: + specifier: 'catalog:' + version: 7.0.2 packages/server: dependencies: