Skip to content

Commit c93a577

Browse files
fix(auth): log OAuth callback errors and drop useless error={} redirect (#3506)
JSON.stringify on a native Error serialises to `{}` because message/stack are non-enumerable, so the OAuth error redirect carried a useless `error={}` query param and the failure was only visible via morgan's 302 access log. Emit a structured logger.error() on both error and no-user branches so Sentry / PostHog actually see OAuth failures, and put a URL-safe message (or code, or a sensible fallback) in the `error=` query so the Vue error screen shows something meaningful. Redirect path + status unchanged. Closes #3495
1 parent a9a1bda commit c93a577

2 files changed

Lines changed: 83 additions & 2 deletions

File tree

modules/auth/controllers/auth.controller.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,11 +441,19 @@ const oauthCallback = async (req, res, next) => {
441441
passport.authenticate(strategy, (err, user) => {
442442
const url = getBaseUrl();
443443
if (err) {
444-
const _err = JSON.stringify(err);
444+
logger.error(
445+
{ err: { message: err?.message, code: err?.code, stack: err?.stack }, strategy },
446+
'OAuth callback failed',
447+
);
448+
const _err = encodeURIComponent(err?.message || err?.code || 'oauth_error');
445449
const path = 'token?message=Unprocessable%20Entity';
446450
res.redirect(302, `${url}/${path}&error=${_err}`);
447451
} else if (!user) {
448-
const _err = JSON.stringify(err);
452+
logger.error(
453+
{ err: { message: err?.message, code: err?.code, stack: err?.stack }, strategy },
454+
'OAuth callback failed',
455+
);
456+
const _err = encodeURIComponent(err?.message || err?.code || 'oauth_no_user');
449457
const path = 'token?message=Could%20not%20define%20user%20in%20oAuth';
450458
res.redirect(302, `${url}/${path}&error=${_err}`);
451459
} else {

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import passport from 'passport';
1010
import { bootstrap } from '../../../lib/app.js';
1111
import mongooseService from '../../../lib/services/mongoose.js';
1212
import config from '../../../config/index.js';
13+
import logger from '../../../lib/services/logger.js';
1314

1415
/**
1516
* Unit tests
@@ -645,6 +646,78 @@ describe('Auth integration tests:', () => {
645646
authenticateSpy.mockRestore();
646647
});
647648

649+
test('should log and redirect with message when classic web oAuth errors out', async () => {
650+
const oauthErr = new Error('token exchange failed');
651+
oauthErr.code = 'OAUTH_TOKEN_EXCHANGE';
652+
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
653+
(strategy, callback) => () => callback(oauthErr, null),
654+
);
655+
const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {});
656+
const redirectCalls = [];
657+
const mockReq = { params: { strategy: 'google' }, body: {} };
658+
const mockRes = {
659+
cookie() { return this; },
660+
redirect(code, url) { redirectCalls.push({ code, url }); },
661+
};
662+
663+
await AuthController.oauthCallback(mockReq, mockRes, () => {});
664+
665+
expect(loggerSpy).toHaveBeenCalledWith(
666+
expect.objectContaining({
667+
err: expect.objectContaining({
668+
message: 'token exchange failed',
669+
code: 'OAUTH_TOKEN_EXCHANGE',
670+
stack: expect.any(String),
671+
}),
672+
strategy: 'google',
673+
}),
674+
'OAuth callback failed',
675+
);
676+
expect(redirectCalls[0].code).toBe(302);
677+
// Redirect must carry the actual error message, not an empty object
678+
expect(redirectCalls[0].url).toContain('error=');
679+
expect(redirectCalls[0].url).not.toContain('error={}');
680+
expect(redirectCalls[0].url).toContain(encodeURIComponent('token exchange failed'));
681+
682+
loggerSpy.mockRestore();
683+
authenticateSpy.mockRestore();
684+
});
685+
686+
test('should log and redirect with sensible message when no user is returned by passport', async () => {
687+
const authenticateSpy = jest.spyOn(passport, 'authenticate').mockImplementationOnce(
688+
(strategy, callback) => () => callback(null, null),
689+
);
690+
const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {});
691+
const redirectCalls = [];
692+
const mockReq = { params: { strategy: 'google' }, body: {} };
693+
const mockRes = {
694+
cookie() { return this; },
695+
redirect(code, url) { redirectCalls.push({ code, url }); },
696+
};
697+
698+
await AuthController.oauthCallback(mockReq, mockRes, () => {});
699+
700+
expect(loggerSpy).toHaveBeenCalledWith(
701+
expect.objectContaining({
702+
err: expect.objectContaining({
703+
message: undefined,
704+
code: undefined,
705+
stack: undefined,
706+
}),
707+
strategy: 'google',
708+
}),
709+
'OAuth callback failed',
710+
);
711+
expect(redirectCalls[0].code).toBe(302);
712+
expect(redirectCalls[0].url).toContain('error=');
713+
expect(redirectCalls[0].url).not.toContain('error={}');
714+
expect(redirectCalls[0].url).toContain('oauth_no_user');
715+
expect(redirectCalls[0].url).toContain('Could%20not%20define%20user%20in%20oAuth');
716+
717+
loggerSpy.mockRestore();
718+
authenticateSpy.mockRestore();
719+
});
720+
648721
test('should find an existing OAuth user via checkOAuthUserProfile', async () => {
649722
// Create an OAuth user directly first
650723
const createdUser = await UserService.create({

0 commit comments

Comments
 (0)