Compare commits

...
Author SHA1 Message Date
sonarly-bot 53e1ef47da fix(auth): validate and default Google APIs callback URL
https://sonarly.com/issue/41085?type=bug

Google OAuth can fail completely in self-hosted multi-workspace setups when callback URLs are configured to non-existent routes. Users get bounced back to login/error pages and may end on 404 URLs with OAuth params.

Fix: Implemented a defensive callback resolution for Google APIs OAuth so misconfigured callback URLs no longer dead-end the flow.

What changed:
1) Added callback URL resolver utility that enforces the expected backend callback path:
   - Expected path: /auth/google-apis/get-access-token
   - If AUTH_GOOGLE_APIS_CALLBACK_URL has a wrong path, keep origin but normalize path to the expected route
   - If AUTH_GOOGLE_APIS_CALLBACK_URL is invalid, fall back to SERVER_URL + expected path

2) Updated GoogleAPIsOauthCommonStrategy to use the resolved callback URL instead of raw env value.

3) Added unit tests for resolver behavior:
   - valid callback path => unchanged
   - invalid callback path => normalized
   - invalid callback URL => fallback to SERVER_URL

Authored by Sonarly by autonomous analysis (run 46840).
2026-05-27 14:08:53 +00:00
4 changed files with 151 additions and 9 deletions
@@ -1,8 +1,10 @@
import { Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, type VerifyCallback } from 'passport-google-oauth20';
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
import { resolveGoogleApisCallbackUrl } from 'src/engine/core-modules/auth/utils/google-apis-callback-url.util';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export type GoogleAPIScopeConfig = {
@@ -14,16 +16,39 @@ export abstract class GoogleAPIsOauthCommonStrategy extends PassportStrategy(
Strategy,
'google-apis',
) {
private readonly logger = new Logger(GoogleAPIsOauthCommonStrategy.name);
constructor(twentyConfigService: TwentyConfigService) {
const scopes = getGoogleApisOauthScopes();
const configuredCallbackUrl = twentyConfigService.get(
'AUTH_GOOGLE_APIS_CALLBACK_URL',
);
const serverUrl = twentyConfigService.get('SERVER_URL');
const resolvedCallbackUrl = resolveGoogleApisCallbackUrl({
callbackUrl: configuredCallbackUrl,
serverUrl,
});
super({
clientID: twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'),
clientSecret: twentyConfigService.get('AUTH_GOOGLE_CLIENT_SECRET'),
callbackURL: twentyConfigService.get('AUTH_GOOGLE_APIS_CALLBACK_URL'),
callbackURL: resolvedCallbackUrl.callbackUrl,
scope: scopes,
passReqToCallback: true,
});
if (resolvedCallbackUrl.normalizationReason === 'invalid_path') {
this.logger.warn(
`AUTH_GOOGLE_APIS_CALLBACK_URL has an invalid path and was normalized to ${resolvedCallbackUrl.callbackUrl}`,
);
}
if (resolvedCallbackUrl.normalizationReason === 'invalid_url') {
this.logger.warn(
`AUTH_GOOGLE_APIS_CALLBACK_URL is invalid and fell back to ${resolvedCallbackUrl.callbackUrl}`,
);
}
}
abstract validate(
@@ -0,0 +1,48 @@
import {
GOOGLE_APIS_OAUTH_CALLBACK_PATH,
resolveGoogleApisCallbackUrl,
} from 'src/engine/core-modules/auth/utils/google-apis-callback-url.util';
describe('resolveGoogleApisCallbackUrl', () => {
it('should keep callback URL when path is valid', () => {
const callbackUrl =
'https://api.twenty.com/auth/google-apis/get-access-token';
const result = resolveGoogleApisCallbackUrl({
callbackUrl,
serverUrl: 'https://fallback.twenty.com',
});
expect(result).toEqual({
callbackUrl,
wasNormalized: false,
normalizationReason: null,
});
});
it('should normalize callback URL path when path is invalid', () => {
const result = resolveGoogleApisCallbackUrl({
callbackUrl: 'https://api.twenty.com/api/auth/callback/google?state=abc',
serverUrl: 'https://fallback.twenty.com',
});
expect(result).toEqual({
callbackUrl: `https://api.twenty.com${GOOGLE_APIS_OAUTH_CALLBACK_PATH}`,
wasNormalized: true,
normalizationReason: 'invalid_path',
});
});
it('should fallback to SERVER_URL when callback URL is invalid', () => {
const result = resolveGoogleApisCallbackUrl({
callbackUrl: 'not-a-url',
serverUrl: 'https://fallback.twenty.com',
});
expect(result).toEqual({
callbackUrl: `https://fallback.twenty.com${GOOGLE_APIS_OAUTH_CALLBACK_PATH}`,
wasNormalized: true,
normalizationReason: 'invalid_url',
});
});
});
@@ -0,0 +1,43 @@
export const GOOGLE_APIS_OAUTH_CALLBACK_PATH =
'/auth/google-apis/get-access-token';
export const resolveGoogleApisCallbackUrl = ({
callbackUrl,
serverUrl,
}: {
callbackUrl: string;
serverUrl: string;
}) => {
const fallbackCallbackUrl = new URL(
GOOGLE_APIS_OAUTH_CALLBACK_PATH,
serverUrl,
).toString();
try {
const parsedCallbackUrl = new URL(callbackUrl);
if (parsedCallbackUrl.pathname === GOOGLE_APIS_OAUTH_CALLBACK_PATH) {
return {
callbackUrl,
wasNormalized: false,
normalizationReason: null,
};
}
parsedCallbackUrl.pathname = GOOGLE_APIS_OAUTH_CALLBACK_PATH;
parsedCallbackUrl.search = '';
parsedCallbackUrl.hash = '';
return {
callbackUrl: parsedCallbackUrl.toString(),
wasNormalized: true,
normalizationReason: 'invalid_path' as const,
};
} catch {
return {
callbackUrl: fallbackCallbackUrl,
wasNormalized: true,
normalizationReason: 'invalid_url' as const,
};
}
};
@@ -1,4 +1,4 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { type Request } from 'express';
import { AppPath } from 'twenty-shared/types';
@@ -15,6 +15,8 @@ import { type CustomException } from 'src/utils/custom-exception';
@Injectable()
export class GuardRedirectService {
private readonly logger = new Logger(GuardRedirectService.name);
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly exceptionHandlerService: ExceptionHandlerService,
@@ -67,17 +69,37 @@ export class GuardRedirectService {
};
}
private captureException(err: Error | CustomException, workspaceId?: string) {
private captureException({
error,
workspaceId,
pathname,
}: {
error: Error | CustomException;
workspaceId?: string;
pathname: string;
}) {
if (
err instanceof AuthException &&
err.code !== AuthExceptionCode.INTERNAL_SERVER_ERROR
)
return;
error instanceof AuthException &&
error.code !== AuthExceptionCode.INTERNAL_SERVER_ERROR
) {
const logMessage = `Auth exception redirected from guard: code=${error.code}, pathname=${pathname}, workspaceId=${workspaceId ?? 'unknown'}`;
this.exceptionHandlerService.captureExceptions([err], {
if (error.code === AuthExceptionCode.OAUTH_ACCESS_DENIED) {
this.logger.log(logMessage);
} else {
this.logger.warn(logMessage);
}
return;
}
this.exceptionHandlerService.captureExceptions([error], {
workspace: {
id: workspaceId,
},
additionalData: {
pathname,
},
});
}
@@ -95,7 +117,11 @@ export class GuardRedirectService {
};
pathname: string;
}) {
this.captureException(error, workspace.id);
this.captureException({
error,
workspaceId: workspace.id,
pathname,
});
const errorMessage =
error instanceof AuthException