ProvenPin Install

Hashed session tokens and TOTP 2FA with the Oslo libraries: working versions (tested 2026-10-15)

Lucia-guide style sessions with random tokens stored only as sha256 hashes, sliding expiry and per-user revocation, plus TOTP enrolment URIs, grace-window verification and single-use recovery codes using @oslojs/crypto, @oslojs/encoding and @oslojs/otp, tested with node:test mock timers.

passed5 passed · 0 failed2026-10-15 15:02 UTC
Status
passed
Last passed
2026-10-15 15:02 UTC
Tests
5 / 5
Language
TypeScript
Runtime
node v22.23.3
pinned versions5
@oslojs/crypto@1.0.1@oslojs/encoding@1.1.0@oslojs/otp@1.1.0tsx@4.23.15@types/node@26.6.5
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38755782594

The code

Exactly the files that passed. Your agent can pull them with get_recipe("oslo-sessions-totp-2fa") (install), or see them on GitHub.

package.json
{
  "name": "oslo-sessions-totp-2fa",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "@oslojs/crypto": "1.0.1",
    "@oslojs/encoding": "1.1.0",
    "@oslojs/otp": "1.1.0"
  },
  "devDependencies": {
    "tsx": "4.23.15",
    "@types/node": "26.6.5"
  }
}
src/auth.ts
import { sha256 } from "@oslojs/crypto/sha2";
import { constantTimeEqual } from "@oslojs/crypto/subtle";
import { generateRandomString, type RandomReader } from "@oslojs/crypto/random";
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase, encodeBase32UpperCaseNoPadding } from "@oslojs/encoding";
import { createTOTPKeyURI, verifyTOTPWithGracePeriod } from "@oslojs/otp";

const DAY = 24 * 60 * 60 * 1000;
const SESSION_TTL = 30 * DAY;

export type Session = { id: string; userId: string; expiresAt: Date; twoFactorVerified: boolean };

const random: RandomReader = { read: (bytes) => crypto.getRandomValues(bytes) };

/**
 * Sessions in the style of the Lucia guide: the client holds a random token, the server stores
 * only sha256(token), so a leaked session table cannot be replayed.
 */
export function createSessionStore() {
  const sessions = new Map<string, Session>();
  const hashToken = (token: string) => encodeHexLowerCase(sha256(new TextEncoder().encode(token)));

  return {
    sessions,
    generateToken() {
      const bytes = new Uint8Array(20);
      crypto.getRandomValues(bytes);
      return encodeBase32LowerCaseNoPadding(bytes); // 160 bits of entropy
    },
    create(token: string, userId: string): Session {
      const s = { id: hashToken(token), userId, expiresAt: new Date(Date.now() + SESSION_TTL), twoFactorVerified: false };
      sessions.set(s.id, s);
      return s;
    },
    /** Validates a token; slides expiry when less than 15 days remain; deletes expired sessions. */
    validate(token: string): Session | null {
      const s = sessions.get(hashToken(token));
      if (!s) return null;
      if (Date.now() >= s.expiresAt.getTime()) { sessions.delete(s.id); return null; }
      if (Date.now() >= s.expiresAt.getTime() - SESSION_TTL / 2) s.expiresAt = new Date(Date.now() + SESSION_TTL);
      return s;
    },
    invalidate(token: string) { sessions.delete(hashToken(token)); },
    invalidateAllForUser(userId: string) { for (const [id, s] of sessions) if (s.userId === userId) sessions.delete(id); },
  };
}

/** TOTP enrolment: a 20-byte secret, an otpauth:// URI for the QR code, and a manual entry key. */
export function enrollTotp(issuer: string, account: string, key = crypto.getRandomValues(new Uint8Array(20))) {
  return { key, uri: createTOTPKeyURI(issuer, account, key, 30, 6), manualKey: encodeBase32UpperCaseNoPadding(key) };
}

/** Accepts the current code or one from the previous/next 30s window to allow for clock drift. */
export function verifyTotp(key: Uint8Array, code: string) {
  return /^\d{6}$/.test(code) && verifyTOTPWithGracePeriod(key, 30, 6, code, 30);
}

/** Single-use recovery codes, stored hashed and compared in constant time. */
export function makeRecoveryCodes(n = 8) {
  const codes = Array.from({ length: n }, () => generateRandomString(random, "ABCDEFGHJKLMNPQRSTUVWXYZ23456789", 10));
  const hashes = codes.map((c) => sha256(new TextEncoder().encode(c)));
  return {
    codes,
    use(code: string) {
      const h = sha256(new TextEncoder().encode(code.toUpperCase()));
      const i = hashes.findIndex((x) => constantTimeEqual(x, h));
      if (i === -1) return false;
      hashes.splice(i, 1);
      return true;
    },
    remaining: () => hashes.length,
  };
}
test/auth.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { generateTOTP, generateHOTP } from "@oslojs/otp";
import { decodeBase32IgnorePadding } from "@oslojs/encoding";
import { createSessionStore, enrollTotp, verifyTotp, makeRecoveryCodes } from "../src/auth.ts";

const DAY = 86_400_000;

test("stores only a hash of the session token", () => {
  const store = createSessionStore();
  const token = store.generateToken();
  assert.match(token, /^[a-z2-7]{32}$/);
  const s = store.create(token, "u1");
  assert.notEqual(s.id, token);
  assert.match(s.id, /^[0-9a-f]{64}$/);
  assert.equal(store.validate(token)?.userId, "u1");
  assert.equal(store.validate(s.id), null); // the stored hash is not a usable token
});

test("sessions slide, expire and can be revoked per user", (t) => {
  t.mock.timers.enable({ apis: ["Date"], now: new Date("2026-10-01T00:00:00Z") });
  const store = createSessionStore();
  const a = store.generateToken(), b = store.generateToken();
  store.create(a, "u1"); store.create(b, "u1");
  t.mock.timers.tick(20 * DAY);
  const slid = store.validate(a);
  assert.equal(slid?.expiresAt.toISOString(), "2026-11-20T00:00:00.000Z"); // extended by 30 days from day 20
  t.mock.timers.tick(31 * DAY);
  assert.equal(store.validate(a), null);
  assert.equal(store.sessions.size, 1);
  store.invalidateAllForUser("u1");
  assert.equal(store.sessions.size, 0);
});

test("TOTP enrolment URI and manual key encode the same secret", () => {
  const e = enrollTotp("ProvenPin Demo", "[email protected]");
  const uri = new URL(e.uri);
  assert.equal(uri.protocol, "otpauth:");
  assert.equal(uri.searchParams.get("issuer"), "ProvenPin Demo");
  assert.equal(uri.searchParams.get("period"), "30");
  assert.equal(uri.searchParams.get("digits"), "6");
  assert.deepEqual(decodeBase32IgnorePadding(uri.searchParams.get("secret")!), e.key);
  assert.deepEqual(decodeBase32IgnorePadding(e.manualKey), e.key);
});

test("TOTP codes verify within the grace window only", (t) => {
  // RFC 6238 SHA-1 test secret; at T=59s the 8-digit code is 94287082, so 6 digits is 287082.
  const key = new TextEncoder().encode("12345678901234567890");
  t.mock.timers.enable({ apis: ["Date"], now: 59_000 });
  assert.equal(generateTOTP(key, 30, 8), "94287082");
  assert.equal(generateHOTP(key, 1n, 6), "287082");
  const code = generateTOTP(key, 30, 6);
  assert.equal(verifyTotp(key, code), true);
  t.mock.timers.tick(30_000); // next window: previous code still accepted
  assert.equal(verifyTotp(key, code), true);
  t.mock.timers.tick(60_000); // two windows later: rejected
  assert.equal(verifyTotp(key, code), false);
  assert.equal(verifyTotp(key, "12345"), false);
});

test("recovery codes are single use and case-insensitive", () => {
  const r = makeRecoveryCodes(3);
  assert.equal(new Set(r.codes).size, 3);
  for (const c of r.codes) assert.match(c, /^[A-HJ-NP-Z2-9]{10}$/);
  assert.equal(r.use(r.codes[0].toLowerCase()), true);
  assert.equal(r.use(r.codes[0]), false);
  assert.equal(r.remaining(), 2);
  assert.equal(r.use("WRONGCODE1"), false);
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.