import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' /** * @typedef {Error & { code?: string }} CodedError * @typedef {{ logger?: { info?: (...args: any[]) => void } }} DevEmailArgs * @typedef {{ email?: string, to?: string, code?: string, purpose?: string }} EmailCodeArgs */ /** * @param {string|Buffer} value * @returns {string} */ function b64url(value) { const buf = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8') return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } /** * @param {string} value * @returns {Buffer} */ function b64urlDecode(value) { const pad = value.length % 4 === 0 ? '' : '='.repeat(4 - (value.length % 4)) return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64') } /** * @param {string} message * @param {string} code * @returns {CodedError} */ function codedError(message, code) { const err = /** @type {CodedError} */ (new Error(message)) err.code = code return err } export function createScryptHasher() { const keyLength = 64 return { /** * @param {string} plain * @returns {Promise} */ async hash(plain) { const salt = randomBytes(16) const derived = scryptSync(String(plain), salt, keyLength) return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}` }, /** * @param {string} plain * @param {string} stored * @returns {Promise} */ async verify(plain, stored) { if (typeof stored !== 'string') return false const parts = stored.split('$') if (parts.length !== 3 || parts[0] !== 'scrypt') return false const salt = Buffer.from(parts[1], 'hex') const expected = Buffer.from(parts[2], 'hex') const derived = scryptSync(String(plain), salt, expected.length) return expected.length === derived.length && timingSafeEqual(expected, derived) }, } } /** * @param {{ secret: string }} args * @returns {import('@drama/auth').TokenSigner} */ export function createHmacTokenSigner({ secret }) { if (!secret || typeof secret !== 'string') throw new Error('createHmacTokenSigner: secret required') const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) /** * @param {string} input * @returns {string} */ function signature(input) { return b64url(createHmac('sha256', secret).update(input).digest()) } return { /** * @param {object} payload * @param {{ ttlMs: number }} opts * @returns {Promise} */ async sign(payload, opts) { const nowSec = Math.floor(Date.now() / 1000) const claims = /** @type {Record} */ ({ ...payload, iat: nowSec }) if (opts?.ttlMs && opts.ttlMs > 0) claims.exp = nowSec + Math.floor(opts.ttlMs / 1000) const body = b64url(JSON.stringify(claims)) const input = `${header}.${body}` return `${input}.${signature(input)}` }, /** * @param {string} token * @returns {Promise} */ async verify(token) { if (typeof token !== 'string') throw codedError('token must be a string', 'TOKEN_INVALID') const parts = token.split('.') if (parts.length !== 3) throw codedError('malformed token', 'TOKEN_INVALID') const [headerPart, bodyPart, sig] = parts const expected = signature(`${headerPart}.${bodyPart}`) const a = b64urlDecode(sig) const b = b64urlDecode(expected) if (a.length !== b.length || !timingSafeEqual(a, b)) throw codedError('bad signature', 'TOKEN_INVALID') const claims = /** @type {Record} */ (JSON.parse(b64urlDecode(bodyPart).toString('utf8'))) if (typeof claims.exp === 'number' && Math.floor(Date.now() / 1000) >= claims.exp) { throw codedError('token expired', 'ERR_JWT_EXPIRED') } return claims }, } } export function createDevOauthPort() { return { async exchange() { throw new Error('[voko] oauth.exchange not configured: set GOOGLE_OAUTH_CLIENT_ID') }, } }