login timeout issue fix

This commit is contained in:
2026-09-19 23:22:45 +05:30
parent 02aadaf091
commit 8456c5e852
3 changed files with 117 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import {cookies} from 'next/headers';
import {Sora, Inter} from 'next/font/google'; import {Sora, Inter} from 'next/font/google';
import './globals.css'; import './globals.css';
import {getServerSession} from '@/features/auth/services/serverSession'; import {getServerSession} from '@/features/auth/services/serverSession';
import {tabSessionScript} from '@/features/auth/services/tabSession';
import { import {
htmlThemeAttr, htmlThemeAttr,
parseThemeMode, parseThemeMode,
@@ -105,6 +106,16 @@ export default async function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<body> <body>
{/*
FIRST child of <body>, and that position is the whole point: it runs
before the markup below it is parsed, so a tab that inherited the
cookies without owning the session never paints the workspace. An
effect inside Providers would run after the first paint instead.
See services/tabSession.ts for what it does and why it fails open.
*/}
<script
dangerouslySetInnerHTML={{__html: tabSessionScript(session !== null)}}
/>
<Providers initialSession={session} initialThemeMode={themeMode}> <Providers initialSession={session} initialThemeMode={themeMode}>
{children} {children}
</Providers> </Providers>

View File

@@ -10,6 +10,7 @@ import {
} from 'react'; } from 'react';
import {useRouter} from 'next/navigation'; import {useRouter} from 'next/navigation';
import {authService} from '@/features/auth/services/authService'; import {authService} from '@/features/auth/services/authService';
import {TAB_SESSION_KEY} from '@/features/auth/services/tabSession';
import type { import type {
AuthSession, AuthSession,
AuthUser, AuthUser,
@@ -108,6 +109,27 @@ export function SessionProvider({
async (credentials: LoginCredentials): Promise<LoginResult> => { async (credentials: LoginCredentials): Promise<LoginResult> => {
const result = await authService.login(credentials); const result = await authService.login(credentials);
if (result.ok) { if (result.ok) {
/**
* Re-claim the tab for the session just created.
*
* Normally the inline script in the root layout has already done this
* while /login rendered, so this is a no-op. It is load-bearing for one
* path: `logout` below CLEARS sessionStorage, and the /login it then
* navigates to is a client transition with no new document, so no
* script runs to put the marker back. Without this line, signing out
* and straight back in inside the same tab left it unmarked — and the
* next full reload of the workspace read that as an inherited session
* and signed the user out again.
*
* Wrapped for the same reason the clear in `logout` is: storage throws
* in private mode, and a failed write must not fail a good sign-in.
*/
try {
window.sessionStorage.setItem(TAB_SESSION_KEY, '1');
} catch {
/* ignore — the guard script fails open for the same reason */
}
setSession(result.session); setSession(result.session);
setStatus('authenticated'); setStatus('authenticated');
} }

View File

@@ -0,0 +1,84 @@
/**
* Tab-scoped session lifetime.
*
* ── The problem cookies cannot solve on their own ─────────────────────────
* `loyaly_session` and `loyaly_tokens` are browser-session cookies: no Max-Age,
* so the browser drops them when it exits. That is per BROWSER, not per TAB —
* one cookie jar is shared by every tab of the profile, so closing the tab a
* merchant signed in on leaves the jar untouched and the next tab is still
* authenticated. No cookie attribute exists that scopes a cookie to one tab.
*
* `sessionStorage` is the only per-tab lifetime the platform gives us: it is
* empty in a newly opened tab, survives a reload of the tab it belongs to, and
* is discarded when that tab closes. So a tab that holds the session marker is
* a tab that was here when the session was established; a tab without it is a
* tab that inherited somebody else's cookies.
*
* The marker is NOT a credential. It is the string "1". The access and refresh
* tokens stay sealed in the httpOnly cookie where JavaScript cannot reach them,
* and nothing here changes that: a forged marker buys an attacker exactly the
* cookies their browser already had.
*
* ── Why an inline script and not an effect ────────────────────────────────
* A React effect runs after the first paint, so a new tab would show the
* dashboard and then bounce to /login. This runs as the first child of <body>,
* before the app markup below it is parsed, so the authenticated shell is never
* painted at all.
*/
/** Present ⇒ this tab is the one the session belongs to. */
export const TAB_SESSION_KEY = 'loyaly.tab-session';
/**
* Set for the duration of one sign-out attempt, and the reason this cannot
* loop. `proxy.ts` redirects /login → /dashboard while the cookies verify, so a
* failed `POST /api/auth/logout` would otherwise land straight back on a
* protected page with the marker still missing, and fire again forever. If the
* guard finds its own flag already set it concludes the clear did not take,
* claims the tab and gets out of the way — degrading to the previous behaviour
* rather than locking somebody out of a console it cannot sign them out of.
*/
const RESET_FLAG = 'loyaly.tab-session.reset';
/**
* Claim the tab for the session that is about to exist.
*
* Runs on every SIGNED-OUT document, which is what makes the no-JavaScript and
* pre-hydration sign-in paths work: /login itself claims the tab, so the native
* form POST lands on /dashboard with the marker already in place. The reset flag
* is cleared here because arriving signed out is proof the sign-out worked.
*/
const CLAIM_SCRIPT = `(function(){try{var s=window.sessionStorage;s.setItem(${JSON.stringify(
TAB_SESSION_KEY,
)},'1');s.removeItem(${JSON.stringify(RESET_FLAG)});}catch(e){}})();`;
/**
* End the session when the tab holding it is gone.
*
* Fails OPEN when sessionStorage is unreadable — private mode, blocked site
* data, an embedded webview. A storage API that throws must not be the thing
* that decides a merchant cannot use the console; the worst case is the
* cross-tab behaviour this file exists to change, not a lockout.
*/
const GUARD_SCRIPT = `(function(){var K=${JSON.stringify(
TAB_SESSION_KEY,
)},R=${JSON.stringify(RESET_FLAG)},s;
try{s=window.sessionStorage;}catch(e){return;}
try{
if(s.getItem(K))return;
if(s.getItem(R)){s.setItem(K,'1');s.removeItem(R);return;}
s.setItem(R,'1');
}catch(e){return;}
document.documentElement.style.visibility='hidden';
var go=function(){location.replace('/login');};
try{fetch('/api/auth/logout',{method:'POST',credentials:'same-origin'}).then(go,go);}catch(e){go();}
})();`;
/**
* The script the root layout inlines, chosen by whether the SERVER resolved a
* session for this request. Two separate bodies rather than one that branches on
* a serialised flag, so neither path can be reached by tampering with the other.
*/
export function tabSessionScript(hasSession: boolean): string {
return hasSession ? GUARD_SCRIPT : CLAIM_SCRIPT;
}