@dbx-tools/cli-tunnel 0.6.50 → 0.6.52

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
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
20
- import { brand, env, log, string } from "@dbx-tools/shared-core";
20
+ import { brand, env, log, object, string } from "@dbx-tools/shared-core";
21
21
  import type { AuthStatus } from "@dbx-tools/shared-email";
22
22
  import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
23
23
  import {
@@ -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, resolveSessionCutoff, signingKey } from "./signing-key.ts";
33
34
 
34
35
  const logger = log.logger("tunnel:auth");
35
36
 
@@ -62,12 +63,27 @@ 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 cutoff: every session issued BEFORE it stops verifying, so moving
80
+ * it forward signs everyone out. Env TUNNEL_AUTH_SESSION_CUTOFF.
81
+ *
82
+ * Anything `object.toDate` accepts: a `Date`, `2026-08-02`, an ISO instant,
83
+ * epoch seconds/millis, or a relative duration (`-30d`, `7 days ago`). Unset
84
+ * means no cutoff.
85
+ */
86
+ sessionCutoff?: string | number | Date;
71
87
  /** Deliver a code to an address. Wired by the app to the email plugin. */
72
88
  sendCode?: (email: string, code: string, opts: SendCodeOptions) => Promise<void>;
73
89
  }
@@ -95,6 +111,8 @@ export interface ResolvedAuthGateConfig {
95
111
  sessionTtlSeconds: number;
96
112
  codeTtlSeconds: number;
97
113
  maxAttempts: number;
114
+ /** Force-clear cutoff in epoch ms; `0` when unset. */
115
+ sessionCutoffMs: number;
98
116
  }
99
117
 
100
118
  const DEFAULTS = {
@@ -107,7 +125,8 @@ const DEFAULTS = {
107
125
  // is the fallback when nothing is configured.
108
126
  brandName: brand.defaultBrandContext.name,
109
127
  message: "Your verification code is:",
110
- sessionTtlSeconds: 43200,
128
+ // 30 days, the same window the cache-backed signing key is stored for.
129
+ sessionTtlSeconds: KEY_TTL_SECONDS,
111
130
  codeTtlSeconds: 600,
112
131
  maxAttempts: 5,
113
132
  };
@@ -130,6 +149,7 @@ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateC
130
149
  ),
131
150
  codeTtlSeconds: env.positiveInt(config.codeTtlSeconds, CODE_TTL_ENV, DEFAULTS.codeTtlSeconds),
132
151
  maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
152
+ sessionCutoffMs: resolveSessionCutoff(config.sessionCutoff),
133
153
  };
134
154
  }
135
155
 
@@ -171,9 +191,14 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
171
191
  override async setup(): Promise<void> {
172
192
  this.resolved = resolveAuthGateConfig(this.config);
173
193
  this.codes = new CodeStore(this.resolved.codeTtlSeconds, this.resolved.maxAttempts);
194
+ // Resolve the signing key HERE rather than lazily on the first sign-in, so a
195
+ // cache that cannot hold it (and the resulting "sessions won't survive a
196
+ // restart" warning) shows up in the startup log, not hours later.
197
+ const { cutoffMs } = await signingKey(this.resolved.sessionCutoffMs);
174
198
  logger.info("ready", {
175
199
  patterns: this.resolved.allow.length,
176
200
  sessionTtlSeconds: this.resolved.sessionTtlSeconds,
201
+ ...object.optional("sessionCutoff", cutoffMs > 0 ? new Date(cutoffMs).toISOString() : null),
177
202
  });
178
203
  }
179
204
 
@@ -0,0 +1,201 @@
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 resolveSessionCutoff} reads a date from `TUNNEL_AUTH_SESSION_CUTOFF` (or
35
+ * `--session-cutoff`), 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 cutoff 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, object } from "@dbx-tools/shared-core";
48
+ import { JWT_SECRET_ENV, SESSION_CUTOFF_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 cutoff as epoch MILLISECONDS, or `0` when unset.
77
+ *
78
+ * Accepts whatever `object.toDate` accepts - a `Date`, `2026-08-02`, an ISO
79
+ * instant, epoch seconds or millis from `date +%s`, or a relative duration
80
+ * (`-30d`, `7 days ago`), which is the spelling an operator reaching for this
81
+ * usually wants: sign out everyone who signed in more than a month ago.
82
+ *
83
+ * An UNPARSEABLE value is ignored with a warning rather than throwing: this is
84
+ * the switch that logs a fleet back in, and failing to boot over a typo is worse
85
+ * than not rotating.
86
+ */
87
+ export function resolveSessionCutoff(configured?: string | number | Date): number {
88
+ const raw = configured ?? env.text(SESSION_CUTOFF_ENV) ?? undefined;
89
+ if (raw === undefined || raw === null || raw === "") return 0;
90
+
91
+ const date = object.toDate(raw);
92
+ if (!date) {
93
+ logger.warn(`ignoring unparseable ${env.name(SESSION_CUTOFF_ENV)}`, { value: String(raw) });
94
+ return 0;
95
+ }
96
+ return clampToPast(date.getTime());
97
+ }
98
+
99
+ /**
100
+ * Hold the cutoff at "now", because a FUTURE cutoff would refuse the sessions it
101
+ * is about to mint as well as the old ones - an app nobody can sign in to, from a
102
+ * mistyped year, with the fix hidden behind understanding this flag. Clamped, a
103
+ * future date means what an operator setting it always meant: clear everything
104
+ * outstanding, then carry on.
105
+ */
106
+ function clampToPast(cutoffMs: number): number {
107
+ const now = Date.now();
108
+ if (cutoffMs <= now) return cutoffMs;
109
+ logger.warn(`${env.name(SESSION_CUTOFF_ENV)} is in the future - clamping to now`, {
110
+ configured: new Date(cutoffMs).toISOString(),
111
+ });
112
+ return now;
113
+ }
114
+
115
+ /** The cache key for one cutoff, so moving the cutoff orphans every earlier key. */
116
+ function cacheKey(cutoffMs: number): string {
117
+ return `${KEY_PREFIX}${cutoffMs}`;
118
+ }
119
+
120
+ function decode(stored: StoredKey): Uint8Array {
121
+ return new Uint8Array(Buffer.from(stored.secret, "base64url"));
122
+ }
123
+
124
+ /**
125
+ * Load the signing key for `cutoffMs` from the cache, minting and storing one
126
+ * when absent. See the module docs for why the write is followed by a re-read.
127
+ */
128
+ async function loadFromCache(cutoffMs: number): Promise<Uint8Array> {
129
+ const cache = CacheManager.getInstanceSync();
130
+ const key = cacheKey(cutoffMs);
131
+
132
+ const existing = await cache.get<StoredKey>(key);
133
+ if (existing?.secret) {
134
+ logger.info("reusing cached signing key", { createdAt: existing.createdAt });
135
+ return decode(existing);
136
+ }
137
+
138
+ const minted: StoredKey = {
139
+ secret: Buffer.from(randomBytes(KEY_BYTES)).toString("base64url"),
140
+ createdAt: new Date().toISOString(),
141
+ };
142
+ await cache.set(key, minted, { ttl: KEY_TTL_SECONDS });
143
+
144
+ // Re-read rather than trusting `minted`: another instance that raced this boot
145
+ // may have written first, and adopting whatever is stored is what makes the two
146
+ // converge on one key.
147
+ const settled = (await cache.get<StoredKey>(key)) ?? minted;
148
+ logger.info("stored a new signing key", {
149
+ createdAt: settled.createdAt,
150
+ ttlSeconds: KEY_TTL_SECONDS,
151
+ adopted: settled.createdAt === minted.createdAt ? "own" : "concurrent-instance",
152
+ });
153
+ return decode(settled);
154
+ }
155
+
156
+ /** A resolved key plus the cutoff it is scoped to. */
157
+ export interface SigningKey {
158
+ key: Uint8Array;
159
+ /** Force-clear cutoff in ms; `0` when unset. Tokens older than this are refused. */
160
+ cutoffMs: number;
161
+ }
162
+
163
+ let pending: Promise<SigningKey> | undefined;
164
+
165
+ /**
166
+ * The signing key for this gate, resolved once per process.
167
+ *
168
+ * Resolved ONCE, so `configuredCutoff` is honoured only on the first call - the
169
+ * plugin's `setup()` passes its resolved value there, before any request can
170
+ * reach the lazy path.
171
+ *
172
+ * `TUNNEL_AUTH_JWT_SECRET` when set, else the cache-backed key. A cache that is
173
+ * unavailable degrades to an ephemeral per-process key (the previous behaviour)
174
+ * rather than refusing to sign: the key only validates an ALREADY-issued session,
175
+ * so losing it costs sessions, never admission - a caller still needs a code
176
+ * delivered to an allow-listed address.
177
+ */
178
+ export function signingKey(configuredCutoff?: string | number | Date): Promise<SigningKey> {
179
+ pending ??= (async () => {
180
+ const cutoffMs = resolveSessionCutoff(configuredCutoff);
181
+ const configured = env.text(JWT_SECRET_ENV);
182
+ if (configured) {
183
+ return { key: new TextEncoder().encode(configured), cutoffMs };
184
+ }
185
+ try {
186
+ return { key: await loadFromCache(cutoffMs), cutoffMs };
187
+ } catch (error) {
188
+ logger.warn(
189
+ `no ${env.name(JWT_SECRET_ENV)} and the cache is unavailable - using an ephemeral per-process key; sessions will not survive a restart`,
190
+ { error },
191
+ );
192
+ return { key: new Uint8Array(randomBytes(KEY_BYTES)), cutoffMs };
193
+ }
194
+ })();
195
+ return pending;
196
+ }
197
+
198
+ /** Reset the per-process key (tests, or after changing the env in-process). */
199
+ export function resetSigningKey(): void {
200
+ pending = undefined;
201
+ }