feat(auth): centralize SSO error handling logic (#9832)

Introduce `SsoErrorRedirectService` to handle SSO error redirection and
exception capturing across the authentication controllers. Refactor
Microsoft, Google, and SSO authentication controllers to utilize this
service, replacing the previous direct calls to `DomainManagerService`.
Added unit tests for the new service to ensure robust error handling.
This commit is contained in:
Antoine Moreaux
2025-01-27 18:55:20 +01:00
committed by GitHub
parent 10476fcb01
commit 2a911b4305
19 changed files with 524 additions and 291 deletions

View File

@ -25,6 +25,7 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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';
@Controller('auth/google-apis')
@UseFilters(AuthRestApiExceptionFilter)
@ -35,6 +36,7 @@ export class GoogleAPIsAuthController {
private readonly environmentService: EnvironmentService,
private readonly onboardingService: OnboardingService,
private readonly domainManagerService: DomainManagerService,
private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@ -52,75 +54,89 @@ export class GoogleAPIsAuthController {
@Req() req: GoogleAPIsRequest,
@Res() res: Response,
) {
const { user } = req;
let workspace: Workspace | null = null;
const {
emails,
accessToken,
refreshToken,
transientToken,
redirectLocation,
calendarVisibility,
messageVisibility,
} = user;
try {
const { user } = req;
const { workspaceMemberId, userId, workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
const {
emails,
accessToken,
refreshToken,
transientToken,
redirectLocation,
calendarVisibility,
messageVisibility,
} = user;
const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS');
const { workspaceMemberId, userId, workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
if (demoWorkspaceIds.includes(workspaceId)) {
throw new AuthException(
'Cannot connect Google account to demo workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const demoWorkspaceIds =
this.environmentService.get('DEMO_WORKSPACE_IDS');
if (!workspaceId) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (demoWorkspaceIds.includes(workspaceId)) {
throw new AuthException(
'Cannot connect Google account to demo workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const handle = emails[0].value;
if (!workspaceId) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
await this.googleAPIsService.refreshGoogleRefreshToken({
handle,
workspaceMemberId: workspaceMemberId,
workspaceId: workspaceId,
accessToken,
refreshToken,
calendarVisibility,
messageVisibility,
});
if (userId) {
await this.onboardingService.setOnboardingConnectAccountPending({
userId,
workspaceId,
value: false,
workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
}
const workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
const handle = emails[0].value;
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
await this.googleAPIsService.refreshGoogleRefreshToken({
handle,
workspaceMemberId: workspaceMemberId,
workspaceId: workspaceId,
accessToken,
refreshToken,
calendarVisibility,
messageVisibility,
});
if (userId) {
await this.onboardingService.setOnboardingConnectAccountPending({
userId,
workspaceId,
value: false,
});
}
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
return res.redirect(
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),
);
} catch (err) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
),
);
}
return res.redirect(
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),
);
}
}

View File

@ -11,10 +11,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
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 { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oauth.guard';
@ -22,9 +18,9 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.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 { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
@ -32,8 +28,8 @@ export class GoogleAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
) {}
@ -120,16 +116,14 @@ export class GoogleAuthController {
}),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl(
err.message,
currentWorkspace?.subdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
),
);
}
throw new AuthException(err, AuthExceptionCode.INTERNAL_SERVER_ERROR);
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
currentWorkspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
),
);
}
}
}

View File

@ -24,8 +24,8 @@ import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microso
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/microsoft-apis')
@UseFilters(AuthRestApiExceptionFilter)
@ -34,9 +34,9 @@ export class MicrosoftAPIsAuthController {
private readonly microsoftAPIsService: MicrosoftAPIsService,
private readonly transientTokenService: TransientTokenService,
private readonly environmentService: EnvironmentService,
private readonly workspaceService: WorkspaceService,
private readonly domainManagerService: DomainManagerService,
private readonly onboardingService: OnboardingService,
private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@ -54,80 +54,96 @@ export class MicrosoftAPIsAuthController {
@Req() req: MicrosoftAPIsRequest,
@Res() res: Response,
) {
const { user } = req;
let workspace: Workspace | null = null;
const {
emails,
accessToken,
refreshToken,
transientToken,
redirectLocation,
calendarVisibility,
messageVisibility,
} = user;
try {
const { user } = req;
const { workspaceMemberId, userId, workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
const {
emails,
accessToken,
refreshToken,
transientToken,
redirectLocation,
calendarVisibility,
messageVisibility,
} = user;
const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS');
const { workspaceMemberId, userId, workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
if (demoWorkspaceIds.includes(workspaceId)) {
throw new AuthException(
'Cannot connect Microsoft account to demo workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (!workspaceId) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const demoWorkspaceIds =
this.environmentService.get('DEMO_WORKSPACE_IDS');
if (emails.length === 0) {
throw new AuthException(
'No email - Ask your Azure Entra Admin to add you one on top of your User Principal Name',
AuthExceptionCode.USER_NOT_FOUND,
);
}
const handle = emails[0].value;
if (demoWorkspaceIds.includes(workspaceId)) {
throw new AuthException(
'Cannot connect Microsoft account to demo workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
await this.microsoftAPIsService.refreshMicrosoftRefreshToken({
handle,
workspaceMemberId: workspaceMemberId,
workspaceId: workspaceId,
accessToken,
refreshToken,
calendarVisibility,
messageVisibility,
});
if (!workspaceId) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (userId) {
await this.onboardingService.setOnboardingConnectAccountPending({
userId,
workspaceId,
value: false,
workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
}
const workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (emails.length === 0) {
throw new AuthException(
'No email - Ask your Azure Entra Admin to add you one on top of your User Principal Name',
AuthExceptionCode.USER_NOT_FOUND,
);
}
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
const handle = emails[0].value;
await this.microsoftAPIsService.refreshMicrosoftRefreshToken({
handle,
workspaceMemberId: workspaceMemberId,
workspaceId: workspaceId,
accessToken,
refreshToken,
calendarVisibility,
messageVisibility,
});
if (userId) {
await this.onboardingService.setOnboardingConnectAccountPending({
userId,
workspaceId,
value: false,
});
}
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
return res.redirect(
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),
);
} catch (err) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
),
);
}
return res.redirect(
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),
);
}
}

View File

@ -11,16 +11,15 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.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 { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
@ -28,7 +27,7 @@ export class MicrosoftAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@ -119,16 +118,14 @@ export class MicrosoftAuthController {
}),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl(
err.message,
currentWorkspace?.subdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
),
);
}
throw err;
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
currentWorkspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
),
);
}
}
}

View File

@ -30,17 +30,17 @@ import {
IdentityProviderType,
WorkspaceSSOIdentityProvider,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth')
export class SSOAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
private readonly sSOService: SSOService,
@InjectRepository(User, 'core')
@ -137,10 +137,11 @@ export class SSOAuthController {
);
} catch (err) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl(
err.message,
workspaceIdentityProvider?.workspace.subdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
workspaceIdentityProvider?.workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
),
);
}

View File

@ -27,11 +27,9 @@ export class EnterpriseFeaturesEnabledGuard implements CanActivate {
return true;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
this.guardRedirectService.getSubdomainFromContext(context),
);
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
});
return false;
}

View File

@ -8,41 +8,53 @@ import {
import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Injectable()
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
'google-apis',
) {
constructor(private readonly environmentService: EnvironmentService) {
constructor(
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
) {
super();
}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const state = JSON.parse(request.query.state);
try {
const request = context.switchToHttp().getRequest();
const state = JSON.parse(request.query.state);
if (
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
!this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED')
) {
throw new AuthException(
'Google apis auth is not enabled',
AuthExceptionCode.GOOGLE_API_AUTH_DISABLED,
if (
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
!this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED')
) {
throw new AuthException(
'Google apis auth is not enabled',
AuthExceptionCode.GOOGLE_API_AUTH_DISABLED,
);
}
new GoogleAPIsOauthExchangeCodeForTokenStrategy(
this.environmentService,
{},
);
setRequestExtraParams(request, {
transientToken: state.transientToken,
redirectLocation: state.redirectLocation,
calendarVisibility: state.calendarVisibility,
messageVisibility: state.messageVisibility,
});
return (await super.canActivate(context)) as boolean;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
});
return false;
}
new GoogleAPIsOauthExchangeCodeForTokenStrategy(
this.environmentService,
{},
);
setRequestExtraParams(request, {
transientToken: state.transientToken,
redirectLocation: state.redirectLocation,
calendarVisibility: state.calendarVisibility,
messageVisibility: state.messageVisibility,
});
return (await super.canActivate(context)) as boolean;
}
}

View File

@ -1,5 +1,8 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
AuthException,
@ -9,14 +12,17 @@ import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
constructor(
private readonly environmentService: EnvironmentService,
private readonly featureFlagService: FeatureFlagService,
private readonly transientTokenService: TransientTokenService,
private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {
super({
prompt: 'select_account',
@ -24,37 +30,53 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
let workspace: Workspace | null = null;
const { workspaceId, userId } =
await this.transientTokenService.verifyTransientToken(
request.query.transientToken,
try {
const request = context.switchToHttp().getRequest();
const { workspaceId, userId } =
await this.transientTokenService.verifyTransientToken(
request.query.transientToken,
);
workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
setRequestExtraParams(request, {
transientToken: request.query.transientToken,
redirectLocation: request.query.redirectLocation,
calendarVisibility: request.query.calendarVisibility,
messageVisibility: request.query.messageVisibility,
loginHint: request.query.loginHint,
userId: userId,
workspaceId: workspaceId,
});
if (
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
!this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED')
) {
throw new AuthException(
'Google apis auth is not enabled',
AuthExceptionCode.GOOGLE_API_AUTH_DISABLED,
);
}
new GoogleAPIsOauthRequestCodeStrategy(this.environmentService, {});
return (await super.canActivate(context)) as boolean;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
);
setRequestExtraParams(request, {
transientToken: request.query.transientToken,
redirectLocation: request.query.redirectLocation,
calendarVisibility: request.query.calendarVisibility,
messageVisibility: request.query.messageVisibility,
loginHint: request.query.loginHint,
userId: userId,
workspaceId: workspaceId,
});
if (
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
!this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED')
) {
throw new AuthException(
'Google apis auth is not enabled',
AuthExceptionCode.GOOGLE_API_AUTH_DISABLED,
);
return false;
}
new GoogleAPIsOauthRequestCodeStrategy(this.environmentService, {});
const activate = (await super.canActivate(context)) as boolean;
return activate;
}
}

View File

@ -75,8 +75,9 @@ export class GoogleOauthGuard extends AuthGuard('google') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
workspace?.subdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
);
return false;

View File

@ -28,11 +28,9 @@ export class GoogleProviderEnabledGuard implements CanActivate {
return true;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
this.guardRedirectService.getSubdomainFromContext(context),
);
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
});
return false;
}

View File

@ -8,12 +8,16 @@ import {
import { MicrosoftAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Injectable()
export class MicrosoftAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
'microsoft-apis',
) {
constructor(private readonly environmentService: EnvironmentService) {
constructor(
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
) {
super();
}
@ -35,12 +39,18 @@ export class MicrosoftAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
return (await super.canActivate(context)) as boolean;
} catch (error) {
if (error?.oauthError?.statusCode === 403) {
throw new AuthException(
`Insufficient privileges to access this microsoft resource. Make sure you have the correct scopes or ask your admin to update your scopes. ${error?.message}`,
AuthExceptionCode.INSUFFICIENT_SCOPES,
);
}
this.guardRedirectService.dispatchErrorFromGuard(
context,
error?.oauthError?.statusCode === 403
? new AuthException(
`Insufficient privileges to access this microsoft resource. Make sure you have the correct scopes or ask your admin to update your scopes. ${error?.message}`,
AuthExceptionCode.INSUFFICIENT_SCOPES,
)
: error,
{
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
},
);
return false;
}

View File

@ -1,5 +1,8 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
AuthException,
@ -11,6 +14,8 @@ import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Injectable()
export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
@ -20,6 +25,9 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
private readonly environmentService: EnvironmentService,
private readonly featureFlagService: FeatureFlagService,
private readonly transientTokenService: TransientTokenService,
private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {
super({
prompt: 'select_account',
@ -27,36 +35,53 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
let workspace: Workspace | null = null;
const { workspaceId } =
await this.transientTokenService.verifyTransientToken(
request.query.transientToken,
);
const isMicrosoftSyncEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsMicrosoftSyncEnabled,
workspaceId,
try {
const request = context.switchToHttp().getRequest();
const { workspaceId } =
await this.transientTokenService.verifyTransientToken(
request.query.transientToken,
);
workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
const isMicrosoftSyncEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsMicrosoftSyncEnabled,
workspaceId,
);
if (!isMicrosoftSyncEnabled) {
throw new AuthException(
'Microsoft sync is not enabled',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
new MicrosoftAPIsOauthRequestCodeStrategy(this.environmentService);
setRequestExtraParams(request, {
transientToken: request.query.transientToken,
redirectLocation: request.query.redirectLocation,
calendarVisibility: request.query.calendarVisibility,
messageVisibility: request.query.messageVisibility,
loginHint: request.query.loginHint,
});
return (await super.canActivate(context)) as boolean;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
);
if (!isMicrosoftSyncEnabled) {
throw new AuthException(
'Microsoft sync is not enabled',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
return false;
}
new MicrosoftAPIsOauthRequestCodeStrategy(this.environmentService);
setRequestExtraParams(request, {
transientToken: request.query.transientToken,
redirectLocation: request.query.redirectLocation,
calendarVisibility: request.query.calendarVisibility,
messageVisibility: request.query.messageVisibility,
loginHint: request.query.loginHint,
});
const activate = (await super.canActivate(context)) as boolean;
return activate;
}
}

View File

@ -64,8 +64,9 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
workspace?.subdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
);
return false;

View File

@ -28,11 +28,9 @@ export class MicrosoftProviderEnabledGuard implements CanActivate {
return true;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
this.guardRedirectService.getSubdomainFromContext(context),
);
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
});
return false;
}

View File

@ -77,8 +77,9 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
identityProvider?.workspace.subdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
identityProvider?.workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
);
return false;

View File

@ -49,8 +49,9 @@ export class SAMLAuthGuard extends AuthGuard('saml') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
identityProvider?.workspace.subdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
identityProvider?.workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
);
return false;

View File

@ -0,0 +1,114 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
describe('SocialSsoService', () => {
let socialSsoService: SocialSsoService;
let workspaceRepository: Repository<Workspace>;
let environmentService: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SocialSsoService,
{
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
socialSsoService = module.get<SocialSsoService>(SocialSsoService);
workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
environmentService = module.get<EnvironmentService>(EnvironmentService);
});
describe('findWorkspaceFromWorkspaceIdOrAuthProvider', () => {
it('should return a workspace by workspaceId', async () => {
const workspaceId = 'workspace-id-123';
const mockWorkspace = { id: workspaceId } as Workspace;
jest
.spyOn(workspaceRepository, 'findOneBy')
.mockResolvedValue(mockWorkspace);
const result =
await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider(
{ authProvider: 'google', email: 'test@example.com' },
workspaceId,
);
expect(result).toEqual(mockWorkspace);
expect(workspaceRepository.findOneBy).toHaveBeenCalledWith({
id: workspaceId,
});
});
it('should return a workspace from authProvider and email when multi-workspace mode is enabled', async () => {
const authProvider = 'google';
const email = 'test@example.com';
const mockWorkspace = { id: 'workspace-id-456' } as Workspace;
jest.spyOn(environmentService, 'get').mockReturnValue(true);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace);
const result =
await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({
authProvider,
email,
});
expect(result).toEqual(mockWorkspace);
expect(workspaceRepository.findOne).toHaveBeenCalledWith({
where: {
isGoogleAuthEnabled: true,
workspaceUsers: {
user: {
email,
},
},
},
relations: ['workspaceUsers', 'workspaceUsers.user'],
});
});
it('should return undefined if no workspace is found when multi-workspace mode is enabled', async () => {
jest.spyOn(environmentService, 'get').mockReturnValue(true);
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
const result =
await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({
authProvider: 'google',
email: 'notfound@example.com',
});
expect(result).toBeUndefined();
});
it('should throw an error for an invalid authProvider', async () => {
jest.spyOn(environmentService, 'get').mockReturnValue(true);
await expect(
socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({
authProvider: 'invalid-provider' as any,
email: 'test@example.com',
}),
).rejects.toThrow('invalid-provider is not a valid auth provider.');
});
});
});

View File

@ -2,27 +2,31 @@ import { ExecutionContext, Injectable } from '@nestjs/common';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { CustomException } from 'src/utils/custom-exception';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@Injectable()
export class GuardRedirectService {
constructor(
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
dispatchErrorFromGuard(context: any, err: any, subdomain: string) {
dispatchErrorFromGuard(
context: ExecutionContext,
error: Error | CustomException,
workspace: { id?: string; subdomain: string },
) {
if ('contextType' in context && context.contextType === 'graphql') {
throw err;
throw error;
}
context
.switchToHttp()
.getResponse()
.redirect(
this.domainManagerService
.computeRedirectErrorUrl(err.message ?? 'Unknown error', subdomain)
.toString(),
);
.redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace));
}
getSubdomainFromContext(context: ExecutionContext) {
@ -35,4 +39,26 @@ export class GuardRedirectService {
return subdomainFromUrl ?? this.environmentService.get('DEFAULT_SUBDOMAIN');
}
private captureException(err: Error | CustomException, workspaceId?: string) {
if (err instanceof AuthException) return;
this.exceptionHandlerService.captureExceptions([err], {
workspace: {
id: workspaceId,
},
});
}
getRedirectErrorUrlAndCaptureExceptions(
err: Error | CustomException,
workspace: { id?: string; subdomain: string },
) {
this.captureException(err, workspace.id);
return this.domainManagerService.computeRedirectErrorUrl(
err instanceof AuthException ? err.message : 'Unknown error',
workspace.subdomain,
);
}
}

View File

@ -23,6 +23,7 @@ import {
OIDCResponseType,
WorkspaceSSOIdentityProvider,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@Injectable()
export class SSOService {
@ -32,6 +33,7 @@ export class SSOService {
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
private readonly environmentService: EnvironmentService,
private readonly billingService: BillingService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
private async isSSOEnabled(workspaceId: string) {
@ -48,6 +50,17 @@ export class SSOService {
}
}
private async getIssuerForOIDC(issuerUrl: string) {
try {
return await Issuer.discover(issuerUrl);
} catch (err) {
throw new SSOException(
'Invalid issuer',
SSOExceptionCode.INVALID_ISSUER_URL,
);
}
}
async createOIDCIdentityProvider(
data: Pick<
WorkspaceSSOIdentityProvider,
@ -58,21 +71,7 @@ export class SSOService {
try {
await this.isSSOEnabled(workspaceId);
if (!data.issuer) {
throw new SSOException(
'Invalid issuer URL',
SSOExceptionCode.INVALID_ISSUER_URL,
);
}
const issuer = await Issuer.discover(data.issuer);
if (!issuer.metadata.issuer) {
throw new SSOException(
'Invalid issuer URL from discovery',
SSOExceptionCode.INVALID_ISSUER_URL,
);
}
const issuer = await this.getIssuerForOIDC(data.issuer);
const identityProvider =
await this.workspaceSSOIdentityProviderRepository.save({
@ -96,6 +95,8 @@ export class SSOService {
return err;
}
this.exceptionHandlerService.captureExceptions([err]);
return new SSOException(
'Unknown SSO configuration error',
SSOExceptionCode.UNKNOWN_SSO_CONFIGURATION_ERROR,
@ -131,6 +132,7 @@ export class SSOService {
async findSSOIdentityProviderById(identityProviderId: string) {
return (await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: identityProviderId },
relations: ['workspace'],
})) as (SSOConfiguration & WorkspaceSSOIdentityProvider) | null;
}