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:
@@ -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(
|
||||
|
||||
+33
-27
@@ -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,
|
||||
|
||||
+8
-7
@@ -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,
|
||||
|
||||
+25
-1
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -266,7 +266,7 @@ describe('GoogleAPIsService', () => {
|
||||
|
||||
expect(
|
||||
createMessageChannelService.createMessageChannel,
|
||||
).not.toHaveBeenCalled();
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+54
-34
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+56
-36
@@ -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);
|
||||
|
||||
+1
@@ -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[];
|
||||
|
||||
+2
-1
@@ -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:
|
||||
|
||||
+80
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+5
-1
@@ -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:
|
||||
|
||||
+10
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user