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

@ -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,
);
}
}