TOTP Code Generator (RFC 6238)

Generate and verify time-based one-time passwords from base32 secrets, with configurable digits, period and HMAC algorithm.

TypescriptAuth Jwt

Code

snippet
import { createHmac, timingSafeEqual } from "node:crypto";

export interface TotpOptions {
  digits?: number;
  period?: number;
  algorithm?: "sha1" | "sha256" | "sha512";
}

export function base32Decode(secret: string): Buffer {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  const clean = secret.toUpperCase().replace(/[\s=-]/g, "");
  let bits = 0;
  let value = 0;
  const bytes: number[] = [];
  for (const character of clean) {
    const index = alphabet.indexOf(character);
    if (index === -1) throw new TypeError("invalid base32 character: " + character);
    value = (value << 5) | index;
    bits += 5;
    if (bits >= 8) {
      bytes.push((value >>> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return Buffer.from(bytes);
}

export function generateTotp(secret: string, timestamp = Date.now(), options: TotpOptions = {}): string {
  const { digits = 6, period = 30, algorithm = "sha1" } = options;
  if (!Number.isInteger(digits) || digits < 6 || digits > 10) {
    throw new RangeError("digits must be an integer between 6 and 10");
  }
  if (!Number.isInteger(period) || period <= 0) throw new RangeError("period must be a positive integer");
  const counter = Math.floor(timestamp / 1000 / period);
  const buffer = Buffer.alloc(8);
  buffer.writeBigUInt64BE(BigInt(counter));
  const hmac = createHmac(algorithm, base32Decode(secret)).update(buffer).digest();
  const offset = hmac[hmac.length - 1]! & 0x0f;
  const binary = ((hmac[offset]! & 0x7f) << 24)
    | (hmac[offset + 1]! << 16)
    | (hmac[offset + 2]! << 8)
    | hmac[offset + 3]!;
  return String(binary % 10 ** digits).padStart(digits, "0");
}

export function verifyTotp(
  secret: string,
  token: string,
  timestamp = Date.now(),
  window = 1,
  options: TotpOptions = {},
): boolean {
  if (!/^\d+$/.test(token)) return false;
  if (!Number.isInteger(window) || window < 0) {
    throw new RangeError("window must be a non-negative integer");
  }
  const periodSeconds = options.period ?? 30;
  for (let offset = -window; offset <= window; offset += 1) {
    const candidate = generateTotp(secret, timestamp + offset * periodSeconds * 1000, options);
    if (candidate.length === token.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(token))) {
      return true;
    }
  }
  return false;
}