@dbx-tools/email 0.6.44 → 0.6.45

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.
@@ -1,135 +0,0 @@
1
- /**
2
- * One-time-code store + session JWT for the email-OTP gate.
3
- *
4
- * Two pieces:
5
- *
6
- * - **Code store** - a 6-digit code is generated with `crypto.randomInt` and
7
- * kept server-side as a SHA-256 hash with an expiry and an attempt counter
8
- * (never the plaintext, never in the JWT). `verifyCode` is constant-time on
9
- * the hash, enforces the TTL, and burns the code after too many attempts or
10
- * one success. In-memory `Map` keyed by lowercased email - fine for a
11
- * single-instance app behind a tunnel.
12
- * - **Session JWT** - on a correct code, `signSession` mints a short-lived
13
- * HS256 JWT (via `jose`) carrying only the email; `verifySession` validates
14
- * it. The signing key comes from `AUTH_JWT_SECRET`; when unset the gate
15
- * FAILS OPEN with an ephemeral per-process key (sessions reset on restart)
16
- * rather than refusing service - a Databricks App is already access-limited,
17
- * so an unset secret degrades to "sessions don't survive restarts", not
18
- * "nobody can log in".
19
- *
20
- * @module
21
- */
22
- import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
23
- import { jwtVerify, SignJWT } from "jose";
24
- import { log } from "@dbx-tools/shared-core";
25
- const logger = log.logger("email:auth:otp");
26
- /** JWT issuer/audience so a token minted for this gate isn't accepted elsewhere. */
27
- const JWT_AUD = "dbx-tools-email-auth";
28
- /** SHA-256 hex of a value (for the stored code + a stable key comparison). */
29
- function sha256(value) {
30
- return createHash("sha256").update(value).digest("hex");
31
- }
32
- /** Constant-time compare of two hex digests of equal length. */
33
- function safeEqualHex(a, b) {
34
- if (a.length !== b.length)
35
- return false;
36
- return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
37
- }
38
- /** In-memory store of pending one-time codes, keyed by lowercased email. */
39
- export class CodeStore {
40
- ttlMs;
41
- maxAttempts;
42
- codes = new Map();
43
- constructor(ttlMs, maxAttempts) {
44
- this.ttlMs = ttlMs;
45
- this.maxAttempts = maxAttempts;
46
- }
47
- /**
48
- * Generate, store (hashed), and RETURN a fresh 6-digit code for `email`. The
49
- * caller emails the returned plaintext; only the hash is retained. Replaces
50
- * any pending code for the address.
51
- */
52
- issue(email, now = Date.now()) {
53
- const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
54
- this.codes.set(email.toLowerCase(), {
55
- hash: sha256(code),
56
- expiresAt: now + this.ttlMs,
57
- attempts: 0,
58
- });
59
- return code;
60
- }
61
- /**
62
- * Check `code` for `email`. Consumes the entry on success or when attempts are
63
- * exhausted, so a code is single-use and can't be brute-forced past the cap.
64
- */
65
- verify(email, code, now = Date.now()) {
66
- const key = email.toLowerCase();
67
- const entry = this.codes.get(key);
68
- if (!entry)
69
- return "invalid";
70
- if (now >= entry.expiresAt) {
71
- this.codes.delete(key);
72
- return "expired";
73
- }
74
- entry.attempts += 1;
75
- if (safeEqualHex(entry.hash, sha256(code))) {
76
- this.codes.delete(key);
77
- return "ok";
78
- }
79
- if (entry.attempts >= this.maxAttempts) {
80
- this.codes.delete(key);
81
- return "too-many-attempts";
82
- }
83
- return "invalid";
84
- }
85
- /** Drop every pending code (tests). */
86
- clear() {
87
- this.codes.clear();
88
- }
89
- }
90
- /**
91
- * Resolve the HS256 signing key. Prefers `AUTH_JWT_SECRET`; when unset, mints an
92
- * ephemeral per-process key (fail-open) and warns once. Memoized so every
93
- * sign/verify in a process shares one key.
94
- */
95
- let cachedKey;
96
- function signingKey() {
97
- if (cachedKey)
98
- return cachedKey;
99
- const secret = process.env.AUTH_JWT_SECRET?.trim();
100
- if (secret) {
101
- cachedKey = new TextEncoder().encode(secret);
102
- }
103
- else {
104
- logger.warn("AUTH_JWT_SECRET is not set - using an ephemeral per-process key; sessions will not survive a restart");
105
- cachedKey = randomBytes(32);
106
- }
107
- return cachedKey;
108
- }
109
- /** Reset the memoized key (tests, or after changing the env in-process). */
110
- export function resetSigningKey() {
111
- cachedKey = undefined;
112
- }
113
- /** Mint a short-lived session JWT for `email`, expiring in `ttlSeconds`. */
114
- export async function signSession(email, ttlSeconds) {
115
- return new SignJWT({ email })
116
- .setProtectedHeader({ alg: "HS256" })
117
- .setSubject(email)
118
- .setAudience(JWT_AUD)
119
- .setIssuedAt()
120
- .setExpirationTime(`${ttlSeconds}s`)
121
- .sign(signingKey());
122
- }
123
- /** Validate a session JWT, returning the email it was minted for, or `undefined`. */
124
- export async function verifySession(token) {
125
- if (!token)
126
- return undefined;
127
- try {
128
- const { payload } = await jwtVerify(token, signingKey(), { audience: JWT_AUD });
129
- return typeof payload.email === "string" ? payload.email : undefined;
130
- }
131
- catch {
132
- return undefined;
133
- }
134
- }
135
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib3RwLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vc3JjL2F1dGgvb3RwLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Ozs7Ozs7Ozs7Ozs7Ozs7OztHQW9CRztBQUVILE9BQU8sRUFBRSxVQUFVLEVBQUUsV0FBVyxFQUFFLFNBQVMsRUFBRSxlQUFlLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFDbEYsT0FBTyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsTUFBTSxNQUFNLENBQUM7QUFDMUMsT0FBTyxFQUFFLEdBQUcsRUFBRSxNQUFNLHdCQUF3QixDQUFDO0FBRTdDLE1BQU0sTUFBTSxHQUFHLEdBQUcsQ0FBQyxNQUFNLENBQUMsZ0JBQWdCLENBQUMsQ0FBQztBQUU1QyxvRkFBb0Y7QUFDcEYsTUFBTSxPQUFPLEdBQUcsc0JBQXNCLENBQUM7QUFFdkMsOEVBQThFO0FBQzlFLFNBQVMsTUFBTSxDQUFDLEtBQWE7SUFDM0IsT0FBTyxVQUFVLENBQUMsUUFBUSxDQUFDLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztBQUMxRCxDQUFDO0FBRUQsZ0VBQWdFO0FBQ2hFLFNBQVMsWUFBWSxDQUFDLENBQVMsRUFBRSxDQUFTO0lBQ3hDLElBQUksQ0FBQyxDQUFDLE1BQU0sS0FBSyxDQUFDLENBQUMsTUFBTTtRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ3hDLE9BQU8sZUFBZSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQyxFQUFFLEtBQUssQ0FBQyxFQUFFLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQyxFQUFFLEtBQUssQ0FBQyxDQUFDLENBQUM7QUFDdkUsQ0FBQztBQVdELDRFQUE0RTtBQUM1RSxNQUFNLE9BQU8sU0FBUztJQUlEO0lBQ0E7SUFKRixLQUFLLEdBQUcsSUFBSSxHQUFHLEVBQXFCLENBQUM7SUFFdEQsWUFDbUIsS0FBYSxFQUNiLFdBQW1CO1FBRG5CLFVBQUss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QUFDLEVBQUUsR0FBRyxFQUFFLE9BQU8sRUFBRSxDQUFDO1NBQ3BDLFVBQVUsQ0FBQyxLQUFLLENBQUM7U0FDakIsV0FBVyxDQUFDLE9BQU8sQ0FBQztTQUNwQixXQUFXLEVBQUU7U0FDYixpQkFBaUIsQ0FBQyxHQUFHLFVBQVUsR0FBRyxDQUFDO1NBQ25DLElBQUksQ0FBQyxVQUFVLEVBQUUsQ0FBQyxDQUFDO0FBQ3hCLENBQUM7QUFFRCxxRkFBcUY7QUFDckYsTUFBTSxDQUFDLEtBQUssVUFBVSxhQUFhLENBQUMsS0FBeUI7SUFDM0QsSUFBSSxDQUFDLEtBQUs7UUFBRSxPQUFPLFNBQVMsQ0FBQztJQUM3QixJQUFJLENBQUM7UUFDSCxNQUFNLEVBQUUsT0FBTyxFQUFFLEdBQUcsTUFBTSxTQUFTLENBQUMsS0FBSyxFQUFFLFVBQVUsRUFBRSxFQUFFLEVBQUUsUUFBUSxFQUFFLE9BQU8sRUFBRSxDQUFDLENBQUM7UUFDaEYsT0FBTyxPQUFPLE9BQU8sQ0FBQyxLQUFLLEtBQUssUUFBUSxDQUFDLENBQUMsQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxTQUFTLENBQUM7SUFDdkUsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLE9BQU8sU0FBUyxDQUFDO0lBQ25CLENBQUM7QUFDSCxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBPbmUtdGltZS1jb2RlIHN0b3JlICsgc2Vzc2lvbiBKV1QgZm9yIHRoZSBlbWFpbC1PVFAgZ2F0ZS5cbiAqXG4gKiBUd28gcGllY2VzOlxuICpcbiAqICAgLSAqKkNvZGUgc3RvcmUqKiAtIGEgNi1kaWdpdCBjb2RlIGlzIGdlbmVyYXRlZCB3aXRoIGBjcnlwdG8ucmFuZG9tSW50YCBhbmRcbiAqICAgICBrZXB0IHNlcnZlci1zaWRlIGFzIGEgU0hBLTI1NiBoYXNoIHdpdGggYW4gZXhwaXJ5IGFuZCBhbiBhdHRlbXB0IGNvdW50ZXJcbiAqICAgICAobmV2ZXIgdGhlIHBsYWludGV4dCwgbmV2ZXIgaW4gdGhlIEpXVCkuIGB2ZXJpZnlDb2RlYCBpcyBjb25zdGFudC10aW1lIG9uXG4gKiAgICAgdGhlIGhhc2gsIGVuZm9yY2VzIHRoZSBUVEwsIGFuZCBidXJucyB0aGUgY29kZSBhZnRlciB0b28gbWFueSBhdHRlbXB0cyBvclxuICogICAgIG9uZSBzdWNjZXNzLiBJbi1tZW1vcnkgYE1hcGAga2V5ZWQgYnkgbG93ZXJjYXNlZCBlbWFpbCAtIGZpbmUgZm9yIGFcbiAqICAgICBzaW5nbGUtaW5zdGFuY2UgYXBwIGJlaGluZCBhIHR1bm5lbC5cbiAqICAgLSAqKlNlc3Npb24gSldUKiogLSBvbiBhIGNvcnJlY3QgY29kZSwgYHNpZ25TZXNzaW9uYCBtaW50cyBhIHNob3J0LWxpdmVkXG4gKiAgICAgSFMyNTYgSldUICh2aWEgYGpvc2VgKSBjYXJyeWluZyBvbmx5IHRoZSBlbWFpbDsgYHZlcmlmeVNlc3Npb25gIHZhbGlkYXRlc1xuICogICAgIGl0LiBUaGUgc2lnbmluZyBrZXkgY29tZXMgZnJvbSBgQVVUSF9KV1RfU0VDUkVUYDsgd2hlbiB1bnNldCB0aGUgZ2F0ZVxuICogICAgIEZBSUxTIE9QRU4gd2l0aCBhbiBlcGhlbWVyYWwgcGVyLXByb2Nlc3Mga2V5IChzZXNzaW9ucyByZXNldCBvbiByZXN0YXJ0KVxuICogICAgIHJhdGhlciB0aGFuIHJlZnVzaW5nIHNlcnZpY2UgLSBhIERhdGFicmlja3MgQXBwIGlzIGFscmVhZHkgYWNjZXNzLWxpbWl0ZWQsXG4gKiAgICAgc28gYW4gdW5zZXQgc2VjcmV0IGRlZ3JhZGVzIHRvIFwic2Vzc2lvbnMgZG9uJ3Qgc3Vydml2ZSByZXN0YXJ0c1wiLCBub3RcbiAqICAgICBcIm5vYm9keSBjYW4gbG9nIGluXCIuXG4gKlxuICogQG1vZHVsZVxuICovXG5cbmltcG9ydCB7IGNyZWF0ZUhhc2gsIHJhbmRvbUJ5dGVzLCByYW5kb21JbnQsIHRpbWluZ1NhZmVFcXVhbCB9IGZyb20gXCJub2RlOmNyeXB0b1wiO1xuaW1wb3J0IHsgand0VmVyaWZ5LCBTaWduSldUIH0gZnJvbSBcImpvc2VcIjtcbmltcG9ydCB7IGxvZyB9IGZyb20gXCJAZGJ4LXRvb2xzL3NoYXJlZC1jb3JlXCI7XG5cbmNvbnN0IGxvZ2dlciA9IGxvZy5sb2dnZXIoXCJlbWFpbDphdXRoOm90cFwiKTtcblxuLyoqIEpXVCBpc3N1ZXIvYXVkaWVuY2Ugc28gYSB0b2tlbiBtaW50ZWQgZm9yIHRoaXMgZ2F0ZSBpc24ndCBhY2NlcHRlZCBlbHNld2hlcmUuICovXG5jb25zdCBKV1RfQVVEID0gXCJkYngtdG9vbHMtZW1haWwtYXV0aFwiO1xuXG4vKiogU0hBLTI1NiBoZXggb2YgYSB2YWx1ZSAoZm9yIHRoZSBzdG9yZWQgY29kZSArIGEgc3RhYmxlIGtleSBjb21wYXJpc29uKS4gKi9cbmZ1bmN0aW9uIHNoYTI1Nih2YWx1ZTogc3RyaW5nKTogc3RyaW5nIHtcbiAgcmV0dXJuIGNyZWF0ZUhhc2goXCJzaGEyNTZcIikudXBkYXRlKHZhbHVlKS5kaWdlc3QoXCJoZXhcIik7XG59XG5cbi8qKiBDb25zdGFudC10aW1lIGNvbXBhcmUgb2YgdHdvIGhleCBkaWdlc3RzIG9mIGVxdWFsIGxlbmd0aC4gKi9cbmZ1bmN0aW9uIHNhZmVFcXVhbEhleChhOiBzdHJpbmcsIGI6IHN0cmluZyk6IGJvb2xlYW4ge1xuICBpZiAoYS5sZW5ndGggIT09IGIubGVuZ3RoKSByZXR1cm4gZmFsc2U7XG4gIHJldHVybiB0aW1pbmdTYWZlRXF1YWwoQnVmZmVyLmZyb20oYSwgXCJoZXhcIiksIEJ1ZmZlci5mcm9tKGIsIFwiaGV4XCIpKTtcbn1cblxuaW50ZXJmYWNlIENvZGVFbnRyeSB7XG4gIGhhc2g6IHN0cmluZztcbiAgZXhwaXJlc0F0OiBudW1iZXI7XG4gIGF0dGVtcHRzOiBudW1iZXI7XG59XG5cbi8qKiBSZXN1bHQgb2Yge0BsaW5rIENvZGVTdG9yZS52ZXJpZnl9LiAqL1xuZXhwb3J0IHR5cGUgVmVyaWZ5T3V0Y29tZSA9IFwib2tcIiB8IFwiaW52YWxpZFwiIHwgXCJleHBpcmVkXCIgfCBcInRvby1tYW55LWF0dGVtcHRzXCI7XG5cbi8qKiBJbi1tZW1vcnkgc3RvcmUgb2YgcGVuZGluZyBvbmUtdGltZSBjb2Rlcywga2V5ZWQgYnkgbG93ZXJjYXNlZCBlbWFpbC4gKi9cbmV4cG9ydCBjbGFzcyBDb2RlU3RvcmUge1xuICBwcml2YXRlIHJlYWRvbmx5IGNvZGVzID0gbmV3IE1hcDxzdHJpbmcsIENvZGVFbnRyeT4oKTtcblxuICBjb25zdHJ1Y3RvcihcbiAgICBwcml2YXRlIHJlYWRvbmx5IHR0bE1zOiBudW1iZXIsXG4gICAgcHJpdmF0ZSByZWFkb25seSBtYXhBdHRlbXB0czogbnVtYmVyLFxuICApIHt9XG5cbiAgLyoqXG4gICAqIEdlbmVyYXRlLCBzdG9yZSAoaGFzaGVkKSwgYW5kIFJFVFVSTiBhIGZyZXNoIDYtZGlnaXQgY29kZSBmb3IgYGVtYWlsYC4gVGhlXG4gICAqIGNhbGxlciBlbWFpbHMgdGhlIHJldHVybmVkIHBsYWludGV4dDsgb25seSB0aGUgaGFzaCBpcyByZXRhaW5lZC4gUmVwbGFjZXNcbiAgICogYW55IHBlbmRpbmcgY29kZSBmb3IgdGhlIGFkZHJlc3MuXG4gICAqL1xuICBpc3N1ZShlbWFpbDogc3RyaW5nLCBub3c6IG51bWJlciA9IERhdGUubm93KCkpOiBzdHJpbmcge1xuICAgIGNvbnN0IGNvZGUgPSBTdHJpbmcocmFuZG9tSW50KDAsIDFfMDAwXzAwMCkpLnBhZFN0YXJ0KDYsIFwiMFwiKTtcbiAgICB0aGlzLmNvZGVzLnNldChlbWFpbC50b0xvd2VyQ2FzZSgpLCB7XG4gICAgICBoYXNoOiBzaGEyNTYoY29kZSksXG4gICAgICBleHBpcmVzQXQ6IG5vdyArIHRoaXMudHRsTXMsXG4gICAgICBhdHRlbXB0czogMCxcbiAgICB9KTtcbiAgICByZXR1cm4gY29kZTtcbiAgfVxuXG4gIC8qKlxuICAgKiBDaGVjayBgY29kZWAgZm9yIGBlbWFpbGAuIENvbnN1bWVzIHRoZSBlbnRyeSBvbiBzdWNjZXNzIG9yIHdoZW4gYXR0ZW1wdHMgYXJlXG4gICAqIGV4aGF1c3RlZCwgc28gYSBjb2RlIGlzIHNpbmdsZS11c2UgYW5kIGNhbid0IGJlIGJydXRlLWZvcmNlZCBwYXN0IHRoZSBjYXAuXG4gICAqL1xuICB2ZXJpZnkoZW1haWw6IHN0cmluZywgY29kZTogc3RyaW5nLCBub3c6IG51bWJlciA9IERhdGUubm93KCkpOiBWZXJpZnlPdXRjb21lIHtcbiAgICBjb25zdCBrZXkgPSBlbWFpbC50b0xvd2VyQ2FzZSgpO1xuICAgIGNvbnN0IGVudHJ5ID0gdGhpcy5jb2Rlcy5nZXQoa2V5KTtcbiAgICBpZiAoIWVudHJ5KSByZXR1cm4gXCJpbnZhbGlkXCI7XG4gICAgaWYgKG5vdyA+PSBlbnRyeS5leHBpcmVzQXQpIHtcbiAgICAgIHRoaXMuY29kZXMuZGVsZXRlKGtleSk7XG4gICAgICByZXR1cm4gXCJleHBpcmVkXCI7XG4gICAgfVxuICAgIGVudHJ5LmF0dGVtcHRzICs9IDE7XG4gICAgaWYgKHNhZmVFcXVhbEhleChlbnRyeS5oYXNoLCBzaGEyNTYoY29kZSkpKSB7XG4gICAgICB0aGlzLmNvZGVzLmRlbGV0ZShrZXkpO1xuICAgICAgcmV0dXJuIFwib2tcIjtcbiAgICB9XG4gICAgaWYgKGVudHJ5LmF0dGVtcHRzID49IHRoaXMubWF4QXR0ZW1wdHMpIHtcbiAgICAgIHRoaXMuY29kZXMuZGVsZXRlKGtleSk7XG4gICAgICByZXR1cm4gXCJ0b28tbWFueS1hdHRlbXB0c1wiO1xuICAgIH1cbiAgICByZXR1cm4gXCJpbnZhbGlkXCI7XG4gIH1cblxuICAvKiogRHJvcCBldmVyeSBwZW5kaW5nIGNvZGUgKHRlc3RzKS4gKi9cbiAgY2xlYXIoKTogdm9pZCB7XG4gICAgdGhpcy5jb2Rlcy5jbGVhcigpO1xuICB9XG59XG5cbi8qKlxuICogUmVzb2x2ZSB0aGUgSFMyNTYgc2lnbmluZyBrZXkuIFByZWZlcnMgYEFVVEhfSldUX1NFQ1JFVGA7IHdoZW4gdW5zZXQsIG1pbnRzIGFuXG4gKiBlcGhlbWVyYWwgcGVyLXByb2Nlc3Mga2V5IChmYWlsLW9wZW4pIGFuZCB3YXJucyBvbmNlLiBNZW1vaXplZCBzbyBldmVyeVxuICogc2lnbi92ZXJpZnkgaW4gYSBwcm9jZXNzIHNoYXJlcyBvbmUga2V5LlxuICovXG5sZXQgY2FjaGVkS2V5OiBVaW50OEFycmF5IHwgdW5kZWZpbmVkO1xuZnVuY3Rpb24gc2lnbmluZ0tleSgpOiBVaW50OEFycmF5IHtcbiAgaWYgKGNhY2hlZEtleSkgcmV0dXJuIGNhY2hlZEtleTtcbiAgY29uc3Qgc2VjcmV0ID0gcHJvY2Vzcy5lbnYuQVVUSF9KV1RfU0VDUkVUPy50cmltKCk7XG4gIGlmIChzZWNyZXQpIHtcbiAgICBjYWNoZWRLZXkgPSBuZXcgVGV4dEVuY29kZXIoKS5lbmNvZGUoc2VjcmV0KTtcbiAgfSBlbHNlIHtcbiAgICBsb2dnZXIud2FybihcbiAgICAgIFwiQVVUSF9KV1RfU0VDUkVUIGlzIG5vdCBzZXQgLSB1c2luZyBhbiBlcGhlbWVyYWwgcGVyLXByb2Nlc3Mga2V5OyBzZXNzaW9ucyB3aWxsIG5vdCBzdXJ2aXZlIGEgcmVzdGFydFwiLFxuICAgICk7XG4gICAgY2FjaGVkS2V5ID0gcmFuZG9tQnl0ZXMoMzIpO1xuICB9XG4gIHJldHVybiBjYWNoZWRLZXk7XG59XG5cbi8qKiBSZXNldCB0aGUgbWVtb2l6ZWQga2V5ICh0ZXN0cywgb3IgYWZ0ZXIgY2hhbmdpbmcgdGhlIGVudiBpbi1wcm9jZXNzKS4gKi9cbmV4cG9ydCBmdW5jdGlvbiByZXNldFNpZ25pbmdLZXkoKTogdm9pZCB7XG4gIGNhY2hlZEtleSA9IHVuZGVmaW5lZDtcbn1cblxuLyoqIE1pbnQgYSBzaG9ydC1saXZlZCBzZXNzaW9uIEpXVCBmb3IgYGVtYWlsYCwgZXhwaXJpbmcgaW4gYHR0bFNlY29uZHNgLiAqL1xuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHNpZ25TZXNzaW9uKGVtYWlsOiBzdHJpbmcsIHR0bFNlY29uZHM6IG51bWJlcik6IFByb21pc2U8c3RyaW5nPiB7XG4gIHJldHVybiBuZXcgU2lnbkpXVCh7IGVtYWlsIH0pXG4gICAgLnNldFByb3RlY3RlZEhlYWRlcih7IGFsZzogXCJIUzI1NlwiIH0pXG4gICAgLnNldFN1YmplY3QoZW1haWwpXG4gICAgLnNldEF1ZGllbmNlKEpXVF9BVUQpXG4gICAgLnNldElzc3VlZEF0KClcbiAgICAuc2V0RXhwaXJhdGlvblRpbWUoYCR7dHRsU2Vjb25kc31zYClcbiAgICAuc2lnbihzaWduaW5nS2V5KCkpO1xufVxuXG4vKiogVmFsaWRhdGUgYSBzZXNzaW9uIEpXVCwgcmV0dXJuaW5nIHRoZSBlbWFpbCBpdCB3YXMgbWludGVkIGZvciwgb3IgYHVuZGVmaW5lZGAuICovXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gdmVyaWZ5U2Vzc2lvbih0b2tlbjogc3RyaW5nIHwgdW5kZWZpbmVkKTogUHJvbWlzZTxzdHJpbmcgfCB1bmRlZmluZWQ+IHtcbiAgaWYgKCF0b2tlbikgcmV0dXJuIHVuZGVmaW5lZDtcbiAgdHJ5IHtcbiAgICBjb25zdCB7IHBheWxvYWQgfSA9IGF3YWl0IGp3dFZlcmlmeSh0b2tlbiwgc2lnbmluZ0tleSgpLCB7IGF1ZGllbmNlOiBKV1RfQVVEIH0pO1xuICAgIHJldHVybiB0eXBlb2YgcGF5bG9hZC5lbWFpbCA9PT0gXCJzdHJpbmdcIiA/IHBheWxvYWQuZW1haWwgOiB1bmRlZmluZWQ7XG4gIH0gY2F0Y2gge1xuICAgIHJldHVybiB1bmRlZmluZWQ7XG4gIH1cbn1cbiJdfQ==
@@ -1,35 +0,0 @@
1
- /**
2
- * A small in-memory fixed-window rate limiter for the email-OTP gate.
3
- *
4
- * Keyed by an arbitrary string (an email address or a client IP). Each key gets
5
- * `max` hits per `windowMs`; the window resets on first use after it elapses.
6
- * `hit()` returns whether the call is allowed and, when not, how many seconds
7
- * until the window resets so a caller can surface a cooldown.
8
- *
9
- * In-memory is intentional and sufficient for a single-app-instance gate: an
10
- * app behind a portr tunnel serves from one process. It is NOT a distributed
11
- * limiter; a multi-replica deployment would need shared state. Entries are
12
- * pruned lazily on access, so an idle key costs nothing after its window.
13
- *
14
- * @module
15
- */
16
- /** A fixed-window rate limiter over string keys. */
17
- export declare class RateLimiter {
18
- private readonly max;
19
- private readonly windowMs;
20
- private readonly windows;
21
- constructor(max: number, windowMs: number);
22
- /**
23
- * Record a hit for `key`. Returns `{ allowed }`, plus `retryAfter` (seconds)
24
- * when the limit is exceeded. A limit of `<= 0` disables limiting (always
25
- * allowed), which lets a config turn it off without special-casing callers.
26
- */
27
- hit(key: string, now?: number): {
28
- allowed: boolean;
29
- retryAfter?: number;
30
- };
31
- /** Forget a key (e.g. clear a caller's window after a successful verify). */
32
- reset(key: string): void;
33
- /** Drop every window (tests). */
34
- clear(): void;
35
- }
@@ -1,53 +0,0 @@
1
- /**
2
- * A small in-memory fixed-window rate limiter for the email-OTP gate.
3
- *
4
- * Keyed by an arbitrary string (an email address or a client IP). Each key gets
5
- * `max` hits per `windowMs`; the window resets on first use after it elapses.
6
- * `hit()` returns whether the call is allowed and, when not, how many seconds
7
- * until the window resets so a caller can surface a cooldown.
8
- *
9
- * In-memory is intentional and sufficient for a single-app-instance gate: an
10
- * app behind a portr tunnel serves from one process. It is NOT a distributed
11
- * limiter; a multi-replica deployment would need shared state. Entries are
12
- * pruned lazily on access, so an idle key costs nothing after its window.
13
- *
14
- * @module
15
- */
16
- /** A fixed-window rate limiter over string keys. */
17
- export class RateLimiter {
18
- max;
19
- windowMs;
20
- windows = new Map();
21
- constructor(max, windowMs) {
22
- this.max = max;
23
- this.windowMs = windowMs;
24
- }
25
- /**
26
- * Record a hit for `key`. Returns `{ allowed }`, plus `retryAfter` (seconds)
27
- * when the limit is exceeded. A limit of `<= 0` disables limiting (always
28
- * allowed), which lets a config turn it off without special-casing callers.
29
- */
30
- hit(key, now = Date.now()) {
31
- if (this.max <= 0)
32
- return { allowed: true };
33
- const existing = this.windows.get(key);
34
- if (!existing || now >= existing.resetAt) {
35
- this.windows.set(key, { count: 1, resetAt: now + this.windowMs });
36
- return { allowed: true };
37
- }
38
- if (existing.count < this.max) {
39
- existing.count += 1;
40
- return { allowed: true };
41
- }
42
- return { allowed: false, retryAfter: Math.ceil((existing.resetAt - now) / 1000) };
43
- }
44
- /** Forget a key (e.g. clear a caller's window after a successful verify). */
45
- reset(key) {
46
- this.windows.delete(key);
47
- }
48
- /** Drop every window (tests). */
49
- clear() {
50
- this.windows.clear();
51
- }
52
- }
53
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmF0ZS1saW1pdC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9hdXRoL3JhdGUtbGltaXQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7Ozs7Ozs7Ozs7O0dBY0c7QUFPSCxvREFBb0Q7QUFDcEQsTUFBTSxPQUFPLFdBQVc7SUFJSDtJQUNBO0lBSkYsT0FBTyxHQUFHLElBQUksR0FBRyxFQUFrQixDQUFDO0lBRXJELFlBQ21CLEdBQVcsRUFDWCxRQUFnQjtRQURoQixRQUFHLEdBQUgsR0FBRyxDQUFRO1FBQ1gsYUFBUSxHQUFSLFFBQVEsQ0FBUTtJQUNoQyxDQUFDO0lBRUo7Ozs7T0FJRztJQUNILEdBQUcsQ0FBQyxHQUFXLEVBQUUsTUFBYyxJQUFJLENBQUMsR0FBRyxFQUFFO1FBQ3ZDLElBQUksSUFBSSxDQUFDLEdBQUcsSUFBSSxDQUFDO1lBQUUsT0FBTyxFQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsQ0FBQztRQUM1QyxNQUFNLFFBQVEsR0FBRyxJQUFJLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUMsQ0FBQztRQUN2QyxJQUFJLENBQUMsUUFBUSxJQUFJLEdBQUcsSUFBSSxRQUFRLENBQUMsT0FBTyxFQUFFLENBQUM7WUFDekMsSUFBSSxDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsR0FBRyxFQUFFLEVBQUUsS0FBSyxFQUFFLENBQUMsRUFBRSxPQUFPLEVBQUUsR0FBRyxHQUFHLElBQUksQ0FBQyxRQUFRLEVBQUUsQ0FBQyxDQUFDO1lBQ2xFLE9BQU8sRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLENBQUM7UUFDM0IsQ0FBQztRQUNELElBQUksUUFBUSxDQUFDLEtBQUssR0FBRyxJQUFJLENBQUMsR0FBRyxFQUFFLENBQUM7WUFDOUIsUUFBUSxDQUFDLEtBQUssSUFBSSxDQUFDLENBQUM7WUFDcEIsT0FBTyxFQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsQ0FBQztRQUMzQixDQUFDO1FBQ0QsT0FBTyxFQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsVUFBVSxFQUFFLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQyxRQUFRLENBQUMsT0FBTyxHQUFHLEdBQUcsQ0FBQyxHQUFHLElBQUksQ0FBQyxFQUFFLENBQUM7SUFDcEYsQ0FBQztJQUVELDZFQUE2RTtJQUM3RSxLQUFLLENBQUMsR0FBVztRQUNmLElBQUksQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFDO0lBQzNCLENBQUM7SUFFRCxpQ0FBaUM7SUFDakMsS0FBSztRQUNILElBQUksQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLENBQUM7SUFDdkIsQ0FBQztDQUNGIiwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBBIHNtYWxsIGluLW1lbW9yeSBmaXhlZC13aW5kb3cgcmF0ZSBsaW1pdGVyIGZvciB0aGUgZW1haWwtT1RQIGdhdGUuXG4gKlxuICogS2V5ZWQgYnkgYW4gYXJiaXRyYXJ5IHN0cmluZyAoYW4gZW1haWwgYWRkcmVzcyBvciBhIGNsaWVudCBJUCkuIEVhY2gga2V5IGdldHNcbiAqIGBtYXhgIGhpdHMgcGVyIGB3aW5kb3dNc2A7IHRoZSB3aW5kb3cgcmVzZXRzIG9uIGZpcnN0IHVzZSBhZnRlciBpdCBlbGFwc2VzLlxuICogYGhpdCgpYCByZXR1cm5zIHdoZXRoZXIgdGhlIGNhbGwgaXMgYWxsb3dlZCBhbmQsIHdoZW4gbm90LCBob3cgbWFueSBzZWNvbmRzXG4gKiB1bnRpbCB0aGUgd2luZG93IHJlc2V0cyBzbyBhIGNhbGxlciBjYW4gc3VyZmFjZSBhIGNvb2xkb3duLlxuICpcbiAqIEluLW1lbW9yeSBpcyBpbnRlbnRpb25hbCBhbmQgc3VmZmljaWVudCBmb3IgYSBzaW5nbGUtYXBwLWluc3RhbmNlIGdhdGU6IGFuXG4gKiBhcHAgYmVoaW5kIGEgcG9ydHIgdHVubmVsIHNlcnZlcyBmcm9tIG9uZSBwcm9jZXNzLiBJdCBpcyBOT1QgYSBkaXN0cmlidXRlZFxuICogbGltaXRlcjsgYSBtdWx0aS1yZXBsaWNhIGRlcGxveW1lbnQgd291bGQgbmVlZCBzaGFyZWQgc3RhdGUuIEVudHJpZXMgYXJlXG4gKiBwcnVuZWQgbGF6aWx5IG9uIGFjY2Vzcywgc28gYW4gaWRsZSBrZXkgY29zdHMgbm90aGluZyBhZnRlciBpdHMgd2luZG93LlxuICpcbiAqIEBtb2R1bGVcbiAqL1xuXG5pbnRlcmZhY2UgV2luZG93IHtcbiAgY291bnQ6IG51bWJlcjtcbiAgcmVzZXRBdDogbnVtYmVyO1xufVxuXG4vKiogQSBmaXhlZC13aW5kb3cgcmF0ZSBsaW1pdGVyIG92ZXIgc3RyaW5nIGtleXMuICovXG5leHBvcnQgY2xhc3MgUmF0ZUxpbWl0ZXIge1xuICBwcml2YXRlIHJlYWRvbmx5IHdpbmRvd3MgPSBuZXcgTWFwPHN0cmluZywgV2luZG93PigpO1xuXG4gIGNvbnN0cnVjdG9yKFxuICAgIHByaXZhdGUgcmVhZG9ubHkgbWF4OiBudW1iZXIsXG4gICAgcHJpdmF0ZSByZWFkb25seSB3aW5kb3dNczogbnVtYmVyLFxuICApIHt9XG5cbiAgLyoqXG4gICAqIFJlY29yZCBhIGhpdCBmb3IgYGtleWAuIFJldHVybnMgYHsgYWxsb3dlZCB9YCwgcGx1cyBgcmV0cnlBZnRlcmAgKHNlY29uZHMpXG4gICAqIHdoZW4gdGhlIGxpbWl0IGlzIGV4Y2VlZGVkLiBBIGxpbWl0IG9mIGA8PSAwYCBkaXNhYmxlcyBsaW1pdGluZyAoYWx3YXlzXG4gICAqIGFsbG93ZWQpLCB3aGljaCBsZXRzIGEgY29uZmlnIHR1cm4gaXQgb2ZmIHdpdGhvdXQgc3BlY2lhbC1jYXNpbmcgY2FsbGVycy5cbiAgICovXG4gIGhpdChrZXk6IHN0cmluZywgbm93OiBudW1iZXIgPSBEYXRlLm5vdygpKTogeyBhbGxvd2VkOiBib29sZWFuOyByZXRyeUFmdGVyPzogbnVtYmVyIH0ge1xuICAgIGlmICh0aGlzLm1heCA8PSAwKSByZXR1cm4geyBhbGxvd2VkOiB0cnVlIH07XG4gICAgY29uc3QgZXhpc3RpbmcgPSB0aGlzLndpbmRvd3MuZ2V0KGtleSk7XG4gICAgaWYgKCFleGlzdGluZyB8fCBub3cgPj0gZXhpc3RpbmcucmVzZXRBdCkge1xuICAgICAgdGhpcy53aW5kb3dzLnNldChrZXksIHsgY291bnQ6IDEsIHJlc2V0QXQ6IG5vdyArIHRoaXMud2luZG93TXMgfSk7XG4gICAgICByZXR1cm4geyBhbGxvd2VkOiB0cnVlIH07XG4gICAgfVxuICAgIGlmIChleGlzdGluZy5jb3VudCA8IHRoaXMubWF4KSB7XG4gICAgICBleGlzdGluZy5jb3VudCArPSAxO1xuICAgICAgcmV0dXJuIHsgYWxsb3dlZDogdHJ1ZSB9O1xuICAgIH1cbiAgICByZXR1cm4geyBhbGxvd2VkOiBmYWxzZSwgcmV0cnlBZnRlcjogTWF0aC5jZWlsKChleGlzdGluZy5yZXNldEF0IC0gbm93KSAvIDEwMDApIH07XG4gIH1cblxuICAvKiogRm9yZ2V0IGEga2V5IChlLmcuIGNsZWFyIGEgY2FsbGVyJ3Mgd2luZG93IGFmdGVyIGEgc3VjY2Vzc2Z1bCB2ZXJpZnkpLiAqL1xuICByZXNldChrZXk6IHN0cmluZyk6IHZvaWQge1xuICAgIHRoaXMud2luZG93cy5kZWxldGUoa2V5KTtcbiAgfVxuXG4gIC8qKiBEcm9wIGV2ZXJ5IHdpbmRvdyAodGVzdHMpLiAqL1xuICBjbGVhcigpOiB2b2lkIHtcbiAgICB0aGlzLndpbmRvd3MuY2xlYXIoKTtcbiAgfVxufVxuIl19
@@ -1,88 +0,0 @@
1
- /**
2
- * Unified access allow-list matching for the email-OTP gate.
3
- *
4
- * Each pattern in the configured list is one of three shapes, tried in order:
5
- *
6
- * - **domain shortcut** - `databricks.com` or `@databricks.com`: matches any
7
- * address whose domain equals it (case-insensitive). The leading `@` is
8
- * optional and stripped.
9
- * - **glob** - contains `*` or `?`, e.g. `*.databricks.com` or
10
- * `*@databricks.com`: matched against the WHOLE address with shell-style
11
- * wildcards (`*` = any run, `?` = one char).
12
- * - **regex** - wrapped in slashes, `/.../ [flags]`: compiled and tested
13
- * against the whole address. An invalid regex never matches (it is skipped
14
- * with a warning rather than throwing).
15
- *
16
- * An EMPTY list matches nobody (fail closed): an app that enables the gate but
17
- * configures no patterns lets no one in, which is the safe default.
18
- *
19
- * @module
20
- */
21
-
22
- import { log } from "@dbx-tools/shared-core";
23
-
24
- const logger = log.logger("email:auth:allowlist");
25
-
26
- /** Escape a string for literal use inside a `RegExp`. */
27
- function escapeRegExp(value: string): string {
28
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
- }
30
-
31
- /** Compile a shell-style glob (`*`, `?`) into an anchored, case-insensitive RegExp. */
32
- function globToRegExp(glob: string): RegExp {
33
- const body = glob
34
- .split(/([*?])/)
35
- .map((part) => (part === "*" ? ".*" : part === "?" ? "." : escapeRegExp(part)))
36
- .join("");
37
- return new RegExp(`^${body}$`, "i");
38
- }
39
-
40
- /** Parse a `/pattern/flags` string into a RegExp, or `undefined` if malformed. */
41
- function parseRegexLiteral(pattern: string): RegExp | undefined {
42
- const match = /^\/(.+)\/([a-z]*)$/is.exec(pattern);
43
- if (!match) return undefined;
44
- try {
45
- const flags = match[2]!.includes("i") ? match[2]! : `${match[2]!}i`;
46
- return new RegExp(match[1]!, flags);
47
- } catch (error) {
48
- logger.warn("ignoring invalid regex allow-list pattern", { pattern, error });
49
- return undefined;
50
- }
51
- }
52
-
53
- /** True when `email` matches a single allow-list `pattern`. */
54
- function matchesPattern(email: string, pattern: string): boolean {
55
- const trimmed = pattern.trim();
56
- if (!trimmed) return false;
57
- const address = email.trim().toLowerCase();
58
-
59
- // Regex literal: /.../
60
- if (trimmed.startsWith("/")) {
61
- const re = parseRegexLiteral(trimmed);
62
- return re ? re.test(address) : false;
63
- }
64
-
65
- // Glob: contains a wildcard.
66
- if (trimmed.includes("*") || trimmed.includes("?")) {
67
- return globToRegExp(trimmed).test(address);
68
- }
69
-
70
- // Domain shortcut: `@d.com` or `d.com` -> match the address's domain.
71
- const domain = trimmed.replace(/^@/, "").toLowerCase();
72
- const at = address.lastIndexOf("@");
73
- return at >= 0 && address.slice(at + 1) === domain;
74
- }
75
-
76
- /**
77
- * True when `email` is allowed by ANY pattern in `patterns`. An empty (or
78
- * missing) list allows nobody - the gate fails closed.
79
- */
80
- export function matchesAllowlist(email: string, patterns: readonly string[] | undefined): boolean {
81
- if (!email || !patterns || patterns.length === 0) return false;
82
- return patterns.some((pattern) => matchesPattern(email, pattern));
83
- }
84
-
85
- /** Rough shape check so a clearly-invalid address is rejected before any work. */
86
- export function looksLikeEmail(value: string): boolean {
87
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
88
- }
package/src/auth/gate.ts DELETED
@@ -1,172 +0,0 @@
1
- /**
2
- * The email-OTP access gate: runtime + Express middleware + request handlers.
3
- *
4
- * {@link AuthGate} owns the allow-list, rate limiters, code store, and session
5
- * signing. The plugin wires four routes to it (`request`/`verify`/`logout`/
6
- * `status`) and mounts {@link AuthGate.middleware} so every OTHER request needs a
7
- * valid session cookie.
8
- *
9
- * Design points:
10
- * - `request` ALWAYS resolves `{ ok: true }` (anti-enumeration): a code is only
11
- * generated + emailed when the address is allow-listed AND under the rate
12
- * limit, but the caller can't tell the difference.
13
- * - the session lives in an HttpOnly + SameSite=Lax cookie (Secure in prod), so
14
- * it survives reloads and is not readable by page scripts.
15
- * - fail-open on a missing signing secret (see `otp.ts`): a Databricks App is
16
- * already access-limited; an unset `AUTH_JWT_SECRET` degrades to
17
- * non-durable sessions, not a lockout.
18
- *
19
- * @module
20
- */
21
-
22
- import type { CookieOptions, NextFunction, Request, Response } from "express";
23
- import { http, log } from "@dbx-tools/shared-core";
24
- import type { AuthStatus } from "@dbx-tools/shared-email";
25
- import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
26
- import { CodeStore, signSession, verifySession } from "./otp.ts";
27
- import { RateLimiter } from "./rate-limit.ts";
28
-
29
- const logger = log.logger("email:auth");
30
-
31
- /** Cookie the session JWT rides in. */
32
- export const SESSION_COOKIE = "dbx_auth";
33
-
34
- /** Route prefix (under the plugin base `/api/email`) the gate leaves open. */
35
- const AUTH_PATH_PREFIX = "/auth";
36
-
37
- /** Resolved gate configuration. */
38
- export interface AuthGateOptions {
39
- /** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. */
40
- readonly allow: readonly string[];
41
- /** Session lifetime in seconds. */
42
- readonly sessionTtlSeconds: number;
43
- /** One-time code lifetime in seconds. */
44
- readonly codeTtlSeconds: number;
45
- /** Max verify attempts per issued code. */
46
- readonly maxAttempts: number;
47
- /** Send the code email. Returns nothing; failures are logged, not surfaced. */
48
- readonly sendCode: (email: string, code: string) => Promise<void>;
49
- /** True in production (sets the `Secure` cookie flag). */
50
- readonly secureCookies: boolean;
51
- }
52
-
53
- /** The email-OTP gate for one app. */
54
- export class AuthGate {
55
- private readonly codes: CodeStore;
56
- // Separate limiters: requesting a code is cheap-to-abuse (email spam), verifying
57
- // is a brute-force surface. Per-email AND per-IP so neither axis alone is a bypass.
58
- private readonly requestLimiter = new RateLimiter(5, 15 * 60 * 1000);
59
- private readonly verifyLimiter = new RateLimiter(10, 15 * 60 * 1000);
60
-
61
- constructor(private readonly options: AuthGateOptions) {
62
- this.codes = new CodeStore(options.codeTtlSeconds * 1000, options.maxAttempts);
63
- }
64
-
65
- /**
66
- * Handle `POST /auth/request`. Always resolves `{ ok: true }` (plus a
67
- * `retryAfter` when rate-limited); a code is issued + emailed only for an
68
- * allow-listed address under the limit. Errors in sending are swallowed so the
69
- * response never reveals whether an address exists / is allowed.
70
- */
71
- async handleRequest(email: string, ip: string): Promise<{ ok: true; retryAfter?: number }> {
72
- const address = email.trim().toLowerCase();
73
- const byIp = this.requestLimiter.hit(`ip:${ip}`);
74
- const byEmail = this.requestLimiter.hit(`email:${address}`);
75
- if (!byIp.allowed || !byEmail.allowed) {
76
- return { ok: true, retryAfter: byIp.retryAfter ?? byEmail.retryAfter };
77
- }
78
- if (looksLikeEmail(address) && matchesAllowlist(address, this.options.allow)) {
79
- const code = this.codes.issue(address);
80
- try {
81
- await this.options.sendCode(address, code);
82
- } catch (error) {
83
- logger.warn("failed to send OTP email", { error });
84
- }
85
- }
86
- return { ok: true };
87
- }
88
-
89
- /**
90
- * Handle `POST /auth/verify`. On a correct code, returns the session `token`
91
- * plus the `cookieOptions` for it so the route sets it with Express's own
92
- * `res.cookie(SESSION_COOKIE, token, options)` (no hand-rolled Set-Cookie).
93
- * Failures return `{ ok: false }` with a generic reason.
94
- */
95
- async handleVerify(
96
- email: string,
97
- code: string,
98
- ip: string,
99
- secure: boolean,
100
- ): Promise<{ ok: boolean; token?: string; cookieOptions?: CookieOptions; retryAfter?: number }> {
101
- const address = email.trim().toLowerCase();
102
- const byIp = this.verifyLimiter.hit(`ip:${ip}`);
103
- const byEmail = this.verifyLimiter.hit(`email:${address}`);
104
- if (!byIp.allowed || !byEmail.allowed) {
105
- return { ok: false, retryAfter: byIp.retryAfter ?? byEmail.retryAfter };
106
- }
107
- if (this.codes.verify(address, code.trim()) !== "ok") return { ok: false };
108
- // Correct code: clear the caller's request/verify budget and mint a session.
109
- this.requestLimiter.reset(`email:${address}`);
110
- this.verifyLimiter.reset(`email:${address}`);
111
- const token = await signSession(address, this.options.sessionTtlSeconds);
112
- return { ok: true, token, cookieOptions: this.cookieOptions(secure) };
113
- }
114
-
115
- /** Express cookie options for the session (used with `res.cookie`/`res.clearCookie`). */
116
- cookieOptions(secure: boolean): CookieOptions {
117
- return {
118
- httpOnly: true,
119
- sameSite: "lax",
120
- secure: secure || this.options.secureCookies,
121
- path: "/",
122
- maxAge: this.options.sessionTtlSeconds * 1000,
123
- };
124
- }
125
-
126
- /** Resolve the authenticated email for a request, or `undefined`. */
127
- async authenticate(req: Request): Promise<string | undefined> {
128
- // Reuse shared-core's cookie parser (accepts an Express req directly) rather
129
- // than re-implementing header splitting here.
130
- const token = http.parseCookies(req)[SESSION_COOKIE];
131
- return verifySession(token);
132
- }
133
-
134
- /** The `GET /auth/status` payload for a request. */
135
- async status(req: Request): Promise<AuthStatus> {
136
- const email = await this.authenticate(req);
137
- return { authenticated: Boolean(email), email, enabled: true };
138
- }
139
-
140
- /**
141
- * Express middleware gating the app's DATA APIs behind a session.
142
- *
143
- * It gates `/api/*` and returns 401 for an unauthenticated caller - EXCEPT the
144
- * login flow itself (`<emailBase>/auth/*`), which must stay open so a caller
145
- * can obtain a session. Static assets (the SPA shell, JS/CSS, favicons) are
146
- * NOT gated: the browser has to load the client so the `<AuthGate>` React
147
- * component can render the login screen and call these endpoints. A gated
148
- * `/api` request from the un-logged-in SPA simply 401s, which the client
149
- * treats as "show the login". This is the standard SPA gate shape - protect
150
- * the data, serve the shell.
151
- *
152
- * `emailBase` is the email plugin's mount path (e.g. `/api/email`), so both the
153
- * open login prefix and the gated API prefix are matched on the full path.
154
- */
155
- middleware(emailBase: string): (req: Request, res: Response, next: NextFunction) => void {
156
- const openPrefix = `${emailBase}${AUTH_PATH_PREFIX}`;
157
- return (req, res, next) => {
158
- // Only API traffic is gated; static assets load freely so the login UI can.
159
- if (!req.path.startsWith("/api/") || req.path.startsWith(openPrefix)) {
160
- next();
161
- return;
162
- }
163
- void this.authenticate(req).then((email) => {
164
- if (email) {
165
- next();
166
- } else {
167
- res.status(401).json({ error: "authentication required", loginPath: openPrefix });
168
- }
169
- });
170
- };
171
- }
172
- }
package/src/auth/otp.ts DELETED
@@ -1,151 +0,0 @@
1
- /**
2
- * One-time-code store + session JWT for the email-OTP gate.
3
- *
4
- * Two pieces:
5
- *
6
- * - **Code store** - a 6-digit code is generated with `crypto.randomInt` and
7
- * kept server-side as a SHA-256 hash with an expiry and an attempt counter
8
- * (never the plaintext, never in the JWT). `verifyCode` is constant-time on
9
- * the hash, enforces the TTL, and burns the code after too many attempts or
10
- * one success. In-memory `Map` keyed by lowercased email - fine for a
11
- * single-instance app behind a tunnel.
12
- * - **Session JWT** - on a correct code, `signSession` mints a short-lived
13
- * HS256 JWT (via `jose`) carrying only the email; `verifySession` validates
14
- * it. The signing key comes from `AUTH_JWT_SECRET`; when unset the gate
15
- * FAILS OPEN with an ephemeral per-process key (sessions reset on restart)
16
- * rather than refusing service - a Databricks App is already access-limited,
17
- * so an unset secret degrades to "sessions don't survive restarts", not
18
- * "nobody can log in".
19
- *
20
- * @module
21
- */
22
-
23
- import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
24
- import { jwtVerify, SignJWT } from "jose";
25
- import { log } from "@dbx-tools/shared-core";
26
-
27
- const logger = log.logger("email:auth:otp");
28
-
29
- /** JWT issuer/audience so a token minted for this gate isn't accepted elsewhere. */
30
- const JWT_AUD = "dbx-tools-email-auth";
31
-
32
- /** SHA-256 hex of a value (for the stored code + a stable key comparison). */
33
- function sha256(value: string): string {
34
- return createHash("sha256").update(value).digest("hex");
35
- }
36
-
37
- /** Constant-time compare of two hex digests of equal length. */
38
- function safeEqualHex(a: string, b: string): boolean {
39
- if (a.length !== b.length) return false;
40
- return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
41
- }
42
-
43
- interface CodeEntry {
44
- hash: string;
45
- expiresAt: number;
46
- attempts: number;
47
- }
48
-
49
- /** Result of {@link CodeStore.verify}. */
50
- export type VerifyOutcome = "ok" | "invalid" | "expired" | "too-many-attempts";
51
-
52
- /** In-memory store of pending one-time codes, keyed by lowercased email. */
53
- export class CodeStore {
54
- private readonly codes = new Map<string, CodeEntry>();
55
-
56
- constructor(
57
- private readonly ttlMs: number,
58
- private readonly maxAttempts: number,
59
- ) {}
60
-
61
- /**
62
- * Generate, store (hashed), and RETURN a fresh 6-digit code for `email`. The
63
- * caller emails the returned plaintext; only the hash is retained. Replaces
64
- * any pending code for the address.
65
- */
66
- issue(email: string, now: number = Date.now()): string {
67
- const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
68
- this.codes.set(email.toLowerCase(), {
69
- hash: sha256(code),
70
- expiresAt: now + this.ttlMs,
71
- attempts: 0,
72
- });
73
- return code;
74
- }
75
-
76
- /**
77
- * Check `code` for `email`. Consumes the entry on success or when attempts are
78
- * exhausted, so a code is single-use and can't be brute-forced past the cap.
79
- */
80
- verify(email: string, code: string, now: number = Date.now()): VerifyOutcome {
81
- const key = email.toLowerCase();
82
- const entry = this.codes.get(key);
83
- if (!entry) return "invalid";
84
- if (now >= entry.expiresAt) {
85
- this.codes.delete(key);
86
- return "expired";
87
- }
88
- entry.attempts += 1;
89
- if (safeEqualHex(entry.hash, sha256(code))) {
90
- this.codes.delete(key);
91
- return "ok";
92
- }
93
- if (entry.attempts >= this.maxAttempts) {
94
- this.codes.delete(key);
95
- return "too-many-attempts";
96
- }
97
- return "invalid";
98
- }
99
-
100
- /** Drop every pending code (tests). */
101
- clear(): void {
102
- this.codes.clear();
103
- }
104
- }
105
-
106
- /**
107
- * Resolve the HS256 signing key. Prefers `AUTH_JWT_SECRET`; when unset, mints an
108
- * ephemeral per-process key (fail-open) and warns once. Memoized so every
109
- * sign/verify in a process shares one key.
110
- */
111
- let cachedKey: Uint8Array | undefined;
112
- function signingKey(): Uint8Array {
113
- if (cachedKey) return cachedKey;
114
- const secret = process.env.AUTH_JWT_SECRET?.trim();
115
- if (secret) {
116
- cachedKey = new TextEncoder().encode(secret);
117
- } else {
118
- logger.warn(
119
- "AUTH_JWT_SECRET is not set - using an ephemeral per-process key; sessions will not survive a restart",
120
- );
121
- cachedKey = randomBytes(32);
122
- }
123
- return cachedKey;
124
- }
125
-
126
- /** Reset the memoized key (tests, or after changing the env in-process). */
127
- export function resetSigningKey(): void {
128
- cachedKey = undefined;
129
- }
130
-
131
- /** Mint a short-lived session JWT for `email`, expiring in `ttlSeconds`. */
132
- export async function signSession(email: string, ttlSeconds: number): Promise<string> {
133
- return new SignJWT({ email })
134
- .setProtectedHeader({ alg: "HS256" })
135
- .setSubject(email)
136
- .setAudience(JWT_AUD)
137
- .setIssuedAt()
138
- .setExpirationTime(`${ttlSeconds}s`)
139
- .sign(signingKey());
140
- }
141
-
142
- /** Validate a session JWT, returning the email it was minted for, or `undefined`. */
143
- export async function verifySession(token: string | undefined): Promise<string | undefined> {
144
- if (!token) return undefined;
145
- try {
146
- const { payload } = await jwtVerify(token, signingKey(), { audience: JWT_AUD });
147
- return typeof payload.email === "string" ? payload.email : undefined;
148
- } catch {
149
- return undefined;
150
- }
151
- }