import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import crypto, { randomUUID } from 'node:crypto'; import { msg } from '@lingui/core/macro'; import { addMilliseconds } from 'date-fns'; import ms from 'ms'; import { PasswordUpdateNotifyEmail, renderEmail } from 'twenty-emails'; import { PermissionFlagType } from 'twenty-shared/constants'; import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types'; import { isNonEmptyString } from '@sniptt/guards'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { IsNull, Repository } from 'typeorm'; import { AppTokenEntity, AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; import { INVITATION_APP_TOKEN_TYPES } from 'src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation'; import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { PASSWORD_REGEX, compareHash, hashPassword, } from 'src/engine/core-modules/auth/auth.util'; import { type AuthTokens } from 'src/engine/core-modules/auth/dto/auth-tokens.dto'; import { type AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto'; import { type AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; import { type UpdatePasswordDTO } from 'src/engine/core-modules/auth/dto/update-password.dto'; import { type UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input'; import { type CheckUserExistDTO } from 'src/engine/core-modules/auth/dto/user-exists.dto'; import { type WorkspaceInviteHashValidDTO } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.dto'; import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { CreateSSOConnectedAccountService } from 'src/engine/core-modules/auth/services/create-sso-connected-account.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { type GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { type MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service'; import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { type AuthProviderWithPasswordType, type ExistingUserOrNewUser, type SignInUpBaseParams, type SignInUpNewUserPayload, } from 'src/engine/core-modules/auth/types/signInUp.type'; import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util'; import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service'; import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { WorkspaceDomainConfig } from 'src/engine/core-modules/domain/workspace-domains/types/workspace-domain-config.type'; import { EmailService } from 'src/engine/core-modules/email/email.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 { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { assertIssuerIsPublishedOrThrow } from 'src/engine/core-modules/auth/utils/assert-issuer-is-published.util'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { isEmailInApprovedAccessDomains } from 'src/engine/core-modules/approved-access-domain/utils/is-email-in-approved-access-domains.util'; @Injectable() // oxlint-disable-next-line twenty/inject-workspace-repository export class AuthService { constructor( private readonly accessTokenService: AccessTokenService, private readonly ssoExchangeTokenService: SSOExchangeTokenService, private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly domainServerConfigService: DomainServerConfigService, private readonly refreshTokenService: RefreshTokenService, private readonly loginTokenService: LoginTokenService, private readonly guardRedirectService: GuardRedirectService, private readonly userWorkspaceService: UserWorkspaceService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly authSsoService: AuthSsoService, private readonly userService: UserService, private readonly signInUpService: SignInUpService, private readonly permissionsService: PermissionsService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, @InjectRepository(UserEntity) private readonly userRepository: Repository, private readonly twentyConfigService: TwentyConfigService, private readonly emailService: EmailService, @InjectRepository(AppTokenEntity) private readonly appTokenRepository: Repository, private readonly i18nService: I18nService, private readonly eventLogEmitterService: EventLogEmitterService, private readonly applicationRegistrationService: ApplicationRegistrationService, private readonly featureFlagService: FeatureFlagService, private readonly createSSOConnectedAccountService: CreateSSOConnectedAccountService, private readonly userSessionService: UserSessionService, ) {} private async checkAccessAndUseInvitationOrThrow( workspace: WorkspaceEntity, user: UserEntity, ) { if ( await this.userWorkspaceService.checkUserWorkspaceExists( user.id, workspace.id, ) ) { return; } const invitation = await this.workspaceInvitationService.getOneWorkspaceInvitation( workspace.id, user.email, ); if (invitation) { await this.workspaceInvitationService.validatePersonalInvitation({ workspacePersonalInviteToken: invitation.value, email: user.email, }); await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace( user, workspace, invitation.context?.roleId, ); return; } throw new AuthException( 'User is not a member of the workspace.', AuthExceptionCode.FORBIDDEN_EXCEPTION, { userFriendlyMessage: msg`User is not a member of the workspace.`, }, ); } async validateLoginWithPassword( input: UserCredentialsInput, targetWorkspace?: WorkspaceEntity, ) { const user = await this.userRepository.findOne({ where: { email: input.email, }, relations: { userWorkspaces: true }, }); if (!user) { throw new AuthException( 'User not found', AuthExceptionCode.USER_NOT_FOUND, ); } if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) { const canBypass = await this.canUserBypassAuthProvider({ user, workspace: targetWorkspace, provider: AuthProviderEnum.Password, }); if (!canBypass) { throw new AuthException( 'Email/Password auth is not enabled for this workspace', AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } } if (targetWorkspace) { await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user); } if (!user.passwordHash) { throw new AuthException( 'Incorrect login method', AuthExceptionCode.INVALID_INPUT, { userFriendlyMessage: msg`User was not created with email/password`, }, ); } const isValid = await compareHash(input.password, user.passwordHash); if (!isValid) { throw new AuthException( 'Wrong password', AuthExceptionCode.FORBIDDEN_EXCEPTION, { userFriendlyMessage: msg`Wrong password.`, }, ); } await this.checkIsEmailVerified(user.isEmailVerified); return user; } async checkIsEmailVerified(isEmailVerified: boolean) { const isEmailVerificationRequired = this.twentyConfigService.get( 'IS_EMAIL_VERIFICATION_REQUIRED', ); if (isEmailVerificationRequired && !isEmailVerified) { throw new AuthException( 'Email is not verified', AuthExceptionCode.EMAIL_NOT_VERIFIED, ); } } private async validatePassword( userData: ExistingUserOrNewUser['userData'], authParams: Extract< AuthProviderWithPasswordType['authParams'], { provider: AuthProviderEnum.Password } >, ) { if (userData.type === 'newUser') { userData.newUserPayload.passwordHash = await this.signInUpService.generateHash(authParams.password); } if (userData.type === 'existingUser') { if (!userData.existingUser.passwordHash) { throw new AuthException( 'Incorrect login method', AuthExceptionCode.INVALID_INPUT, { userFriendlyMessage: msg`User was not created with email/password`, }, ); } await this.signInUpService.validatePassword({ password: authParams.password, passwordHash: userData.existingUser.passwordHash, }); } } private async isAuthProviderEnabledOrThrow( userData: ExistingUserOrNewUser['userData'], authParams: AuthProviderWithPasswordType['authParams'], workspace: WorkspaceEntity | undefined | null, ) { if (authParams.provider === AuthProviderEnum.Password) { await this.validatePassword(userData, authParams); } if (isDefined(workspace)) { const isProviderEnabled = workspaceValidator.isAuthEnabled( authParams.provider, workspace, ); if (isProviderEnabled) { return; } const existingUser = userData.type === 'existingUser' ? userData.existingUser : undefined; if ( existingUser && (await this.canUserBypassAuthProvider({ user: existingUser, workspace, provider: authParams.provider, })) ) { return; } workspaceValidator.isAuthEnabledOrThrow(authParams.provider, workspace); } } private async canUserBypassAuthProvider({ user, workspace, provider, }: { user: UserEntity; workspace: WorkspaceEntity; provider: AuthProviderEnum; }): Promise { const bypassEnabled = (() => { switch (provider) { case AuthProviderEnum.Password: return workspace.isPasswordAuthBypassEnabled; case AuthProviderEnum.Google: return workspace.isGoogleAuthBypassEnabled; case AuthProviderEnum.Microsoft: return workspace.isMicrosoftAuthBypassEnabled; default: return false; } })(); if (!bypassEnabled) { return false; } const userWorkspace = user.userWorkspaces?.find( (userWorkspace) => userWorkspace.workspaceId === workspace.id, ); if (!userWorkspace) { return false; } return await this.permissionsService.userHasWorkspaceSettingPermission({ userWorkspaceId: userWorkspace.id, workspaceId: workspace.id, setting: PermissionFlagType.SSO_BYPASS, }); } async signInUp( params: SignInUpBaseParams & ExistingUserOrNewUser & AuthProviderWithPasswordType, ) { await this.isAuthProviderEnabledOrThrow( params.userData, params.authParams, params.workspace, ); if (params.userData.type === 'newUser') { const partialUserWithPicture = await this.signInUpService.computePartialUserFromUserPayload( params.userData.newUserPayload, params.authParams, ); return await this.signInUpService.signInUp({ ...params, userData: { type: 'newUserWithPicture', newUserWithPicture: partialUserWithPicture, }, }); } return await this.signInUpService.signInUp({ ...params, userData: { type: 'existingUser', existingUser: params.userData.existingUser, }, }); } async verify( email: string, workspaceId: string, authProvider: AuthProviderEnum, ): Promise { if (!email) { throw new AuthException( 'Email is required', AuthExceptionCode.INVALID_INPUT, { userFriendlyMessage: msg`Email is required.`, }, ); } const user = await this.userService.findUserByEmail(email); assertIsDefinedOrThrow( user, new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), ); // passwordHash is hidden for security reasons user.passwordHash = ''; const accessToken = await this.accessTokenService.generateAccessToken({ userId: user.id, workspaceId, authProvider, }); const refreshToken = await this.refreshTokenService.generateRefreshToken({ userId: user.id, workspaceId, authProvider, targetedTokenType: JwtTokenTypeEnum.ACCESS, }); return { tokens: { accessOrWorkspaceAgnosticToken: accessToken, refreshToken, }, }; } async generateImpersonationAccessTokenAndRefreshToken({ workspaceId, impersonatorUserWorkspaceId, impersonatedUserWorkspaceId, _impersonatorUserId, impersonatedUserId, }: { workspaceId: string; impersonatorUserWorkspaceId: string; impersonatedUserWorkspaceId: string; _impersonatorUserId: string; impersonatedUserId: string; }): Promise { const correlationId = randomUUID(); const eventLogContext = this.eventLogEmitterService.createContext({ workspaceId, userId: _impersonatorUserId, }); void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { level: 'workspace', action: 'attempted', message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`, }); const accessToken = await this.accessTokenService.generateAccessToken({ userId: impersonatedUserId, workspaceId, authProvider: AuthProviderEnum.Impersonation, isImpersonating: true, impersonatorUserWorkspaceId, impersonatedUserWorkspaceId, }); const refreshToken = await this.refreshTokenService.generateRefreshToken( { userId: impersonatedUserId, workspaceId, authProvider: AuthProviderEnum.Impersonation, targetedTokenType: JwtTokenTypeEnum.ACCESS, isImpersonating: true, impersonatorUserWorkspaceId, impersonatedUserWorkspaceId, }, true, ); void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { level: 'workspace', action: 'issued', message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`, }); return { tokens: { accessOrWorkspaceAgnosticToken: accessToken, refreshToken, }, }; } async countAvailableWorkspacesByEmail(email: string): Promise { return Object.values( await this.userWorkspaceService.findAvailableWorkspacesByEmail(email), ).flat(2).length; } async checkUserExists(email: string): Promise { const user = await this.userService.findUserByEmail(email); const isUserExist = isDefined(user); return { exists: isUserExist, availableWorkspacesCount: await this.countAvailableWorkspacesByEmail(email), isEmailVerified: isUserExist ? user.isEmailVerified : false, }; } async checkWorkspaceInviteHashIsValid( inviteHash: string, ): Promise { const workspace = await this.workspaceRepository.findOneBy({ inviteHash, }); return { isValid: !!workspace }; } async generateAuthorizationCode({ authorizeAppInput, user, workspace, requestBaseUrl, }: { authorizeAppInput: AuthorizeAppInput; user: AuthContextUser; workspace: WorkspaceEntity; requestBaseUrl: string; }): Promise { const { clientId, codeChallenge } = authorizeAppInput; const applicationRegistration = await this.applicationRegistrationService.findOneByClientId(clientId); if (!applicationRegistration) { throw new AuthException( `Client not found for '${clientId}'`, AuthExceptionCode.CLIENT_NOT_FOUND, ); } if (!authorizeAppInput.redirectUrl) { throw new AuthException( `redirectUrl not provided for '${clientId}'`, AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } // OAuth 2.1 / MCP auth spec: PKCE is mandatory for public clients // (clients registered with token_endpoint_auth_method=none, i.e. no // client secret hash). Confidential clients are authenticated at the // token endpoint instead. const isPublicClient = !applicationRegistration.oAuthClientSecretHash; if (isPublicClient && !codeChallenge) { throw new AuthException( `code_challenge is required for public clients (PKCE S256, per OAuth 2.1)`, AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } // RFC 8252 ยง7.3: Native apps using loopback redirect URIs may use any port. // When a registration has no explicit redirect URIs (e.g. the seeded CLI registration), // allow any loopback redirect URI. const hasRegisteredRedirectUris = applicationRegistration.oAuthRedirectUris.length > 0; if (hasRegisteredRedirectUris) { if ( !applicationRegistration.oAuthRedirectUris.includes( authorizeAppInput.redirectUrl, ) ) { throw new AuthException( `redirectUrl mismatch for '${clientId}'`, AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } } else { let redirectUrl: URL; try { redirectUrl = new URL(authorizeAppInput.redirectUrl); } catch { throw new AuthException( `Invalid redirectUrl for '${clientId}'`, AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } const isLoopback = redirectUrl.hostname === 'localhost' || redirectUrl.hostname === '127.0.0.1'; if (!isLoopback) { throw new AuthException( `redirectUrl mismatch for '${clientId}'`, AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } } const parsedScopes = authorizeAppInput.scope ? authorizeAppInput.scope.split(' ').filter(Boolean) : []; const requestedScopes = parsedScopes.length > 0 ? parsedScopes : applicationRegistration.oAuthScopes; const invalidScopes = requestedScopes.filter( (scope) => !applicationRegistration.oAuthScopes.includes(scope), ); if (invalidScopes.length > 0) { throw new AuthException( `Invalid scopes: ${invalidScopes.join(', ')}`, AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } const redirectUriValidation = validateRedirectUri( authorizeAppInput.redirectUrl, ); if (!redirectUriValidation.valid) { throw new AuthException( redirectUriValidation.reason, AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } const authorizationCode = crypto.randomBytes(42).toString('hex'); const hashedAuthorizationCode = crypto .createHash('sha256') .update(authorizationCode) .digest('hex'); const expiresAt = addMilliseconds(new Date().getTime(), ms('5m')); const authCodeContext = { redirectUri: authorizeAppInput.redirectUrl, clientId: applicationRegistration.oAuthClientId, scope: requestedScopes.join(' '), ...(codeChallenge ? { codeChallenge } : {}), }; const token = this.appTokenRepository.create({ value: hashedAuthorizationCode, type: AppTokenType.AuthorizationCode, userId: user.id, workspaceId: workspace.id, expiresAt, context: authCodeContext, }); await this.appTokenRepository.save(token); redirectUriValidation.parsed.searchParams.set('code', authorizationCode); const issuer = authorizeAppInput.issuer ?? requestBaseUrl; assertIssuerIsPublishedOrThrow({ issuer, requestBaseUrl, serverUrl: this.twentyConfigService.get('SERVER_URL'), }); redirectUriValidation.parsed.searchParams.set('iss', issuer); if (authorizeAppInput.state) { redirectUriValidation.parsed.searchParams.set( 'state', authorizeAppInput.state, ); } return { redirectUrl: redirectUriValidation.parsed.toString() }; } async updatePassword( userId: string, newPassword: string, ): Promise { if (!userId) { throw new AuthException( 'User ID is required', AuthExceptionCode.INVALID_INPUT, ); } const user = await this.userRepository.findOne({ where: { id: userId }, relations: { userWorkspaces: true }, }); if (!user) { throw new AuthException( 'User not found', AuthExceptionCode.USER_NOT_FOUND, ); } const [firstUserWorkspace] = user.userWorkspaces; if (!firstUserWorkspace) { throw new AuthException( 'User does not have a workspace', AuthExceptionCode.USER_WORKSPACE_NOT_FOUND, ); } const isPasswordValid = PASSWORD_REGEX.test(newPassword); if (!isPasswordValid) { throw new AuthException( 'Password is too weak', AuthExceptionCode.INVALID_INPUT, { userFriendlyMessage: msg`Password is too weak.`, }, ); } const newPasswordHash = await hashPassword(newPassword); await this.userRepository.update(userId, { passwordHash: newPasswordHash, }); await this.appTokenRepository.update( { userId, type: AppTokenType.RefreshToken, revokedAt: IsNull(), }, { revokedAt: new Date(), }, ); await this.userSessionService.revokeAllSessionsForUser({ userId, reason: UserSessionRevokedReason.PasswordChanged, }); const emailTemplate = PasswordUpdateNotifyEmail({ userName: `${user.firstName} ${user.lastName}`, email: user.email, link: this.domainServerConfigService.getBaseUrl().toString(), locale: firstUserWorkspace.locale, }); const html = await renderEmail(emailTemplate, { pretty: true }); const text = await renderEmail(emailTemplate, { plainText: true }); const passwordChangedMsg = msg`Your Password Has Been Successfully Changed`; const i18n = this.i18nService.getI18nInstance(firstUserWorkspace.locale); const subject = i18n._(passwordChangedMsg); await this.emailService.send({ from: `${this.twentyConfigService.get( 'EMAIL_FROM_NAME', )} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`, to: user.email, subject, text, html, }); return { success: true }; } async findWorkspaceFromInviteHashOrFail( inviteHash: string, ): Promise { const workspace = await this.workspaceRepository.findOneBy({ inviteHash, }); if (!workspace) { throw new AuthException( 'Workspace does not exist', AuthExceptionCode.INVALID_INPUT, { userFriendlyMessage: msg`Workspace does not exist.`, }, ); } return workspace; } computeRedirectURI({ loginToken, workspace, billingCheckoutSessionState, returnToPath, }: { loginToken: string; workspace: WorkspaceDomainConfig; billingCheckoutSessionState?: string; returnToPath?: string; }) { const url = this.workspaceDomainsService.buildWorkspaceURL({ workspace, pathname: AppPath.Verify, searchParams: { loginToken, ...(billingCheckoutSessionState ? { billingCheckoutSessionState } : {}), ...(isNonEmptyString(returnToPath) && returnToPath.startsWith('/') ? { returnToPath } : {}), }, }); return url.toString(); } async findInvitationForSignInUp( params: { currentWorkspace: WorkspaceEntity; } & ({ workspacePersonalInviteToken: string } | { email: string }), ) { const qr = this.appTokenRepository .createQueryBuilder('appToken') .where('"appToken"."workspaceId" = :workspaceId', { workspaceId: params.currentWorkspace.id, }) .andWhere('"appToken".type IN (:...types)', { types: INVITATION_APP_TOKEN_TYPES, }) .andWhere('"appToken"."deletedAt" IS NULL') .andWhere('"appToken"."expiresAt" > :now', { now: new Date(), }); if ('workspacePersonalInviteToken' in params) { qr.andWhere('"appToken".value = :personalInviteToken', { personalInviteToken: params.workspacePersonalInviteToken, }); } if ('email' in params) { qr.andWhere('lower("appToken".context->>\'email\') = lower(:email)', { email: params.email, }); } return (await qr.getOne()) ?? undefined; } async findWorkspaceForSignInUp( params: { workspaceId?: string; workspaceInviteHash?: string; } & ( | { authProvider: Exclude; email: string; } | { authProvider: Extract } ), ) { if (params.workspaceInviteHash) { return ( (await this.workspaceRepository.findOne({ where: { inviteHash: params.workspaceInviteHash, }, relations: ['approvedAccessDomains'], })) ?? undefined ); } if (params.authProvider !== AuthProviderEnum.Password) { return ( (await this.authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { email: params.email, authProvider: params.authProvider, }, params.workspaceId, )) ?? undefined ); } return params.workspaceId ? await this.workspaceRepository.findOne({ where: { id: params.workspaceId, }, relations: ['approvedAccessDomains'], }) : undefined; } formatUserDataPayload( newUserPayload: SignInUpNewUserPayload, existingUser?: UserEntity | null, ): ExistingUserOrNewUser { return { userData: existingUser ? { type: 'existingUser', existingUser } : { type: 'newUser', newUserPayload, }, }; } async checkAccessForSignIn({ userData, invitation, workspaceInviteHash, workspace, }: { workspaceInviteHash?: string; } & ExistingUserOrNewUser & SignInUpBaseParams) { const hasPublicInviteLink = !!workspaceInviteHash; const hasPersonalInvitation = !!invitation; const isInvitedToWorkspace = hasPersonalInvitation || hasPublicInviteLink; const isTargetAnExistingWorkspace = !!workspace; const isAnExistingUser = userData.type === 'existingUser'; const email = userData.type === 'newUser' ? userData.newUserPayload.email : userData.existingUser.email; if ( isDefined(workspace) && isEmailInApprovedAccessDomains({ email, approvedAccessDomains: workspace.approvedAccessDomains, isEmailVerificationRequired: this.twentyConfigService.get( 'IS_EMAIL_VERIFICATION_REQUIRED', ), }) ) { return; } if ( hasPublicInviteLink && !hasPersonalInvitation && workspace && !workspace.isPublicInviteLinkEnabled ) { throw new AuthException( 'Public invite link is disabled for this workspace', AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } if ( !isInvitedToWorkspace && isTargetAnExistingWorkspace && isAnExistingUser ) { return await this.userService.hasUserAccessToWorkspaceOrThrow( userData.existingUser.id, workspace.id, ); } if ( !isInvitedToWorkspace && isTargetAnExistingWorkspace && !isAnExistingUser ) { throw new AuthException( 'User does not have access to this workspace', AuthExceptionCode.FORBIDDEN_EXCEPTION, { userFriendlyMessage: msg`User does not have access to this workspace`, }, ); } } async signInUpWithSocialSSO( { firstName, lastName, email: rawEmail, picture, workspaceInviteHash, workspaceId, billingCheckoutSessionState, locale, returnToPath, }: MicrosoftRequest['user'] | GoogleRequest['user'], authProvider: AuthProviderEnum.Google | AuthProviderEnum.Microsoft, ): Promise { const email = rawEmail.toLowerCase(); const existingUser = await this.userService.findUserByEmailWithWorkspaces(email); // Route SSO sign-ins through the same create-or-select flow as credentials // instead of landing straight on a workspace subdomain. if (!workspaceId && !workspaceInviteHash) { const user = existingUser ?? (await this.signInUpService.signUpWithoutWorkspace( { firstName, lastName, email, picture, locale, isEmailAlreadyVerified: true, }, { provider: authProvider, }, )); const ssoExchangeToken = await this.ssoExchangeTokenService.generateSSOExchangeToken({ userId: user.id, authProvider, }); // The token rides in the fragment so it never reaches access logs, // proxies or Referer headers: browsers keep it out of the request line. const url = this.domainServerConfigService.buildBaseUrl({ pathname: AppPath.SignInUp, searchParams: { ...(isNonEmptyString(returnToPath) && returnToPath.startsWith('/') ? { returnToPath } : {}), }, hash: `ssoExchangeToken=${ssoExchangeToken.token}`, }); return url.toString(); } const currentWorkspace = await this.findWorkspaceForSignInUp({ workspaceId, workspaceInviteHash, email, authProvider, }); try { const invitation = currentWorkspace && email ? await this.findInvitationForSignInUp({ currentWorkspace, email, }) : undefined; const { userData } = this.formatUserDataPayload( { firstName, lastName, email, picture, locale, isEmailAlreadyVerified: true, }, existingUser, ); await this.checkAccessForSignIn({ userData, invitation, workspaceInviteHash, workspace: currentWorkspace, }); const { user, workspace } = await this.signInUp({ invitation, workspace: currentWorkspace, userData, authParams: { provider: authProvider, }, billingCheckoutSessionState, }); await this.createSSOConnectedAccountIfFeatureFlagIsOn({ workspaceId: workspace.id, userId: user.id, handle: email, authProvider, }); const loginToken = await this.loginTokenService.generateLoginToken( user.email, workspace.id, authProvider, ); return this.computeRedirectURI({ loginToken: loginToken.token, workspace, billingCheckoutSessionState, returnToPath, }); } catch (error) { return this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ error, workspace: this.workspaceDomainsService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( currentWorkspace, ), pathname: AppPath.Verify, }); } } async createSSOConnectedAccountIfFeatureFlagIsOn(input: { workspaceId: string; userId: string; handle: string; authProvider: | AuthProviderEnum.Google | AuthProviderEnum.Microsoft | AuthProviderEnum.SSO; oidcTokenClaims?: Record; connectedAccountProvider?: ConnectedAccountProvider; }): Promise { const provider = input.connectedAccountProvider ?? this.mapAuthProviderToConnectedAccountProvider(input.authProvider); const scopes = this.getSSOScopes(provider); await this.createSSOConnectedAccountService.createOrUpdateSSOConnectedAccount( { workspaceId: input.workspaceId, userId: input.userId, handle: input.handle, provider, scopes, oidcTokenClaims: input.oidcTokenClaims, }, ); } private mapAuthProviderToConnectedAccountProvider( authProvider: | AuthProviderEnum.Google | AuthProviderEnum.Microsoft | AuthProviderEnum.SSO, ): ConnectedAccountProvider { switch (authProvider) { case AuthProviderEnum.Google: return ConnectedAccountProvider.GOOGLE; case AuthProviderEnum.Microsoft: return ConnectedAccountProvider.MICROSOFT; case AuthProviderEnum.SSO: return ConnectedAccountProvider.OIDC; default: throw new Error( `Unsupported auth provider: ${authProvider satisfies never}`, ); } } private getSSOScopes(provider: ConnectedAccountProvider): string[] { switch (provider) { case ConnectedAccountProvider.GOOGLE: return ['email', 'profile']; case ConnectedAccountProvider.MICROSOFT: return ['user.read']; case ConnectedAccountProvider.OIDC: return ['openid', 'email', 'profile']; case ConnectedAccountProvider.SAML: return []; case ConnectedAccountProvider.IMAP_SMTP_CALDAV: return []; case ConnectedAccountProvider.EMAIL_GROUP: case ConnectedAccountProvider.APP: return []; default: throw new Error( `Unsupported connected account provider: ${provider satisfies never}`, ); } } }