Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/hooks/useLastVisitedPath.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
return [lastVisitedPath, setPathAsLastVisitedPath] as const;
}

/**
* Clear the remembered last visited path so the next login does not reuse a
* stale redirect from a previous user.
*/
export function clearLastVisitedPath(): void {
setPersistedState("lastVisitedPath", "/");
}

/**
* Hook that automatically tracks the current path as the last visited path.
* This uses a ref to track the previous path and updates localStorage directly
Expand Down
32 changes: 31 additions & 1 deletion app/stores/AuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import type RootStore from "~/stores/RootStore";
import Team from "~/models/Team";
import env from "~/env";
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
import { clearLastVisitedPath, setPostLoginPath } from "~/hooks/useLastVisitedPath";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import { deleteAllDatabases } from "~/utils/developer";
import Logger from "~/utils/Logger";
import { homePath } from "~/utils/routeHelpers";
import {
consumePostSwitchRedirectHome,
wipeAndReload,
} from "~/utils/userContinuity";
import isCloudHosted from "~/utils/isCloudHosted";
import Store from "./base/Store";

Expand Down Expand Up @@ -201,6 +206,21 @@ export default class AuthStore extends Store<Team> {
const res = await client.post("/auth.info", undefined, {
credentials: "same-origin",
});
// Stale-session fallback: if /auth.info came back without a
// parseable payload (e.g. the server bounced us to /home HTML
// and ApiClient's redirect detection didn't fire for some
// reason — SW intercept, missing response.redirected on some
// browser/network combos), wipe and reload before the invariant
// throws into the ErrorBoundary. This is belt-and-suspenders on
// top of ApiClient.fetch's primary detection; either path lands
// us on the same wipeAndReload helper which is idempotent.
if (env.AUTH_TYPE === "SSO" && !res?.data?.user) {
Logger.warn(
"/auth.info returned no user payload — assuming stale session"
);
await wipeAndReload();
return;
}
invariant(res?.data, "Auth not available");

runInAction("AuthStore#refresh", () => {
Expand Down Expand Up @@ -241,6 +261,14 @@ export default class AuthStore extends Store<Team> {
return;
}

if (consumePostSwitchRedirectHome()) {
const targetPath = homePath();
if (window.location.pathname !== targetPath) {
window.location.replace(targetPath);
return;
}
}

// Update the user's timezone if it has changed
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (data.user.timezone !== timezone) {
Expand Down Expand Up @@ -340,6 +368,8 @@ export default class AuthStore extends Store<Team> {
}
}

clearLastVisitedPath();

// remove session record on apex cookie
const team = this.team;

Expand Down
8 changes: 8 additions & 0 deletions app/stores/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import RootStore from "~/stores/RootStore";
import env from "~/env";
import { checkUserContinuity } from "~/utils/userContinuity";

// Runs BEFORE RootStore is constructed so that AuthStore (and every
// other mobx-persisted store that rehydrates from localStorage in its
// constructor) sees a clean slate when the authenticated user has
// changed since the last session in this browser. See the docstring on
// `checkUserContinuity` for the full rationale.
checkUserContinuity();

const stores = new RootStore();

Expand Down
62 changes: 62 additions & 0 deletions app/utils/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { getCookie } from "tiny-cookie";
import { CSRF } from "@shared/constants";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { wipeAndReload } from "./userContinuity";

type Options = {
baseUrl?: string;
Expand Down Expand Up @@ -164,6 +165,67 @@ class ApiClient {
const timeEnd = window.performance.now();
const success = response.status >= 200 && response.status < 300;

// Stale-session redirect detection. Outline's auth middleware only
// runs on /api/* routes — so on a user switch, the SPA HTML at /
// is served WITHOUT the middleware noticing the cookie/header
// mismatch. The mismatch is only detected on the first /api call
// after boot (typically auth.info). The server responds with 302
// + Set-Cookie clearing accessToken + Location: /home. Fetch
// auto-follows (redirect: "follow"), lands on /home which serves
// HTML — so `response.redirected` is true and `response.url`
// points at /home (or some other non-/api path).
//
// Without this hook the SPA keeps showing the previous user's
// data from the rehydrated AUTH_STORE until a full hard nav
// triggers checkUserContinuity. Symptom: bottom-left avatar
// shows the previous user immediately after a user switch.
//
// We trigger the same wipe checkUserContinuity does, then
// hard-navigate to /home so the SPA re-mounts on a clean slate
// (localStorage empty → fresh AuthStore → fresh fetchAuth →
// correct user).
//
// Only trigger in SSO mode where the redirect is part of the
// expected stale-session flow. In non-SSO deployments, any
// /api redirect is unexpected and shouldn't trigger a wipe.
//
// Detection signals (any of):
// - response.redirected is true and final URL is not under /api
// (definitive: fetch followed at least one redirect off the
// /api namespace)
// - Content-Type is text/html for an /api request (the SPA
// HTML fallback handler caught it — happens when the redirect
// target served HTML, or when some intermediary stripped the
// 302 and returned HTML directly)
//
// Either signal is sufficient. Both can be true together but only
// the first qualifying detection matters since wipeAndReload is
// idempotent.
if (env.AUTH_TYPE === "SSO") {
const contentType = response.headers.get("content-type") || "";
const finalUrlOffApi = !response.url.includes("/api/");
const wasRedirected = response.redirected && finalUrlOffApi;
// Only treat HTML-on-API as a stale-session signal when the
// response is a SUCCESS (the 302→/home bounce ends up as 200
// HTML after fetch follows). A 502/503/4xx HTML error page —
// Traefik gateway error, oauth2-proxy expiry redirect, etc. —
// means the user has to re-auth via the normal channels but
// their browser-local state should NOT be wiped on a transient
// infrastructure hiccup. Gating on `success` keeps the wipe
// tightly scoped to the actual stale-session flow.
const gotHtmlOnApiCall = success && contentType.includes("text/html");
if (wasRedirected || gotHtmlOnApiCall) {
Logger.info("lifecycle", "Stale-session redirect detected", {
redirected: response.redirected,
finalUrl: response.url,
contentType,
status: response.status,
});
await wipeAndReload();
throw new AuthorizationError();
}
}

if (options.download && success) {
const blob = await response.blob();
const fileName = (
Expand Down
Loading
Loading