@binary1702/auth 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/AGENTS.md +155 -0
  2. package/CHANGELOG.md +49 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +325 -36
  5. package/README.md +338 -53
  6. package/dist/cli.js +248 -17
  7. package/dist/core/admin-html.d.ts +32 -0
  8. package/dist/core/admin-html.js +350 -0
  9. package/dist/core/admin.d.ts +56 -0
  10. package/dist/core/admin.js +90 -0
  11. package/dist/core/config.d.ts +10 -3
  12. package/dist/core/config.js +22 -8
  13. package/dist/core/db.d.ts +155 -10
  14. package/dist/core/db.js +449 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +52 -0
  18. package/dist/core/guard.js +106 -0
  19. package/dist/core/html.d.ts +43 -0
  20. package/dist/core/html.js +122 -28
  21. package/dist/core/invite.d.ts +7 -0
  22. package/dist/core/invite.js +30 -0
  23. package/dist/core/options.d.ts +95 -0
  24. package/dist/core/options.js +377 -0
  25. package/dist/core/org.d.ts +43 -0
  26. package/dist/core/org.js +65 -0
  27. package/dist/core/pages.d.ts +34 -0
  28. package/dist/core/pages.js +73 -0
  29. package/dist/core/password.d.ts +16 -0
  30. package/dist/core/password.js +41 -12
  31. package/dist/core/rate.d.ts +26 -0
  32. package/dist/core/rate.js +70 -0
  33. package/dist/core/reset.d.ts +9 -0
  34. package/dist/core/reset.js +31 -0
  35. package/dist/core/session.d.ts +11 -1
  36. package/dist/core/session.js +27 -7
  37. package/dist/core/signup.d.ts +13 -6
  38. package/dist/core/signup.js +30 -15
  39. package/dist/core/tokens.d.ts +29 -0
  40. package/dist/core/tokens.js +64 -0
  41. package/dist/core/verify.d.ts +8 -0
  42. package/dist/core/verify.js +33 -0
  43. package/dist/core/yaml.d.ts +10 -0
  44. package/dist/core/yaml.js +85 -0
  45. package/dist/index.d.ts +18 -6
  46. package/dist/index.js +18 -6
  47. package/dist/next/index.d.ts +10 -3
  48. package/dist/next/index.js +649 -63
  49. package/dist/next/proxy.d.ts +2 -1
  50. package/dist/next/proxy.js +22 -10
  51. package/dist/react/index.d.ts +72 -0
  52. package/dist/react/index.js +107 -0
  53. package/package.json +18 -4
@@ -0,0 +1,73 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Where the sign-in and sign-up pages live. Pure.
3
+ //
4
+ // The package serves plain HTML pages under /auth/. An app that wants
5
+ // its own design names its pages in b17_auth.yaml:
6
+ //
7
+ // pages:
8
+ // signIn: /sign-in
9
+ // signUp: /sign-up
10
+ //
11
+ // Then every place the package sends a browser (the proxy, sign-out,
12
+ // a verified email, "create the first account") goes to the app's page,
13
+ // and GET /auth/sign-in redirects there too, so old links still land.
14
+ // The protocol does not move: the app's page still POSTs to
15
+ // /auth/sign-in and /auth/sign-up (JSON or a form). The package owns
16
+ // the protocol; the app owns the pixels.
17
+ // ─────────────────────────────────────────────────────────────
18
+ // The built-in paths live here (not in config.ts, which imports options.ts,
19
+ // which imports this file): no import cycle at module load.
20
+ export const SIGN_IN_PATH = "/auth/sign-in";
21
+ export const SIGN_UP_PATH = "/auth/sign-up";
22
+ export const PAGE_KEYS = ["signIn", "signUp"];
23
+ const BUILT_IN = { signIn: SIGN_IN_PATH, signUp: SIGN_UP_PATH };
24
+ /** The app's path for a page, or undefined when the package serves it */
25
+ export function pageOverride(pages, key) {
26
+ return pages?.[key];
27
+ }
28
+ /** Where a browser is sent for a page: the app's path if named, else the package's */
29
+ export function pagePath(pages, key) {
30
+ return pageOverride(pages, key) ?? BUILT_IN[key];
31
+ }
32
+ /** The app's pages, for the proxy: they must open without a session */
33
+ export function appPages(pages) {
34
+ return PAGE_KEYS.map((k) => pages?.[k]).filter((p) => typeof p === "string");
35
+ }
36
+ /**
37
+ * An app path is an absolute path on this site, outside /auth/ (which the
38
+ * package serves), with no pattern, query or fragment. Error text or null.
39
+ */
40
+ export function validateAppPath(key, path) {
41
+ if (!path.startsWith("/"))
42
+ return `${key} must be a path starting with /, not "${path}"`;
43
+ if (/^\/auth(\/|$)/.test(path))
44
+ return `${key} cannot be under /auth/, which the package serves`;
45
+ if (/[*?#\s]/.test(path))
46
+ return `${key} is one plain path, no * ? # or spaces: "${path}"`;
47
+ return null;
48
+ }
49
+ export function validatePagePath(key, path) {
50
+ return validateAppPath(`pages.${key}`, path);
51
+ }
52
+ /**
53
+ * Where a signed-in visitor lands when nothing says otherwise: `home:` from
54
+ * b17_auth.yaml (the app's signed-in home, say /app), else the site root.
55
+ * after sign-in with no `next`, or `next` reduced to "/" by safeNext
56
+ * the admin page's "app" link
57
+ * a signed-in visitor opening "/" (the proxy sends them home: the root is
58
+ * the public site, the app is where they were going)
59
+ */
60
+ export function landing(next, home) {
61
+ return next && next !== "/" ? next : (home ?? "/");
62
+ }
63
+ /** Should the proxy send this signed-in request home? Only the root, only when home is elsewhere. */
64
+ export function sendsHome(pathname, home) {
65
+ return pathname === "/" && !!home && home !== "/";
66
+ }
67
+ /**
68
+ * The same URL on the app's page: GET /auth/sign-in?next=/x&error=1 becomes
69
+ * /sign-in?next=/x&error=1, so a bookmark or an old link keeps working.
70
+ */
71
+ export function movedTo(path, search) {
72
+ return path + search;
73
+ }
@@ -1,2 +1,18 @@
1
+ export declare const SCRYPT_N: number;
2
+ export declare const SCRYPT_R = 8;
3
+ export declare const SCRYPT_P = 1;
1
4
  export declare function hashPassword(password: string): Promise<string>;
5
+ type Parsed = {
6
+ n: number;
7
+ r: number;
8
+ p: number;
9
+ salt: Buffer;
10
+ hash: Buffer;
11
+ };
12
+ /** The parts of a stored hash, or null when it is not one of ours. Pure. */
13
+ export declare function parseHash(stored: string): Parsed | null;
14
+ /** False for a wrong password and for anything that is not a well-formed hash. Never throws. */
2
15
  export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
16
+ /** True when the stored hash was made with weaker parameters than today's, or is not ours. Pure. */
17
+ export declare function needsRehash(stored: string): boolean;
18
+ export {};
@@ -3,30 +3,59 @@
3
3
  //
4
4
  // Stored format: scrypt$N$r$p$<salt b64url>$<hash b64url>
5
5
  // Parameters live in the string, so they can be raised later
6
- // without invalidating existing rows.
6
+ // without invalidating existing rows: needsRehash() spots a row
7
+ // hashed under weaker parameters, and sign-in re-hashes it.
8
+ //
9
+ // N = 2^17 since 0.3.0 (about 0.3 s and 128 MB per hash): slow on
10
+ // purpose, it is what makes a stolen table expensive to crack.
7
11
  // ─────────────────────────────────────────────────────────────
8
12
  import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
9
13
  import { promisify } from "node:util";
10
14
  const scrypt = promisify(scryptCb);
11
- const N = 16384;
12
- const R = 8;
13
- const P = 1;
15
+ export const SCRYPT_N = 2 ** 17;
16
+ export const SCRYPT_R = 8;
17
+ export const SCRYPT_P = 1;
14
18
  const KEYLEN = 64;
15
19
  function run(password, salt, n, r, p) {
16
20
  return scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: 256 * n * r });
17
21
  }
18
22
  export async function hashPassword(password) {
19
23
  const salt = randomBytes(16);
20
- const hash = await run(password, salt, N, R, P);
21
- return ["scrypt", N, R, P, salt.toString("base64url"), hash.toString("base64url")].join("$");
24
+ const hash = await run(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P);
25
+ return ["scrypt", SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString("base64url"), hash.toString("base64url")].join("$");
22
26
  }
23
- export async function verifyPassword(password, stored) {
27
+ /** The parts of a stored hash, or null when it is not one of ours. Pure. */
28
+ export function parseHash(stored) {
24
29
  const parts = stored.split("$");
25
30
  if (parts.length !== 6 || parts[0] !== "scrypt")
26
- return false;
31
+ return null;
27
32
  const [, n, r, p, saltB64, hashB64] = parts;
28
- const salt = Buffer.from(saltB64, "base64url");
29
- const expected = Buffer.from(hashB64, "base64url");
30
- const actual = await run(password, salt, Number(n), Number(r), Number(p));
31
- return actual.length === expected.length && timingSafeEqual(actual, expected);
33
+ const num = (s) => (/^\d+$/.test(s) ? Number(s) : NaN);
34
+ const parsed = { n: num(n), r: num(r), p: num(p), salt: Buffer.from(saltB64, "base64url"), hash: Buffer.from(hashB64, "base64url") };
35
+ // N must be a power of two above 1; r and p positive; salt and hash present
36
+ if (!(parsed.n > 1 && Number.isInteger(parsed.n) && (parsed.n & (parsed.n - 1)) === 0))
37
+ return null;
38
+ if (!(parsed.r >= 1 && parsed.p >= 1))
39
+ return null;
40
+ if (parsed.salt.length === 0 || parsed.hash.length === 0)
41
+ return null;
42
+ return parsed;
43
+ }
44
+ /** False for a wrong password and for anything that is not a well-formed hash. Never throws. */
45
+ export async function verifyPassword(password, stored) {
46
+ const h = parseHash(stored);
47
+ if (!h)
48
+ return false;
49
+ try {
50
+ const actual = await run(password, h.salt, h.n, h.r, h.p);
51
+ return actual.length === h.hash.length && timingSafeEqual(actual, h.hash);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /** True when the stored hash was made with weaker parameters than today's, or is not ours. Pure. */
58
+ export function needsRehash(stored) {
59
+ const h = parseHash(stored);
60
+ return !h || h.n < SCRYPT_N || h.r < SCRYPT_R || h.p < SCRYPT_P;
32
61
  }
@@ -0,0 +1,26 @@
1
+ import { type Db } from "./db.ts";
2
+ export type Bucket = "signin:ip" | "signin:email" | "signup:ip" | "forgot:email" | "verify:email";
3
+ /** [limit, window in seconds] */
4
+ export declare const LIMITS: Record<Bucket, readonly [number, number]>;
5
+ export type Hit = {
6
+ count: number;
7
+ reset_at: string | Date;
8
+ };
9
+ export type Decision = {
10
+ allowed: boolean;
11
+ retryAfter: number;
12
+ count: number;
13
+ limit: number;
14
+ };
15
+ /** The rule. `hit` is the bucket's row after this request was counted. Pure. */
16
+ export declare function decide(hit: Hit, limit: number, now?: Date): Decision;
17
+ /** The client's address, as the first proxy saw it; "unknown" when no header says. Pure. */
18
+ export declare function clientIp(headers: {
19
+ get(name: string): string | null;
20
+ }): string;
21
+ /** The row's key. Emails lowercased, so "Ben@" and "ben@" share a bucket. Pure. */
22
+ export declare function bucketKey(bucket: Bucket, value: string): string;
23
+ /** The message a refused request sees. Pure. */
24
+ export declare function retryMessage(retryAfter: number): string;
25
+ /** Count this request against the bucket and decide. One statement, plus housekeeping when a window starts. */
26
+ export declare function rateLimit(bucket: Bucket, value: string, store?: Db, now?: Date): Promise<Decision>;
@@ -0,0 +1,70 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Rate limiting: a fixed window per bucket, stored in one row.
3
+ //
4
+ // rateLimit("signin:email", "ben@x.y") one INSERT … ON CONFLICT that
5
+ // starts or bumps the window and
6
+ // returns the count; decide() then
7
+ // says allowed or not, and for how
8
+ // long to wait.
9
+ //
10
+ // Buckets and limits (per window):
11
+ // signin:ip 20 / 15 min stops guessing many accounts from one place
12
+ // signin:email 10 / 15 min stops guessing one account from many places
13
+ // signup:ip 10 / 1 h stops account spam
14
+ // forgot:email 3 / 1 h stops reset-email spam
15
+ // verify:email 3 / 1 h stops verification-email spam
16
+ //
17
+ // Refused requests get 429 with Retry-After. The decision is pure; the
18
+ // storage is one function.
19
+ // ─────────────────────────────────────────────────────────────
20
+ import { db, ensureSchema } from "./db.js";
21
+ /** [limit, window in seconds] */
22
+ export const LIMITS = {
23
+ "signin:ip": [20, 15 * 60],
24
+ "signin:email": [10, 15 * 60],
25
+ "signup:ip": [10, 60 * 60],
26
+ "forgot:email": [3, 60 * 60],
27
+ "verify:email": [3, 60 * 60],
28
+ };
29
+ /** The rule. `hit` is the bucket's row after this request was counted. Pure. */
30
+ export function decide(hit, limit, now = new Date()) {
31
+ const count = Number(hit.count);
32
+ const resetAt = new Date(hit.reset_at).getTime();
33
+ const retryAfter = Math.max(1, Math.ceil((resetAt - now.getTime()) / 1000));
34
+ return count <= limit ? { allowed: true, retryAfter: 0, count, limit } : { allowed: false, retryAfter, count, limit };
35
+ }
36
+ /** The client's address, as the first proxy saw it; "unknown" when no header says. Pure. */
37
+ export function clientIp(headers) {
38
+ const fwd = headers.get("x-forwarded-for");
39
+ if (fwd) {
40
+ const first = fwd.split(",")[0].trim();
41
+ if (first)
42
+ return first;
43
+ }
44
+ return headers.get("x-real-ip")?.trim() || "unknown";
45
+ }
46
+ /** The row's key. Emails lowercased, so "Ben@" and "ben@" share a bucket. Pure. */
47
+ export function bucketKey(bucket, value) {
48
+ return `${bucket}:${value.trim().toLowerCase()}`;
49
+ }
50
+ /** The message a refused request sees. Pure. */
51
+ export function retryMessage(retryAfter) {
52
+ const m = Math.ceil(retryAfter / 60);
53
+ return retryAfter < 90 ? `Too many attempts. Try again in ${retryAfter} seconds.` : `Too many attempts. Try again in ${m} minutes.`;
54
+ }
55
+ /** Count this request against the bucket and decide. One statement, plus housekeeping when a window starts. */
56
+ export async function rateLimit(bucket, value, store = db(), now = new Date()) {
57
+ const [limit, windowSeconds] = LIMITS[bucket];
58
+ await ensureSchema(store);
59
+ const [hit] = await store.query(`INSERT INTO rate_limits (bucket, count, reset_at)
60
+ VALUES ($1, 1, now() + ($2::int * interval '1 second'))
61
+ ON CONFLICT (bucket) DO UPDATE SET
62
+ count = CASE WHEN rate_limits.reset_at <= now() THEN 1 ELSE rate_limits.count + 1 END,
63
+ reset_at = CASE WHEN rate_limits.reset_at <= now() THEN now() + ($2::int * interval '1 second') ELSE rate_limits.reset_at END
64
+ RETURNING count, reset_at`, [bucketKey(bucket, value), windowSeconds]);
65
+ if (Number(hit.count) === 1) {
66
+ // A window just started: a cheap moment to drop windows nobody will look at again
67
+ await store.query(`DELETE FROM rate_limits WHERE reset_at < now() - interval '1 day'`);
68
+ }
69
+ return decide(hit, limit, now);
70
+ }
@@ -0,0 +1,9 @@
1
+ import type { EmailMessage } from "./email.ts";
2
+ export declare const FORGOT_PATH = "/auth/forgot";
3
+ export declare const RESET_PATH = "/auth/reset";
4
+ export declare const RESET_TTL_SECONDS: number;
5
+ export declare const FORGOT_SENT = "If that address has an account, we sent a link to reset the password.";
6
+ export declare const RESET_LINK_INVALID = "This reset link is invalid, already used, or older than an hour.";
7
+ /** The absolute link that goes in the email */
8
+ export declare function resetLink(base: string, rawToken: string): string;
9
+ export declare function resetEmail(to: string, link: string, site: string): EmailMessage;
@@ -0,0 +1,31 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Password reset. Pure: the link, the email, the constants.
3
+ //
4
+ // POST /auth/forgot always answers "if that address has an account,
5
+ // we sent a link", so it never reveals who exists.
6
+ // When the user exists: a `reset` token (1 h) in
7
+ // an emailed link to /auth/reset?token=…
8
+ // POST /auth/reset consumes the token, sets the password (which
9
+ // bumps session_epoch: every old session dies),
10
+ // marks the email verified (the link proved it),
11
+ // signs the user in.
12
+ // ─────────────────────────────────────────────────────────────
13
+ export const FORGOT_PATH = "/auth/forgot";
14
+ export const RESET_PATH = "/auth/reset";
15
+ export const RESET_TTL_SECONDS = 60 * 60;
16
+ export const FORGOT_SENT = "If that address has an account, we sent a link to reset the password.";
17
+ export const RESET_LINK_INVALID = "This reset link is invalid, already used, or older than an hour.";
18
+ /** The absolute link that goes in the email */
19
+ export function resetLink(base, rawToken) {
20
+ const url = new URL(RESET_PATH, base);
21
+ url.searchParams.set("token", rawToken);
22
+ return url.toString();
23
+ }
24
+ export function resetEmail(to, link, site) {
25
+ return {
26
+ to,
27
+ subject: `Reset your password for ${site}`,
28
+ text: `Someone asked to reset the password for this address on ${site}. If that was you, open the link below. It works once and expires in one hour.\n\n${link}\n\nIf it was not you, ignore this email: nothing changes until the link is used.`,
29
+ html: `<p>Someone asked to reset the password for this address on ${site}. If that was you, open the link below. It works once and expires in one hour.</p><p><a href="${link}">${link}</a></p><p>If it was not you, ignore this email: nothing changes until the link is used.</p>`,
30
+ };
31
+ }
@@ -6,9 +6,19 @@ export type User = {
6
6
  export type Claim = User & {
7
7
  epoch: number;
8
8
  };
9
+ export declare const ISSUER = "@binary1702/auth";
10
+ export declare const AUDIENCE = "session";
11
+ /** Tokens issued at or after this moment must carry iss and aud (0.3.0's release day) */
12
+ export declare const CLAIMS_REQUIRED_FROM: number;
9
13
  export declare function signSession(user: User & {
10
14
  epoch?: number;
11
- }, secret: string): Promise<string>;
15
+ }, secret: string, days?: number): Promise<string>;
16
+ /** The iss/aud rule: required for new tokens, forgiven for old ones. Pure. */
17
+ export declare function claimsAcceptable(payload: {
18
+ iat?: number;
19
+ iss?: string;
20
+ aud?: string | string[];
21
+ }): boolean;
12
22
  export declare function verifySession(token: string, secret: string): Promise<Claim | null>;
13
23
  /**
14
24
  * The revocation rule. `epoch` is the user's current session_epoch,
@@ -5,30 +5,50 @@
5
5
  // signature verifySession() needs the secret only, never the
6
6
  // database. The proxy runs this on every request, free.
7
7
  // epoch isCurrent() the token carries the user's
8
- // session_epoch from when it was signed. getUser() looks
9
- // the user up and compares. Deleting the user (no row) or
10
- // bumping the epoch (password change, sign-out-everywhere)
11
- // makes every older token stop matching: revocation.
8
+ // session_epoch from when it was signed. getSession()
9
+ // looks the user up and compares. Deleting the user (no
10
+ // row) or bumping the epoch (password change, sign-out-
11
+ // everywhere) makes every older token stop matching.
12
12
  //
13
13
  // So a revoked token still passes the proxy and loads a page shell,
14
- // but getUser()/requireUser() say no, and those guard the data.
14
+ // but getSession()/requireSession() say no, and those guard the data.
15
+ //
16
+ // Since 0.3.0 tokens carry iss and aud, so a token minted by another
17
+ // app that happens to share the secret is refused. Tokens from before
18
+ // have neither; they are accepted only if signed before the cutoff.
15
19
  // ─────────────────────────────────────────────────────────────
16
20
  import { SignJWT, jwtVerify } from "jose";
17
21
  import { SESSION_DAYS } from "./config.js";
22
+ export const ISSUER = "@binary1702/auth";
23
+ export const AUDIENCE = "session";
24
+ /** Tokens issued at or after this moment must carry iss and aud (0.3.0's release day) */
25
+ export const CLAIMS_REQUIRED_FROM = Date.UTC(2026, 9, 22) / 1000;
18
26
  const key = (secret) => new TextEncoder().encode(secret);
19
- export async function signSession(user, secret) {
27
+ export async function signSession(user, secret, days = SESSION_DAYS) {
20
28
  return new SignJWT({ email: user.email, ep: user.epoch ?? 0 })
21
29
  .setProtectedHeader({ alg: "HS256" })
22
30
  .setSubject(user.id)
31
+ .setIssuer(ISSUER)
32
+ .setAudience(AUDIENCE)
23
33
  .setIssuedAt()
24
- .setExpirationTime(`${SESSION_DAYS}d`)
34
+ .setExpirationTime(`${days}d`)
25
35
  .sign(key(secret));
26
36
  }
37
+ /** The iss/aud rule: required for new tokens, forgiven for old ones. Pure. */
38
+ export function claimsAcceptable(payload) {
39
+ const aud = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
40
+ const has = payload.iss === ISSUER && aud.includes(AUDIENCE);
41
+ if (has)
42
+ return true;
43
+ return typeof payload.iat === "number" && payload.iat < CLAIMS_REQUIRED_FROM;
44
+ }
27
45
  export async function verifySession(token, secret) {
28
46
  try {
29
47
  const { payload } = await jwtVerify(token, key(secret), { algorithms: ["HS256"] });
30
48
  if (typeof payload.sub !== "string" || typeof payload.email !== "string")
31
49
  return null;
50
+ if (!claimsAcceptable(payload))
51
+ return null;
32
52
  // Tokens from before epochs existed carry no `ep`: they count as 0,
33
53
  // so upgrading the package signs nobody out
34
54
  const epoch = typeof payload.ep === "number" ? payload.ep : 0;
@@ -1,3 +1,4 @@
1
+ import type { SignupMode } from "./options.ts";
1
2
  export type SignupPolicy = {
2
3
  /** Can anyone reach the sign-up page right now? */
3
4
  allowed: boolean;
@@ -6,12 +7,18 @@ export type SignupPolicy = {
6
7
  /** Why it is allowed. "first" skips the invite code: there is nobody to invite you. */
7
8
  reason: "open" | "first" | "closed";
8
9
  };
9
- export declare function signupPolicy(env: {
10
- AUTH_SIGNUP?: string;
11
- AUTH_INVITE_CODE?: string;
12
- }, userCount: number): SignupPolicy;
10
+ export declare function signupPolicy(mode: SignupMode, userCount: number): SignupPolicy;
13
11
  export declare const EMAIL_RE: RegExp;
12
+ export declare const PASSWORD_MIN = 8;
13
+ /** scrypt on a megabyte of password is a denial of service; cap before hashing */
14
+ export declare const PASSWORD_MAX = 256;
15
+ /** Returns an error message, or null when the password is acceptable. */
16
+ export declare function validatePassword(password: string): string | null;
17
+ /** Returns an error message, or null when the address is acceptable (shape, and the domain rule if any). */
18
+ export declare function validateEmail(email: string, opts?: {
19
+ emailDomain?: string;
20
+ }): string | null;
14
21
  /** Returns an error message, or null when the pair is acceptable. */
15
- export declare function validateCredentials(email: string, password: string, env?: {
16
- AUTH_EMAIL_DOMAIN?: string;
22
+ export declare function validateCredentials(email: string, password: string, opts?: {
23
+ emailDomain?: string;
17
24
  }): string | null;
@@ -1,30 +1,45 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // Sign-up policy. One pure function, so it can be tested and read.
2
+ // Sign-up policy. Pure functions, so they can be tested and read.
3
3
  //
4
- // AUTH_SIGNUP=open anyone may register at /auth/sign-up
5
- // AUTH_INVITE_CODE=<code> with "open": the form also needs this code
6
- // (unset) closed, EXCEPT when there are no users yet,
7
- // so a fresh install can create its first
8
- // account in the browser instead of the CLI
4
+ // signup: open anyone may register at /auth/sign-up
5
+ // signup: invite anyone who knows inviteCode
6
+ // signup: closed nobody, EXCEPT when there are no users yet, so a
7
+ // fresh install can create its first account in the
8
+ // browser instead of the CLI
9
+ // (b17_auth.yaml keys; AUTH_SIGNUP=open and AUTH_INVITE_CODE map onto them)
9
10
  // ─────────────────────────────────────────────────────────────
10
- export function signupPolicy(env, userCount) {
11
+ export function signupPolicy(mode, userCount) {
11
12
  if (userCount === 0)
12
13
  return { allowed: true, inviteRequired: false, reason: "first" };
13
- if (env.AUTH_SIGNUP === "open") {
14
- return { allowed: true, inviteRequired: Boolean(env.AUTH_INVITE_CODE), reason: "open" };
15
- }
14
+ if (mode === "open")
15
+ return { allowed: true, inviteRequired: false, reason: "open" };
16
+ if (mode === "invite")
17
+ return { allowed: true, inviteRequired: true, reason: "open" };
16
18
  return { allowed: false, inviteRequired: false, reason: "closed" };
17
19
  }
18
20
  export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
19
- /** Returns an error message, or null when the pair is acceptable. */
20
- export function validateCredentials(email, password, env) {
21
+ export const PASSWORD_MIN = 8;
22
+ /** scrypt on a megabyte of password is a denial of service; cap before hashing */
23
+ export const PASSWORD_MAX = 256;
24
+ /** Returns an error message, or null when the password is acceptable. */
25
+ export function validatePassword(password) {
26
+ if (password.length < PASSWORD_MIN)
27
+ return `Password must be at least ${PASSWORD_MIN} characters.`;
28
+ if (password.length > PASSWORD_MAX)
29
+ return `Password must be at most ${PASSWORD_MAX} characters.`;
30
+ return null;
31
+ }
32
+ /** Returns an error message, or null when the address is acceptable (shape, and the domain rule if any). */
33
+ export function validateEmail(email, opts) {
21
34
  if (!EMAIL_RE.test(email))
22
35
  return "Please enter a valid email address.";
23
- const domain = env?.AUTH_EMAIL_DOMAIN;
36
+ const domain = opts?.emailDomain;
24
37
  if (domain && !email.toLowerCase().endsWith(`@${domain.toLowerCase()}`)) {
25
38
  return `Only @${domain} emails are allowed.`;
26
39
  }
27
- if (password.length < 8)
28
- return "Password must be at least 8 characters.";
29
40
  return null;
30
41
  }
42
+ /** Returns an error message, or null when the pair is acceptable. */
43
+ export function validateCredentials(email, password, opts) {
44
+ return validateEmail(email, opts) ?? validatePassword(password);
45
+ }
@@ -0,0 +1,29 @@
1
+ import { type Db } from "./db.ts";
2
+ export type TokenKind = "verify" | "reset" | "magic" | "invite";
3
+ /** What a consumed token tells the handler */
4
+ export type Token = {
5
+ id: string;
6
+ userId: string;
7
+ kind: TokenKind;
8
+ meta: Record<string, unknown>;
9
+ };
10
+ export type TokenRow = {
11
+ id: string;
12
+ user_id: string;
13
+ kind: TokenKind;
14
+ token_hash: string;
15
+ expires_at: string | Date;
16
+ used_at: string | Date | null;
17
+ meta: Record<string, unknown> | null;
18
+ };
19
+ /** 256 bits of randomness, URL-safe: 43 characters */
20
+ export declare function newRawToken(): string;
21
+ export declare function hashToken(raw: string): string;
22
+ /** Constant-time equality of two hashes, false on a length mismatch instead of throwing */
23
+ export declare function sameHash(a: string, b: string): boolean;
24
+ /** Why a stored token may or may not be consumed right now */
25
+ export declare function tokenState(row: Pick<TokenRow, "expires_at" | "used_at">, now?: Date): "ok" | "used" | "expired";
26
+ /** Create a token for the user and return the raw value to put in a link. */
27
+ export declare function issueToken(userId: string, kind: TokenKind, ttlSeconds: number, meta?: Record<string, unknown>, store?: Db): Promise<string>;
28
+ /** Redeem a raw token of the given kind. Null for anything but a live, unused token; works once. */
29
+ export declare function consumeToken(raw: string, kind: TokenKind, store?: Db, now?: Date): Promise<Token | null>;
@@ -0,0 +1,64 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // One-time tokens: the thing in an emailed link.
3
+ //
4
+ // issueToken() 32 random bytes → base64url. Only the SHA-256 is
5
+ // stored, so a database leak gives away no live link.
6
+ // consumeToken() returns the row once: unknown, expired, used, or
7
+ // wrong kind all come back null, and the claim is one
8
+ // UPDATE ... WHERE used_at IS NULL, so two clicks at
9
+ // the same instant cannot both succeed.
10
+ //
11
+ // Kinds in use: verify (email), reset (password).
12
+ // Reserved for later versions: magic (sign-in link), invite (org).
13
+ //
14
+ // The rules are pure functions; the store is injected (see core/db.ts).
15
+ // ─────────────────────────────────────────────────────────────
16
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
17
+ import { db, ensureSchema } from "./db.js";
18
+ // ── pure ──────────────────────────────────────────────────────
19
+ /** 256 bits of randomness, URL-safe: 43 characters */
20
+ export function newRawToken() {
21
+ return randomBytes(32).toString("base64url");
22
+ }
23
+ export function hashToken(raw) {
24
+ return createHash("sha256").update(raw).digest("hex");
25
+ }
26
+ /** Constant-time equality of two hashes, false on a length mismatch instead of throwing */
27
+ export function sameHash(a, b) {
28
+ const x = Buffer.from(a);
29
+ const y = Buffer.from(b);
30
+ return x.length === y.length && timingSafeEqual(x, y);
31
+ }
32
+ /** Why a stored token may or may not be consumed right now */
33
+ export function tokenState(row, now = new Date()) {
34
+ if (row.used_at)
35
+ return "used";
36
+ if (new Date(row.expires_at).getTime() <= now.getTime())
37
+ return "expired";
38
+ return "ok";
39
+ }
40
+ // ── store ─────────────────────────────────────────────────────
41
+ /** Create a token for the user and return the raw value to put in a link. */
42
+ export async function issueToken(userId, kind, ttlSeconds, meta = {}, store = db()) {
43
+ await ensureSchema(store);
44
+ const raw = newRawToken();
45
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
46
+ await store.query(`INSERT INTO tokens (user_id, kind, token_hash, expires_at, meta) VALUES ($1, $2, $3, $4, $5::jsonb)`, [userId, kind, hashToken(raw), expiresAt, JSON.stringify(meta)]);
47
+ // Housekeeping: rows nobody can use any more
48
+ await store.query(`DELETE FROM tokens WHERE expires_at < now() - interval '7 days'`);
49
+ return raw;
50
+ }
51
+ /** Redeem a raw token of the given kind. Null for anything but a live, unused token; works once. */
52
+ export async function consumeToken(raw, kind, store = db(), now = new Date()) {
53
+ if (!/^[A-Za-z0-9_-]{43}$/.test(raw))
54
+ return null;
55
+ await ensureSchema(store);
56
+ const hash = hashToken(raw);
57
+ const [row] = await store.query(`SELECT id, user_id, kind, token_hash, expires_at, used_at, meta FROM tokens WHERE token_hash = $1 AND kind = $2`, [hash, kind]);
58
+ if (!row || !sameHash(hash, row.token_hash) || tokenState(row, now) !== "ok")
59
+ return null;
60
+ const claimed = await store.query(`UPDATE tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`, [row.id]);
61
+ if (claimed.length === 0)
62
+ return null;
63
+ return { id: row.id, userId: row.user_id, kind: row.kind, meta: row.meta ?? {} };
64
+ }
@@ -0,0 +1,8 @@
1
+ import type { EmailMessage } from "./email.ts";
2
+ export declare const VERIFY_PATH = "/auth/verify";
3
+ export declare const VERIFY_TTL_SECONDS: number;
4
+ /** The gate: may a user with this verification state have a session? */
5
+ export declare function mayAccess(verified: boolean, requireVerified: boolean): boolean;
6
+ /** The absolute link that goes in the email */
7
+ export declare function verifyLink(base: string, rawToken: string, next?: string): string;
8
+ export declare function verifyEmail(to: string, link: string, site: string): EmailMessage;
@@ -0,0 +1,33 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Email verification policy and the email itself. Pure.
3
+ //
4
+ // requireVerified: true (b17_auth.yaml, or AUTH_REQUIRE_VERIFIED=true)
5
+ // an unverified user gets no session: getUser()
6
+ // is null, sign-in says "check your email".
7
+ // Default off, so existing installs change nothing.
8
+ //
9
+ // Sign-up always issues a `verify` token (24 h) and emails the link;
10
+ // the flag only decides whether that gate is closed meanwhile.
11
+ // ─────────────────────────────────────────────────────────────
12
+ export const VERIFY_PATH = "/auth/verify";
13
+ export const VERIFY_TTL_SECONDS = 24 * 60 * 60;
14
+ /** The gate: may a user with this verification state have a session? */
15
+ export function mayAccess(verified, requireVerified) {
16
+ return verified || !requireVerified;
17
+ }
18
+ /** The absolute link that goes in the email */
19
+ export function verifyLink(base, rawToken, next) {
20
+ const url = new URL(VERIFY_PATH, base);
21
+ url.searchParams.set("token", rawToken);
22
+ if (next && next !== "/")
23
+ url.searchParams.set("next", next);
24
+ return url.toString();
25
+ }
26
+ export function verifyEmail(to, link, site) {
27
+ return {
28
+ to,
29
+ subject: `Verify your email for ${site}`,
30
+ text: `Confirm this is your address by opening the link below. It works once and expires in 24 hours.\n\n${link}\n\nIf you did not create an account, ignore this email.`,
31
+ html: `<p>Confirm this is your address by opening the link below. It works once and expires in 24 hours.</p><p><a href="${link}">${link}</a></p><p>If you did not create an account, ignore this email.</p>`,
32
+ };
33
+ }
@@ -0,0 +1,10 @@
1
+ export type YamlTree = {
2
+ [key: string]: string | YamlTree;
3
+ };
4
+ export declare class YamlError extends Error {
5
+ line: number;
6
+ constructor(message: string, line: number);
7
+ }
8
+ export declare function parseYaml(text: string): YamlTree;
9
+ /** Words of a space-separated value. "" → []. */
10
+ export declare function words(value: string | undefined): string[];