@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/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 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.
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 { http, json, log, token } from "@dbx-tools/shared-core";
27
- import { authRequestSchema, authVerifySchema, SESSION_COOKIE_NAME } from "@dbx-tools/shared-email";
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
- /** Route prefix the login flow lives under (open, answered in-process). */
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(`${SESSION_COOKIE_NAME}=`));
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 an OTP-authenticated caller to the app the SAME way a platform front
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 OTP session
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: Request): Promise<string> {
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 OTP session.
201
- const cookie = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
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
- addRoute: (method: "get" | "post", path: string, handler: RequestHandler) => void,
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
- // --- Login routes (open on tunnel traffic; answered in-process) ---
242
-
243
- const statusHandler = (async (req, res) => {
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
- sendJson(res, 200, { authenticated: false, enabled: false });
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
- const cookie = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
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") sendJson(res, 401, UNAUTHORIZED_BODY);
270
+ if (action === "deny") res.status(401).json(UNAUTHORIZED_BODY);
302
271
  else next();
303
272
  }) as RequestHandler;
304
273
 
@@ -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 email-OTP GATE is a separate concern: it is the `authGate` AppKit plugin,
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 plugin behind the tunnel's email-OTP gate.
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 the allow-list, the per-email/per-IP rate limiters, the CacheManager-backed
7
- * one-time-code store, and the session JWT. `createApp` (with no `server()`) is
8
- * used only to auto-init `CacheManager` + prime the sibling `email` transport;
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 { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
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 { brand, log, object, string } from "@dbx-tools/shared-core";
22
- import type { AuthStatus } from "@dbx-tools/shared-email";
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
- /** Handle a code request. Always resolves `{ ok: true }` (anti-enumeration). */
246
- request(email: string, ip: string): Promise<{ ok: true; retryAfter?: number }>;
247
- /** Handle a code verification. On success returns the session token to cookie. */
248
- verify(
249
- email: string,
250
- code: string,
251
- ip: string,
252
- ): Promise<{ ok: boolean; token?: string; retryAfter?: number }>;
253
- /** Resolve the authenticated email for a session token, or undefined. */
254
- session(token: string | undefined): Promise<string | undefined>;
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 owning the email-OTP gate. On `setup()` it registers the login
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: "Email one-time-password access gate for a public tunnel.",
297
+ description: "Better Auth email OTP and passkey access gate for a public tunnel.",
273
298
  stability: "beta",
274
- resources: { required: [], optional: [] },
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 codes!: CodeStore;
279
- // Requesting a code is email-spam-prone; verifying is a brute-force surface.
280
- // Per-email AND per-IP so neither axis alone is a bypass.
281
- private readonly requestLimiter = new RateLimiter(5, 15 * 60 * 1000);
282
- private readonly verifyLimiter = new RateLimiter(10, 15 * 60 * 1000);
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 email-OTP gate");
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
- ...object.optional("sessionCutoff", cutoffMs > 0 ? new Date(cutoffMs).toISOString() : null),
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 OTP gate cannot mount its routes");
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
- sessionTtlSeconds: this.resolved.sessionTtlSeconds,
344
- request: (email, ip) => this.handleRequest(email, ip),
345
- verify: (email, code, ip) => this.handleVerify(email, code, ip),
346
- session: (token) => verifySession(token),
347
- status: async (token) => ({
348
- authenticated: Boolean(await verifySession(token)),
349
- email: (await verifySession(token)) ?? undefined,
350
- enabled: true,
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
- private async handleRequest(
356
- email: string,
357
- ip: string,
358
- ): Promise<{ ok: true; retryAfter?: number }> {
359
- const address = email.trim().toLowerCase();
360
- const byIp = this.requestLimiter.hit(`ip:${ip}`);
361
- const byEmail = this.requestLimiter.hit(`email:${address}`);
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. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The gate's HS256 session-signing key, persisted in AppKit's cache.
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 key (256-bit, matching HS256's hash width). */
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 key. A cache that is
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