@dbx-tools/tunnel 0.6.90 → 0.6.92

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.
package/lib/src/otp.d.ts DELETED
@@ -1,49 +0,0 @@
1
- /**
2
- * One-time-code store + session JWT for the email-OTP tunnel gate.
3
- *
4
- * The code store is backed by AppKit's `CacheManager` (auto-configured to memory,
5
- * or Lakebase when the app wires a persistent `CacheStorage`), so TTL EXPIRY and
6
- * eviction are the cache's job - no hand-rolled Map or timers. A 6-digit code is
7
- * generated with `crypto.randomInt` and stored as a SHA-256 hash with an attempt
8
- * counter (never the plaintext, never in the JWT); `verify` is constant-time on
9
- * the hash, and the entry is deleted on success or once attempts are exhausted.
10
- *
11
- * The session JWT is an HS256 token (via `jose`) carrying only the email. Its
12
- * signing key is resolved by `./signing-key.ts`: `TUNNEL_AUTH_JWT_SECRET` when
13
- * set, else a key persisted in the cache for 30 days so cookies survive the
14
- * restarts a tunnel sees whenever the app it wraps reloads. A cookie signed
15
- * before `TUNNEL_AUTH_SESSION_CUTOFF` is refused here as well as being orphaned
16
- * by the cache key, so the force-clear switch holds even for a still-current key.
17
- *
18
- * @module
19
- */
20
- /** Result of {@link CodeStore.verify}. */
21
- export type VerifyOutcome = "ok" | "invalid" | "expired" | "too-many-attempts";
22
- /**
23
- * Pending one-time codes, stored in AppKit's cache keyed by lowercased email.
24
- * Expiry is the cache's TTL (no manual clock); a miss means expired-or-never.
25
- */
26
- export declare class CodeStore {
27
- private readonly ttlSeconds;
28
- private readonly maxAttempts;
29
- constructor(ttlSeconds: number, maxAttempts: number);
30
- private cache;
31
- private key;
32
- /**
33
- * Generate, store (hashed, with the cache TTL), and RETURN a fresh 6-digit
34
- * code. The caller emails the returned plaintext; only the hash is retained.
35
- * Replaces any pending code for the address.
36
- */
37
- issue(email: string): Promise<string>;
38
- /**
39
- * Check `code` for `email`. A cache miss is `expired` (TTL elapsed or never
40
- * issued). Deletes the entry on success or when attempts are exhausted, so a
41
- * code is single-use and can't be brute-forced past the cap. An attempt
42
- * increments the stored counter (re-persisted with a fresh TTL window).
43
- */
44
- verify(email: string, code: string): Promise<VerifyOutcome>;
45
- }
46
- /** Mint a session JWT for `email`, expiring in `ttlSeconds`. */
47
- export declare function signSession(email: string, ttlSeconds: number): Promise<string>;
48
- /** Validate a session JWT, returning the email it was minted for, or `undefined`. */
49
- export declare function verifySession(token: string | undefined): Promise<string | undefined>;
package/lib/src/otp.js DELETED
@@ -1,130 +0,0 @@
1
- /**
2
- * One-time-code store + session JWT for the email-OTP tunnel gate.
3
- *
4
- * The code store is backed by AppKit's `CacheManager` (auto-configured to memory,
5
- * or Lakebase when the app wires a persistent `CacheStorage`), so TTL EXPIRY and
6
- * eviction are the cache's job - no hand-rolled Map or timers. A 6-digit code is
7
- * generated with `crypto.randomInt` and stored as a SHA-256 hash with an attempt
8
- * counter (never the plaintext, never in the JWT); `verify` is constant-time on
9
- * the hash, and the entry is deleted on success or once attempts are exhausted.
10
- *
11
- * The session JWT is an HS256 token (via `jose`) carrying only the email. Its
12
- * signing key is resolved by `./signing-key.ts`: `TUNNEL_AUTH_JWT_SECRET` when
13
- * set, else a key persisted in the cache for 30 days so cookies survive the
14
- * restarts a tunnel sees whenever the app it wraps reloads. A cookie signed
15
- * before `TUNNEL_AUTH_SESSION_CUTOFF` is refused here as well as being orphaned
16
- * by the cache key, so the force-clear switch holds even for a still-current key.
17
- *
18
- * @module
19
- */
20
- import { createHash, randomInt, timingSafeEqual } from "node:crypto";
21
- import { CacheManager } from "@databricks/appkit";
22
- import { processLock } from "@dbx-tools/core";
23
- import { jwtVerify, SignJWT } from "jose";
24
- import { signingKey } from "./signing-key.js";
25
- /** JWT issuer/audience so a token minted for this gate isn't accepted elsewhere. */
26
- const JWT_AUD = "dbx-tools-tunnel-auth";
27
- /** Cache-key prefix for pending codes, namespaced away from any other cache use. */
28
- const CODE_PREFIX = "tunnel:otp:";
29
- /** SHA-256 hex of a value. */
30
- function sha256(value) {
31
- return createHash("sha256").update(value).digest("hex");
32
- }
33
- /** Constant-time compare of two equal-length hex digests. */
34
- function safeEqualHex(a, b) {
35
- if (a.length !== b.length)
36
- return false;
37
- return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
38
- }
39
- /**
40
- * Pending one-time codes, stored in AppKit's cache keyed by lowercased email.
41
- * Expiry is the cache's TTL (no manual clock); a miss means expired-or-never.
42
- */
43
- export class CodeStore {
44
- ttlSeconds;
45
- maxAttempts;
46
- constructor(ttlSeconds, maxAttempts) {
47
- this.ttlSeconds = ttlSeconds;
48
- this.maxAttempts = maxAttempts;
49
- }
50
- cache() {
51
- return CacheManager.getInstanceSync();
52
- }
53
- key(email) {
54
- return `${CODE_PREFIX}${email.toLowerCase()}`;
55
- }
56
- /**
57
- * Generate, store (hashed, with the cache TTL), and RETURN a fresh 6-digit
58
- * code. The caller emails the returned plaintext; only the hash is retained.
59
- * Replaces any pending code for the address.
60
- */
61
- async issue(email) {
62
- const key = this.key(email);
63
- return processLock.withProcessLock(key, async () => {
64
- const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
65
- const entry = { hash: sha256(code), attempts: 0 };
66
- await this.cache().set(key, entry, { ttl: this.ttlSeconds });
67
- return code;
68
- });
69
- }
70
- /**
71
- * Check `code` for `email`. A cache miss is `expired` (TTL elapsed or never
72
- * issued). Deletes the entry on success or when attempts are exhausted, so a
73
- * code is single-use and can't be brute-forced past the cap. An attempt
74
- * increments the stored counter (re-persisted with a fresh TTL window).
75
- */
76
- async verify(email, code) {
77
- const key = this.key(email);
78
- return processLock.withProcessLock(key, async () => {
79
- const entry = await this.cache().get(key);
80
- if (!entry)
81
- return "expired";
82
- const attempts = entry.attempts + 1;
83
- if (safeEqualHex(entry.hash, sha256(code))) {
84
- await this.cache().delete(key);
85
- return "ok";
86
- }
87
- if (attempts >= this.maxAttempts) {
88
- await this.cache().delete(key);
89
- return "too-many-attempts";
90
- }
91
- await this.cache().set(key, { ...entry, attempts }, { ttl: this.ttlSeconds });
92
- return "invalid";
93
- });
94
- }
95
- }
96
- /** Mint a session JWT for `email`, expiring in `ttlSeconds`. */
97
- export async function signSession(email, ttlSeconds) {
98
- const { key } = await signingKey();
99
- return new SignJWT({ email })
100
- .setProtectedHeader({ alg: "HS256" })
101
- .setSubject(email)
102
- .setAudience(JWT_AUD)
103
- .setIssuedAt()
104
- .setExpirationTime(`${ttlSeconds}s`)
105
- .sign(key);
106
- }
107
- /** Validate a session JWT, returning the email it was minted for, or `undefined`. */
108
- export async function verifySession(token) {
109
- if (!token)
110
- return undefined;
111
- try {
112
- const { key, cutoffMs } = await signingKey();
113
- const { payload } = await jwtVerify(token, key, { audience: JWT_AUD });
114
- // Belt and braces with the cutoff-scoped cache key: that alone already
115
- // orphans older keys, but an operator who moved the cutoff while
116
- // TUNNEL_AUTH_JWT_SECRET is set has no key rotation to rely on, and this
117
- // check is what makes the switch work in that case too.
118
- // Compared in whole SECONDS because `iat` has no finer resolution: against a
119
- // millisecond cutoff, a cookie minted in the same second as it would be
120
- // refused depending on sub-second rounding.
121
- if (cutoffMs > 0 && (payload.iat === undefined || payload.iat < Math.floor(cutoffMs / 1000))) {
122
- return undefined;
123
- }
124
- return typeof payload.email === "string" ? payload.email : undefined;
125
- }
126
- catch {
127
- return undefined;
128
- }
129
- }
130
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib3RwLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL290cC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7Ozs7Ozs7Ozs7Ozs7O0dBa0JHO0FBRUgsT0FBTyxFQUFFLFVBQVUsRUFBRSxTQUFTLEVBQUUsZUFBZSxFQUFFLE1BQU0sYUFBYSxDQUFDO0FBQ3JFLE9BQU8sRUFBRSxZQUFZLEVBQUUsTUFBTSxvQkFBb0IsQ0FBQztBQUNsRCxPQUFPLEVBQUUsV0FBVyxFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFDOUMsT0FBTyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsTUFBTSxNQUFNLENBQUM7QUFDMUMsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBRTlDLG9GQUFvRjtBQUNwRixNQUFNLE9BQU8sR0FBRyx1QkFBdUIsQ0FBQztBQUV4QyxvRkFBb0Y7QUFDcEYsTUFBTSxXQUFXLEdBQUcsYUFBYSxDQUFDO0FBRWxDLDhCQUE4QjtBQUM5QixTQUFTLE1BQU0sQ0FBQyxLQUFhO0lBQzNCLE9BQU8sVUFBVSxDQUFDLFFBQVEsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDMUQsQ0FBQztBQUVELDZEQUE2RDtBQUM3RCxTQUFTLFlBQVksQ0FBQyxDQUFTLEVBQUUsQ0FBUztJQUN4QyxJQUFJLENBQUMsQ0FBQyxNQUFNLEtBQUssQ0FBQyxDQUFDLE1BQU07UUFBRSxPQUFPLEtBQUssQ0FBQztJQUN4QyxPQUFPLGVBQWUsQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUMsRUFBRSxLQUFLLENBQUMsRUFBRSxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUMsRUFBRSxLQUFLLENBQUMsQ0FBQyxDQUFDO0FBQ3ZFLENBQUM7QUFVRDs7O0dBR0c7QUFDSCxNQUFNLE9BQU8sU0FBUztJQUVEO0lBQ0E7SUFGbkIsWUFDbUIsVUFBa0IsRUFDbEIs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QixXQUFXLENBQUMsT0FBTyxDQUFDO1NBQ3BCLFdBQVcsRUFBRTtTQUNiLGlCQUFpQixDQUFDLEdBQUcsVUFBVSxHQUFHLENBQUM7U0FDbkMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDO0FBQ2YsQ0FBQztBQUVELHFGQUFxRjtBQUNyRixNQUFNLENBQUMsS0FBSyxVQUFVLGFBQWEsQ0FBQyxLQUF5QjtJQUMzRCxJQUFJLENBQUMsS0FBSztRQUFFLE9BQU8sU0FBUyxDQUFDO0lBQzdCLElBQUksQ0FBQztRQUNILE1BQU0sRUFBRSxHQUFHLEVBQUUsUUFBUSxFQUFFLEdBQUcsTUFBTSxVQUFVLEVBQUUsQ0FBQztRQUM3QyxNQUFNLEVBQUUsT0FBTyxFQUFFLEdBQUcsTUFBTSxTQUFTLENBQUMsS0FBSyxFQUFFLEdBQUcsRUFBRSxFQUFFLFFBQVEsRUFBRSxPQUFPLEVBQUUsQ0FBQyxDQUFDO1FBQ3ZFLHVFQUF1RTtRQUN2RSxpRUFBaUU7UUFDakUseUVBQXlFO1FBQ3pFLHdEQUF3RDtRQUN4RCw2RUFBNkU7UUFDN0Usd0VBQXdFO1FBQ3hFLDRDQUE0QztRQUM1QyxJQUFJLFFBQVEsR0FBRyxDQUFDLElBQUksQ0FBQyxPQUFPLENBQUMsR0FBRyxLQUFLLFNBQVMsSUFBSSxPQUFPLENBQUMsR0FBRyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsUUFBUSxHQUFHLElBQUksQ0FBQyxDQUFDLEVBQUUsQ0FBQztZQUM3RixPQUFPLFNBQVMsQ0FBQztRQUNuQixDQUFDO1FBQ0QsT0FBTyxPQUFPLE9BQU8sQ0FBQyxLQUFLLEtBQUssUUFBUSxDQUFDLENBQUMsQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxTQUFTLENBQUM7SUFDdkUsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLE9BQU8sU0FBUyxDQUFDO0lBQ25CLENBQUM7QUFDSCxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBPbmUtdGltZS1jb2RlIHN0b3JlICsgc2Vzc2lvbiBKV1QgZm9yIHRoZSBlbWFpbC1PVFAgdHVubmVsIGdhdGUuXG4gKlxuICogVGhlIGNvZGUgc3RvcmUgaXMgYmFja2VkIGJ5IEFwcEtpdCdzIGBDYWNoZU1hbmFnZXJgIChhdXRvLWNvbmZpZ3VyZWQgdG8gbWVtb3J5LFxuICogb3IgTGFrZWJhc2Ugd2hlbiB0aGUgYXBwIHdpcmVzIGEgcGVyc2lzdGVudCBgQ2FjaGVTdG9yYWdlYCksIHNvIFRUTCBFWFBJUlkgYW5kXG4gKiBldmljdGlvbiBhcmUgdGhlIGNhY2hlJ3Mgam9iIC0gbm8gaGFuZC1yb2xsZWQgTWFwIG9yIHRpbWVycy4gQSA2LWRpZ2l0IGNvZGUgaXNcbiAqIGdlbmVyYXRlZCB3aXRoIGBjcnlwdG8ucmFuZG9tSW50YCBhbmQgc3RvcmVkIGFzIGEgU0hBLTI1NiBoYXNoIHdpdGggYW4gYXR0ZW1wdFxuICogY291bnRlciAobmV2ZXIgdGhlIHBsYWludGV4dCwgbmV2ZXIgaW4gdGhlIEpXVCk7IGB2ZXJpZnlgIGlzIGNvbnN0YW50LXRpbWUgb25cbiAqIHRoZSBoYXNoLCBhbmQgdGhlIGVudHJ5IGlzIGRlbGV0ZWQgb24gc3VjY2VzcyBvciBvbmNlIGF0dGVtcHRzIGFyZSBleGhhdXN0ZWQuXG4gKlxuICogVGhlIHNlc3Npb24gSldUIGlzIGFuIEhTMjU2IHRva2VuICh2aWEgYGpvc2VgKSBjYXJyeWluZyBvbmx5IHRoZSBlbWFpbC4gSXRzXG4gKiBzaWduaW5nIGtleSBpcyByZXNvbHZlZCBieSBgLi9zaWduaW5nLWtleS50c2A6IGBUVU5ORUxfQVVUSF9KV1RfU0VDUkVUYCB3aGVuXG4gKiBzZXQsIGVsc2UgYSBrZXkgcGVyc2lzdGVkIGluIHRoZSBjYWNoZSBmb3IgMzAgZGF5cyBzbyBjb29raWVzIHN1cnZpdmUgdGhlXG4gKiByZXN0YXJ0cyBhIHR1bm5lbCBzZWVzIHdoZW5ldmVyIHRoZSBhcHAgaXQgd3JhcHMgcmVsb2Fkcy4gQSBjb29raWUgc2lnbmVkXG4gKiBiZWZvcmUgYFRVTk5FTF9BVVRIX1NFU1NJT05fQ1VUT0ZGYCBpcyByZWZ1c2VkIGhlcmUgYXMgd2VsbCBhcyBiZWluZyBvcnBoYW5lZFxuICogYnkgdGhlIGNhY2hlIGtleSwgc28gdGhlIGZvcmNlLWNsZWFyIHN3aXRjaCBob2xkcyBldmVuIGZvciBhIHN0aWxsLWN1cnJlbnQga2V5LlxuICpcbiAqIEBtb2R1bGVcbiAqL1xuXG5pbXBvcnQgeyBjcmVhdGVIYXNoLCByYW5kb21JbnQsIHRpbWluZ1NhZmVFcXVhbCB9IGZyb20gXCJub2RlOmNyeXB0b1wiO1xuaW1wb3J0IHsgQ2FjaGVNYW5hZ2VyIH0gZnJvbSBcIkBkYXRhYnJpY2tzL2FwcGtpdFwiO1xuaW1wb3J0IHsgcHJvY2Vzc0xvY2sgfSBmcm9tIFwiQGRieC10b29scy9jb3JlXCI7XG5pbXBvcnQgeyBqd3RWZXJpZnksIFNpZ25KV1QgfSBmcm9tIFwiam9zZVwiO1xuaW1wb3J0IHsgc2lnbmluZ0tleSB9IGZyb20gXCIuL3NpZ25pbmcta2V5LnRzXCI7XG5cbi8qKiBKV1QgaXNzdWVyL2F1ZGllbmNlIHNvIGEgdG9rZW4gbWludGVkIGZvciB0aGlzIGdhdGUgaXNuJ3QgYWNjZXB0ZWQgZWxzZXdoZXJlLiAqL1xuY29uc3QgSldUX0FVRCA9IFwiZGJ4LXRvb2xzLXR1bm5lbC1hdXRoXCI7XG5cbi8qKiBDYWNoZS1rZXkgcHJlZml4IGZvciBwZW5kaW5nIGNvZGVzLCBuYW1lc3BhY2VkIGF3YXkgZnJvbSBhbnkgb3RoZXIgY2FjaGUgdXNlLiAqL1xuY29uc3QgQ09ERV9QUkVGSVggPSBcInR1bm5lbDpvdHA6XCI7XG5cbi8qKiBTSEEtMjU2IGhleCBvZiBhIHZhbHVlLiAqL1xuZnVuY3Rpb24gc2hhMjU2KHZhbHVlOiBzdHJpbmcpOiBzdHJpbmcge1xuICByZXR1cm4gY3JlYXRlSGFzaChcInNoYTI1NlwiKS51cGRhdGUodmFsdWUpLmRpZ2VzdChcImhleFwiKTtcbn1cblxuLyoqIENvbnN0YW50LXRpbWUgY29tcGFyZSBvZiB0d28gZXF1YWwtbGVuZ3RoIGhleCBkaWdlc3RzLiAqL1xuZnVuY3Rpb24gc2FmZUVxdWFsSGV4KGE6IHN0cmluZywgYjogc3RyaW5nKTogYm9vbGVhbiB7XG4gIGlmIChhLmxlbmd0aCAhPT0gYi5sZW5ndGgpIHJldHVybiBmYWxzZTtcbiAgcmV0dXJuIHRpbWluZ1NhZmVFcXVhbChCdWZmZXIuZnJvbShhLCBcImhleFwiKSwgQnVmZmVyLmZyb20oYiwgXCJoZXhcIikpO1xufVxuXG5pbnRlcmZhY2UgQ29kZUVudHJ5IHtcbiAgaGFzaDogc3RyaW5nO1xuICBhdHRlbXB0czogbnVtYmVyO1xufVxuXG4vKiogUmVzdWx0IG9mIHtAbGluayBDb2RlU3RvcmUudmVyaWZ5fS4gKi9cbmV4cG9ydCB0eXBlIFZlcmlmeU91dGNvbWUgPSBcIm9rXCIgfCBcImludmFsaWRcIiB8IFwiZXhwaXJlZFwiIHwgXCJ0b28tbWFueS1hdHRlbXB0c1wiO1xuXG4vKipcbiAqIFBlbmRpbmcgb25lLXRpbWUgY29kZXMsIHN0b3JlZCBpbiBBcHBLaXQncyBjYWNoZSBrZXllZCBieSBsb3dlcmNhc2VkIGVtYWlsLlxuICogRXhwaXJ5IGlzIHRoZSBjYWNoZSdzIFRUTCAobm8gbWFudWFsIGNsb2NrKTsgYSBtaXNzIG1lYW5zIGV4cGlyZWQtb3ItbmV2ZXIuXG4gKi9cbmV4cG9ydCBjbGFzcyBDb2RlU3RvcmUge1xuICBjb25zdHJ1Y3RvcihcbiAgICBwcml2YXRlIHJlYWRvbmx5IHR0bFNlY29uZHM6IG51bWJlcixcbiAgICBwcml2YXRlIHJlYWRvbmx5IG1heEF0dGVtcHRzOiBudW1iZXIsXG4gICkge31cblxuICBwcml2YXRlIGNhY2hlKCk6IENhY2hlTWFuYWdlciB7XG4gICAgcmV0dXJuIENhY2hlTWFuYWdlci5nZXRJbnN0YW5jZVN5bmMoKTtcbiAgfVxuXG4gIHByaXZhdGUga2V5KGVtYWlsOiBzdHJpbmcpOiBzdHJpbmcge1xuICAgIHJldHVybiBgJHtDT0RFX1BSRUZJWH0ke2VtYWlsLnRvTG93ZXJDYXNlKCl9YDtcbiAgfVxuXG4gIC8qKlxuICAgKiBHZW5lcmF0ZSwgc3RvcmUgKGhhc2hlZCwgd2l0aCB0aGUgY2FjaGUgVFRMKSwgYW5kIFJFVFVSTiBhIGZyZXNoIDYtZGlnaXRcbiAgICogY29kZS4gVGhlIGNhbGxlciBlbWFpbHMgdGhlIHJldHVybmVkIHBsYWludGV4dDsgb25seSB0aGUgaGFzaCBpcyByZXRhaW5lZC5cbiAgICogUmVwbGFjZXMgYW55IHBlbmRpbmcgY29kZSBmb3IgdGhlIGFkZHJlc3MuXG4gICAqL1xuICBhc3luYyBpc3N1ZShlbWFpbDogc3RyaW5nKTogUHJvbWlzZTxzdHJpbmc+IHtcbiAgICBjb25zdCBrZXkgPSB0aGlzLmtleShlbWFpbCk7XG4gICAgcmV0dXJuIHByb2Nlc3NMb2NrLndpdGhQcm9jZXNzTG9jayhrZXksIGFzeW5jICgpID0+IHtcbiAgICAgIGNvbnN0IGNvZGUgPSBTdHJpbmcocmFuZG9tSW50KDAsIDFfMDAwXzAwMCkpLnBhZFN0YXJ0KDYsIFwiMFwiKTtcbiAgICAgIGNvbnN0IGVudHJ5OiBDb2RlRW50cnkgPSB7IGhhc2g6IHNoYTI1Nihjb2RlKSwgYXR0ZW1wdHM6IDAgfTtcbiAgICAgIGF3YWl0IHRoaXMuY2FjaGUoKS5zZXQoa2V5LCBlbnRyeSwgeyB0dGw6IHRoaXMudHRsU2Vjb25kcyB9KTtcbiAgICAgIHJldHVybiBjb2RlO1xuICAgIH0pO1xuICB9XG5cbiAgLyoqXG4gICAqIENoZWNrIGBjb2RlYCBmb3IgYGVtYWlsYC4gQSBjYWNoZSBtaXNzIGlzIGBleHBpcmVkYCAoVFRMIGVsYXBzZWQgb3IgbmV2ZXJcbiAgICogaXNzdWVkKS4gRGVsZXRlcyB0aGUgZW50cnkgb24gc3VjY2VzcyBvciB3aGVuIGF0dGVtcHRzIGFyZSBleGhhdXN0ZWQsIHNvIGFcbiAgICogY29kZSBpcyBzaW5nbGUtdXNlIGFuZCBjYW4ndCBiZSBicnV0ZS1mb3JjZWQgcGFzdCB0aGUgY2FwLiBBbiBhdHRlbXB0XG4gICAqIGluY3JlbWVudHMgdGhlIHN0b3JlZCBjb3VudGVyIChyZS1wZXJzaXN0ZWQgd2l0aCBhIGZyZXNoIFRUTCB3aW5kb3cpLlxuICAgKi9cbiAgYXN5bmMgdmVyaWZ5KGVtYWlsOiBzdHJpbmcsIGNvZGU6IHN0cmluZyk6IFByb21pc2U8VmVyaWZ5T3V0Y29tZT4ge1xuICAgIGNvbnN0IGtleSA9IHRoaXMua2V5KGVtYWlsKTtcbiAgICByZXR1cm4gcHJvY2Vzc0xvY2sud2l0aFByb2Nlc3NMb2NrKGtleSwgYXN5bmMgKCkgPT4ge1xuICAgICAgY29uc3QgZW50cnkgPSBhd2FpdCB0aGlzLmNhY2hlKCkuZ2V0PENvZGVFbnRyeT4oa2V5KTtcbiAgICAgIGlmICghZW50cnkpIHJldHVybiBcImV4cGlyZWRcIjtcbiAgICAgIGNvbnN0IGF0dGVtcHRzID0gZW50cnkuYXR0ZW1wdHMgKyAxO1xuICAgICAgaWYgKHNhZmVFcXVhbEhleChlbnRyeS5oYXNoLCBzaGEyNTYoY29kZSkpKSB7XG4gICAgICAgIGF3YWl0IHRoaXMuY2FjaGUoKS5kZWxldGUoa2V5KTtcbiAgICAgICAgcmV0dXJuIFwib2tcIjtcbiAgICAgIH1cbiAgICAgIGlmIChhdHRlbXB0cyA+PSB0aGlzLm1heEF0dGVtcHRzKSB7XG4gICAgICAgIGF3YWl0IHRoaXMuY2FjaGUoKS5kZWxldGUoa2V5KTtcbiAgICAgICAgcmV0dXJuIFwidG9vLW1hbnktYXR0ZW1wdHNcIjtcbiAgICAgIH1cbiAgICAgIGF3YWl0IHRoaXMuY2FjaGUoKS5zZXQoa2V5LCB7IC4uLmVudHJ5LCBhdHRlbXB0cyB9LCB7IHR0bDogdGhpcy50dGxTZWNvbmRzIH0pO1xuICAgICAgcmV0dXJuIFwiaW52YWxpZFwiO1xuICAgIH0pO1xuICB9XG59XG5cbi8qKiBNaW50IGEgc2Vzc2lvbiBKV1QgZm9yIGBlbWFpbGAsIGV4cGlyaW5nIGluIGB0dGxTZWNvbmRzYC4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBzaWduU2Vzc2lvbihlbWFpbDogc3RyaW5nLCB0dGxTZWNvbmRzOiBudW1iZXIpOiBQcm9taXNlPHN0cmluZz4ge1xuICBjb25zdCB7IGtleSB9ID0gYXdhaXQgc2lnbmluZ0tleSgpO1xuICByZXR1cm4gbmV3IFNpZ25KV1QoeyBlbWFpbCB9KVxuICAgIC5zZXRQcm90ZWN0ZWRIZWFkZXIoeyBhbGc6IFwiSFMyNTZcIiB9KVxuICAgIC5zZXRTdWJqZWN0KGVtYWlsKVxuICAgIC5zZXRBdWRpZW5jZShKV1RfQVVEKVxuICAgIC5zZXRJc3N1ZWRBdCgpXG4gICAgLnNldEV4cGlyYXRpb25UaW1lKGAke3R0bFNlY29uZHN9c2ApXG4gICAgLnNpZ24oa2V5KTtcbn1cblxuLyoqIFZhbGlkYXRlIGEgc2Vzc2lvbiBKV1QsIHJldHVybmluZyB0aGUgZW1haWwgaXQgd2FzIG1pbnRlZCBmb3IsIG9yIGB1bmRlZmluZWRgLiAqL1xuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHZlcmlmeVNlc3Npb24odG9rZW46IHN0cmluZyB8IHVuZGVmaW5lZCk6IFByb21pc2U8c3RyaW5nIHwgdW5kZWZpbmVkPiB7XG4gIGlmICghdG9rZW4pIHJldHVybiB1bmRlZmluZWQ7XG4gIHRyeSB7XG4gICAgY29uc3QgeyBrZXksIGN1dG9mZk1zIH0gPSBhd2FpdCBzaWduaW5nS2V5KCk7XG4gICAgY29uc3QgeyBwYXlsb2FkIH0gPSBhd2FpdCBqd3RWZXJpZnkodG9rZW4sIGtleSwgeyBhdWRpZW5jZTogSldUX0FVRCB9KTtcbiAgICAvLyBCZWx0IGFuZCBicmFjZXMgd2l0aCB0aGUgY3V0b2ZmLXNjb3BlZCBjYWNoZSBrZXk6IHRoYXQgYWxvbmUgYWxyZWFkeVxuICAgIC8vIG9ycGhhbnMgb2xkZXIga2V5cywgYnV0IGFuIG9wZXJhdG9yIHdobyBtb3ZlZCB0aGUgY3V0b2ZmIHdoaWxlXG4gICAgLy8gVFVOTkVMX0FVVEhfSldUX1NFQ1JFVCBpcyBzZXQgaGFzIG5vIGtleSByb3RhdGlvbiB0byByZWx5IG9uLCBhbmQgdGhpc1xuICAgIC8vIGNoZWNrIGlzIHdoYXQgbWFrZXMgdGhlIHN3aXRjaCB3b3JrIGluIHRoYXQgY2FzZSB0b28uXG4gICAgLy8gQ29tcGFyZWQgaW4gd2hvbGUgU0VDT05EUyBiZWNhdXNlIGBpYXRgIGhhcyBubyBmaW5lciByZXNvbHV0aW9uOiBhZ2FpbnN0IGFcbiAgICAvLyBtaWxsaXNlY29uZCBjdXRvZmYsIGEgY29va2llIG1pbnRlZCBpbiB0aGUgc2FtZSBzZWNvbmQgYXMgaXQgd291bGQgYmVcbiAgICAvLyByZWZ1c2VkIGRlcGVuZGluZyBvbiBzdWItc2Vjb25kIHJvdW5kaW5nLlxuICAgIGlmIChjdXRvZmZNcyA+IDAgJiYgKHBheWxvYWQuaWF0ID09PSB1bmRlZmluZWQgfHwgcGF5bG9hZC5pYXQgPCBNYXRoLmZsb29yKGN1dG9mZk1zIC8gMTAwMCkpKSB7XG4gICAgICByZXR1cm4gdW5kZWZpbmVkO1xuICAgIH1cbiAgICByZXR1cm4gdHlwZW9mIHBheWxvYWQuZW1haWwgPT09IFwic3RyaW5nXCIgPyBwYXlsb2FkLmVtYWlsIDogdW5kZWZpbmVkO1xuICB9IGNhdGNoIHtcbiAgICByZXR1cm4gdW5kZWZpbmVkO1xuICB9XG59XG4iXX0=
@@ -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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmF0ZS1saW1pdC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9yYXRlLWxpbWl0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Ozs7Ozs7Ozs7OztHQWNHO0FBT0gsb0RBQW9EO0FBQ3BELE1BQU0sT0FBTyxXQUFXO0lBSUg7SUFDQTtJQUpGLE9BQU8sR0FBRyxJQUFJLEdBQUcsRUFBa0IsQ0FBQztJQUVyRCxZQUNtQixHQUFXLEVBQ1gsUUFBZ0I7UUFEaEIsUUFBRyxHQUFILEdBQUcsQ0FBUTtRQUNYLGFBQVEsR0FBUixRQUFRLENBQVE7SUFDaEMsQ0FBQztJQUVKOzs7O09BSUc7SUFDSCxHQUFHLENBQUMsR0FBVyxFQUFFLE1BQWMsSUFBSSxDQUFDLEdBQUcsRUFBRTtRQUN2QyxJQUFJLElBQUksQ0FBQyxHQUFHLElBQUksQ0FBQztZQUFFLE9BQU8sRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLENBQUM7UUFDNUMsTUFBTSxRQUFRLEdBQUcsSUFBSSxDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFDLENBQUM7UUFDdkMsSUFBSSxDQUFDLFFBQVEsSUFBSSxHQUFHLElBQUksUUFBUSxDQUFDLE9BQU8sRUFBRSxDQUFDO1lBQ3pDLElBQUksQ0FBQyxPQUFPLENBQUMsR0FBRyxDQUFDLEdBQUcsRUFBRSxFQUFFLEtBQUssRUFBRSxDQUFDLEVBQUUsT0FBTyxFQUFFLEdBQUcsR0FBRyxJQUFJLENBQUMsUUFBUSxFQUFFLENBQUMsQ0FBQztZQUNsRSxPQUFPLEVBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxDQUFDO1FBQzNCLENBQUM7UUFDRCxJQUFJLFFBQVEsQ0FBQyxLQUFLLEdBQUcsSUFBSSxDQUFDLEdBQUcsRUFBRSxDQUFDO1lBQzlCLFFBQVEsQ0FBQyxLQUFLLElBQUksQ0FBQyxDQUFDO1lBQ3BCLE9BQU8sRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLENBQUM7UUFDM0IsQ0FBQztRQUNELE9BQU8sRUFBRSxPQUFPLEVBQUUsS0FBSyxFQUFFLFVBQVUsRUFBRSxJQUFJLENBQUMsSUFBSSxDQUFDLENBQUMsUUFBUSxDQUFDLE9BQU8sR0FBRyxHQUFHLENBQUMsR0FBRyxJQUFJLENBQUMsRUFBRSxDQUFDO0lBQ3BGLENBQUM7SUFFRCw2RUFBNkU7SUFDN0UsS0FBSyxDQUFDLEdBQVc7UUFDZixJQUFJLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUMzQixDQUFDO0lBRUQsaUNBQWlDO0lBQ2pDLEtBQUs7UUFDSCxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssRUFBRSxDQUFDO0lBQ3ZCLENBQUM7Q0FDRiIsInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogQSBzbWFsbCBpbi1tZW1vcnkgZml4ZWQtd2luZG93IHJhdGUgbGltaXRlciBmb3IgdGhlIGVtYWlsLU9UUCBnYXRlLlxuICpcbiAqIEtleWVkIGJ5IGFuIGFyYml0cmFyeSBzdHJpbmcgKGFuIGVtYWlsIGFkZHJlc3Mgb3IgYSBjbGllbnQgSVApLiBFYWNoIGtleSBnZXRzXG4gKiBgbWF4YCBoaXRzIHBlciBgd2luZG93TXNgOyB0aGUgd2luZG93IHJlc2V0cyBvbiBmaXJzdCB1c2UgYWZ0ZXIgaXQgZWxhcHNlcy5cbiAqIGBoaXQoKWAgcmV0dXJucyB3aGV0aGVyIHRoZSBjYWxsIGlzIGFsbG93ZWQgYW5kLCB3aGVuIG5vdCwgaG93IG1hbnkgc2Vjb25kc1xuICogdW50aWwgdGhlIHdpbmRvdyByZXNldHMgc28gYSBjYWxsZXIgY2FuIHN1cmZhY2UgYSBjb29sZG93bi5cbiAqXG4gKiBJbi1tZW1vcnkgaXMgaW50ZW50aW9uYWwgYW5kIHN1ZmZpY2llbnQgZm9yIGEgc2luZ2xlLWFwcC1pbnN0YW5jZSBnYXRlOiBhblxuICogYXBwIGJlaGluZCBhIHBvcnRyIHR1bm5lbCBzZXJ2ZXMgZnJvbSBvbmUgcHJvY2Vzcy4gSXQgaXMgTk9UIGEgZGlzdHJpYnV0ZWRcbiAqIGxpbWl0ZXI7IGEgbXVsdGktcmVwbGljYSBkZXBsb3ltZW50IHdvdWxkIG5lZWQgc2hhcmVkIHN0YXRlLiBFbnRyaWVzIGFyZVxuICogcHJ1bmVkIGxhemlseSBvbiBhY2Nlc3MsIHNvIGFuIGlkbGUga2V5IGNvc3RzIG5vdGhpbmcgYWZ0ZXIgaXRzIHdpbmRvdy5cbiAqXG4gKiBAbW9kdWxlXG4gKi9cblxuaW50ZXJmYWNlIFdpbmRvdyB7XG4gIGNvdW50OiBudW1iZXI7XG4gIHJlc2V0QXQ6IG51bWJlcjtcbn1cblxuLyoqIEEgZml4ZWQtd2luZG93IHJhdGUgbGltaXRlciBvdmVyIHN0cmluZyBrZXlzLiAqL1xuZXhwb3J0IGNsYXNzIFJhdGVMaW1pdGVyIHtcbiAgcHJpdmF0ZSByZWFkb25seSB3aW5kb3dzID0gbmV3IE1hcDxzdHJpbmcsIFdpbmRvdz4oKTtcblxuICBjb25zdHJ1Y3RvcihcbiAgICBwcml2YXRlIHJlYWRvbmx5IG1heDogbnVtYmVyLFxuICAgIHByaXZhdGUgcmVhZG9ubHkgd2luZG93TXM6IG51bWJlcixcbiAgKSB7fVxuXG4gIC8qKlxuICAgKiBSZWNvcmQgYSBoaXQgZm9yIGBrZXlgLiBSZXR1cm5zIGB7IGFsbG93ZWQgfWAsIHBsdXMgYHJldHJ5QWZ0ZXJgIChzZWNvbmRzKVxuICAgKiB3aGVuIHRoZSBsaW1pdCBpcyBleGNlZWRlZC4gQSBsaW1pdCBvZiBgPD0gMGAgZGlzYWJsZXMgbGltaXRpbmcgKGFsd2F5c1xuICAgKiBhbGxvd2VkKSwgd2hpY2ggbGV0cyBhIGNvbmZpZyB0dXJuIGl0IG9mZiB3aXRob3V0IHNwZWNpYWwtY2FzaW5nIGNhbGxlcnMuXG4gICAqL1xuICBoaXQoa2V5OiBzdHJpbmcsIG5vdzogbnVtYmVyID0gRGF0ZS5ub3coKSk6IHsgYWxsb3dlZDogYm9vbGVhbjsgcmV0cnlBZnRlcj86IG51bWJlciB9IHtcbiAgICBpZiAodGhpcy5tYXggPD0gMCkgcmV0dXJuIHsgYWxsb3dlZDogdHJ1ZSB9O1xuICAgIGNvbnN0IGV4aXN0aW5nID0gdGhpcy53aW5kb3dzLmdldChrZXkpO1xuICAgIGlmICghZXhpc3RpbmcgfHwgbm93ID49IGV4aXN0aW5nLnJlc2V0QXQpIHtcbiAgICAgIHRoaXMud2luZG93cy5zZXQoa2V5LCB7IGNvdW50OiAxLCByZXNldEF0OiBub3cgKyB0aGlzLndpbmRvd01zIH0pO1xuICAgICAgcmV0dXJuIHsgYWxsb3dlZDogdHJ1ZSB9O1xuICAgIH1cbiAgICBpZiAoZXhpc3RpbmcuY291bnQgPCB0aGlzLm1heCkge1xuICAgICAgZXhpc3RpbmcuY291bnQgKz0gMTtcbiAgICAgIHJldHVybiB7IGFsbG93ZWQ6IHRydWUgfTtcbiAgICB9XG4gICAgcmV0dXJuIHsgYWxsb3dlZDogZmFsc2UsIHJldHJ5QWZ0ZXI6IE1hdGguY2VpbCgoZXhpc3RpbmcucmVzZXRBdCAtIG5vdykgLyAxMDAwKSB9O1xuICB9XG5cbiAgLyoqIEZvcmdldCBhIGtleSAoZS5nLiBjbGVhciBhIGNhbGxlcidzIHdpbmRvdyBhZnRlciBhIHN1Y2Nlc3NmdWwgdmVyaWZ5KS4gKi9cbiAgcmVzZXQoa2V5OiBzdHJpbmcpOiB2b2lkIHtcbiAgICB0aGlzLndpbmRvd3MuZGVsZXRlKGtleSk7XG4gIH1cblxuICAvKiogRHJvcCBldmVyeSB3aW5kb3cgKHRlc3RzKS4gKi9cbiAgY2xlYXIoKTogdm9pZCB7XG4gICAgdGhpcy53aW5kb3dzLmNsZWFyKCk7XG4gIH1cbn1cbiJdfQ==
package/src/otp.ts DELETED
@@ -1,143 +0,0 @@
1
- /**
2
- * One-time-code store + session JWT for the email-OTP tunnel gate.
3
- *
4
- * The code store is backed by AppKit's `CacheManager` (auto-configured to memory,
5
- * or Lakebase when the app wires a persistent `CacheStorage`), so TTL EXPIRY and
6
- * eviction are the cache's job - no hand-rolled Map or timers. A 6-digit code is
7
- * generated with `crypto.randomInt` and stored as a SHA-256 hash with an attempt
8
- * counter (never the plaintext, never in the JWT); `verify` is constant-time on
9
- * the hash, and the entry is deleted on success or once attempts are exhausted.
10
- *
11
- * The session JWT is an HS256 token (via `jose`) carrying only the email. Its
12
- * signing key is resolved by `./signing-key.ts`: `TUNNEL_AUTH_JWT_SECRET` when
13
- * set, else a key persisted in the cache for 30 days so cookies survive the
14
- * restarts a tunnel sees whenever the app it wraps reloads. A cookie signed
15
- * before `TUNNEL_AUTH_SESSION_CUTOFF` is refused here as well as being orphaned
16
- * by the cache key, so the force-clear switch holds even for a still-current key.
17
- *
18
- * @module
19
- */
20
-
21
- import { createHash, randomInt, timingSafeEqual } from "node:crypto";
22
- import { CacheManager } from "@databricks/appkit";
23
- import { processLock } from "@dbx-tools/core";
24
- import { jwtVerify, SignJWT } from "jose";
25
- import { signingKey } from "./signing-key.ts";
26
-
27
- /** JWT issuer/audience so a token minted for this gate isn't accepted elsewhere. */
28
- const JWT_AUD = "dbx-tools-tunnel-auth";
29
-
30
- /** Cache-key prefix for pending codes, namespaced away from any other cache use. */
31
- const CODE_PREFIX = "tunnel:otp:";
32
-
33
- /** SHA-256 hex of a value. */
34
- function sha256(value: string): string {
35
- return createHash("sha256").update(value).digest("hex");
36
- }
37
-
38
- /** Constant-time compare of two equal-length hex digests. */
39
- function safeEqualHex(a: string, b: string): boolean {
40
- if (a.length !== b.length) return false;
41
- return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
42
- }
43
-
44
- interface CodeEntry {
45
- hash: string;
46
- attempts: number;
47
- }
48
-
49
- /** Result of {@link CodeStore.verify}. */
50
- export type VerifyOutcome = "ok" | "invalid" | "expired" | "too-many-attempts";
51
-
52
- /**
53
- * Pending one-time codes, stored in AppKit's cache keyed by lowercased email.
54
- * Expiry is the cache's TTL (no manual clock); a miss means expired-or-never.
55
- */
56
- export class CodeStore {
57
- constructor(
58
- private readonly ttlSeconds: number,
59
- private readonly maxAttempts: number,
60
- ) {}
61
-
62
- private cache(): CacheManager {
63
- return CacheManager.getInstanceSync();
64
- }
65
-
66
- private key(email: string): string {
67
- return `${CODE_PREFIX}${email.toLowerCase()}`;
68
- }
69
-
70
- /**
71
- * Generate, store (hashed, with the cache TTL), and RETURN a fresh 6-digit
72
- * code. The caller emails the returned plaintext; only the hash is retained.
73
- * Replaces any pending code for the address.
74
- */
75
- async issue(email: string): Promise<string> {
76
- const key = this.key(email);
77
- return processLock.withProcessLock(key, async () => {
78
- const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
79
- const entry: CodeEntry = { hash: sha256(code), attempts: 0 };
80
- await this.cache().set(key, entry, { ttl: this.ttlSeconds });
81
- return code;
82
- });
83
- }
84
-
85
- /**
86
- * Check `code` for `email`. A cache miss is `expired` (TTL elapsed or never
87
- * issued). Deletes the entry on success or when attempts are exhausted, so a
88
- * code is single-use and can't be brute-forced past the cap. An attempt
89
- * increments the stored counter (re-persisted with a fresh TTL window).
90
- */
91
- async verify(email: string, code: string): Promise<VerifyOutcome> {
92
- const key = this.key(email);
93
- return processLock.withProcessLock(key, async () => {
94
- const entry = await this.cache().get<CodeEntry>(key);
95
- if (!entry) return "expired";
96
- const attempts = entry.attempts + 1;
97
- if (safeEqualHex(entry.hash, sha256(code))) {
98
- await this.cache().delete(key);
99
- return "ok";
100
- }
101
- if (attempts >= this.maxAttempts) {
102
- await this.cache().delete(key);
103
- return "too-many-attempts";
104
- }
105
- await this.cache().set(key, { ...entry, attempts }, { ttl: this.ttlSeconds });
106
- return "invalid";
107
- });
108
- }
109
- }
110
-
111
- /** Mint a session JWT for `email`, expiring in `ttlSeconds`. */
112
- export async function signSession(email: string, ttlSeconds: number): Promise<string> {
113
- const { key } = await signingKey();
114
- return new SignJWT({ email })
115
- .setProtectedHeader({ alg: "HS256" })
116
- .setSubject(email)
117
- .setAudience(JWT_AUD)
118
- .setIssuedAt()
119
- .setExpirationTime(`${ttlSeconds}s`)
120
- .sign(key);
121
- }
122
-
123
- /** Validate a session JWT, returning the email it was minted for, or `undefined`. */
124
- export async function verifySession(token: string | undefined): Promise<string | undefined> {
125
- if (!token) return undefined;
126
- try {
127
- const { key, cutoffMs } = await signingKey();
128
- const { payload } = await jwtVerify(token, key, { audience: JWT_AUD });
129
- // Belt and braces with the cutoff-scoped cache key: that alone already
130
- // orphans older keys, but an operator who moved the cutoff while
131
- // TUNNEL_AUTH_JWT_SECRET is set has no key rotation to rely on, and this
132
- // check is what makes the switch work in that case too.
133
- // Compared in whole SECONDS because `iat` has no finer resolution: against a
134
- // millisecond cutoff, a cookie minted in the same second as it would be
135
- // refused depending on sub-second rounding.
136
- if (cutoffMs > 0 && (payload.iat === undefined || payload.iat < Math.floor(cutoffMs / 1000))) {
137
- return undefined;
138
- }
139
- return typeof payload.email === "string" ? payload.email : undefined;
140
- } catch {
141
- return undefined;
142
- }
143
- }
package/src/rate-limit.ts DELETED
@@ -1,59 +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
-
17
- interface Window {
18
- count: number;
19
- resetAt: number;
20
- }
21
-
22
- /** A fixed-window rate limiter over string keys. */
23
- export class RateLimiter {
24
- private readonly windows = new Map<string, Window>();
25
-
26
- constructor(
27
- private readonly max: number,
28
- private readonly windowMs: number,
29
- ) {}
30
-
31
- /**
32
- * Record a hit for `key`. Returns `{ allowed }`, plus `retryAfter` (seconds)
33
- * when the limit is exceeded. A limit of `<= 0` disables limiting (always
34
- * allowed), which lets a config turn it off without special-casing callers.
35
- */
36
- hit(key: string, now: number = Date.now()): { allowed: boolean; retryAfter?: number } {
37
- if (this.max <= 0) return { allowed: true };
38
- const existing = this.windows.get(key);
39
- if (!existing || now >= existing.resetAt) {
40
- this.windows.set(key, { count: 1, resetAt: now + this.windowMs });
41
- return { allowed: true };
42
- }
43
- if (existing.count < this.max) {
44
- existing.count += 1;
45
- return { allowed: true };
46
- }
47
- return { allowed: false, retryAfter: Math.ceil((existing.resetAt - now) / 1000) };
48
- }
49
-
50
- /** Forget a key (e.g. clear a caller's window after a successful verify). */
51
- reset(key: string): void {
52
- this.windows.delete(key);
53
- }
54
-
55
- /** Drop every window (tests). */
56
- clear(): void {
57
- this.windows.clear();
58
- }
59
- }