security.js 3.99 KB
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<string>}
     */
    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<boolean>}
     */
    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<string>}
     */
    async sign(payload, opts) {
      const nowSec = Math.floor(Date.now() / 1000)
      const claims = /** @type {Record<string, unknown>} */ ({ ...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<object>}
     */
    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<string, unknown>} */ (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')
    },
  }
}