fix(pwa): return the caller's own host in the CLI device-flow URLs (#105)
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

`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>
This commit is contained in:
Anthony Ettinger 2026-07-30 09:19:37 -07:00 committed by GitHub
parent 0510d86f85
commit 60c0cbfbce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 83 additions and 2 deletions

View file

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

View file

@ -17,6 +17,7 @@ import { token, sha256 } from "../lib/crypto.mjs";
import { page, footer, appBar, esc } from "../lib/html.mjs";
import { requireAuth, csrfInput } from "../lib/session.mjs";
import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs";
import { requestOrigin } from "../lib/origin.mjs";
import { config } from "../config.mjs";
export const cliRouter = Router();
@ -127,11 +128,14 @@ cliRouter.post("/cli/device/code", async (req, res) => {
`INSERT INTO cli_device_codes (device_code_hash,user_code,name,status,created_at,expires_at) VALUES (?,?,?,'pending',?,?)`,
[sha256(deviceCode), code, name, now, now + DEVICE_TTL_MS]
);
// Echo back the host the CLI actually called us on, not $PUBLIC_ORIGIN — the
// user is told to open this link, and it has to be a domain they can reach.
const origin = requestOrigin(req, config.origin);
res.json({
device_code: deviceCode,
user_code: code,
verification_uri: `${config.origin}/cli/device`,
verification_uri_complete: `${config.origin}/cli/device?user_code=${encodeURIComponent(code)}`,
verification_uri: `${origin}/cli/device`,
verification_uri_complete: `${origin}/cli/device?user_code=${encodeURIComponent(code)}`,
expires_in: Math.floor(DEVICE_TTL_MS / 1000),
interval: DEVICE_POLL_SECONDS
});