Skip to content

Commit 60c0cbf

Browse files
ralyodioclaude
andauthored
fix(pwa): return the caller's own host in the CLI device-flow URLs (#105)
`logicsrc login --device` told users to open https://logicsrc-credentials-production.up.railway.app/cli/device even when they had reached the app on the real domain. /cli/device/code built verification_uri from `config.origin`, which is a single fixed value read from $PUBLIC_ORIGIN, so the response was wrong for every hostname except the one that variable happened to name. Derive the origin from the request instead: whatever host the CLI called is the host it gets sent back to. Express honours X-Forwarded-Proto/Host here because server.mjs sets `trust proxy` behind Railway's TLS terminator. Deliberately scoped to the two device-flow URLs. The WebAuthn expectedOrigin in passkey.mjs stays pinned to config.origin — validating a signature against a host the caller supplied would defeat the check. Note this fixes which URL is *printed*; the host still has to route to this service for the link to load. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0510d86 commit 60c0cbf

3 files changed

Lines changed: 83 additions & 2 deletions

File tree

apps/pwa/src/lib/origin.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Which origin to hand back to a caller.
2+
//
3+
// `config.origin` comes from $PUBLIC_ORIGIN and is a single fixed value, so any
4+
// response that echoes it is wrong the moment the app is reachable on more than
5+
// one hostname — that is how `logicsrc login` ended up printing a generated
6+
// Railway hostname to users on the real domain. For URLs we hand back to the
7+
// caller, derive the origin from the request instead: whatever host the client
8+
// reached us on is the host it should be sent back to.
9+
//
10+
// Express honours X-Forwarded-Proto/X-Forwarded-Host here because server.mjs
11+
// sets `trust proxy` behind Railway's TLS terminator.
12+
//
13+
// NOT for security decisions. The WebAuthn `expectedOrigin` in passkey.mjs must
14+
// stay pinned to config.origin — validating a signature against a host the
15+
// caller supplied would defeat the check.
16+
17+
/**
18+
* The origin this request arrived on (`https://logicsrc.com`), falling back to
19+
* the configured origin when there is no Host header (HTTP/1.0, direct socket).
20+
*
21+
* @param {{ protocol?: string, get?: (h: string) => string | undefined, headers?: Record<string, unknown> }} req
22+
* @param {string} fallback - config.origin
23+
* @returns {string} origin with no trailing slash
24+
*/
25+
export function requestOrigin(req, fallback) {
26+
const host = req?.get?.("host") || req?.headers?.host;
27+
if (!host) return String(fallback || "").replace(/\/+$/, "");
28+
const protocol = req?.protocol || "https";
29+
return `${protocol}://${host}`.replace(/\/+$/, "");
30+
}

apps/pwa/src/routes/cli.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { token, sha256 } from "../lib/crypto.mjs";
1717
import { page, footer, appBar, esc } from "../lib/html.mjs";
1818
import { requireAuth, csrfInput } from "../lib/session.mjs";
1919
import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs";
20+
import { requestOrigin } from "../lib/origin.mjs";
2021
import { config } from "../config.mjs";
2122

2223
export const cliRouter = Router();
@@ -127,11 +128,14 @@ cliRouter.post("/cli/device/code", async (req, res) => {
127128
`INSERT INTO cli_device_codes (device_code_hash,user_code,name,status,created_at,expires_at) VALUES (?,?,?,'pending',?,?)`,
128129
[sha256(deviceCode), code, name, now, now + DEVICE_TTL_MS]
129130
);
131+
// Echo back the host the CLI actually called us on, not $PUBLIC_ORIGIN — the
132+
// user is told to open this link, and it has to be a domain they can reach.
133+
const origin = requestOrigin(req, config.origin);
130134
res.json({
131135
device_code: deviceCode,
132136
user_code: code,
133-
verification_uri: `${config.origin}/cli/device`,
134-
verification_uri_complete: `${config.origin}/cli/device?user_code=${encodeURIComponent(code)}`,
137+
verification_uri: `${origin}/cli/device`,
138+
verification_uri_complete: `${origin}/cli/device?user_code=${encodeURIComponent(code)}`,
135139
expires_in: Math.floor(DEVICE_TTL_MS / 1000),
136140
interval: DEVICE_POLL_SECONDS
137141
});

apps/pwa/test/origin.test.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// `logicsrc login --device` printed a generated Railway hostname to users on the
2+
// real domain, because /cli/device/code echoed $PUBLIC_ORIGIN instead of the host
3+
// the CLI had just called. These pin the replacement behaviour.
4+
import assert from "node:assert/strict";
5+
import test from "node:test";
6+
7+
import { requestOrigin } from "../src/lib/origin.mjs";
8+
9+
/** A minimal stand-in for the Express request surface requestOrigin touches. */
10+
const req = (host, protocol = "https") => ({
11+
protocol,
12+
headers: { host },
13+
get: (h) => (h.toLowerCase() === "host" ? host : undefined),
14+
});
15+
16+
const FALLBACK = "https://logicsrc-credentials-production.up.railway.app";
17+
18+
test("uses the host the caller actually reached", () => {
19+
assert.equal(requestOrigin(req("logicsrc.com"), FALLBACK), "https://logicsrc.com");
20+
// The same deployment answering on its Railway hostname still self-describes
21+
// correctly — this is not a hardcode swap, it follows the request.
22+
assert.equal(
23+
requestOrigin(req("logicsrc-credentials-production.up.railway.app"), FALLBACK),
24+
FALLBACK,
25+
);
26+
});
27+
28+
test("keeps the forwarded protocol and any explicit port", () => {
29+
assert.equal(requestOrigin(req("localhost:8080", "http"), FALLBACK), "http://localhost:8080");
30+
});
31+
32+
test("falls back to the configured origin when there is no Host header", () => {
33+
assert.equal(requestOrigin({ protocol: "https" }, FALLBACK), FALLBACK);
34+
assert.equal(requestOrigin({}, `${FALLBACK}/`), FALLBACK, "trailing slash is trimmed");
35+
});
36+
37+
test("reads the header directly when req.get is unavailable", () => {
38+
// Some middleware stacks (and our own tests) pass a bare object.
39+
assert.equal(
40+
requestOrigin({ protocol: "https", headers: { host: "logicsrc.com" } }, FALLBACK),
41+
"https://logicsrc.com",
42+
);
43+
});
44+
45+
test("defaults to https when the request carries no protocol", () => {
46+
assert.equal(requestOrigin({ headers: { host: "logicsrc.com" } }, FALLBACK), "https://logicsrc.com");
47+
});

0 commit comments

Comments
 (0)