Skip to content

Commit 997c51d

Browse files
fix(auth): guard OAuth :strategy route — 404 unknown/disabled + stateless authenticate (#3947)
* fix(auth): guard OAuth :strategy route — 404 unknown/disabled + stateless authenticate oauthCall and oauthCallback passed req.params.strategy straight into passport.authenticate() with no validation: an unknown strategy made passport throw synchronously ("Unknown authentication strategy") -> 500, and a registered-but-unguarded provider defaulted to session:true, which fails on this stateless JWT stack (no express-session mounted). Add isEnabledOAuthProvider(), shared by both routes: allowlisted (in ALLOWED_PROVIDERS) AND actually registered with passport (passport._strategy(strategy)) -> otherwise reject with a clean 404 (OAUTH_PROVIDER_NOT_FOUND) before ever reaching passport.authenticate(). oauthCall now also passes { session: false } explicitly. Update the invitations alias-shadow test to assert on the new deliberate 404 (code) instead of "not 404", and add strategy-registration stubs to the existing oauthCallback integration tests so they keep exercising the post-authenticate path. New unit tests cover unknown/disabled/enabled strategies for oauthCall. * test(auth): cover oauthCallback 404 guard + restore session-mint regression test Adversarial review of the #3900 guard diff found the oauthCallback reject branch had zero coverage (all 6 existing tests stub _strategy truthy) and that the pre-existing "should NOT mint a session" security regression test was silently defeated: since google is unregistered in test config, the new guard now 404s before the request ever reaches the post-authenticate identity-trust logic the test protects. - Add oauthCallback guard-reject unit tests (unknown + allowlisted-but- unregistered strategy), mirroring the existing oauthCall coverage. - Stub passport._strategy + passport.authenticate in the session-mint regression test so it again reaches the post-auth "no user" branch instead of short-circuiting at the guard; assert the redirect carries the no-user error, never OAUTH_PROVIDER_NOT_FOUND. - Reuse the existing 'oAuth, unsupported provider' AppError message for both new guards instead of a fresh string, for consistency. - Add a describe-scoped afterEach(jest.restoreAllMocks()) safety net so a failing assertion mid-test can't leak a stubbed spy into later tests (clearMocks alone doesn't restore spy implementations). * test(auth): extract shared oauth controller mock setup into a fixture (CodeRabbit #3947) Deduplicates ~85 lines of identical jest.unstable_mockModule setup that was copy-pasted across the oauthCall and oauthCallback describe blocks' beforeEach hooks into a single setupAuthControllerMocks() helper, called from both. Placed under tests/fixtures/ (matching lib/middlewares/tests/fixtures/) so Jest's testPathIgnorePatterns keeps it out of testMatch — a tests/helpers/ name would have made Jest try to run it as its own (empty) test suite.
1 parent 9558e22 commit 997c51d

5 files changed

Lines changed: 365 additions & 13 deletions

File tree

modules/auth/controllers/auth.controller.js

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,31 @@ const token = async (req, res) => {
439439
.json({ user, tokenExpiresIn: Date.now() + config.jwt.expiresIn * 1000, abilities, pendingRequests });
440440
};
441441

442+
/**
443+
* Known OAuth providers — used to validate the `provider` argument and `key` argument
444+
* before constructing dynamic query paths, preventing prototype-pollution-style injections.
445+
*/
446+
const ALLOWED_PROVIDERS = new Set(['google', 'apple']);
447+
const ALLOWED_PROVIDER_KEYS = new Set(['sub', 'id', 'email']);
448+
449+
/**
450+
* @desc Check whether `:strategy` (from `req.params.strategy`) is both an allowlisted
451+
* provider AND actually registered with passport at boot time. A provider in
452+
* ALLOWED_PROVIDERS is only registered (modules/auth/strategies/local/{provider}.js)
453+
* when its config carries valid credentials — otherwise passport never sees it.
454+
* Guards the `/api/auth/:strategy` and `/api/auth/:strategy/callback` routes so an
455+
* unknown or disabled strategy name is rejected with a clean 404 before it can reach
456+
* passport.authenticate(), which otherwise throws synchronously ("Unknown
457+
* authentication strategy") for an unregistered name.
458+
* `passport._strategy` is documented `@api private` in passport's own source, but it
459+
* is the single source of truth for "is this strategy registered" — asking config
460+
* directly would mean duplicating each provider's own credential-presence check here
461+
* and risking drift from modules/auth/strategies/local/*.js over time.
462+
* @param {string} strategy - strategy name from req.params.strategy
463+
* @returns {boolean} true when strategy is both allowlisted and registered with passport
464+
*/
465+
const isEnabledOAuthProvider = (strategy) => ALLOWED_PROVIDERS.has(strategy) && Boolean(passport._strategy(strategy));
466+
442467
/**
443468
* @desc Endpoint for oautCall
444469
* @param {Object} req - Express request object
@@ -447,16 +472,13 @@ const token = async (req, res) => {
447472
*/
448473
const oauthCall = (req, res, next) => {
449474
const strategy = req.params.strategy;
450-
passport.authenticate(strategy)(req, res, next);
475+
if (!isEnabledOAuthProvider(strategy)) {
476+
return next(new AppError('oAuth, unsupported provider', { status: 404, code: 'OAUTH_PROVIDER_NOT_FOUND' }));
477+
}
478+
// session: false — stateless JWT stack, no express-session middleware is mounted.
479+
return passport.authenticate(strategy, { session: false })(req, res, next);
451480
};
452481

453-
/**
454-
* Known OAuth providers — used to validate the `provider` argument and `key` argument
455-
* before constructing dynamic query paths, preventing prototype-pollution-style injections.
456-
*/
457-
const ALLOWED_PROVIDERS = new Set(['google', 'apple']);
458-
const ALLOWED_PROVIDER_KEYS = new Set(['sub', 'id', 'email']);
459-
460482
/**
461483
* @desc Resolve or create a user from an OAuth profile. Lookup order:
462484
* 1. Primary identity (provider + providerData[key])
@@ -659,12 +681,19 @@ const oauthErrorRedirect = (res, err, fallbackTitle) => {
659681
*/
660682
const oauthCallback = async (req, res, next) => {
661683
const strategy = req.params.strategy;
684+
if (!isEnabledOAuthProvider(strategy)) {
685+
return next(new AppError('oAuth, unsupported provider', { status: 404, code: 'OAUTH_PROVIDER_NOT_FOUND' }));
686+
}
662687
// The identity is always derived server-side from the provider's response via
663688
// passport.authenticate(). The part that was previously unsafe was trusting a
664689
// client-asserted identity from the request body (email, key, value) with no
665690
// provider-token verification — a caller who knows a victim's identifier could
666691
// have minted that victim's session. That branch is now removed entirely.
667-
passport.authenticate(strategy, (err, user) => {
692+
// No `{ session: false }` here: passport.authenticate()'s callback form (a
693+
// function as the 2nd/3rd arg) never calls req.logIn() itself — session
694+
// establishment is entirely the caller's responsibility below (JWT + cookie,
695+
// no express-session) — so the `session` option has nothing to act on.
696+
return passport.authenticate(strategy, (err, user) => {
668697
if (err) {
669698
logger.error(
670699
{ err: { message: err?.message, code: err?.code, stack: err?.stack }, strategy },

modules/auth/tests/auth.integration.tests.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,15 @@ describe('Auth integration tests:', () => {
558558
}
559559
});
560560

561+
// Safety net alongside each test's own manual mockRestore(): jest.config.js
562+
// only sets `clearMocks` (resets calls, not implementations), so a spy left
563+
// dangling by a failing assertion earlier in a test would otherwise leak its
564+
// stubbed truthy passport._strategy()/mockRejectedValueOnce() into later
565+
// tests in this block.
566+
afterEach(() => {
567+
jest.restoreAllMocks();
568+
});
569+
561570
test('should create user with default provider when none is specified', async () => {
562571
const result = await UserService.create({
563572
firstName: 'No',
@@ -625,6 +634,18 @@ describe('Auth integration tests:', () => {
625634
// identity payload. A request that claims to be a known account by email
626635
// (no server-side provider-token verification) must be rejected, not
627636
// turned into a victim session. The callback always delegates to passport.
637+
//
638+
// Simulate 'google' being registered with passport (bypasses the
639+
// isEnabledOAuthProvider guard, covered separately for oauthCall/oauthCallback)
640+
// and stub passport.authenticate the same way a real OAuth2 strategy would
641+
// behave for this request: there is no valid provider code, so authentication
642+
// fails regardless of what the attacker put in the body. Without this stub the
643+
// request would 404 at the guard before ever reaching the identity-trust logic
644+
// this test protects.
645+
const strategySpy = jest.spyOn(passport, '_strategy').mockReturnValue({});
646+
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
647+
(strategy, callback) => () => callback(null, false),
648+
);
628649
const victimEmail = 'oauthcb-victim@test.com';
629650
let victim;
630651
try {
@@ -649,13 +670,26 @@ describe('Auth integration tests:', () => {
649670
const tokenCookie = result.headers['set-cookie']?.find((c) => c.startsWith('TOKEN='));
650671
expect(tokenCookie).toBeUndefined();
651672
expect(result.status).not.toBe(200);
673+
// Confirm the request reached oauthCallback's post-authenticate
674+
// identity-trust logic (not the 404 provider-registration guard) —
675+
// the redirect must carry the "no user" error, never the guard's code.
676+
expect(result.status).toBe(302);
677+
expect(result.headers.location).toContain('/token');
678+
expect(result.headers.location).not.toContain('OAUTH_PROVIDER_NOT_FOUND');
652679
} finally {
680+
authenticateSpy.mockRestore();
681+
strategySpy.mockRestore();
653682
try { if (victim) await UserService.remove(victim); } catch (_) { /* cleanup */ }
654683
}
655684
});
656685

657686
test('should set tokenCookieOptions and redirect on classic web oAuth success', async () => {
658687
const mockUserId = 'mock-oauth-user-id-123';
688+
// Simulate 'google' being registered with passport (isEnabledOAuthProvider's
689+
// guard check) — this test targets oauthCallback's post-authenticate
690+
// handling, not the provider-registration guard itself (covered separately
691+
// for oauthCall).
692+
const strategySpy = jest.spyOn(passport, '_strategy').mockReturnValue({});
659693
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
660694
(strategy, callback) => () => callback(null, { id: mockUserId }),
661695
);
@@ -675,9 +709,11 @@ describe('Auth integration tests:', () => {
675709
expect(redirectCalls[0]).toMatchObject({ code: 302 });
676710
expect(redirectCalls[0].url).toMatch(/\/token$/);
677711
authenticateSpy.mockRestore();
712+
strategySpy.mockRestore();
678713
});
679714

680715
test('should handle GET callback when req.body is undefined (Express 5)', async () => {
716+
const strategySpy = jest.spyOn(passport, '_strategy').mockReturnValue({});
681717
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
682718
(strategy, callback) => () => callback(null, { id: 'mock-get-cb-user' }),
683719
);
@@ -694,11 +730,13 @@ describe('Auth integration tests:', () => {
694730
expect(cookies.TOKEN).toBeDefined();
695731
expect(redirectCalls[0]).toMatchObject({ code: 302 });
696732
authenticateSpy.mockRestore();
733+
strategySpy.mockRestore();
697734
});
698735

699736
test('should log and redirect with canonical error envelope when classic web oAuth errors out', async () => {
700737
const oauthErr = new Error('token exchange failed');
701738
oauthErr.code = 'OAUTH_TOKEN_EXCHANGE';
739+
const strategySpy = jest.spyOn(passport, '_strategy').mockReturnValue({});
702740
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
703741
(strategy, callback) => () => callback(oauthErr, null),
704742
);
@@ -743,6 +781,7 @@ describe('Auth integration tests:', () => {
743781

744782
loggerSpy.mockRestore();
745783
authenticateSpy.mockRestore();
784+
strategySpy.mockRestore();
746785
});
747786

748787
test('should redirect with canonical envelope preserving AppError details.message', async () => {
@@ -751,6 +790,7 @@ describe('Auth integration tests:', () => {
751790
code: 'VALIDATION_ERROR',
752791
details: { message: 'Registration is currently deactivated' },
753792
});
793+
const strategySpy = jest.spyOn(passport, '_strategy').mockReturnValue({});
754794
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
755795
(strategy, callback) => () => callback(oauthErr, null),
756796
);
@@ -781,9 +821,11 @@ describe('Auth integration tests:', () => {
781821

782822
loggerSpy.mockRestore();
783823
authenticateSpy.mockRestore();
824+
strategySpy.mockRestore();
784825
});
785826

786827
test('should fall back to OAUTH_ERROR code for plain Error without code', async () => {
828+
const strategySpy = jest.spyOn(passport, '_strategy').mockReturnValue({});
787829
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
788830
(strategy, callback) => () => callback(new Error('boom'), null),
789831
);
@@ -805,9 +847,11 @@ describe('Auth integration tests:', () => {
805847

806848
loggerSpy.mockRestore();
807849
authenticateSpy.mockRestore();
850+
strategySpy.mockRestore();
808851
});
809852

810853
test('should log and redirect with canonical envelope when no user is returned by passport', async () => {
854+
const strategySpy = jest.spyOn(passport, '_strategy').mockReturnValue({});
811855
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
812856
(strategy, callback) => () => callback(null, null),
813857
);
@@ -851,6 +895,7 @@ describe('Auth integration tests:', () => {
851895

852896
loggerSpy.mockRestore();
853897
authenticateSpy.mockRestore();
898+
strategySpy.mockRestore();
854899
});
855900

856901
test('should find an existing OAuth user via checkOAuthUserProfile', async () => {
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
import { setupAuthControllerMocks } from './fixtures/auth-controller.mock-setup.js';
6+
7+
/**
8+
* Unit tests for auth.controller oauthCall() (issue #3900).
9+
*
10+
* Before this guard, `oauthCall` passed `req.params.strategy` straight into
11+
* `passport.authenticate()` with no validation and no `{ session: false }`:
12+
* - an unknown strategy name made passport throw synchronously ("Unknown
13+
* authentication strategy") -> 500.
14+
* - an allowlisted-but-unregistered provider hit the same throw.
15+
* - a registered provider defaulted to `session: true`, which fails on this
16+
* stateless JWT stack ("Login sessions require session support").
17+
*
18+
* These tests verify the new isEnabledOAuthProvider() guard turns the first
19+
* two cases into a clean 404 and never reaches passport.authenticate(), and
20+
* that the third case delegates with `{ session: false }`.
21+
*/
22+
describe('auth.controller oauthCall:', () => {
23+
let mockPassport;
24+
25+
beforeEach(() => {
26+
mockPassport = setupAuthControllerMocks();
27+
});
28+
29+
test('unknown strategy (not in ALLOWED_PROVIDERS) is rejected with a 404, passport.authenticate is never called', async () => {
30+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
31+
32+
const req = { params: { strategy: 'me' } };
33+
const res = {};
34+
const next = jest.fn();
35+
36+
AuthController.oauthCall(req, res, next);
37+
38+
expect(mockPassport.authenticate).not.toHaveBeenCalled();
39+
expect(next).toHaveBeenCalledTimes(1);
40+
const err = next.mock.calls[0][0];
41+
expect(err).toBeInstanceOf(Error);
42+
expect(err.status).toBe(404);
43+
expect(err.code).toBe('OAUTH_PROVIDER_NOT_FOUND');
44+
});
45+
46+
test('another unknown strategy segment (e.g. "callback") is rejected with a 404', async () => {
47+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
48+
49+
const req = { params: { strategy: 'callback' } };
50+
const res = {};
51+
const next = jest.fn();
52+
53+
AuthController.oauthCall(req, res, next);
54+
55+
expect(mockPassport.authenticate).not.toHaveBeenCalled();
56+
const err = next.mock.calls[0][0];
57+
expect(err.status).toBe(404);
58+
expect(err.code).toBe('OAUTH_PROVIDER_NOT_FOUND');
59+
});
60+
61+
test('allowlisted but unregistered provider (not enabled in config) is rejected with a 404', async () => {
62+
// passport._strategy() returns undefined by default in this suite's mock —
63+
// simulating a provider that is in ALLOWED_PROVIDERS but was never
64+
// passport.use()'d at boot (e.g. missing clientID/clientSecret).
65+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
66+
67+
const req = { params: { strategy: 'google' } };
68+
const res = {};
69+
const next = jest.fn();
70+
71+
AuthController.oauthCall(req, res, next);
72+
73+
expect(mockPassport._strategy).toHaveBeenCalledWith('google');
74+
expect(mockPassport.authenticate).not.toHaveBeenCalled();
75+
const err = next.mock.calls[0][0];
76+
expect(err.status).toBe(404);
77+
expect(err.code).toBe('OAUTH_PROVIDER_NOT_FOUND');
78+
});
79+
80+
test('enabled + allowlisted provider delegates to passport.authenticate with { session: false }', async () => {
81+
mockPassport._strategy.mockReturnValue({ name: 'google' }); // simulate registered strategy
82+
const authenticateMiddleware = jest.fn();
83+
mockPassport.authenticate.mockReturnValue(authenticateMiddleware);
84+
85+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
86+
87+
const req = { params: { strategy: 'google' } };
88+
const res = {};
89+
const next = jest.fn();
90+
91+
AuthController.oauthCall(req, res, next);
92+
93+
expect(mockPassport.authenticate).toHaveBeenCalledWith('google', { session: false });
94+
expect(authenticateMiddleware).toHaveBeenCalledWith(req, res, next);
95+
expect(next).not.toHaveBeenCalled();
96+
});
97+
98+
test('apple (the other allowlisted provider) also delegates when registered', async () => {
99+
mockPassport._strategy.mockReturnValue({ name: 'apple' });
100+
const authenticateMiddleware = jest.fn();
101+
mockPassport.authenticate.mockReturnValue(authenticateMiddleware);
102+
103+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
104+
105+
const req = { params: { strategy: 'apple' } };
106+
const res = {};
107+
const next = jest.fn();
108+
109+
AuthController.oauthCall(req, res, next);
110+
111+
expect(mockPassport.authenticate).toHaveBeenCalledWith('apple', { session: false });
112+
expect(authenticateMiddleware).toHaveBeenCalledWith(req, res, next);
113+
});
114+
});
115+
116+
/**
117+
* Unit tests for auth.controller oauthCallback()'s isEnabledOAuthProvider guard
118+
* (issue #3900). The integration suite (auth.integration.tests.js) covers the
119+
* post-authenticate handling with `_strategy`/`authenticate` both stubbed truthy;
120+
* these tests cover the guard's reject branch specifically — an unknown or
121+
* allowlisted-but-unregistered strategy must 404 before passport.authenticate()
122+
* is ever reached, mirroring the oauthCall reject-path tests above.
123+
*/
124+
describe('auth.controller oauthCallback:', () => {
125+
let mockPassport;
126+
127+
beforeEach(() => {
128+
mockPassport = setupAuthControllerMocks();
129+
});
130+
131+
test('unknown strategy (not in ALLOWED_PROVIDERS) is rejected with a 404, passport.authenticate is never called', async () => {
132+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
133+
134+
const req = { params: { strategy: 'me' }, body: {} };
135+
const res = {};
136+
const next = jest.fn();
137+
138+
await AuthController.oauthCallback(req, res, next);
139+
140+
expect(mockPassport.authenticate).not.toHaveBeenCalled();
141+
expect(next).toHaveBeenCalledTimes(1);
142+
const err = next.mock.calls[0][0];
143+
expect(err).toBeInstanceOf(Error);
144+
expect(err.status).toBe(404);
145+
expect(err.code).toBe('OAUTH_PROVIDER_NOT_FOUND');
146+
});
147+
148+
test('allowlisted but unregistered provider (not enabled in config) is rejected with a 404', async () => {
149+
// passport._strategy() returns undefined by default in this suite's mock —
150+
// simulating a provider that is in ALLOWED_PROVIDERS but was never
151+
// passport.use()'d at boot (e.g. missing clientID/clientSecret).
152+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
153+
154+
const req = { params: { strategy: 'google' }, body: {} };
155+
const res = {};
156+
const next = jest.fn();
157+
158+
await AuthController.oauthCallback(req, res, next);
159+
160+
expect(mockPassport._strategy).toHaveBeenCalledWith('google');
161+
expect(mockPassport.authenticate).not.toHaveBeenCalled();
162+
const err = next.mock.calls[0][0];
163+
expect(err.status).toBe(404);
164+
expect(err.code).toBe('OAUTH_PROVIDER_NOT_FOUND');
165+
});
166+
});

0 commit comments

Comments
 (0)