admin login issue for timeout issue
This commit is contained in:
@@ -6,12 +6,13 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Sits in the (public) group on purpose: no shell, no nav, no store scope, and
|
||||
* a GuestGuard instead of an AuthGuard — see PublicLayout.
|
||||
* Sits in the (public) group on purpose: no shell, no nav, no store scope and
|
||||
* no auth guard — see PublicLayout.
|
||||
*
|
||||
* Reaching this page with a live session is already impossible via a fresh
|
||||
* request (src/proxy.ts redirects it to /dashboard); the guard covers the
|
||||
* client-side navigation the proxy never sees.
|
||||
* This page renders whenever it is asked for, with or without a live session in
|
||||
* this browser. Sessions are per tab, so "somebody is signed in here" is not a
|
||||
* reason to refuse the sign-in form to a tab that has no session of its own —
|
||||
* and a page that always renders cannot take part in a redirect cycle.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
return <LoginSplit />;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import {NextResponse} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {UpstreamError} from '@/services/api/apiClient';
|
||||
import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
|
||||
import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
|
||||
import {sessionCookieOptions} from '@/features/auth/services/sessionToken';
|
||||
import {
|
||||
sessionCookieFor,
|
||||
tokenCookieFor,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import {NoSessionError, withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import type {AuthSession} from '@/features/auth/types/auth';
|
||||
@@ -64,14 +68,37 @@ export async function GET() {
|
||||
{data: null, meta: {generatedAt: new Date().toISOString()}},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
|
||||
res.cookies.set(TOKEN_COOKIE, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear THIS TAB's cookies, by their tab-scoped names.
|
||||
*
|
||||
* This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped
|
||||
* names from before sessions were per-tab. Those cookies do not exist any
|
||||
* more, so the clear silently did nothing and a confirmed 401 left the
|
||||
* tab's real `loyaly_session_<tabId>` in place. The result was the
|
||||
* half-authenticated state upstreamSession warns about, with a twist: the
|
||||
* client set itself unauthenticated and went to /login, the proxy saw a
|
||||
* still-valid session cookie and sent it straight back, and the two flapped.
|
||||
*
|
||||
* Same two helpers the logout route uses, so there is one naming scheme and
|
||||
* the two paths cannot drift. Scoped to the resolved tab and no other: a
|
||||
* dead session in one tab says nothing about the others, and clearing more
|
||||
* than asked would sign out a tab that is working fine.
|
||||
*
|
||||
* A request with no resolvable tab clears nothing. There is no cookie to
|
||||
* name, and guessing would reach into somebody else's session.
|
||||
*/
|
||||
const tabId = await resolveTabId();
|
||||
if (tabId) {
|
||||
res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
|
||||
res.cookies.set(tokenCookieFor(tabId), '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import {tabSessionScript} from '@/features/auth/services/tabSession';
|
||||
import {
|
||||
htmlThemeAttr,
|
||||
@@ -88,6 +89,22 @@ export default async function RootLayout({
|
||||
* already answered in this very request.
|
||||
*/
|
||||
const session = await getServerSession();
|
||||
/**
|
||||
* WHICH tab that session was resolved from, handed to the client alongside it.
|
||||
*
|
||||
* A document navigation carries no `X-Tab-Id`, so `getServerSession` resolves
|
||||
* through the `loyaly_tab` pointer — the tab that was last FOCUSED, which for
|
||||
* a newly opened tab is somebody else. The seed is still worth sending (it is
|
||||
* what saves a guard spinner on every load), but the client has to be able to
|
||||
* tell whether the seed is its own. Without this it could not: it received an
|
||||
* identity with nothing attached saying who it belonged to, so a second tab
|
||||
* rendered the first tab's user in the shell and only found out when a data
|
||||
* call answered 401.
|
||||
*
|
||||
* Not a credential and not a decision — just the label that lets the client
|
||||
* recognise a seed that is not about it. See SessionProvider.
|
||||
*/
|
||||
const tabId = await resolveTabId();
|
||||
const themeMode = await readThemeMode();
|
||||
|
||||
return (
|
||||
@@ -117,7 +134,11 @@ export default async function RootLayout({
|
||||
services/tabSession.ts for what it replaced and why.
|
||||
*/}
|
||||
<script dangerouslySetInnerHTML={{__html: tabSessionScript()}} />
|
||||
<Providers initialSession={session} initialThemeMode={themeMode}>
|
||||
<Providers
|
||||
initialSession={session}
|
||||
initialTabId={tabId}
|
||||
initialThemeMode={themeMode}
|
||||
>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -32,11 +32,14 @@ import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider'
|
||||
export function Providers({
|
||||
children,
|
||||
initialSession,
|
||||
initialTabId,
|
||||
initialThemeMode,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Resolved from the signed cookie in the root layout — see SessionProvider. */
|
||||
initialSession: AuthSession | null;
|
||||
/** Which tab that session was read from — see SessionProvider. */
|
||||
initialTabId: string | null;
|
||||
/** Resolved from the theme-mode cookie in the root layout. */
|
||||
initialThemeMode: ThemeMode;
|
||||
}) {
|
||||
@@ -44,7 +47,10 @@ export function Providers({
|
||||
// Outside <Theme>, because <Theme> takes the mode as a prop — a context
|
||||
// rendered inside it could never reach it.
|
||||
<ThemeModeProvider initialMode={initialThemeMode}>
|
||||
<ThemedProviders initialSession={initialSession}>
|
||||
<ThemedProviders
|
||||
initialSession={initialSession}
|
||||
initialTabId={initialTabId}
|
||||
>
|
||||
{children}
|
||||
</ThemedProviders>
|
||||
</ThemeModeProvider>
|
||||
@@ -54,9 +60,11 @@ export function Providers({
|
||||
function ThemedProviders({
|
||||
children,
|
||||
initialSession,
|
||||
initialTabId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialSession: AuthSession | null;
|
||||
initialTabId: string | null;
|
||||
}) {
|
||||
const {mode} = useThemeMode();
|
||||
|
||||
@@ -67,7 +75,10 @@ function ThemedProviders({
|
||||
<MotionConfig reducedMotion="user">
|
||||
{/* Above the route tree AND outside (workspace), because /login
|
||||
establishes the session that the workspace then reads. */}
|
||||
<SessionProvider initialSession={initialSession}>
|
||||
<SessionProvider
|
||||
initialSession={initialSession}
|
||||
initialTabId={initialTabId}
|
||||
>
|
||||
<WorkspaceProvider>
|
||||
<LoyalyAiProvider>{children}</LoyalyAiProvider>
|
||||
</WorkspaceProvider>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {Center} from '@astryxdesign/core/Center';
|
||||
import {Spinner} from '@astryxdesign/core/Spinner';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
|
||||
import {destinationForUser} from '@/features/auth/services/roleDestination';
|
||||
|
||||
/**
|
||||
* The mirror of AuthGuard: keeps a signed-in user OFF the sign-in screen.
|
||||
*
|
||||
* The proxy already redirects /login → /dashboard for a request carrying a
|
||||
* valid cookie. This covers the case the proxy cannot see: a client-side
|
||||
* navigation back to /login after logging in within the same page session.
|
||||
* Offering the form again to someone who is already authenticated reads as the
|
||||
* login having failed.
|
||||
*
|
||||
* While `loading`, the form is NOT rendered. Painting it and then yanking it
|
||||
* away is worse than a beat of spinner, and it invites someone to start typing
|
||||
* into a form that is about to disappear.
|
||||
*/
|
||||
export function GuestGuard({children}: {children: React.ReactNode}) {
|
||||
const {status, user} = useSession();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'authenticated') return;
|
||||
/**
|
||||
* The SAME destination the login form resolves. These two redirects race
|
||||
* on a successful sign-in, and a hardcoded /dashboard here silently threw
|
||||
* away the deep link the proxy had preserved in `?next=`.
|
||||
*
|
||||
* The role arm is the same hazard a second time. Once sign-in started
|
||||
* sending people to a destination chosen by their role, a plain
|
||||
* `resolveRedirectTarget` here meant a staff member landed on /floor or on
|
||||
* /dashboard depending purely on which effect fired first — a race with a
|
||||
* different answer each way, which is worse than either answer. Both sides
|
||||
* now read the same two functions in the same order.
|
||||
*/
|
||||
const next = searchParams.get('next');
|
||||
router.replace(
|
||||
// `user` is non-null in this branch (status === 'authenticated'), but the
|
||||
// fallback keeps the default rather than asserting it away.
|
||||
next || !user
|
||||
? resolveRedirectTarget(next)
|
||||
: destinationForUser(user),
|
||||
);
|
||||
}, [status, router, searchParams, user]);
|
||||
|
||||
if (status !== 'unauthenticated') {
|
||||
return (
|
||||
<Center height="100vh" role="status" aria-label="Checking your session">
|
||||
<Spinner size="lg" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -73,8 +73,9 @@ export function useLoginForm() {
|
||||
const next = searchParams.get('next');
|
||||
|
||||
// Consumed at submit time through resolveRedirectTargetFor, which is shared
|
||||
// with GuestGuard so the two cannot race to different answers — and which
|
||||
// drops a `next` belonging to the other console. See redirectTarget.ts.
|
||||
// with the no-JavaScript path in the login route so the two cannot land in
|
||||
// different places — and which drops a `next` belonging to the other console.
|
||||
// See redirectTarget.ts.
|
||||
|
||||
const submit = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {authService} from '@/features/auth/services/authService';
|
||||
import {TAB_ID_STORAGE_KEY} from '@/features/auth/services/tabScope';
|
||||
import {SIDENAV_COLLAPSED_KEY} from '@/shared/layouts/workspace/sidebarStorage';
|
||||
import type {
|
||||
AuthSession,
|
||||
@@ -68,15 +69,65 @@ export function SessionProvider({
|
||||
* already had in the same request.
|
||||
*/
|
||||
initialSession,
|
||||
/**
|
||||
* The tab the seed above was resolved from, or null when the server could
|
||||
* not tell. Compared against this tab's own id — see the effect below.
|
||||
*/
|
||||
initialTabId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialSession: AuthSession | null;
|
||||
initialTabId: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<AuthSession | null>(initialSession);
|
||||
const [status, setStatus] = useState<SessionStatus>(
|
||||
initialSession ? 'authenticated' : 'unauthenticated',
|
||||
);
|
||||
/**
|
||||
* `seedIsOurs` decides whether the server's answer is about THIS tab.
|
||||
*
|
||||
* Read during the first render rather than in an effect so the first paint is
|
||||
* already right: a tab whose seed belongs to somebody else starts in
|
||||
* 'loading' and shows a guard spinner, instead of painting another person's
|
||||
* name into the shell and swapping it a moment later.
|
||||
*
|
||||
* ── Why a seed can belong to another tab ─────────────────────────────────
|
||||
* A document navigation cannot carry `X-Tab-Id`, so the server resolves the
|
||||
* session through the `loyaly_tab` pointer cookie, which names whichever tab
|
||||
* was last focused. A newly opened tab has not written it yet when its first
|
||||
* document is rendered, so it is handed the previous tab's identity. That is
|
||||
* a HINT (see tabScopeRequest.ts), and it was being treated as an answer:
|
||||
* measured, a second tab opened on /admin rendered the first tab's operator
|
||||
* in the chrome and then failed every data call with 401, because the cookies
|
||||
* those calls need are keyed to a tab id it does not have.
|
||||
*
|
||||
* ── Why comparing ids is enough ──────────────────────────────────────────
|
||||
* The inline script in the root layout writes this tab's id into
|
||||
* `sessionStorage` before any of this parses, so by the time React renders,
|
||||
* the id is there. If it matches the one the seed came from, the seed IS this
|
||||
* tab's and nothing needs re-asking — which is the common case, and it keeps
|
||||
* the seed doing the job it exists for. If it differs, the only honest state
|
||||
* is 'loading' until `GET /api/auth/session` answers with the header, which
|
||||
* addresses the right cookie.
|
||||
*
|
||||
* Storage that throws (private mode) reads as `null` and the seed is trusted,
|
||||
* which is the same fail-open the script and `tabHeaders()` take: a browser
|
||||
* with no `sessionStorage` has one shared session, and that is the documented
|
||||
* degraded mode, not a reason to refuse to render.
|
||||
*/
|
||||
const [seedIsOurs] = useState(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
let own: string | null = null;
|
||||
try {
|
||||
own = window.sessionStorage.getItem(TAB_ID_STORAGE_KEY);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return own === null || own === initialTabId;
|
||||
});
|
||||
|
||||
const [status, setStatus] = useState<SessionStatus>(() => {
|
||||
if (!seedIsOurs) return 'loading';
|
||||
return initialSession ? 'authenticated' : 'unauthenticated';
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const next = await authService.currentSession();
|
||||
@@ -84,6 +135,39 @@ export function SessionProvider({
|
||||
setStatus(next ? 'authenticated' : 'unauthenticated');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Ask for real when the seed was not ours.
|
||||
*
|
||||
* Runs once, only in the tab that detected the mismatch, and it is the whole
|
||||
* reason `seedIsOurs` exists: `refresh()` goes through `httpClient`, which
|
||||
* attaches `X-Tab-Id`, so the server reads THIS tab's cookies and answers
|
||||
* about this tab. A tab with no session of its own lands on 'unauthenticated'
|
||||
* here, and AuthGuard sends it to /login — which now always renders, so that
|
||||
* is where it stops.
|
||||
*
|
||||
* `seedIsOurs` is fixed for the life of the mount, so this cannot re-fire —
|
||||
* there is no state it writes that could feed back into its own condition.
|
||||
* Every later re-check is the visibilitychange listener below.
|
||||
*
|
||||
* The request is spelled out rather than delegated to `refresh()` so the
|
||||
* state updates sit in the async continuation, where they can be dropped if
|
||||
* the provider unmounts first. A late answer landing on a torn-down tree is
|
||||
* the one way a one-shot fetch like this can misbehave.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (seedIsOurs) return;
|
||||
|
||||
let cancelled = false;
|
||||
void authService.currentSession().then((next) => {
|
||||
if (cancelled) return;
|
||||
setSession(next);
|
||||
setStatus(next ? 'authenticated' : 'unauthenticated');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [seedIsOurs]);
|
||||
|
||||
/**
|
||||
* Re-check on focus.
|
||||
*
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
* Where a signed-in user should land, from the proxy's `?next=` hint.
|
||||
*
|
||||
* ── Why this is shared rather than inlined ───────────────────────────────
|
||||
* Two things redirect after a successful sign-in and they RACE: the login form
|
||||
* (which knows the attempt just succeeded) and GuestGuard (which reacts to the
|
||||
* session becoming authenticated). Measured: signing in from
|
||||
* `/login?next=/settings/billing` landed on /dashboard, because the guard's
|
||||
* effect fired first with a hardcoded destination and the form's redirect was
|
||||
* replaced. Both now resolve through this function, so whichever wins, the
|
||||
* user arrives at the same place.
|
||||
* Two paths redirect after a successful sign-in — the hydrated form and the
|
||||
* native form POST handled in the login route — and they must agree. Measured
|
||||
* back when they did not: signing in from `/login?next=/settings/billing`
|
||||
* landed on /dashboard because one side carried a hardcoded destination. Both
|
||||
* now resolve through `resolveRedirectTargetFor` below, so the answer does not
|
||||
* depend on which path the browser took.
|
||||
*
|
||||
* (A third redirecting authority used to exist — `GuestGuard`, which reacted to
|
||||
* the session becoming authenticated — and racing it was the original reason
|
||||
* this module is shared. It is gone: see shared/layouts/PublicLayout.tsx.)
|
||||
*
|
||||
* ── Why the validation matters ───────────────────────────────────────────
|
||||
* `next` is attacker-controllable — it is a query parameter on a public page.
|
||||
@@ -23,13 +26,6 @@
|
||||
|
||||
export const DEFAULT_DESTINATION = '/dashboard';
|
||||
|
||||
export function resolveRedirectTarget(next: string | null): string {
|
||||
if (!next || !next.startsWith('/') || next.startsWith('//')) {
|
||||
return DEFAULT_DESTINATION;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* `next`, but only when it belongs to the person who just signed in.
|
||||
*
|
||||
|
||||
@@ -16,8 +16,7 @@ import type {AuthUser, UserRole} from '@/features/auth/types/auth';
|
||||
* answer exists to close.
|
||||
*
|
||||
* One module, read by BOTH sign-in paths — the hydrated fetch and the native
|
||||
* form POST — and by GuestGuard, which redirects on the same event and would
|
||||
* otherwise race them to a different answer.
|
||||
* form POST — so a browser with JavaScript disabled cannot land somewhere else.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,12 +52,22 @@ if(!id||!/^[a-z0-9]{8,32}$/.test(id)){
|
||||
id=(Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)).slice(0,16);
|
||||
s.setItem(K,id);
|
||||
}
|
||||
var point=function(){document.cookie=P+'='+id+';path=/;samesite=lax'+(location.protocol==='https:'?';secure':'');};
|
||||
// Only a VISIBLE tab claims the pointer, and that condition is load-bearing.
|
||||
// A tab opened in the background (a middle-click, "open link in new tab")
|
||||
// starts hidden, and pointing from there would aim the pointer at a tab with
|
||||
// no session while the person is still working in the one that has it — so the
|
||||
// focused tab's very next navigation would be gated against the wrong cookie
|
||||
// and bounced to /login. A hidden tab simply waits: the visibilitychange
|
||||
// listener below points the moment it is actually looked at.
|
||||
var point=function(){
|
||||
if(document.visibilityState!=='visible')return;
|
||||
document.cookie=P+'='+id+';path=/;samesite=lax'+(location.protocol==='https:'?';secure':'');
|
||||
};
|
||||
point();
|
||||
// Re-point on focus: only one tab is focused at a time, so this keeps the
|
||||
// pointer aimed at the tab the person is actually using, which is the tab whose
|
||||
// next navigation the server has to render.
|
||||
window.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')point();});
|
||||
window.addEventListener('visibilitychange',point);
|
||||
window.addEventListener('pageshow',point);
|
||||
}catch(e){}})();`;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
tokenCookieOptions,
|
||||
type TokenBundle,
|
||||
} from './tokenStore';
|
||||
import {tokenCookieFor} from './tabScope';
|
||||
import {sessionCookieFor, tokenCookieFor} from './tabScope';
|
||||
import {resolveTabId} from './tabScopeRequest';
|
||||
import {
|
||||
REMEMBERED_MAX_AGE_SECONDS,
|
||||
verifySessionToken,
|
||||
} from './sessionToken';
|
||||
|
||||
/**
|
||||
* Authenticated access to the platform, with the three refresh rules the
|
||||
@@ -98,6 +102,50 @@ export function toBundle(res: ApiTokenBundle): TokenBundle {
|
||||
* The persist happens INSIDE the locked section, before the promise resolves,
|
||||
* so every waiter observes a bundle that is already durable.
|
||||
*/
|
||||
/**
|
||||
* How long the refreshed token cookie should live — read off the identity
|
||||
* cookie, which is the only thing that still knows.
|
||||
*
|
||||
* ── The bug this closes ──────────────────────────────────────────────────
|
||||
* `persistTokens(next)` was called with no lifetime, and `tokenCookieOptions`
|
||||
* omits `maxAge` when it is falsy. So every refresh quietly downgraded
|
||||
* `loyaly_tokens_<tab>` to a browser-session cookie. For a remembered sign-in
|
||||
* that meant `loyaly_session_<tab>` kept its 30 days while the sealed tokens
|
||||
* did not: after a browser restart the identity survived, the tokens were gone,
|
||||
* the proxy admitted the page on the identity alone, and every data call
|
||||
* answered 401 — the exact half-authenticated state the note on `storeTokens`
|
||||
* below describes, arrived at from the other direction.
|
||||
*
|
||||
* ── Why the identity cookie is the source of truth ───────────────────────
|
||||
* A cookie cannot report its own `maxAge`, and by refresh time the `rememberMe`
|
||||
* checkbox is long gone. But the signed payload carries `iat` and `exp`, and
|
||||
* their span IS the lifetime chosen at sign-in — 12h for a normal session, 30d
|
||||
* for a remembered one. So the span identifies which kind this is without
|
||||
* trusting anything the client sends, and the payload is HMAC-signed, so it
|
||||
* cannot be edited into a longer one.
|
||||
*
|
||||
* Returns the REMAINING time rather than a fresh 30 days: the two cookies must
|
||||
* expire together, and re-granting the full window on every refresh would let
|
||||
* the tokens outlive the identity that authorises them.
|
||||
*
|
||||
* `undefined` for a normal session, which keeps it a browser-session cookie —
|
||||
* the unticked box is asking for exactly that, and it must stay that way.
|
||||
*/
|
||||
async function rememberedLifetime(): Promise<number | undefined> {
|
||||
const tabId = await resolveTabId();
|
||||
if (!tabId) return undefined;
|
||||
|
||||
const store = await cookies();
|
||||
const payload = verifySessionToken(store.get(sessionCookieFor(tabId))?.value);
|
||||
if (!payload) return undefined;
|
||||
|
||||
// Only a remembered sign-in was ever given a persistent cookie.
|
||||
if (payload.exp - payload.iat < REMEMBERED_MAX_AGE_SECONDS) return undefined;
|
||||
|
||||
const remaining = payload.exp - Math.floor(Date.now() / 1000);
|
||||
return remaining > 0 ? remaining : undefined;
|
||||
}
|
||||
|
||||
async function refresh(current: TokenBundle): Promise<TokenBundle> {
|
||||
const k = lockKey(current.refreshToken);
|
||||
const existing = inFlight.get(k);
|
||||
@@ -110,7 +158,7 @@ async function refresh(current: TokenBundle): Promise<TokenBundle> {
|
||||
body: {refresh_token: current.refreshToken, device: 'Loyaly Web Console'},
|
||||
});
|
||||
const next = toBundle(res);
|
||||
await persistTokens(next);
|
||||
await persistTokens(next, await rememberedLifetime());
|
||||
return next;
|
||||
})();
|
||||
|
||||
|
||||
104
src/proxy.ts
104
src/proxy.ts
@@ -3,8 +3,8 @@ import type {NextRequest} from 'next/server';
|
||||
import {configStatus} from '@/shared/config/configCheck';
|
||||
import {verifySessionToken} from '@/features/auth/services/sessionToken';
|
||||
import {
|
||||
TAB_ID_HEADER,
|
||||
TAB_POINTER_COOKIE,
|
||||
isSessionCookieName,
|
||||
isValidTabId,
|
||||
sessionCookieFor,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
@@ -20,12 +20,21 @@ import {
|
||||
*
|
||||
* Three rules, in order:
|
||||
*
|
||||
* 1. `/` → /dashboard when signed in, else /login
|
||||
* 2. a protected path → /login?next=… when there is no valid session
|
||||
* 3. /login while signed in → /dashboard
|
||||
* 1. `/` → /dashboard when THIS TAB is signed in, else /login
|
||||
* 2. a protected path → /login?next=… when this tab has no valid session
|
||||
* 3. /login → always served, signed in or not
|
||||
*
|
||||
* Rule 3 is what makes /login a GuestGuard-ed route: coming back to it with a
|
||||
* live session should not offer to sign you in again.
|
||||
* ── Rule 3 used to redirect a signed-in browser to /dashboard ────────────
|
||||
* It no longer does, and the reason is that "signed in" is not a property of a
|
||||
* BROWSER in this app — sessions are per tab (see tabScope.ts). A second tab
|
||||
* asking for /login has no session of its own; bouncing it to the dashboard
|
||||
* showed it a console it could not use, because every data call in that tab is
|
||||
* authorised by a cookie it does not have. Explicitly opening /login is now
|
||||
* always answered with the sign-in page, which is also the only answer that
|
||||
* cannot disagree with the client guard and ping-pong against it.
|
||||
*
|
||||
* Nothing about protection changes: /login is the one PUBLIC path, and every
|
||||
* other route still needs a verified session for THIS tab.
|
||||
*
|
||||
* The session is verified, not merely detected. A cookie whose signature fails
|
||||
* or whose `exp` has passed is treated as absent, so a tampered or stale
|
||||
@@ -147,33 +156,60 @@ export function proxy(request: NextRequest): NextResponse {
|
||||
/**
|
||||
* Which tab, and therefore which session cookie.
|
||||
*
|
||||
* A document navigation cannot carry `X-Tab-Id`, so the `loyaly_tab` pointer
|
||||
* is all there is here. When it resolves, this is the focused tab's real
|
||||
* session and every rule below applies to it.
|
||||
* The SAME two channels in the SAME order as `resolveTabId` — the header
|
||||
* first, the pointer cookie second — because this gate and the route handlers
|
||||
* behind it must answer about one identical session. They used to differ: a
|
||||
* fetch carrying `X-Tab-Id` was admitted here on the strength of the pointer
|
||||
* cookie (some other tab) and then answered 401 by the handler, which read
|
||||
* the header. Reading both here removes that disagreement at the source.
|
||||
*
|
||||
* When it does NOT resolve — a stale pointer, a tab that has not claimed an
|
||||
* id yet, a bookmark opened cold — the gate falls back to "is ANY tab in this
|
||||
* browser signed in". That is enough to decide whether to show the sign-in
|
||||
* page, and it is deliberately NOT enough to decide role routing: sending a
|
||||
* manager to /admin because another tab holds an operator session would be
|
||||
* exactly the cross-tab confusion this scoping removes. So `session` stays
|
||||
* null in that case and the client re-resolves with the header.
|
||||
*
|
||||
* No data rides on this. Every fetch behind the page carries the tab id and
|
||||
* is authorised per-session by the platform.
|
||||
* `tabScopeRequest.ts` cannot be imported — it is `server-only` and reads
|
||||
* `next/headers` — so the two channels are re-read from the request here. The
|
||||
* pure half (`isValidTabId`, `sessionCookieFor`) is shared, so the NAMING can
|
||||
* never drift; only the plumbing to reach a header is duplicated.
|
||||
*/
|
||||
const fromHeader = request.headers.get(TAB_ID_HEADER);
|
||||
const pointer = request.cookies.get(TAB_POINTER_COOKIE)?.value;
|
||||
const session = isValidTabId(pointer)
|
||||
? verifySessionToken(request.cookies.get(sessionCookieFor(pointer))?.value)
|
||||
const tabId = isValidTabId(fromHeader)
|
||||
? fromHeader
|
||||
: isValidTabId(pointer)
|
||||
? pointer
|
||||
: null;
|
||||
|
||||
const session = tabId
|
||||
? verifySessionToken(request.cookies.get(sessionCookieFor(tabId))?.value)
|
||||
: null;
|
||||
|
||||
const isAuthenticated =
|
||||
session !== null ||
|
||||
request.cookies
|
||||
.getAll()
|
||||
.some(
|
||||
(c) => isSessionCookieName(c.name) && verifySessionToken(c.value) !== null,
|
||||
);
|
||||
/**
|
||||
* THIS TAB's session, and no other. This is the line that was wrong.
|
||||
*
|
||||
* ── What it used to say ──────────────────────────────────────────────────
|
||||
* It fell back to "is ANY tab in this browser signed in" by scanning every
|
||||
* `loyaly_session_*` cookie in the jar. The intent was charitable — decide
|
||||
* whether to show the sign-in page even when the pointer is stale — but a
|
||||
* cookie jar is shared by the whole browser while a SESSION here is not, so
|
||||
* the answer it gave was about a different tab than the one asking.
|
||||
*
|
||||
* ── What that cost ───────────────────────────────────────────────────────
|
||||
* Everything else in the request path is already tab-scoped: `getServerSession`
|
||||
* seeds the shell from this tab's cookie, `withUpstream` opens this tab's
|
||||
* sealed tokens. So a tab with no session of its own was admitted to
|
||||
* /dashboard by this gate, rendered a shell, was told `GET /api/auth/session`
|
||||
* → null, and had AuthGuard send it to /login — which this same gate then
|
||||
* redirected back to /dashboard, because another tab's cookie was still in
|
||||
* the jar. That is the login loop, and it was unbounded: measured as
|
||||
* /dashboard → /api/auth/session (null) → /login → /dashboard → … while
|
||||
* `GET /api/sites` answered 401 throughout.
|
||||
*
|
||||
* Reading only this tab's cookie makes the gate agree with the two layers
|
||||
* beneath it. A tab that cannot be identified has no session, which is the
|
||||
* same rule `resolveTabId`'s callers follow and for the same reason: guessing
|
||||
* would hand one tab another tab's identity.
|
||||
*
|
||||
* This is a TIGHTENING, not a relaxation. No request that was refused before
|
||||
* is admitted now.
|
||||
*/
|
||||
const isAuthenticated = session !== null;
|
||||
|
||||
/**
|
||||
* Which console this session belongs to. Read from the SIGNED payload, so a
|
||||
@@ -192,10 +228,16 @@ export function proxy(request: NextRequest): NextResponse {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in page, always served — see rule 3 at the top of this file.
|
||||
*
|
||||
* Deliberately BEFORE the `!isAuthenticated` branch below, so this is the one
|
||||
* path whose answer does not depend on session state at all. A page that
|
||||
* always renders cannot take part in a redirect cycle, which is what makes
|
||||
* /login the terminal state of every failed-auth path rather than one more
|
||||
* hop in it.
|
||||
*/
|
||||
if (PUBLIC_PATHS.has(pathname)) {
|
||||
if (isAuthenticated) {
|
||||
return NextResponse.redirect(new URL(home, request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import {GuestGuard} from '@/features/auth/guards/GuestGuard';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
/**
|
||||
* The unauthenticated surface: sign-in today, password reset and invitation
|
||||
@@ -10,7 +8,33 @@ import {GuestGuard} from '@/features/auth/guards/GuestGuard';
|
||||
* means anything without an account, and mounting the providers behind it
|
||||
* would start fetching workspace data for a visitor who has not signed in.
|
||||
* The page owns its own full-bleed layout.
|
||||
*
|
||||
* ── Why there is no guard here any more ──────────────────────────────────
|
||||
* This used to wrap its children in `GuestGuard`, which redirected anyone the
|
||||
* session provider called `authenticated` away to their home route. Two things
|
||||
* made that wrong once sessions became per-tab (see auth/services/tabScope.ts):
|
||||
*
|
||||
* 1. The provider is SEEDED from `getServerSession()`, which resolves through
|
||||
* the `loyaly_tab` pointer cookie. A newly opened tab has not written that
|
||||
* cookie yet when its first document is rendered, so the seed is whichever
|
||||
* tab was last focused — and the guard redirected the new tab on the
|
||||
* strength of a DIFFERENT tab's session, to a console it has no cookies
|
||||
* for. That is issue 1: opening /login in a second tab never showed the
|
||||
* sign-in page.
|
||||
* 2. Even correctly seeded, it fought src/proxy.ts over the same decision on
|
||||
* two different clocks. Two redirecting authorities pointing opposite ways
|
||||
* is what a redirect loop is made of.
|
||||
*
|
||||
* So /login now simply renders. Nothing is weakened by that: it is the one
|
||||
* public route, it holds no data, and the workspace behind it is still gated by
|
||||
* `AuthGuard` on the client and by the proxy on the server. Signing in from it
|
||||
* replaces THIS tab's session and leaves every other tab alone, which is what
|
||||
* per-tab sessions already meant everywhere else.
|
||||
*
|
||||
* It is a plain server component now — there was nothing client-side left in it
|
||||
* once the guard went, and a needless `'use client'` here would pull the whole
|
||||
* public tree into the client bundle.
|
||||
*/
|
||||
export function PublicLayout({children}: {children: React.ReactNode}) {
|
||||
return <GuestGuard>{children}</GuestGuard>;
|
||||
export function PublicLayout({children}: {children: ReactNode}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user