Skip to content

Commit 79e6dc4

Browse files
committed
feat(FR-2626): migrate LoginView sToken path to STokenLoginBoundary (#6861)
Resolves FR-2636, FR-2637, FR-2638 (under Story [FR-2626](https://lablup.atlassian.net/browse/FR-2626), Epic [FR-2616](https://lablup.atlassian.net/browse/FR-2616)) resolves #NNN (FR-MMM) <!-- replace NNN, MMM with the GitHub issue number and the corresponding Jira issue number. --> <!-- Please precisely, concisely, and concretely describe what this PR changes, the rationale behind codes, and how it affects the users and other developers. --> **Checklist:** (if applicable) - [ ] Documentation - [ ] Minium required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after ## Stack Story 2 of Epic FR-2616. See [dev plan](../blob/main/.specs/draft-stoken-login-boundary/dev-plan.md) for the full story breakdown. [FR-2626]: https://lablup.atlassian.net/browse/FR-2626?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [FR-2616]: https://lablup.atlassian.net/browse/FR-2616?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent e109675 commit 79e6dc4

3 files changed

Lines changed: 129 additions & 69 deletions

File tree

react/src/components/LoginView.tsx

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
import {
2121
createBackendAIClient,
2222
connectViaGQL,
23-
tokenLogin,
2423
loadConfigFromWebServer,
2524
loginWithSAML,
2625
loginWithOpenID,
@@ -452,42 +451,10 @@ const LoginView: React.FC<{
452451
block(t('login.PleaseWait'), t('login.ConnectingToCluster'));
453452
}
454453

455-
// Check for SSO token
456-
const urlParams = new URLSearchParams(window.location.search);
457-
const sToken = urlParams.get('sToken');
458-
if (sToken) {
459-
try {
460-
document.cookie = `sToken=${sToken}; expires=Session; path=/; Secure; SameSite=Lax`;
461-
const updatedEndpoints = await tokenLogin(
462-
client,
463-
sToken,
464-
configRef.current,
465-
endpoints,
466-
);
467-
setEndpoints(updatedEndpoints);
468-
469-
// tokenLogin already called connectViaGQL internally; reuse the
470-
// shared post-connect helper to stay in sync with the regular
471-
// session-login path (login_attempt/last_login, clearSavedLoginInfo,
472-
// forceLoginApprovedRef reset, panel close, endpoint persistence).
473-
postConnectSetup(client);
474-
475-
// Strip the sToken from the URL so it doesn't leak into browser
476-
// history or get re-processed on refresh.
477-
window.history.replaceState({}, '', '/');
478-
return;
479-
} catch (err) {
480-
logger.error('tokenLogin failed', err);
481-
notification(t('eduapi.CannotAuthorizeSessionByToken'));
482-
// Previously a hard reload cleared the UI; now we must restore
483-
// the login panel ourselves so the user isn't stuck in a loading
484-
// or blocked state.
485-
setIsBlockPanelOpen(false);
486-
open();
487-
setIsLoading(false);
488-
return;
489-
}
490-
}
454+
// sToken (SSO) URL entry is handled entirely by STokenLoginBoundary
455+
// at the route level (see routes.tsx `STokenGuard`). LoginView no
456+
// longer reads sToken from the URL; when a token is present the
457+
// boundary mounts LoginView only after authentication succeeds.
491458

492459
// Do session login
493460
// client.login() returns only on success (authenticated === true).

react/src/helper/loginSessionAuth.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,48 @@ export async function tokenLogin(
206206
return connectViaGQL(client, cfg, endpoints);
207207
}
208208

209+
/**
210+
* Persist the state a successful login should leave behind, independent of
211+
* which UI surface performed the login (LoginView panel, STokenLoginBoundary,
212+
* etc.). Centralized here so every successful login path keeps the same
213+
* side effects in lockstep:
214+
*
215+
* - `last_login` timestamp + reset of `login_attempt` counter
216+
* - drop any saved username / password / keypair credentials from prior
217+
* signed-out sessions on this device
218+
* - persist the resolved API endpoint into `localStorage` so the next
219+
* cold start can re-use it
220+
* - mark the client `ready` so `useLoginOrchestration` short-circuits on
221+
* subsequent mounts within the same page load
222+
*
223+
* Callers: the `STokenLoginBoundary` `onSuccess` route handlers (route-level
224+
* sToken flow for `/`, `/interactive-login`, `/edu-applauncher`, `/applauncher`).
225+
* LoginView's panel-based login still runs its own `postConnectSetup` inline —
226+
* unifying both paths through this helper is tracked as a follow-up refactor
227+
* once the boundary migrations settle (see PR #6861 review discussion). Kept
228+
* separate from `connectViaGQL` because the GQL step also runs for the
229+
* non-authenticated paths (e.g. an already-logged-in session refresh) where
230+
* the counter and credential-cleanup side effects would be incorrect.
231+
*/
232+
export function persistPostLoginState(client: any): void {
233+
const options = (globalThis as any).backendaioptions;
234+
if (options) {
235+
options.set('last_login', Math.floor(Date.now() / 1000), 'general');
236+
options.set('login_attempt', 0, 'general');
237+
}
238+
localStorage.removeItem('backendaiwebui.login.api_key');
239+
localStorage.removeItem('backendaiwebui.login.secret_key');
240+
localStorage.removeItem('backendaiwebui.login.user_id');
241+
localStorage.removeItem('backendaiwebui.login.password');
242+
const endpoint = client?._config?.endpoint;
243+
if (typeof endpoint === 'string' && endpoint) {
244+
localStorage.setItem('backendaiwebui.api_endpoint', endpoint);
245+
}
246+
if (client) {
247+
client.ready = true;
248+
}
249+
}
250+
209251
/**
210252
* Load webserver config when the api_endpoint differs from current URL origin.
211253
* Uses React's fetchAndParseConfig instead of the Lit shell's _parseConfig.

react/src/routes.tsx

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,16 @@ import FlexActivityIndicator from './components/FlexActivityIndicator';
1212
import LocationStateBreadCrumb from './components/LocationStateBreadCrumb';
1313
import LoginView from './components/LoginView';
1414
import MainLayout from './components/MainLayout/MainLayout';
15+
import { STokenLoginBoundary } from './components/STokenLoginBoundary';
1516
import WebUINavigate from './components/WebUINavigate';
17+
import { persistPostLoginState } from './helper/loginSessionAuth';
1618
import { useSuspendedBackendaiClient } from './hooks';
1719
import { useAutoDiagnostics } from './hooks/useAutoDiagnostics';
1820
import { useBAISettingUserState } from './hooks/useBAISetting';
1921
import { LogoutEventHandler } from './hooks/useLogout';
22+
import { useSToken } from './hooks/useSToken';
2023
import { useWebUIMenuItems } from './hooks/useWebUIMenuItems';
24+
import { pluginApiEndpointState } from './hooks/useWebUIPluginState';
2125
// High priority to import the component
2226
import ComputeSessionListPage from './pages/ComputeSessionListPage';
2327
import LegacyModelStoreListPage from './pages/LegacyModelStoreListPage';
@@ -26,6 +30,7 @@ import ServingPage from './pages/ServingPage';
2630
import VFolderNodeListPage from './pages/VFolderNodeListPage';
2731
import { Skeleton, theme } from 'antd';
2832
import { BAIFlex, BAICard } from 'backend.ai-ui';
33+
import { useSetAtom } from 'jotai';
2934
import React, { Suspense } from 'react';
3035
import { useTranslation } from 'react-i18next';
3136
import { RouteObject, useLocation } from 'react-router-dom';
@@ -692,6 +697,48 @@ const AutoDiagnosticsEffect = () => {
692697
return null;
693698
};
694699

700+
/**
701+
* Route-level gate that delegates to `STokenLoginBoundary` when an sToken
702+
* is present in the URL (transparently passes through otherwise). Sourced
703+
* here so the regular `LoginView` + `MainLayout` tree never re-reads the
704+
* URL query for authentication — see the spec "URL 파라미터 파싱 규약
705+
* (nuqs)" section for the invariant.
706+
*
707+
* On boundary success, the route-level `onSuccess` persists the login
708+
* state (`last_login`, `login_attempt`, saved-credential cleanup,
709+
* `api_endpoint`, `client.ready`) and nulls both `sToken` / `stoken` keys
710+
* from the URL via the nuqs setter so the token doesn't leak into browser
711+
* history or referer headers.
712+
*/
713+
const STokenGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
714+
'use memo';
715+
const [sToken, clearSToken] = useSToken();
716+
const setPluginApiEndpoint = useSetAtom(pluginApiEndpointState);
717+
if (!sToken) {
718+
return <>{children}</>;
719+
}
720+
return (
721+
<STokenLoginBoundary
722+
sToken={sToken}
723+
onSuccess={(client) => {
724+
persistPostLoginState(client);
725+
// Mirror LoginView's postConnectSetup so `PluginLoader` (which gates
726+
// on this atom) loads plugins on the sToken entry paths too. Without
727+
// this set, Electron and plugin-enabled deployments would leave
728+
// plugins permanently unloaded on any sToken-based login.
729+
const endpoint = (client as { _config?: { endpoint?: unknown } })
730+
?._config?.endpoint;
731+
if (typeof endpoint === 'string' && endpoint) {
732+
setPluginApiEndpoint(endpoint);
733+
}
734+
clearSToken(null);
735+
}}
736+
>
737+
{children}
738+
</STokenLoginBoundary>
739+
);
740+
};
741+
695742
/**
696743
* Root routes configuration
697744
*/
@@ -702,13 +749,15 @@ export const routes: RouteObject[] = [
702749
element: (
703750
<BAIErrorBoundary>
704751
<DefaultProvidersForReactRoot>
705-
<Suspense>
706-
<LoginView waitForMainLayout={false} />
707-
</Suspense>
708-
<LogoutEventHandler />
709-
<Suspense fallback={<Skeleton active />}>
710-
<InteractiveLoginPage />
711-
</Suspense>
752+
<STokenGuard>
753+
<Suspense>
754+
<LoginView waitForMainLayout={false} />
755+
</Suspense>
756+
<LogoutEventHandler />
757+
<Suspense fallback={<Skeleton active />}>
758+
<InteractiveLoginPage />
759+
</Suspense>
760+
</STokenGuard>
712761
</DefaultProvidersForReactRoot>
713762
</BAIErrorBoundary>
714763
),
@@ -771,33 +820,35 @@ export const routes: RouteObject[] = [
771820
element: (
772821
<BAIErrorBoundary>
773822
<DefaultProvidersForReactRoot>
774-
<Suspense>
775-
<LoginView />
776-
</Suspense>
777-
{/*FYI, MainLayout has ErrorBoundaryWithNullFallback for <Outlet/> */}
778-
<MainLayout />
779-
<ErrorBoundaryWithNullFallback>
780-
<RoutingEventHandler />
781-
</ErrorBoundaryWithNullFallback>
782-
<Suspense>
783-
<ErrorBoundaryWithNullFallback>
784-
<AutoDiagnosticsEffect />
785-
</ErrorBoundaryWithNullFallback>
786-
</Suspense>
787-
<Suspense>
788-
<ErrorBoundaryWithNullFallback>
789-
<LoginViewLazy />
790-
</ErrorBoundaryWithNullFallback>
791-
<ErrorBoundaryWithNullFallback>
792-
<FolderExplorerOpener />
793-
</ErrorBoundaryWithNullFallback>
794-
<ErrorBoundaryWithNullFallback>
795-
<FolderInvitationResponseModalOpener />
796-
</ErrorBoundaryWithNullFallback>
823+
<STokenGuard>
824+
<Suspense>
825+
<LoginView />
826+
</Suspense>
827+
{/*FYI, MainLayout has ErrorBoundaryWithNullFallback for <Outlet/> */}
828+
<MainLayout />
797829
<ErrorBoundaryWithNullFallback>
798-
<FileUploadManager />
830+
<RoutingEventHandler />
799831
</ErrorBoundaryWithNullFallback>
800-
</Suspense>
832+
<Suspense>
833+
<ErrorBoundaryWithNullFallback>
834+
<AutoDiagnosticsEffect />
835+
</ErrorBoundaryWithNullFallback>
836+
</Suspense>
837+
<Suspense>
838+
<ErrorBoundaryWithNullFallback>
839+
<LoginViewLazy />
840+
</ErrorBoundaryWithNullFallback>
841+
<ErrorBoundaryWithNullFallback>
842+
<FolderExplorerOpener />
843+
</ErrorBoundaryWithNullFallback>
844+
<ErrorBoundaryWithNullFallback>
845+
<FolderInvitationResponseModalOpener />
846+
</ErrorBoundaryWithNullFallback>
847+
<ErrorBoundaryWithNullFallback>
848+
<FileUploadManager />
849+
</ErrorBoundaryWithNullFallback>
850+
</Suspense>
851+
</STokenGuard>
801852
</DefaultProvidersForReactRoot>
802853
</BAIErrorBoundary>
803854
),

0 commit comments

Comments
 (0)