diff --git a/public/app.js b/public/app.js index 93643cd..9c5a2d2 100644 --- a/public/app.js +++ b/public/app.js @@ -609,6 +609,7 @@ dutyRunNow?.addEventListener("click", () => { runDutyNow(); }); +consumeRedirectedAccessToken(); initializeApp(); document.addEventListener("hotness:programs-changed", refreshPrograms); @@ -617,6 +618,17 @@ async function initializeApp() { startApp(); } +function consumeRedirectedAccessToken() { + const params = new URLSearchParams(window.location.search); + const token = params.get("access_token"); + if (!token) return; + localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, token); + params.delete("access_token"); + const search = params.toString(); + const cleanUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`; + history.replaceState(null, "", cleanUrl || "/"); +} + function startApp() { if (appStarted) return; appStarted = true; diff --git a/public/mobile.js b/public/mobile.js index fec8d81..c446a5a 100644 --- a/public/mobile.js +++ b/public/mobile.js @@ -175,6 +175,7 @@ window.addEventListener("appinstalled", () => { updateInstallPrompt("installed"); }); +consumeRedirectedAccessToken(); initializeApp(); async function initializeApp() { @@ -182,6 +183,17 @@ async function initializeApp() { startApp(); } +function consumeRedirectedAccessToken() { + const params = new URLSearchParams(window.location.search); + const token = params.get("access_token"); + if (!token) return; + localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, token); + params.delete("access_token"); + const search = params.toString(); + const cleanUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`; + history.replaceState(null, "", cleanUrl || "/"); +} + async function startApp() { if (appStarted) return; appStarted = true; diff --git a/src/server.js b/src/server.js index 078ae65..e9b3ff5 100644 --- a/src/server.js +++ b/src/server.js @@ -80,7 +80,7 @@ const server = http.createServer(async (request, response) => { const body = await readFormBody(request); if (!ACCESS_PASSWORD || timingSafeEqualText(String(body.password || ""), ACCESS_PASSWORD)) { if (ACCESS_PASSWORD) response.setHeader("set-cookie", cookieHeader(ACCESS_TOKEN)); - return sendRedirect(response, body.next || "/"); + return sendRedirect(response, buildAuthRedirectLocation(body.next || "/", ACCESS_TOKEN)); } return sendRedirect(response, "/?auth_error=1"); } @@ -909,6 +909,14 @@ function cookieHeader(token) { return `hotness_auth=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}`; } +function buildAuthRedirectLocation(location, token) { + const safeLocation = String(location || "/").startsWith("/") ? String(location || "/") : "/"; + if (!token) return safeLocation; + const redirectUrl = new URL(safeLocation, "http://video-hotness.local"); + redirectUrl.searchParams.set("access_token", token); + return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`; +} + function timingSafeEqualText(left, right) { const leftBuffer = Buffer.from(String(left || "")); const rightBuffer = Buffer.from(String(right || "")); diff --git a/test/access-password.test.js b/test/access-password.test.js index 8d4aaeb..6faf490 100644 --- a/test/access-password.test.js +++ b/test/access-password.test.js @@ -42,6 +42,14 @@ test("desktop login form is not blocked by JavaScript", () => { assert.doesNotMatch(desktopJs, /async function submitAccessPassword/); }); +test("desktop can finish login from a redirected access token", () => { + assert.match(server, /buildAuthRedirectLocation/); + assert.match(server, /access_token/); + assert.match(desktopJs, /consumeRedirectedAccessToken/); + assert.match(desktopJs, /URLSearchParams/); + assert.match(desktopJs, /history\.replaceState/); +}); + test("mobile page has the same password gate for cloud use", () => { assert.match(mobileHtml, /id="auth-gate"/); assert.match(mobileHtml, /action="\/auth\/login"/); @@ -61,6 +69,12 @@ test("mobile login form is not blocked by JavaScript", () => { assert.doesNotMatch(mobileJs, /async function submitAccessPassword/); }); +test("mobile can finish login from a redirected access token", () => { + assert.match(mobileJs, /consumeRedirectedAccessToken/); + assert.match(mobileJs, /URLSearchParams/); + assert.match(mobileJs, /history\.replaceState/); +}); + test("ranking radar requests respect the shared cloud login token", () => { assert.match(rankingsJs, /HOTNESS_AUTH_TOKEN_KEY/); assert.match(rankingsJs, /authHeaders/);