feat(auth): generated one-time passwords for new accounts

This commit is contained in:
Dave 2026-09-16 11:25:52 +02:00
parent d1fdc72d29
commit ed048c5bb3
2 changed files with 70 additions and 0 deletions

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { ALPHABET, generateInitialPassword } from "@/lib/auth/initial-password";
describe("generateInitialPassword", () => {
it("produces three groups of four, hyphen separated", () => {
expect(generateInitialPassword()).toMatch(/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/);
});
it("clears PocketBase's eight-character minimum", () => {
expect(generateInitialPassword().length).toBeGreaterThanOrEqual(8);
});
it("omits characters that are misread when typed from a screen", () => {
// The owner reads this aloud or copies it by hand into an email, so 0/O
// and 1/l/I must not both be possible.
for (const banned of ["0", "o", "1", "l", "i"]) {
expect(ALPHABET).not.toContain(banned);
}
});
it("draws only from the declared alphabet", () => {
for (let i = 0; i < 50; i++) {
for (const char of generateInitialPassword().replace(/-/g, "")) {
expect(ALPHABET).toContain(char);
}
}
});
it("does not repeat across calls", () => {
const seen = new Set<string>();
for (let i = 0; i < 200; i++) seen.add(generateInitialPassword());
expect(seen.size).toBe(200);
});
});

View file

@ -0,0 +1,36 @@
/**
* A one-time password for a newly issued client-manager account.
*
* Generated rather than chosen, because the alternative is the owner inventing
* one password and reusing it for every client they onboard.
*
* Uses the Web Crypto API, not Math.random. Math.random is seeded from
* predictable state and is not a credential source; the two are one character
* apart to write and worlds apart in consequence.
*/
/**
* Deliberately excludes 0/o, 1/l/i and u/v — the pairs that get misread when
* the owner copies this into an email or reads it over the phone.
*/
export const ALPHABET = "23456789abcdefghjkmnpqrstwxyz";
const GROUPS = 3;
const GROUP_LENGTH = 4;
export function generateInitialPassword(): string {
const bytes = new Uint32Array(GROUPS * GROUP_LENGTH);
crypto.getRandomValues(bytes);
const chars = Array.from(
bytes,
(value) => ALPHABET[value % ALPHABET.length]
);
const groups: string[] = [];
for (let i = 0; i < GROUPS; i++) {
groups.push(chars.slice(i * GROUP_LENGTH, (i + 1) * GROUP_LENGTH).join(""));
}
return groups.join("-");
}