EncryptedString PlaintextString branded string types (#21001)
## Summary closes https://github.com/twentyhq/core-team-issues/issues/2464 Introduces compile-time branded types to distinguish encrypted ciphertext from plaintext strings, preventing mix-ups like the one fixed in #20819 — but at the type level rather in addition to the one existing at runtime. ### Branded string primitives - Created `EncryptedString` and `PlaintextString` as hard nominal brands using `z.string().brand(...)`, making them non-assignable to each other or to raw `string` - Created `isEncryptedString` type predicate to narrow `string` to `EncryptedString` based on the `enc:v2:` envelope prefix - Retyped `SecretEncryptionService`: `encryptVersioned` accepts `PlaintextString`, `decryptVersioned` returns `PlaintextString` ### Entity typing - Typed encrypted columns across entities: `SigningKeyEntity.privateKey`, `TwoFactorAuthenticationMethodEntity.secret`, `ApplicationRegistrationVariableEntity.encryptedValue`, `ApplicationVariableEntity.value` - Parameterized JSONB types for connected account connection parameters (`ImapSmtpCaldavParams<Pwd>`) with reusable aliases `EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams` - Typed DTOs (`CreateApplicationRegistrationVariableInput`, `UpdateApplicationRegistrationVariablePayload`, `UpdateApplicationVariableEntityInput`) with `PlaintextString` ### ApplicationVariable always-encrypt uniformization - Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` — all values are now encrypted regardless of `isSecret` - Updated `ApplicationVariableEntityService` to always encrypt on write and always decrypt on read - Simplified `UpdateApplicationVariableActionHandlerService` by removing conditional encrypt/decrypt-on-isSecret-toggle logic - Added slow instance command (`2.9.0`) to backfill-encrypt existing `isSecret=false` plaintext rows and tighten the `CHECK` constraint ### ConfigStorageService refactor - Split `convertAndSecureValue` (which used `any`) into two well-typed methods: `convertAndDecrypt` and `convertAndEncrypt` - Introduced `isSensitiveStringValue` type predicate to narrow values before encryption/decryption ### What's next - Typeorm entity derivation to strictly type sitemap configuration as code + handler logic for encryption rotation - https://github.com/twentyhq/core-team-issues/issues/2465
This commit is contained in:
+6
-3
@@ -3,19 +3,22 @@ import { Injectable } from '@nestjs/common';
|
||||
import { google } from 'googleapis';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
ConnectedAccountRefreshAccessTokenException,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode,
|
||||
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
|
||||
import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
||||
import { parseGoogleOAuthError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util';
|
||||
import { type ConnectedAccountPlaintextTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAPIRefreshAccessTokenService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
async refreshTokens(refreshToken: string): Promise<ConnectedAccountTokens> {
|
||||
async refreshTokens(
|
||||
refreshToken: PlaintextString,
|
||||
): Promise<ConnectedAccountPlaintextTokens> {
|
||||
const oAuth2Client = new google.auth.OAuth2(
|
||||
this.twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'),
|
||||
this.twentyConfigService.get('AUTH_GOOGLE_CLIENT_SECRET'),
|
||||
@@ -35,7 +38,7 @@ export class GoogleAPIRefreshAccessTokenService {
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: token,
|
||||
accessToken: token as PlaintextString,
|
||||
refreshToken,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
+7
-4
@@ -2,19 +2,22 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node';
|
||||
|
||||
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
ConnectedAccountRefreshAccessTokenException,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode,
|
||||
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
|
||||
import type { ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
||||
import { parseMsalError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util';
|
||||
import type { ConnectedAccountPlaintextTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftAPIRefreshAccessTokenService {
|
||||
constructor(private readonly config: TwentyConfigService) {}
|
||||
|
||||
async refreshTokens(refreshToken: string): Promise<ConnectedAccountTokens> {
|
||||
async refreshTokens(
|
||||
refreshToken: PlaintextString,
|
||||
): Promise<ConnectedAccountPlaintextTokens> {
|
||||
const msalClient = new ConfidentialClientApplication({
|
||||
auth: {
|
||||
clientId: this.config.get('AUTH_MICROSOFT_CLIENT_ID'),
|
||||
@@ -38,7 +41,7 @@ export class MicrosoftAPIRefreshAccessTokenService {
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: response.accessToken,
|
||||
accessToken: response.accessToken as PlaintextString,
|
||||
refreshToken: this.extractRefreshTokenFromCache(msalClient),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -52,7 +55,7 @@ export class MicrosoftAPIRefreshAccessTokenService {
|
||||
|
||||
private extractRefreshTokenFromCache(
|
||||
msalClient: ConfidentialClientApplication,
|
||||
): string {
|
||||
): PlaintextString {
|
||||
const tokenCache = JSON.parse(msalClient.getTokenCache().serialize());
|
||||
const refreshTokenKey = Object.keys(tokenCache.RefreshToken)[0];
|
||||
|
||||
|
||||
+5
-3
@@ -15,6 +15,7 @@ import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modu
|
||||
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
|
||||
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
|
||||
|
||||
import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings';
|
||||
import { ConnectedAccountRefreshTokensService } from './connected-account-refresh-tokens.service';
|
||||
|
||||
const FAKE_CIPHER_PREFIX = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}keyid:`;
|
||||
@@ -32,9 +33,9 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
const mockWorkspaceId = 'workspace-123';
|
||||
const mockConnectedAccountId = 'account-456';
|
||||
|
||||
const mockAccessTokenPlaintext = 'valid-access-token';
|
||||
const mockRefreshTokenPlaintext = 'valid-refresh-token';
|
||||
const mockNewAccessTokenPlaintext = 'new-access-token';
|
||||
const mockAccessTokenPlaintext = 'valid-access-token' as PlaintextString;
|
||||
const mockRefreshTokenPlaintext = 'valid-refresh-token' as PlaintextString;
|
||||
const mockNewAccessTokenPlaintext = 'new-access-token' as PlaintextString;
|
||||
|
||||
const mockEncryptedAccessToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockAccessTokenPlaintext})`;
|
||||
const mockEncryptedRefreshToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`;
|
||||
@@ -263,6 +264,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
|
||||
const newPlaintextTokens = {
|
||||
accessToken: mockNewAccessTokenPlaintext,
|
||||
|
||||
refreshToken: mockRefreshTokenPlaintext,
|
||||
};
|
||||
|
||||
|
||||
+21
-6
@@ -6,6 +6,8 @@ import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service';
|
||||
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
|
||||
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import {
|
||||
ConnectedAccountRefreshAccessTokenException,
|
||||
@@ -15,11 +17,24 @@ import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modu
|
||||
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
|
||||
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
|
||||
|
||||
export type ConnectedAccountTokens = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
// Tokens flowing through this service can be in two states depending on
|
||||
// where they enter the pipeline. We model both shapes explicitly so the
|
||||
// type system can prevent the #20819 class of bug (mixing encrypted and
|
||||
// decrypted tokens in the same flow).
|
||||
export type ConnectedAccountPlaintextTokens = {
|
||||
accessToken: PlaintextString;
|
||||
refreshToken: PlaintextString | null;
|
||||
};
|
||||
|
||||
export type ConnectedAccountEncryptedTokens = {
|
||||
accessToken: EncryptedString;
|
||||
refreshToken: EncryptedString | null;
|
||||
};
|
||||
|
||||
// Public return type of resolveTokens: always encrypted (either fresh from
|
||||
// the database or freshly re-encrypted after a refresh round-trip).
|
||||
export type ConnectedAccountTokens = ConnectedAccountEncryptedTokens;
|
||||
|
||||
const CONNECTED_ACCOUNT_ACCESS_TOKEN_EXPIRATION = 1000 * 60 * 60;
|
||||
|
||||
@Injectable()
|
||||
@@ -91,7 +106,7 @@ export class ConnectedAccountRefreshTokensService {
|
||||
|
||||
private async performRefreshAndSave(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
encryptedRefreshToken: string,
|
||||
encryptedRefreshToken: EncryptedString,
|
||||
workspaceId: string,
|
||||
): Promise<ConnectedAccountTokens> {
|
||||
const decryptedRefreshToken =
|
||||
@@ -166,9 +181,9 @@ export class ConnectedAccountRefreshTokensService {
|
||||
|
||||
async refreshTokens(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
refreshToken: string,
|
||||
refreshToken: PlaintextString,
|
||||
workspaceId: string,
|
||||
): Promise<ConnectedAccountTokens> {
|
||||
): Promise<ConnectedAccountPlaintextTokens> {
|
||||
try {
|
||||
switch (connectedAccount.provider) {
|
||||
case ConnectedAccountProvider.GOOGLE:
|
||||
|
||||
+30
-24
@@ -9,7 +9,12 @@ import {
|
||||
|
||||
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
|
||||
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
|
||||
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import {
|
||||
type EncryptedImapSmtpCaldavParams,
|
||||
type PlaintextImapSmtpCaldavParams,
|
||||
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
|
||||
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
@@ -117,12 +122,13 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
resetAndMarkAsCalendarEventListFetchPending: jest.fn(),
|
||||
};
|
||||
|
||||
const encryptPassword = (password: string) => `enc:v2:${password}`;
|
||||
const encryptPassword = (password: string): EncryptedString =>
|
||||
`enc:v2:${password}` as EncryptedString;
|
||||
|
||||
const withEncryptedPasswords = (
|
||||
params: ImapSmtpCaldavParams,
|
||||
): ImapSmtpCaldavParams => {
|
||||
const result: ImapSmtpCaldavParams = {};
|
||||
params: PlaintextImapSmtpCaldavParams,
|
||||
): EncryptedImapSmtpCaldavParams => {
|
||||
const result: EncryptedImapSmtpCaldavParams = {};
|
||||
|
||||
for (const protocol of ['IMAP', 'SMTP', 'CALDAV'] as const) {
|
||||
if (params[protocol]) {
|
||||
@@ -141,7 +147,7 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
({
|
||||
connectionParameters,
|
||||
}: {
|
||||
connectionParameters: ImapSmtpCaldavParams;
|
||||
connectionParameters: PlaintextImapSmtpCaldavParams;
|
||||
workspaceId: string;
|
||||
}) => withEncryptedPasswords(connectionParameters),
|
||||
),
|
||||
@@ -240,16 +246,16 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
SMTP: {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: true,
|
||||
username: 'test@example.com',
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
} as ImapSmtpCaldavParams,
|
||||
} as PlaintextImapSmtpCaldavParams,
|
||||
};
|
||||
|
||||
it('should create new account with message channel when account does not exist and IMAP is configured', async () => {
|
||||
@@ -338,9 +344,9 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
port: 443,
|
||||
secure: true,
|
||||
username: 'test@example.com',
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
} as ImapSmtpCaldavParams,
|
||||
} as PlaintextImapSmtpCaldavParams,
|
||||
connectedAccountId: 'existing-account-id',
|
||||
};
|
||||
|
||||
@@ -467,9 +473,9 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
} as ImapSmtpCaldavParams,
|
||||
} as PlaintextImapSmtpCaldavParams,
|
||||
};
|
||||
|
||||
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
|
||||
@@ -503,9 +509,9 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
port: 443,
|
||||
secure: true,
|
||||
username: 'test@example.com',
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
} as ImapSmtpCaldavParams,
|
||||
} as PlaintextImapSmtpCaldavParams,
|
||||
};
|
||||
|
||||
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
|
||||
@@ -538,16 +544,16 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
SMTP: {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: true,
|
||||
username: 'test@example.com',
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
} as ImapSmtpCaldavParams,
|
||||
} as PlaintextImapSmtpCaldavParams,
|
||||
};
|
||||
|
||||
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
|
||||
@@ -580,23 +586,23 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
SMTP: {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: true,
|
||||
username: 'test@example.com',
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
CALDAV: {
|
||||
host: 'caldav.example.com',
|
||||
port: 443,
|
||||
secure: true,
|
||||
username: 'test@example.com',
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
} as ImapSmtpCaldavParams,
|
||||
} as PlaintextImapSmtpCaldavParams,
|
||||
};
|
||||
|
||||
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
|
||||
@@ -676,9 +682,9 @@ describe('ImapSmtpCalDavAPIService', () => {
|
||||
port: 587,
|
||||
secure: true,
|
||||
username: 'test@example.com',
|
||||
password: 'password',
|
||||
password: 'password' as PlaintextString,
|
||||
},
|
||||
} as ImapSmtpCaldavParams,
|
||||
} as PlaintextImapSmtpCaldavParams,
|
||||
};
|
||||
|
||||
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
+5
-2
@@ -13,7 +13,7 @@ import { v4 } from 'uuid';
|
||||
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
|
||||
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
|
||||
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { type PlaintextImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
@@ -65,7 +65,10 @@ export class ImapSmtpCalDavAPIService {
|
||||
handle: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
connectionParameters: ImapSmtpCaldavParams;
|
||||
// Caller (resolver) has already validated the input through
|
||||
// `ImapSmtpCaldavService.validateAndTestConnectionParameters`, which
|
||||
// produces fully plaintext passwords ready for re-encryption.
|
||||
connectionParameters: PlaintextImapSmtpCaldavParams;
|
||||
existingAccount?: ConnectedAccountEntity | null;
|
||||
}): Promise<string> {
|
||||
const { handle, workspaceId, userWorkspaceId } = input;
|
||||
|
||||
+3
-2
@@ -13,6 +13,7 @@ import { In } from 'typeorm';
|
||||
|
||||
import { type DiscoveredMessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
|
||||
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { GmailGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service';
|
||||
@@ -46,8 +47,8 @@ const createMockMessageChannel = (
|
||||
id: 'account-456',
|
||||
handle: 'test@gmail.com',
|
||||
provider: overrides.provider ?? ConnectedAccountProvider.GOOGLE,
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
accessToken: 'mock-access-token' as EncryptedString,
|
||||
refreshToken: 'mock-refresh-token' as EncryptedString,
|
||||
connectionParameters: {},
|
||||
workspaceId: 'workspace-123',
|
||||
},
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
import { type MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
|
||||
import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service';
|
||||
@@ -42,8 +43,8 @@ describe('ImapGetMessageListService', () => {
|
||||
> = {
|
||||
id: 'connected-account-id',
|
||||
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessToken: 'access-token' as EncryptedString,
|
||||
refreshToken: 'refresh-token' as EncryptedString,
|
||||
handle: 'test@example.com',
|
||||
connectionParameters: {},
|
||||
workspaceId: 'workspace-id',
|
||||
|
||||
Reference in New Issue
Block a user