UNAUTHORIZED gmail error (#12262)

# Gmail OAuth authentication flow issues

### TLDR
This error is not an error and therefore should be treated as a simple
redirect with a snackbar.

### More details
Fixing incomplete OAuth token exchange processes and improving error
handling for empty Gmail inboxes.
The changes include modifications to OAuth guards, to ensure that if a
user clicks "cancel" instead of completing the authentication workflow
if fails

## Before:
Redirection from `/settings/accounts` to `app.twenty.com` with an
`UNAUTHORIZED` error

## After :
<img width="948" alt="Screenshot 2025-05-26 at 18 04 37"
src="https://github.com/user-attachments/assets/62c8721e-c2b3-4e3d-ad0b-e4059dfb7a98"
/>


Fixes https://github.com/twentyhq/twenty/issues/11895

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-05-27 16:45:42 +02:00
committed by GitHub
parent 4b25aabfa2
commit 7cacccf0b8
14 changed files with 170 additions and 65 deletions

View File

@ -8,6 +8,7 @@ import { ChromeExtensionSidecarProvider } from '@/chrome-extension-sidecar/compo
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider';
import { ErrorMessageEffect } from '@/error-handler/components/ErrorMessageEffect';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { ApolloMetadataClientProvider } from '@/object-metadata/components/ApolloMetadataClientProvider';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
@ -49,6 +50,7 @@ export const AppRouterProviders = () => {
<PrefetchDataProvider>
<UserThemeProviderEffect />
<SnackBarProvider>
<ErrorMessageEffect />
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>

View File

@ -5,8 +5,6 @@ import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { AppPath } from '@/types/AppPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
@ -14,9 +12,7 @@ import { useNavigateApp } from '~/hooks/useNavigateApp';
export const VerifyLoginTokenEffect = () => {
const [searchParams] = useSearchParams();
const loginToken = searchParams.get('loginToken');
const errorMessage = searchParams.get('errorMessage');
const { enqueueSnackBar } = useSnackBar();
const isLogged = useIsLogged();
const navigate = useNavigateApp();
const { verifyLoginToken } = useVerifyLogin();
@ -26,13 +22,6 @@ export const VerifyLoginTokenEffect = () => {
);
useEffect(() => {
if (isDefined(errorMessage)) {
enqueueSnackBar(errorMessage, {
dedupeKey: 'get-auth-tokens-from-login-token-failed-dedupe-key',
variant: SnackBarVariant.Error,
});
}
if (!clientConfigLoaded) return;
if (isDefined(loginToken)) {

View File

@ -0,0 +1,26 @@
import { useEffect } from 'react';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSearchParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
export const ErrorMessageEffect = () => {
const { enqueueSnackBar } = useSnackBar();
const [searchParams, setSearchParams] = useSearchParams();
const errorMessage = searchParams.get('errorMessage');
useEffect(() => {
if (isDefined(errorMessage)) {
enqueueSnackBar(errorMessage, {
dedupeKey: 'error-message-dedupe-key',
variant: SnackBarVariant.Error,
});
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.delete('errorMessage');
setSearchParams(newSearchParams);
}
}, [enqueueSnackBar, errorMessage, searchParams, setSearchParams]);
return <></>;
};

View File

@ -1,5 +1,3 @@
import { useRecoilValue } from 'recoil';
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
@ -14,6 +12,7 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';

View File

@ -20,6 +20,7 @@ import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dt
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
@ -53,7 +54,6 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';

View File

@ -118,15 +118,16 @@ export class GoogleAPIsAuthController {
})
.toString(),
);
} catch (err) {
} catch (error) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
workspace ?? {
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace: workspace ?? {
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
customDomain: null,
},
),
pathname: '/verify',
}),
);
}
}

View File

@ -118,14 +118,16 @@ export class GoogleAuthController {
billingCheckoutSessionState,
}),
);
} catch (err) {
} catch (error) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace,
),
),
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace:
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace,
),
pathname: '/verify',
}),
);
}
}

View File

@ -125,15 +125,16 @@ export class MicrosoftAPIsAuthController {
})
.toString(),
);
} catch (err) {
} catch (error) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
workspace ?? {
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace: workspace ?? {
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
customDomain: null,
},
),
pathname: '/verify',
}),
);
}
}

View File

@ -119,14 +119,16 @@ export class MicrosoftAuthController {
billingCheckoutSessionState,
}),
);
} catch (err) {
} catch (error) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace,
),
),
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace:
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace,
),
pathname: '/verify',
}),
);
}
}

View File

@ -19,25 +19,25 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard';
import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard';
import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.guard';
import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import {
IdentityProviderType,
WorkspaceSSOIdentityProvider,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Controller('auth')
export class SSOAuthController {
@ -157,14 +157,16 @@ export class SSOAuthController {
workspace: currentWorkspace,
}),
);
} catch (err) {
} catch (error) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
workspaceIdentityProvider?.workspace,
),
),
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace:
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
workspaceIdentityProvider?.workspace,
),
pathname: '/verify',
}),
);
}
}

View File

@ -14,10 +14,18 @@ export class AuthRestApiExceptionFilter implements ExceptionFilter {
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: AuthException, host: ArgumentsHost) {
catch(exception: AuthException | Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
if (!(exception instanceof AuthException)) {
return this.httpExceptionHandlerService.handleError(
exception,
response,
500,
);
}
switch (exception.code) {
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.CLIENT_NOT_FOUND:
@ -43,6 +51,7 @@ export class AuthRestApiExceptionFilter implements ExceptionFilter {
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
case AuthExceptionCode.SIGNUP_DISABLED:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,

View File

@ -1,14 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
@ -17,6 +23,10 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
constructor(
private readonly guardRedirectService: GuardRedirectService,
private readonly twentyConfigService: TwentyConfigService,
private readonly transientTokenService: TransientTokenService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {
super();
}
@ -47,6 +57,30 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
return (await super.canActivate(context)) as boolean;
} catch (err) {
if (err.status === 401) {
const request = context.switchToHttp().getRequest();
const state = JSON.parse(request.query.state);
const workspace = await this.getWorkspaceFromTransientToken(
state.transientToken,
);
const redirectErrorUrl =
this.domainManagerService.computeRedirectErrorUrl(
'We cannot connect to your Google account, please try again with more permissions, or a valid account',
{
subdomain: workspace.subdomain,
customDomain: workspace.customDomain,
isCustomDomainEnabled: workspace.isCustomDomainEnabled ?? false,
},
'/settings/accounts',
);
context.switchToHttp().getResponse().redirect(redirectErrorUrl);
return false;
}
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
@ -58,4 +92,24 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
return false;
}
}
private async getWorkspaceFromTransientToken(
transientToken: string,
): Promise<Workspace> {
const { workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
const workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new AuthException(
`Error extracting workspace from transientToken for Google APIs connect for ${workspaceId}`,
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
return workspace;
}
}

View File

@ -128,10 +128,11 @@ export class DomainManagerService {
computeRedirectErrorUrl(
errorMessage: string,
workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType,
pathname: string,
) {
const url = this.buildWorkspaceURL({
workspace,
pathname: '/verify',
pathname,
searchParams: { errorMessage },
});

View File

@ -2,7 +2,10 @@ import { ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@ -25,15 +28,19 @@ export class GuardRedirectService {
customDomain: string | null;
isCustomDomainEnabled?: boolean;
},
pathname = '/verify',
) {
if ('contextType' in context && context.contextType === 'graphql') {
throw error;
}
context
.switchToHttp()
.getResponse()
.redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace));
context.switchToHttp().getResponse().redirect(
this.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace,
pathname,
}),
);
}
getSubdomainAndCustomDomainFromContext(context: ExecutionContext) {
@ -58,7 +65,11 @@ export class GuardRedirectService {
}
private captureException(err: Error | CustomException, workspaceId?: string) {
if (err instanceof AuthException) return;
if (
err instanceof AuthException &&
err.code !== AuthExceptionCode.INTERNAL_SERVER_ERROR
)
return;
this.exceptionHandlerService.captureExceptions([err], {
workspace: {
@ -67,24 +78,30 @@ export class GuardRedirectService {
});
}
getRedirectErrorUrlAndCaptureExceptions(
err: Error | CustomException,
getRedirectErrorUrlAndCaptureExceptions({
error,
workspace,
pathname,
}: {
error: Error | AuthException;
workspace: {
id?: string;
subdomain: string;
customDomain: string | null;
isCustomDomainEnabled?: boolean;
},
) {
this.captureException(err, workspace.id);
};
pathname: string;
}) {
this.captureException(error, workspace.id);
return this.domainManagerService.computeRedirectErrorUrl(
err instanceof AuthException ? err.message : 'Unknown error',
error instanceof AuthException ? error.message : 'Unknown error',
{
subdomain: workspace.subdomain,
customDomain: workspace.customDomain,
isCustomDomainEnabled: workspace.isCustomDomainEnabled ?? false,
},
pathname,
);
}
}