Skip to content

Commit 541721c

Browse files
committed
feat(FR-2627): migrate EduAppLauncher sToken path to STokenLoginBoundary
Story 3 of Epic FR-2616. EduAppLauncher no longer owns sToken authentication or `window.location` parsing. The route page wraps the component with STokenLoginBoundary via a useSToken-backed capture, and the component receives sToken and remaining query parameters as props. EduAppLauncherPage (react/src/pages/EduAppLauncherPage.tsx): - Reads sToken via useSToken (nuqs) and snapshots it with useState before any onSuccess-driven URL cleanup. - Collects remaining URL params (app, session_id, resource hints) once at mount into capturedExtraParams. - When sToken is present, wraps its content in STokenLoginBoundary with the captured sToken and extraParams. onSuccess calls persistPostLoginState (counters, credential cleanup, endpoint localStorage, client.ready) and clearSToken(null) to strip sToken / stoken from the URL without touching unrelated params (spec Pitfall #7). - Without an sToken, the boundary is skipped and the launcher runs directly (existing edu integrations that carry a valid session cookie without a URL token keep working). - Threads sToken and extraParams through EduAppLauncherPageContent to EduAppLauncher as props. EduAppLauncher (react/src/components/EduAppLauncher.tsx): - New optional props `sToken: string | null` and `extraParams: Record<string, string>`; legacy callers that pass neither still work (defaults provided). - Removes `_token_login` and the manual `document.dispatchEvent( 'backend-ai-connected')` call at the top of `_launch`; the boundary owns both now. - Replaces `_initClient` (client creation + proxyURL + get_manager _version + ready) with `_attachProxyURL`, which only layers the wsproxy URL on top of the boundary-authenticated client. Client construction, version ping, and `ready` flag are owned by the boundary + persistPostLoginState. - `_launch` and `_createEduSession` read app / session_id / resource hints from `extraParams` and sToken from the `sToken` prop instead of `new URLSearchParams(window.location.search)`. The existing customer-specific `eduApp.get_user_credential(sToken)` call in step 4c keeps working because the prop captures the token before the URL-cleanup round-trips back to render (FR-2640 resolution). Behavior change: - sToken is now stripped from the URL after successful boundary auth (spec Story 3). Prior EduAppLauncher left it in place, which leaked the token into browser history / referer headers. Other query params remain as before. Resolves FR-2641, FR-2642 (bundled — same files, tightly coupled). FR-2640 is resolved separately as investigation-only. FR-2643 (E2E regression) follows in a separate PR. Refs FR-2616, FR-2627
1 parent f062859 commit 541721c

3 files changed

Lines changed: 149 additions & 145 deletions

File tree

react/src/components/EduAppLauncher.tsx

Lines changed: 48 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ import { useBackendAIAppLauncherFragment$key } from 'src/__generated__/useBacken
2323
interface EduAppLauncherProps {
2424
apiEndpoint: string;
2525
active: boolean;
26+
/**
27+
* sToken captured by the route-level `STokenLoginBoundary` wrapper before
28+
* the URL was cleaned. The boundary already authenticated with this token;
29+
* the value is only plumbed through so customer-specific code paths that
30+
* still reference the token (e.g. `eduApp.get_user_credential` inside
31+
* `_createEduSession`) can continue to function after the URL cleanup.
32+
* `null` when the page is opened without an sToken URL.
33+
*/
34+
sToken?: string | null;
35+
/**
36+
* Remaining URL query parameters (all keys except `sToken` / `stoken`)
37+
* captured at route mount. Carries app / session_id / resource hints that
38+
* drive the launch sequence without re-parsing `window.location`.
39+
*/
40+
extraParams?: Record<string, string>;
2641
}
2742

2843
/**
@@ -94,6 +109,8 @@ const g = globalThis as any;
94109
const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
95110
apiEndpoint,
96111
active,
112+
sToken = null,
113+
extraParams = {},
97114
}) => {
98115
'use memo';
99116

@@ -164,76 +181,19 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
164181
};
165182

166183
/**
167-
* Initialize the backend.ai client with session-based auth mode.
168-
*
169-
* The caller (`EduAppLauncherPage`) is responsible for providing a
170-
* non-empty endpoint via `useResolvedApiEndpoint()`, which reads from
171-
* `config.toml` and suspends until resolved. If the endpoint is still
172-
* empty here, client initialization will be rejected and the outer
173-
* catch in `_launch` will surface the error via notification.
184+
* Attach the wsproxy URL from `config.toml` to the already-authenticated
185+
* Backend.AI client set up by `STokenLoginBoundary` (route-level). The
186+
* boundary owns client creation and authentication (sToken login,
187+
* `get_manager_version`, `client.ready`); EduAppLauncher only layers on
188+
* the proxyURL needed for the app-launch step, which is not part of the
189+
* boundary's narrow scope.
174190
*/
175-
const _initClient = async (endpoint: string) => {
176-
const resolvedEndpoint = endpoint.trim();
177-
178-
if (!resolvedEndpoint) {
179-
throw new Error('API endpoint is empty; cannot initialize client.');
180-
}
181-
182-
const clientConfig = new g.BackendAIClientConfig(
183-
'',
184-
'',
185-
resolvedEndpoint,
186-
'SESSION',
187-
);
188-
g.backendaiclient = new g.BackendAIClient(
189-
clientConfig,
190-
'Backend.AI Web UI.',
191-
);
191+
const _attachProxyURL = async () => {
192192
const configPath = g.isElectron ? './config.toml' : '../../config.toml';
193193
const { config: tomlConfig } = await fetchAndParseConfig(configPath);
194-
if (tomlConfig?.wsproxy?.proxyURL) {
194+
if (tomlConfig?.wsproxy?.proxyURL && g.backendaiclient?._config) {
195195
g.backendaiclient._config._proxyURL = tomlConfig.wsproxy.proxyURL;
196196
}
197-
await g.backendaiclient.get_manager_version();
198-
g.backendaiclient.ready = true;
199-
};
200-
201-
/**
202-
* Authenticate via token from URL query parameters.
203-
*/
204-
const _token_login = async (): Promise<boolean> => {
205-
const urlParams = new URLSearchParams(window.location.search);
206-
const sToken = urlParams.get('sToken') || urlParams.get('stoken') || null;
207-
208-
if (sToken !== null) {
209-
document.cookie = `sToken=${encodeURIComponent(sToken)}; path=/; Secure; SameSite=Lax`;
210-
}
211-
212-
const extraParams: Record<string, string> = {};
213-
for (const [key, value] of urlParams.entries()) {
214-
if (key !== 'sToken' && key !== 'stoken') {
215-
extraParams[key] = value;
216-
}
217-
}
218-
219-
try {
220-
const alreadyLoggedIn = await g.backendaiclient.check_login();
221-
if (!alreadyLoggedIn) {
222-
const loginSuccess = await g.backendaiclient.token_login(
223-
sToken,
224-
extraParams,
225-
);
226-
if (!loginSuccess) {
227-
notify(t('eduapi.CannotAuthorizeSessionByToken'));
228-
return false;
229-
}
230-
}
231-
return true;
232-
} catch (err) {
233-
logger.error('Token login failed:', err);
234-
notify(t('eduapi.CannotAuthorizeSessionByToken'));
235-
return false;
236-
}
237197
};
238198

239199
/**
@@ -396,12 +356,11 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
396356
return;
397357
}
398358

399-
const urlParams = new URLSearchParams(window.location.search);
400-
const requestedApp = urlParams.get('app') || 'jupyter';
359+
const requestedApp = extraParams.app || 'jupyter';
401360
let parsedAppName = requestedApp;
402361
const sessionTemplateName =
403-
urlParams.get('session_template') ||
404-
urlParams.get('sessionTemplate') ||
362+
extraParams.session_template ||
363+
extraParams.sessionTemplate ||
405364
requestedApp;
406365

407366
if (eduAppNamePrefix !== '' && requestedApp.startsWith(eduAppNamePrefix)) {
@@ -619,8 +578,10 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
619578
});
620579

621580
// 4c. credential bootstrap script (only when an sToken is present
622-
// AND the customer-specific endpoint is available).
623-
const sToken = urlParams.get('sToken') || urlParams.get('stoken');
581+
// AND the customer-specific endpoint is available). The sToken
582+
// prop comes from the route-level `STokenLoginBoundary` wrapper
583+
// and is captured before the URL is cleaned on successful auth,
584+
// so this call still receives the original token.
624585
logger.info('[_createEduSession] step 4c: get_user_credential', {
625586
has_sToken: !!sToken,
626587
});
@@ -737,16 +698,21 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
737698
};
738699

739700
/**
740-
* Main launch sequence: init client, token login, prepare project,
741-
* then start or reuse session and launch the app.
701+
* Main launch sequence: attach proxy URL, prepare project, then start or
702+
* reuse session and launch the app. sToken authentication is handled
703+
* upstream by `STokenLoginBoundary` (route wrapper in
704+
* `EduAppLauncherPage`); by the time this function runs
705+
* `globalThis.backendaiclient` is already authenticated and `ready`.
706+
* URL parameters are passed via `extraParams` / `sToken` props rather
707+
* than re-parsing `window.location` (spec "URL 파라미터 파싱 규약 (nuqs)").
742708
*/
743709
const _launch = async (endpoint: string) => {
744710
logger.info('[EduAppLauncher] _launch() start', { endpoint });
745711
transition({ name: 'auth' });
746712
try {
747-
await _initClient(endpoint);
713+
await _attachProxyURL();
748714
} catch (err) {
749-
logger.error('Failed to initialize client:', err);
715+
logger.error('Failed to attach wsproxy URL:', err);
750716
transition({
751717
name: 'error',
752718
step: 'auth',
@@ -756,40 +722,14 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
756722
return;
757723
}
758724

759-
const urlParams = new URLSearchParams(window.location.search);
760725
const resources: Record<string, string | null> = {
761-
cpu: urlParams.get('cpu'),
762-
mem: urlParams.get('mem'),
763-
shmem: urlParams.get('shmem'),
764-
'cuda.shares': urlParams.get('cuda-shares'),
765-
'cuda.device': urlParams.get('cuda-device'),
726+
cpu: extraParams.cpu ?? null,
727+
mem: extraParams.mem ?? null,
728+
shmem: extraParams.shmem ?? null,
729+
'cuda.shares': extraParams['cuda-shares'] ?? null,
730+
'cuda.device': extraParams['cuda-device'] ?? null,
766731
};
767732

768-
const loginSuccess = await _token_login();
769-
if (!loginSuccess) {
770-
transition({
771-
name: 'error',
772-
step: 'auth',
773-
message: t('eduapi.CannotAuthorizeSessionByToken'),
774-
});
775-
return;
776-
}
777-
778-
// Dispatch `backend-ai-connected` so any descendant component using
779-
// `useSuspendedBackendaiClient()` (e.g. `useBackendAIAppLauncher` deep
780-
// inside `EduAppSessionLauncher`) can resolve. The shared
781-
// `backendaiClientPromise` is created at module load time and only
782-
// resolves on this event — `LoginView` dispatches it after a normal
783-
// login, but the EduAppLauncher token-login flow bypasses LoginView
784-
// entirely, so we have to dispatch it ourselves once the client is
785-
// authenticated and ready.
786-
logger.info('[EduAppLauncher] dispatching backend-ai-connected');
787-
document.dispatchEvent(
788-
new CustomEvent('backend-ai-connected', {
789-
detail: g.backendaiclient,
790-
}),
791-
);
792-
793733
try {
794734
await _prepareProjectInformation();
795735
} catch (err) {
@@ -803,12 +743,12 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
803743
return;
804744
}
805745

806-
const sessionId = urlParams.get('session_id') || null;
746+
const sessionId = extraParams.session_id || null;
807747
if (sessionId) {
808748
logger.info('[EduAppLauncher] _launch: Path A (session_id provided)', {
809749
sessionId,
810750
});
811-
const requestedApp = urlParams.get('app') || 'jupyter';
751+
const requestedApp = extraParams.app || 'jupyter';
812752
transition({
813753
name: 'session',
814754
sessionRowId: sessionId,

react/src/pages/EduAppLauncherPage.tsx

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,38 +14,43 @@ const EduAppLauncherLazy = React.lazy(
1414
* Standalone page for education app launcher.
1515
* Renders outside MainLayout (no sidebar).
1616
*
17-
* Because this page is entered via a token URL and never goes through
18-
* LoginView, it resolves the API endpoint directly from `config.toml`
19-
* via `useResolvedApiEndpoint()`. The Suspense boundary below gates
20-
* EduAppLauncher rendering until endpoint resolution completes; the
21-
* resolved value may still be an empty string if every fallback source
22-
* is unavailable, in which case `EduAppLauncher._initClient` throws and
23-
* surfaces the error via notification.
24-
*
25-
* Notifications are rendered via `useSetBAINotification` inside
26-
* `EduAppLauncher`, which works on this anonymous page because
27-
* `DefaultProvidersForReactRoot` already wraps it in antd `App` (the
28-
* provider `App.useApp()` requires). The legacy
29-
* `NotificationForAnonymous` CustomEvent bridge is no longer needed
30-
* here; it remains exported for the other anonymous pages and
31-
* `MainLayout` that still rely on it.
17+
* sToken authentication is handled upstream at the route level (see
18+
* `EduAppSTokenRoute` in `routes.tsx`), which captures `sToken` /
19+
* `extraParams` from the URL before the boundary's `onSuccess` strips
20+
* the token, then passes them down as props. This page is only
21+
* responsible for resolving the API endpoint via `useResolvedApiEndpoint`
22+
* (suspends until `config.toml` load completes) and threading the
23+
* captured values into `EduAppLauncher`.
3224
*/
33-
const EduAppLauncherPage: React.FC = () => {
25+
const EduAppLauncherPage: React.FC<{
26+
sToken: string | null;
27+
extraParams: Record<string, string>;
28+
}> = ({ sToken, extraParams }) => {
3429
return (
3530
<>
3631
<CSSTokenVariables />
3732
<Suspense fallback={null}>
38-
<EduAppLauncherPageContent />
33+
<EduAppLauncherPageContent sToken={sToken} extraParams={extraParams} />
3934
</Suspense>
4035
</>
4136
);
4237
};
4338

44-
const EduAppLauncherPageContent: React.FC = () => {
39+
const EduAppLauncherPageContent: React.FC<{
40+
sToken: string | null;
41+
extraParams: Record<string, string>;
42+
}> = ({ sToken, extraParams }) => {
4543
'use memo';
4644
const apiEndpoint = useResolvedApiEndpoint();
4745

48-
return <EduAppLauncherLazy apiEndpoint={apiEndpoint} active={true} />;
46+
return (
47+
<EduAppLauncherLazy
48+
apiEndpoint={apiEndpoint}
49+
active={true}
50+
sToken={sToken}
51+
extraParams={extraParams}
52+
/>
53+
);
4954
};
5055

5156
export default EduAppLauncherPage;

0 commit comments

Comments
 (0)