login timeout issue fix
This commit is contained in:
@@ -3,6 +3,7 @@ import {cookies} from 'next/headers';
|
||||
import {Sora, Inter} from 'next/font/google';
|
||||
import './globals.css';
|
||||
import {getServerSession} from '@/features/auth/services/serverSession';
|
||||
import {tabSessionScript} from '@/features/auth/services/tabSession';
|
||||
import {
|
||||
htmlThemeAttr,
|
||||
parseThemeMode,
|
||||
@@ -105,6 +106,16 @@ export default async function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<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}>
|
||||
{children}
|
||||
</Providers>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {authService} from '@/features/auth/services/authService';
|
||||
import {TAB_SESSION_KEY} from '@/features/auth/services/tabSession';
|
||||
import type {
|
||||
AuthSession,
|
||||
AuthUser,
|
||||
@@ -108,6 +109,27 @@ export function SessionProvider({
|
||||
async (credentials: LoginCredentials): Promise<LoginResult> => {
|
||||
const result = await authService.login(credentials);
|
||||
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);
|
||||
setStatus('authenticated');
|
||||
}
|
||||
|
||||
84
src/features/auth/services/tabSession.ts
Normal file
84
src/features/auth/services/tabSession.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user