@dbx-tools/cli-tunnel 0.6.45 → 0.6.47
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 +167 -1
- package/bin/dbx-tools-tunnel.ts +1 -1
- package/lib/bin/dbx-tools-tunnel.js +2 -2
- package/lib/src/allowlist.d.ts +3 -3
- package/lib/src/allowlist.js +4 -4
- package/lib/src/app.d.ts +8 -0
- package/lib/src/app.js +48 -5
- package/lib/src/cli.d.ts +6 -4
- package/lib/src/cli.js +9 -7
- package/lib/src/otp.d.ts +7 -4
- package/lib/src/otp.js +8 -5
- package/lib/src/plugin.d.ts +29 -3
- package/lib/src/plugin.js +19 -15
- package/lib/src/portr.d.ts +3 -2
- package/lib/src/portr.js +4 -3
- package/lib/src/proxy.d.ts +5 -3
- package/lib/src/proxy.js +14 -11
- package/package.json +46 -18
- package/src/allowlist.ts +3 -3
- package/src/app.ts +49 -4
- package/src/cli.ts +11 -6
- package/src/otp.ts +7 -4
- package/src/plugin.ts +52 -19
- package/src/portr.ts +3 -2
- package/src/proxy.ts +13 -10
package/package.json
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"directory": "packages/cli/tunnel"
|
|
7
7
|
},
|
|
8
8
|
"bin": {
|
|
9
|
-
"dbx-tools-tunnel": "./bin/dbx-tools-tunnel.
|
|
10
|
-
"dbxt-tunnel": "./bin/dbx-tools-tunnel.
|
|
9
|
+
"dbx-tools-tunnel": "./lib/bin/dbx-tools-tunnel.js",
|
|
10
|
+
"dbxt-tunnel": "./lib/bin/dbx-tools-tunnel.js"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "projen build",
|
|
@@ -29,15 +29,16 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@clack/prompts": "^1.7.0",
|
|
31
31
|
"@databricks/appkit": "^0.43.0",
|
|
32
|
-
"@dbx-tools/appkit": "0.6.
|
|
33
|
-
"@dbx-tools/
|
|
34
|
-
"@dbx-tools/
|
|
35
|
-
"@dbx-tools/shared-
|
|
32
|
+
"@dbx-tools/appkit": "0.6.47",
|
|
33
|
+
"@dbx-tools/core": "0.6.47",
|
|
34
|
+
"@dbx-tools/email": "0.6.47",
|
|
35
|
+
"@dbx-tools/shared-core": "0.6.47",
|
|
36
|
+
"@dbx-tools/shared-email": "0.6.47",
|
|
36
37
|
"commander": "^15.0.0",
|
|
37
38
|
"http-proxy-3": "^1.23.3",
|
|
38
39
|
"jose": "^6.2.3"
|
|
39
40
|
},
|
|
40
|
-
"main": "index.
|
|
41
|
+
"main": "./lib/index.js",
|
|
41
42
|
"license": "UNLICENSED",
|
|
42
43
|
"publishConfig": {
|
|
43
44
|
"access": "public",
|
|
@@ -87,19 +88,46 @@
|
|
|
87
88
|
"./package.json": "./package.json"
|
|
88
89
|
}
|
|
89
90
|
},
|
|
90
|
-
"version": "0.6.
|
|
91
|
-
"types": "index.ts",
|
|
91
|
+
"version": "0.6.47",
|
|
92
|
+
"types": "./lib/index.d.ts",
|
|
92
93
|
"type": "module",
|
|
93
94
|
"exports": {
|
|
94
|
-
".":
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"./
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"./
|
|
95
|
+
".": {
|
|
96
|
+
"types": "./lib/index.d.ts",
|
|
97
|
+
"default": "./lib/index.js"
|
|
98
|
+
},
|
|
99
|
+
"./allowlist": {
|
|
100
|
+
"types": "./lib/src/allowlist.d.ts",
|
|
101
|
+
"default": "./lib/src/allowlist.js"
|
|
102
|
+
},
|
|
103
|
+
"./app": {
|
|
104
|
+
"types": "./lib/src/app.d.ts",
|
|
105
|
+
"default": "./lib/src/app.js"
|
|
106
|
+
},
|
|
107
|
+
"./cli": {
|
|
108
|
+
"types": "./lib/src/cli.d.ts",
|
|
109
|
+
"default": "./lib/src/cli.js"
|
|
110
|
+
},
|
|
111
|
+
"./otp": {
|
|
112
|
+
"types": "./lib/src/otp.d.ts",
|
|
113
|
+
"default": "./lib/src/otp.js"
|
|
114
|
+
},
|
|
115
|
+
"./plugin": {
|
|
116
|
+
"types": "./lib/src/plugin.d.ts",
|
|
117
|
+
"default": "./lib/src/plugin.js"
|
|
118
|
+
},
|
|
119
|
+
"./portr": {
|
|
120
|
+
"types": "./lib/src/portr.d.ts",
|
|
121
|
+
"default": "./lib/src/portr.js"
|
|
122
|
+
},
|
|
123
|
+
"./proxy": {
|
|
124
|
+
"types": "./lib/src/proxy.d.ts",
|
|
125
|
+
"default": "./lib/src/proxy.js"
|
|
126
|
+
},
|
|
127
|
+
"./rate-limit": {
|
|
128
|
+
"types": "./lib/src/rate-limit.d.ts",
|
|
129
|
+
"default": "./lib/src/rate-limit.js"
|
|
130
|
+
},
|
|
103
131
|
"./package.json": "./package.json"
|
|
104
132
|
},
|
|
105
133
|
"files": [
|
package/src/allowlist.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Each pattern in the configured list is one of three shapes, tried in order:
|
|
5
5
|
*
|
|
6
|
-
* - **domain shortcut** - `
|
|
6
|
+
* - **domain shortcut** - `example.com` or `@example.com`: matches any
|
|
7
7
|
* address whose domain equals it (case-insensitive). The leading `@` is
|
|
8
8
|
* optional and stripped.
|
|
9
|
-
* - **glob** - contains `*` or `?`, e.g. `*.
|
|
10
|
-
* `*@
|
|
9
|
+
* - **glob** - contains `*` or `?`, e.g. `*.example.com` or
|
|
10
|
+
* `*@example.com`: matched against the WHOLE address with shell-style
|
|
11
11
|
* wildcards (`*` = any run, `?` = one char).
|
|
12
12
|
* - **regex** - wrapped in slashes, `/.../ [flags]`: compiled and tested
|
|
13
13
|
* against the whole address. An invalid regex never matches (it is skipped
|
package/src/app.ts
CHANGED
|
@@ -23,15 +23,31 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { createApp as createAppNs } from "@dbx-tools/appkit";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { brand as nodeBrand } from "@dbx-tools/core";
|
|
27
|
+
import { brand as emailBrand, email, sender, transport } from "@dbx-tools/email";
|
|
28
|
+
import { log, string } from "@dbx-tools/shared-core";
|
|
28
29
|
import { authGate, type AuthGateApi, type AuthGateConfig } from "./plugin.ts";
|
|
29
30
|
|
|
30
31
|
const logger = log.logger("tunnel:app");
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A code TTL as the plain phrase the email states ("10 minutes", "45 seconds").
|
|
35
|
+
*
|
|
36
|
+
* Whole minutes read as minutes; anything else stays in seconds rather than
|
|
37
|
+
* rounding, so a 90-second TTL is not advertised as "1 minute" and a recipient is
|
|
38
|
+
* never told the code lives longer than it does.
|
|
39
|
+
*/
|
|
40
|
+
export function expiresIn(seconds: number): string {
|
|
41
|
+
return seconds >= 60 && seconds % 60 === 0
|
|
42
|
+
? string.pluralize(seconds / 60, "minute")
|
|
43
|
+
: string.pluralize(seconds, "second");
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
const { createApp } = createAppNs;
|
|
33
47
|
const { resolveSenderAddress } = sender;
|
|
34
48
|
const { getEmailRuntime, sendEmail } = transport;
|
|
49
|
+
const { emailBrandFromContext } = emailBrand;
|
|
50
|
+
const { loadBrandContext } = nodeBrand;
|
|
35
51
|
|
|
36
52
|
/**
|
|
37
53
|
* Boot the gate app and return the API the proxy calls. Throws when email is not
|
|
@@ -40,6 +56,13 @@ const { getEmailRuntime, sendEmail } = transport;
|
|
|
40
56
|
* fall back to insecure/open mode when the operator opted in.
|
|
41
57
|
*/
|
|
42
58
|
export async function startGateApp(config: AuthGateConfig): Promise<AuthGateApi> {
|
|
59
|
+
// The host app's own brand (`branding/brand.yaml` discovered from cwd) or the
|
|
60
|
+
// dbx-tools default. This is the ONE brand source for the gate: it styles the
|
|
61
|
+
// code email (accent band, font, logo) via the email plugin AND supplies the
|
|
62
|
+
// display name the copy uses, so a deployment that themes its app themes its
|
|
63
|
+
// sign-in email with it. An explicit `brandName` still wins (see below).
|
|
64
|
+
const context = await loadBrandContext();
|
|
65
|
+
|
|
43
66
|
// `sendCode` delivers the OTP through the email plugin's SHARED transport, which
|
|
44
67
|
// the `email()` plugin primes during its `setup()` (awaited by `createApp`
|
|
45
68
|
// below). Using the module-level `sendEmail` avoids a circular dependency on the
|
|
@@ -51,12 +74,21 @@ export async function startGateApp(config: AuthGateConfig): Promise<AuthGateApi>
|
|
|
51
74
|
{
|
|
52
75
|
to: [to],
|
|
53
76
|
subject: opts.subject,
|
|
77
|
+
// Deliberately the conventional one-time-code layout: the prompt line,
|
|
78
|
+
// then the bare code ALONE on the next line, then the expiry. iOS, Gmail,
|
|
79
|
+
// Outlook, and Android all detect a code from this shape and offer to
|
|
80
|
+
// autofill it, and anything more decorative is what breaks that. The code
|
|
81
|
+
// stays visible TEXT in both MIME parts (the email plugin renders the
|
|
82
|
+
// HTML and plain-text alternatives from one React Email tree), never an
|
|
83
|
+
// image, so a client that scrapes the text part still finds it.
|
|
54
84
|
body: [
|
|
55
85
|
opts.message,
|
|
56
86
|
"",
|
|
57
87
|
`## ${code}`,
|
|
58
88
|
"",
|
|
59
|
-
|
|
89
|
+
`This code expires in ${expiresIn(opts.codeTtlSeconds)}.`,
|
|
90
|
+
"",
|
|
91
|
+
"If you did not request this code, you can ignore this email.",
|
|
60
92
|
].join("\n"),
|
|
61
93
|
},
|
|
62
94
|
from,
|
|
@@ -65,7 +97,20 @@ export async function startGateApp(config: AuthGateConfig): Promise<AuthGateApi>
|
|
|
65
97
|
|
|
66
98
|
// `createApp` namespaces each plugin's exports on the handle by manifest name;
|
|
67
99
|
// `handle.authGate` is the in-process gate API the proxy drives.
|
|
68
|
-
const handle = await createApp({
|
|
100
|
+
const handle = await createApp({
|
|
101
|
+
plugins: [
|
|
102
|
+
// Brand the code email from the resolved context, the same bridge every
|
|
103
|
+
// other dbx-tools email surface uses (accent + font inlined, logo only when
|
|
104
|
+
// it is a fetchable URL - a package-export path cannot load in an inbox).
|
|
105
|
+
email({ brand: emailBrandFromContext(context) }),
|
|
106
|
+
// `brandName` falls back to the resolved context's display name, so the
|
|
107
|
+
// email copy names the app rather than a generic placeholder. Spelled as an
|
|
108
|
+
// explicit `??` rather than a key before `...config`: commander sets
|
|
109
|
+
// `brandName` on the options object whether or not the flag was passed, so
|
|
110
|
+
// spreading it would clobber the context name with `undefined`.
|
|
111
|
+
authGate({ ...config, brandName: config.brandName ?? context.name, sendCode }),
|
|
112
|
+
],
|
|
113
|
+
});
|
|
69
114
|
|
|
70
115
|
// Fail fast: the gate needs SMTP to email codes. `getEmailRuntime()` resolves to
|
|
71
116
|
// `mode: "file"` (outbox) or throws when no SMTP creds are configured - neither
|
package/src/cli.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `dbx-tools-tunnel` / `dbxt-tunnel` CLI.
|
|
3
3
|
*
|
|
4
|
-
* Wraps
|
|
5
|
-
*
|
|
4
|
+
* Wraps an app's start command with a public portr tunnel and an email-OTP
|
|
5
|
+
* access gate. Everything after `--` is the REAL app start command:
|
|
6
6
|
*
|
|
7
|
-
* dbxt-tunnel --subject "Here's your OTP" --allow
|
|
7
|
+
* dbxt-tunnel --subject "Here's your OTP" --allow example.com -- bun src/server.ts
|
|
8
8
|
*
|
|
9
9
|
* Boot sequence:
|
|
10
10
|
* 1. Pick a random PRIVATE port and spawn the app command with
|
|
11
|
-
* `DATABRICKS_APP_PORT` set to it (so the app binds loopback-private).
|
|
11
|
+
* `DATABRICKS_APP_PORT` set to it (so the app binds loopback-private). That
|
|
12
|
+
* variable name is the Databricks Apps runtime contract; the gate itself is
|
|
13
|
+
* platform-neutral and honours whatever port it finds there.
|
|
12
14
|
* 2. Boot the tiny gate AppKit app (no server): inits `CacheManager` + the
|
|
13
15
|
* email transport, yields the in-process gate API.
|
|
14
16
|
* 3. Start the gate PROXY on the ORIGINAL public port, forwarding to the app.
|
|
@@ -56,7 +58,7 @@ interface TunnelOpts {
|
|
|
56
58
|
function program(): Command {
|
|
57
59
|
return new Command()
|
|
58
60
|
.name("dbx-tools-tunnel")
|
|
59
|
-
.description("Front
|
|
61
|
+
.description("Front an app with a public portr tunnel + email-OTP gate")
|
|
60
62
|
.option("--subject <text>", "Subject line for the code email (env AUTH_SUBJECT)")
|
|
61
63
|
.option(
|
|
62
64
|
"--allow <patterns>",
|
|
@@ -64,7 +66,10 @@ function program(): Command {
|
|
|
64
66
|
)
|
|
65
67
|
.option("--subdomain <name>", "portr subdomain (else derived from PUBLIC_DOMAIN)")
|
|
66
68
|
.option("--public-domain <host>", "portr <subdomain>.<server> (env PUBLIC_DOMAIN)")
|
|
67
|
-
.option(
|
|
69
|
+
.option(
|
|
70
|
+
"--brand-name <name>",
|
|
71
|
+
"Display name in the code email copy (env AUTH_BRAND_NAME; defaults to the brand context name)",
|
|
72
|
+
)
|
|
68
73
|
.option("--message <text>", "Line shown above the code in the email (env AUTH_MESSAGE)")
|
|
69
74
|
.option("--session-ttl <seconds>", "Session lifetime (env AUTH_SESSION_TTL)")
|
|
70
75
|
.option("--code-ttl <seconds>", "One-time-code lifetime (env AUTH_CODE_TTL)")
|
package/src/otp.ts
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* the hash, and the entry is deleted on success or once attempts are exhausted.
|
|
10
10
|
*
|
|
11
11
|
* The session JWT is a short-lived HS256 token (via `jose`) carrying only the
|
|
12
|
-
* email. Its signing key comes from `AUTH_JWT_SECRET`; when unset the gate
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* email. Its signing key comes from `AUTH_JWT_SECRET`; when unset the gate uses an
|
|
13
|
+
* ephemeral per-process key rather than refusing service, so an unset secret
|
|
14
|
+
* degrades to "sessions don't survive a restart", not "nobody can log in". Note
|
|
15
|
+
* what this does NOT weaken: a caller still needs a code delivered to an
|
|
16
|
+
* allow-listed address, because the signing key only validates an ALREADY-issued
|
|
17
|
+
* session. Set it in any deployment running more than one instance, or sessions
|
|
18
|
+
* minted by one will not verify on another.
|
|
16
19
|
*
|
|
17
20
|
* @module
|
|
18
21
|
*/
|
package/src/plugin.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
|
|
20
|
-
import { log, string } from "@dbx-tools/shared-core";
|
|
20
|
+
import { brand, env, log, string } from "@dbx-tools/shared-core";
|
|
21
21
|
import type { AuthStatus } from "@dbx-tools/shared-email";
|
|
22
22
|
import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
|
|
23
23
|
import { CodeStore, signSession, verifySession } from "./otp.ts";
|
|
@@ -29,11 +29,30 @@ const logger = log.logger("tunnel:auth");
|
|
|
29
29
|
export interface AuthGateConfig extends BasePluginConfig {
|
|
30
30
|
/** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env EMAIL_AUTH_ALLOW. */
|
|
31
31
|
allow?: string | string[];
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Subject line for the code email. Env AUTH_SUBJECT.
|
|
34
|
+
*
|
|
35
|
+
* Defaults to "Your verification code". The wording of the subject and
|
|
36
|
+
* {@link message} is deliberately the conventional phrasing rather than
|
|
37
|
+
* anything branded: iOS, Gmail, Outlook, and Android all detect a one-time
|
|
38
|
+
* code from this shape and offer to autofill it, and a novel phrasing is what
|
|
39
|
+
* breaks that detection.
|
|
40
|
+
*/
|
|
33
41
|
subject?: string;
|
|
34
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Display name used in the code email copy. Env AUTH_BRAND_NAME.
|
|
44
|
+
*
|
|
45
|
+
* Defaults to the brand context's `name` - the app's own `branding/brand.yaml`
|
|
46
|
+
* when it has one, else the dbx-tools default. Set this only to override the
|
|
47
|
+
* brand for this gate.
|
|
48
|
+
*/
|
|
35
49
|
brandName?: string;
|
|
36
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Line shown immediately above the code in the email. Env AUTH_MESSAGE.
|
|
52
|
+
*
|
|
53
|
+
* Keep the code on its OWN line directly after this text - that adjacency is
|
|
54
|
+
* what the platform code-detection heuristics key on.
|
|
55
|
+
*/
|
|
37
56
|
message?: string;
|
|
38
57
|
/** Session lifetime (seconds). Env AUTH_SESSION_TTL. Default 43200 (12h). */
|
|
39
58
|
sessionTtlSeconds?: number;
|
|
@@ -50,6 +69,13 @@ export interface SendCodeOptions {
|
|
|
50
69
|
subject: string;
|
|
51
70
|
brandName: string;
|
|
52
71
|
message: string;
|
|
72
|
+
/**
|
|
73
|
+
* Lifetime of the code being sent, in seconds - the RESOLVED
|
|
74
|
+
* {@link AuthGateConfig.codeTtlSeconds}, so the email can state the real
|
|
75
|
+
* expiry ("This code expires in 10 minutes") instead of a vague "shortly"
|
|
76
|
+
* that drifts from the configured TTL.
|
|
77
|
+
*/
|
|
78
|
+
codeTtlSeconds: number;
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
/** Resolved gate config with env fallbacks + defaults applied. */
|
|
@@ -64,33 +90,39 @@ export interface ResolvedAuthGateConfig {
|
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
const DEFAULTS = {
|
|
67
|
-
subject: "Your
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
subject: "Your verification code",
|
|
94
|
+
// The repo-wide brand context's display name, NOT a hardcoded product string:
|
|
95
|
+
// this name is what the recipient reads in the code email, so it has to be the
|
|
96
|
+
// same identity the rest of the app presents. A host with its own
|
|
97
|
+
// `branding/brand.yaml` overrides it by passing `brandName` (see
|
|
98
|
+
// {@link startGateApp}, which resolves the on-disk context); the shared default
|
|
99
|
+
// is the fallback when nothing is configured.
|
|
100
|
+
brandName: brand.defaultBrandContext.name,
|
|
101
|
+
message: "Your verification code is:",
|
|
70
102
|
sessionTtlSeconds: 43200,
|
|
71
103
|
codeTtlSeconds: 600,
|
|
72
104
|
maxAttempts: 5,
|
|
73
105
|
};
|
|
74
106
|
|
|
75
|
-
/** Positive finite number from a value / env string, else the fallback. */
|
|
76
|
-
function num(value: number | undefined, env: string | undefined, fallback: number): number {
|
|
77
|
-
const raw = value ?? (env ? Number(env) : undefined);
|
|
78
|
-
return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
107
|
/** Merge {@link AuthGateConfig} over env over defaults into a resolved config. */
|
|
82
108
|
export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateConfig {
|
|
83
109
|
return {
|
|
110
|
+
// Both sources are unioned rather than one overriding: a deployment-wide
|
|
111
|
+
// EMAIL_AUTH_ALLOW and a per-invocation `--allow` should both grant access.
|
|
84
112
|
allow: [...string.parseList(config.allow), ...string.parseList(process.env.EMAIL_AUTH_ALLOW)],
|
|
85
|
-
subject: config.subject
|
|
86
|
-
brandName: config.brandName
|
|
87
|
-
message: config.message
|
|
88
|
-
sessionTtlSeconds:
|
|
113
|
+
subject: env.string(config.subject, "AUTH_SUBJECT") ?? DEFAULTS.subject,
|
|
114
|
+
brandName: env.string(config.brandName, "AUTH_BRAND_NAME") ?? DEFAULTS.brandName,
|
|
115
|
+
message: env.string(config.message, "AUTH_MESSAGE") ?? DEFAULTS.message,
|
|
116
|
+
sessionTtlSeconds: env.positiveInt(
|
|
89
117
|
config.sessionTtlSeconds,
|
|
90
|
-
|
|
118
|
+
"AUTH_SESSION_TTL",
|
|
91
119
|
DEFAULTS.sessionTtlSeconds,
|
|
92
120
|
),
|
|
93
|
-
codeTtlSeconds:
|
|
121
|
+
codeTtlSeconds: env.positiveInt(
|
|
122
|
+
config.codeTtlSeconds,
|
|
123
|
+
"AUTH_CODE_TTL",
|
|
124
|
+
DEFAULTS.codeTtlSeconds,
|
|
125
|
+
),
|
|
94
126
|
maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
|
|
95
127
|
};
|
|
96
128
|
}
|
|
@@ -170,6 +202,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
170
202
|
subject: this.resolved.subject,
|
|
171
203
|
brandName: this.resolved.brandName,
|
|
172
204
|
message: this.resolved.message,
|
|
205
|
+
codeTtlSeconds: this.resolved.codeTtlSeconds,
|
|
173
206
|
});
|
|
174
207
|
} catch (error) {
|
|
175
208
|
logger.warn("failed to send OTP email", { error });
|
package/src/portr.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* portr install + config + launch for the tunnel CLI.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* portr binary and its config are placed under a writable,
|
|
4
|
+
* Some hosts (Databricks Apps among them) mount the container's `$HOME` read-only
|
|
5
|
+
* on cold start, so the portr binary and its config are placed under a writable,
|
|
6
|
+
* cwd-rooted `.home` unconditionally - it costs nothing where `$HOME` is writable.
|
|
6
7
|
* The install is idempotent (the installer skips when the on-PATH binary is
|
|
7
8
|
* current). The config is rendered from `PUBLIC_DOMAIN` (`<subdomain>.<server>`)
|
|
8
9
|
* + `PORTR_TOKEN` and points portr at the PUBLIC port (the proxy listens there).
|
package/src/proxy.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - **portr client** (same container, connects over LOOPBACK) - the public
|
|
9
9
|
* tunnel. This is what the gate protects.
|
|
10
|
-
* - **
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - **the hosting platform's front door** (Databricks Apps' control plane, or
|
|
11
|
+
* any other host reaching the `0.0.0.0` port from a NON-loopback
|
|
12
|
+
* container-network address) - passed through UNGATED, per the rule "if it's
|
|
13
|
+
* not from the portr client, let it in". The platform already authenticates
|
|
14
|
+
* that path; the gate exists for the tunnel, which nothing else protects.
|
|
13
15
|
*
|
|
14
16
|
* The distinguisher is the connection's source address: a loopback
|
|
15
17
|
* `req.socket.remoteAddress` is the portr client; anything else is the platform.
|
|
@@ -126,10 +128,11 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
|
|
|
126
128
|
};
|
|
127
129
|
|
|
128
130
|
/**
|
|
129
|
-
* Present an OTP-authenticated caller to the app the SAME way
|
|
130
|
-
*
|
|
131
|
-
* address (AppKit reads `x-forwarded-user` for the OBO user id)
|
|
132
|
-
*
|
|
131
|
+
* Present an OTP-authenticated caller to the app the SAME way a platform front
|
|
132
|
+
* door does: set `x-forwarded-user` / `x-forwarded-email` to the verified
|
|
133
|
+
* address (AppKit reads `x-forwarded-user` for the OBO user id), so the app
|
|
134
|
+
* needs no gate-specific code path. Any inbound copies are overwritten so a
|
|
135
|
+
* client can't spoof identity through the gate.
|
|
133
136
|
*/
|
|
134
137
|
const injectIdentity = (req: IncomingMessage, email: string): void => {
|
|
135
138
|
req.headers["x-forwarded-user"] = email;
|
|
@@ -139,8 +142,8 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
|
|
|
139
142
|
const server = createServer(async (req, res) => {
|
|
140
143
|
const path = (req.url ?? "/").split("?")[0]!;
|
|
141
144
|
|
|
142
|
-
// Insecure/open mode (no gate) OR non-loopback traffic (the
|
|
143
|
-
// door / control plane): forward ungated.
|
|
145
|
+
// Insecure/open mode (no gate) OR non-loopback traffic (the hosting
|
|
146
|
+
// platform's front door / control plane): forward ungated.
|
|
144
147
|
if (!gate || !isLoopback(req.socket.remoteAddress)) {
|
|
145
148
|
proxy.web(req, res);
|
|
146
149
|
return;
|
|
@@ -198,7 +201,7 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
|
|
|
198
201
|
const token = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
|
|
199
202
|
const email = await gate.session(token);
|
|
200
203
|
if (email) {
|
|
201
|
-
// Present the OTP user like
|
|
204
|
+
// Present the OTP user like a platform front door, and drop the gate
|
|
202
205
|
// cookie so the app sees a clean, front-door-shaped request.
|
|
203
206
|
injectIdentity(req, email);
|
|
204
207
|
stripSessionCookie(req);
|