@dbx-tools/email 0.6.41 → 0.6.42

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * One-time-code store + session JWT for the email-OTP gate.
3
+ *
4
+ * Two pieces:
5
+ *
6
+ * - **Code store** - a 6-digit code is generated with `crypto.randomInt` and
7
+ * kept server-side as a SHA-256 hash with an expiry and an attempt counter
8
+ * (never the plaintext, never in the JWT). `verifyCode` is constant-time on
9
+ * the hash, enforces the TTL, and burns the code after too many attempts or
10
+ * one success. In-memory `Map` keyed by lowercased email - fine for a
11
+ * single-instance app behind a tunnel.
12
+ * - **Session JWT** - on a correct code, `signSession` mints a short-lived
13
+ * HS256 JWT (via `jose`) carrying only the email; `verifySession` validates
14
+ * it. The signing key comes from `AUTH_JWT_SECRET`; when unset the gate
15
+ * FAILS OPEN with an ephemeral per-process key (sessions reset on restart)
16
+ * rather than refusing service - a Databricks App is already access-limited,
17
+ * so an unset secret degrades to "sessions don't survive restarts", not
18
+ * "nobody can log in".
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
24
+ import { jwtVerify, SignJWT } from "jose";
25
+ import { log } from "@dbx-tools/shared-core";
26
+
27
+ const logger = log.logger("email:auth:otp");
28
+
29
+ /** JWT issuer/audience so a token minted for this gate isn't accepted elsewhere. */
30
+ const JWT_AUD = "dbx-tools-email-auth";
31
+
32
+ /** SHA-256 hex of a value (for the stored code + a stable key comparison). */
33
+ function sha256(value: string): string {
34
+ return createHash("sha256").update(value).digest("hex");
35
+ }
36
+
37
+ /** Constant-time compare of two hex digests of equal length. */
38
+ function safeEqualHex(a: string, b: string): boolean {
39
+ if (a.length !== b.length) return false;
40
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
41
+ }
42
+
43
+ interface CodeEntry {
44
+ hash: string;
45
+ expiresAt: number;
46
+ attempts: number;
47
+ }
48
+
49
+ /** Result of {@link CodeStore.verify}. */
50
+ export type VerifyOutcome = "ok" | "invalid" | "expired" | "too-many-attempts";
51
+
52
+ /** In-memory store of pending one-time codes, keyed by lowercased email. */
53
+ export class CodeStore {
54
+ private readonly codes = new Map<string, CodeEntry>();
55
+
56
+ constructor(
57
+ private readonly ttlMs: number,
58
+ private readonly maxAttempts: number,
59
+ ) {}
60
+
61
+ /**
62
+ * Generate, store (hashed), and RETURN a fresh 6-digit code for `email`. The
63
+ * caller emails the returned plaintext; only the hash is retained. Replaces
64
+ * any pending code for the address.
65
+ */
66
+ issue(email: string, now: number = Date.now()): string {
67
+ const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
68
+ this.codes.set(email.toLowerCase(), {
69
+ hash: sha256(code),
70
+ expiresAt: now + this.ttlMs,
71
+ attempts: 0,
72
+ });
73
+ return code;
74
+ }
75
+
76
+ /**
77
+ * Check `code` for `email`. Consumes the entry on success or when attempts are
78
+ * exhausted, so a code is single-use and can't be brute-forced past the cap.
79
+ */
80
+ verify(email: string, code: string, now: number = Date.now()): VerifyOutcome {
81
+ const key = email.toLowerCase();
82
+ const entry = this.codes.get(key);
83
+ if (!entry) return "invalid";
84
+ if (now >= entry.expiresAt) {
85
+ this.codes.delete(key);
86
+ return "expired";
87
+ }
88
+ entry.attempts += 1;
89
+ if (safeEqualHex(entry.hash, sha256(code))) {
90
+ this.codes.delete(key);
91
+ return "ok";
92
+ }
93
+ if (entry.attempts >= this.maxAttempts) {
94
+ this.codes.delete(key);
95
+ return "too-many-attempts";
96
+ }
97
+ return "invalid";
98
+ }
99
+
100
+ /** Drop every pending code (tests). */
101
+ clear(): void {
102
+ this.codes.clear();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Resolve the HS256 signing key. Prefers `AUTH_JWT_SECRET`; when unset, mints an
108
+ * ephemeral per-process key (fail-open) and warns once. Memoized so every
109
+ * sign/verify in a process shares one key.
110
+ */
111
+ let cachedKey: Uint8Array | undefined;
112
+ function signingKey(): Uint8Array {
113
+ if (cachedKey) return cachedKey;
114
+ const secret = process.env.AUTH_JWT_SECRET?.trim();
115
+ if (secret) {
116
+ cachedKey = new TextEncoder().encode(secret);
117
+ } else {
118
+ logger.warn(
119
+ "AUTH_JWT_SECRET is not set - using an ephemeral per-process key; sessions will not survive a restart",
120
+ );
121
+ cachedKey = randomBytes(32);
122
+ }
123
+ return cachedKey;
124
+ }
125
+
126
+ /** Reset the memoized key (tests, or after changing the env in-process). */
127
+ export function resetSigningKey(): void {
128
+ cachedKey = undefined;
129
+ }
130
+
131
+ /** Mint a short-lived session JWT for `email`, expiring in `ttlSeconds`. */
132
+ export async function signSession(email: string, ttlSeconds: number): Promise<string> {
133
+ return new SignJWT({ email })
134
+ .setProtectedHeader({ alg: "HS256" })
135
+ .setSubject(email)
136
+ .setAudience(JWT_AUD)
137
+ .setIssuedAt()
138
+ .setExpirationTime(`${ttlSeconds}s`)
139
+ .sign(signingKey());
140
+ }
141
+
142
+ /** Validate a session JWT, returning the email it was minted for, or `undefined`. */
143
+ export async function verifySession(token: string | undefined): Promise<string | undefined> {
144
+ if (!token) return undefined;
145
+ try {
146
+ const { payload } = await jwtVerify(token, signingKey(), { audience: JWT_AUD });
147
+ return typeof payload.email === "string" ? payload.email : undefined;
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A small in-memory fixed-window rate limiter for the email-OTP gate.
3
+ *
4
+ * Keyed by an arbitrary string (an email address or a client IP). Each key gets
5
+ * `max` hits per `windowMs`; the window resets on first use after it elapses.
6
+ * `hit()` returns whether the call is allowed and, when not, how many seconds
7
+ * until the window resets so a caller can surface a cooldown.
8
+ *
9
+ * In-memory is intentional and sufficient for a single-app-instance gate: an
10
+ * app behind a portr tunnel serves from one process. It is NOT a distributed
11
+ * limiter; a multi-replica deployment would need shared state. Entries are
12
+ * pruned lazily on access, so an idle key costs nothing after its window.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ interface Window {
18
+ count: number;
19
+ resetAt: number;
20
+ }
21
+
22
+ /** A fixed-window rate limiter over string keys. */
23
+ export class RateLimiter {
24
+ private readonly windows = new Map<string, Window>();
25
+
26
+ constructor(
27
+ private readonly max: number,
28
+ private readonly windowMs: number,
29
+ ) {}
30
+
31
+ /**
32
+ * Record a hit for `key`. Returns `{ allowed }`, plus `retryAfter` (seconds)
33
+ * when the limit is exceeded. A limit of `<= 0` disables limiting (always
34
+ * allowed), which lets a config turn it off without special-casing callers.
35
+ */
36
+ hit(key: string, now: number = Date.now()): { allowed: boolean; retryAfter?: number } {
37
+ if (this.max <= 0) return { allowed: true };
38
+ const existing = this.windows.get(key);
39
+ if (!existing || now >= existing.resetAt) {
40
+ this.windows.set(key, { count: 1, resetAt: now + this.windowMs });
41
+ return { allowed: true };
42
+ }
43
+ if (existing.count < this.max) {
44
+ existing.count += 1;
45
+ return { allowed: true };
46
+ }
47
+ return { allowed: false, retryAfter: Math.ceil((existing.resetAt - now) / 1000) };
48
+ }
49
+
50
+ /** Forget a key (e.g. clear a caller's window after a successful verify). */
51
+ reset(key: string): void {
52
+ this.windows.delete(key);
53
+ }
54
+
55
+ /** Drop every window (tests). */
56
+ clear(): void {
57
+ this.windows.clear();
58
+ }
59
+ }
package/src/config.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { resolve } from "node:path";
27
27
  import { ConfigurationError, ValidationError, type BasePluginConfig } from "@databricks/appkit";
28
- import { object } from "@dbx-tools/shared-core";
28
+ import { object, string } from "@dbx-tools/shared-core";
29
29
  import type { JSONSchema7 } from "json-schema";
30
30
  import type { EmailBrand } from "./brand.ts";
31
31
  import { parseAllowedSenders } from "./sender.ts";
@@ -114,6 +114,33 @@ export interface EmailPluginConfig extends BasePluginConfig {
114
114
  * `BrandContext`.
115
115
  */
116
116
  brand?: EmailBrand;
117
+ /**
118
+ * Optional email-OTP ACCESS GATE. When `enabled`, the plugin mounts a login
119
+ * flow (`/api/email/auth/*`) and gates every other route behind a session
120
+ * cookie - the front door for an app exposed publicly (e.g. through a portr
121
+ * tunnel that bypasses the Databricks OAuth proxy). Reuses this plugin's
122
+ * transport to email the code. Omit or `enabled: false` to leave the app open.
123
+ */
124
+ auth?: AuthConfig;
125
+ }
126
+
127
+ /** Email-OTP access-gate configuration (see {@link EmailPluginConfig.auth}). */
128
+ export interface AuthConfig {
129
+ /** Turn the gate on. Falls back to `EMAIL_AUTH_ENABLED`. Default off. */
130
+ enabled?: boolean;
131
+ /**
132
+ * Who may request a code. Each entry is a domain shortcut (`databricks.com`
133
+ * or `@databricks.com`), a glob (`*.databricks.com`), or a `/regex/`. An
134
+ * EMPTY list allows nobody (fail closed). Falls back to `EMAIL_AUTH_ALLOW`
135
+ * (comma/space-separated).
136
+ */
137
+ allow?: string | string[];
138
+ /** Session lifetime (seconds). Falls back to `EMAIL_AUTH_SESSION_TTL`. Default 43200 (12h). */
139
+ sessionTtlSeconds?: number;
140
+ /** One-time-code lifetime (seconds). Falls back to `EMAIL_AUTH_CODE_TTL`. Default 600 (10m). */
141
+ codeTtlSeconds?: number;
142
+ /** Max verify attempts per issued code. Default 5. */
143
+ maxAttempts?: number;
117
144
  }
118
145
 
119
146
  /** Config shared by both resolved modes. */
@@ -233,9 +260,71 @@ export const EMAIL_CONFIG_SCHEMA: JSONSchema7 = {
233
260
  },
234
261
  required: ["accent", "fontFamily"],
235
262
  },
263
+ auth: {
264
+ type: "object",
265
+ description:
266
+ "Email-OTP access gate. When enabled, mounts a login flow at /api/email/auth/* and gates every other route behind a session cookie.",
267
+ properties: {
268
+ enabled: {
269
+ type: "boolean",
270
+ description: "Turn the gate on (env EMAIL_AUTH_ENABLED). Default off.",
271
+ },
272
+ allow: {
273
+ type: "array",
274
+ items: { type: "string" },
275
+ description:
276
+ 'Allow-list of who may request a code: domain shortcut ("databricks.com"), glob ("*.databricks.com"), or "/regex/". Empty = nobody. Also accepts a comma/space-separated string. Env EMAIL_AUTH_ALLOW.',
277
+ },
278
+ sessionTtlSeconds: {
279
+ type: "number",
280
+ description: "Session lifetime in seconds (env EMAIL_AUTH_SESSION_TTL). Default 43200.",
281
+ },
282
+ codeTtlSeconds: {
283
+ type: "number",
284
+ description: "One-time-code lifetime in seconds (env EMAIL_AUTH_CODE_TTL). Default 600.",
285
+ },
286
+ maxAttempts: {
287
+ type: "number",
288
+ description: "Max verify attempts per issued code. Default 5.",
289
+ },
290
+ },
291
+ },
236
292
  },
237
293
  };
238
294
 
295
+ /** Resolved email-OTP gate config, with env fallbacks applied (or `undefined` when off). */
296
+ export interface ResolvedAuthConfig {
297
+ readonly allow: string[];
298
+ readonly sessionTtlSeconds: number;
299
+ readonly codeTtlSeconds: number;
300
+ readonly maxAttempts: number;
301
+ }
302
+
303
+ /**
304
+ * Resolve {@link AuthConfig} against env, returning `undefined` when the gate is
305
+ * off. `enabled` gates everything; `allow` merges the config list + env
306
+ * (`parseList` handles a `string[]` or a delimited string). Empty `allow` is
307
+ * allowed here (the gate then denies everyone - a deliberate fail-closed state).
308
+ */
309
+ export function resolveAuthConfig(auth: AuthConfig | undefined): ResolvedAuthConfig | undefined {
310
+ const enabled = auth?.enabled ?? object.toBoolean(process.env.EMAIL_AUTH_ENABLED) ?? false;
311
+ if (!enabled) return undefined;
312
+ const allow = [
313
+ ...string.parseList(auth?.allow),
314
+ ...string.parseList(process.env.EMAIL_AUTH_ALLOW),
315
+ ];
316
+ const num = (value: number | undefined, env: string | undefined, fallback: number): number => {
317
+ const raw = value ?? (env ? Number(env) : undefined);
318
+ return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : fallback;
319
+ };
320
+ return {
321
+ allow,
322
+ sessionTtlSeconds: num(auth?.sessionTtlSeconds, process.env.EMAIL_AUTH_SESSION_TTL, 43200),
323
+ codeTtlSeconds: num(auth?.codeTtlSeconds, process.env.EMAIL_AUTH_CODE_TTL, 600),
324
+ maxAttempts: auth?.maxAttempts ?? 5,
325
+ };
326
+ }
327
+
239
328
  /** Parse the `SMTP_SECURE` env / config flag, defaulting by port. */
240
329
  function resolveSecure(flag: boolean | undefined, port: number): boolean {
241
330
  if (typeof flag === "boolean") return flag;
package/src/plugin.ts CHANGED
@@ -52,7 +52,10 @@ import {
52
52
  type EmailResult,
53
53
  type EmailSenders,
54
54
  } from "@dbx-tools/shared-email";
55
- import { EMAIL_CONFIG_SCHEMA, type EmailPluginConfig } from "./config.ts";
55
+ import type express from "express";
56
+ import { authRequestSchema, authVerifySchema } from "@dbx-tools/shared-email";
57
+ import { EMAIL_CONFIG_SCHEMA, resolveAuthConfig, type EmailPluginConfig } from "./config.ts";
58
+ import { AuthGate, SESSION_COOKIE } from "./auth/gate.ts";
56
59
  import { EMAIL_SENDERS_SETTINGS, EMAIL_VERIFY_SETTINGS } from "./defaults.ts";
57
60
  import { isSenderAllowed, listSenderOptions, resolveSenderAddress } from "./sender.ts";
58
61
  import { SEND_EMAIL_DESCRIPTION } from "./tool.ts";
@@ -93,6 +96,9 @@ const logger = log.logger("email");
93
96
  * ```
94
97
  */
95
98
  export class EmailPlugin extends Plugin<EmailPluginConfig> implements ToolProvider {
99
+ /** The email-OTP access gate, constructed in {@link setup} when `auth.enabled`. */
100
+ private authGate?: AuthGate;
101
+
96
102
  static manifest = {
97
103
  name: "email",
98
104
  displayName: "Email",
@@ -143,6 +149,7 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> implements ToolProvid
143
149
  override async setup(): Promise<void> {
144
150
  const { transporter, config } = getEmailRuntime(this.config);
145
151
  setEmailExecutor((fn, settings) => this.execute(fn, settings));
152
+ this.setupAuthGate();
146
153
  const policy = {
147
154
  mode: config.mode,
148
155
  senderPolicy: config.senderPolicy,
@@ -218,6 +225,85 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> implements ToolProvid
218
225
  res.json(result.data);
219
226
  },
220
227
  });
228
+ this.injectAuthRoutes(router);
229
+ }
230
+
231
+ /**
232
+ * Mount the email-OTP login flow under `/api/email/auth/*` when the gate is
233
+ * enabled. These routes are the ONLY ones the gate middleware leaves open (see
234
+ * {@link AuthGate.middleware}); everything else requires the session cookie
235
+ * they establish. No-op when auth is off.
236
+ */
237
+ private injectAuthRoutes(router: IAppRouter): void {
238
+ const gate = this.authGate;
239
+ if (!gate) return;
240
+
241
+ // `request` and `verify` take a raw email/code, not an OBO user, so they are
242
+ // NOT wrapped in `asUser` - the caller is anonymous until verified.
243
+ this.route(router, {
244
+ name: "authRequest",
245
+ method: "post",
246
+ path: "/auth/request",
247
+ handler: async (req, res) => {
248
+ const parsed = authRequestSchema.safeParse(req.body);
249
+ if (!parsed.success) {
250
+ // Even a malformed body reports success (anti-enumeration).
251
+ res.json({ ok: true });
252
+ return;
253
+ }
254
+ const result = await gate.handleRequest(parsed.data.email, this.clientIp(req));
255
+ res.json(result);
256
+ },
257
+ });
258
+
259
+ this.route(router, {
260
+ name: "authVerify",
261
+ method: "post",
262
+ path: "/auth/verify",
263
+ handler: async (req, res) => {
264
+ const parsed = authVerifySchema.safeParse(req.body);
265
+ if (!parsed.success) {
266
+ res.json({ ok: false });
267
+ return;
268
+ }
269
+ const result = await gate.handleVerify(
270
+ parsed.data.email,
271
+ parsed.data.code,
272
+ this.clientIp(req),
273
+ req.secure,
274
+ );
275
+ if (result.ok && result.token && result.cookieOptions) {
276
+ res.cookie(SESSION_COOKIE, result.token, result.cookieOptions);
277
+ }
278
+ res.json({ ok: result.ok, ...(result.retryAfter ? { retryAfter: result.retryAfter } : {}) });
279
+ },
280
+ });
281
+
282
+ this.route(router, {
283
+ name: "authLogout",
284
+ method: "post",
285
+ path: "/auth/logout",
286
+ handler: async (_req, res) => {
287
+ res.clearCookie(SESSION_COOKIE, { path: "/" });
288
+ res.json({ ok: true });
289
+ },
290
+ });
291
+
292
+ this.route(router, {
293
+ name: "authStatus",
294
+ method: "get",
295
+ path: "/auth/status",
296
+ handler: async (req, res) => {
297
+ res.json(await gate.status(req));
298
+ },
299
+ });
300
+ }
301
+
302
+ /** Client IP for rate-limiting, honoring the Apps ingress `x-forwarded-for`. */
303
+ private clientIp(req: express.Request): string {
304
+ const fwd = req.headers["x-forwarded-for"];
305
+ const first = Array.isArray(fwd) ? fwd[0] : fwd?.split(",")[0];
306
+ return (first ?? req.ip ?? "unknown").trim();
221
307
  }
222
308
 
223
309
  override exports() {
@@ -268,6 +354,72 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> implements ToolProvid
268
354
  return sendEmail(message, from ?? this.resolveSender(), signal);
269
355
  }
270
356
 
357
+ /**
358
+ * Build the email-OTP access gate when `auth.enabled`, wiring its code
359
+ * delivery through this plugin's own transport. Called from {@link setup}; a
360
+ * no-op (leaves {@link authGate} undefined) when the gate is off.
361
+ */
362
+ private setupAuthGate(): void {
363
+ const auth = resolveAuthConfig(this.config.auth);
364
+ if (!auth) return;
365
+ const gate = new AuthGate({
366
+ allow: auth.allow,
367
+ sessionTtlSeconds: auth.sessionTtlSeconds,
368
+ codeTtlSeconds: auth.codeTtlSeconds,
369
+ maxAttempts: auth.maxAttempts,
370
+ secureCookies: process.env.NODE_ENV === "production",
371
+ sendCode: (email, code) => this.sendOtpEmail(email, code),
372
+ });
373
+ this.authGate = gate;
374
+
375
+ // The gate must protect the WHOLE app, but a plugin's own `injectRoutes`
376
+ // router only covers `/api/email/*`. AppKit's `server` plugin exposes
377
+ // `addExtension(fn)` for exactly this: an app-level middleware registered
378
+ // before the server listens. Look it up by its registered name (no
379
+ // dependency on a specific server-plugin package) and mount the gate there.
380
+ // The login routes themselves are exempted by the middleware's `emailBase`
381
+ // open-prefix (`/api/email/auth`).
382
+ const server = this.context?.getPlugins().get("server") as
383
+ | { addExtension?: (fn: (app: express.Application) => void) => void }
384
+ | undefined;
385
+ if (server?.addExtension) {
386
+ server.addExtension((app) => app.use(gate.middleware(`/api/${EmailPlugin.manifest.name}`)));
387
+ logger.info("auth:enabled", {
388
+ patterns: auth.allow.length,
389
+ sessionTtlSeconds: auth.sessionTtlSeconds,
390
+ });
391
+ } else {
392
+ // No server plugin to gate through - the login routes still work, but the
393
+ // rest of the app would be ungated, so refuse to half-enable silently.
394
+ this.authGate = undefined;
395
+ logger.warn(
396
+ "auth:disabled - the `server` plugin is required to gate the app; add server() to your plugins",
397
+ );
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Deliver a one-time code to `email` through this plugin's transport. Throws
403
+ * on failure (the gate swallows it, so a delivery error never leaks whether
404
+ * the address was allow-listed).
405
+ */
406
+ private async sendOtpEmail(email: string, code: string): Promise<void> {
407
+ await this.send(
408
+ {
409
+ to: [email],
410
+ subject: `Your sign-in code: ${code}`,
411
+ body: [
412
+ `Your one-time sign-in code is:`,
413
+ ``,
414
+ `## ${code}`,
415
+ ``,
416
+ `It expires shortly. If you didn't request this, you can ignore this email.`,
417
+ ].join("\n"),
418
+ },
419
+ undefined,
420
+ );
421
+ }
422
+
271
423
  /** Run the sender-options lookup through the plugin's interceptor chain. */
272
424
  private async executeListSenders(): Promise<ExecutionResult<EmailSenders>> {
273
425
  return this.execute(async () => this.listSenders(), EMAIL_SENDERS_SETTINGS);