From 87a9ecee282fbcb630065af03b98983f0005451d Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 24 Apr 2024 14:56:02 +0200 Subject: [PATCH] D gamer007/add microsoft oauth (#5103) Need to create a new branch because original branch name is `main` and we cannot push additional commits Linked to https://github.com/twentyhq/twenty/pull/4718 ![image](https://github.com/twentyhq/twenty/assets/29927851/52b220e7-770a-4ffe-b6e9-468605c2b8fa) ![image](https://github.com/twentyhq/twenty/assets/29927851/7a7a4737-f09f-4d9b-8962-5a9b8c71edc1) --------- Co-authored-by: DGamer007 --- package.json | 2 + .../docs/start/self-hosting/self-hosting.mdx | 8 +- .../twenty-front/src/generated/graphql.tsx | 5 +- .../auth/hooks/__test__/useAuth.test.tsx | 3 +- .../src/modules/auth/hooks/useAuth.ts | 9 + .../components/HorizontalSeparator.tsx | 13 +- .../sign-in-up/components/SignInUpForm.tsx | 235 ++++++++++-------- .../hooks/useSignInWithMicrosoft.ts | 11 + .../components/ClientConfigProviderEffect.tsx | 1 + .../graphql/queries/getClientConfig.ts | 1 + .../states/authProvidersState.ts | 7 +- packages/twenty-server/.env.example | 8 +- .../engine/core-modules/auth/auth.module.ts | 2 + .../controllers/microsoft-auth.controller.ts | 49 ++++ .../auth/guards/google-oauth.guard.ts | 3 +- .../auth/guards/microsoft-oauth.guard.ts | 22 ++ .../microsoft-provider-enabled.guard.ts | 21 ++ .../strategies/microsoft.auth.strategy.ts | 76 ++++++ .../client-config/client-config.entity.ts | 3 + .../client-config/client-config.resolver.ts | 3 +- .../environment/environment-variables.ts | 43 +++- .../src/display/icon/assets/microsoft.svg | 7 + .../display/icon/components/IconMicrosoft.tsx | 14 ++ packages/twenty-ui/src/display/index.ts | 1 + yarn.lock | 40 +++ 25 files changed, 458 insertions(+), 129 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithMicrosoft.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts create mode 100644 packages/twenty-ui/src/display/icon/assets/microsoft.svg create mode 100644 packages/twenty-ui/src/display/icon/components/IconMicrosoft.tsx diff --git a/package.json b/package.json index 8d64dd74a2..6aba58f229 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@types/lodash.pick": "^4.3.7", "@types/mailparser": "^3.4.4", "@types/nodemailer": "^6.4.14", + "@types/passport-microsoft": "^1.0.3", "add": "^2.0.6", "afterframe": "^1.0.2", "apollo-server-express": "^3.12.0", @@ -136,6 +137,7 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "passport-microsoft": "^2.0.0", "patch-package": "^8.0.0", "pg": "^8.11.3", "pg-boss": "^9.0.3", diff --git a/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx b/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx index 936e675773..9bbacd045f 100644 --- a/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx +++ b/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx @@ -70,10 +70,16 @@ import TabItem from '@theme/TabItem'; ['MESSAGING_PROVIDER_GMAIL_ENABLED', 'false', 'Enable Gmail API connection'], ['CALENDAR_PROVIDER_GMAIL_ENABLED', 'false', 'Enable Google Calendar API connection'], ['AUTH_GOOGLE_APIS_CALLBACK_URL', '', 'Google APIs auth callback'], - ['AUTH_GOOGLE_ENABLED', 'false', 'Enable Goole SSO login'], + ['AUTH_PASSWORD_ENABLED', 'false', 'Enable Email/Password login'], + ['AUTH_GOOGLE_ENABLED', 'false', 'Enable Google SSO login'], ['AUTH_GOOGLE_CLIENT_ID', '', 'Google client ID'], ['AUTH_GOOGLE_CLIENT_SECRET', '', 'Google client secret'], ['AUTH_GOOGLE_CALLBACK_URL', '', 'Google auth callback'], + ['AUTH_MICROSOFT_ENABLED', 'false', 'Enable Microsoft SSO login'], + ['AUTH_MICROSOFT_CLIENT_ID', '', 'Microsoft client ID'], + ['AUTH_MICROSOFT_TENANT_ID', '', 'Microsoft tenant ID'], + ['AUTH_MICROSOFT_CLIENT_SECRET', '', 'Microsoft client secret'], + ['AUTH_MICROSOFT_CALLBACK_URL', '', 'Microsoft auth callback'], ['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'], ['IS_SIGN_UP_DISABLED', 'false', 'Disable sign-up'], ['PASSWORD_RESET_TOKEN_EXPIRES_IN', '5m', 'Password reset token expiration time'], diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index a647edeb6f..100824406a 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -56,6 +56,7 @@ export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']; magicLink: Scalars['Boolean']; + microsoft: Scalars['Boolean']; password: Scalars['Boolean']; }; @@ -562,6 +563,7 @@ export type RemoteServer = { export type RemoteTable = { __typename?: 'RemoteTable'; + id?: Maybe; name: Scalars['String']; schema: Scalars['String']; status: RemoteTableStatus; @@ -1111,7 +1113,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null } } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -2140,6 +2142,7 @@ export const GetClientConfigDocument = gql` authProviders { google password + microsoft } billing { isBillingEnabled diff --git a/packages/twenty-front/src/modules/auth/hooks/__test__/useAuth.test.tsx b/packages/twenty-front/src/modules/auth/hooks/__test__/useAuth.test.tsx index 79e915dc64..333f97ed25 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__test__/useAuth.test.tsx +++ b/packages/twenty-front/src/modules/auth/hooks/__test__/useAuth.test.tsx @@ -116,8 +116,9 @@ describe('useAuth', () => { expect(state.icons).toEqual({}); expect(state.authProviders).toEqual({ google: false, + microsoft: false, magicLink: false, - password: true, + password: false, }); expect(state.billing).toBeNull(); expect(state.isSignInPrefilled).toBe(false); diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 392c717a17..ea204c95ac 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -234,6 +234,14 @@ export const useAuth = () => { }` || ''; }, []); + const handleMicrosoftLogin = useCallback((workspaceInviteHash?: string) => { + const authServerUrl = REACT_APP_SERVER_BASE_URL; + window.location.href = + `${authServerUrl}/auth/microsoft/${ + workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : '' + }` || ''; + }, []); + return { challenge: handleChallenge, verify: handleVerify, @@ -244,5 +252,6 @@ export const useAuth = () => { signUpWithCredentials: handleCredentialsSignUp, signInWithCredentials: handleCrendentialsSignIn, signInWithGoogle: handleGoogleLogin, + signInWithMicrosoft: handleMicrosoftLogin, }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx index 415bae72d3..f7c5b61f0e 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx @@ -1,12 +1,19 @@ import { JSX } from 'react'; import styled from '@emotion/styled'; -const StyledSeparator = styled.div` +type HorizontalSeparatorProps = { + visible?: boolean; +}; +const StyledSeparator = styled.div` background-color: ${({ theme }) => theme.border.color.medium}; - height: 1px; + height: ${({ visible }) => (visible ? '1px' : 0)}; margin-bottom: ${({ theme }) => theme.spacing(3)}; margin-top: ${({ theme }) => theme.spacing(3)}; width: 100%; `; -export const HorizontalSeparator = (): JSX.Element => ; +export const HorizontalSeparator = ({ + visible = true, +}: HorizontalSeparatorProps): JSX.Element => ( + +); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index ae5a08e58d..9c6ce8de83 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -4,11 +4,12 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; import { useRecoilState } from 'recoil'; -import { IconGoogle } from 'twenty-ui'; +import { IconGoogle, IconMicrosoft } from 'twenty-ui'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; +import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { Loader } from '@/ui/feedback/loader/components/Loader'; @@ -27,7 +28,6 @@ import { HorizontalSeparator } from './HorizontalSeparator'; const StyledContentContainer = styled.div` margin-bottom: ${({ theme }) => theme.spacing(8)}; margin-top: ${({ theme }) => theme.spacing(4)}; - width: 200px; `; const StyledForm = styled.form` @@ -51,6 +51,7 @@ export const SignInUpForm = () => { const { handleResetPassword } = useHandleResetPassword(); const workspace = useWorkspaceFromInviteHash(); const { signInWithGoogle } = useSignInWithGoogle(); + const { signInWithMicrosoft } = useSignInWithMicrosoft(); const { form } = useSignInUpForm(); const { @@ -125,119 +126,133 @@ export const SignInUpForm = () => { onClick={signInWithGoogle} fullWidth /> - + )} - { - event.preventDefault(); - }} - > - {signInUpStep !== SignInUpStep.Init && ( - - ( - - { - onChange(value); - if (signInUpStep === SignInUpStep.Password) { - continueWithEmail(); - } - }} - error={showErrors ? error?.message : undefined} - fullWidth - disableHotkeys - onKeyDown={handleKeyDown} - /> - - )} - /> - - )} - {signInUpStep === SignInUpStep.Password && ( - - ( - - - - )} - /> - - )} + {authProviders.microsoft && ( + <> + } + title="Continue with Microsoft" + onClick={signInWithMicrosoft} + fullWidth + /> + + + )} - { - if (signInUpStep === SignInUpStep.Init) { - continueWithEmail(); - return; - } - if (signInUpStep === SignInUpStep.Email) { - continueWithCredentials(); - return; - } - setShowErrors(true); - form.handleSubmit(submitCredentials)(); + {authProviders.password && ( + { + event.preventDefault(); }} - Icon={() => form.formState.isSubmitting && } - disabled={ - signInUpStep === SignInUpStep.Init - ? false - : signInUpStep === SignInUpStep.Email - ? !form.watch('email') - : !form.watch('email') || - !form.watch('password') || - form.formState.isSubmitting - } - fullWidth - /> - + > + {signInUpStep !== SignInUpStep.Init && ( + + ( + + { + onChange(value); + if (signInUpStep === SignInUpStep.Password) { + continueWithEmail(); + } + }} + error={showErrors ? error?.message : undefined} + fullWidth + disableHotkeys + onKeyDown={handleKeyDown} + /> + + )} + /> + + )} + {signInUpStep === SignInUpStep.Password && ( + + ( + + + + )} + /> + + )} + + { + if (signInUpStep === SignInUpStep.Init) { + continueWithEmail(); + return; + } + if (signInUpStep === SignInUpStep.Email) { + continueWithCredentials(); + return; + } + setShowErrors(true); + form.handleSubmit(submitCredentials)(); + }} + Icon={() => form.formState.isSubmitting && } + disabled={ + signInUpStep === SignInUpStep.Init + ? false + : signInUpStep === SignInUpStep.Email + ? !form.watch('email') + : !form.watch('email') || + !form.watch('password') || + form.formState.isSubmitting + } + fullWidth + /> + + )} {signInUpStep === SignInUpStep.Password && ( diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithMicrosoft.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithMicrosoft.ts new file mode 100644 index 0000000000..06150ec0d8 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithMicrosoft.ts @@ -0,0 +1,11 @@ +import { useParams } from 'react-router-dom'; + +import { useAuth } from '@/auth/hooks/useAuth.ts'; + +export const useSignInWithMicrosoft = () => { + const workspaceInviteHash = useParams().workspaceInviteHash; + const { signInWithMicrosoft } = useAuth(); + return { + signInWithMicrosoft: () => signInWithMicrosoft(workspaceInviteHash), + }; +}; diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 976921d407..27bbb8923c 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -38,6 +38,7 @@ export const ClientConfigProviderEffect = () => { setIsClientConfigLoaded(true); setAuthProviders({ google: data?.clientConfig.authProviders.google, + microsoft: data?.clientConfig.authProviders.microsoft, password: data?.clientConfig.authProviders.password, magicLink: false, }); diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index b076a54455..59d61c4a21 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -6,6 +6,7 @@ export const GET_CLIENT_CONFIG = gql` authProviders { google password + microsoft } billing { isBillingEnabled diff --git a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts index 6ea130303a..6b705d0375 100644 --- a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts +++ b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts @@ -4,5 +4,10 @@ import { AuthProviders } from '~/generated/graphql'; export const authProvidersState = createState({ key: 'authProvidersState', - defaultValue: { google: false, magicLink: false, password: true }, + defaultValue: { + google: false, + magicLink: false, + password: false, + microsoft: false, + }, }); diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 2c881827c7..dd36a7c8ba 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -21,12 +21,18 @@ SIGN_IN_PREFILLED=true # REFRESH_TOKEN_EXPIRES_IN=90d # FILE_TOKEN_EXPIRES_IN=1d # FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify -# AUTH_GOOGLE_ENABLED=false # MESSAGING_PROVIDER_GMAIL_ENABLED=false # CALENDAR_PROVIDER_GOOGLE_ENABLED=false # IS_BILLING_ENABLED=false # BILLING_PLAN_REQUIRED_LINK=https://twenty.com/stripe-redirection +# AUTH_PASSWORD_ENABLED=false # IS_SIGN_UP_DISABLED=false +# AUTH_MICROSOFT_ENABLED=false +# AUTH_MICROSOFT_CLIENT_ID=replace_me_with_azure_client_id +# AUTH_MICROSOFT_TENANT_ID=replace_me_with_azure_tenant_id +# AUTH_MICROSOFT_CLIENT_SECRET=replace_me_with_azure_client_secret +# AUTH_MICROSOFT_CALLBACK_URL=http://localhost:3000/auth/microsoft/redirect +# AUTH_GOOGLE_ENABLED=false # AUTH_GOOGLE_CLIENT_ID=replace_me_with_google_client_id # AUTH_GOOGLE_CLIENT_SECRET=replace_me_with_google_client_secret # AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index f2eb77f6e6..c3853b5638 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -21,6 +21,7 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; +import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller'; import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service'; import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; import { ConnectedAccountObjectMetadata } from 'src/modules/connected-account/standard-objects/connected-account.object-metadata'; @@ -65,6 +66,7 @@ const jwtModule = JwtModule.registerAsync({ ], controllers: [ GoogleAuthController, + MicrosoftAuthController, GoogleAPIsAuthController, VerifyAuthController, ], 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 new file mode 100644 index 0000000000..798f8ed184 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; + +import { Response } from 'express'; + +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +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 { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; + +@Controller('auth/microsoft') +export class MicrosoftAuthController { + constructor( + private readonly tokenService: TokenService, + private readonly typeORMService: TypeORMService, + private readonly authService: AuthService, + ) {} + + @Get() + @UseGuards(MicrosoftProviderEnabledGuard, MicrosoftOAuthGuard) + async microsoftAuth() { + // As this method is protected by Microsoft Auth guard, it will trigger Microsoft SSO flow + return; + } + + @Get('redirect') + @UseGuards(MicrosoftProviderEnabledGuard, MicrosoftOAuthGuard) + async microsoftAuthRedirect( + @Req() req: MicrosoftRequest, + @Res() res: Response, + ) { + const { firstName, lastName, email, picture, workspaceInviteHash } = + req.user; + + const user = await this.authService.signInUp({ + email, + firstName, + lastName, + picture, + workspaceInviteHash, + fromSSO: true, + }); + + const loginToken = await this.tokenService.generateLoginToken(user.email); + + return res.redirect(this.tokenService.computeRedirectURI(loginToken.token)); + } +} 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 c7edba6126..c7185ff883 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 @@ -16,8 +16,7 @@ export class GoogleOauthGuard extends AuthGuard('google') { if (workspaceInviteHash && typeof workspaceInviteHash === 'string') { request.params.workspaceInviteHash = workspaceInviteHash; } - const activate = (await super.canActivate(context)) as boolean; - return activate; + return (await super.canActivate(context)) as boolean; } } 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 new file mode 100644 index 0000000000..44f084a26c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts @@ -0,0 +1,22 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { + constructor() { + super({ + prompt: 'select_account', + }); + } + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const workspaceInviteHash = request.query.inviteHash; + + if (workspaceInviteHash && typeof workspaceInviteHash === 'string') { + request.params.workspaceInviteHash = workspaceInviteHash; + } + + return (await super.canActivate(context)) as boolean; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard.ts new file mode 100644 index 0000000000..2cd65f3593 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard.ts @@ -0,0 +1,21 @@ +import { Injectable, CanActivate, NotFoundException } from '@nestjs/common'; + +import { Observable } from 'rxjs'; + +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { MicrosoftStrategy } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; + +@Injectable() +export class MicrosoftProviderEnabledGuard implements CanActivate { + constructor(private readonly environmentService: EnvironmentService) {} + + canActivate(): boolean | Promise | Observable { + if (!this.environmentService.get('AUTH_MICROSOFT_ENABLED')) { + throw new NotFoundException('Microsoft auth is not enabled'); + } + + new MicrosoftStrategy(this.environmentService); + + return true; + } +} 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 new file mode 100644 index 0000000000..e26e02e9f9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts @@ -0,0 +1,76 @@ +import { BadRequestException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; + +import { Request } from 'express'; +import { VerifyCallback } from 'passport-google-oauth20'; +import { Strategy } from 'passport-microsoft'; + +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; + +export type MicrosoftRequest = Omit< + Request, + 'user' | 'workspace' | 'cacheVersion' +> & { + user: { + firstName?: string | null; + lastName?: string | null; + email: string; + picture: string | null; + workspaceInviteHash?: string; + }; +}; + +export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { + constructor(environmentService: EnvironmentService) { + super({ + clientID: environmentService.get('AUTH_MICROSOFT_CLIENT_ID'), + clientSecret: environmentService.get('AUTH_MICROSOFT_CLIENT_SECRET'), + callbackURL: environmentService.get('AUTH_MICROSOFT_CALLBACK_URL'), + tenant: environmentService.get('AUTH_MICROSOFT_TENANT_ID'), + scope: ['user.read'], + passReqToCallback: true, + }); + } + + authenticate(req: any, options: any) { + options = { + ...options, + state: JSON.stringify({ + workspaceInviteHash: req.params.workspaceInviteHash, + }), + }; + + return super.authenticate(req, options); + } + + async validate( + request: MicrosoftRequest, + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + const { name, emails, photos } = profile; + + const state = + typeof request.query.state === 'string' + ? JSON.parse(request.query.state) + : undefined; + + const email = emails?.[0]?.value ?? null; + + if (!email) { + throw new BadRequestException('No email found in your Microsoft profile'); + } + + const user: MicrosoftRequest['user'] = { + email, + firstName: name.givenName, + lastName: name.familyName, + picture: photos?.[0]?.value, + workspaceInviteHash: state.workspaceInviteHash, + }; + + done(null, user); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 368ba81edb..9a8a80101b 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -10,6 +10,9 @@ class AuthProviders { @Field(() => Boolean) password: boolean; + + @Field(() => Boolean) + microsoft: boolean; } @ObjectType() diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index 8338493e1a..bfaaef8829 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -14,7 +14,8 @@ export class ClientConfigResolver { authProviders: { google: this.environmentService.get('AUTH_GOOGLE_ENABLED'), magicLink: false, - password: true, + password: this.environmentService.get('AUTH_PASSWORD_ENABLED'), + microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'), }, telemetry: { enabled: this.environmentService.get('TELEMETRY_ENABLED'), diff --git a/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts b/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts index 1414acbfb3..7553c4ff0c 100644 --- a/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts @@ -52,11 +52,6 @@ export class EnvironmentVariables { @Max(65535) DEBUG_PORT = 9000; - @CastToBoolean() - @IsOptional() - @IsBoolean() - SIGN_IN_PREFILLED = false; - @CastToBoolean() @IsOptional() @IsBoolean() @@ -156,21 +151,53 @@ export class EnvironmentVariables { @IsOptional() FRONT_AUTH_CALLBACK_URL: string; + @CastToBoolean() + @IsOptional() + @IsBoolean() + AUTH_PASSWORD_ENABLED = true; + + @CastToBoolean() + @IsOptional() + @IsBoolean() + @ValidateIf((env) => env.AUTH_PASSWORD_ENABLED) + SIGN_IN_PREFILLED = false; + + @CastToBoolean() + @IsOptional() + @IsBoolean() + AUTH_MICROSOFT_ENABLED = false; + + @IsString() + @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) + AUTH_MICROSOFT_CLIENT_ID: string; + + @IsString() + @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) + AUTH_MICROSOFT_TENANT_ID: string; + + @IsString() + @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) + AUTH_MICROSOFT_CLIENT_SECRET: string; + + @IsUrl({ require_tld: false }) + @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) + AUTH_MICROSOFT_CALLBACK_URL: string; + @CastToBoolean() @IsOptional() @IsBoolean() AUTH_GOOGLE_ENABLED = false; @IsString() - @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED === true) + @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) AUTH_GOOGLE_CLIENT_ID: string; @IsString() - @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED === true) + @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) AUTH_GOOGLE_CLIENT_SECRET: string; @IsUrl({ require_tld: false }) - @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED === true) + @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) AUTH_GOOGLE_CALLBACK_URL: string; // Storage diff --git a/packages/twenty-ui/src/display/icon/assets/microsoft.svg b/packages/twenty-ui/src/display/icon/assets/microsoft.svg new file mode 100644 index 0000000000..404ad3eb77 --- /dev/null +++ b/packages/twenty-ui/src/display/icon/assets/microsoft.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/twenty-ui/src/display/icon/components/IconMicrosoft.tsx b/packages/twenty-ui/src/display/icon/components/IconMicrosoft.tsx new file mode 100644 index 0000000000..b936998a66 --- /dev/null +++ b/packages/twenty-ui/src/display/icon/components/IconMicrosoft.tsx @@ -0,0 +1,14 @@ +import { useTheme } from '@emotion/react'; + +import IconMicrosoftRaw from '../assets/microsoft.svg?react'; + +interface IconMicrosoftProps { + size?: number; +} + +export const IconMicrosoft = (props: IconMicrosoftProps) => { + const theme = useTheme(); + const size = props.size ?? theme.icon.size.lg; + + return ; +}; diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index 2d74a886fb..9e898de787 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -8,6 +8,7 @@ export * from './icon/components/IconAddressBook'; export * from './icon/components/IconGmail'; export * from './icon/components/IconGoogle'; export * from './icon/components/IconGoogleCalendar'; +export * from './icon/components/IconMicrosoft'; export * from './icon/components/IconTwentyStar'; export * from './icon/components/IconTwentyStarFilled'; export * from './icon/components/TablerIcons'; diff --git a/yarn.lock b/yarn.lock index 7f36c4f4fe..cbc8063060 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17093,6 +17093,15 @@ __metadata: languageName: node linkType: hard +"@types/passport-microsoft@npm:^1.0.3": + version: 1.0.3 + resolution: "@types/passport-microsoft@npm:1.0.3" + dependencies: + "@types/passport-oauth2": "npm:*" + checksum: d177f63ae4976253242966146030b9913dc3b77c4eb192f5491d397f9036b64f258325157c244960ef4539e9c46807d0bba53497630253f4ef98d1318142b3d8 + languageName: node + linkType: hard + "@types/passport-oauth2@npm:*": version: 1.4.15 resolution: "@types/passport-oauth2@npm:1.4.15" @@ -38165,6 +38174,13 @@ __metadata: languageName: node linkType: hard +"oauth@npm:0.10.x": + version: 0.10.0 + resolution: "oauth@npm:0.10.0" + checksum: 76f3e186cfd76cb33e5d5d442861c86680a5c3b71b2db1b854212087532c265a69de1a2ab9db683e6c6df733e17cfc67476527b81b224a19c1917de2bc3f75fa + languageName: node + linkType: hard + "oauth@npm:0.9.x": version: 0.9.15 resolution: "oauth@npm:0.9.15" @@ -39110,6 +39126,28 @@ __metadata: languageName: node linkType: hard +"passport-microsoft@npm:^2.0.0": + version: 2.0.0 + resolution: "passport-microsoft@npm:2.0.0" + dependencies: + passport-oauth2: "npm:1.8.0" + checksum: 6b7d053673d5af6cbbce3812a628afff2435aa321d70c9feaa3309d7a610a2e61c753a7cde8c0febee2cacf74b971fcdc7699fa1c073c72052bc4334f1b203ec + languageName: node + linkType: hard + +"passport-oauth2@npm:1.8.0": + version: 1.8.0 + resolution: "passport-oauth2@npm:1.8.0" + dependencies: + base64url: "npm:3.x.x" + oauth: "npm:0.10.x" + passport-strategy: "npm:1.x.x" + uid2: "npm:0.0.x" + utils-merge: "npm:1.x.x" + checksum: 16b431bd856b84dfe0c9c913dcbea6ff54875befac1035171b0dce1c77f79072dc5e26d785b13c2e62c034c8174a1a47571751d1066bdbcdb9108de217c0b19b + languageName: node + linkType: hard + "passport-oauth2@npm:1.x.x": version: 1.7.0 resolution: "passport-oauth2@npm:1.7.0" @@ -46629,6 +46667,7 @@ __metadata: "@types/nodemailer": "npm:^6.4.14" "@types/passport-google-oauth20": "npm:^2.0.11" "@types/passport-jwt": "npm:^3.0.8" + "@types/passport-microsoft": "npm:^1.0.3" "@types/react": "npm:^18.2.39" "@types/react-datepicker": "npm:^6.2.0" "@types/react-dom": "npm:^18.2.15" @@ -46745,6 +46784,7 @@ __metadata: passport-google-oauth20: "npm:^2.0.0" passport-jwt: "npm:^4.0.1" passport-local: "npm:^1.0.0" + passport-microsoft: "npm:^2.0.0" patch-package: "npm:^8.0.0" pg: "npm:^8.11.3" pg-boss: "npm:^9.0.3"