/** * Shared base error class — the single error structure behind every * `@exortek/*` package's `errors.js`. * * Every package keeps its own class identity with a one-liner subclass; * codes stay per-package frozen maps, status mapping is declared as a * static field: * * import { BaseError } from '@exortek/shared/errors'; * * export const ErrorCode = Object.freeze({ * INVALID_ARGUMENT: 'INVALID_ARGUMENT', * INVALID_TOKEN: 'INVALID_TOKEN', * }); * * export class JwtError extends BaseError { * static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 }; * static defaultStatus = 500; * } * * Instances carry a stable machine-readable `code` (branch on this, * never on the message), an optional HTTP `status`, an optional * `details` object, and the standard `cause` chain. */ declare class BaseError extends Error { /** * Optional `code → HTTP status` map declared on the subclass. When * absent the instance carries no `status` at all — for HTTP-agnostic * packages like `@exortek/crypto`. * * @type {Record | undefined} */ static statuses: Record | undefined; /** * Fallback status for codes missing from `statuses`. * * @type {number} */ static defaultStatus: number; /** * @param {string} code Stable machine-readable code; branch on this. * @param {string} message Human-readable diagnostic. Free-form; may * change across versions. * @param {{ cause?: unknown, status?: number, details?: Record }} [options] */ constructor(code: string, message: string, options?: { cause?: unknown; status?: number; details?: Record; }); /** @type {string} */ code: string; /** @type {number | undefined} */ status: number | undefined; /** @type {Record | undefined} */ details: Record | undefined; } declare const ErrorCode: Readonly<{ INVALID_ARGUMENT: "INVALID_ARGUMENT"; INVALID_SECRET: "INVALID_SECRET"; }>; /** * Every recoverable failure raised by this package. Carries a stable * `code` (from {@link ErrorCode}) and a `status` — the HTTP response * status a middleware layer would use when translating the error. */ declare class ChallengeError extends BaseError { static statuses: { INVALID_ARGUMENT: number; INVALID_SECRET: number; }; } /** * @typedef {'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code' * | 'passkey' | 'magic_link' | 'password' | 'webauthn' | 'oauth' | 'oidc' * | string} ChallengeMethod */ /** * @typedef {object} IncrStore * @property {(key: string, ttlMs: number) => Promise<{ count: number }>} incr * Atomic increment-with-expiry. First call returns `{ count: 1 }` and * arms a TTL; subsequent calls before expiry return the incremented * count. Used as compare-and-set for single-use enforcement. */ /** * @typedef {object} ChallengePayload * @property {string} jti * @property {number} iat * @property {number} exp * @property {string} [userId] * @property {ChallengeMethod} [method] * @property {string} [step] * @property {string} [nextStep] * @property {string} [ip] Only set when `ipBinding: true`. * @property {string} [ua] Only set when `ua` supplied. * @property {Record} [meta] */ /** * @typedef {object} CreateChallengeOptions * @property {string | Buffer | Uint8Array} secret * HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is * interpreted as UTF-8 — for a hex or base64 secret, decode to * Buffer first. * @property {string} [userId] * @property {ChallengeMethod} [method] * @property {string} [step] * @property {string} [nextStep] * @property {string} [ip] * @property {string} [ua] * @property {Record} [metadata] * @property {string | number} expiresIn Duration string (`'5m'`) or ms integer. * @property {boolean} [singleUse=false] * When true, the returned token can only be verified once — subsequent * verifies with `consume: true` fail with `reason: 'replay'`. Requires * `store` to be supplied. * @property {IncrStore} [store] * Any object exposing `incr(key, ttlMs) → { count }`. Compatible with * `@exortek/security`'s rate-limit stores; also easy to wrap Redis. * @property {boolean} [ipBinding=false] * When true, the caller-supplied `ip` is stamped into the payload and * `verifyChallenge` will reject a request whose `ip` differs. * @property {string} [prefix='chall_v1'] * Wire-format prefix. Defaults to `'chall_v1'` — the value shipped * with this package. Callers can override to brand the token * family (e.g. `'server_challenge'`, `'myapp_v1'`); must match * `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at * verify time or verification returns `reason: 'malformed'`. * @property {number} [now] Override `Date.now()` for testing. */ /** * @typedef {object} VerifyChallengeOptions * @property {string | Buffer | Uint8Array} secret * @property {boolean} [consume=false] * Enforce single-use. Requires `store` (typically the same one used * at create time). * @property {IncrStore} [store] * @property {string} [expectedUserId] * @property {ChallengeMethod} [expectedMethod] * @property {string} [expectedStep] * @property {string} [expectedNextStep] * @property {string} [ip] * The current request's IP. Required to verify a token that was * created with `ipBinding: true`; ignored otherwise. * @property {string} [prefix='chall_v1'] * Wire-format prefix. Must match the value passed to * `createChallenge` — a token minted with a different prefix will * fail with `reason: 'malformed'`. * @property {number} [now] Override `Date.now()` for testing. */ /** * @typedef {'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid' * | 'user_mismatch' | 'method_mismatch' | 'step_mismatch' * | 'next_step_mismatch' | 'ip_mismatch' | 'ip_missing' | 'replay' * | 'store_unavailable'} VerifyFailureReason */ /** * @typedef {{ valid: true, payload: ChallengePayload } * | { valid: false, reason: VerifyFailureReason }} VerifyChallengeResult */ /** * Create a signed challenge token. * * const token = await createChallenge({ * secret: process.env.CHALLENGE_SECRET, * userId: 'usr_123', * method: 'totp', * step: 'mfa_verified', * nextStep: 'login', * expiresIn: '5m', * singleUse: true, * store, * }) * * @param {CreateChallengeOptions} options * @returns {Promise} */ declare function createChallenge(options: CreateChallengeOptions): Promise; /** * Verify a challenge token. Returns `{ valid: true, payload }` on * success or `{ valid: false, reason }` on any expected failure. Only * throws on programmer errors (bad options, missing secret). * * const res = await verifyChallenge(token, { * secret: process.env.CHALLENGE_SECRET, * consume: true, * store, * expectedUserId: pendingUserId, * expectedMethod: 'totp', * ip: req.ip, * }) * if (!res.valid) return reply.code(401).send({ error: res.reason }) * * @param {string} token * @param {VerifyChallengeOptions} options * @returns {Promise} */ declare function verifyChallenge(token: string, options: VerifyChallengeOptions): Promise; type ChallengeMethod = "totp" | "hotp" | "email_otp" | "sms_otp" | "backup_code" | "passkey" | "magic_link" | "password" | "webauthn" | "oauth" | "oidc" | string; type IncrStore = { /** * Atomic increment-with-expiry. First call returns `{ count: 1 }` and * arms a TTL; subsequent calls before expiry return the incremented * count. Used as compare-and-set for single-use enforcement. */ incr: (key: string, ttlMs: number) => Promise<{ count: number; }>; }; type ChallengePayload = { jti: string; iat: number; exp: number; userId?: string | undefined; method?: string | undefined; step?: string | undefined; nextStep?: string | undefined; /** * Only set when `ipBinding: true`. */ ip?: string | undefined; /** * Only set when `ua` supplied. */ ua?: string | undefined; meta?: Record | undefined; }; type CreateChallengeOptions = { /** * HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is * interpreted as UTF-8 — for a hex or base64 secret, decode to * Buffer first. */ secret: string | Buffer | Uint8Array; userId?: string | undefined; method?: string | undefined; step?: string | undefined; nextStep?: string | undefined; ip?: string | undefined; ua?: string | undefined; metadata?: Record | undefined; /** * Duration string (`'5m'`) or ms integer. */ expiresIn: string | number; /** * When true, the returned token can only be verified once — subsequent * verifies with `consume: true` fail with `reason: 'replay'`. Requires * `store` to be supplied. */ singleUse?: boolean | undefined; /** * Any object exposing `incr(key, ttlMs) → { count }`. Compatible with * `@exortek/security`'s rate-limit stores; also easy to wrap Redis. */ store?: IncrStore | undefined; /** * When true, the caller-supplied `ip` is stamped into the payload and * `verifyChallenge` will reject a request whose `ip` differs. */ ipBinding?: boolean | undefined; /** * Wire-format prefix. Defaults to `'chall_v1'` — the value shipped * with this package. Callers can override to brand the token * family (e.g. `'server_challenge'`, `'myapp_v1'`); must match * `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at * verify time or verification returns `reason: 'malformed'`. */ prefix?: string | undefined; /** * Override `Date.now()` for testing. */ now?: number | undefined; }; type VerifyChallengeOptions = { secret: string | Buffer | Uint8Array; /** * Enforce single-use. Requires `store` (typically the same one used * at create time). */ consume?: boolean | undefined; store?: IncrStore | undefined; expectedUserId?: string | undefined; expectedMethod?: string | undefined; expectedStep?: string | undefined; expectedNextStep?: string | undefined; /** * The current request's IP. Required to verify a token that was * created with `ipBinding: true`; ignored otherwise. */ ip?: string | undefined; /** * Wire-format prefix. Must match the value passed to * `createChallenge` — a token minted with a different prefix will * fail with `reason: 'malformed'`. */ prefix?: string | undefined; /** * Override `Date.now()` for testing. */ now?: number | undefined; }; type VerifyFailureReason = "malformed" | "bad_signature" | "expired" | "not_yet_valid" | "user_mismatch" | "method_mismatch" | "step_mismatch" | "next_step_mismatch" | "ip_mismatch" | "ip_missing" | "replay" | "store_unavailable"; type VerifyChallengeResult = { valid: true; payload: ChallengePayload; } | { valid: false; reason: VerifyFailureReason; }; export { ChallengeError, ErrorCode, createChallenge, verifyChallenge }; export type { ChallengeMethod, ChallengePayload, CreateChallengeOptions, IncrStore, VerifyChallengeOptions, VerifyChallengeResult, VerifyFailureReason };