fix(auth): stop the re-login dialog hijacking the login screen
Nightly Build / build (push) Successful in 8m11s
Nightly Build / build (push) Successful in 8m11s
On a cold load with no session, both shells mount every component before their boot auth check resolves, so a dozen gated /api calls 401 in parallel and the fetch watch raised the re-login dialog over the login screen the boot check was about to show (.relogin-backdrop is z-10000, .login-page z-9999). The user typed their password into the modal, which only closes itself on success — revealing the login page still up with the app hidden, so they were asked a second time and only a manual reload got them in. The dialog is for a session that dies under an open tab, so gate it on one having ever been established. Recognising that is passive, in the same fetch wrapper: mobile.html probes /api/auth/me from a classic inline script that runs before this module exists, so an explicit marker per shell would never fire there and the dialog would be dead on mobile. Any 2xx from a gated endpoint proves a session; only the routes guard.rs::is_public lets through unauthenticated are excluded. Knock-on: with the report now a no-op on a cold load, the chat's reconnect loop no longer stopped on it. Retry only in the native shell, which authenticates on its own — everywhere else something is already asking for a password.
This commit is contained in:
+10
-4
@@ -2,7 +2,7 @@ import { html, nothing } from 'lit';
|
|||||||
import { LightElement } from './base.js';
|
import { LightElement } from './base.js';
|
||||||
import { InboxCardsMixin } from './inbox-cards.js';
|
import { InboxCardsMixin } from './inbox-cards.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
import { isSessionExpired, notifySessionExpired, probeSession } from './session-expiry.js';
|
import { isSessionExpired, isNativeShell, notifySessionExpired, probeSession } from './session-expiry.js';
|
||||||
|
|
||||||
// Slash commands handled entirely server-side: they reply with a `Done` and never
|
// Slash commands handled entirely server-side: they reply with a `Done` and never
|
||||||
// echo back as a `user_message`, so they are the only commands rendered
|
// echo back as a `user_message`, so they are the only commands rendered
|
||||||
@@ -492,9 +492,15 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
|||||||
if (isSessionExpired()) return; // the dialog is already up
|
if (isSessionExpired()) return; // the dialog is already up
|
||||||
if ((await probeSession()) === 'expired') {
|
if ((await probeSession()) === 'expired') {
|
||||||
notifySessionExpired();
|
notifySessionExpired();
|
||||||
// A shell that handles auth itself (native mobile) ignores the report;
|
// Only the native shell keeps retrying: it authenticates in the background,
|
||||||
// there is no dialog coming, so keep retrying as before.
|
// so a refused upgrade there really can be transient. Everywhere else the
|
||||||
if (isSessionExpired()) return;
|
// server has just told us this browser is nobody, and something is already
|
||||||
|
// asking for a password — the re-login dialog if a session died under the
|
||||||
|
// tab, or the shell's boot check showing the login screen on a cold load
|
||||||
|
// (where `notifySessionExpired` is deliberately a no-op). Retrying past
|
||||||
|
// that is pure noise; the socket comes back on `auth-restored`, or the
|
||||||
|
// login page reloads the whole page.
|
||||||
|
if (!isNativeShell()) return;
|
||||||
}
|
}
|
||||||
setTimeout(() => this._connectWS(), 2000);
|
setTimeout(() => this._connectWS(), 2000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,39 @@
|
|||||||
|
|
||||||
let expired = false;
|
let expired = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this page has ever held a session.
|
||||||
|
*
|
||||||
|
* The dialog answers "your session died **while you were using the app**", and
|
||||||
|
* that premise is not free: on a cold load with no session at all, the shells
|
||||||
|
* mount every component before their boot auth check resolves, so a dozen `/api`
|
||||||
|
* calls 401 in parallel and used to raise the dialog *over* the login screen the
|
||||||
|
* boot check was about to show (`.relogin-backdrop` is z-10000, `.login-page`
|
||||||
|
* z-9999). Logging in through that modal only closes the modal — the login page
|
||||||
|
* underneath stayed up with the app hidden, so the user was asked for their
|
||||||
|
* password a second time and a manual reload was the only way through.
|
||||||
|
*
|
||||||
|
* So a 401 is only an *expiry* once something proved we had a session; before
|
||||||
|
* that it is the ordinary "not logged in yet", which the boot check owns.
|
||||||
|
*/
|
||||||
|
let established = false;
|
||||||
|
|
||||||
// The native mobile shell authenticates in the background and must never be
|
// The native mobile shell authenticates in the background and must never be
|
||||||
// gated by a web login form (the rule `mobile.html`'s bootstrap already states).
|
// gated by a web login form (the rule `mobile.html`'s bootstrap already states).
|
||||||
// Guarding the report rather than each producer means no future caller can
|
// Guarding the report rather than each producer means no future caller can
|
||||||
// reintroduce the dialog there.
|
// reintroduce the dialog there.
|
||||||
const NATIVE_SHELL = new URLSearchParams(location.search).get('native') === 'true';
|
const NATIVE_SHELL = new URLSearchParams(location.search).get('native') === 'true';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True in the native mobile shell, which authenticates on its own and gets no
|
||||||
|
* dialog. Exported because "no dialog is coming" is not the same fact as "the
|
||||||
|
* session is fine": a caller deciding whether to keep retrying needs to tell the
|
||||||
|
* two apart (see `chat-session.js::_scheduleReconnect`).
|
||||||
|
*/
|
||||||
|
export function isNativeShell() {
|
||||||
|
return NATIVE_SHELL;
|
||||||
|
}
|
||||||
|
|
||||||
/** True once the server has told us this browser has no session anymore. */
|
/** True once the server has told us this browser has no session anymore. */
|
||||||
export function isSessionExpired() {
|
export function isSessionExpired() {
|
||||||
return expired;
|
return expired;
|
||||||
@@ -33,9 +60,13 @@ export function isSessionExpired() {
|
|||||||
* Report a lost session. Idempotent and one-way: the first call fires the
|
* Report a lost session. Idempotent and one-way: the first call fires the
|
||||||
* `auth-expired` window event, every later one is a no-op — several components
|
* `auth-expired` window event, every later one is a no-op — several components
|
||||||
* discover the same 401 at once, and the dialog must be raised once.
|
* discover the same 401 at once, and the dialog must be raised once.
|
||||||
|
*
|
||||||
|
* A no-op while no session was ever established (see [`established`]): there is
|
||||||
|
* nothing to renew, and the shell's own boot check is already showing the login
|
||||||
|
* screen.
|
||||||
*/
|
*/
|
||||||
export function notifySessionExpired() {
|
export function notifySessionExpired() {
|
||||||
if (expired || NATIVE_SHELL) return;
|
if (expired || !established || NATIVE_SHELL) return;
|
||||||
expired = true;
|
expired = true;
|
||||||
window.dispatchEvent(new CustomEvent('auth-expired'));
|
window.dispatchEvent(new CustomEvent('auth-expired'));
|
||||||
}
|
}
|
||||||
@@ -79,18 +110,36 @@ export async function probeSession() {
|
|||||||
* because 401 is a *normal* answer there — `auth/me` is the "am I logged in?"
|
* because 401 is a *normal* answer there — `auth/me` is the "am I logged in?"
|
||||||
* probe and `auth/login` answers it to a wrong password; treating either as an
|
* probe and `auth/login` answers it to a wrong password; treating either as an
|
||||||
* expiry would raise the login screen from the login screen.
|
* expiry would raise the login screen from the login screen.
|
||||||
|
*
|
||||||
|
* The same wrapper is where a session is recognised as **established**, and it
|
||||||
|
* is deliberately passive rather than a call the two shells each make after
|
||||||
|
* their boot check: `mobile.html` probes `/api/auth/me` from a classic inline
|
||||||
|
* script that runs *before* this module exists, so an explicit marker would
|
||||||
|
* never fire there and the dialog would be dead on mobile. Any success from a
|
||||||
|
* gated endpoint proves a live session (the gate is deny-by-default), which is
|
||||||
|
* why only the routes `guard.rs::is_public` lets through unauthenticated are
|
||||||
|
* excluded — `auth/me` and `auth/login` answering 200 *do* prove one.
|
||||||
*/
|
*/
|
||||||
export function installSessionExpiryWatch() {
|
export function installSessionExpiryWatch() {
|
||||||
const native = window.fetch.bind(window);
|
const native = window.fetch.bind(window);
|
||||||
window.fetch = async (input, init) => {
|
window.fetch = async (input, init) => {
|
||||||
const res = await native(input, init);
|
const res = await native(input, init);
|
||||||
if (res.status === 401) {
|
|
||||||
const url = typeof input === 'string' ? input : (input?.url ?? '');
|
const url = typeof input === 'string' ? input : (input?.url ?? '');
|
||||||
const path = url.startsWith('http') ? new URL(url).pathname : url;
|
const path = url.startsWith('http') ? new URL(url).pathname : url;
|
||||||
|
if (res.status === 401) {
|
||||||
if (path.startsWith('/api/') && !path.startsWith('/api/auth/') && !path.startsWith('/api/setup/')) {
|
if (path.startsWith('/api/') && !path.startsWith('/api/auth/') && !path.startsWith('/api/setup/')) {
|
||||||
notifySessionExpired();
|
notifySessionExpired();
|
||||||
}
|
}
|
||||||
|
} else if (res.ok && provesSession(path)) {
|
||||||
|
established = true;
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a 2xx on this path can only have come from an authenticated call. */
|
||||||
|
function provesSession(path) {
|
||||||
|
return path.startsWith('/api/')
|
||||||
|
&& !path.startsWith('/api/setup/')
|
||||||
|
&& path !== '/api/auth/logout';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user