diff --git a/src/server/templates/form.js b/src/server/templates/form.js index f785e01..c108d3a 100644 --- a/src/server/templates/form.js +++ b/src/server/templates/form.js @@ -29,6 +29,7 @@ let countdownTimer = null; let mealsListDelegationBound = false; const GOOGLE_AUTH_MAX_ATTEMPTS = 200; +const GOOGLE_SESSION_KEY = "seahaven.meals.googleIdToken"; window.googleCredential = null; @@ -95,6 +96,68 @@ function waitForGoogleAuth(attempt = 0) { } } +function decodeJwtPayload(token) { + if (!token || typeof token !== "string") return null; + const parts = token.split("."); + if (parts.length < 2) return null; + try { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const json = new TextDecoder().decode( + Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)), + ); + return JSON.parse(json); + } catch (e) { + return null; + } +} + +function googleTokenIsUsable(token) { + const payload = decodeJwtPayload(token); + if (!payload || !payload.email) return false; + if (typeof payload.exp === "number" && payload.exp * 1000 <= Date.now()) { + return false; + } + if (GOOGLE_CLIENT_ID && payload.aud && payload.aud !== GOOGLE_CLIENT_ID) { + return false; + } + return true; +} + +function readStoredGoogleToken() { + try { + return sessionStorage.getItem(GOOGLE_SESSION_KEY); + } catch (e) { + return null; + } +} + +function persistGoogleSession(token) { + try { + if (googleTokenIsUsable(token)) { + sessionStorage.setItem(GOOGLE_SESSION_KEY, token); + } else { + sessionStorage.removeItem(GOOGLE_SESSION_KEY); + } + } catch (e) {} +} + +function clearGoogleSession() { + try { + sessionStorage.removeItem(GOOGLE_SESSION_KEY); + } catch (e) {} +} + +function restoreGoogleSession() { + const token = readStoredGoogleToken(); + if (!googleTokenIsUsable(token)) { + if (token) clearGoogleSession(); + return false; + } + handleCredentialResponse({ credential: token }); + return true; +} + function initGoogleAuth() { google.accounts.id.initialize({ client_id: GOOGLE_CLIENT_ID, @@ -107,17 +170,19 @@ function initGoogleAuth() { text: "signin_with", width: 300, }); + if (restoreGoogleSession()) return; + try { + google.accounts.id.prompt(); + } catch (e) {} } function handleCredentialResponse(response) { - googleCredential = response.credential; + const token = response && response.credential; + const payload = decodeJwtPayload(token); + if (!payload || !payload.email) return; + googleCredential = token; syncGoogleCredential(); - const b64 = response.credential - .split(".")[1] - .replace(/-/g, "+") - .replace(/_/g, "/"); - const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); - const payload = JSON.parse(new TextDecoder().decode(payloadBytes)); + persistGoogleSession(token); googleUser = { name: payload.name, email: payload.email }; document.getElementById("auth-overlay").classList.add("is-hidden"); @@ -149,6 +214,7 @@ function signOut() { googleCredential = null; googleUser = null; syncGoogleCredential(); + clearGoogleSession(); if ( CONFIG.authMode === "google" && typeof google !== "undefined" && diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py index 9bcadbf..f54477b 100644 --- a/tests/test_generate_form.py +++ b/tests/test_generate_form.py @@ -75,18 +75,20 @@ def _render_for_browser(*, google: bool = False) -> str: return html -def _google_credential() -> str: - payload = json.dumps( - { - "name": "Test Admin", - "email": "test-admin@example.com", - "picture": ( - "data:image/svg+xml," - "" - ), - } - ).encode() - encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=") +def _google_credential(extra: dict | None = None) -> str: + payload_obj = { + "name": "Test Admin", + "email": "test-admin@example.com", + "picture": ( + "data:image/svg+xml," + "" + ), + } + if extra: + payload_obj.update(extra) + encoded = ( + base64.urlsafe_b64encode(json.dumps(payload_obj).encode()).decode().rstrip("=") + ) return f"e30.{encoded}.signature" @@ -410,6 +412,7 @@ class TestGenerateFormPlaywright: id: { initialize() {}, renderButton() {}, + prompt() {}, disableAutoSelect() {}, }, }, @@ -933,6 +936,7 @@ class TestGenerateFormGooglePlaywright: id: { initialize() {}, renderButton() {}, + prompt() {}, disableAutoSelect() {}, }, }, @@ -1007,6 +1011,105 @@ class TestGenerateFormGooglePlaywright: finally: browser.close() + def test_refresh_restores_google_session(self, tmp_path): + sync_api = pytest.importorskip("playwright.sync_api") + sync_playwright = sync_api.sync_playwright + + out = tmp_path / "google-session-form.html" + out.write_text(_render_for_browser(google=True)) + credential = _google_credential({"exp": 2_000_000_000}) + + with sync_playwright() as p: + try: + browser = p.chromium.launch(headless=True) + except Exception as exc: + raise RuntimeError( + "Chromium is required for form Playwright tests. " + "Run: playwright install --with-deps chromium" + ) from exc + try: + page = browser.new_page() + page.add_init_script( + """window.google = { + accounts: { + id: { + initialize() {}, + renderButton() {}, + prompt() {}, + disableAutoSelect() {}, + }, + }, + };""" + ) + page.route( + "https://accounts.google.com/gsi/client", + lambda route: route.abort(), + ) + _mock_form_routes(page) + page.goto(out.as_uri(), wait_until="domcontentloaded") + page.evaluate( + "(token) => handleCredentialResponse({ credential: token })", + credential, + ) + page.wait_for_function( + """() => { + const app = document.getElementById('app'); + return app && !app.classList.contains('is-hidden'); + }""" + ) + assert page.evaluate( + "() => sessionStorage.getItem('seahaven.meals.googleIdToken')" + ) + + page.reload(wait_until="domcontentloaded") + _mock_form_routes(page) + page.wait_for_function( + """() => { + const app = document.getElementById('app'); + const overlay = document.getElementById('auth-overlay'); + const email = document.getElementById('user-email'); + return app && !app.classList.contains('is-hidden') + && overlay && overlay.classList.contains('is-hidden') + && email && email.textContent === 'test-admin@example.com'; + }""" + ) + + page.evaluate("signOut()") + assert ( + page.evaluate( + "() => sessionStorage.getItem('seahaven.meals.googleIdToken')" + ) + is None + ) + page.reload(wait_until="domcontentloaded") + _mock_form_routes(page) + page.wait_for_function( + """() => { + const overlay = document.getElementById('auth-overlay'); + const app = document.getElementById('app'); + return overlay && !overlay.classList.contains('is-hidden') + && app && app.classList.contains('is-hidden'); + }""" + ) + + page.evaluate( + "(token) => sessionStorage.setItem('seahaven.meals.googleIdToken', token)", + _google_credential({"exp": 1}), + ) + page.reload(wait_until="domcontentloaded") + _mock_form_routes(page) + page.wait_for_function( + """() => { + const overlay = document.getElementById('auth-overlay'); + const app = document.getElementById('app'); + return overlay && !overlay.classList.contains('is-hidden') + && app && app.classList.contains('is-hidden') + && !sessionStorage.getItem('seahaven.meals.googleIdToken'); + }""" + ) + finally: + browser.close() + @pytest.fixture(scope="class") @classmethod def signed_in_admin_page(cls, tmp_path_factory): @@ -1036,6 +1139,7 @@ class TestGenerateFormGooglePlaywright: id: { initialize() {}, renderButton() {}, + prompt() {}, disableAutoSelect() {}, }, },