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
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.