Skip to content

Commit aec9ce5

Browse files
committed
feat(FR-2616): handle totp and concurrent-session inline in STokenLoginBoundary
- STokenLoginError gains `totp-required` (with `invalidOtp` hint); the existing `concurrent-session` kind is now wired up end-to-end. - `tokenLogin` helper throws a structured `TokenLoginFailedError` instead of a generic `Error`, fixing a latent bug where a `{ fail_reason }` return value (truthy object) was treated as success by the caller. - `STokenLoginBoundary` keeps a pending OTP ref (single-use) and a sticky `forceApprovedRef`; both fold into `extraParams` on the next retry. - `DefaultErrorCard` swaps its action area in place for `totp-required` (OTP input + Submit) and `concurrent-session` (Copy details + Sign in anyway) — no separate modal, no layout split. Card status shifts from `error` to `warning` for these two kinds so the user reads them as required follow-ups, not terminal failures. - Classification uses duck-typed field extraction instead of `instanceof TokenLoginFailedError` so cross-module-instance errors (Jest mocks, HMR reloads) classify the same. - TODO(user-tunable): once `client.token_login` surfaces the probe `type`, replace the string-matching classifier with `type` comparisons. The current substrings mirror LoginView's legacy fallback.
1 parent d650bff commit aec9ce5

25 files changed

Lines changed: 899 additions & 125 deletions

e2e/auth/stoken-login.spec.ts

Lines changed: 271 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,95 @@
1313
* - URL preservation on error: the boundary only strips `sToken`
1414
* after a successful login, so an invalid token remains in the URL
1515
* and the user's Retry can still pick it up.
16+
*
17+
* Additionally, this file mocks `/server/token-login` to exercise the
18+
* interactive paths that do not require a customer-specific auth plugin
19+
* to reproduce:
20+
* - `require-totp-authentication` → inline OTP form
21+
* - `active-login-session-exists` → "Logged in elsewhere" + Login
22+
* - sticky retries fold both `otp` and `force: true` into the same
23+
* body when the user satisfies both challenges in sequence.
1624
*/
1725
import { webuiEndpoint } from '../utils/test-util';
18-
import { expect, test } from '@playwright/test';
26+
import { expect, test, type Page } from '@playwright/test';
27+
28+
/**
29+
* Mock webserver version response returned by the boundary's
30+
* `get_manager_version()` probe. Needed so the ping step passes and the
31+
* sequence actually reaches `token_login` where the test-specific
32+
* response is fulfilled.
33+
*/
34+
const MOCK_SERVER_VERSION = {
35+
manager: '25.0.0',
36+
version: 'v6.20220615',
37+
'backend.ai': '25.0.0',
38+
};
39+
40+
/** Not-logged-in envelope for the fast-path session probe. */
41+
const MOCK_LOGIN_CHECK_NOT_AUTHED = { authenticated: false };
42+
43+
const TOTP_REQUIRED_RESPONSE = {
44+
authenticated: false,
45+
data: {
46+
type: 'https://api.backend.ai/probs/require-totp-authentication',
47+
title: 'Two-Factor Authentication needed.',
48+
details: 'You must authenticate using Two-Factor Authentication.',
49+
},
50+
};
51+
52+
const CONCURRENT_SESSION_RESPONSE = {
53+
authenticated: false,
54+
data: {
55+
type: 'https://api.backend.ai/probs/active-login-session-exists',
56+
title: 'Too many concurrent login sessions for this user.',
57+
details: 'Internal server error',
58+
},
59+
};
60+
61+
const AUTH_FAILED_INERT_RESPONSE = {
62+
authenticated: false,
63+
data: {
64+
type: 'https://api.backend.ai/probs/auth-failed',
65+
title: 'stub',
66+
details: 'stub',
67+
},
68+
};
69+
70+
/**
71+
* Fixture JWT-shaped sToken used to exercise the interactive flows.
72+
* It never actually authenticates — the tests mock `/server/token-login`
73+
* — but using a realistic shape ensures no assumptions about length,
74+
* URL-encoding safety, or signed-section format accidentally leak into
75+
* the classifier or cookie-writing code.
76+
*/
77+
const FIXTURE_STOKEN =
78+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3Nfa2V5IjoiQUtJQUlPU0ZPRE5ON0VYQU1QTEUiLCJzZWNyZXRfa2V5Ijoid0phbHJYVXRuRkVNSS9LN01ERU5HL2JQeFJmaUNZRVhBTVBMRUtFWSJ9.BC4eXVHEG0_HhYknddlUo8NlUnr0aY99qpXAO5FfN5g';
79+
80+
/**
81+
* Install the ping + existing-session probes once so the boundary reaches
82+
* `token_login`. Individual tests register `**\/server/token-login` on
83+
* top of this to drive the flow they care about.
84+
*/
85+
async function installBoundaryProbeMocks(page: Page): Promise<void> {
86+
await page.route('**/func/', async (route) => {
87+
if (route.request().method() !== 'GET') {
88+
await route.continue();
89+
return;
90+
}
91+
await route.fulfill({
92+
status: 200,
93+
contentType: 'application/json',
94+
body: JSON.stringify(MOCK_SERVER_VERSION),
95+
});
96+
});
97+
await page.route('**/server/login-check', async (route) => {
98+
await route.fulfill({
99+
status: 200,
100+
contentType: 'application/json',
101+
body: JSON.stringify(MOCK_LOGIN_CHECK_NOT_AUTHED),
102+
});
103+
});
104+
}
19105

20106
test.describe(
21107
'sToken login boundary (LoginView routes)',
@@ -74,3 +160,187 @@ test.describe(
74160
});
75161
},
76162
);
163+
164+
test.describe(
165+
'sToken login boundary (interactive flows)',
166+
{ tag: ['@regression', '@auth', '@functional'] },
167+
() => {
168+
/**
169+
* Critical: the webserver signals `authenticated: false` with a 200
170+
* status. `client.token_login` only routes into the `{ fail_reason,
171+
* fail_type }` branch when `_wrapWithPromise` resolves; any non-2xx
172+
* is rethrown as a generic "no manager found" and misclassified as
173+
* `token-invalid`. Every mock below therefore returns 200 regardless
174+
* of the authentication outcome.
175+
*/
176+
177+
test('TOTP-required response swaps the action area for an OTP form (no Retry button)', async ({
178+
page,
179+
}) => {
180+
await installBoundaryProbeMocks(page);
181+
await page.route('**/server/token-login', async (route) => {
182+
await route.fulfill({
183+
status: 200,
184+
contentType: 'application/json',
185+
body: JSON.stringify(TOTP_REQUIRED_RESPONSE),
186+
});
187+
});
188+
189+
await page.goto(`${webuiEndpoint}/?sToken=${FIXTURE_STOKEN}`);
190+
191+
// Inline OTP input + Submit, per design "no separate modal, only
192+
// the lower half of the card changes". Input.OTP exposes an
193+
// aria-label on its root; individual slots are queryable via
194+
// `locator('input')`.
195+
await expect(page.getByLabel(/authenticator code/i)).toBeVisible({
196+
timeout: 15_000,
197+
});
198+
await expect(
199+
page.getByRole('button', { name: /^submit$/i }),
200+
).toBeVisible();
201+
// The OTP kind replaces Retry — it must NOT co-exist with the
202+
// generic retry button (would be a design regression).
203+
await expect(
204+
page.getByRole('button', { name: /retry/i }),
205+
).not.toBeVisible();
206+
});
207+
208+
test('submitting the OTP folds `otp` into the next token_login body', async ({
209+
page,
210+
}) => {
211+
await installBoundaryProbeMocks(page);
212+
213+
let callIndex = 0;
214+
const bodies: Array<Record<string, unknown> | null> = [];
215+
await page.route('**/server/token-login', async (route) => {
216+
bodies.push(route.request().postDataJSON() ?? null);
217+
callIndex += 1;
218+
// First call: demand TOTP. Second call (after submit): inert
219+
// failure so the test stays focused on the request body.
220+
await route.fulfill({
221+
status: 200,
222+
contentType: 'application/json',
223+
body: JSON.stringify(
224+
callIndex === 1
225+
? TOTP_REQUIRED_RESPONSE
226+
: AUTH_FAILED_INERT_RESPONSE,
227+
),
228+
});
229+
});
230+
231+
await page.goto(`${webuiEndpoint}/?sToken=${FIXTURE_STOKEN}`);
232+
233+
const otpGroup = page.getByLabel(/authenticator code/i);
234+
await expect(otpGroup).toBeVisible({ timeout: 15_000 });
235+
// Input.OTP distributes a multi-char string pasted into a single
236+
// slot across the remaining slots; `fill` dispatches the same
237+
// input event.
238+
await otpGroup.locator('input').first().fill('123456');
239+
await page.getByRole('button', { name: /^submit$/i }).click();
240+
241+
await expect
242+
.poll(() => bodies.length, { timeout: 10_000 })
243+
.toBeGreaterThanOrEqual(2);
244+
expect(bodies[0]).not.toHaveProperty('otp');
245+
expect(bodies[1]).toMatchObject({ otp: '123456' });
246+
});
247+
248+
test('concurrent-session response renders the Login confirm (no Retry) and sends `force: true`', async ({
249+
page,
250+
}) => {
251+
await installBoundaryProbeMocks(page);
252+
253+
let callIndex = 0;
254+
const bodies: Array<Record<string, unknown> | null> = [];
255+
await page.route('**/server/token-login', async (route) => {
256+
bodies.push(route.request().postDataJSON() ?? null);
257+
callIndex += 1;
258+
await route.fulfill({
259+
status: 200,
260+
contentType: 'application/json',
261+
body: JSON.stringify(
262+
callIndex === 1
263+
? CONCURRENT_SESSION_RESPONSE
264+
: AUTH_FAILED_INERT_RESPONSE,
265+
),
266+
});
267+
});
268+
269+
await page.goto(`${webuiEndpoint}/?sToken=${FIXTURE_STOKEN}`);
270+
271+
// The concurrent-session card uses the LoginView-aligned copy:
272+
// "Logged in elsewhere" title with a Login confirm button.
273+
await expect(
274+
page.getByText('Logged in elsewhere', { exact: true }),
275+
).toBeVisible({
276+
timeout: 15_000,
277+
});
278+
await expect(
279+
page.getByRole('button', { name: /^login$/i }),
280+
).toBeVisible();
281+
// Retry button must NOT appear — this kind replaces it entirely.
282+
await expect(
283+
page.getByRole('button', { name: /retry/i }),
284+
).not.toBeVisible();
285+
286+
await page.getByRole('button', { name: /^login$/i }).click();
287+
288+
await expect
289+
.poll(() => bodies.length, { timeout: 10_000 })
290+
.toBeGreaterThanOrEqual(2);
291+
expect(bodies[0]).not.toHaveProperty('force');
292+
expect(bodies[1]).toMatchObject({ force: true });
293+
});
294+
295+
test('OTP-then-concurrent sequence keeps both `otp` and `force: true` sticky on the final body', async ({
296+
page,
297+
}) => {
298+
await installBoundaryProbeMocks(page);
299+
300+
let callIndex = 0;
301+
const bodies: Array<Record<string, unknown> | null> = [];
302+
await page.route('**/server/token-login', async (route) => {
303+
bodies.push(route.request().postDataJSON() ?? null);
304+
callIndex += 1;
305+
let body: unknown;
306+
if (callIndex === 1) {
307+
body = TOTP_REQUIRED_RESPONSE;
308+
} else if (callIndex === 2) {
309+
// After the OTP submission, the server discovers an existing
310+
// active session. This is the sticky-OTP scenario the bug
311+
// report uncovered.
312+
body = CONCURRENT_SESSION_RESPONSE;
313+
} else {
314+
body = AUTH_FAILED_INERT_RESPONSE;
315+
}
316+
await route.fulfill({
317+
status: 200,
318+
contentType: 'application/json',
319+
body: JSON.stringify(body),
320+
});
321+
});
322+
323+
await page.goto(`${webuiEndpoint}/?sToken=${FIXTURE_STOKEN}`);
324+
325+
// Step 1: supply OTP.
326+
const otpGroup = page.getByLabel(/authenticator code/i);
327+
await expect(otpGroup).toBeVisible({ timeout: 15_000 });
328+
await otpGroup.locator('input').first().fill('999111');
329+
await page.getByRole('button', { name: /^submit$/i }).click();
330+
331+
// Step 2: confirm force-login.
332+
await expect(
333+
page.getByText('Logged in elsewhere', { exact: true }),
334+
).toBeVisible({
335+
timeout: 10_000,
336+
});
337+
await page.getByRole('button', { name: /^login$/i }).click();
338+
339+
await expect
340+
.poll(() => bodies.length, { timeout: 10_000 })
341+
.toBeGreaterThanOrEqual(3);
342+
// Final body must carry BOTH factors the user satisfied.
343+
expect(bodies[2]).toMatchObject({ otp: '999111', force: true });
344+
});
345+
},
346+
);

0 commit comments

Comments
 (0)