@dbx-tools/tunnel 0.6.60 → 0.6.62
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 +69 -61
- package/index.ts +6 -3
- package/lib/index.d.ts +6 -3
- package/lib/index.js +5 -3
- package/lib/src/{app.d.ts → code-email.d.ts} +10 -61
- package/lib/src/code-email.js +108 -0
- package/lib/src/gate.d.ts +60 -0
- package/lib/src/gate.js +204 -0
- package/lib/src/interceptor.d.ts +4 -4
- package/lib/src/interceptor.js +7 -7
- package/lib/src/plugin.d.ts +58 -4
- package/lib/src/plugin.js +77 -6
- package/lib/src/send-code.d.ts +28 -0
- package/lib/src/send-code.js +68 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -7
- package/src/code-email.ts +118 -0
- package/src/gate.ts +249 -0
- package/src/interceptor.ts +6 -6
- package/src/plugin.ts +137 -7
- package/src/send-code.ts +112 -0
- package/lib/src/app.js +0 -258
- package/lib/src/proxy.d.ts +0 -49
- package/lib/src/proxy.js +0 -247
- package/src/app.ts +0 -292
- package/src/proxy.ts +0 -299
package/src/gate.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tunnel's in-app AUTH GATE - Express middleware + login routes the
|
|
3
|
+
* {@link AuthGatePlugin} registers on the app's OWN server via `this.context`.
|
|
4
|
+
*
|
|
5
|
+
* This replaces the old standalone reverse-proxy (`proxy.ts`): the app is now the
|
|
6
|
+
* process, so there is nothing to forward to. Instead the gate is middleware that
|
|
7
|
+
* either short-circuits (401, or answers the open login routes) or calls `next()`
|
|
8
|
+
* to let the app's real handlers run. It is the "stands in for AppKit auth" path:
|
|
9
|
+
* a portr caller proves an email via OTP, and on success the gate injects the
|
|
10
|
+
* identity headers AppKit reads, so a gated request runs like a front-door one.
|
|
11
|
+
*
|
|
12
|
+
* WHICH TRAFFIC IS GATED - the `Host` header, not the socket. portr's client
|
|
13
|
+
* forwards with Go's `httputil.NewSingleHostReverseProxy` and a `Director` that
|
|
14
|
+
* PRESERVES the original `Host`, so tunnel requests arrive with
|
|
15
|
+
* `Host: <subdomain>.<server>` (the public domain). A local process hitting the
|
|
16
|
+
* app directly sends `Host: 127.0.0.1:<port>` / `localhost`. So ONLY requests whose
|
|
17
|
+
* `Host` matches the configured public domain are gated; the platform front door
|
|
18
|
+
* and any other local client pass through untouched. There is no portr-injected
|
|
19
|
+
* identifying header and no TCP/source-IP signal to use instead (the client dials
|
|
20
|
+
* the target over plain loopback).
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { http, json, log, token } from "@dbx-tools/shared-core";
|
|
26
|
+
import { authRequestSchema, authVerifySchema, SESSION_COOKIE_NAME } from "@dbx-tools/shared-email";
|
|
27
|
+
import type { Request, RequestHandler, Response } from "express";
|
|
28
|
+
import { toHeaderPolicy } from "./headers.ts";
|
|
29
|
+
import type { AuthGateApi } from "./plugin.ts";
|
|
30
|
+
|
|
31
|
+
const logger = log.logger("tunnel:gate");
|
|
32
|
+
|
|
33
|
+
/** Route prefix the login flow lives under (open, answered in-process). */
|
|
34
|
+
export const AUTH_PREFIX = "/api/email/auth";
|
|
35
|
+
|
|
36
|
+
/** Options for {@link mountGate}. */
|
|
37
|
+
export interface GateOptions {
|
|
38
|
+
/** The in-process gate API (session/request/verify/status). */
|
|
39
|
+
gate: AuthGateApi;
|
|
40
|
+
/**
|
|
41
|
+
* The public `<subdomain>.<server>` that identifies portr traffic by `Host`.
|
|
42
|
+
* When absent, no request is ever classified as tunnel traffic and the gate is
|
|
43
|
+
* inert (everything passes through) - a tunnel with no public domain gates
|
|
44
|
+
* nothing.
|
|
45
|
+
*/
|
|
46
|
+
publicDomain?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Extra `x-` request headers tunnel traffic may forward (unioned with the
|
|
49
|
+
* built-in allow-list). Every other `x-` header is stripped from tunnel traffic.
|
|
50
|
+
*/
|
|
51
|
+
forwardHeaders?: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True when the request's `Host` is the tunnel's public domain - i.e. it came in
|
|
56
|
+
* over portr. Case-insensitive; the optional `:port` is ignored. When no public
|
|
57
|
+
* domain is configured, nothing is tunnel traffic.
|
|
58
|
+
*/
|
|
59
|
+
export function isTunnelHost(req: Request, publicDomain: string | undefined): boolean {
|
|
60
|
+
if (!publicDomain) return false;
|
|
61
|
+
const host = (req.headers.host ?? "").toLowerCase().split(":")[0];
|
|
62
|
+
return host === publicDomain.toLowerCase().split(":")[0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The session cookie for a verified email, as a Set-Cookie string. */
|
|
66
|
+
function sessionCookie(value: string, maxAgeSeconds: number): string {
|
|
67
|
+
return [
|
|
68
|
+
`${SESSION_COOKIE_NAME}=${value}`,
|
|
69
|
+
"Path=/",
|
|
70
|
+
"HttpOnly",
|
|
71
|
+
"SameSite=Lax",
|
|
72
|
+
`Max-Age=${maxAgeSeconds}`,
|
|
73
|
+
// Real enforcement in production: the session cookie must be Secure so it is
|
|
74
|
+
// never sent over plaintext. Local dev (http) omits it so the cookie works.
|
|
75
|
+
process.env.NODE_ENV === "production" ? "Secure" : "",
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join("; ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Client IP for rate-limiting: the RIGHTMOST `x-forwarded-for` entry (the value
|
|
83
|
+
* the nearest trusted hop - portr - wrote), else the socket address. Reading the
|
|
84
|
+
* rightmost, not the leftmost, stops a caller minting a fresh rate-limit bucket by
|
|
85
|
+
* varying the header. Called before {@link stripHeaders}.
|
|
86
|
+
*/
|
|
87
|
+
function clientIp(req: Request): string {
|
|
88
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
89
|
+
const chain = (Array.isArray(forwarded) ? forwarded.join(",") : (forwarded ?? ""))
|
|
90
|
+
.split(",")
|
|
91
|
+
.map((entry) => entry.trim())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
return chain.at(-1) ?? req.socket.remoteAddress ?? "unknown";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Remove the gate's session cookie from the `Cookie` header before the app's
|
|
98
|
+
* handlers run, so the app never sees `dbx_auth` (it is the gate's concern).
|
|
99
|
+
* Preserves other cookies; deletes the header when it becomes empty.
|
|
100
|
+
*/
|
|
101
|
+
function stripSessionCookie(req: Request): void {
|
|
102
|
+
const raw = req.headers.cookie;
|
|
103
|
+
if (!raw) return;
|
|
104
|
+
const kept = raw
|
|
105
|
+
.split(";")
|
|
106
|
+
.map((c) => c.trim())
|
|
107
|
+
.filter((c) => c && !c.startsWith(`${SESSION_COOKIE_NAME}=`));
|
|
108
|
+
if (kept.length) req.headers.cookie = kept.join("; ");
|
|
109
|
+
else delete req.headers.cookie;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Present an OTP-authenticated caller to the app the SAME way a platform front
|
|
114
|
+
* door does: set the front-door identity headers to the verified address (AppKit
|
|
115
|
+
* reads {@link token.USER_ID_HEADER} for the OBO user id), so the app needs no
|
|
116
|
+
* gate-specific code path.
|
|
117
|
+
*
|
|
118
|
+
* What the gate CANNOT set is {@link token.ACCESS_TOKEN_HEADER}: an OTP session
|
|
119
|
+
* proves an email, not possession of a Databricks credential. Its absence is what
|
|
120
|
+
* `@dbx-tools/appkit`'s `identity: "auto"` detects, so a gated request runs as the
|
|
121
|
+
* app service principal instead of throwing.
|
|
122
|
+
*/
|
|
123
|
+
function injectIdentity(req: Request, email: string): void {
|
|
124
|
+
req.headers[token.USER_ID_HEADER] = email;
|
|
125
|
+
req.headers[token.USER_EMAIL_HEADER] = email;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sendJson(res: Response, status: number, body: unknown, setCookie?: string): void {
|
|
129
|
+
if (setCookie) res.setHeader("set-cookie", setCookie);
|
|
130
|
+
res.status(status).json(body);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read the raw request body as text (AppKit parses JSON, but the gate routes
|
|
134
|
+
* are mounted before that runs for tunnel traffic, so read defensively). */
|
|
135
|
+
function readBody(req: Request): Promise<string> {
|
|
136
|
+
// AppKit's json body-parser may have already populated req.body; prefer it.
|
|
137
|
+
if (req.body !== undefined && req.body !== null) {
|
|
138
|
+
return Promise.resolve(typeof req.body === "string" ? req.body : JSON.stringify(req.body));
|
|
139
|
+
}
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
let data = "";
|
|
142
|
+
req.on("data", (chunk) => (data += chunk));
|
|
143
|
+
req.on("end", () => resolve(data));
|
|
144
|
+
req.on("error", () => resolve(data));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Register the login routes and the gating middleware on the app's Express
|
|
150
|
+
* instance. Called from {@link AuthGatePlugin} with the router AppKit hands
|
|
151
|
+
* `injectRoutes`, plus `this.context.addMiddleware` for the global gate.
|
|
152
|
+
*
|
|
153
|
+
* `addRoute`/`addMiddleware` are used (via the passed callbacks) because the login
|
|
154
|
+
* routes live at an ABSOLUTE path (`/api/email/auth/*`, the client's contract),
|
|
155
|
+
* not under the plugin's `/api/authGate` base.
|
|
156
|
+
*/
|
|
157
|
+
export function mountGate(
|
|
158
|
+
opts: GateOptions,
|
|
159
|
+
addRoute: (method: "get" | "post", path: string, handler: RequestHandler) => void,
|
|
160
|
+
addMiddleware: (path: string, handler: RequestHandler) => void,
|
|
161
|
+
): void {
|
|
162
|
+
const { gate, publicDomain, forwardHeaders } = opts;
|
|
163
|
+
const headerPolicy = toHeaderPolicy(forwardHeaders);
|
|
164
|
+
logger.debug("gate mounted", { publicDomain, forward: headerPolicy.patterns });
|
|
165
|
+
|
|
166
|
+
const applyHeaderPolicy = (req: Request): void => {
|
|
167
|
+
const removed = headerPolicy.apply(req.headers as Record<string, unknown>);
|
|
168
|
+
if (removed.length) logger.debug("stripped inbound headers", { removed });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// --- Login routes (open on tunnel traffic; answered in-process) ---
|
|
172
|
+
|
|
173
|
+
const statusHandler = (async (req, res) => {
|
|
174
|
+
// A local or front-door caller is never gated, so the honest answer is that
|
|
175
|
+
// the gate does not apply - not the session state of a cookie it would never
|
|
176
|
+
// check. Without this, a browser on `localhost` renders the OTP login screen
|
|
177
|
+
// for a request that would have passed through untouched.
|
|
178
|
+
if (!isTunnelHost(req, publicDomain)) {
|
|
179
|
+
sendJson(res, 200, { authenticated: false, enabled: false });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const cookie = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
|
|
183
|
+
sendJson(res, 200, await gate.status(cookie));
|
|
184
|
+
}) as RequestHandler;
|
|
185
|
+
|
|
186
|
+
const requestHandler = (async (req, res) => {
|
|
187
|
+
const parsed = authRequestSchema.safeParse(json.parseRecord(await readBody(req)));
|
|
188
|
+
if (!parsed.success) return sendJson(res, 200, { ok: true }); // anti-enumeration
|
|
189
|
+
sendJson(res, 200, await gate.request(parsed.data.email, clientIp(req)));
|
|
190
|
+
}) as RequestHandler;
|
|
191
|
+
|
|
192
|
+
const verifyHandler = (async (req, res) => {
|
|
193
|
+
const parsed = authVerifySchema.safeParse(json.parseRecord(await readBody(req)));
|
|
194
|
+
if (!parsed.success) return sendJson(res, 200, { ok: false });
|
|
195
|
+
const result = await gate.verify(parsed.data.email, parsed.data.code, clientIp(req));
|
|
196
|
+
const cookie =
|
|
197
|
+
result.ok && result.token ? sessionCookie(result.token, gate.sessionTtlSeconds) : undefined;
|
|
198
|
+
sendJson(
|
|
199
|
+
res,
|
|
200
|
+
200,
|
|
201
|
+
{ ok: result.ok, ...(result.retryAfter ? { retryAfter: result.retryAfter } : {}) },
|
|
202
|
+
cookie,
|
|
203
|
+
);
|
|
204
|
+
}) as RequestHandler;
|
|
205
|
+
|
|
206
|
+
const logoutHandler = ((_req, res) => {
|
|
207
|
+
sendJson(res, 200, { ok: true }, `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`);
|
|
208
|
+
}) as RequestHandler;
|
|
209
|
+
|
|
210
|
+
addRoute("get", `${AUTH_PREFIX}/status`, statusHandler);
|
|
211
|
+
addRoute("post", `${AUTH_PREFIX}/request`, requestHandler);
|
|
212
|
+
addRoute("post", `${AUTH_PREFIX}/verify`, verifyHandler);
|
|
213
|
+
addRoute("post", `${AUTH_PREFIX}/logout`, logoutHandler);
|
|
214
|
+
|
|
215
|
+
// --- The gate middleware (runs before static + the app's /api handlers) ---
|
|
216
|
+
|
|
217
|
+
const gateMiddleware = (async (req, res, next) => {
|
|
218
|
+
// Not tunnel traffic (platform front door, or any other local caller): the
|
|
219
|
+
// request is already authenticated (or is not ours to gate). Pass through.
|
|
220
|
+
if (!isTunnelHost(req, publicDomain)) return next();
|
|
221
|
+
|
|
222
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
223
|
+
|
|
224
|
+
// The login flow is open so the browser can render <AuthGate> and sign in.
|
|
225
|
+
if (path.startsWith(AUTH_PREFIX)) return next();
|
|
226
|
+
|
|
227
|
+
// Anti-spoof: only the gate may assert identity on tunnel traffic.
|
|
228
|
+
applyHeaderPolicy(req);
|
|
229
|
+
|
|
230
|
+
// Static (non-API) loads freely so the SPA can render; drop the gate cookie.
|
|
231
|
+
if (!path.startsWith("/api/")) {
|
|
232
|
+
stripSessionCookie(req);
|
|
233
|
+
return next();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Every other /api/* needs a valid OTP session.
|
|
237
|
+
const cookie = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
|
|
238
|
+
const email = await gate.session(cookie);
|
|
239
|
+
if (!email) {
|
|
240
|
+
sendJson(res, 401, { error: "authentication required", loginPath: AUTH_PREFIX });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
injectIdentity(req, email);
|
|
244
|
+
stripSessionCookie(req);
|
|
245
|
+
next();
|
|
246
|
+
}) as RequestHandler;
|
|
247
|
+
|
|
248
|
+
addMiddleware("/", gateMiddleware);
|
|
249
|
+
}
|
package/src/interceptor.ts
CHANGED
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
* 4. registers an AppKit `shutdown` lifecycle handler so an orderly app shutdown
|
|
17
17
|
* also stops portr.
|
|
18
18
|
*
|
|
19
|
-
* The email-OTP GATE is a separate concern: it is the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* portr half - "update the host, bind portr" -
|
|
19
|
+
* The email-OTP GATE is a separate concern: it is the `authGate` AppKit plugin,
|
|
20
|
+
* which registers the login routes + a gating middleware on the app's own server.
|
|
21
|
+
* Register it in the app's `plugins` for gated traffic. This interceptor is only
|
|
22
|
+
* the portr half - "update the host, bind portr" - the smallest useful unit.
|
|
23
23
|
*
|
|
24
24
|
* @module
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { Interceptor, InterceptorContext } from "@dbx-tools/appkit";
|
|
28
|
-
import { log } from "@dbx-tools/shared-core";
|
|
28
|
+
import { log, object } from "@dbx-tools/shared-core";
|
|
29
29
|
import { installPortr, resolvePortrConfig, startPortr, writePortrConfig } from "./portr.ts";
|
|
30
30
|
|
|
31
31
|
const logger = log.logger("tunnel:interceptor");
|
|
@@ -46,7 +46,7 @@ export interface TunnelInterceptorOptions {
|
|
|
46
46
|
|
|
47
47
|
/** Resolve the public port portr should target: explicit, else the Apps contract. */
|
|
48
48
|
function resolvePublicPort(port?: number): number {
|
|
49
|
-
return port ??
|
|
49
|
+
return port ?? object.toNumber(process.env.DATABRICKS_APP_PORT) ?? 8000;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
package/src/plugin.ts
CHANGED
|
@@ -19,21 +19,68 @@
|
|
|
19
19
|
import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
|
|
20
20
|
import { brand, env, log, object, string } from "@dbx-tools/shared-core";
|
|
21
21
|
import type { AuthStatus } from "@dbx-tools/shared-email";
|
|
22
|
+
import type { RequestHandler } from "express";
|
|
22
23
|
import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
|
|
23
24
|
import {
|
|
24
25
|
ALLOW_ENV,
|
|
25
26
|
BRAND_NAME_ENV,
|
|
26
27
|
CODE_TTL_ENV,
|
|
28
|
+
FORWARD_HEADERS_ENV,
|
|
29
|
+
INSECURE_ENV,
|
|
27
30
|
MESSAGE_ENV,
|
|
31
|
+
PUBLIC_DOMAIN_ENV,
|
|
28
32
|
SESSION_TTL_ENV,
|
|
29
33
|
SUBJECT_ENV,
|
|
30
34
|
} from "./env.ts";
|
|
35
|
+
import { mountGate, type GateOptions } from "./gate.ts";
|
|
31
36
|
import { CodeStore, signSession, verifySession } from "./otp.ts";
|
|
32
37
|
import { RateLimiter } from "./rate-limit.ts";
|
|
38
|
+
import { ensureEmailAvailable, sendCode as defaultSendCode } from "./send-code.ts";
|
|
33
39
|
import { KEY_TTL_SECONDS, resolveSessionCutoff, signingKey } from "./signing-key.ts";
|
|
34
40
|
|
|
35
41
|
const logger = log.logger("tunnel:auth");
|
|
36
42
|
|
|
43
|
+
interface GateServerApplication {
|
|
44
|
+
get(path: string, handler: RequestHandler): unknown;
|
|
45
|
+
post(path: string, handler: RequestHandler): unknown;
|
|
46
|
+
use(path: string, handler: RequestHandler): unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface GateMountContext {
|
|
50
|
+
addRoute(method: string, path: string, handler: RequestHandler): void;
|
|
51
|
+
addMiddleware(path: string, handler: RequestHandler): void;
|
|
52
|
+
getPlugins(): ReadonlyMap<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function serverApplication(context: GateMountContext): GateServerApplication | undefined {
|
|
56
|
+
const server = context.getPlugins().get("server") as
|
|
57
|
+
{ serverApplication?: Partial<GateServerApplication> } | undefined;
|
|
58
|
+
const application = server?.serverApplication;
|
|
59
|
+
return application &&
|
|
60
|
+
typeof application.get === "function" &&
|
|
61
|
+
typeof application.post === "function" &&
|
|
62
|
+
typeof application.use === "function"
|
|
63
|
+
? (application as GateServerApplication)
|
|
64
|
+
: undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function mountGateOnContext(context: GateMountContext, options: GateOptions): void {
|
|
68
|
+
const application = serverApplication(context);
|
|
69
|
+
if (application) {
|
|
70
|
+
mountGate(
|
|
71
|
+
options,
|
|
72
|
+
(method, path, handler) => application[method](path, handler),
|
|
73
|
+
(path, handler) => application.use(path, handler),
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
mountGate(
|
|
78
|
+
options,
|
|
79
|
+
(method, path, handler) => context.addRoute(method, path, handler),
|
|
80
|
+
(path, handler) => context.addMiddleware(path, handler),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
37
84
|
/** Options for the {@link authGate} plugin (all resolvable from env - see below). */
|
|
38
85
|
export interface AuthGateConfig extends BasePluginConfig {
|
|
39
86
|
/** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env TUNNEL_AUTH_ALLOW. */
|
|
@@ -50,7 +97,7 @@ export interface AuthGateConfig extends BasePluginConfig {
|
|
|
50
97
|
* This is the subject TEMPLATE, not the literal line sent: the code is spliced
|
|
51
98
|
* into it (`"123456 is your verification code"`) because a push notification
|
|
52
99
|
* shows only the subject and preheader, and that notification is what mobile
|
|
53
|
-
* autofill reads. See `codeEmailSubject` in `./
|
|
100
|
+
* autofill reads. See `codeEmailSubject` in `./code-email.ts`.
|
|
54
101
|
*/
|
|
55
102
|
subject?: string;
|
|
56
103
|
/**
|
|
@@ -89,8 +136,31 @@ export interface AuthGateConfig extends BasePluginConfig {
|
|
|
89
136
|
* means no cutoff.
|
|
90
137
|
*/
|
|
91
138
|
sessionCutoff?: string | number | Date;
|
|
92
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Deliver a code to an address. Defaults to sending through the host app's
|
|
141
|
+
* shared `@dbx-tools/email` transport (see `./send-code`); override to wire a
|
|
142
|
+
* different delivery path.
|
|
143
|
+
*/
|
|
93
144
|
sendCode?: (email: string, code: string, opts: SendCodeOptions) => Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* The public `<subdomain>.<server>` that identifies portr traffic by its `Host`
|
|
147
|
+
* header. Only requests whose `Host` matches this are gated; everything else
|
|
148
|
+
* (the platform front door, other local callers) passes through. Env
|
|
149
|
+
* `TUNNEL_PUBLIC_DOMAIN`. When absent, the gate is inert (nothing is tunnel
|
|
150
|
+
* traffic).
|
|
151
|
+
*/
|
|
152
|
+
publicDomain?: string;
|
|
153
|
+
/**
|
|
154
|
+
* Extra `x-` request headers tunnel traffic may forward (literal / glob /
|
|
155
|
+
* `/regex/`), unioned with the built-in allow-list. Env `TUNNEL_FORWARD_HEADERS`.
|
|
156
|
+
*/
|
|
157
|
+
forwardHeaders?: string | string[];
|
|
158
|
+
/**
|
|
159
|
+
* Run OPEN with no gate (env `TUNNEL_INSECURE=true`). The login routes and gate
|
|
160
|
+
* middleware are not mounted, and the SMTP fail-fast is skipped. Use only when
|
|
161
|
+
* the tunnel is deliberately public.
|
|
162
|
+
*/
|
|
163
|
+
insecure?: boolean;
|
|
94
164
|
}
|
|
95
165
|
|
|
96
166
|
/** Branding/messaging passed to {@link AuthGateConfig.sendCode}. */
|
|
@@ -118,6 +188,12 @@ export interface ResolvedAuthGateConfig {
|
|
|
118
188
|
maxAttempts: number;
|
|
119
189
|
/** Force-clear cutoff in epoch ms; `0` when unset. */
|
|
120
190
|
sessionCutoffMs: number;
|
|
191
|
+
/** Public domain that identifies portr traffic by `Host`; `undefined` = inert. */
|
|
192
|
+
publicDomain?: string;
|
|
193
|
+
/** Extra `x-` headers tunnel traffic may forward (unioned with the defaults). */
|
|
194
|
+
forwardHeaders: string[];
|
|
195
|
+
/** Run OPEN with no gate. */
|
|
196
|
+
insecure: boolean;
|
|
121
197
|
}
|
|
122
198
|
|
|
123
199
|
const DEFAULTS = {
|
|
@@ -125,8 +201,7 @@ const DEFAULTS = {
|
|
|
125
201
|
// The repo-wide brand context's display name, NOT a hardcoded product string:
|
|
126
202
|
// this name is what the recipient reads in the code email, so it has to be the
|
|
127
203
|
// same identity the rest of the app presents. A host with its own
|
|
128
|
-
// `branding/brand.yaml` overrides it by passing `brandName
|
|
129
|
-
// {@link startGateApp}, which resolves the on-disk context); the shared default
|
|
204
|
+
// `branding/brand.yaml` overrides it by passing `brandName`; the shared default
|
|
130
205
|
// is the fallback when nothing is configured.
|
|
131
206
|
brandName: brand.defaultBrandContext.name,
|
|
132
207
|
message: "Your verification code is:",
|
|
@@ -155,10 +230,16 @@ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateC
|
|
|
155
230
|
codeTtlSeconds: env.positiveInt(config.codeTtlSeconds, CODE_TTL_ENV, DEFAULTS.codeTtlSeconds),
|
|
156
231
|
maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
|
|
157
232
|
sessionCutoffMs: resolveSessionCutoff(config.sessionCutoff),
|
|
233
|
+
publicDomain: env.string(config.publicDomain, PUBLIC_DOMAIN_ENV) ?? undefined,
|
|
234
|
+
forwardHeaders: [
|
|
235
|
+
...string.parseList(config.forwardHeaders),
|
|
236
|
+
...string.parseList(env.text(FORWARD_HEADERS_ENV)),
|
|
237
|
+
],
|
|
238
|
+
insecure: env.boolean(config.insecure, INSECURE_ENV) ?? false,
|
|
158
239
|
};
|
|
159
240
|
}
|
|
160
241
|
|
|
161
|
-
/** The handlers the
|
|
242
|
+
/** The handlers the gate middleware calls in-process (returned by {@link AuthGatePlugin.exports}). */
|
|
162
243
|
export interface AuthGateApi {
|
|
163
244
|
/** Handle a code request. Always resolves `{ ok: true }` (anti-enumeration). */
|
|
164
245
|
request(email: string, ip: string): Promise<{ ok: true; retryAfter?: number }>;
|
|
@@ -176,7 +257,13 @@ export interface AuthGateApi {
|
|
|
176
257
|
status(token: string | undefined): Promise<AuthStatus>;
|
|
177
258
|
}
|
|
178
259
|
|
|
179
|
-
/**
|
|
260
|
+
/**
|
|
261
|
+
* AppKit plugin owning the email-OTP gate. On `setup()` it registers the login
|
|
262
|
+
* routes (`/api/email/auth/*`) and a gating middleware on the app's OWN Express
|
|
263
|
+
* server via `this.context`, so a public portr caller must prove an email before
|
|
264
|
+
* reaching the app's `/api/*` - see `./gate`. Front-door (platform) traffic and
|
|
265
|
+
* other local callers pass through untouched (the gate keys on the `Host` header).
|
|
266
|
+
*/
|
|
180
267
|
export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
181
268
|
static manifest = {
|
|
182
269
|
name: "authGate",
|
|
@@ -200,13 +287,56 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
200
287
|
// cache that cannot hold it (and the resulting "sessions won't survive a
|
|
201
288
|
// restart" warning) shows up in the startup log, not hours later.
|
|
202
289
|
const { cutoffMs } = await signingKey(this.resolved.sessionCutoffMs);
|
|
290
|
+
|
|
291
|
+
if (this.resolved.insecure) {
|
|
292
|
+
logger.warn("insecure mode - the tunnel runs OPEN with no email-OTP gate");
|
|
293
|
+
} else {
|
|
294
|
+
// `server()` is deferred, so it does not exist during this plugin's setup.
|
|
295
|
+
// At `setup:complete` the Express app exists but has not injected plugin
|
|
296
|
+
// routes or static handling yet, which is the one point the gate can mount
|
|
297
|
+
// ahead of every protected route.
|
|
298
|
+
this.context?.onLifecycle("setup:complete", async () => {
|
|
299
|
+
this.mountGateRoutes();
|
|
300
|
+
// Fail fast once the sibling email plugin has primed its transport: a
|
|
301
|
+
// gate that cannot email a code lets nobody in.
|
|
302
|
+
await ensureEmailAvailable();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
203
306
|
logger.info("ready", {
|
|
204
307
|
patterns: this.resolved.allow.length,
|
|
205
308
|
sessionTtlSeconds: this.resolved.sessionTtlSeconds,
|
|
309
|
+
publicDomain: this.resolved.publicDomain ?? null,
|
|
310
|
+
insecure: this.resolved.insecure,
|
|
206
311
|
...object.optional("sessionCutoff", cutoffMs > 0 ? new Date(cutoffMs).toISOString() : null),
|
|
207
312
|
});
|
|
208
313
|
}
|
|
209
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Register the login routes (`/api/email/auth/*`) and the gating middleware on
|
|
317
|
+
* the app's OWN Express server. At `setup:complete`, the deferred server plugin
|
|
318
|
+
* has constructed its Express app but has not injected plugin routes or static
|
|
319
|
+
* handling, so direct registration puts the gate first. The context's buffered
|
|
320
|
+
* route API remains a fallback for compatible non-standard server plugins.
|
|
321
|
+
*/
|
|
322
|
+
private mountGateRoutes(): void {
|
|
323
|
+
const context = this.context;
|
|
324
|
+
if (!context) {
|
|
325
|
+
logger.warn("no plugin context - the OTP gate cannot mount its routes");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
mountGateOnContext(context, {
|
|
329
|
+
gate: this.exports(),
|
|
330
|
+
publicDomain: this.resolved.publicDomain,
|
|
331
|
+
forwardHeaders: this.resolved.forwardHeaders,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The code sender: the configured `sendCode`, else the shared-transport default. */
|
|
336
|
+
private get sendCode(): NonNullable<AuthGateConfig["sendCode"]> {
|
|
337
|
+
return this.config.sendCode ?? defaultSendCode;
|
|
338
|
+
}
|
|
339
|
+
|
|
210
340
|
override exports(): AuthGateApi {
|
|
211
341
|
return {
|
|
212
342
|
sessionTtlSeconds: this.resolved.sessionTtlSeconds,
|
|
@@ -234,7 +364,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
234
364
|
if (looksLikeEmail(address) && matchesAllowlist(address, this.resolved.allow)) {
|
|
235
365
|
const code = await this.codes.issue(address);
|
|
236
366
|
try {
|
|
237
|
-
await this.
|
|
367
|
+
await this.sendCode(address, code, {
|
|
238
368
|
subject: this.resolved.subject,
|
|
239
369
|
brandName: this.resolved.brandName,
|
|
240
370
|
message: this.resolved.message,
|
package/src/send-code.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default `sendCode` for the {@link AuthGatePlugin}: deliver the OTP through
|
|
3
|
+
* the host app's ALREADY-PRIMED `@dbx-tools/email` transport, as the system
|
|
4
|
+
* sender (a verification code is machine-generated and unanswerable, so it must
|
|
5
|
+
* not arrive from a person's address inviting a reply).
|
|
6
|
+
*
|
|
7
|
+
* `@dbx-tools/email` is an OPTIONAL dependency of the tunnel - a tunnel used
|
|
8
|
+
* without the gate needs no mail. So it is imported LAZILY here; a missing module
|
|
9
|
+
* surfaces only when a gate actually tries to send a code, and
|
|
10
|
+
* {@link ensureEmailAvailable} turns that into a clear fail-fast at boot.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { log } from "@dbx-tools/shared-core";
|
|
16
|
+
import {
|
|
17
|
+
codeEmailHtmlBody,
|
|
18
|
+
codeEmailPreview,
|
|
19
|
+
codeEmailSubject,
|
|
20
|
+
codeEmailTextBody,
|
|
21
|
+
} from "./code-email.ts";
|
|
22
|
+
import type { AuthGateConfig, SendCodeOptions } from "./plugin.ts";
|
|
23
|
+
|
|
24
|
+
const logger = log.logger("tunnel:send-code");
|
|
25
|
+
|
|
26
|
+
/** The slice of `@dbx-tools/email` the gate uses, resolved lazily. */
|
|
27
|
+
interface EmailModule {
|
|
28
|
+
sender: { resolveSystemSenderAddress: (config: unknown) => string };
|
|
29
|
+
transport: {
|
|
30
|
+
getEmailRuntime: () => { config: { mode: string } };
|
|
31
|
+
sendEmail: (
|
|
32
|
+
message: { to: string[]; subject: string; body: string },
|
|
33
|
+
from: string,
|
|
34
|
+
onBehalfOf: undefined,
|
|
35
|
+
extra: { text: string; preview: string },
|
|
36
|
+
) => Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let cached: EmailModule | undefined;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Import `@dbx-tools/email` lazily. Throws a clear, actionable error when the
|
|
44
|
+
* optional dependency is absent - the caller (a gate that is NOT insecure) treats
|
|
45
|
+
* that as fatal, since a gate that cannot email a code lets nobody in.
|
|
46
|
+
*/
|
|
47
|
+
async function loadEmail(): Promise<EmailModule> {
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
try {
|
|
50
|
+
const mod = (await import("@dbx-tools/email")) as unknown as EmailModule;
|
|
51
|
+
cached = mod;
|
|
52
|
+
return mod;
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"the OTP gate needs @dbx-tools/email to send codes, but it is not installed. " +
|
|
56
|
+
"Add @dbx-tools/email to the app, or run the tunnel with --insecure / TUNNEL_INSECURE=true.",
|
|
57
|
+
{ cause },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fail fast when the gate cannot deliver codes: the optional email module must be
|
|
64
|
+
* importable AND configured for SMTP (real delivery). `mode: "file"` (outbox) or a
|
|
65
|
+
* throwing runtime means no code reaches a real inbox, so a gate on that config
|
|
66
|
+
* would silently admit nobody. Called from the plugin's `setup:complete` unless
|
|
67
|
+
* insecure.
|
|
68
|
+
*/
|
|
69
|
+
export async function ensureEmailAvailable(): Promise<void> {
|
|
70
|
+
const { transport } = await loadEmail();
|
|
71
|
+
if (transport.getEmailRuntime().config.mode !== "smtp") {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"email is not configured for SMTP delivery - the OTP gate cannot send codes. " +
|
|
74
|
+
"Set SMTP_HOST/SMTP_USER/SMTP_PASSWORD, or pass --insecure / TUNNEL_INSECURE=true.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The default code sender: render the OTP email (code in the subject + preheader
|
|
81
|
+
* for mobile autofill; see `codeEmailSubject`) and send it through the shared
|
|
82
|
+
* transport as the system sender.
|
|
83
|
+
*/
|
|
84
|
+
export const sendCode: NonNullable<AuthGateConfig["sendCode"]> = async (
|
|
85
|
+
to: string,
|
|
86
|
+
code: string,
|
|
87
|
+
opts: SendCodeOptions,
|
|
88
|
+
): Promise<void> => {
|
|
89
|
+
const { sender, transport } = await loadEmail();
|
|
90
|
+
const from = sender.resolveSystemSenderAddress(transport.getEmailRuntime().config);
|
|
91
|
+
await sendEmailWith(transport, to, code, opts, from);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function sendEmailWith(
|
|
95
|
+
transport: EmailModule["transport"],
|
|
96
|
+
to: string,
|
|
97
|
+
code: string,
|
|
98
|
+
opts: SendCodeOptions,
|
|
99
|
+
from: string,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
logger.debug("sending code email", { to });
|
|
102
|
+
return transport.sendEmail(
|
|
103
|
+
{
|
|
104
|
+
to: [to],
|
|
105
|
+
subject: codeEmailSubject(code, opts.subject),
|
|
106
|
+
body: codeEmailHtmlBody(code, opts),
|
|
107
|
+
},
|
|
108
|
+
from,
|
|
109
|
+
undefined,
|
|
110
|
+
{ text: codeEmailTextBody(code, opts), preview: codeEmailPreview(code, opts) },
|
|
111
|
+
);
|
|
112
|
+
}
|