From 85579d364f32761ea18029662c0ebd5d3cec0c95 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 31 Jan 2025 14:53:48 +0100 Subject: [PATCH 1/2] feat(auth/sso): Add support for forceSubdomainUrl functionality Introduce `forceSubdomainUrl` for SSO login flow to handle subdomain-specific redirections. Updated guards, controllers, and services to incorporate the new parameter while removing redundant dependencies like `EnvironmentService`. Also added utility hooks and modified UI components to support the feature seamlessly. --- .../controllers/google-auth.controller.ts | 3 +- .../controllers/microsoft-auth.controller.ts | 3 +- .../auth/controllers/sso-auth.controller.ts | 16 ++-- .../auth/guards/google-oauth.guard.ts | 26 +------ .../auth/guards/microsoft-oauth.guard.ts | 23 ------ .../auth/strategies/google.auth.strategy.ts | 18 +---- .../strategies/microsoft.auth.strategy.ts | 18 +---- .../auth/strategies/oidc.auth.strategy.ts | 74 +++++++++++++------ .../auth/strategies/saml.auth.strategy.ts | 49 ++++++++---- 9 files changed, 108 insertions(+), 122 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index 530fd553394b..d7bf1d479d3b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -51,7 +51,6 @@ export class GoogleAuthController { email, picture, workspaceInviteHash, - workspacePersonalInviteToken, workspaceId, billingCheckoutSessionState, } = req.user; @@ -65,7 +64,7 @@ export class GoogleAuthController { try { const invitation = - currentWorkspace && workspacePersonalInviteToken && email + currentWorkspace && email ? await this.authService.findInvitationForSignInUp({ currentWorkspace, email, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 7f172d04ee4f..7b7b6a6f620b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -52,7 +52,6 @@ export class MicrosoftAuthController { email, picture, workspaceInviteHash, - workspacePersonalInviteToken, workspaceId, billingCheckoutSessionState, } = req.user; @@ -66,7 +65,7 @@ export class MicrosoftAuthController { try { const invitation = - currentWorkspace && workspacePersonalInviteToken && email + currentWorkspace && email ? await this.authService.findInvitationForSignInUp({ currentWorkspace, email, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 67ade3f879f0..002fc85941b8 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -34,6 +34,8 @@ 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'; +import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; +import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy'; @Controller('auth') export class SSOAuthController { @@ -85,14 +87,14 @@ export class SSOAuthController { @Get('oidc/callback') @UseGuards(EnterpriseFeaturesEnabledGuard, OIDCAuthGuard) @UseFilters(AuthOAuthExceptionFilter) - async oidcAuthCallback(@Req() req: any, @Res() res: Response) { + async oidcAuthCallback(@Req() req: OIDCRequest, @Res() res: Response) { return await this.authCallback(req, res); } @Post('saml/callback/:identityProviderId') @UseGuards(EnterpriseFeaturesEnabledGuard, SAMLAuthGuard) @UseFilters(AuthOAuthExceptionFilter) - async samlAuthCallback(@Req() req: any, @Res() res: Response) { + async samlAuthCallback(@Req() req: SAMLRequest, @Res() res: Response) { try { return await this.authCallback(req, res); } catch (err) { @@ -103,10 +105,10 @@ export class SSOAuthController { } } - private async authCallback({ user }: any, res: Response) { + private async authCallback(req: OIDCRequest | SAMLRequest, res: Response) { const workspaceIdentityProvider = await this.findWorkspaceIdentityProviderByIdentityProviderId( - user.identityProviderId, + req.user.identityProviderId, ); try { @@ -117,7 +119,7 @@ export class SSOAuthController { ); } - if (!user.user.email) { + if (!req.user.email) { throw new AuthException( 'Email not found from identity provider.', AuthExceptionCode.OAUTH_ACCESS_DENIED, @@ -125,7 +127,7 @@ export class SSOAuthController { } const { loginToken, identityProvider } = await this.generateLoginToken( - user.user, + req.user, workspaceIdentityProvider, ); @@ -157,7 +159,7 @@ export class SSOAuthController { } private async generateLoginToken( - payload: { email: string } & Record, + payload: { email: string }, identityProvider: WorkspaceSSOIdentityProvider, ) { if (!identityProvider) { diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts index e53c50194d97..96d9bfd33f41 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts @@ -3,6 +3,7 @@ import { AuthGuard } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { Request } from 'express'; import { AuthException, @@ -26,7 +27,7 @@ export class GoogleOauthGuard extends AuthGuard('google') { } async canActivate(context: ExecutionContext) { - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); let workspace: Workspace | null = null; try { @@ -40,9 +41,6 @@ export class GoogleOauthGuard extends AuthGuard('google') { }); } - const workspaceInviteHash = request.query.inviteHash; - const workspacePersonalInviteToken = request.query.inviteToken; - if (request.query.error === 'access_denied') { throw new AuthException( 'Google OAuth access denied', @@ -50,26 +48,6 @@ export class GoogleOauthGuard extends AuthGuard('google') { ); } - if (workspaceInviteHash && typeof workspaceInviteHash === 'string') { - request.params.workspaceInviteHash = workspaceInviteHash; - } - - if ( - workspacePersonalInviteToken && - typeof workspacePersonalInviteToken === 'string' - ) { - request.params.workspacePersonalInviteToken = - workspacePersonalInviteToken; - } - - if ( - request.query.billingCheckoutSessionState && - typeof request.query.billingCheckoutSessionState === 'string' - ) { - request.params.billingCheckoutSessionState = - request.query.billingCheckoutSessionState; - } - return (await super.canActivate(context)) as boolean; } catch (err) { this.guardRedirectService.dispatchErrorFromGuard( diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts index 78c7b00833b3..58c1a2d596af 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts @@ -36,29 +36,6 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { }); } - const workspaceInviteHash = request.query.inviteHash; - const workspacePersonalInviteToken = request.query.inviteToken; - - if (workspaceInviteHash && typeof workspaceInviteHash === 'string') { - request.params.workspaceInviteHash = workspaceInviteHash; - } - - if ( - workspacePersonalInviteToken && - typeof workspacePersonalInviteToken === 'string' - ) { - request.params.workspacePersonalInviteToken = - workspacePersonalInviteToken; - } - - if ( - request.query.billingCheckoutSessionState && - typeof request.query.billingCheckoutSessionState === 'string' - ) { - request.params.billingCheckoutSessionState = - request.query.billingCheckoutSessionState; - } - return (await super.canActivate(context)) as boolean; } catch (err) { this.guardRedirectService.dispatchErrorFromGuard( diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts index 358ad7e62d19..e12e7c452009 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts @@ -34,24 +34,14 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { }); } - authenticate(req: any, options: any) { + authenticate(req: Request, options: any) { options = { ...options, state: JSON.stringify({ - workspaceInviteHash: req.params.workspaceInviteHash, + workspaceInviteHash: req.query.workspaceInviteHash, workspaceId: req.params.workspaceId, - ...(req.params.billingCheckoutSessionState - ? { - billingCheckoutSessionState: - req.params.billingCheckoutSessionState, - } - : {}), - ...(req.params.workspacePersonalInviteToken - ? { - workspacePersonalInviteToken: - req.params.workspacePersonalInviteToken, - } - : {}), + billingCheckoutSessionState: req.query.billingCheckoutSessionState, + workspacePersonalInviteToken: req.query.workspacePersonalInviteToken, }), }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts index cbe7231d9651..02b13f9cf218 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts @@ -38,24 +38,14 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { }); } - authenticate(req: any, options: any) { + authenticate(req: Request, options: any) { options = { ...options, state: JSON.stringify({ - workspaceInviteHash: req.params.workspaceInviteHash, + workspaceInviteHash: req.query.workspaceInviteHash, workspaceId: req.params.workspaceId, - ...(req.params.billingCheckoutSessionState - ? { - billingCheckoutSessionState: - req.params.billingCheckoutSessionState, - } - : {}), - ...(req.params.workspacePersonalInviteToken - ? { - workspacePersonalInviteToken: - req.params.workspacePersonalInviteToken, - } - : {}), + billingCheckoutSessionState: req.query.billingCheckoutSessionState, + workspacePersonalInviteToken: req.query.workspacePersonalInviteToken, }), }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts index 8d3b94e9b024..c57623496ef4 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts @@ -3,11 +3,26 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; +import { isEmail } from 'class-validator'; +import { Request } from 'express'; +import { Strategy, StrategyOptions, TokenSet } from 'openid-client'; + import { - Strategy, - StrategyOptions, - StrategyVerifyCallbackReq, -} from 'openid-client'; + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; + +export type OIDCRequest = Omit< + Request, + 'user' | 'workspace' | 'workspaceMetadataVersion' +> & { + user: { + identityProviderId: string; + email: string; + firstName?: string | null; + lastName?: string | null; + }; +}; @Injectable() export class OIDCAuthStrategy extends PassportStrategy( @@ -30,7 +45,7 @@ export class OIDCAuthStrategy extends PassportStrategy( }); } - async authenticate(req: any, options: any) { + async authenticate(req: Request, options: any) { return super.authenticate(req, { ...options, state: JSON.stringify({ @@ -39,37 +54,50 @@ export class OIDCAuthStrategy extends PassportStrategy( }); } - validate: StrategyVerifyCallbackReq<{ + private extractState(req: Request): { identityProviderId: string; - user: { - email?: string; - firstName?: string | null; - lastName?: string | null; - }; - }> = async (req, tokenset, done) => { + } { try { const state = JSON.parse( - 'query' in req && - req.query && - typeof req.query === 'object' && - 'state' in req.query && - req.query.state && - typeof req.query.state === 'string' + req.query.state && typeof req.query.state === 'string' ? req.query.state : '{}', ); + if (!state.identityProviderId) { + throw new Error(); + } + + return { + identityProviderId: state.identityProviderId, + }; + } catch (err) { + throw new AuthException('Invalid state', AuthExceptionCode.INVALID_INPUT); + } + } + + async validate( + req: Request, + tokenset: TokenSet, + done: (err: any, user?: OIDCRequest['user']) => void, + ) { + try { + const state = this.extractState(req); + const userinfo = await this.client.userinfo(tokenset); - const user = { + if (!userinfo.email || !isEmail(userinfo.email)) { + return done(new Error('Invalid email')); + } + + done(null, { email: userinfo.email, + identityProviderId: state.identityProviderId, ...(userinfo.given_name ? { firstName: userinfo.given_name } : {}), ...(userinfo.family_name ? { lastName: userinfo.family_name } : {}), - }; - - done(null, { user, identityProviderId: state.identityProviderId }); + }); } catch (err) { done(err); } - }; + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts index 822b80f5ef3d..c3c9e5e7ee01 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts @@ -15,6 +15,20 @@ import { isEmail } from 'class-validator'; import { Request } from 'express'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; + +export type SAMLRequest = Omit< + Request, + 'user' | 'workspace' | 'workspaceMetadataVersion' +> & { + user: { + identityProviderId: string; + email: string; + }; +}; @Injectable() export class SamlAuthStrategy extends PassportStrategy( @@ -69,6 +83,24 @@ export class SamlAuthStrategy extends PassportStrategy( }); } + private extractState(req: Request): { + identityProviderId: string; + } { + try { + if ('RelayState' in req.body && typeof req.body.RelayState === 'string') { + const RelayState = JSON.parse(req.body.RelayState); + + return { + identityProviderId: RelayState.identityProviderId, + }; + } + + throw new Error(); + } catch (err) { + throw new AuthException('Invalid state', AuthExceptionCode.INVALID_INPUT); + } + } + validate: VerifyWithRequest = async (request, profile, done) => { try { if (!profile) { @@ -80,20 +112,11 @@ export class SamlAuthStrategy extends PassportStrategy( if (!isEmail(email)) { return done(new Error('Invalid email')); } + const state = this.extractState(request); - const result: { - user: Record; - identityProviderId?: string; - } = { user: { email } }; - - if ( - 'RelayState' in request.body && - typeof request.body.RelayState === 'string' - ) { - const RelayState = JSON.parse(request.body.RelayState); - - result.identityProviderId = RelayState.identityProviderId; - } + const result: Pick = { + user: { ...state, email }, + }; done(null, result); } catch (err) { From 5b9c9c5fe557d73cf5afb5c1623d6b23b6e49c15 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 31 Jan 2025 15:02:25 +0100 Subject: [PATCH 2/2] feat(security): add support for Microsoft SSO icon detection Updated the icon mapping logic to recognize Microsoft SSO URLs and return the appropriate icon. Also corrected the import and usage of the Microsoft icon asset to ensure consistency. --- .../utils/guessSSOIdentityProviderIconByUrl.ts | 11 ++++++++++- .../display/icon/components/IconMicrosoftOutlook.tsx | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts b/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts index f8582577f999..1fbde994daaa 100644 --- a/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts +++ b/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts @@ -1,6 +1,11 @@ /* @license Enterprise */ -import { IconComponent, IconGoogle, IconKey } from 'twenty-ui'; +import { + IconComponent, + IconGoogle, + IconKey, + IconMicrosoftOutlook, +} from 'twenty-ui'; export const guessSSOIdentityProviderIconByUrl = ( url: string, @@ -9,5 +14,9 @@ export const guessSSOIdentityProviderIconByUrl = ( return IconGoogle; } + if (url.includes('microsoft')) { + return IconMicrosoftOutlook; + } + return IconKey; }; diff --git a/packages/twenty-ui/src/display/icon/components/IconMicrosoftOutlook.tsx b/packages/twenty-ui/src/display/icon/components/IconMicrosoftOutlook.tsx index 153d0af09049..913eb6c4678a 100644 --- a/packages/twenty-ui/src/display/icon/components/IconMicrosoftOutlook.tsx +++ b/packages/twenty-ui/src/display/icon/components/IconMicrosoftOutlook.tsx @@ -1,6 +1,6 @@ import { useTheme } from '@emotion/react'; -import IconMicrosoftOutlookRaw from '../assets/microsoft-outlook.svg?react'; +import IconMicrosoftRaw from '../assets/microsoft.svg?react'; interface IconMicrosoftOutlookProps { size?: number; @@ -10,5 +10,5 @@ export const IconMicrosoftOutlook = (props: IconMicrosoftOutlookProps) => { const theme = useTheme(); const size = props.size ?? theme.icon.size.lg; - return ; + return ; };