@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/src/gate.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* nothing to forward to. It either short-circuits (401, or answers the open login
|
|
7
7
|
* routes) or calls `next()` to let the app's real handlers run. It is the
|
|
8
8
|
* "stands in for AppKit auth" path:
|
|
9
|
-
* a portr caller
|
|
10
|
-
*
|
|
9
|
+
* a portr caller authenticates with Better Auth OTP or a passkey, and on
|
|
10
|
+
* success the gate injects the identity headers AppKit reads.
|
|
11
11
|
*
|
|
12
12
|
* WHICH TRAFFIC IS GATED - the `Host` header, not the socket. portr's client
|
|
13
13
|
* forwards with Go's `httputil.NewSingleHostReverseProxy` and a `Director` that
|
|
@@ -23,15 +23,18 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import type { IncomingMessage } from "node:http";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import type { Request, RequestHandler, Response } from "express";
|
|
26
|
+
import { log, token } from "@dbx-tools/shared-core";
|
|
27
|
+
import type { RequestHandler, Response } from "express";
|
|
29
28
|
import { toHeaderPolicy, type HeaderPolicy } from "./headers.ts";
|
|
30
29
|
import type { AuthGateApi } from "./plugin.ts";
|
|
31
30
|
|
|
32
31
|
const logger = log.logger("tunnel:gate");
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
type WebRequestInput = IncomingMessage & {
|
|
34
|
+
body?: unknown;
|
|
35
|
+
originalUrl?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
35
38
|
export const AUTH_PREFIX = "/api/email/auth";
|
|
36
39
|
|
|
37
40
|
/** Options for {@link mountGate}. */
|
|
@@ -63,22 +66,6 @@ export function isTunnelHost(req: IncomingMessage, publicDomain: string | undefi
|
|
|
63
66
|
return host === publicDomain.toLowerCase().split(":")[0];
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
/** The session cookie for a verified email, as a Set-Cookie string. */
|
|
67
|
-
function sessionCookie(value: string, maxAgeSeconds: number): string {
|
|
68
|
-
return [
|
|
69
|
-
`${SESSION_COOKIE_NAME}=${value}`,
|
|
70
|
-
"Path=/",
|
|
71
|
-
"HttpOnly",
|
|
72
|
-
"SameSite=Lax",
|
|
73
|
-
`Max-Age=${maxAgeSeconds}`,
|
|
74
|
-
// Real enforcement in production: the session cookie must be Secure so it is
|
|
75
|
-
// never sent over plaintext. Local dev (http) omits it so the cookie works.
|
|
76
|
-
process.env.NODE_ENV === "production" ? "Secure" : "",
|
|
77
|
-
]
|
|
78
|
-
.filter(Boolean)
|
|
79
|
-
.join("; ");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
69
|
/**
|
|
83
70
|
* Client IP for rate-limiting: the RIGHTMOST `x-forwarded-for` entry (the value
|
|
84
71
|
* the nearest trusted hop - portr - wrote), else the socket address. Reading the
|
|
@@ -105,18 +92,18 @@ function stripSessionCookie(req: IncomingMessage): void {
|
|
|
105
92
|
const kept = raw
|
|
106
93
|
.split(";")
|
|
107
94
|
.map((c) => c.trim())
|
|
108
|
-
.filter((c) => c && !c.startsWith(
|
|
95
|
+
.filter((c) => c && !c.startsWith("dbx-tools-auth=") && !c.startsWith("dbx-tools-passkey="));
|
|
109
96
|
if (kept.length) req.headers.cookie = kept.join("; ");
|
|
110
97
|
else delete req.headers.cookie;
|
|
111
98
|
}
|
|
112
99
|
|
|
113
100
|
/**
|
|
114
|
-
* Present
|
|
101
|
+
* Present a passwordless-authenticated caller to the app the SAME way a platform front
|
|
115
102
|
* door does: set the front-door identity headers to the verified address (AppKit
|
|
116
103
|
* reads {@link token.USER_ID_HEADER} for the OBO user id), so the app needs no
|
|
117
104
|
* gate-specific code path.
|
|
118
105
|
*
|
|
119
|
-
* What the gate CANNOT set is {@link token.ACCESS_TOKEN_HEADER}: an
|
|
106
|
+
* What the gate CANNOT set is {@link token.ACCESS_TOKEN_HEADER}: an auth session
|
|
120
107
|
* proves an email, not possession of a Databricks credential. Its absence is what
|
|
121
108
|
* `@dbx-tools/appkit`'s `identity: "auto"` detects, so a gated request runs as the
|
|
122
109
|
* app service principal instead of throwing.
|
|
@@ -126,14 +113,9 @@ function injectIdentity(req: IncomingMessage, email: string): void {
|
|
|
126
113
|
req.headers[token.USER_EMAIL_HEADER] = email;
|
|
127
114
|
}
|
|
128
115
|
|
|
129
|
-
function sendJson(res: Response, status: number, body: unknown, setCookie?: string): void {
|
|
130
|
-
if (setCookie) res.setHeader("set-cookie", setCookie);
|
|
131
|
-
res.status(status).json(body);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
116
|
/** Read the raw request body as text (AppKit parses JSON, but the gate routes
|
|
135
117
|
* are mounted before that runs for tunnel traffic, so read defensively). */
|
|
136
|
-
function readBody(req:
|
|
118
|
+
function readBody(req: WebRequestInput): Promise<string> {
|
|
137
119
|
// AppKit's json body-parser may have already populated req.body; prefer it.
|
|
138
120
|
if (req.body !== undefined && req.body !== null) {
|
|
139
121
|
return Promise.resolve(typeof req.body === "string" ? req.body : JSON.stringify(req.body));
|
|
@@ -146,6 +128,42 @@ function readBody(req: Request): Promise<string> {
|
|
|
146
128
|
});
|
|
147
129
|
}
|
|
148
130
|
|
|
131
|
+
export function webHeaders(req: IncomingMessage): Headers {
|
|
132
|
+
const headers = new Headers();
|
|
133
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
134
|
+
if (Array.isArray(value)) {
|
|
135
|
+
for (const item of value) headers.append(name, item);
|
|
136
|
+
} else if (value !== undefined) {
|
|
137
|
+
headers.set(name, value);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
headers.set("x-real-ip", clientIp(req));
|
|
141
|
+
return headers;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function webRequest(req: WebRequestInput): Promise<globalThis.Request> {
|
|
145
|
+
const host = req.headers.host ?? "localhost";
|
|
146
|
+
const hostname = host.split(":")[0]?.toLowerCase();
|
|
147
|
+
const protocol = hostname === "localhost" || hostname === "127.0.0.1" ? "http" : "https";
|
|
148
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
149
|
+
const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req);
|
|
150
|
+
return new globalThis.Request(`${protocol}://${host}${req.originalUrl || req.url}`, {
|
|
151
|
+
method,
|
|
152
|
+
headers: webHeaders(req),
|
|
153
|
+
...(body ? { body } : {}),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function sendWebResponse(res: Response, response: globalThis.Response): Promise<void> {
|
|
158
|
+
for (const [name, value] of response.headers.entries()) {
|
|
159
|
+
if (name.toLowerCase() !== "set-cookie") res.setHeader(name, value);
|
|
160
|
+
}
|
|
161
|
+
const cookies = response.headers.getSetCookie();
|
|
162
|
+
if (cookies.length) res.setHeader("set-cookie", cookies);
|
|
163
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
164
|
+
res.status(response.status).send(body);
|
|
165
|
+
}
|
|
166
|
+
|
|
149
167
|
/**
|
|
150
168
|
* What the caller should do with a request, from {@link gateRequest}.
|
|
151
169
|
*
|
|
@@ -197,9 +215,8 @@ export async function gateRequest(
|
|
|
197
215
|
return "pass";
|
|
198
216
|
}
|
|
199
217
|
|
|
200
|
-
// Every other /api/* needs a valid
|
|
201
|
-
const
|
|
202
|
-
const email = await gate.session(cookie);
|
|
218
|
+
// Every other /api/* needs a valid Better Auth session.
|
|
219
|
+
const email = await gate.session(webHeaders(req));
|
|
203
220
|
if (!email) return "deny";
|
|
204
221
|
injectIdentity(req, email);
|
|
205
222
|
stripSessionCookie(req);
|
|
@@ -212,14 +229,6 @@ export const UNAUTHORIZED_BODY = {
|
|
|
212
229
|
loginPath: AUTH_PREFIX,
|
|
213
230
|
} as const;
|
|
214
231
|
|
|
215
|
-
/** The `Set-Cookie` value that issues a verified session. */
|
|
216
|
-
export function sessionSetCookie(sessionToken: string, maxAgeSeconds: number): string {
|
|
217
|
-
return sessionCookie(sessionToken, maxAgeSeconds);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** The `Set-Cookie` value that clears the session. */
|
|
221
|
-
export const LOGOUT_SET_COOKIE = `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`;
|
|
222
|
-
|
|
223
232
|
/**
|
|
224
233
|
* Register the login routes and the gating middleware on the app's Express
|
|
225
234
|
* instance. Called from {@link AuthGatePlugin} with the router AppKit hands
|
|
@@ -231,74 +240,34 @@ export const LOGOUT_SET_COOKIE = `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; Max
|
|
|
231
240
|
*/
|
|
232
241
|
export function mountGate(
|
|
233
242
|
opts: GateOptions,
|
|
234
|
-
|
|
243
|
+
_addRoute: (method: "get" | "post", path: string, handler: RequestHandler) => void,
|
|
235
244
|
addMiddleware: (path: string, handler: RequestHandler) => void,
|
|
236
245
|
): void {
|
|
237
246
|
const { gate, publicDomain, forwardHeaders } = opts;
|
|
238
247
|
const headerPolicy = toHeaderPolicy(forwardHeaders);
|
|
239
248
|
logger.debug("gate mounted", { publicDomain, forward: headerPolicy.patterns });
|
|
240
249
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
// A local or front-door caller is never gated, so the honest answer is that
|
|
245
|
-
// the gate does not apply - not the session state of a cookie it would never
|
|
246
|
-
// check. Without this, a browser on `localhost` renders the OTP login screen
|
|
247
|
-
// for a request that would have passed through untouched.
|
|
250
|
+
// Better Auth and the compatibility routes share one fetch-compatible
|
|
251
|
+
// handler in both hosting modes.
|
|
252
|
+
const authHandler = (async (req, res) => {
|
|
248
253
|
if (!isTunnelHost(req, publicDomain)) {
|
|
249
|
-
|
|
254
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
255
|
+
if (path.endsWith("/status")) {
|
|
256
|
+
res.status(200).json({ authenticated: false, enabled: false, passkeysEnabled: false });
|
|
257
|
+
} else {
|
|
258
|
+
res.status(404).json({ error: "not found" });
|
|
259
|
+
}
|
|
250
260
|
return;
|
|
251
261
|
}
|
|
252
|
-
|
|
253
|
-
sendJson(res, 200, await gate.status(cookie));
|
|
262
|
+
await sendWebResponse(res, await gate.handler(await webRequest(req)));
|
|
254
263
|
}) as RequestHandler;
|
|
255
|
-
|
|
256
|
-
const requestHandler = (async (req, res) => {
|
|
257
|
-
if (!isTunnelHost(req, publicDomain)) {
|
|
258
|
-
sendJson(res, 404, { error: "not found" });
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
const parsed = authRequestSchema.safeParse(json.parseRecord(await readBody(req)));
|
|
262
|
-
if (!parsed.success) return sendJson(res, 200, { ok: true }); // anti-enumeration
|
|
263
|
-
sendJson(res, 200, await gate.request(parsed.data.email, clientIp(req)));
|
|
264
|
-
}) as RequestHandler;
|
|
265
|
-
|
|
266
|
-
const verifyHandler = (async (req, res) => {
|
|
267
|
-
if (!isTunnelHost(req, publicDomain)) {
|
|
268
|
-
sendJson(res, 404, { error: "not found" });
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
const parsed = authVerifySchema.safeParse(json.parseRecord(await readBody(req)));
|
|
272
|
-
if (!parsed.success) return sendJson(res, 200, { ok: false });
|
|
273
|
-
const result = await gate.verify(parsed.data.email, parsed.data.code, clientIp(req));
|
|
274
|
-
const cookie =
|
|
275
|
-
result.ok && result.token ? sessionCookie(result.token, gate.sessionTtlSeconds) : undefined;
|
|
276
|
-
sendJson(
|
|
277
|
-
res,
|
|
278
|
-
200,
|
|
279
|
-
{ ok: result.ok, ...(result.retryAfter ? { retryAfter: result.retryAfter } : {}) },
|
|
280
|
-
cookie,
|
|
281
|
-
);
|
|
282
|
-
}) as RequestHandler;
|
|
283
|
-
|
|
284
|
-
const logoutHandler = ((req, res) => {
|
|
285
|
-
if (!isTunnelHost(req, publicDomain)) {
|
|
286
|
-
sendJson(res, 404, { error: "not found" });
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
sendJson(res, 200, { ok: true }, LOGOUT_SET_COOKIE);
|
|
290
|
-
}) as RequestHandler;
|
|
291
|
-
|
|
292
|
-
addRoute("get", `${AUTH_PREFIX}/status`, statusHandler);
|
|
293
|
-
addRoute("post", `${AUTH_PREFIX}/request`, requestHandler);
|
|
294
|
-
addRoute("post", `${AUTH_PREFIX}/verify`, verifyHandler);
|
|
295
|
-
addRoute("post", `${AUTH_PREFIX}/logout`, logoutHandler);
|
|
264
|
+
addMiddleware(AUTH_PREFIX, authHandler);
|
|
296
265
|
|
|
297
266
|
// --- The gate middleware (runs before static + the app's /api handlers) ---
|
|
298
267
|
|
|
299
268
|
const gateMiddleware = (async (req, res, next) => {
|
|
300
269
|
const action = await gateRequest(req, { gate, publicDomain, headerPolicy });
|
|
301
|
-
if (action === "deny")
|
|
270
|
+
if (action === "deny") res.status(401).json(UNAUTHORIZED_BODY);
|
|
302
271
|
else next();
|
|
303
272
|
}) as RequestHandler;
|
|
304
273
|
|
package/src/interceptor.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* app's public port, binds the child process to the app, and stops portr during an
|
|
10
10
|
* orderly AppKit shutdown.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
12
|
+
* The passwordless GATE is a separate concern: it is the `authGate` AppKit plugin,
|
|
13
13
|
* which registers the login routes + a gating middleware on the app's own server.
|
|
14
14
|
* Register it in the app's `plugins` for gated traffic. This interceptor is only
|
|
15
15
|
* the portr half - "update the host, bind portr" - the smallest useful unit.
|
package/src/plugin.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `authGate()` - the AppKit
|
|
2
|
+
* `authGate()` - the AppKit adapter around `@dbx-tools/auth`.
|
|
3
3
|
*
|
|
4
4
|
* It has NO routes of its own: the tunnel PROXY (not an HTTP server) calls the
|
|
5
5
|
* handlers this plugin exposes via {@link AuthGatePlugin.exports}. The plugin
|
|
6
|
-
* owns
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* this plugin is where the gate logic lives.
|
|
6
|
+
* owns tunnel authorization, AppKit Lakebase discovery, email delivery, and
|
|
7
|
+
* transport mounting. Better Auth owns users, OTPs, sessions, rate limits, and
|
|
8
|
+
* passkeys. `createApp` can run this plugin with or without `server()`.
|
|
10
9
|
*
|
|
11
10
|
* Options come from CLI flags OR env, with sensible defaults - see
|
|
12
11
|
* {@link resolveAuthGateConfig}. The one runtime dependency the plugin can't
|
|
@@ -16,16 +15,31 @@
|
|
|
16
15
|
* @module
|
|
17
16
|
*/
|
|
18
17
|
|
|
19
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
lakebase,
|
|
20
|
+
Plugin,
|
|
21
|
+
toPlugin,
|
|
22
|
+
type BasePluginConfig,
|
|
23
|
+
type PluginManifest,
|
|
24
|
+
type ResourceRequirement,
|
|
25
|
+
ResourceType,
|
|
26
|
+
} from "@databricks/appkit";
|
|
27
|
+
import { plugin as appkitPlugin } from "@dbx-tools/appkit";
|
|
28
|
+
import {
|
|
29
|
+
auth as passwordlessAuth,
|
|
30
|
+
storage as authStorage,
|
|
31
|
+
type AuthorizeIdentity,
|
|
32
|
+
type AuthStorageConfig,
|
|
33
|
+
type AuthStorageMode,
|
|
34
|
+
type PasswordlessAuthRuntime,
|
|
35
|
+
} from "@dbx-tools/auth";
|
|
20
36
|
import { config as coreConfig } from "@dbx-tools/core";
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
37
|
+
import type { AuthStatus } from "@dbx-tools/shared-auth";
|
|
38
|
+
import { brand, log, string } from "@dbx-tools/shared-core";
|
|
23
39
|
import type { RequestHandler } from "express";
|
|
24
40
|
import { TUNNEL_CONFIG } from "./_config.ts";
|
|
25
41
|
import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
|
|
26
42
|
import { mountGate, type GateOptions } from "./gate.ts";
|
|
27
|
-
import { CodeStore, signSession, verifySession } from "./otp.ts";
|
|
28
|
-
import { RateLimiter } from "./rate-limit.ts";
|
|
29
43
|
import { ensureEmailAvailable, sendCode as defaultSendCode } from "./send-code.ts";
|
|
30
44
|
import { KEY_TTL_SECONDS, resolveSessionCutoff, signingKey } from "./signing-key.ts";
|
|
31
45
|
|
|
@@ -73,7 +87,7 @@ export function mountGateOnContext(context: GateMountContext, options: GateOptio
|
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
/** Options for the {@link authGate} plugin (all resolvable from env - see below). */
|
|
76
|
-
export interface AuthGateConfig extends BasePluginConfig {
|
|
90
|
+
export interface AuthGateConfig extends BasePluginConfig, AuthStorageConfig {
|
|
77
91
|
/** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env TUNNEL_AUTH_ALLOW. */
|
|
78
92
|
allow?: string | string[];
|
|
79
93
|
/**
|
|
@@ -133,6 +147,11 @@ export interface AuthGateConfig extends BasePluginConfig {
|
|
|
133
147
|
* different delivery path.
|
|
134
148
|
*/
|
|
135
149
|
sendCode?: (email: string, code: string, opts: SendCodeOptions) => Promise<void>;
|
|
150
|
+
/**
|
|
151
|
+
* Identity authorization independent of authentication. Defaults to the
|
|
152
|
+
* configured allow-list and is re-evaluated for every accepted session.
|
|
153
|
+
*/
|
|
154
|
+
authorizeIdentity?: AuthorizeIdentity;
|
|
136
155
|
/**
|
|
137
156
|
* The public `<subdomain>.<server>` that identifies portr traffic by its `Host`
|
|
138
157
|
* header. Only requests whose `Host` matches this are gated; everything else
|
|
@@ -185,6 +204,8 @@ export interface ResolvedAuthGateConfig {
|
|
|
185
204
|
forwardHeaders: string[];
|
|
186
205
|
/** Run OPEN with no gate. */
|
|
187
206
|
insecure: boolean;
|
|
207
|
+
storage: AuthStorageMode;
|
|
208
|
+
sqlitePath?: string;
|
|
188
209
|
}
|
|
189
210
|
|
|
190
211
|
const DEFAULTS = {
|
|
@@ -204,6 +225,12 @@ const DEFAULTS = {
|
|
|
204
225
|
|
|
205
226
|
/** Merge {@link AuthGateConfig} over env over defaults into a resolved config. */
|
|
206
227
|
export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateConfig {
|
|
228
|
+
const storage = authStorage.resolveAuthStorageConfig({
|
|
229
|
+
storage:
|
|
230
|
+
config.storage ??
|
|
231
|
+
(coreConfig.text("AUTH_STORAGE", TUNNEL_CONFIG) as AuthStorageMode | undefined),
|
|
232
|
+
sqlitePath: config.sqlitePath ?? coreConfig.text("AUTH_SQLITE_PATH", TUNNEL_CONFIG),
|
|
233
|
+
});
|
|
207
234
|
return {
|
|
208
235
|
// Both sources are unioned rather than one overriding: a deployment-wide
|
|
209
236
|
// TUNNEL_AUTH_ALLOW and a per-invocation `--allow` should both grant access.
|
|
@@ -237,29 +264,27 @@ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateC
|
|
|
237
264
|
...string.parseList(coreConfig.text("FORWARD_HEADERS", TUNNEL_CONFIG)),
|
|
238
265
|
],
|
|
239
266
|
insecure: coreConfig.boolean(config.insecure, "INSECURE", TUNNEL_CONFIG) ?? false,
|
|
267
|
+
storage: storage.mode,
|
|
268
|
+
sqlitePath: storage.sqlitePath,
|
|
240
269
|
};
|
|
241
270
|
}
|
|
242
271
|
|
|
243
272
|
/** The handlers the gate middleware calls in-process (returned by {@link AuthGatePlugin.exports}). */
|
|
244
273
|
export interface AuthGateApi {
|
|
245
|
-
/**
|
|
246
|
-
request
|
|
247
|
-
/**
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
-
/** Session TTL in seconds (for the cookie Max-Age). */
|
|
256
|
-
readonly sessionTtlSeconds: number;
|
|
257
|
-
/** The gate status payload (`enabled` is always true when this plugin runs). */
|
|
258
|
-
status(token: string | undefined): Promise<AuthStatus>;
|
|
274
|
+
/** Better Auth and compatibility routes under `/api/email/auth/*`. */
|
|
275
|
+
handler(request: Request): Promise<Response>;
|
|
276
|
+
/** Resolve the authorized email for request headers, or undefined. */
|
|
277
|
+
session(headers: Headers): Promise<string | undefined>;
|
|
278
|
+
/** The gate status payload. */
|
|
279
|
+
status(headers: Headers): Promise<AuthStatus>;
|
|
280
|
+
/** Whether the runtime exposes passkey enrollment and authentication. */
|
|
281
|
+
readonly passkeysEnabled: boolean;
|
|
282
|
+
/** Close auth storage owned by this runtime. */
|
|
283
|
+
close(): Promise<void>;
|
|
259
284
|
}
|
|
260
285
|
|
|
261
286
|
/**
|
|
262
|
-
* AppKit plugin
|
|
287
|
+
* AppKit plugin adapting `@dbx-tools/auth` to tunnel traffic. On `setup()` it registers the login
|
|
263
288
|
* routes (`/api/email/auth/*`) and a gating middleware on the app's OWN Express
|
|
264
289
|
* server via `this.context`, so a public portr caller must prove an email before
|
|
265
290
|
* reaching the app's `/api/*` - see `./gate`. Front-door (platform) traffic and
|
|
@@ -269,29 +294,74 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
269
294
|
static manifest = {
|
|
270
295
|
name: "authGate",
|
|
271
296
|
displayName: "Auth Gate",
|
|
272
|
-
description: "
|
|
297
|
+
description: "Better Auth email OTP and passkey access gate for a public tunnel.",
|
|
273
298
|
stability: "beta",
|
|
274
|
-
resources: {
|
|
299
|
+
resources: {
|
|
300
|
+
required: [],
|
|
301
|
+
optional: [
|
|
302
|
+
{
|
|
303
|
+
type: ResourceType.POSTGRES,
|
|
304
|
+
alias: "auth",
|
|
305
|
+
resourceKey: "auth-database",
|
|
306
|
+
description: "Durable users, sessions, OTP records, and passkeys.",
|
|
307
|
+
permission: "CAN_CONNECT_AND_CREATE",
|
|
308
|
+
fields: {
|
|
309
|
+
instance_name: { env: "LAKEBASE_INSTANCE_NAME" },
|
|
310
|
+
database_name: { env: "PGDATABASE" },
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
275
315
|
} satisfies PluginManifest<"authGate">;
|
|
276
316
|
|
|
277
317
|
private resolved!: ResolvedAuthGateConfig;
|
|
278
|
-
private
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
318
|
+
private runtime?: PasswordlessAuthRuntime;
|
|
319
|
+
|
|
320
|
+
static getResourceRequirements(config: AuthGateConfig): ResourceRequirement[] {
|
|
321
|
+
if (authStorage.resolveAuthStorageConfig(config).mode !== "lakebase") return [];
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
type: ResourceType.POSTGRES,
|
|
325
|
+
alias: "auth",
|
|
326
|
+
resourceKey: "auth-database",
|
|
327
|
+
description: "Durable users, sessions, OTP records, and passkeys.",
|
|
328
|
+
permission: "CAN_CONNECT_AND_CREATE",
|
|
329
|
+
fields: {
|
|
330
|
+
instance_name: { env: "LAKEBASE_INSTANCE_NAME" },
|
|
331
|
+
database_name: { env: "PGDATABASE" },
|
|
332
|
+
},
|
|
333
|
+
required: true,
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
}
|
|
283
337
|
|
|
284
338
|
override async setup(): Promise<void> {
|
|
285
339
|
this.resolved = resolveAuthGateConfig(this.config);
|
|
286
|
-
this.codes = new CodeStore(this.resolved.codeTtlSeconds, this.resolved.maxAttempts);
|
|
287
|
-
// Resolve the signing key HERE rather than lazily on the first sign-in, so a
|
|
288
|
-
// cache that cannot hold it (and the resulting "sessions won't survive a
|
|
289
|
-
// restart" warning) shows up in the startup log, not hours later.
|
|
290
|
-
const { cutoffMs } = await signingKey(this.resolved.sessionCutoffMs);
|
|
291
340
|
|
|
292
341
|
if (this.resolved.insecure) {
|
|
293
|
-
logger.warn("insecure mode - the tunnel runs OPEN with no
|
|
342
|
+
logger.warn("insecure mode - the tunnel runs OPEN with no auth gate");
|
|
294
343
|
} else {
|
|
344
|
+
const lakebasePlugin = appkitPlugin.instance(this.context, lakebase);
|
|
345
|
+
const storage = await authStorage.createAuthStorage(
|
|
346
|
+
this.resolved,
|
|
347
|
+
lakebasePlugin?.exports().pool,
|
|
348
|
+
);
|
|
349
|
+
const { key } = await signingKey(this.resolved.sessionCutoffMs);
|
|
350
|
+
this.runtime = await passwordlessAuth.createPasswordlessAuth({
|
|
351
|
+
storage,
|
|
352
|
+
baseURL: authOrigin(this.resolved.publicDomain),
|
|
353
|
+
basePath: "/api/email/auth",
|
|
354
|
+
appName: this.resolved.brandName,
|
|
355
|
+
secret: Buffer.from(key).toString("base64url"),
|
|
356
|
+
sessionTtlSeconds: this.resolved.sessionTtlSeconds,
|
|
357
|
+
sessionCutoffMs: this.resolved.sessionCutoffMs,
|
|
358
|
+
codeTtlSeconds: this.resolved.codeTtlSeconds,
|
|
359
|
+
maxAttempts: this.resolved.maxAttempts,
|
|
360
|
+
authorizeIdentity: (email) => this.authorizeIdentity(email),
|
|
361
|
+
sendCode: (email, code, options) => this.sendCode(email, code, options),
|
|
362
|
+
subject: this.resolved.subject,
|
|
363
|
+
message: this.resolved.message,
|
|
364
|
+
});
|
|
295
365
|
// `server()` is deferred, so it does not exist during this plugin's setup.
|
|
296
366
|
// At `setup:complete` the Express app exists but has not injected plugin
|
|
297
367
|
// routes or static handling yet, which is the one point the gate can mount
|
|
@@ -300,7 +370,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
300
370
|
this.mountGateRoutes();
|
|
301
371
|
// Fail fast once the sibling email plugin has primed its transport: a
|
|
302
372
|
// gate that cannot email a code lets nobody in.
|
|
303
|
-
await ensureEmailAvailable();
|
|
373
|
+
if (!this.config.sendCode) await ensureEmailAvailable();
|
|
304
374
|
});
|
|
305
375
|
}
|
|
306
376
|
|
|
@@ -309,10 +379,18 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
309
379
|
sessionTtlSeconds: this.resolved.sessionTtlSeconds,
|
|
310
380
|
publicDomain: this.resolved.publicDomain ?? null,
|
|
311
381
|
insecure: this.resolved.insecure,
|
|
312
|
-
|
|
382
|
+
storage: this.resolved.storage,
|
|
383
|
+
sessionCutoff:
|
|
384
|
+
this.resolved.sessionCutoffMs > 0
|
|
385
|
+
? new Date(this.resolved.sessionCutoffMs).toISOString()
|
|
386
|
+
: null,
|
|
313
387
|
});
|
|
314
388
|
}
|
|
315
389
|
|
|
390
|
+
async shutdown(): Promise<void> {
|
|
391
|
+
await this.runtime?.close();
|
|
392
|
+
}
|
|
393
|
+
|
|
316
394
|
/**
|
|
317
395
|
* Register the login routes (`/api/email/auth/*`) and the gating middleware on
|
|
318
396
|
* the app's OWN Express server. At `setup:complete`, the deferred server plugin
|
|
@@ -323,7 +401,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
323
401
|
private mountGateRoutes(): void {
|
|
324
402
|
const context = this.context;
|
|
325
403
|
if (!context) {
|
|
326
|
-
logger.warn("no plugin context - the
|
|
404
|
+
logger.warn("no plugin context - the auth gate cannot mount its routes");
|
|
327
405
|
return;
|
|
328
406
|
}
|
|
329
407
|
mountGateOnContext(context, {
|
|
@@ -338,62 +416,33 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
338
416
|
return this.config.sendCode ?? defaultSendCode;
|
|
339
417
|
}
|
|
340
418
|
|
|
419
|
+
private authorizeIdentity(email: string): boolean | Promise<boolean> {
|
|
420
|
+
if (this.config.authorizeIdentity) return this.config.authorizeIdentity(email);
|
|
421
|
+
return looksLikeEmail(email) && matchesAllowlist(email, this.resolved.allow);
|
|
422
|
+
}
|
|
423
|
+
|
|
341
424
|
override exports(): AuthGateApi {
|
|
425
|
+
const runtime = this.runtime;
|
|
342
426
|
return {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
session: (
|
|
347
|
-
status:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}),
|
|
427
|
+
passkeysEnabled: runtime?.passkeysEnabled ?? false,
|
|
428
|
+
handler: (request) =>
|
|
429
|
+
runtime?.handler(request) ?? Promise.resolve(new Response("Not Found", { status: 404 })),
|
|
430
|
+
session: (headers) => runtime?.session(headers) ?? Promise.resolve(undefined),
|
|
431
|
+
status: (headers) =>
|
|
432
|
+
runtime?.status(headers) ??
|
|
433
|
+
Promise.resolve({ authenticated: false, enabled: false, passkeysEnabled: false }),
|
|
434
|
+
close: () => runtime?.close() ?? Promise.resolve(),
|
|
352
435
|
};
|
|
353
436
|
}
|
|
437
|
+
}
|
|
354
438
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (!byIp.allowed || !byEmail.allowed) {
|
|
363
|
-
return { ok: true, retryAfter: byIp.retryAfter ?? byEmail.retryAfter };
|
|
364
|
-
}
|
|
365
|
-
if (looksLikeEmail(address) && matchesAllowlist(address, this.resolved.allow)) {
|
|
366
|
-
const code = await this.codes.issue(address);
|
|
367
|
-
try {
|
|
368
|
-
await this.sendCode(address, code, {
|
|
369
|
-
subject: this.resolved.subject,
|
|
370
|
-
brandName: this.resolved.brandName,
|
|
371
|
-
message: this.resolved.message,
|
|
372
|
-
codeTtlSeconds: this.resolved.codeTtlSeconds,
|
|
373
|
-
});
|
|
374
|
-
} catch (error) {
|
|
375
|
-
logger.warn("failed to send OTP email", { error });
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
return { ok: true };
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
private async handleVerify(
|
|
382
|
-
email: string,
|
|
383
|
-
code: string,
|
|
384
|
-
ip: string,
|
|
385
|
-
): Promise<{ ok: boolean; token?: string; retryAfter?: number }> {
|
|
386
|
-
const address = email.trim().toLowerCase();
|
|
387
|
-
const byIp = this.verifyLimiter.hit(`ip:${ip}`);
|
|
388
|
-
const byEmail = this.verifyLimiter.hit(`email:${address}`);
|
|
389
|
-
if (!byIp.allowed || !byEmail.allowed) {
|
|
390
|
-
return { ok: false, retryAfter: byIp.retryAfter ?? byEmail.retryAfter };
|
|
391
|
-
}
|
|
392
|
-
if ((await this.codes.verify(address, code.trim())) !== "ok") return { ok: false };
|
|
393
|
-
this.requestLimiter.reset(`email:${address}`);
|
|
394
|
-
this.verifyLimiter.reset(`email:${address}`);
|
|
395
|
-
return { ok: true, token: await signSession(address, this.resolved.sessionTtlSeconds) };
|
|
396
|
-
}
|
|
439
|
+
function authOrigin(publicDomain?: string): string {
|
|
440
|
+
const value = string.trimToNull(publicDomain);
|
|
441
|
+
if (!value) return "http://localhost";
|
|
442
|
+
if (/^https?:\/\//i.test(value)) return new URL(value).origin;
|
|
443
|
+
const host = value.split("/")[0]!;
|
|
444
|
+
const local = host === "localhost" || host.startsWith("localhost:");
|
|
445
|
+
return `${local ? "http" : "https"}://${host}`;
|
|
397
446
|
}
|
|
398
447
|
|
|
399
448
|
/** Factory: `authGate({ allow, subject, ... })` for an AppKit `plugins` array. */
|
package/src/signing-key.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The gate's
|
|
2
|
+
* The gate's Better Auth secret, persisted in AppKit's cache.
|
|
3
3
|
*
|
|
4
4
|
* The key decides whether a session COOKIE still verifies, so it must OUTLIVE the
|
|
5
5
|
* process: a per-process `randomBytes(32)` would invalidate every outstanding cookie
|
|
@@ -62,7 +62,7 @@ export const KEY_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
|
62
62
|
/** Cache-key prefix for the signing key, namespaced away from other cache use. */
|
|
63
63
|
const KEY_PREFIX = "tunnel:auth:signing-key:";
|
|
64
64
|
|
|
65
|
-
/** Bytes of entropy in a generated
|
|
65
|
+
/** Bytes of entropy in a generated Better Auth secret. */
|
|
66
66
|
const KEY_BYTES = 32;
|
|
67
67
|
|
|
68
68
|
/** What the cache stores: the key plus when it was minted, for observability. */
|
|
@@ -176,7 +176,7 @@ let pending: Promise<SigningKey> | undefined;
|
|
|
176
176
|
* plugin's `setup()` passes its resolved value there, before any request can
|
|
177
177
|
* reach the lazy path.
|
|
178
178
|
*
|
|
179
|
-
* `TUNNEL_AUTH_JWT_SECRET` when set, else the cache-backed
|
|
179
|
+
* `TUNNEL_AUTH_JWT_SECRET` when set, else the cache-backed secret. A cache that is
|
|
180
180
|
* unavailable degrades to an ephemeral per-process key (the previous behaviour)
|
|
181
181
|
* rather than refusing to sign: the key only validates an ALREADY-issued session,
|
|
182
182
|
* so losing it costs sessions, never admission - a caller still needs a code
|