@dbx-tools/cli-tunnel 0.6.51 → 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/README.md +33 -26
- package/index.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +2 -2
- package/lib/src/cli.js +3 -3
- package/lib/src/env.d.ts +4 -3
- package/lib/src/env.js +8 -4
- package/lib/src/otp.d.ts +2 -2
- package/lib/src/otp.js +9 -9
- package/lib/src/plugin.d.ts +7 -6
- package/lib/src/plugin.js +6 -6
- package/lib/src/signing-key.d.ts +15 -12
- package/lib/src/signing-key.js +37 -48
- package/package.json +6 -6
- package/src/cli.ts +4 -4
- package/src/env.ts +7 -3
- package/src/otp.ts +8 -8
- package/src/plugin.ts +12 -11
- package/src/signing-key.ts +38 -49
package/src/signing-key.ts
CHANGED
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
*
|
|
32
32
|
* ## Forcing every session to end
|
|
33
33
|
*
|
|
34
|
-
* {@link
|
|
35
|
-
* `--session-
|
|
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
36
|
* makes every prior key unreachable, so every cookie signed against it stops
|
|
37
37
|
* verifying - the log-everyone-out switch, without having to find and flush a
|
|
38
|
-
* cache entry. The
|
|
38
|
+
* cache entry. The cutoff is also asserted against each token's `iat`, so a
|
|
39
39
|
* cookie that predates it is refused even if it was signed with the key that is
|
|
40
40
|
* somehow still current.
|
|
41
41
|
*
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
|
|
45
45
|
import { randomBytes } from "node:crypto";
|
|
46
46
|
import { CacheManager } from "@databricks/appkit";
|
|
47
|
-
import { env, log } from "@dbx-tools/shared-core";
|
|
48
|
-
import { JWT_SECRET_ENV,
|
|
47
|
+
import { env, log, object } from "@dbx-tools/shared-core";
|
|
48
|
+
import { JWT_SECRET_ENV, SESSION_CUTOFF_ENV } from "./env.ts";
|
|
49
49
|
|
|
50
50
|
const logger = log.logger("tunnel:signing-key");
|
|
51
51
|
|
|
@@ -73,59 +73,48 @@ interface StoredKey {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Resolve the force-clear
|
|
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.
|
|
77
82
|
*
|
|
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
83
|
* An UNPARSEABLE value is ignored with a warning rather than throwing: this is
|
|
81
84
|
* the switch that logs a fleet back in, and failing to boot over a typo is worse
|
|
82
85
|
* than not rotating.
|
|
83
86
|
*/
|
|
84
|
-
export function
|
|
85
|
-
const raw = configured ?? env.text(
|
|
87
|
+
export function resolveSessionCutoff(configured?: string | number | Date): number {
|
|
88
|
+
const raw = configured ?? env.text(SESSION_CUTOFF_ENV) ?? undefined;
|
|
86
89
|
if (raw === undefined || raw === null || raw === "") return 0;
|
|
87
|
-
return clampToPast(parseEpoch(raw));
|
|
88
|
-
}
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 });
|
|
91
|
+
const date = object.toDate(raw);
|
|
92
|
+
if (!date) {
|
|
93
|
+
logger.warn(`ignoring unparseable ${env.name(SESSION_CUTOFF_ENV)}`, { value: String(raw) });
|
|
105
94
|
return 0;
|
|
106
95
|
}
|
|
107
|
-
return
|
|
96
|
+
return clampToPast(date.getTime());
|
|
108
97
|
}
|
|
109
98
|
|
|
110
99
|
/**
|
|
111
|
-
* Hold the cutoff at "now", because a FUTURE
|
|
100
|
+
* Hold the cutoff at "now", because a FUTURE cutoff would refuse the sessions it
|
|
112
101
|
* is about to mint as well as the old ones - an app nobody can sign in to, from a
|
|
113
102
|
* mistyped year, with the fix hidden behind understanding this flag. Clamped, a
|
|
114
103
|
* future date means what an operator setting it always meant: clear everything
|
|
115
104
|
* outstanding, then carry on.
|
|
116
105
|
*/
|
|
117
|
-
function clampToPast(
|
|
106
|
+
function clampToPast(cutoffMs: number): number {
|
|
118
107
|
const now = Date.now();
|
|
119
|
-
if (
|
|
120
|
-
logger.warn(`${env.name(
|
|
121
|
-
configured: new Date(
|
|
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(),
|
|
122
111
|
});
|
|
123
112
|
return now;
|
|
124
113
|
}
|
|
125
114
|
|
|
126
|
-
/** The cache key for one
|
|
127
|
-
function cacheKey(
|
|
128
|
-
return `${KEY_PREFIX}${
|
|
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}`;
|
|
129
118
|
}
|
|
130
119
|
|
|
131
120
|
function decode(stored: StoredKey): Uint8Array {
|
|
@@ -133,12 +122,12 @@ function decode(stored: StoredKey): Uint8Array {
|
|
|
133
122
|
}
|
|
134
123
|
|
|
135
124
|
/**
|
|
136
|
-
* Load the signing key for `
|
|
137
|
-
* absent. See the module docs for why the write is followed by a re-read.
|
|
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.
|
|
138
127
|
*/
|
|
139
|
-
async function loadFromCache(
|
|
128
|
+
async function loadFromCache(cutoffMs: number): Promise<Uint8Array> {
|
|
140
129
|
const cache = CacheManager.getInstanceSync();
|
|
141
|
-
const key = cacheKey(
|
|
130
|
+
const key = cacheKey(cutoffMs);
|
|
142
131
|
|
|
143
132
|
const existing = await cache.get<StoredKey>(key);
|
|
144
133
|
if (existing?.secret) {
|
|
@@ -164,11 +153,11 @@ async function loadFromCache(epochMs: number): Promise<Uint8Array> {
|
|
|
164
153
|
return decode(settled);
|
|
165
154
|
}
|
|
166
155
|
|
|
167
|
-
/** A resolved key plus the
|
|
156
|
+
/** A resolved key plus the cutoff it is scoped to. */
|
|
168
157
|
export interface SigningKey {
|
|
169
158
|
key: Uint8Array;
|
|
170
|
-
/** Force-clear
|
|
171
|
-
|
|
159
|
+
/** Force-clear cutoff in ms; `0` when unset. Tokens older than this are refused. */
|
|
160
|
+
cutoffMs: number;
|
|
172
161
|
}
|
|
173
162
|
|
|
174
163
|
let pending: Promise<SigningKey> | undefined;
|
|
@@ -176,7 +165,7 @@ let pending: Promise<SigningKey> | undefined;
|
|
|
176
165
|
/**
|
|
177
166
|
* The signing key for this gate, resolved once per process.
|
|
178
167
|
*
|
|
179
|
-
* Resolved ONCE, so `
|
|
168
|
+
* Resolved ONCE, so `configuredCutoff` is honoured only on the first call - the
|
|
180
169
|
* plugin's `setup()` passes its resolved value there, before any request can
|
|
181
170
|
* reach the lazy path.
|
|
182
171
|
*
|
|
@@ -186,21 +175,21 @@ let pending: Promise<SigningKey> | undefined;
|
|
|
186
175
|
* so losing it costs sessions, never admission - a caller still needs a code
|
|
187
176
|
* delivered to an allow-listed address.
|
|
188
177
|
*/
|
|
189
|
-
export function signingKey(
|
|
178
|
+
export function signingKey(configuredCutoff?: string | number | Date): Promise<SigningKey> {
|
|
190
179
|
pending ??= (async () => {
|
|
191
|
-
const
|
|
180
|
+
const cutoffMs = resolveSessionCutoff(configuredCutoff);
|
|
192
181
|
const configured = env.text(JWT_SECRET_ENV);
|
|
193
182
|
if (configured) {
|
|
194
|
-
return { key: new TextEncoder().encode(configured),
|
|
183
|
+
return { key: new TextEncoder().encode(configured), cutoffMs };
|
|
195
184
|
}
|
|
196
185
|
try {
|
|
197
|
-
return { key: await loadFromCache(
|
|
186
|
+
return { key: await loadFromCache(cutoffMs), cutoffMs };
|
|
198
187
|
} catch (error) {
|
|
199
188
|
logger.warn(
|
|
200
189
|
`no ${env.name(JWT_SECRET_ENV)} and the cache is unavailable - using an ephemeral per-process key; sessions will not survive a restart`,
|
|
201
190
|
{ error },
|
|
202
191
|
);
|
|
203
|
-
return { key: new Uint8Array(randomBytes(KEY_BYTES)),
|
|
192
|
+
return { key: new Uint8Array(randomBytes(KEY_BYTES)), cutoffMs };
|
|
204
193
|
}
|
|
205
194
|
})();
|
|
206
195
|
return pending;
|