feat: store SSO connections as connected accounts during sign-in (#18825)

## Summary

- Store SSO connections (Google, Microsoft, OIDC, SAML) as connected
accounts in the core schema during sign-in/sign-up, gated behind the
`IS_CONNECTED_ACCOUNT_MIGRATED` feature flag
- Add `OIDC` and `SAML` to `ConnectedAccountProvider` enum with
exhaustive switch handling across frontend and backend
- Add `IS_CONNECTED_ACCOUNT_MIGRATED` to `DEFAULT_FEATURE_FLAGS` for new
workspaces, with a fallback check so SSO accounts are created even
before workspace activation
- Always upsert connected accounts to both workspace and core schemas
during messaging OAuth flow, fixing FK constraint violations when
SSO-only accounts exist only in core
- Create message/calendar channels when they don't exist regardless of
new vs reconnect flow
- Filter settings accounts list to only show accounts that have message
or calendar channels

## Test plan

- [ ] Sign up with Google SSO → verify connected account is created in
core schema
- [ ] Connect messaging (Google APIs) after SSO sign-up → verify no FK
errors, channels created, configuration page renders correctly
- [ ] Reconnect an existing messaging account → verify tokens updated,
sync resets triggered
- [ ] Sign in with OIDC/SAML SSO → verify connected account created with
oidcTokenClaims
- [ ] Verify settings accounts page only shows accounts with channels
(SSO-only accounts hidden)
- [ ] Verify typecheck, lint, and unit tests pass
This commit is contained in:
Charles Bochet
2026-03-22 12:29:58 +01:00
committed by GitHub
parent 246afe0f2a
commit 9a306ddb9a
19 changed files with 487 additions and 114 deletions
@@ -25,6 +25,8 @@ export const getMissingDraftEmailScopes = (
return hasScope ? [] : [MICROSOFT_SEND_SCOPE];
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
return [];
default:
assertUnreachable(
@@ -72,33 +72,39 @@ export const useMyConnectedAccounts = () => {
return [];
}
return metadataData.myConnectedAccounts.map(
(account: MetadataConnectedAccount) =>
({
id: account.id,
handle: account.handle,
provider: account.provider,
accessToken: '',
refreshToken: '',
accountOwnerId: account.userWorkspaceId,
lastSyncHistoryId: '',
authFailedAt: account.authFailedAt
? new Date(account.authFailedAt)
: null,
messageChannels: messageChannels.filter(
(channel) =>
(channel as unknown as { connectedAccountId: string })
.connectedAccountId === account.id,
),
calendarChannels: calendarChannels.filter(
(channel) =>
(channel as unknown as { connectedAccountId: string })
.connectedAccountId === account.id,
),
scopes: account.scopes,
__typename: 'ConnectedAccount',
}) as ConnectedAccount,
);
return metadataData.myConnectedAccounts
.map(
(account: MetadataConnectedAccount) =>
({
id: account.id,
handle: account.handle,
provider: account.provider,
accessToken: '',
refreshToken: '',
accountOwnerId: account.userWorkspaceId,
lastSyncHistoryId: '',
authFailedAt: account.authFailedAt
? new Date(account.authFailedAt)
: null,
messageChannels: messageChannels.filter(
(channel) =>
'connectedAccountId' in channel &&
channel.connectedAccountId === account.id,
),
calendarChannels: calendarChannels.filter(
(channel) =>
'connectedAccountId' in channel &&
channel.connectedAccountId === account.id,
),
scopes: account.scopes,
__typename: 'ConnectedAccount',
}) as ConnectedAccount,
)
.filter(
(account) =>
account.messageChannels.length > 0 ||
account.calendarChannels.length > 0,
);
}, [
isMigrated,
workspaceAccounts,
@@ -86,8 +86,8 @@ export const SidePanelMessageThreadPage = () => {
const canReply = useMemo(() => {
return (
connectedAccountHandle &&
connectedAccountProvider &&
isDefined(connectedAccountHandle) &&
isDefined(connectedAccountProvider) &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
@@ -103,7 +103,7 @@ export const SidePanelMessageThreadPage = () => {
]);
const handleReplyClick = () => {
if (!isDefined(canReply)) {
if (!canReply) {
return;
}
@@ -118,9 +118,10 @@ export const SidePanelMessageThreadPage = () => {
window.open(url, '_blank');
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
throw new Error('Account provider not supported');
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case null:
throw new Error('Account provider not provided');
return;
default:
assertUnreachable(connectedAccountProvider);
}
@@ -166,14 +167,14 @@ export const SidePanelMessageThreadPage = () => {
</>
)}
</StyledContainer>
{isDefined(canReply) && !messageChannelLoading && (
{!messageChannelLoading && (
<StyledButtonContainer>
<Button
size="small"
onClick={handleReplyClick}
title={t`Reply`}
Icon={IconArrowBackUp}
disabled={!isDefined(canReply)}
disabled={!canReply}
/>
</StyledButtonContainer>
)}
@@ -23,11 +23,13 @@ type ActorDisplayProps = Partial<FieldActorValue> & {
avatarUrl?: string | null;
};
const PROVIDORS_ICON_MAPPING = {
const PROVIDERS_ICON_MAPPING = {
EMAIL: {
[ConnectedAccountProvider.MICROSOFT]: IconMicrosoftOutlook,
[ConnectedAccountProvider.GOOGLE]: IconGmail,
[ConnectedAccountProvider.IMAP_SMTP_CALDAV]: IconMail,
[ConnectedAccountProvider.OIDC]: IconMail,
[ConnectedAccountProvider.SAML]: IconMail,
default: IconMail,
},
CALENDAR: {
@@ -51,12 +53,12 @@ export const ActorDisplay = ({
case 'IMPORT':
return IconUpload;
case 'EMAIL':
return PROVIDORS_ICON_MAPPING.EMAIL[context?.provider ?? 'default'];
return PROVIDERS_ICON_MAPPING.EMAIL[context?.provider ?? 'default'];
case 'CALENDAR':
return (
PROVIDORS_ICON_MAPPING.CALENDAR[
context?.provider as keyof typeof PROVIDORS_ICON_MAPPING.CALENDAR
] ?? PROVIDORS_ICON_MAPPING.CALENDAR.default
PROVIDERS_ICON_MAPPING.CALENDAR[
context?.provider as keyof typeof PROVIDERS_ICON_MAPPING.CALENDAR
] ?? PROVIDERS_ICON_MAPPING.CALENDAR.default
);
case 'SYSTEM':
return IconRobot;
@@ -20,6 +20,7 @@ import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.s
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { CreateSSOConnectedAccountService } from 'src/engine/core-modules/auth/services/create-sso-connected-account.service';
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/auth/services/google-apis-service-availability.service';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
@@ -60,6 +61,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -99,6 +101,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
UserWorkspaceEntity,
TwoFactorAuthenticationMethodEntity,
ObjectMetadataEntity,
ConnectedAccountEntity,
]),
UserWorkspaceModule,
WorkspaceModule,
@@ -160,6 +163,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
CreateMessageChannelService,
CreateCalendarChannelService,
CreateConnectedAccountService,
CreateSSOConnectedAccountService,
UpdateConnectedAccountOnReconnectService,
TransientTokenService,
AuthSsoService,
@@ -13,7 +13,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { generateServiceProviderMetadata } from '@node-saml/node-saml';
import { Response } from 'express';
import { AppPath } from 'twenty-shared/types';
import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -167,9 +167,18 @@ export class SSOAuthController {
),
);
const oidcTokenClaims =
'oidcTokenClaims' in req.user ? req.user.oidcTokenClaims : undefined;
const connectedAccountProvider =
workspaceIdentityProvider.type === IdentityProviderType.SAML
? ConnectedAccountProvider.SAML
: ConnectedAccountProvider.OIDC;
const { loginToken } = await this.generateLoginToken(
req.user,
currentWorkspace,
{ oidcTokenClaims, connectedAccountProvider },
);
return res.redirect(
@@ -195,6 +204,10 @@ export class SSOAuthController {
private async generateLoginToken(
payload: { email: string; workspaceInviteHash?: string },
currentWorkspace: WorkspaceEntity,
ssoContext?: {
oidcTokenClaims?: Record<string, unknown>;
connectedAccountProvider: ConnectedAccountProvider;
},
) {
const invitation = payload.email
? await this.authService.findInvitationForSignInUp({
@@ -226,6 +239,17 @@ export class SSOAuthController {
},
});
if (ssoContext) {
await this.authService.createSSOConnectedAccountIfFeatureFlagIsOn({
workspaceId: workspace.id,
userId: user.id,
handle: payload.email.toLowerCase(),
authProvider: AuthProviderEnum.SSO,
oidcTokenClaims: ssoContext.oidcTokenClaims,
connectedAccountProvider: ssoContext.connectedAccountProvider,
});
}
return {
workspace,
loginToken: await this.loginTokenService.generateLoginToken(
@@ -30,6 +30,8 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { CreateSSOConnectedAccountService } from 'src/engine/core-modules/auth/services/create-sso-connected-account.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { AuthService } from './auth.service';
@@ -174,6 +176,20 @@ describe('AuthService', () => {
provide: ApplicationRegistrationService,
useValue: {},
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
provide: CreateSSOConnectedAccountService,
useValue: {
createOrUpdateSSOConnectedAccount: jest
.fn()
.mockResolvedValue(undefined),
},
},
],
}).compile();
@@ -9,7 +9,11 @@ import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppPath } from 'twenty-shared/types';
import {
AppPath,
ConnectedAccountProvider,
FeatureFlagKey,
} from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
@@ -36,6 +40,7 @@ import { type UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user
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';
@@ -58,6 +63,7 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain
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';
@@ -69,6 +75,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
// import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
@Injectable()
// oxlint-disable-next-line twenty/inject-workspace-repository
@@ -98,6 +105,8 @@ export class AuthService {
private readonly i18nService: I18nService,
private readonly auditService: AuditService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly featureFlagService: FeatureFlagService,
private readonly createSSOConnectedAccountService: CreateSSOConnectedAccountService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -1031,6 +1040,13 @@ export class AuthService {
billingCheckoutSessionState,
});
await this.createSSOConnectedAccountIfFeatureFlagIsOn({
workspaceId: workspace.id,
userId: user.id,
handle: email,
authProvider,
});
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
@@ -1053,4 +1069,85 @@ export class AuthService {
});
}
}
async createSSOConnectedAccountIfFeatureFlagIsOn(input: {
workspaceId: string;
userId: string;
handle: string;
authProvider:
| AuthProviderEnum.Google
| AuthProviderEnum.Microsoft
| AuthProviderEnum.SSO;
oidcTokenClaims?: Record<string, unknown>;
connectedAccountProvider?: ConnectedAccountProvider;
}): Promise<void> {
const isConnectedAccountMigrated =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
input.workspaceId,
);
// const willBeEnabledByDefault = DEFAULT_FEATURE_FLAGS.includes(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED);
const willBeEnabledByDefault = false;
if (!isConnectedAccountMigrated && !willBeEnabledByDefault) {
return;
}
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 [];
default:
throw new Error(
`Unsupported connected account provider: ${provider satisfies never}`,
);
}
}
}
@@ -0,0 +1,81 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ConnectedAccountProvider } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
type CreateSSOConnectedAccountParams = {
workspaceId: string;
userId: string;
handle: string;
provider: ConnectedAccountProvider;
scopes: string[];
oidcTokenClaims?: Record<string, unknown>;
};
@Injectable()
export class CreateSSOConnectedAccountService {
private readonly logger = new Logger(CreateSSOConnectedAccountService.name);
constructor(
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
async createOrUpdateSSOConnectedAccount(
params: CreateSSOConnectedAccountParams,
): Promise<void> {
const { workspaceId, userId, handle, provider, scopes, oidcTokenClaims } =
params;
const userWorkspace = await this.userWorkspaceRepository.findOneBy({
userId,
workspaceId,
});
if (!userWorkspace) {
this.logger.warn(
`Could not find userWorkspace for userId=${userId} workspaceId=${workspaceId}, skipping SSO connected account creation`,
);
return;
}
const existing = await this.connectedAccountRepository.findOneBy({
handle,
provider,
userWorkspaceId: userWorkspace.id,
workspaceId,
});
if (existing) {
await this.connectedAccountRepository.update(existing.id, {
lastSignedInAt: new Date(),
provider,
scopes,
...(oidcTokenClaims !== undefined
? { oidcTokenClaims: oidcTokenClaims as object }
: {}),
});
return;
}
await this.connectedAccountRepository.save({
handle,
provider,
scopes,
accessToken: null,
refreshToken: null,
lastSignedInAt: new Date(),
userWorkspaceId: userWorkspace.id,
workspaceId,
...(oidcTokenClaims ? { oidcTokenClaims } : {}),
});
}
}
@@ -266,7 +266,7 @@ describe('GoogleAPIsService', () => {
expect(
createMessageChannelService.createMessageChannel,
).not.toHaveBeenCalled();
).toHaveBeenCalled();
});
});
});
@@ -145,41 +145,19 @@ export class GoogleAPIsService {
await workspaceDataSource.transaction(
async (manager: WorkspaceEntityManager) => {
if (!existingAccountId) {
await this.createConnectedAccountService.createConnectedAccount({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
provider: ConnectedAccountProvider.GOOGLE,
accessToken: input.accessToken,
refreshToken: input.refreshToken,
accountOwnerId: workspaceMemberId,
scopes,
manager,
});
await this.createConnectedAccountService.createConnectedAccount({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
provider: ConnectedAccountProvider.GOOGLE,
accessToken: input.accessToken,
refreshToken: input.refreshToken,
accountOwnerId: workspaceMemberId,
scopes,
manager,
});
if (isMessagingEnabled && isMessagingAvailable) {
await this.createMessageChannelService.createMessageChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
messageVisibility,
manager,
skipMessageChannelConfiguration,
});
}
if (isCalendarEnabled && isCalendarAvailable) {
await this.createCalendarChannelService.createCalendarChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
calendarVisibility,
manager,
skipMessageChannelConfiguration,
});
}
} else {
if (existingAccountId) {
await this.updateConnectedAccountOnReconnectService.updateConnectedAccountOnReconnect(
{
workspaceId,
@@ -211,6 +189,48 @@ export class GoogleAPIsService {
newOrExistingConnectedAccountId,
);
}
const existingMessageChannels =
await this.messageChannelDataAccessService.find(workspaceId, {
connectedAccountId: newOrExistingConnectedAccountId,
});
if (
isMessagingEnabled &&
isMessagingAvailable &&
existingMessageChannels.length === 0
) {
await this.createMessageChannelService.createMessageChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
messageVisibility,
manager,
skipMessageChannelConfiguration,
});
}
const existingCalendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
if (
isCalendarEnabled &&
isCalendarAvailable &&
existingCalendarChannels.length === 0
) {
await this.createCalendarChannelService.createCalendarChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
calendarVisibility,
manager,
skipMessageChannelConfiguration,
});
}
},
);
@@ -99,43 +99,19 @@ export class MicrosoftAPIsService {
await workspaceDataSource.transaction(
async (manager: WorkspaceEntityManager) => {
if (!existingAccountId) {
await this.createConnectedAccountService.createConnectedAccount({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
provider: ConnectedAccountProvider.MICROSOFT,
accessToken: input.accessToken,
refreshToken: input.refreshToken,
accountOwnerId: workspaceMemberId,
scopes,
manager,
});
await this.createConnectedAccountService.createConnectedAccount({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
provider: ConnectedAccountProvider.MICROSOFT,
accessToken: input.accessToken,
refreshToken: input.refreshToken,
accountOwnerId: workspaceMemberId,
scopes,
manager,
});
await this.createMessageChannelService.createMessageChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
messageVisibility,
manager,
skipMessageChannelConfiguration,
});
if (
this.twentyConfigService.get(
'CALENDAR_PROVIDER_MICROSOFT_ENABLED',
)
) {
await this.createCalendarChannelService.createCalendarChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
calendarVisibility,
manager,
skipMessageChannelConfiguration,
});
}
} else {
if (existingAccountId) {
await this.updateConnectedAccountOnReconnectService.updateConnectedAccountOnReconnect(
{
workspaceId,
@@ -177,6 +153,50 @@ export class MicrosoftAPIsService {
workspaceId,
);
}
const existingMessageChannels =
await this.messageChannelDataAccessService.find(workspaceId, {
connectedAccountId: newOrExistingConnectedAccountId,
});
if (
this.twentyConfigService.get(
'MESSAGING_PROVIDER_MICROSOFT_ENABLED',
) &&
existingMessageChannels.length === 0
) {
await this.createMessageChannelService.createMessageChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
messageVisibility,
manager,
skipMessageChannelConfiguration,
});
}
const existingCalendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
if (
this.twentyConfigService.get(
'CALENDAR_PROVIDER_MICROSOFT_ENABLED',
) &&
existingCalendarChannels.length === 0
) {
await this.createCalendarChannelService.createCalendarChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
calendarVisibility,
manager,
skipMessageChannelConfiguration,
});
}
},
);
@@ -21,6 +21,7 @@ export type OIDCRequest = Omit<
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string;
oidcTokenClaims?: Record<string, unknown>;
};
};
@@ -110,6 +111,7 @@ export class OIDCAuthStrategy extends PassportStrategy(
identityProviderId: state.identityProviderId,
...(userinfo.given_name ? { firstName: userinfo.given_name } : {}),
...(userinfo.family_name ? { lastName: userinfo.family_name } : {}),
oidcTokenClaims: tokenset.claims() as Record<string, unknown>,
});
} catch (err) {
done(err);
@@ -4,4 +4,5 @@ export const DEFAULT_FEATURE_FLAGS = [
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
] as const satisfies FeatureFlagKey[];
@@ -39,7 +39,8 @@ export class EmailAliasManagerService {
);
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
// IMAP Protocol does not support email aliases
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
handleAliases = [];
break;
default:
@@ -338,5 +338,85 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(result).toBe(true);
});
it('should return true for OIDC provider regardless of lastCredentialsRefreshedAt', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.OIDC,
lastCredentialsRefreshedAt: null,
} as ConnectedAccountWorkspaceEntity;
const result = await service.isAccessTokenStillValid(connectedAccount);
expect(result).toBe(true);
});
it('should return true for SAML provider regardless of lastCredentialsRefreshedAt', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.SAML,
lastCredentialsRefreshedAt: null,
} as ConnectedAccountWorkspaceEntity;
const result = await service.isAccessTokenStillValid(connectedAccount);
expect(result).toBe(true);
});
});
describe('refreshAndSaveTokens - OIDC/SAML', () => {
it('should reuse existing tokens for OIDC without attempting a refresh', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.OIDC,
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
lastCredentialsRefreshedAt: null,
} as unknown as ConnectedAccountWorkspaceEntity;
const result = await service.refreshAndSaveTokens(
connectedAccount,
mockWorkspaceId,
);
expect(result).toEqual({
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
});
expect(
googleAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
expect(connectedAccountDataAccessService.update).not.toHaveBeenCalled();
});
it('should reuse existing tokens for SAML without attempting a refresh', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.SAML,
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
lastCredentialsRefreshedAt: null,
} as unknown as ConnectedAccountWorkspaceEntity;
const result = await service.refreshAndSaveTokens(
connectedAccount,
mockWorkspaceId,
);
expect(result).toEqual({
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
});
expect(
googleAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
expect(connectedAccountDataAccessService.update).not.toHaveBeenCalled();
});
});
});
@@ -114,6 +114,8 @@ export class ConnectedAccountRefreshTokensService {
);
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
return true;
default:
return assertUnreachable(
@@ -139,8 +141,10 @@ export class ConnectedAccountRefreshTokensService {
refreshToken,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
throw new ConnectedAccountRefreshAccessTokenException(
`Token refresh is not supported for IMAP provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
`Token refresh is not supported for ${connectedAccount.provider} provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
default:
@@ -37,6 +37,11 @@ export class MessagingMessageOutboundService {
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
throw new Error(
`Provider ${connectedAccount.provider} does not support sending messages`,
);
default:
assertUnreachable(
connectedAccount.provider,
@@ -65,6 +70,11 @@ export class MessagingMessageOutboundService {
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
throw new Error(
`Provider ${connectedAccount.provider} does not support creating drafts`,
);
default:
assertUnreachable(
connectedAccount.provider,
@@ -2,4 +2,6 @@ export enum ConnectedAccountProvider {
GOOGLE = 'google',
MICROSOFT = 'microsoft',
IMAP_SMTP_CALDAV = 'imap_smtp_caldav',
OIDC = 'oidc',
SAML = 'saml',
}