@dbx-tools/tunnel 0.6.89 → 0.6.91
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/README.md +22 -31
- package/index.ts +2 -6
- package/lib/index.d.ts +2 -6
- package/lib/index.js +3 -6
- package/lib/src/allowlist.d.ts +1 -1
- package/lib/src/allowlist.js +2 -2
- package/lib/src/gate.d.ts +12 -9
- package/lib/src/gate.js +60 -82
- package/lib/src/interceptor.d.ts +1 -1
- package/lib/src/interceptor.js +2 -2
- package/lib/src/plugin.d.ts +45 -32
- package/lib/src/plugin.js +104 -72
- package/lib/src/signing-key.d.ts +2 -2
- package/lib/src/signing-key.js +4 -4
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/allowlist.ts +1 -1
- package/src/gate.ts +64 -95
- package/src/interceptor.ts +1 -1
- package/src/plugin.ts +142 -93
- package/src/signing-key.ts +3 -3
- package/lib/src/otp.d.ts +0 -49
- package/lib/src/otp.js +0 -130
- package/lib/src/rate-limit.d.ts +0 -35
- package/lib/src/rate-limit.js +0 -53
- package/src/otp.ts +0 -143
- package/src/rate-limit.ts +0 -59
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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib3RwLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL290cC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7Ozs7Ozs7Ozs7Ozs7O0dBa0JHO0FBRUgsT0FBTyxFQUFFLFVBQVUsRUFBRSxTQUFTLEVBQUUsZUFBZSxFQUFFLE1BQU0sYUFBYSxDQUFDO0FBQ3JFLE9BQU8sRUFBRSxZQUFZLEVBQUUsTUFBTSxvQkFBb0IsQ0FBQztBQUNsRCxPQUFPLEVBQUUsV0FBVyxFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFDOUMsT0FBTyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsTUFBTSxNQUFNLENBQUM7QUFDMUMsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBRTlDLG9GQUFvRjtBQUNwRixNQUFNLE9BQU8sR0FBRyx1QkFBdUIsQ0FBQztBQUV4QyxvRkFBb0Y7QUFDcEYsTUFBTSxXQUFXLEdBQUcsYUFBYSxDQUFDO0FBRWxDLDhCQUE4QjtBQUM5QixTQUFTLE1BQU0sQ0FBQyxLQUFhO0lBQzNCLE9BQU8sVUFBVSxDQUFDLFFBQVEsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDMUQsQ0FBQztBQUVELDZEQUE2RDtBQUM3RCxTQUFTLFlBQVksQ0FBQyxDQUFTLEVBQUUsQ0FBUztJQUN4QyxJQUFJLENBQUMsQ0FBQyxNQUFNLEtBQUssQ0FBQyxDQUFDLE1BQU07UUFBRSxPQUFPLEtBQUssQ0FBQztJQUN4QyxPQUFPLGVBQWUsQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUMsRUFBRSxLQUFLLENBQUMsRUFBRSxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUMsRUFBRSxLQUFLLENBQUMsQ0FBQyxDQUFDO0FBQ3ZFLENBQUM7QUFVRDs7O0dBR0c7QUFDSCxNQUFNLE9BQU8sU0FBUztJQUVEO0lBQ0E7SUFGbkIsWUFDbUIsVUFBa0IsRUFDbEIsV0FBbUI7UUFEbkIsZUFBVSxHQUFWLFVBQVUsQ0FBUTtRQUNsQixnQkFBVyxHQUFYLFdBQVcsQ0FBUTtJQUNuQyxDQUFDO0lBRUksS0FBSztRQUNYLE9BQU8sWUFBWSxDQUFDLGVBQWUsRUFBRSxDQUFDO0lBQ3hDLENBQUM7SUFFTyxHQUFHLENBQUMsS0FBYTtRQUN2QixPQUFPLEdBQUcsV0FBVyxHQUFHLEtBQUssQ0FBQyxXQUFXLEVBQUUsRUFBRSxDQUFDO0lBQ2hELENBQUM7SUFFRDs7OztPQUlHO0lBQ0gsS0FBSyxDQUFDLEtBQUssQ0FBQyxLQUFhO1FBQ3ZCLE1BQU0sR0FBRyxHQUFHLElBQUksQ0FBQyxHQUFHLENBQUMsS0FBSyxDQUFDLENBQUM7UUFDNUIsT0FBTyxXQUFXLENBQUMsZUFBZSxDQUFDLEdBQUcsRUFBRSxLQUFLLElBQUksRUFBRTtZQUNqRCxNQUFNLElBQUksR0FBRyxNQUFNLENBQUMsU0FBUyxDQUFDLENBQUMsRUFBRSxTQUFTLENBQUMsQ0FBQyxDQUFDLFFBQVEsQ0FBQyxDQUFDLEVBQUUsR0FBRyxDQUFDLENBQUM7WUFDOUQsTUFBTSxLQUFLLEdBQWMsRUFBRSxJQUFJLEVBQUUsTUFBTSxDQUFDLElBQUksQ0FBQyxFQUFFLFFBQVEsRUFBRSxDQUFDLEVBQUUsQ0FBQztZQUM3RCxNQUFNLElBQUksQ0FBQyxLQUFLLEVBQUUsQ0FBQyxHQUFHLENBQUMsR0FBRyxFQUFFLEtBQUssRUFBRSxFQUFFLEdBQUcsRUFBRSxJQUFJLENBQUMsVUFBVSxFQUFFLENBQUMsQ0FBQztZQUM3RCxPQUFPLElBQUksQ0FBQztRQUNkLENBQUMsQ0FBQyxDQUFDO0lBQ0wsQ0FBQztJQUVEOzs7OztPQUtHO0lBQ0gsS0FBSyxDQUFDLE1BQU0sQ0FBQyxLQUFhLEVBQUUsSUFBWTtRQUN0QyxNQUFNLEdBQUcsR0FBRyxJQUFJLENBQUMsR0FBRyxDQUFDLEtBQUssQ0FBQyxDQUFDO1FBQzVCLE9BQU8sV0FBVyxDQUFDLGVBQWUsQ0FBQyxHQUFHLEVBQUUsS0FBSyxJQUFJLEVBQUU7WUFDakQsTUFBTSxLQUFLLEdBQUcsTUFBTSxJQUFJLENBQUMsS0FBSyxFQUFFLENBQUMsR0FBRyxDQUFZLEdBQUcsQ0FBQyxDQUFDO1lBQ3JELElBQUksQ0FBQyxLQUFLO2dCQUFFLE9BQU8sU0FBUyxDQUFDO1lBQzdCLE1BQU0sUUFBUSxHQUFHLEtBQUssQ0FBQyxRQUFRLEdBQUcsQ0FBQyxDQUFDO1lBQ3BDLElBQUksWUFBWSxDQUFDLEtBQUssQ0FBQyxJQUFJLEVBQUUsTUFBTSxDQUFDLElBQUksQ0FBQyxDQUFDLEVBQUUsQ0FBQztnQkFDM0MsTUFBTSxJQUFJLENBQUMsS0FBSyxFQUFFLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFDO2dCQUMvQixPQUFPLElBQUksQ0FBQztZQUNkLENBQUM7WUFDRCxJQUFJLFFBQVEsSUFBSSxJQUFJLENBQUMsV0FBVyxFQUFFLENBQUM7Z0JBQ2pDLE1BQU0sSUFBSSxDQUFDLEtBQUssRUFBRSxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQztnQkFDL0IsT0FBTyxtQkFBbUIsQ0FBQztZQUM3QixDQUFDO1lBQ0QsTUFBTSxJQUFJLENBQUMsS0FBSyxFQUFFLENBQUMsR0FBRyxDQUFDLEdBQUcsRUFBRSxFQUFFLEdBQUcsS0FBSyxFQUFFLFFBQVEsRUFBRSxFQUFFLEVBQUUsR0FBRyxFQUFFLElBQUksQ0FBQyxVQUFVLEVBQUUsQ0FBQyxDQUFDO1lBQzlFLE9BQU8sU0FBUyxDQUFDO1FBQ25CLENBQUMsQ0FBQyxDQUFDO0lBQ0wsQ0FBQztDQUNGO0FBRUQsZ0VBQWdFO0FBQ2hFLE1BQU0sQ0FBQyxLQUFLLFVBQVUsV0FBVyxDQUFDLEtBQWEsRUFBRSxVQUFrQjtJQUNqRSxNQUFNLEVBQUUsR0FBRyxFQUFFLEdBQUcsTUFBTSxVQUFVLEVBQUUsQ0FBQztJQUNuQyxPQUFPLElBQUksT0FBTyxDQUFDLEVBQUUsS0FBSyxFQUFFLENBQUM7U0FDMUIsa0JBQWtCLENBQUMsRUFBRSxHQUFHLEVBQUUsT0FBTyxFQUFFLENBQUM7U0FDcEMsVUFBVSxDQUFDLEtBQUssQ0FBQztTQUNqQixXQUFXLENBQUMsT0FBTyxDQUFDO1NBQ3BCLFdBQVcsRUFBRTtTQUNiLGlCQUFpQixDQUFDLEdBQUcsVUFBVSxHQUFHLENBQUM7U0FDbkMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDO0FBQ2YsQ0FBQztBQUVELHFGQUFxRjtBQUNyRixNQUFNLENBQUMsS0FBSyxVQUFVLGFBQWEsQ0FBQyxLQUF5QjtJQUMzRCxJQUFJLENBQUMsS0FBSztRQUFFLE9BQU8sU0FBUyxDQUFDO0lBQzdCLElBQUksQ0FBQztRQUNILE1BQU0sRUFBRSxHQUFHLEVBQUUsUUFBUSxFQUFFLEdBQUcsTUFBTSxVQUFVLEVBQUUsQ0FBQztRQUM3QyxNQUFNLEVBQUUsT0FBTyxFQUFFLEdBQUcsTUFBTSxTQUFTLENBQUMsS0FBSyxFQUFFLEdBQUcsRUFBRSxFQUFFLFFBQVEsRUFBRSxPQUFPLEVBQUUsQ0FBQyxDQUFDO1FBQ3ZFLHVFQUF1RTtRQUN2RSxpRUFBaUU7UUFDakUseUVBQXlFO1FBQ3pFLHdEQUF3RDtRQUN4RCw2RUFBNkU7UUFDN0Usd0VBQXdFO1FBQ3hFLDRDQUE0QztRQUM1QyxJQUFJLFFBQVEsR0FBRyxDQUFDLElBQUksQ0FBQyxPQUFPLENBQUMsR0FBRyxLQUFLLFNBQVMsSUFBSSxPQUFPLENBQUMsR0FBRyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsUUFBUSxHQUFHLElBQUksQ0FBQyxDQUFDLEVBQUUsQ0FBQztZQUM3RixPQUFPLFNBQVMsQ0FBQztRQUNuQixDQUFDO1FBQ0QsT0FBTyxPQUFPLE9BQU8sQ0FBQyxLQUFLLEtBQUssUUFBUSxDQUFDLENBQUMsQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxTQUFTLENBQUM7SUFDdkUsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLE9BQU8sU0FBUyxDQUFDO0lBQ25CLENBQUM7QUFDSCxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBPbmUtdGltZS1jb2RlIHN0b3JlICsgc2Vzc2lvbiBKV1QgZm9yIHRoZSBlbWFpbC1PVFAgdHVubmVsIGdhdGUuXG4gKlxuICogVGhlIGNvZGUgc3RvcmUgaXMgYmFja2VkIGJ5IEFwcEtpdCdzIGBDYWNoZU1hbmFnZXJgIChhdXRvLWNvbmZpZ3VyZWQgdG8gbWVtb3J5LFxuICogb3IgTGFrZWJhc2Ugd2hlbiB0aGUgYXBwIHdpcmVzIGEgcGVyc2lzdGVudCBgQ2FjaGVTdG9yYWdlYCksIHNvIFRUTCBFWFBJUlkgYW5kXG4gKiBldmljdGlvbiBhcmUgdGhlIGNhY2hlJ3Mgam9iIC0gbm8gaGFuZC1yb2xsZWQgTWFwIG9yIHRpbWVycy4gQSA2LWRpZ2l0IGNvZGUgaXNcbiAqIGdlbmVyYXRlZCB3aXRoIGBjcnlwdG8ucmFuZG9tSW50YCBhbmQgc3RvcmVkIGFzIGEgU0hBLTI1NiBoYXNoIHdpdGggYW4gYXR0ZW1wdFxuICogY291bnRlciAobmV2ZXIgdGhlIHBsYWludGV4dCwgbmV2ZXIgaW4gdGhlIEpXVCk7IGB2ZXJpZnlgIGlzIGNvbnN0YW50LXRpbWUgb25cbiAqIHRoZSBoYXNoLCBhbmQgdGhlIGVudHJ5IGlzIGRlbGV0ZWQgb24gc3VjY2VzcyBvciBvbmNlIGF0dGVtcHRzIGFyZSBleGhhdXN0ZWQuXG4gKlxuICogVGhlIHNlc3Npb24gSldUIGlzIGFuIEhTMjU2IHRva2VuICh2aWEgYGpvc2VgKSBjYXJyeWluZyBvbmx5IHRoZSBlbWFpbC4gSXRzXG4gKiBzaWduaW5nIGtleSBpcyByZXNvbHZlZCBieSBgLi9zaWduaW5nLWtleS50c2A6IGBUVU5ORUxfQVVUSF9KV1RfU0VDUkVUYCB3aGVuXG4gKiBzZXQsIGVsc2UgYSBrZXkgcGVyc2lzdGVkIGluIHRoZSBjYWNoZSBmb3IgMzAgZGF5cyBzbyBjb29raWVzIHN1cnZpdmUgdGhlXG4gKiByZXN0YXJ0cyBhIHR1bm5lbCBzZWVzIHdoZW5ldmVyIHRoZSBhcHAgaXQgd3JhcHMgcmVsb2Fkcy4gQSBjb29raWUgc2lnbmVkXG4gKiBiZWZvcmUgYFRVTk5FTF9BVVRIX1NFU1NJT05fQ1VUT0ZGYCBpcyByZWZ1c2VkIGhlcmUgYXMgd2VsbCBhcyBiZWluZyBvcnBoYW5lZFxuICogYnkgdGhlIGNhY2hlIGtleSwgc28gdGhlIGZvcmNlLWNsZWFyIHN3aXRjaCBob2xkcyBldmVuIGZvciBhIHN0aWxsLWN1cnJlbnQga2V5LlxuICpcbiAqIEBtb2R1bGVcbiAqL1xuXG5pbXBvcnQgeyBjcmVhdGVIYXNoLCByYW5kb21JbnQsIHRpbWluZ1NhZmVFcXVhbCB9IGZyb20gXCJub2RlOmNyeXB0b1wiO1xuaW1wb3J0IHsgQ2FjaGVNYW5hZ2VyIH0gZnJvbSBcIkBkYXRhYnJpY2tzL2FwcGtpdFwiO1xuaW1wb3J0IHsgcHJvY2Vzc0xvY2sgfSBmcm9tIFwiQGRieC10b29scy9jb3JlXCI7XG5pbXBvcnQgeyBqd3RWZXJpZnksIFNpZ25KV1QgfSBmcm9tIFwiam9zZVwiO1xuaW1wb3J0IHsgc2lnbmluZ0tleSB9IGZyb20gXCIuL3NpZ25pbmcta2V5LnRzXCI7XG5cbi8qKiBKV1QgaXNzdWVyL2F1ZGllbmNlIHNvIGEgdG9rZW4gbWludGVkIGZvciB0aGlzIGdhdGUgaXNuJ3QgYWNjZXB0ZWQgZWxzZXdoZXJlLiAqL1xuY29uc3QgSldUX0FVRCA9IFwiZGJ4LXRvb2xzLXR1bm5lbC1hdXRoXCI7XG5cbi8qKiBDYWNoZS1rZXkgcHJlZml4IGZvciBwZW5kaW5nIGNvZGVzLCBuYW1lc3BhY2VkIGF3YXkgZnJvbSBhbnkgb3RoZXIgY2FjaGUgdXNlLiAqL1xuY29uc3QgQ09ERV9QUkVGSVggPSBcInR1bm5lbDpvdHA6XCI7XG5cbi8qKiBTSEEtMjU2IGhleCBvZiBhIHZhbHVlLiAqL1xuZnVuY3Rpb24gc2hhMjU2KHZhbHVlOiBzdHJpbmcpOiBzdHJpbmcge1xuICByZXR1cm4gY3JlYXRlSGFzaChcInNoYTI1NlwiKS51cGRhdGUodmFsdWUpLmRpZ2VzdChcImhleFwiKTtcbn1cblxuLyoqIENvbnN0YW50LXRpbWUgY29tcGFyZSBvZiB0d28gZXF1YWwtbGVuZ3RoIGhleCBkaWdlc3RzLiAqL1xuZnVuY3Rpb24gc2FmZUVxdWFsSGV4KGE6IHN0cmluZywgYjogc3RyaW5nKTogYm9vbGVhbiB7XG4gIGlmIChhLmxlbmd0aCAhPT0gYi5sZW5ndGgpIHJldHVybiBmYWxzZTtcbiAgcmV0dXJuIHRpbWluZ1NhZmVFcXVhbChCdWZmZXIuZnJvbShhLCBcImhleFwiKSwgQnVmZmVyLmZyb20oYiwgXCJoZXhcIikpO1xufVxuXG5pbnRlcmZhY2UgQ29kZUVudHJ5IHtcbiAgaGFzaDogc3RyaW5nO1xuICBhdHRlbXB0czogbnVtYmVyO1xufVxuXG4vKiogUmVzdWx0IG9mIHtAbGluayBDb2RlU3RvcmUudmVyaWZ5fS4gKi9cbmV4cG9ydCB0eXBlIFZlcmlmeU91dGNvbWUgPSBcIm9rXCIgfCBcImludmFsaWRcIiB8IFwiZXhwaXJlZFwiIHwgXCJ0b28tbWFueS1hdHRlbXB0c1wiO1xuXG4vKipcbiAqIFBlbmRpbmcgb25lLXRpbWUgY29kZXMsIHN0b3JlZCBpbiBBcHBLaXQncyBjYWNoZSBrZXllZCBieSBsb3dlcmNhc2VkIGVtYWlsLlxuICogRXhwaXJ5IGlzIHRoZSBjYWNoZSdzIFRUTCAobm8gbWFudWFsIGNsb2NrKTsgYSBtaXNzIG1lYW5zIGV4cGlyZWQtb3ItbmV2ZXIuXG4gKi9cbmV4cG9ydCBjbGFzcyBDb2RlU3RvcmUge1xuICBjb25zdHJ1Y3RvcihcbiAgICBwcml2YXRlIHJlYWRvbmx5IHR0bFNlY29uZHM6IG51bWJlcixcbiAgICBwcml2YXRlIHJlYWRvbmx5IG1heEF0dGVtcHRzOiBudW1iZXIsXG4gICkge31cblxuICBwcml2YXRlIGNhY2hlKCk6IENhY2hlTWFuYWdlciB7XG4gICAgcmV0dXJuIENhY2hlTWFuYWdlci5nZXRJbnN0YW5jZVN5bmMoKTtcbiAgfVxuXG4gIHByaXZhdGUga2V5KGVtYWlsOiBzdHJpbmcpOiBzdHJpbmcge1xuICAgIHJldHVybiBgJHtDT0RFX1BSRUZJWH0ke2VtYWlsLnRvTG93ZXJDYXNlKCl9YDtcbiAgfVxuXG4gIC8qKlxuICAgKiBHZW5lcmF0ZSwgc3RvcmUgKGhhc2hlZCwgd2l0aCB0aGUgY2FjaGUgVFRMKSwgYW5kIFJFVFVSTiBhIGZyZXNoIDYtZGlnaXRcbiAgICogY29kZS4gVGhlIGNhbGxlciBlbWFpbHMgdGhlIHJldHVybmVkIHBsYWludGV4dDsgb25seSB0aGUgaGFzaCBpcyByZXRhaW5lZC5cbiAgICogUmVwbGFjZXMgYW55IHBlbmRpbmcgY29kZSBmb3IgdGhlIGFkZHJlc3MuXG4gICAqL1xuICBhc3luYyBpc3N1ZShlbWFpbDogc3RyaW5nKTogUHJvbWlzZTxzdHJpbmc+IHtcbiAgICBjb25zdCBrZXkgPSB0aGlzLmtleShlbWFpbCk7XG4gICAgcmV0dXJuIHByb2Nlc3NMb2NrLndpdGhQcm9jZXNzTG9jayhrZXksIGFzeW5jICgpID0+IHtcbiAgICAgIGNvbnN0IGNvZGUgPSBTdHJpbmcocmFuZG9tSW50KDAsIDFfMDAwXzAwMCkpLnBhZFN0YXJ0KDYsIFwiMFwiKTtcbiAgICAgIGNvbnN0IGVudHJ5OiBDb2RlRW50cnkgPSB7IGhhc2g6IHNoYTI1Nihjb2RlKSwgYXR0ZW1wdHM6IDAgfTtcbiAgICAgIGF3YWl0IHRoaXMuY2FjaGUoKS5zZXQoa2V5LCBlbnRyeSwgeyB0dGw6IHRoaXMudHRsU2Vjb25kcyB9KTtcbiAgICAgIHJldHVybiBjb2RlO1xuICAgIH0pO1xuICB9XG5cbiAgLyoqXG4gICAqIENoZWNrIGBjb2RlYCBmb3IgYGVtYWlsYC4gQSBjYWNoZSBtaXNzIGlzIGBleHBpcmVkYCAoVFRMIGVsYXBzZWQgb3IgbmV2ZXJcbiAgICogaXNzdWVkKS4gRGVsZXRlcyB0aGUgZW50cnkgb24gc3VjY2VzcyBvciB3aGVuIGF0dGVtcHRzIGFyZSBleGhhdXN0ZWQsIHNvIGFcbiAgICogY29kZSBpcyBzaW5nbGUtdXNlIGFuZCBjYW4ndCBiZSBicnV0ZS1mb3JjZWQgcGFzdCB0aGUgY2FwLiBBbiBhdHRlbXB0XG4gICAqIGluY3JlbWVudHMgdGhlIHN0b3JlZCBjb3VudGVyIChyZS1wZXJzaXN0ZWQgd2l0aCBhIGZyZXNoIFRUTCB3aW5kb3cpLlxuICAgKi9cbiAgYXN5bmMgdmVyaWZ5KGVtYWlsOiBzdHJpbmcsIGNvZGU6IHN0cmluZyk6IFByb21pc2U8VmVyaWZ5T3V0Y29tZT4ge1xuICAgIGNvbnN0IGtleSA9IHRoaXMua2V5KGVtYWlsKTtcbiAgICByZXR1cm4gcHJvY2Vzc0xvY2sud2l0aFByb2Nlc3NMb2NrKGtleSwgYXN5bmMgKCkgPT4ge1xuICAgICAgY29uc3QgZW50cnkgPSBhd2FpdCB0aGlzLmNhY2hlKCkuZ2V0PENvZGVFbnRyeT4oa2V5KTtcbiAgICAgIGlmICghZW50cnkpIHJldHVybiBcImV4cGlyZWRcIjtcbiAgICAgIGNvbnN0IGF0dGVtcHRzID0gZW50cnkuYXR0ZW1wdHMgKyAxO1xuICAgICAgaWYgKHNhZmVFcXVhbEhleChlbnRyeS5oYXNoLCBzaGEyNTYoY29kZSkpKSB7XG4gICAgICAgIGF3YWl0IHRoaXMuY2FjaGUoKS5kZWxldGUoa2V5KTtcbiAgICAgICAgcmV0dXJuIFwib2tcIjtcbiAgICAgIH1cbiAgICAgIGlmIChhdHRlbXB0cyA+PSB0aGlzLm1heEF0dGVtcHRzKSB7XG4gICAgICAgIGF3YWl0IHRoaXMuY2FjaGUoKS5kZWxldGUoa2V5KTtcbiAgICAgICAgcmV0dXJuIFwidG9vLW1hbnktYXR0ZW1wdHNcIjtcbiAgICAgIH1cbiAgICAgIGF3YWl0IHRoaXMuY2FjaGUoKS5zZXQoa2V5LCB7IC4uLmVudHJ5LCBhdHRlbXB0cyB9LCB7IHR0bDogdGhpcy50dGxTZWNvbmRzIH0pO1xuICAgICAgcmV0dXJuIFwiaW52YWxpZFwiO1xuICAgIH0pO1xuICB9XG59XG5cbi8qKiBNaW50IGEgc2Vzc2lvbiBKV1QgZm9yIGBlbWFpbGAsIGV4cGlyaW5nIGluIGB0dGxTZWNvbmRzYC4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBzaWduU2Vzc2lvbihlbWFpbDogc3RyaW5nLCB0dGxTZWNvbmRzOiBudW1iZXIpOiBQcm9taXNlPHN0cmluZz4ge1xuICBjb25zdCB7IGtleSB9ID0gYXdhaXQgc2lnbmluZ0tleSgpO1xuICByZXR1cm4gbmV3IFNpZ25KV1QoeyBlbWFpbCB9KVxuICAgIC5zZXRQcm90ZWN0ZWRIZWFkZXIoeyBhbGc6IFwiSFMyNTZcIiB9KVxuICAgIC5zZXRTdWJqZWN0KGVtYWlsKVxuICAgIC5zZXRBdWRpZW5jZShKV1RfQVVEKVxuICAgIC5zZXRJc3N1ZWRBdCgpXG4gICAgLnNldEV4cGlyYXRpb25UaW1lKGAke3R0bFNlY29uZHN9c2ApXG4gICAgLnNpZ24oa2V5KTtcbn1cblxuLyoqIFZhbGlkYXRlIGEgc2Vzc2lvbiBKV1QsIHJldHVybmluZyB0aGUgZW1haWwgaXQgd2FzIG1pbnRlZCBmb3IsIG9yIGB1bmRlZmluZWRgLiAqL1xuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHZlcmlmeVNlc3Npb24odG9rZW46IHN0cmluZyB8IHVuZGVmaW5lZCk6IFByb21pc2U8c3RyaW5nIHwgdW5kZWZpbmVkPiB7XG4gIGlmICghdG9rZW4pIHJldHVybiB1bmRlZmluZWQ7XG4gIHRyeSB7XG4gICAgY29uc3QgeyBrZXksIGN1dG9mZk1zIH0gPSBhd2FpdCBzaWduaW5nS2V5KCk7XG4gICAgY29uc3QgeyBwYXlsb2FkIH0gPSBhd2FpdCBqd3RWZXJpZnkodG9rZW4sIGtleSwgeyBhdWRpZW5jZTogSldUX0FVRCB9KTtcbiAgICAvLyBCZWx0IGFuZCBicmFjZXMgd2l0aCB0aGUgY3V0b2ZmLXNjb3BlZCBjYWNoZSBrZXk6IHRoYXQgYWxvbmUgYWxyZWFkeVxuICAgIC8vIG9ycGhhbnMgb2xkZXIga2V5cywgYnV0IGFuIG9wZXJhdG9yIHdobyBtb3ZlZCB0aGUgY3V0b2ZmIHdoaWxlXG4gICAgLy8gVFVOTkVMX0FVVEhfSldUX1NFQ1JFVCBpcyBzZXQgaGFzIG5vIGtleSByb3RhdGlvbiB0byByZWx5IG9uLCBhbmQgdGhpc1xuICAgIC8vIGNoZWNrIGlzIHdoYXQgbWFrZXMgdGhlIHN3aXRjaCB3b3JrIGluIHRoYXQgY2FzZSB0b28uXG4gICAgLy8gQ29tcGFyZWQgaW4gd2hvbGUgU0VDT05EUyBiZWNhdXNlIGBpYXRgIGhhcyBubyBmaW5lciByZXNvbHV0aW9uOiBhZ2FpbnN0IGFcbiAgICAvLyBtaWxsaXNlY29uZCBjdXRvZmYsIGEgY29va2llIG1pbnRlZCBpbiB0aGUgc2FtZSBzZWNvbmQgYXMgaXQgd291bGQgYmVcbiAgICAvLyByZWZ1c2VkIGRlcGVuZGluZyBvbiBzdWItc2Vjb25kIHJvdW5kaW5nLlxuICAgIGlmIChjdXRvZmZNcyA+IDAgJiYgKHBheWxvYWQuaWF0ID09PSB1bmRlZmluZWQgfHwgcGF5bG9hZC5pYXQgPCBNYXRoLmZsb29yKGN1dG9mZk1zIC8gMTAwMCkpKSB7XG4gICAgICByZXR1cm4gdW5kZWZpbmVkO1xuICAgIH1cbiAgICByZXR1cm4gdHlwZW9mIHBheWxvYWQuZW1haWwgPT09IFwic3RyaW5nXCIgPyBwYXlsb2FkLmVtYWlsIDogdW5kZWZpbmVkO1xuICB9IGNhdGNoIHtcbiAgICByZXR1cm4gdW5kZWZpbmVkO1xuICB9XG59XG4iXX0=
|
package/lib/src/rate-limit.d.ts
DELETED
|
@@ -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
|
-
}
|
package/lib/src/rate-limit.js
DELETED
|
@@ -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
|
-
}
|