feat(auth): detect session expiry live and offer re-login in place

Every response already funnels through runtimeFetch, so a classifier there
spots 401s, confirms them against /auth/session (a proxied provider 401
must not read as a logout), and flips a small auth-session store. The web
and hosted surfaces show a frosted banner under the header whose Log in
button hands off to the session gate's existing unlock flow; sends are
paused while expired, the session-load error screen explains the auth case
and retries itself after login, and returning to a long-idle window
revalidates once via visibility/focus. Native mobile feeds the same signal
into its connection re-probe instead of showing the banner; VS Code is
exempt.
This commit is contained in:
Bohdan Triapitsyn
2026-08-26 18:22:04 +03:00
parent 5612849bd7
commit f7a006dc6a
19 changed files with 322 additions and 16 deletions
@@ -0,0 +1,41 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
/**
* Non-blocking notice that the OpenChamber session expired mid-work. It never
* takes the screen on its own: work stays visible and interactive, and only
* the explicit "Log in" click hands control to the session gate's full login
* flow (password, passkey, desktop shell — all already there).
*/
export const AuthExpiredBanner: React.FC = () => {
const { t } = useI18n();
const authState = useAuthSessionStore((store) => store.state);
const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating);
if (authState !== 'expired') {
return null;
}
return (
// Below the header on purpose: the header row can be a window-drag region
// on desktop, where nothing under the cursor is clickable.
<div
className="pointer-events-none fixed inset-x-0 z-[200] flex justify-center px-4"
style={{ top: 'calc(var(--oc-header-height, 56px) + 8px)' }}
>
<div
role="alert"
className="oc-glass-popover oc-glass-floating pointer-events-auto flex items-center gap-3 rounded-lg px-3 py-2"
>
<Icon name="lock" className="size-4 flex-shrink-0" style={{ color: 'var(--status-error)' }} />
<span className="typography-ui-label text-foreground">{t('sessionAuth.expired.banner')}</span>
<Button size="xs" variant="outline" onClick={markReauthenticating} className="normal-case">
{t('sessionAuth.expired.loginAction')}
</Button>
</div>
</div>
);
};
@@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { AuthExpiredBanner } from './AuthExpiredBanner';
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
@@ -557,6 +559,27 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
}
}, [skipAuth, state]);
// Mid-session expiry: the banner asks for a re-login by flipping the shared
// auth store to 'reauthenticating'; the gate answers with its own status
// check, which lands in the full 'locked' flow on a genuine 401. A
// successful login resolves the store back to 'ok'.
const authSessionState = useAuthSessionStore((store) => store.state);
React.useEffect(() => {
if (!skipAuth) installAuthSessionFocusWatch();
}, [skipAuth]);
React.useEffect(() => {
if (skipAuth) return;
if (authSessionState === 'reauthenticating') {
void checkStatusRef.current?.();
}
}, [authSessionState, skipAuth]);
React.useEffect(() => {
if (skipAuth) return;
if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') {
useAuthSessionStore.getState().markAuthenticated();
}
}, [skipAuth, state]);
React.useEffect(() => {
if (state === 'locked' && passwordInputRef.current) {
passwordInputRef.current.focus();
@@ -983,5 +1006,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
);
}
return <>{children}</>;
return (
<>
{skipAuth ? null : <AuthExpiredBanner />}
{children}
</>
);
};
@@ -18,6 +18,7 @@ import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { useScrollShadow } from '@/components/ui/useScrollShadow';
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
import { useChatTimelineController } from './hooks/useChatTimelineController';
@@ -645,6 +646,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
suspendPartUpdatesForMessageId: streamingMessageId,
});
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
const authSessionExpired = useAuthSessionStore((store) => store.state !== 'ok');
const wasAuthExpiredRef = React.useRef(false);
const sessionMessageLoadState = useSessionMessageLoadState(
currentSessionId ?? '',
effectiveSessionDirectory,
@@ -1170,6 +1173,23 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
}, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]);
// A load that failed while the session was expired retries itself the
// moment the re-login lands — the error screen should never outlive its
// cause.
React.useEffect(() => {
if (authSessionExpired) {
wasAuthExpiredRef.current = true;
return;
}
if (wasAuthExpiredRef.current) {
wasAuthExpiredRef.current = false;
if (sessionMessageLoadState.status === 'error') {
retrySessionLoad();
}
}
}, [authSessionExpired, retrySessionLoad, sessionMessageLoadState.status]);
React.useEffect(() => {
if (!active || !currentSessionId) return;
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
@@ -1298,10 +1318,20 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
<p className="typography-meta mt-1 text-muted-foreground">
{authSessionExpired
? t('chat.container.sessionLoadError.authDescription')
: t('chat.container.sessionLoadError.description')}
</p>
{authSessionExpired ? (
<Button variant="outline" size="sm" className="mt-4" onClick={() => useAuthSessionStore.getState().markReauthenticating()}>
{t('sessionAuth.expired.loginAction')}
</Button>
) : (
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
)}
</div>
</div>
);
@@ -78,6 +78,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { usePermissionStore } from '@/stores/permissionStore';
import { togglePermissionAutoAccept } from './permissionAutoAccept';
import { useKeybind } from '@/hooks/useKeybind';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { extractGitChangedFiles } from './changedFiles';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -965,6 +966,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const queuedMessageId = options?.queuedMessageId;
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
const capturedTarget = messageQueueTarget;
// An expired session cannot deliver anything: keep the prompt in the
// composer and point at the login banner instead of burning the send
// on a guaranteed 401.
if (useAuthSessionStore.getState().state !== 'ok') {
toast.error(t('sessionAuth.expired.sendBlocked'));
return;
}
// Snapshot the draft and current-session identity before the first
// async gap so a later sidebar selection cannot reroute the send.
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;