Skip to content

Commit 50f3909

Browse files
authored
Merge pull request #11389 from growilabs/refactor/login-success-handler-respond-with-redirect
refactor(auth): loginSuccessHandler の isExternalAccount を respondWithRedirect にリネーム+回帰テスト追加
2 parents 4764227 + 4076a61 commit 50f3909

3 files changed

Lines changed: 94 additions & 6 deletions

File tree

apps/app/src/server/routes/login-passport.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createRedirectToForUnauthenticated } from '~/server/util/createRedirect
77
import loggerFactory from '~/utils/logger';
88

99
import { externalAccountService } from '../service/external-account';
10+
import { sendLoginSuccessResponse } from './login-success-response';
1011

1112
/** @param {import('~/server/crowi').default} crowi Crowi instance */
1213
module.exports = (crowi, app) => {
@@ -59,7 +60,7 @@ module.exports = (crowi, app) => {
5960
res,
6061
user,
6162
action,
62-
isExternalAccount = false,
63+
respondWithRedirect = false,
6364
) => {
6465
// update lastLoginAt
6566
user.updateLastLoginAt(new Date(), (err, userData) => {
@@ -87,11 +88,7 @@ module.exports = (crowi, app) => {
8788
const redirectTo =
8889
redirectToForUnauthenticated ?? res.locals.redirectTo ?? '/';
8990

90-
if (isExternalAccount) {
91-
return res.safeRedirect(redirectTo);
92-
}
93-
94-
return res.apiv3({ redirectTo });
91+
return sendLoginSuccessResponse(res, redirectTo, respondWithRedirect);
9592
};
9693

9794
const injectRedirectTo = (req, res, next) => {
@@ -252,6 +249,10 @@ module.exports = (crowi, app) => {
252249
return next(err);
253250
}
254251

252+
// LDAP login is submitted through the AJAX login form (POST /_api/v3/login), the
253+
// same as local login, so it must respond with JSON. Do NOT pass respondWithRedirect
254+
// here even though LDAP is an external account: a 302 would leave the client stuck on
255+
// the login page until a manual reload. See issue #11384.
255256
return loginSuccessHandler(
256257
req,
257258
res,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { mock } from 'vitest-mock-extended';
2+
3+
import type { ResWithSafeRedirect } from '~/server/middlewares/safe-redirect';
4+
5+
import type { ApiV3Response } from './apiv3/interfaces/apiv3-response';
6+
import { sendLoginSuccessResponse } from './login-success-response';
7+
8+
type LoginSuccessResponse = ApiV3Response & ResWithSafeRedirect;
9+
10+
describe('sendLoginSuccessResponse', () => {
11+
const redirectTo = '/path/to/redirect';
12+
13+
// Contract: the response TRANSPORT the client experiences (JSON body vs HTTP 302),
14+
// not which internal helper is called. See issue #11384.
15+
16+
describe('when respondWithRedirect is omitted (AJAX login form: local & LDAP)', () => {
17+
it('responds with JSON { redirectTo } and never issues a redirect', () => {
18+
// Arrange
19+
const res = mock<LoginSuccessResponse>();
20+
21+
// Act
22+
sendLoginSuccessResponse(res, redirectTo);
23+
24+
// Assert: the client reads redirectTo from the body and navigates itself.
25+
// A 302 here would be followed silently by the XHR (the #11384 regression).
26+
expect(res.apiv3).toHaveBeenCalledWith({ redirectTo });
27+
expect(res.safeRedirect).not.toHaveBeenCalled();
28+
});
29+
});
30+
31+
describe('when respondWithRedirect is false', () => {
32+
it('responds with JSON { redirectTo } and never issues a redirect', () => {
33+
const res = mock<LoginSuccessResponse>();
34+
35+
sendLoginSuccessResponse(res, redirectTo, false);
36+
37+
expect(res.apiv3).toHaveBeenCalledWith({ redirectTo });
38+
expect(res.safeRedirect).not.toHaveBeenCalled();
39+
});
40+
});
41+
42+
describe('when respondWithRedirect is true (full-page callback: OAuth/SAML)', () => {
43+
it('responds with an HTTP 302 and never returns a JSON body', () => {
44+
const res = mock<LoginSuccessResponse>();
45+
46+
sendLoginSuccessResponse(res, redirectTo, true);
47+
48+
expect(res.safeRedirect).toHaveBeenCalledWith(redirectTo);
49+
expect(res.apiv3).not.toHaveBeenCalled();
50+
});
51+
});
52+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { ResWithSafeRedirect } from '~/server/middlewares/safe-redirect';
2+
3+
import type { ApiV3Response } from './apiv3/interfaces/apiv3-response';
4+
5+
type LoginSuccessResponse = ApiV3Response & ResWithSafeRedirect;
6+
7+
/**
8+
* Send the response for a successful login.
9+
*
10+
* `respondWithRedirect` selects the response TRANSPORT, not whether the account is
11+
* external:
12+
* - `false` (default): reply with JSON `{ redirectTo }` via `res.apiv3`. Used by the
13+
* AJAX login form (`POST /_api/v3/login`) for BOTH local and LDAP login — the client
14+
* reads `redirectTo` from the body and navigates on its own (`router.push`).
15+
* - `true`: reply with an HTTP 302 via `res.safeRedirect`. Used by the OAuth/SAML callback
16+
* routes (`GET`/`POST` under `/passport/`), which are full-page browser navigations that
17+
* the server itself must redirect.
18+
*
19+
* NOTE: LDAP is an external account but still uses `false`, because it is submitted
20+
* through the AJAX form rather than a full-page callback. Passing `true` for LDAP returns
21+
* a 302 that the XHR follows silently, so the client never receives `redirectTo` and the
22+
* user stays on the login page until a manual reload. See issue #11384.
23+
*/
24+
export const sendLoginSuccessResponse = (
25+
res: LoginSuccessResponse,
26+
redirectTo: string,
27+
respondWithRedirect = false,
28+
): void => {
29+
if (respondWithRedirect) {
30+
res.safeRedirect(redirectTo);
31+
return;
32+
}
33+
34+
res.apiv3({ redirectTo });
35+
};

0 commit comments

Comments
 (0)