Skip to content

Commit 7fba64c

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 c9bf0d6 commit 7fba64c

3 files changed

Lines changed: 182 additions & 218 deletions

File tree

react/src/components/EduAppLauncher.tsx

Lines changed: 75 additions & 181 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
/**
@@ -43,13 +58,19 @@ export type EduAppSessionErrorCategory =
4358
* Staged state machine for the EduAppLauncher flow.
4459
*
4560
* Stages progress:
46-
* idle -> auth -> session -> launching -> done
61+
* idle -> session -> launching -> done
4762
* Any stage may transition to `error` with a step label. The Card UI
4863
* (FR-2487) consumes this state; FR-2484 introduces the machine only.
64+
*
65+
* Authentication is owned by the route-level `STokenLoginBoundary` wrapper —
66+
* by the time this component mounts, `globalThis.backendaiclient` is already
67+
* authenticated via sToken (or an existing session) and `connectViaGQL` has
68+
* resolved `groups` / `groupIds` / `current_group`. The legacy `auth`
69+
* stage is therefore removed; the launcher only visualizes session and
70+
* launch work.
4971
*/
5072
export type EduAppLaunchStage =
5173
| { name: 'idle' }
52-
| { name: 'auth' }
5374
| {
5475
name: 'session';
5576
sessionRowId: string;
@@ -64,7 +85,7 @@ export type EduAppLaunchStage =
6485
| { name: 'done'; appConnectUrl: string }
6586
| {
6687
name: 'error';
67-
step: 'auth' | 'session' | 'launch';
88+
step: 'session' | 'launch';
6889
category?: EduAppSessionErrorCategory;
6990
message: string;
7091
};
@@ -94,6 +115,8 @@ const g = globalThis as any;
94115
const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
95116
apiEndpoint,
96117
active,
118+
sToken = null,
119+
extraParams = {},
97120
}) => {
98121
'use memo';
99122

@@ -164,103 +187,19 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
164187
};
165188

166189
/**
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.
190+
* Attach the wsproxy URL from `config.toml` to the already-authenticated
191+
* Backend.AI client set up by `STokenLoginBoundary` (route-level). The
192+
* boundary owns client creation and authentication (sToken login,
193+
* `get_manager_version`, `client.ready`); EduAppLauncher only layers on
194+
* the proxyURL needed for the app-launch step, which is not part of the
195+
* boundary's narrow scope.
174196
*/
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-
);
197+
const _attachProxyURL = async () => {
192198
const configPath = g.isElectron ? './config.toml' : '../../config.toml';
193199
const { config: tomlConfig } = await fetchAndParseConfig(configPath);
194-
if (tomlConfig?.wsproxy?.proxyURL) {
200+
if (tomlConfig?.wsproxy?.proxyURL && g.backendaiclient?._config) {
195201
g.backendaiclient._config._proxyURL = tomlConfig.wsproxy.proxyURL;
196202
}
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-
}
237-
};
238-
239-
/**
240-
* Fetch and cache group/project information for the current user.
241-
*/
242-
const _prepareProjectInformation = async () => {
243-
const fields = ['email', 'groups {name, id}'];
244-
const query = `query { user{ ${fields.join(' ')} } }`;
245-
const response = await g.backendaiclient.query(query, {});
246-
247-
g.backendaiclient.groups = response.user.groups
248-
.map((item: { name: string }) => item.name)
249-
.sort();
250-
g.backendaiclient.groupIds = response.user.groups.reduce(
251-
(acc: Record<string, string>, group: { name: string; id: string }) => {
252-
acc[group.name] = group.id;
253-
return acc;
254-
},
255-
{},
256-
);
257-
const currentProject = g.backendaiutils._readRecentProjectGroup();
258-
g.backendaiclient.current_group = currentProject
259-
? currentProject
260-
: g.backendaiclient.groups[0];
261-
g.backendaiclient.current_group_id = () => {
262-
return g.backendaiclient.groupIds[g.backendaiclient.current_group];
263-
};
264203
};
265204

266205
/**
@@ -396,12 +335,11 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
396335
return;
397336
}
398337

399-
const urlParams = new URLSearchParams(window.location.search);
400-
const requestedApp = urlParams.get('app') || 'jupyter';
338+
const requestedApp = extraParams.app || 'jupyter';
401339
let parsedAppName = requestedApp;
402340
const sessionTemplateName =
403-
urlParams.get('session_template') ||
404-
urlParams.get('sessionTemplate') ||
341+
extraParams.session_template ||
342+
extraParams.sessionTemplate ||
405343
requestedApp;
406344

407345
if (eduAppNamePrefix !== '' && requestedApp.startsWith(eduAppNamePrefix)) {
@@ -619,8 +557,10 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
619557
});
620558

621559
// 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');
560+
// AND the customer-specific endpoint is available). The sToken
561+
// prop comes from the route-level `STokenLoginBoundary` wrapper
562+
// and is captured before the URL is cleaned on successful auth,
563+
// so this call still receives the original token.
624564
logger.info('[_createEduSession] step 4c: get_user_credential', {
625565
has_sToken: !!sToken,
626566
});
@@ -737,78 +677,44 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
737677
};
738678

739679
/**
740-
* Main launch sequence: init client, token login, prepare project,
741-
* then start or reuse session and launch the app.
680+
* Main launch sequence: attach proxy URL, then start or reuse a session
681+
* and launch the app. sToken authentication and project/group bootstrap
682+
* are handled upstream by `STokenLoginBoundary` (route wrapper in
683+
* `EduAppLauncherPage`); by the time this function runs
684+
* `globalThis.backendaiclient` is already authenticated, `ready`, and
685+
* has `groups` / `current_group` populated by `connectViaGQL`. URL
686+
* parameters are passed via `extraParams` / `sToken` props rather than
687+
* re-parsing `window.location` (spec "URL 파라미터 파싱 규약 (nuqs)").
742688
*/
743689
const _launch = async (endpoint: string) => {
744690
logger.info('[EduAppLauncher] _launch() start', { endpoint });
745-
transition({ name: 'auth' });
746691
try {
747-
await _initClient(endpoint);
692+
await _attachProxyURL();
748693
} catch (err) {
749-
logger.error('Failed to initialize client:', err);
694+
logger.error('Failed to attach wsproxy URL:', err);
750695
transition({
751696
name: 'error',
752-
step: 'auth',
697+
step: 'session',
753698
message: t('eduapi.CannotInitializeClient'),
754699
});
755700
notify(t('eduapi.CannotInitializeClient'), undefined, true);
756701
return;
757702
}
758703

759-
const urlParams = new URLSearchParams(window.location.search);
760704
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'),
705+
cpu: extraParams.cpu ?? null,
706+
mem: extraParams.mem ?? null,
707+
shmem: extraParams.shmem ?? null,
708+
'cuda.shares': extraParams['cuda-shares'] ?? null,
709+
'cuda.device': extraParams['cuda-device'] ?? null,
766710
};
767711

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-
793-
try {
794-
await _prepareProjectInformation();
795-
} catch (err) {
796-
logger.error('Failed to prepare project information:', err);
797-
transition({
798-
name: 'error',
799-
step: 'auth',
800-
message: (err as any)?.message ?? String(err),
801-
});
802-
_handleError(err);
803-
return;
804-
}
805-
806-
const sessionId = urlParams.get('session_id') || null;
712+
const sessionId = extraParams.session_id || null;
807713
if (sessionId) {
808714
logger.info('[EduAppLauncher] _launch: Path A (session_id provided)', {
809715
sessionId,
810716
});
811-
const requestedApp = urlParams.get('app') || 'jupyter';
717+
const requestedApp = extraParams.app || 'jupyter';
812718
transition({
813719
name: 'session',
814720
sessionRowId: sessionId,
@@ -849,53 +755,45 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
849755
}, [active, apiEndpoint]);
850756

851757
// Map the state machine stage to the visual Steps component:
852-
// step 0 = Authentication
853-
// step 1 = Session (lookup or create)
854-
// step 2 = Launch (proxy + window.open)
758+
// step 0 = Session (lookup or create)
759+
// step 1 = Launch (proxy + window.open)
760+
// - Authentication is handled upstream by `STokenLoginBoundary` and is
761+
// not represented in this stepper at all.
855762
// - Stages strictly before the current step are 'finish'.
856763
// - The current step is 'process', or 'error' when stage.name === 'error'
857764
// and the error step matches.
858765
// - Stages after the current step are 'wait'.
859-
// - On 'done', all three steps are 'finish'.
766+
// - On 'done', both steps are 'finish'.
860767
// No `useMemo` needed: `'use memo'` directive at the top of this
861768
// component lets the React Compiler memoize derived values automatically.
862-
const STEP_AUTH = 0;
863-
const STEP_SESSION = 1;
864-
const STEP_LAUNCH = 2;
865-
let currentStep = STEP_AUTH;
769+
const STEP_SESSION = 0;
770+
const STEP_LAUNCH = 1;
771+
let currentStep = STEP_SESSION;
866772
let stepStatuses: Array<'wait' | 'process' | 'finish' | 'error'> = [
867773
'wait',
868774
'wait',
869-
'wait',
870775
];
871776
switch (stage.name) {
872777
case 'idle':
873-
case 'auth':
874-
currentStep = STEP_AUTH;
875-
stepStatuses = ['process', 'wait', 'wait'];
876-
break;
877778
case 'session':
878779
currentStep = STEP_SESSION;
879-
stepStatuses = ['finish', 'process', 'wait'];
780+
stepStatuses = ['process', 'wait'];
880781
break;
881782
case 'launching':
882783
currentStep = STEP_LAUNCH;
883-
stepStatuses = ['finish', 'finish', 'process'];
784+
stepStatuses = ['finish', 'process'];
884785
break;
885786
case 'done':
886787
currentStep = STEP_LAUNCH;
887-
stepStatuses = ['finish', 'finish', 'finish'];
788+
stepStatuses = ['finish', 'finish'];
888789
break;
889790
case 'error': {
890-
if (stage.step === 'auth') {
891-
currentStep = STEP_AUTH;
892-
stepStatuses = ['error', 'wait', 'wait'];
893-
} else if (stage.step === 'session') {
791+
if (stage.step === 'session') {
894792
currentStep = STEP_SESSION;
895-
stepStatuses = ['finish', 'error', 'wait'];
793+
stepStatuses = ['error', 'wait'];
896794
} else {
897795
currentStep = STEP_LAUNCH;
898-
stepStatuses = ['finish', 'finish', 'error'];
796+
stepStatuses = ['finish', 'error'];
899797
}
900798
break;
901799
}
@@ -984,17 +882,13 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
984882
orientation="vertical"
985883
current={currentStep}
986884
items={[
987-
{
988-
title: t('eduapi.CheckingAuthentication'),
989-
status: stepStatuses[0],
990-
},
991885
{
992886
title: t('eduapi.PreparingSession'),
993-
status: stepStatuses[1],
887+
status: stepStatuses[0],
994888
},
995889
{
996890
title: t('eduapi.LaunchingAppStep'),
997-
status: stepStatuses[2],
891+
status: stepStatuses[1],
998892
},
999893
]}
1000894
/>

0 commit comments

Comments
 (0)