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
19 changes: 18 additions & 1 deletion src/services/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("api client session gate", () => {

const burst = () =>
Promise.allSettled([
apiClient.get("/users/me"),
apiClient.get("/containers"),
apiClient.get("/deployments"),
apiClient.get("/stats"),
apiClient.get("/ai/status"),
Expand Down Expand Up @@ -82,6 +82,23 @@ describe("api client session gate", () => {
expect(location.href).toBe("/login");
});

it("keeps the session when reading the current user is refused", async () => {
localStorage.setItem("auth_token", "good");
apiClient.defaults.adapter = ((config: Parameters<AxiosAdapter>[0]) =>
(config.url || "").startsWith("/users/me") ? unauthorized(config) : ok(config)) as AxiosAdapter;

const [me, ...rest] = await Promise.allSettled([
apiClient.get("/users/me"),
apiClient.get("/deployments"),
apiClient.get("/stats"),
]);

expect(me.status).toBe("rejected");
expect(rest.every((r) => r.status === "fulfilled")).toBe(true);
expect(localStorage.getItem("auth_token")).toBe("good");
expect(location.href).toBe("/");
});

// A page that loaded fine can still be holding a token that lapses while it is open.
it("gates the next burst again after the first one has drained", async () => {
localStorage.setItem("auth_token", "good-then-stale");
Expand Down
21 changes: 16 additions & 5 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export const apiClient = axios.create({

const publicPaths = ["/auth/login", "/auth/status", "/setup", "/health"];
const isPublic = (url: string) => publicPaths.some((p) => url.startsWith(p));

// The agent answers 401 on /users/me for an authenticated actor with no user record, so this call
// stays outside the gate until that becomes a 403: https://github.com/flatrun/agent/issues/208
const sessionAgnosticPaths = ["/users/me"];
const isSessionAgnostic = (url: string) => sessionAgnosticPaths.some((p) => url.startsWith(p));

const isUngated = (url: string) => isPublic(url) || isSessionAgnostic(url);
const onAuthPage = () => window.location.pathname.includes("/login") || window.location.pathname.includes("/setup");

// A page load fans out into a dozen calls at once. Firing them all against a token the agent
Expand Down Expand Up @@ -93,11 +100,15 @@ apiClient.interceptors.request.use(async (config) => {
}
config.headers.Authorization = `Bearer ${token}`;

if (sessionRejected) {
throw new axios.Cancel("session already rejected");
}
if (isSessionAgnostic(url)) {
return config;
}

inFlight++;
try {
if (sessionRejected) {
throw new axios.Cancel("session already rejected");
}
if (!gate) {
gate = startGate();
return config;
Expand All @@ -122,14 +133,14 @@ apiClient.interceptors.request.use(async (config) => {

apiClient.interceptors.response.use(
(response) => {
if (!isPublic(response.config.url || "")) {
if (!isUngated(response.config.url || "")) {
settleGate(true);
finish();
}
return response;
},
(error) => {
if (axios.isCancel(error) || isPublic(error.config?.url || "")) {
if (axios.isCancel(error) || isUngated(error.config?.url || "")) {
return Promise.reject(error);
}

Expand Down
Loading