Skip to content

Conversation

@Ben-El
Copy link
Contributor

@Ben-El Ben-El commented Oct 21, 2025

Closes #9544

Summary

This PR refactors the WebUI authentication and navigation flow to improve reliability and consistency across login, logout, and protected routes.

Motivation

Previously, authentication state and redirects were handled in multiple components, which caused inconsistencies:

  • Logout didn't always clear UI state immediately.
  • After logging out, clicking Back in the browser displayed the previous authenticated page.
  • Users could access /auth/credentials after session expiry or cookie deletion until a refresh occurred.
  • The top navigation bar sometimes persisted or disappeared unexpectedly between auth transitions.

Etc.

Changes

  • Centralized authentication state via AuthContext and unified markAuthenticated / markUnauthenticated API.
  • Added RequireAuth to handle redirects for unauthenticated users consistently.
  • Updated useUser to rely on AuthContext status and cache only when authenticated.
  • Adjusted ConfigProvider to re-fetch configuration when user state changes.
  • Improved logout behavior in navbar.jsx to cleanly reset user state and redirect properly.
  • Removed redundant or stale local storage reads/writes.
  • Ensured /auth/login no longer shows when the user is already authenticated.
  • Guaranteed login page loads without the main navbar when logged out.

Testings

Tested manually across the following flows:

https://docs.google.com/spreadsheets/d/1-1UFTf0sbI16325zvV8FYfnoc7y8i62Mb_GbVHhXdv8/edit?gid=0#gid=0


… replace scattered authentication logic in the frontend.
@Ben-El Ben-El added the exclude-changelog PR description should not be included in next release changelog label Oct 21, 2025
@Ben-El Ben-El changed the title Introduce centralized authentication context to manage auth state and replace scattered authentication logic in the frontend. Centralize Authentication Oct 21, 2025
@Ben-El Ben-El requested a review from itaigilo October 22, 2025 07:47
…d `useAPI`, streamlining state management and simplifying configuration retrieval logic.
Copy link
Contributor

@itaigilo itaigilo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will review this soon, but before starting -
@Ben-El can you please describe how this was tested?

This change touches a lot of sensitive flows, in different system setups.
We should be very careful with QA and testing this change, so I'd be happy for more details about it.

Also, @Annaseli may have some more context as the one recently updated these files.

@itaigilo itaigilo requested a review from Annaseli October 22, 2025 16:31
@Ben-El Ben-El requested a review from itaigilo October 22, 2025 18:46
Copy link
Contributor

@itaigilo itaigilo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this massive change -
It's a very good step forward!

And I like the RequiresAuth idea, it makes a lot of sense.

Blocking mainly since the login flow is still scattered, and I think it can be further encapsulated.

And in addition:
These are maybe the most delicate flows in the WebUI, require proper testing, and are not properly covered by unit or integration tests.
Hence, IMHO the hardest part in this change is validating and QA-ing the different flows.
So please make sure that you have validated all the different flows yourself, including Cloud, Enterprise, SCIM and RBAC. According to the PR description, these are still missing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other contexts (for example configProvider.tsx) all the types are on the same file.
I think we should keep a unified pattern.

import React, { createContext, useContext, useMemo, useState, ReactNode } from "react";
import { AUTH_STATUS, type AuthStatus } from "./status";

type AuthContextShape = {
Copy link
Contributor

@itaigilo itaigilo Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: what's "shape"?
Let's align to AuthContextType like the other contexts we have.


function readPersistedStatus(): AuthStatus {
try {
const v = window.localStorage.getItem(STORAGE_KEY);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In api/index.js, the "user" is persisted into cache (see auth.login()).
Why do we need to save and maintain this information twice, instead of encapsulating it into a single place?

status,
markAuthenticated: () => {
setStatus(AUTH_STATUS.AUTHENTICATED);
try { window.localStorage.setItem(STORAGE_KEY, AUTH_STATUS.AUTHENTICATED); } catch { return }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An empty catch clause is a bad practice.
At least log an error there.


type AuthContextShape = {
status: AuthStatus;
markAuthenticated: () => void;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need two functions here, and not unify to a single setStatus() function?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even better - maybe use useEffect() to update the localStorage as a side effect (as done in configProvider, for example)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the recent changes, useEffect is no longer necessary 👍

auth.clearCurrentUser();
window.location = logoutUrl;
markUnauthenticated();
if (logoutUrl !== "/logout") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain this change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For SSO: external IdP logout must be a full navigation and replace history

import {useAuth} from "../auth/authContext";
import {AUTH_STATUS} from "../auth/status";

export default function RequireAuth() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit but important - should be RequiresAuth, since it's a trait of a component.

return;
}

router.navigate("/auth/login", { replace: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect a redirection to "/", and have the "/" to redirect to login if needed.
In other words, have the "/auth/login" redirection in a single place.

…e redundant `status.ts`, and rename `RequireAuth` to `RequiresAuth` for consistency.
…d `markUnauthenticated` into `setAuthStatus`, simplifying auth state management.
…alizing auth redirection logic and simplifying `login.tsx`.
… and improve readability of authentication logic.
@Annaseli
Copy link
Contributor

Annaseli commented Oct 23, 2025

@Ben-El @itaigilo
Hi I will review it as well a bit later today

… definitions for improved readability and organization.
@Ben-El Ben-El requested a review from itaigilo October 26, 2025 09:11
Copy link
Contributor

@itaigilo itaigilo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great progress here @Ben-El .
I think that the architecture is now in place,
And the AuthProvider and RequiresAuth are a good solution,
And the code looks good.

Added some comments, most of them mainly revolve around cleaning up the code and readability.

In addition, please address the questions raised about testing this:
We've already had some changes in the auth area that ended up in either new production bugs or in regression. Since the changes in this PR are major, we should do the most for finding these bugs before releasing. Since these area isn't properly covered by tests, the whole testing / QA of this feature must be addressed.

Comment on lines 22 to 23
if (path === "/auth/login") return true;
return path.startsWith("/auth/oidc");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (path === "/auth/login") return true;
return path.startsWith("/auth/oidc");
if (path === "/auth/login") return true;
if (path.startsWith("/auth/oidc")) return true;
return false;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (path === "/auth/login") return true;
return path.startsWith("/auth/oidc");
return path === "/auth/login" || path.startsWith("/auth/oidc");

export const AUTH_STATUS = {
AUTHENTICATED: "authenticated",
UNAUTHENTICATED: "unauthenticated",
UNKNOWN: "unknown",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe replace UKNOWN with PENDING?
Is there a scenario in which there's no auth request initiated?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UNKNOWN (or PENDING) state just represents this short initial phase before that request completes, not a scenario where the request doesn't happen.

So PENDING might indeed be a clearer name, since it better conveys "auth check in progress" rather than "no request".


export const useAuth = (): AuthContextType => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this happen?
Isn't AuthContext initialized no matter what?

Copy link
Contributor Author

@Ben-El Ben-El Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can happen if a component using useAuth() is rendered outside the <AuthProvider>, for example in a storybook, or future refactors where a subtree is mounted separately.

The guard makes the failure explicit and easy to debug, instead of causing null errors later.
So it's a safety net, it should never trigger in production, but it's still valuable during development.

auth.clearCurrentUser();
setStatus(AUTH_STATUS.UNAUTHENTICATED);
const path = window.location.pathname;
const next = path + (window.location.search || "") + (window.location.hash || "");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please explain this hashing?
What it does and why it's needed?

Copy link
Contributor Author

@Ben-El Ben-El Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hash was Anna's suggestion, the "hash" here refers to the fragment part of the URL (the part after #, not cryptographic hashing).

We include both location.search and location.hash in the next value so that when the user gets redirected back after login, they return to the exact same URL, including query params and hash anchors.

Without this, the redirect would lose any #fragment and land the user at the base page instead.

Maybe hashing is not critical for now, but in the future we may have anchors or something, so it'll cover that too.

const AuthContext = createContext<AuthContextType | null>(null);

const isPublicAuthRoute = (path: string) => {
if (path === "/auth/login") return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both "/auth/login" and "/auth/oidc" should be consts.


if (loading) return <Loading/>;
if (!user) {
const next = location.pathname + (location.search || "") + (location.hash || "");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this logic also required here (and not only in authContext)? Isn't this covered by flows catched by the context?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question!
The check in RequiresAuth is intentional.
While authContext handles unauthorized events globally (e.g. when a token expires or the server returns 401), RequiresAuth acts as a route guard that ensures protected pages aren’t rendered even briefly when there's no active user yet.

So the logic overlaps a bit but serves a different timing:
authContext reacts after unauthorized events, while RequiresAuth prevents access before rendering a protected route.

const pluginManager = usePluginManager();
const {user} = useUser();
const [storageConfig, setConfig] = useState<ConfigContextType>(configInitialState);
const { response, loading, error } = useAPI(() => config.getConfig(), [user]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @Annaseli here.
This hook is already minimal -
It can be unified with the authContext for simplicity and easier tracking.

}
};

const getLoginIntent = (location: ReturnType<typeof useLocation>) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make this code more readable, either by extracting to sub-functions, renaming vars or adding comments.
Currently it's pretty hard to understand what it does.

await auth.login(username, password);
router.push(next || '/');
navigate(0);
setAuthStatus(AUTH_STATUS.AUTHENTICATED);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect there might be a race condition here, between setAuthStatus and router.navigate.
How about making sure that the router.navigate will happen after the setAuthStatus?

@Ben-El Ben-El requested a review from itaigilo November 4, 2025 13:19
Copy link
Contributor

@itaigilo itaigilo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ben-El great progress, it's almost there.

Still some questions,
And some small improvements required.


useEffect(() => {
if (status === AUTH_STATUS.AUTHENTICATED) {
const stored = window.sessionStorage.getItem("lakefs_post_login_next");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make lakefs_post_login_next a const common to all relevant files.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: rename stored to postLoginNext or something like this.

export const isPublicAuthRoute = (path: string) =>
path === ROUTES.LOGIN || path.startsWith(ROUTES.OIDC_PREFIX);

export const buildNextFromWindow = () =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read this function a few time, and still can't figure out what it's doing,
And what "build next from window" means.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It builds the redirect target URL from window.location.
I will change to a clearer name, getCurrentRelativeUrl or something.
Thx.

auth.clearCurrentUser();
setStatus(AUTH_STATUS.UNAUTHENTICATED);
const path = window.location.pathname;
const next = buildNextFromWindow();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this can be inside the if.

// handle global dark mode here
const {state} = useContext(AppContext);
document.documentElement.setAttribute('data-bs-theme', state.settings.darkMode ? 'dark' : 'light')
document.documentElement.setAttribute(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is inside useEffect?
Did you test the behavior of the Dark Mode after this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question!
I moved this logic into useEffect because setting a DOM attribute is a side-effect,
and React best practices recommend running side-effects inside useEffect rather than during render.
This ensures that the attribute is updated only when darkMode changes, and prevents double execution under strict mode.
I tested it and confirmed that dark mode toggling and initial theme load still behave as expected.

if (loading) return <Loading/>;
if (!user) {
const next = buildNextFromWindow();
const url = `${ROUTES.LOGIN}?redirected=true&next=${encodeURIComponent(next)}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is both hard to read and tricky to maintain.
Please use URLSearchParams() or something similar to create this in a more robust way.

qp: URLSearchParams
) => {
const qs = qp.toString();
return `${loc.pathname}${qs ? `?${qs}` : ""}${loc.hash ?? ""}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is both hard to read and tricky to maintain.
Please use URLSearchParams() or something similar to create this in a more robust way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ben-El this comment still awaits your addressing.

export const queryOf = (loc: ReturnType<typeof useLocation>) =>
new URLSearchParams(loc.search);

export const isTrue = (v: string | null) => v === "true";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔

u.searchParams.set("next", safeNext);
return u.toString();
} catch {
return `${url}${url.includes("?") ? "&" : "?"}next=${encodeURIComponent(safeNext)}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is both hard to read and tricky to maintain.
Please use URLSearchParams() or something similar to create this in a more robust way.

if (setupResponse && (setupResponse.state !== SETUP_STATE_INITIALIZED || setupResponse.comm_prefs_missing)) {
router.push({pathname: '/setup', params: {}, query: router.query as Record<string, string>})
return null;
const qs = new URLSearchParams(location.search);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, this whole login algorithm/flow is still not clear.
Note that there's been a decent amount of comments in the original code, for a reason.
Making this flow clearer will make it much easier to review.

…CurrentRelativeUrl`, unify `lakefs_post_login_next` handling, and centralize redirection utilities for consistency.
…management in `authContext`, and update components accordingly.
@Ben-El Ben-El requested a review from itaigilo November 6, 2025 12:59
Copy link
Contributor

@itaigilo itaigilo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, nice work!

I hope the testing part will work out fine 🤞

Comment on lines 2 to 4
import { useNavigate } from "react-router-dom";
import { auth } from "../api";
import {getCurrentRelativeUrl, isPublicAuthRoute, ROUTES} from "../utils";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: what's the style, {aaa} or { aaa }?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{ aaa }

if (error) {
return <AlertError error={error} className={"mt-1 w-50 m-auto"} onDismiss={() => window.location.reload()} />;
}
// Setup doesn't complete, send to /setup with redirected=true&next=...
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment belongs to the next line...

type AuthContextType = {
status: AuthStatus;
user: User;
refreshUser: (opts?: { useCache?: boolean }) => Promise<void>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment that explains this code?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is that refreshUser can optionally receive an object of options, currently only { useCache?: boolean }.
refreshUser is an async method to reload the current user state from the API.
It accepts an optional options object (currently { useCache?: boolean }),
allowing callers to control whether to use cached data or force a fresh fetch.

user: User;
refreshUser: (opts?: { useCache?: boolean }) => Promise<void>;
setStatus: (s: AuthStatus) => void;
onUnauthorized: () => void;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why onUnauthorized and not onUnauthenticated?


export const ROUTES = {
LOGIN: "/auth/login",
OIDC_PREFIX: "/auth/oidc",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not familiar with /auth/oidc, but I am familiar with /oidc/login and /oidc/logout. Could you point out where you saw /auth/oidc being used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will change it


if (isPublicAuthRoute(window.location.pathname)) return;

navigate(ROUTES.LOGIN, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why redirect to ROUTES.LOGIN instead of to the root, which already redirects to the login page?
Also, shouldn’t the /logout endpoints handle this redirection already?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We redirect explicitly to ROUTES.LOGIN instead of / for a few reasons:

onUnauthorized is triggered by 401s from XHR/fetch calls anywhere in the app.
Redirects returned by the /logout endpoint don't affect SPA navigation (the browser will not change window.location), so the client must navigate explicitly.

Going directly to /auth/login avoids an extra hop (root → login) and reduces flicker.

It also guarantees we keep the intended next target and doesn't rely on whatever logic the root route currently implements.

We keep the isPublicAuthRoute guard to avoid redirect loops if we are already on the login/oidc/sso paths.

TL;DR: direct, deterministic navigation to the login page is safer and smoother here.

async ({ useCache = true }: { useCache?: boolean } = {}) => {
try {
const u = useCache
? await auth.getCurrentUserWithCache()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren’t you wrapping await auth.getCurrentUserWithCache() with useAPI?
Also, related to this, if you’re recalculating this while the user is in a loading state, shouldn’t you handle it by setting setStatus(AUTH_STATUS.PENDING)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided not to use useAPI here because the AuthContext runs outside the component lifecycle - we call refreshUser on app startup, after login/logout, and on pageshow events.
Using useAPI here would introduce redundant redirects and implicit 401 handling that conflict with onUnauthorized.
Regarding PENDING, I will add a selective state update: it will set only when performing a cold refresh (useCache: false) and the user isn't already authenticated.
This will avoid flicker during background updates while still providing a proper loading state.


/** Tiny component for external redirects (SSO) */
const ExternalRedirect: React.FC<{ to: string }> = ({ to }) => {
useEffect(() => { window.location.replace(to); }, [to]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it in useEffect?
Also, it looks similar to the DoNavigate func

return <Navigate to={ROUTES.SETUP} replace />;
}

if (error) return <AlertError error={error} className="mt-1 w-50 m-auto" onDismiss={() => window.location.reload()} />;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the previous code, the check for error was above the check for setup

}

if (error) return <AlertError error={error} className="mt-1 w-50 m-auto" onDismiss={() => window.location.reload()} />;
if (redirectedFromQuery) return <DoNavigate to={cleanUrl} replace state={{ redirected: true, next }} />;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this step added here? we didn't have a step like that before, didn't we?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had.

</Route>
<Route path="*" element={<Navigate to="/repositories" replace/>}/>
</Route>
<Route path="auth" element={<Layout/>}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that in the previous code we had:

<Route index element={<Navigate to="credentials" />} />

right after:

<Route path="auth" element={<Layout />} >

and before:

<Route path="login" element={<LoginPage />} />
<Route path="users/create" element={...} />

Copy link
Contributor Author

@Ben-El Ben-El Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we want the credentials page to be guarded, no?
We want it to be accessible only when the user is authenticated.
In the current implementation the <Route index element={} is under
<Route element={}>.
If it should not be, please do tell.

…henticated`, improve user refresh logic, and streamline login flow by removing unused utilities and centralizing route management.
@Ben-El Ben-El requested a review from Annaseli November 9, 2025 16:28
…le cache for user refresh, and improve logout redirection logic.
Comment on lines 1 to 4
import React, {createContext, useContext, useMemo, useState, ReactNode, useCallback, useEffect} from "react";
import { useNavigate } from "react-router-dom";
import { auth } from "../api";
import {getCurrentRelativeUrl, isPublicAuthRoute, ROUTES} from "../utils";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import React, {createContext, useContext, useMemo, useState, ReactNode, useCallback, useEffect} from "react";
import { useNavigate } from "react-router-dom";
import { auth } from "../api";
import {getCurrentRelativeUrl, isPublicAuthRoute, ROUTES} from "../utils";
import React, { createContext, useContext, useMemo, useState, ReactNode, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { auth } from "../api";
import { getCurrentRelativeUrl, isPublicAuthRoute, ROUTES } from "../utils";

auth.clearCurrentUser();
window.location = logoutUrl;
window.sessionStorage.removeItem(LAKEFS_POST_LOGIN_NEXT);
window.history.replaceState(null, "", `${ROUTES.LOGIN}?redirected=true`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't ${ROUTES.LOGIN}?redirected=true include the whole origin url path (before /auth/login)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case we intentionally don't include the full origin.
history.replaceState treats relative URLs as scoped to the current origin, so
replaceState(null, "", "/auth/login?redirected=true") automatically becomes
http://<current-origin>/auth/login?redirected=true.

Keeping it relative is safer and more portable.
It works consistently in local dev, production, and behind reverse proxies, without hardcoding the origin.

// they are first redirected to the '/auth/login' endpoint. For users logging in via lakeFS
// (not via SSO), after successful authentication they will be redirected back to the original endpoint
// they attempted to access. The redirected flag is set here so it can later be used to properly
// handle SSO redirection when login via SSO is configured.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep a comment (where it should be in the new version) explaining why the redirected flag is set to true, since it wasn’t clear to either Barak or me when we first saw it.

if (setupResponse && (setupResponse.state !== SETUP_STATE_INITIALIZED || setupResponse.comm_prefs_missing)) {
router.push({pathname: '/setup', params: {}, query: router.query as Record<string, string>})
return null;
return <Navigate to={ROUTES.SETUP} replace />;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the previous version we kept the: query: router.query

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will update the redirect to keep the existing query.
In React Router v6, we do this with location.search.

@Ben-El Ben-El merged commit e2c3727 into master Nov 12, 2025
41 checks passed
@Ben-El Ben-El deleted the webui-refactor-centralize-authentication branch November 12, 2025 19:37
arielshaqed added a commit that referenced this pull request Nov 18, 2025
Authentication changed in #9593.  This broke the ability to redirect to a
non-React URL after logging in -- which @Isan-Rivkin discovered broke
`lakectl login`.

Restore the ability to go to the particular route needed under /api/v1.
Checked by re-logging-in.
arielshaqed added a commit that referenced this pull request Nov 23, 2025
* Add LoginToken support to OpenAPI

- OpenAPI support
- Login tokens abstraction
- Controller hookup to login tokens abstraction

(This feature is unimplemented in base lakeFS, and only a trivial login
tokens abstraction exists here.)

* Add releaseTokenToMailbox API

This is typically only called by the browser -- but it's still handled as
OpenAPI in the controller.

* Add `lakectl login` client code

* Release token correctly, with usable web page

* make gen

* [lint] Make govet & golanci-lint pass again

* Open browser at login URL

* Update `lakectl help` golden file

* Use RetryClient in lakectl login

Use the same RetryClient type as the rest of lakeFS, only with a different
retry policy - one that retries status code 404.  This involves refactoring
getClient... so do that.

* Use different (longer) login retries config

* Explicitly redirect to login page from controller during lakectl login

releaseToken is _not_ part of the UI, and there is no implicit redirection
there from middleware.  Instead, redirect there from the controller.

* golangci-lint

* [bug] Copilot fixes: HTTP header issues, nit in doc

* [CR] Limit loginRequestToken length; extract X-Lakefs-Mailbox header

* [CR] Retrieve login URL from config when possible

* [CR] Fix bug: full redirect after login

Authentication changed in #9593.  This broke the ability to redirect to a
non-React URL after logging in -- which @Isan-Rivkin discovered broke
`lakectl login`.

Restore the ability to go to the particular route needed under /api/v1.
Checked by re-logging-in.

* [bug] Correctly encode "next" URL

It's a query param that contains "/" and ":" and things - encode it as such!

* [bug] Fix golangci-lint: actually copy URL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

exclude-changelog PR description should not be included in next release changelog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebUI: Refactor - Centralize Authentication State and Redirect Unauthenticated Users to the Login Page

3 participants