@dbx-tools/cli-tunnel 0.6.49 → 0.6.51

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/src/plugin.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from "./env.ts";
31
31
  import { CodeStore, signSession, verifySession } from "./otp.ts";
32
32
  import { RateLimiter } from "./rate-limit.ts";
33
+ import { KEY_TTL_SECONDS, resolveSessionEpoch, signingKey } from "./signing-key.ts";
33
34
 
34
35
  const logger = log.logger("tunnel:auth");
35
36
 
@@ -62,12 +63,26 @@ export interface AuthGateConfig extends BasePluginConfig {
62
63
  * what the platform code-detection heuristics key on.
63
64
  */
64
65
  message?: string;
65
- /** Session lifetime (seconds). Env TUNNEL_AUTH_SESSION_TTL. Default 43200 (12h). */
66
+ /**
67
+ * Session lifetime (seconds). Env TUNNEL_AUTH_SESSION_TTL. Default 2592000 (30d).
68
+ *
69
+ * Matched to the cache-backed signing key's own 30-day TTL (see
70
+ * `./signing-key.ts`): the cookie and the key that validates it should expire
71
+ * together, or one silently outlives the other.
72
+ */
66
73
  sessionTtlSeconds?: number;
67
74
  /** One-time-code lifetime (seconds). Env TUNNEL_AUTH_CODE_TTL. Default 600 (10m). */
68
75
  codeTtlSeconds?: number;
69
76
  /** Max verify attempts per issued code. Default 5. */
70
77
  maxAttempts?: number;
78
+ /**
79
+ * Force-clear date: every session issued BEFORE it stops verifying, so moving
80
+ * it forward signs everyone out. Env TUNNEL_AUTH_SESSION_EPOCH.
81
+ *
82
+ * Any `Date`-parseable value (`2026-08-02`, an ISO timestamp) or bare epoch
83
+ * seconds / millis. Unset means no cutoff.
84
+ */
85
+ sessionEpoch?: string | number | Date;
71
86
  /** Deliver a code to an address. Wired by the app to the email plugin. */
72
87
  sendCode?: (email: string, code: string, opts: SendCodeOptions) => Promise<void>;
73
88
  }
@@ -95,6 +110,8 @@ export interface ResolvedAuthGateConfig {
95
110
  sessionTtlSeconds: number;
96
111
  codeTtlSeconds: number;
97
112
  maxAttempts: number;
113
+ /** Force-clear cutoff in epoch ms; `0` when unset. */
114
+ sessionEpochMs: number;
98
115
  }
99
116
 
100
117
  const DEFAULTS = {
@@ -107,7 +124,8 @@ const DEFAULTS = {
107
124
  // is the fallback when nothing is configured.
108
125
  brandName: brand.defaultBrandContext.name,
109
126
  message: "Your verification code is:",
110
- sessionTtlSeconds: 43200,
127
+ // 30 days, the same window the cache-backed signing key is stored for.
128
+ sessionTtlSeconds: KEY_TTL_SECONDS,
111
129
  codeTtlSeconds: 600,
112
130
  maxAttempts: 5,
113
131
  };
@@ -130,6 +148,7 @@ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateC
130
148
  ),
131
149
  codeTtlSeconds: env.positiveInt(config.codeTtlSeconds, CODE_TTL_ENV, DEFAULTS.codeTtlSeconds),
132
150
  maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
151
+ sessionEpochMs: resolveSessionEpoch(config.sessionEpoch),
133
152
  };
134
153
  }
135
154
 
@@ -171,9 +190,14 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
171
190
  override async setup(): Promise<void> {
172
191
  this.resolved = resolveAuthGateConfig(this.config);
173
192
  this.codes = new CodeStore(this.resolved.codeTtlSeconds, this.resolved.maxAttempts);
193
+ // Resolve the signing key HERE rather than lazily on the first sign-in, so a
194
+ // cache that cannot hold it (and the resulting "sessions won't survive a
195
+ // restart" warning) shows up in the startup log, not hours later.
196
+ const { epochMs } = await signingKey(this.resolved.sessionEpochMs);
174
197
  logger.info("ready", {
175
198
  patterns: this.resolved.allow.length,
176
199
  sessionTtlSeconds: this.resolved.sessionTtlSeconds,
200
+ ...(epochMs > 0 ? { sessionEpoch: new Date(epochMs).toISOString() } : {}),
177
201
  });
178
202
  }
179
203
 
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The gate's HS256 session-signing key, persisted in AppKit's cache.
3
+ *
4
+ * The key decides whether a session COOKIE still verifies. Before this module the
5
+ * fallback was `randomBytes(32)` per process, so every restart invalidated every
6
+ * outstanding cookie and each signed-in user had to request a new code - painful
7
+ * for a tunnel, which restarts whenever the app it wraps does. Now the key is
8
+ * stored in the cache AppKit already configured (memory, or Lakebase when the
9
+ * host wires a persistent `CacheStorage`), so with persistent storage a restart
10
+ * keeps sessions alive for {@link KEY_TTL_SECONDS}.
11
+ *
12
+ * An explicitly configured `TUNNEL_AUTH_JWT_SECRET` still wins outright. That is
13
+ * the right answer for a fleet: an operator-held secret needs no shared cache and
14
+ * no convergence, and it survives a cache flush.
15
+ *
16
+ * ## get / generate / get
17
+ *
18
+ * Two instances booting at once both miss, so both would generate - and the loser
19
+ * would sign cookies with a key the winner rejects. Resolution is a re-READ after
20
+ * the write: whatever the cache holds afterwards is the key everyone adopts, so
21
+ * the instances converge on ONE value instead of trusting the one they minted.
22
+ * (`set` is not conditional in the cache API - there is no `setnx` to lean on -
23
+ * and this runs once per process, so the extra round-trip is free.)
24
+ *
25
+ * A pathological interleave can still cost a key: if A writes between B's write
26
+ * and B's re-read, B adopts A's key while A adopts its own. The cost is bounded -
27
+ * a cookie minted in that window fails to verify and the holder signs in again -
28
+ * and it cannot produce a key one instance TRUSTS but another rejects for longer
29
+ * than the window itself. Set `TUNNEL_AUTH_JWT_SECRET` to remove the race
30
+ * entirely.
31
+ *
32
+ * ## Forcing every session to end
33
+ *
34
+ * {@link resolveSessionEpoch} reads a date from `TUNNEL_AUTH_SESSION_EPOCH` (or
35
+ * `--session-epoch`), and that date is part of the cache KEY. Moving it forward
36
+ * makes every prior key unreachable, so every cookie signed against it stops
37
+ * verifying - the log-everyone-out switch, without having to find and flush a
38
+ * cache entry. The epoch is also asserted against each token's `iat`, so a
39
+ * cookie that predates it is refused even if it was signed with the key that is
40
+ * somehow still current.
41
+ *
42
+ * @module
43
+ */
44
+
45
+ import { randomBytes } from "node:crypto";
46
+ import { CacheManager } from "@databricks/appkit";
47
+ import { env, log } from "@dbx-tools/shared-core";
48
+ import { JWT_SECRET_ENV, SESSION_EPOCH_ENV } from "./env.ts";
49
+
50
+ const logger = log.logger("tunnel:signing-key");
51
+
52
+ /**
53
+ * How long a cached signing key lives: 30 days.
54
+ *
55
+ * This is the ceiling on how long a session cookie can stay valid across
56
+ * restarts, so it is deliberately >= the default session TTL - a key that expired
57
+ * before the cookies it signed would log everyone out for no reason.
58
+ */
59
+ export const KEY_TTL_SECONDS = 30 * 24 * 60 * 60;
60
+
61
+ /** Cache-key prefix for the signing key, namespaced away from other cache use. */
62
+ const KEY_PREFIX = "tunnel:auth:signing-key:";
63
+
64
+ /** Bytes of entropy in a generated key (256-bit, matching HS256's hash width). */
65
+ const KEY_BYTES = 32;
66
+
67
+ /** What the cache stores: the key plus when it was minted, for observability. */
68
+ interface StoredKey {
69
+ /** Base64url of the raw key bytes. */
70
+ secret: string;
71
+ /** When this key was generated, ISO-8601. */
72
+ createdAt: string;
73
+ }
74
+
75
+ /**
76
+ * Resolve the force-clear epoch as epoch MILLISECONDS, or `0` when unset.
77
+ *
78
+ * Accepts anything `Date` parses (`2026-08-02`, an ISO timestamp) plus a bare
79
+ * epoch-seconds / epoch-millis number, so a value pasted from `date +%s` works.
80
+ * An UNPARSEABLE value is ignored with a warning rather than throwing: this is
81
+ * the switch that logs a fleet back in, and failing to boot over a typo is worse
82
+ * than not rotating.
83
+ */
84
+ export function resolveSessionEpoch(configured?: string | number | Date): number {
85
+ const raw = configured ?? env.text(SESSION_EPOCH_ENV) ?? undefined;
86
+ if (raw === undefined || raw === null || raw === "") return 0;
87
+ return clampToPast(parseEpoch(raw));
88
+ }
89
+
90
+ /** Parse the accepted epoch spellings to ms, or `0` when unusable. */
91
+ function parseEpoch(raw: string | number | Date): number {
92
+ if (raw instanceof Date) return Number.isNaN(raw.getTime()) ? 0 : raw.getTime();
93
+
94
+ const text = String(raw).trim();
95
+ // A bare number is a timestamp, not a date string: `Date.parse("1785...")`
96
+ // would read it as a YEAR. Values below ~1e11 are seconds (any millis
97
+ // timestamp since 1973 is larger), which is what `date +%s` prints.
98
+ if (/^\d+$/.test(text)) {
99
+ const numeric = Number(text);
100
+ return numeric < 1e11 ? numeric * 1000 : numeric;
101
+ }
102
+ const parsed = Date.parse(text);
103
+ if (Number.isNaN(parsed)) {
104
+ logger.warn(`ignoring unparseable ${env.name(SESSION_EPOCH_ENV)}`, { value: text });
105
+ return 0;
106
+ }
107
+ return parsed;
108
+ }
109
+
110
+ /**
111
+ * Hold the cutoff at "now", because a FUTURE epoch would refuse the sessions it
112
+ * is about to mint as well as the old ones - an app nobody can sign in to, from a
113
+ * mistyped year, with the fix hidden behind understanding this flag. Clamped, a
114
+ * future date means what an operator setting it always meant: clear everything
115
+ * outstanding, then carry on.
116
+ */
117
+ function clampToPast(epochMs: number): number {
118
+ const now = Date.now();
119
+ if (epochMs <= now) return epochMs;
120
+ logger.warn(`${env.name(SESSION_EPOCH_ENV)} is in the future - clamping to now`, {
121
+ configured: new Date(epochMs).toISOString(),
122
+ });
123
+ return now;
124
+ }
125
+
126
+ /** The cache key for one epoch, so moving the epoch orphans every earlier key. */
127
+ function cacheKey(epochMs: number): string {
128
+ return `${KEY_PREFIX}${epochMs}`;
129
+ }
130
+
131
+ function decode(stored: StoredKey): Uint8Array {
132
+ return new Uint8Array(Buffer.from(stored.secret, "base64url"));
133
+ }
134
+
135
+ /**
136
+ * Load the signing key for `epochMs` from the cache, minting and storing one when
137
+ * absent. See the module docs for why the write is followed by a re-read.
138
+ */
139
+ async function loadFromCache(epochMs: number): Promise<Uint8Array> {
140
+ const cache = CacheManager.getInstanceSync();
141
+ const key = cacheKey(epochMs);
142
+
143
+ const existing = await cache.get<StoredKey>(key);
144
+ if (existing?.secret) {
145
+ logger.info("reusing cached signing key", { createdAt: existing.createdAt });
146
+ return decode(existing);
147
+ }
148
+
149
+ const minted: StoredKey = {
150
+ secret: Buffer.from(randomBytes(KEY_BYTES)).toString("base64url"),
151
+ createdAt: new Date().toISOString(),
152
+ };
153
+ await cache.set(key, minted, { ttl: KEY_TTL_SECONDS });
154
+
155
+ // Re-read rather than trusting `minted`: another instance that raced this boot
156
+ // may have written first, and adopting whatever is stored is what makes the two
157
+ // converge on one key.
158
+ const settled = (await cache.get<StoredKey>(key)) ?? minted;
159
+ logger.info("stored a new signing key", {
160
+ createdAt: settled.createdAt,
161
+ ttlSeconds: KEY_TTL_SECONDS,
162
+ adopted: settled.createdAt === minted.createdAt ? "own" : "concurrent-instance",
163
+ });
164
+ return decode(settled);
165
+ }
166
+
167
+ /** A resolved key plus the epoch it is scoped to. */
168
+ export interface SigningKey {
169
+ key: Uint8Array;
170
+ /** Force-clear epoch in ms; `0` when unset. Tokens older than this are refused. */
171
+ epochMs: number;
172
+ }
173
+
174
+ let pending: Promise<SigningKey> | undefined;
175
+
176
+ /**
177
+ * The signing key for this gate, resolved once per process.
178
+ *
179
+ * Resolved ONCE, so `configuredEpoch` is honoured only on the first call - the
180
+ * plugin's `setup()` passes its resolved value there, before any request can
181
+ * reach the lazy path.
182
+ *
183
+ * `TUNNEL_AUTH_JWT_SECRET` when set, else the cache-backed key. A cache that is
184
+ * unavailable degrades to an ephemeral per-process key (the previous behaviour)
185
+ * rather than refusing to sign: the key only validates an ALREADY-issued session,
186
+ * so losing it costs sessions, never admission - a caller still needs a code
187
+ * delivered to an allow-listed address.
188
+ */
189
+ export function signingKey(configuredEpoch?: string | number | Date): Promise<SigningKey> {
190
+ pending ??= (async () => {
191
+ const epochMs = resolveSessionEpoch(configuredEpoch);
192
+ const configured = env.text(JWT_SECRET_ENV);
193
+ if (configured) {
194
+ return { key: new TextEncoder().encode(configured), epochMs };
195
+ }
196
+ try {
197
+ return { key: await loadFromCache(epochMs), epochMs };
198
+ } catch (error) {
199
+ logger.warn(
200
+ `no ${env.name(JWT_SECRET_ENV)} and the cache is unavailable - using an ephemeral per-process key; sessions will not survive a restart`,
201
+ { error },
202
+ );
203
+ return { key: new Uint8Array(randomBytes(KEY_BYTES)), epochMs };
204
+ }
205
+ })();
206
+ return pending;
207
+ }
208
+
209
+ /** Reset the per-process key (tests, or after changing the env in-process). */
210
+ export function resetSigningKey(): void {
211
+ pending = undefined;
212
+ }