Skip to content

Commit de58f88

Browse files
nowgnuesLeeclaude
andcommitted
fix(FR-2574): fix sToken SSO login on root URL
Three bugs prevented sToken-based SSO login from working on /?sToken=...: 1. Race condition: useLoginOrchestration fired before apiEndpoint state was populated, causing connectUsingSession to bail on empty endpoint. Fix: gate orchestration on both isConfigLoaded AND apiEndpoint. 2. Full page reload: window.location.href='/' after tokenLogin stripped the sToken and caused a second load without credentials. Fix: replace with in-React post-connection setup + history.replaceState. 3. Missing session persistence: token_login() didn't save _loginSessionId to localStorage, so check_login() failed after page refresh. Fix: add localStorage.setItem in token_login() success path. Resolves #6692(FR-2574) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ab84bd0 commit de58f88

3 files changed

Lines changed: 82 additions & 20 deletions

File tree

react/src/components/LoginView.tsx

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -336,25 +336,20 @@ const LoginView: React.FC<{
336336
localStorage.removeItem('backendaiwebui.login.password');
337337
}, []);
338338

339-
const doGQLConnect = useCallback(
340-
async (client: ReturnType<typeof createBackendAIClient>['client']) => {
341-
// Read directly from Jotai store to get the latest config synchronously,
342-
// including any merged webserver config from loadConfigFromWebServer().
343-
// Using configRef.current here would return stale config because React
344-
// hasn't re-rendered yet after the Jotai atom update.
345-
const cfg = jotaiStore.get(loginConfigState) ?? configRef.current;
339+
// Shared post-connection setup used by both the regular session login
340+
// (doGQLConnect) and the sToken SSO path. Keeps the two flows consistent:
341+
// login_attempt/last_login counters, saved-credentials cleanup, panel
342+
// close, connected-event dispatch, and endpoint persistence.
343+
const postConnectSetup = useCallback(
344+
(client: ReturnType<typeof createBackendAIClient>['client']) => {
346345
const currentTime = Math.floor(Date.now() / 1000);
347-
348346
(globalThis as any).backendaioptions.set(
349347
'last_login',
350348
currentTime,
351349
'general',
352350
);
353351
(globalThis as any).backendaioptions.set('login_attempt', 0, 'general');
354352

355-
const updatedEndpoints = await connectViaGQL(client, cfg, endpoints);
356-
setEndpoints(updatedEndpoints);
357-
358353
const event = new CustomEvent('backend-ai-connected', {
359354
detail: client,
360355
});
@@ -374,16 +369,15 @@ const LoginView: React.FC<{
374369
clearSavedLoginInfo();
375370
// Read the endpoint from the connected client to avoid stale closure
376371
// values. When handleLogin calls setApiEndpoint(ep) then immediately
377-
// invokes connectUsingSession, the doGQLConnect closure still captures
378-
// the OLD apiEndpoint (often "" on first launch). The client object
379-
// always has the correct endpoint that was used for the connection.
372+
// invokes connectUsingSession, the closure still captures the OLD
373+
// apiEndpoint (often "" on first launch). The client object always
374+
// has the correct endpoint that was used for the connection.
380375
const connectedEndpoint =
381376
(globalThis as any).backendaiclient?._config?.endpoint || apiEndpoint;
382377
localStorage.setItem('backendaiwebui.api_endpoint', connectedEndpoint);
383378
setPluginApiEndpoint(connectedEndpoint);
384379
},
385380
[
386-
endpoints,
387381
close,
388382
clearSavedLoginInfo,
389383
apiEndpoint,
@@ -392,6 +386,22 @@ const LoginView: React.FC<{
392386
],
393387
);
394388

389+
const doGQLConnect = useCallback(
390+
async (client: ReturnType<typeof createBackendAIClient>['client']) => {
391+
// Read directly from Jotai store to get the latest config synchronously,
392+
// including any merged webserver config from loadConfigFromWebServer().
393+
// Using configRef.current here would return stale config because React
394+
// hasn't re-rendered yet after the Jotai atom update.
395+
const cfg = jotaiStore.get(loginConfigState) ?? configRef.current;
396+
397+
const updatedEndpoints = await connectViaGQL(client, cfg, endpoints);
398+
setEndpoints(updatedEndpoints);
399+
400+
postConnectSetup(client);
401+
},
402+
[endpoints, postConnectSetup],
403+
);
404+
395405
const connectUsingSession = useCallback(
396406
async (showError = true, endpointOverride?: string) => {
397407
const ep = (endpointOverride ?? apiEndpoint).trim();
@@ -455,11 +465,26 @@ const LoginView: React.FC<{
455465
endpoints,
456466
);
457467
setEndpoints(updatedEndpoints);
458-
window.location.href = '/';
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({}, '', '/');
459478
return;
460-
} catch {
479+
} catch (err) {
480+
logger.error('tokenLogin failed', err);
461481
notification(t('eduapi.CannotAuthorizeSessionByToken'));
462-
window.location.href = '/';
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);
463488
return;
464489
}
465490
}

react/src/hooks/useLoginOrchestration.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,34 @@ export function useLoginOrchestration({
204204
});
205205

206206
useEffect(() => {
207+
// Wait until config is loaded before running the orchestration.
208+
//
209+
// When apiEndpoint is temporarily empty due to effect ordering
210+
// (setApiEndpoint() in LoginView runs in a separate useEffect on the
211+
// same render cycle), we want to avoid a premature silent session-login
212+
// attempt because connectUsingSession would receive an empty endpoint
213+
// and bail out before reaching the sToken check.
214+
//
215+
// However, an empty api_endpoint can also be intentional: the user may
216+
// need to enter it manually in the login UI. In that case the
217+
// orchestrator must still run so it can open the login panel.
218+
//
219+
// Therefore, only defer orchestration for a missing endpoint when a
220+
// stored sToken exists (or one arrives via URL) and a silent login or
221+
// SSO flow may be attempted. On Electron we still allow an empty
222+
// apiEndpoint because the orchestrator falls back to the
223+
// localStorage-persisted endpoint.
224+
const isElectron = !!(globalThis as Record<string, unknown>).isElectron;
225+
const hasStoredSessionToken =
226+
typeof window !== 'undefined' &&
227+
(window.localStorage.getItem('sToken') !== null ||
228+
window.sessionStorage.getItem('sToken') !== null ||
229+
new URLSearchParams(window.location.search).has('sToken'));
230+
207231
if (!isConfigLoaded || hasRunRef.current) return;
232+
if (!apiEndpoint && !isElectron && hasStoredSessionToken) return;
208233
hasRunRef.current = true;
209234

210235
doOrchestrate();
211-
}, [isConfigLoaded]);
236+
}, [isConfigLoaded, apiEndpoint]);
212237
}

src/lib/backend.ai-client-esm.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1078,9 +1078,21 @@ class Client {
10781078
const result = await this._wrapWithPromise(rqst);
10791079
if (result.authenticated === true) {
10801080
await this.get_manager_version();
1081+
// Persist the login session ID so that the session survives a
1082+
// page refresh — same as the regular login() path.
1083+
if (this._loginSessionId !== null && this._loginSessionId !== '') {
1084+
localStorage.setItem(
1085+
'backendaiwebui.sessionid',
1086+
this._loginSessionId,
1087+
);
1088+
}
10811089
return this.check_login();
10821090
} else if (result.authenticated === false) {
1083-
// Authentication failed.
1091+
// Authentication failed. Clear any stale session id that may have
1092+
// been persisted by a previous login so that subsequent
1093+
// check_login() calls don't confuse it with a live session.
1094+
// Mirrors the regular login() failure-path behavior.
1095+
localStorage.removeItem('backendaiwebui.sessionid');
10841096
if (result.data && result.data.details) {
10851097
return Promise.resolve({ fail_reason: result.data.details });
10861098
} else {

0 commit comments

Comments
 (0)