Skip to content

Commit c09de24

Browse files
fix(auth): remove unverified client-asserted OAuth identity branch + throttle callback (#3866)
* fix(auth): remove unverified client-asserted OAuth identity branch + throttle callback * fix(auth): throttle the OAuth initiation route for consistency * test(auth): robust oauth-callback no-session assertion + pre-cleanup; clarify comment - Move victim UserService.create() inside try/finally to avoid flakiness on reruns (pre-delete any leftover oauthcb-victim@test.com first) - Replace fragile `status >= 400` with `not.toBe(200)` + no-TOKEN-cookie; the failure path may 302-redirect rather than return 4xx, so the real security invariant (no session minted) is now the hard assertion - Reword oauthCallback comment: passport.authenticate() does verify the provider response; the unsafe part was trusting a client-asserted body identity with no provider-token check (that branch is now removed)
1 parent ef97b28 commit c09de24

3 files changed

Lines changed: 35 additions & 74 deletions

File tree

modules/auth/controllers/auth.controller.js

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -636,43 +636,11 @@ const oauthErrorRedirect = (res, err, fallbackTitle) => {
636636
*/
637637
const oauthCallback = async (req, res, next) => {
638638
const strategy = req.params.strategy;
639-
// app Auth with Strategy managed on client side
640-
if (req.body?.strategy === false && req.body?.key) {
641-
const allowedKeys = ['id', 'sub', 'email'];
642-
if (!allowedKeys.includes(req.body.key)) {
643-
return responses.error(res, 422, 'Unprocessable Entity', 'Invalid provider key')();
644-
}
645-
try {
646-
let user = {
647-
firstName: req.body.firstName,
648-
lastName: req.body.lastName,
649-
email: req.body.email,
650-
providerData: {},
651-
};
652-
user.providerData[req.body.key] = req.body.value;
653-
user = await checkOAuthUserProfile(user, req.body.key, strategy);
654-
const token = jwt.sign({ userId: user.id }, config.jwt.secret, {
655-
expiresIn: config.jwt.expiresIn,
656-
});
657-
return res
658-
.status(200)
659-
.cookie('TOKEN', token, tokenCookieOptions)
660-
.json({
661-
user,
662-
tokenExpiresIn: Date.now() + config.jwt.expiresIn * 1000,
663-
type: 'success',
664-
message: 'oAuth Ok',
665-
});
666-
} catch (err) {
667-
return responses.error(
668-
res,
669-
422,
670-
err instanceof AppError && err.code === 'VALIDATION_ERROR' ? errors.getMessage(err) : 'Unprocessable Entity',
671-
errors.getMessage(err.details || err),
672-
)(err);
673-
}
674-
}
675-
// classic web oAuth
639+
// The identity is always derived server-side from the provider's response via
640+
// passport.authenticate(). The part that was previously unsafe was trusting a
641+
// client-asserted identity from the request body (email, key, value) with no
642+
// provider-token verification — a caller who knows a victim's identifier could
643+
// have minted that victim's session. That branch is now removed entirely.
676644
passport.authenticate(strategy, (err, user) => {
677645
if (err) {
678646
logger.error(

modules/auth/routes/auth.routes.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export default (app) => {
9292
app.route('/api/auth/token').get(passport.authenticate('jwt', { session: false }), auth.token);
9393

9494
// Setting the oauth routes
95-
app.route('/api/auth/:strategy').get(auth.oauthCall);
96-
app.route('/api/auth/:strategy/callback').get(auth.oauthCallback);
97-
app.route('/api/auth/:strategy/callback').post(auth.oauthCallback); // specific for apple call back
95+
app.route('/api/auth/:strategy').get(authLimiter, auth.oauthCall);
96+
app.route('/api/auth/:strategy/callback').get(authLimiter, auth.oauthCallback);
97+
app.route('/api/auth/:strategy/callback').post(authLimiter, auth.oauthCallback); // specific for apple call back
9898
};

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

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -571,47 +571,40 @@ describe('Auth integration tests:', () => {
571571
createSpy.mockRestore();
572572
});
573573

574-
test('should authenticate via client-side OAuth and set tokenCookieOptions on response', async () => {
575-
const oauthEmail = 'oauthcb-appauth@test.com';
576-
try {
574+
test('should NOT mint a session from a client-asserted identity on the OAuth callback', async () => {
575+
// Security regression: the callback must never trust a client-supplied
576+
// identity payload. A request that claims to be a known account by email
577+
// (no server-side provider-token verification) must be rejected, not
578+
// turned into a victim session. The callback always delegates to passport.
579+
const victimEmail = 'oauthcb-victim@test.com';
580+
let victim;
581+
try {
582+
const existing = await UserService.getBrut({ email: victimEmail });
583+
if (existing) await UserService.remove(existing);
584+
victim = await UserService.create({
585+
firstName: 'Victim',
586+
lastName: 'Owner',
587+
email: victimEmail,
588+
provider: 'google',
589+
providerData: { email: victimEmail },
590+
roles: ['user'],
591+
});
577592
const result = await agent
578593
.post('/api/auth/google/callback')
579-
.send({ strategy: false, key: 'id', value: 'cb-app-auth-id-999', firstName: 'OAuth', lastName: 'Callback', email: oauthEmail })
580-
.expect(200);
594+
.send({ strategy: false, key: 'email', value: victimEmail, email: victimEmail });
595+
596+
// No session may be issued to the attacker — the real security invariant.
597+
// The failure path may 302-redirect to an error route rather than returning
598+
// a 4xx, so we assert the outcome (no TOKEN cookie, no successful session)
599+
// rather than the transport status code.
581600
const tokenCookie = result.headers['set-cookie']?.find((c) => c.startsWith('TOKEN='));
582-
expect(tokenCookie).toBeDefined();
583-
expect(tokenCookie).toMatch(/HttpOnly/i);
584-
expect(tokenCookie).toMatch(/SameSite=Strict/i);
585-
expect(result.body.message).toBe('oAuth Ok');
586-
} catch (err) {
587-
console.log(err);
588-
expect(err).toBeFalsy();
601+
expect(tokenCookie).toBeUndefined();
602+
expect(result.status).not.toBe(200);
589603
} finally {
590-
try {
591-
const u = await UserService.getBrut({ email: oauthEmail });
592-
if (u) await UserService.remove(u);
593-
} catch (_) { /* cleanup */ }
604+
try { if (victim) await UserService.remove(victim); } catch (_) { /* cleanup */ }
594605
}
595606
});
596607

597-
test('should return 422 when client-side OAuth callback receives an invalid profile', async () => {
598-
const result = await agent
599-
.post('/api/auth/google/callback')
600-
.send({
601-
strategy: false,
602-
key: 'id',
603-
value: 'cb-app-auth-id-invalid-999',
604-
firstName: 'Invalid1',
605-
lastName: 'Callback',
606-
email: 'oauthcb-invalid@test.com',
607-
})
608-
.expect(422);
609-
610-
expect(result.body.type).toBe('error');
611-
expect(result.body.message).toMatch(/^Schema validation error/);
612-
expect(result.body.description).toEqual(expect.any(String));
613-
});
614-
615608
test('should set tokenCookieOptions and redirect on classic web oAuth success', async () => {
616609
const mockUserId = 'mock-oauth-user-id-123';
617610
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(

0 commit comments

Comments
 (0)