diff --git a/server/src/config/auth-token.test.ts b/server/src/config/auth-token.test.ts index a0cb184a..c402b897 100644 --- a/server/src/config/auth-token.test.ts +++ b/server/src/config/auth-token.test.ts @@ -9,9 +9,10 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { assertValidAuthPassword, + authTokenPrintCommand, isShortAuthPassword, defaultAuthTokenPath, loadOrCreateAuthToken, @@ -78,6 +79,143 @@ describe("generated authentication token", () => { ); }); + test("prints the token without revealing the account name", () => { + const home = join("C:", "Users", "tester"); + const appData = join(home, "AppData", "Roaming"); + expect( + authTokenPrintCommand( + join(appData, "roamgate", "auth-token"), + home, + "win32", + appData, + ), + ).toBe('Get-Content "$env:APPDATA\\roamgate\\auth-token"'); + expect( + authTokenPrintCommand( + join(home, ".config", "roamgate", "auth-token"), + home, + "win32", + undefined, + ), + ).toBe('Get-Content "$HOME\\.config\\roamgate\\auth-token"'); + expect( + authTokenPrintCommand( + "/home/tester/.config/roamgate/auth-token", + "/home/tester", + "linux", + ), + ).toBe("cat ~/.config/roamgate/auth-token"); + expect( + authTokenPrintCommand( + "/Users/tester/.config/roamgate/auth-token", + "/Users/tester", + "darwin", + ), + ).toBe("cat ~/.config/roamgate/auth-token"); + }); + + test("prints the default token command with trailing HOME separators", () => { + const home = tempHome(); + for (const suffix of [sep, sep.repeat(2)]) { + const homeWithSeparator = home + suffix; + const path = defaultAuthTokenPath( + homeWithSeparator, + process.platform, + join(home, "AppData", "Roaming"), + ); + expect( + authTokenPrintCommand(path, homeWithSeparator, process.platform, ""), + ).toBe( + process.platform === "win32" + ? 'Get-Content "$HOME\\AppData\\Roaming\\roamgate\\auth-token"' + : "cat ~/.config/roamgate/auth-token", + ); + } + }); + + test("handles filesystem-root HOME without losing the boundary", () => { + expect( + authTokenPrintCommand("/.config/roamgate/auth-token", "/", "linux"), + ).toBe("cat ~/.config/roamgate/auth-token"); + expect( + authTokenPrintCommand("relative/auth-token", "/", "linux"), + ).toBeUndefined(); + }); + + // These are command-string tests, not execution in a Windows shell. + test("prints Windows commands with trailing APPDATA or HOME separators", () => { + const home = "C:\\Users\\tester"; + const appData = `${home}\\AppData\\Roaming`; + for (const suffix of ["\\", "\\\\", "/"]) { + expect( + authTokenPrintCommand( + `${appData}\\roamgate\\auth-token`, + home, + "win32", + appData + suffix, + ), + ).toBe('Get-Content "$env:APPDATA\\roamgate\\auth-token"'); + expect( + authTokenPrintCommand( + `${home}\\.config\\roamgate\\auth-token`, + home + suffix, + "win32", + "", + ), + ).toBe('Get-Content "$HOME\\.config\\roamgate\\auth-token"'); + expect( + authTokenPrintCommand( + `${appData}Extra\\auth-token`, + "C:\\OtherUser", + "win32", + appData + suffix, + ), + ).toBeUndefined(); + } + }); + + test("keeps adjacent prefixes and unsafe shell paths omitted", () => { + expect( + authTokenPrintCommand( + "/home/tester-other/auth-token", + "/home/tester/", + "linux", + ), + ).toBeUndefined(); + for (const name of ["token name", "token;echo", "token$HOME"]) { + expect( + authTokenPrintCommand(`/home/tester/${name}`, "/home/tester/", "linux"), + ).toBeUndefined(); + } + for (const name of ['token"', "token`", "token$HOME"]) { + expect( + authTokenPrintCommand( + `C:\\Users\\tester\\${name}`, + "C:\\Users\\tester\\", + "win32", + "", + ), + ).toBeUndefined(); + } + }); + + test("omits the token command outside the home directory", () => { + expect( + authTokenPrintCommand( + "/srv/roamgate/auth-token", + "/home/tester", + "linux", + ), + ).toBeUndefined(); + expect( + authTokenPrintCommand( + "/home/testerx/auth-token", + "/home/tester", + "linux", + ), + ).toBeUndefined(); + }); + test("creates a persistent random token with private permissions", () => { const path = tempAuthTokenPath(); const first = loadOrCreateAuthToken(path); diff --git a/server/src/config/auth-token.ts b/server/src/config/auth-token.ts index c5993ecb..0ec79945 100644 --- a/server/src/config/auth-token.ts +++ b/server/src/config/auth-token.ts @@ -48,6 +48,39 @@ export function defaultAuthTokenPath( return defaultDataFile("auth-token", homeDir, platform, appDataDir); } +function pathUnder(path: string, root: string | undefined): string | null { + if (!root) return null; + // Keep the separator boundary check even when the root already ends in one. + const prefix = root.replace(/[\\/]+$/, ""); + if (!path.startsWith(prefix)) return null; + const rest = path.slice(prefix.length); + return /^[\\/]/.test(rest) ? rest.slice(1) : null; +} + +/** + * A host shell command that prints the generated token, for the login page. + * It names the file relative to the home or APPDATA directory so the + * unauthenticated page never reveals the account name. + */ +export function authTokenPrintCommand( + path: string, + homeDir = homedir(), + platform = process.platform, + appDataDir = process.env.APPDATA, +): string | undefined { + if (platform === "win32") { + const inAppData = pathUnder(path, appDataDir); + const inHome = inAppData === null ? pathUnder(path, homeDir) : null; + const rest = inAppData ?? inHome; + if (rest === null || /["`$]/.test(rest)) return undefined; + const root = inAppData === null ? "$HOME" : "$env:APPDATA"; + return `Get-Content "${root}\\${rest.replaceAll("/", "\\")}"`; + } + const rest = pathUnder(path, homeDir); + if (rest === null || !/^[\w./-]+$/.test(rest)) return undefined; + return `cat ~/${rest}`; +} + export function readAuthToken(path: string): string { const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); try { diff --git a/server/src/http/auth.ts b/server/src/http/auth.ts index 11e3cc4a..cfb20824 100644 --- a/server/src/http/auth.ts +++ b/server/src/http/auth.ts @@ -48,6 +48,8 @@ export function createAuthHandlers(args: { sessionSecret: string; pin?: string; urlLoginToken?: string; + /** Host command that prints the generated token, shown on the login page. */ + tokenCommand?: string; secureCookies?: boolean; }) { const authRequired = args.authRequired ?? true; @@ -366,7 +368,7 @@ export function createAuthHandlers(args: { headers: { location: "/", "cache-control": "no-store" }, }); } - return new Response(renderLoginPage(Boolean(args.pin)), { + return new Response(renderLoginPage(Boolean(args.pin), args.tokenCommand), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", diff --git a/server/src/http/login-page-pin.test.ts b/server/src/http/login-page-pin.test.ts index 8db306bf..b717e544 100644 --- a/server/src/http/login-page-pin.test.ts +++ b/server/src/http/login-page-pin.test.ts @@ -3,7 +3,11 @@ import { createContext, runInContext } from "node:vm"; import { type HTMLButtonElement, Window } from "happy-dom"; import { LOGIN_HTML, renderLoginPage } from "./login-page"; -function loginPage(pinEnabled = true) { +function loginPage( + pinEnabled = true, + tokenCommand?: string, + globals: Record = {}, +) { const window = new Window({ settings: { disableJavaScriptEvaluation: true, @@ -12,7 +16,7 @@ function loginPage(pinEnabled = true) { }, }); const document = window.document; - document.write(renderLoginPage(pinEnabled)); + document.write(renderLoginPage(pinEnabled, tokenCommand)); const calls: { url: string; init: RequestInit }[] = []; const responses: (() => Promise)[] = []; const location = { @@ -23,6 +27,7 @@ function loginPage(pinEnabled = true) { }, }; const context = createContext({ + ...globals, document, location, fetch: (url: string, init: RequestInit) => { @@ -363,3 +368,83 @@ describe("optional PIN login page", () => { expect(page.location.replacements).toHaveLength(1); }); }); + +describe("login page token help", () => { + test("only token logins explain where to find the token", () => { + const command = 'Get-Content "$env:APPDATA\\roamgate\\auth-token"'; + expect(LOGIN_HTML).not.toContain("token-cmd"); + for (const pinEnabled of [false, true]) { + const page = loginPage(pinEnabled, command); + expect(page.document.querySelector("#token-cmd")?.textContent).toBe( + command, + ); + const help = page.document.querySelector("details")!; + expect(help.open).toBe(false); + expect(help.querySelector("a")?.getAttribute("href")).toEndWith( + "docs/DEPLOYMENT.md#basic-runtime-configuration", + ); + expect(help.hidden).toBe(pinEnabled); + if (pinEnabled) { + page.forceSwitch(); + expect(help.hidden).toBe(false); + page.forceSwitch(); + expect(help.hidden).toBe(true); + } + } + }); + + test.each([false, true])( + "phone help distinguishes the user service from foreground instances (PIN: %s)", + (pinEnabled) => { + const page = loginPage(pinEnabled, "cat ~/.config/roamgate/auth-token"); + if (pinEnabled) page.forceSwitch(); + const help = page.document.querySelector("#help")!; + expect(help.hasAttribute("hidden")).toBe(false); + const phoneHelp = [...help.querySelectorAll("p")].find((p) => + p.textContent.includes("On a phone?"), + )!; + expect(phoneHelp.querySelector("code")?.textContent).toBe( + "roamgate url --qr", + ); + expect(phoneHelp.textContent).toContain( + "For an installed Roamgate user service", + ); + expect(phoneHelp.textContent).toContain("uses the service configuration"); + expect(phoneHelp.textContent).toContain( + "For a foreground instance, keep using this instance's address", + ); + expect(phoneHelp.textContent).toContain( + "read its token on the host with the command above", + ); + }, + ); + + test("escapes the command", () => { + const html = renderLoginPage(false, 'cat ~/&"x"'); + expect(html).toContain("cat ~/<b>&"x""); + expect(html).not.toContain(""); + }); + + test("copies the command only in a secure context", async () => { + expect( + loginPage( + false, + "cat ~/.config/roamgate/auth-token", + ).document.querySelector("#copy")?.hidden, + ).toBe(true); + const copied: string[] = []; + const page = loginPage(false, "cat ~/.config/roamgate/auth-token", { + isSecureContext: true, + navigator: { + clipboard: { writeText: async (text: string) => copied.push(text) }, + }, + setTimeout, + clearTimeout, + }); + const copy = page.document.querySelector("#copy")!; + expect(copy.hidden).toBe(false); + await (copy.onclick as unknown as () => Promise)(); + expect(copied).toEqual(["cat ~/.config/roamgate/auth-token"]); + expect(copy.textContent).toBe("Copied"); + }); +}); diff --git a/server/src/http/login-page.ts b/server/src/http/login-page.ts index 5c431006..699c874b 100644 --- a/server/src/http/login-page.ts +++ b/server/src/http/login-page.ts @@ -1,4 +1,15 @@ -export function renderLoginPage(pinEnabled: boolean): string { +// Covers ROAMGATE_PASSWORD and the PIN subsection that follows it. +const LOGIN_OPTIONS_URL = + "https://github.com/powerfooI/roamgate/blob/main/docs/DEPLOYMENT.md#basic-runtime-configuration"; + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); +} + +export function renderLoginPage( + pinEnabled: boolean, + tokenCommand?: string, +): string { return ` @@ -41,7 +52,23 @@ export function renderLoginPage(pinEnabled: boolean): string { .submit:disabled{opacity:.65;cursor:wait} .err{color:var(--error);font-size:13px;line-height:1.5;min-height:24px;margin:10px 0 6px} ${pinEnabled ? ".method{display:block;width:100%;min-height:44px;margin-top:12px;padding:8px;border:0;border-radius:7px;background:transparent;color:var(--accent);font-size:13px}.method:disabled{opacity:.65;cursor:wait}\n " : ""}.note{margin-top:24px;text-align:center;font-size:12px} - footer{margin-top:24px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6} + ${ + tokenCommand + ? `.help{margin-top:8px;font-size:13px} + .help summary{width:fit-content;margin:0 auto;padding:12px 8px;border-radius:7px;color:var(--accent);cursor:pointer;list-style:none} + .help summary::-webkit-details-marker{display:none} + .help-body{margin-top:4px;padding:16px;border:1px solid var(--border);border-radius:12px;background:var(--input)} + .help-body p{font-size:13px;line-height:1.5} + .cmd{display:flex;align-items:center;gap:8px;margin:8px 0 14px;padding:4px 4px 4px 12px;border:1px solid var(--border);border-radius:8px;background:var(--panel)} + code{font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere} + .cmd code{flex:1;padding:6px 0;-webkit-user-select:all;user-select:all} + .copy{flex:none;min-width:56px;min-height:32px;padding:0 10px;border:0;border-radius:6px;background:transparent;color:var(--accent);font-size:12px;font-weight:600} + .copy:hover{background:var(--input)} + .options{margin-top:14px;padding-top:14px;border-top:1px solid var(--border)} + .options a{color:var(--accent);text-underline-offset:2px} + ` + : "" + }footer{margin-top:24px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6} @media(prefers-color-scheme:light){ :root{background:#f5f5f3;color:#252629;color-scheme:light;--panel:#fff;--border:#dcdde0; --muted:#64666d;--input:#fafafa;--accent:#315fb4;--error:#b42332} @@ -67,12 +94,25 @@ export function renderLoginPage(pinEnabled: boolean): string { ${pinEnabled ? ' \n ' : ""}

${pinEnabled ? "Use the 6-12 digit PIN configured on your server." : "Use the password or token configured on your server."}

- + ${ + tokenCommand + ? `
+ Where do I find my token? +
+

On the computer running Roamgate, run this to print it:

+
${escapeHtml(tokenCommand)}
+

On a phone? For an installed Roamgate user service, run roamgate url --qr on that computer and scan the code to log in. This uses the service configuration. For a foreground instance, keep using this instance's address and read its token on the host with the command above.

+

Prefer a password or PIN you can remember? See login options

+
+
+ ` + : "" + }
Your workspace, wherever you are.
`; @@ -108,7 +148,7 @@ const PASSWORD_LOGIN_SCRIPT = ` const form=document.getElementById('login'),pw= const PIN_LOGIN_SCRIPT = ` const form=document.getElementById('login'),pw=document.getElementById('pw'),btn=document.getElementById('btn'),err=document.getElementById('err'),reveal=document.getElementById('reveal'); - const method=document.getElementById('method'),label=document.getElementById('credential-label'),note=document.getElementById('credential-note'); + const method=document.getElementById('method'),label=document.getElementById('credential-label'),note=document.getElementById('credential-note'),help=document.getElementById('help'); let pinMode=true,submitting=false; const hideSecret=()=>{ pw.type='password'; @@ -138,6 +178,7 @@ const PIN_LOGIN_SCRIPT = ` label.textContent=pinMode?'PIN':'Password or token'; note.textContent=pinMode?'Use the 6-12 digit PIN configured on your server.':'Use the password or token configured on your server.'; method.textContent=pinMode?'Use password or token instead':'Use PIN instead'; + if(help)help.hidden=pinMode; pw.focus(); }; reveal.onclick=()=>{ @@ -189,4 +230,18 @@ const PIN_LOGIN_SCRIPT = ` }; `; +// The clipboard needs a secure context, which a plain LAN URL is not. +const COPY_COMMAND_SCRIPT = ` + const copy=document.getElementById('copy'); + if(globalThis.isSecureContext&&globalThis.navigator?.clipboard){ + let copyTimer; + copy.hidden=false; + copy.onclick=async()=>{ + try{await navigator.clipboard.writeText(document.getElementById('token-cmd').textContent);}catch{return;} + copy.textContent='Copied'; + clearTimeout(copyTimer); + copyTimer=setTimeout(()=>{copy.textContent='Copy';},1500); + }; + }`; + export const LOGIN_HTML = renderLoginPage(false); diff --git a/server/src/index.ts b/server/src/index.ts index 587447a6..62876b82 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,6 @@ import { loadOrCreateSessionSecret } from "./config/session-secret"; import { + authTokenPrintCommand, isShortAuthPassword, RECOMMENDED_PASSWORD_LENGTH, } from "./config/auth-token"; @@ -190,6 +191,9 @@ const { pin: config.pin, sessionSecret, urlLoginToken: config.generatedAuthToken, + tokenCommand: + config.generatedAuthTokenPath && + authTokenPrintCommand(config.generatedAuthTokenPath), secureCookies: Boolean(config.tls), });