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 {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>
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
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