From 94ff20f69f6aa81c7954e08153a9e13986b52830 Mon Sep 17 00:00:00 2001 From: Brandon Date: Thu, 29 Jan 2026 14:36:38 -0500 Subject: [PATCH 1/4] scaffold new state service, add migration, initial commit --- jslib/common/src/enums/stateVersion.ts | 3 +- src/abstractions/state-vNext.service.ts | 54 +++ src/app/services/services.module.ts | 36 +- src/bwdc.ts | 16 +- src/main.ts | 13 +- src/models/state.model.ts | 108 +++++ src/services/authService.spec.ts | 2 +- src/services/directory-factory.service.ts | 9 +- ...uite-directory.service.integration.spec.ts | 13 +- .../gsuite-directory.service.ts | 9 +- ...ldap-directory.service.integration.spec.ts | 2 +- .../state-service/state-vNext.service.ts | 409 ++++++++++++++++ .../{ => state-service}/state.service.ts | 29 +- .../state-service/stateMigration.service.ts | 290 ++++++++++++ src/services/state-vNext.service.ts | 435 ++++++++++++++++++ src/services/stateMigration.service.ts | 121 +++++ src/services/sync.service.integration.spec.ts | 2 +- src/services/sync.service.spec.ts | 2 +- 18 files changed, 1512 insertions(+), 41 deletions(-) create mode 100644 src/abstractions/state-vNext.service.ts create mode 100644 src/models/state.model.ts create mode 100644 src/services/state-service/state-vNext.service.ts rename src/services/{ => state-service}/state.service.ts (96%) create mode 100644 src/services/state-service/stateMigration.service.ts create mode 100644 src/services/state-vNext.service.ts diff --git a/jslib/common/src/enums/stateVersion.ts b/jslib/common/src/enums/stateVersion.ts index 5aeb02e5..476d89ad 100644 --- a/jslib/common/src/enums/stateVersion.ts +++ b/jslib/common/src/enums/stateVersion.ts @@ -3,5 +3,6 @@ export enum StateVersion { Two = 2, // Move to a typed State object Three = 3, // Fix migration of users' premium status Four = 4, // Fix 'Never Lock' option by removing stale data - Latest = Four, + Five = 5, // New state service implementation + Latest = Five, } diff --git a/src/abstractions/state-vNext.service.ts b/src/abstractions/state-vNext.service.ts new file mode 100644 index 00000000..8d4827d0 --- /dev/null +++ b/src/abstractions/state-vNext.service.ts @@ -0,0 +1,54 @@ +import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions"; + +import { DirectoryType } from "@/src/enums/directoryType"; +import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; +import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; +import { LdapConfiguration } from "@/src/models/ldapConfiguration"; +import { OktaConfiguration } from "@/src/models/oktaConfiguration"; +import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { SyncConfiguration } from "@/src/models/syncConfiguration"; + +export abstract class StateServiceVNext { + getDirectory: (type: DirectoryType) => Promise; + setDirectory: ( + type: DirectoryType, + config: + | LdapConfiguration + | GSuiteConfiguration + | EntraIdConfiguration + | OktaConfiguration + | OneLoginConfiguration, + ) => Promise; + getLdapConfiguration: (options?: StorageOptions) => Promise; + setLdapConfiguration: (value: LdapConfiguration, options?: StorageOptions) => Promise; + getGsuiteConfiguration: (options?: StorageOptions) => Promise; + setGsuiteConfiguration: (value: GSuiteConfiguration, options?: StorageOptions) => Promise; + getEntraConfiguration: (options?: StorageOptions) => Promise; + setEntraConfiguration: (value: EntraIdConfiguration, options?: StorageOptions) => Promise; + getOktaConfiguration: (options?: StorageOptions) => Promise; + setOktaConfiguration: (value: OktaConfiguration, options?: StorageOptions) => Promise; + getOneLoginConfiguration: (options?: StorageOptions) => Promise; + setOneLoginConfiguration: ( + value: OneLoginConfiguration, + options?: StorageOptions, + ) => Promise; + getOrganizationId: (options?: StorageOptions) => Promise; + setOrganizationId: (value: string, options?: StorageOptions) => Promise; + getSync: (options?: StorageOptions) => Promise; + setSync: (value: SyncConfiguration, options?: StorageOptions) => Promise; + getDirectoryType: (options?: StorageOptions) => Promise; + setDirectoryType: (value: DirectoryType, options?: StorageOptions) => Promise; + getUserDelta: (options?: StorageOptions) => Promise; + setUserDelta: (value: string, options?: StorageOptions) => Promise; + getLastUserSync: (options?: StorageOptions) => Promise; + setLastUserSync: (value: Date, options?: StorageOptions) => Promise; + getLastGroupSync: (options?: StorageOptions) => Promise; + setLastGroupSync: (value: Date, options?: StorageOptions) => Promise; + getGroupDelta: (options?: StorageOptions) => Promise; + setGroupDelta: (value: string, options?: StorageOptions) => Promise; + getLastSyncHash: (options?: StorageOptions) => Promise; + setLastSyncHash: (value: string, options?: StorageOptions) => Promise; + getSyncingDir: (options?: StorageOptions) => Promise; + setSyncingDir: (value: boolean, options?: StorageOptions) => Promise; + clearSyncSettings: (syncHashToo: boolean) => Promise; +} diff --git a/src/app/services/services.module.ts b/src/app/services/services.module.ts index 9457980e..1f5afd3c 100644 --- a/src/app/services/services.module.ts +++ b/src/app/services/services.module.ts @@ -31,12 +31,14 @@ import { DefaultDirectoryFactoryService } from "@/src/services/directory-factory import { SingleRequestBuilder } from "@/src/services/single-request-builder"; import { AuthService as AuthServiceAbstraction } from "../../abstractions/auth.service"; +import { StateServiceVNext } from "../../abstractions/state-vNext.service"; import { StateService as StateServiceAbstraction } from "../../abstractions/state.service"; import { Account } from "../../models/account"; import { AuthService } from "../../services/auth.service"; import { I18nService } from "../../services/i18n.service"; -import { StateService } from "../../services/state.service"; -import { StateMigrationService } from "../../services/stateMigration.service"; +import { StateServiceVNextImplementation } from "../../services/state-service/state-vNext.service"; +import { StateService } from "../../services/state-service/state.service"; +import { StateMigrationService } from "../../services/state-service/stateMigration.service"; import { SyncService } from "../../services/sync.service"; import { AuthGuardService } from "./auth-guard.service"; @@ -222,6 +224,29 @@ export function initFactory( StateMigrationServiceAbstraction, ], }), + // Use new StateServiceVNext with flat key-value structure (new interface) + safeProvider({ + provide: StateServiceVNext, + useFactory: ( + storageService: StorageServiceAbstraction, + secureStorageService: StorageServiceAbstraction, + logService: LogServiceAbstraction, + stateMigrationService: StateMigrationServiceAbstraction, + ) => + new StateServiceVNextImplementation( + storageService, + secureStorageService, + logService, + stateMigrationService, + true, + ), + deps: [ + StorageServiceAbstraction, + SECURE_STORAGE, + LogServiceAbstraction, + StateMigrationServiceAbstraction, + ], + }), safeProvider({ provide: SingleRequestBuilder, deps: [], @@ -233,7 +258,12 @@ export function initFactory( safeProvider({ provide: DirectoryFactoryService, useClass: DefaultDirectoryFactoryService, - deps: [LogServiceAbstraction, I18nServiceAbstraction, StateServiceAbstraction], + deps: [ + LogServiceAbstraction, + I18nServiceAbstraction, + StateServiceAbstraction, + StateServiceVNext, + ], }), ] satisfies SafeProvider[], }) diff --git a/src/bwdc.ts b/src/bwdc.ts index afa06430..332c38b0 100644 --- a/src/bwdc.ts +++ b/src/bwdc.ts @@ -18,6 +18,7 @@ import { NodeApiService } from "@/jslib/node/src/services/nodeApi.service"; import { NodeCryptoFunctionService } from "@/jslib/node/src/services/nodeCryptoFunction.service"; import { DirectoryFactoryService } from "./abstractions/directory-factory.service"; +import { StateServiceVNext } from "./abstractions/state-vNext.service"; import { Account } from "./models/account"; import { Program } from "./program"; import { AuthService } from "./services/auth.service"; @@ -27,8 +28,9 @@ import { I18nService } from "./services/i18n.service"; import { KeytarSecureStorageService } from "./services/keytarSecureStorage.service"; import { LowdbStorageService } from "./services/lowdbStorage.service"; import { SingleRequestBuilder } from "./services/single-request-builder"; -import { StateService } from "./services/state.service"; -import { StateMigrationService } from "./services/stateMigration.service"; +import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service"; +import { StateService } from "./services/state-service/state.service"; +import { StateMigrationService } from "./services/state-service/stateMigration.service"; import { SyncService } from "./services/sync.service"; // eslint-disable-next-line @@ -53,6 +55,7 @@ export class Main { cryptoFunctionService: NodeCryptoFunctionService; authService: AuthService; syncService: SyncService; + stateServiceVNext: StateServiceVNext; stateService: StateService; stateMigrationService: StateMigrationService; directoryFactoryService: DirectoryFactoryService; @@ -116,6 +119,14 @@ export class Main { process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true", new StateFactory(GlobalState, Account), ); + // Use new StateServiceVNext with flat key-value structure + this.stateServiceVNext = new StateServiceVNextImplementation( + this.storageService, + this.secureStorageService, + this.logService, + this.stateMigrationService, + process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true", + ); this.cryptoService = new CryptoService( this.cryptoFunctionService, @@ -157,6 +168,7 @@ export class Main { this.logService, this.i18nService, this.stateService, + this.stateServiceVNext, ); this.batchRequestBuilder = new BatchRequestBuilder(); diff --git a/src/main.ts b/src/main.ts index 51c7f048..e76b6e72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,12 +11,14 @@ import { TrayMain } from "@/jslib/electron/src/tray.main"; import { UpdaterMain } from "@/jslib/electron/src/updater.main"; import { WindowMain } from "@/jslib/electron/src/window.main"; +import { StateServiceVNext } from "./abstractions/state-vNext.service"; import { DCCredentialStorageListener } from "./main/credential-storage-listener"; import { MenuMain } from "./main/menu.main"; import { MessagingMain } from "./main/messaging.main"; import { Account } from "./models/account"; import { I18nService } from "./services/i18n.service"; -import { StateService } from "./services/state.service"; +import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service"; +import { StateService } from "./services/state-service/state.service"; export class Main { logService: ElectronLogService; @@ -24,6 +26,7 @@ export class Main { storageService: ElectronStorageService; messagingService: ElectronMainMessagingService; credentialStorageListener: DCCredentialStorageListener; + stateServiceVNext: StateServiceVNext; stateService: StateService; windowMain: WindowMain; @@ -66,6 +69,14 @@ export class Main { true, new StateFactory(GlobalState, Account), ); + // Use new StateServiceVNext with flat key-value structure + this.stateServiceVNext = new StateServiceVNextImplementation( + this.storageService, + null, + this.logService, + null, + true, + ); this.windowMain = new WindowMain( this.stateService, diff --git a/src/models/state.model.ts b/src/models/state.model.ts new file mode 100644 index 00000000..12610312 --- /dev/null +++ b/src/models/state.model.ts @@ -0,0 +1,108 @@ +// =================================================================== +// vNext Storage Keys (Flat key-value structure) +// =================================================================== + +export const StorageKeysVNext = { + stateVersion: "stateVersion", + directoryType: "directoryType", + organizationId: "organizationId", + directory_ldap: "directory_ldap", + directory_gsuite: "directory_gsuite", + directory_entra: "directory_entra", + directory_okta: "directory_okta", + directory_onelogin: "directory_onelogin", + sync: "sync", + syncingDir: "syncingDir", +}; + +export const SecureStorageKeysVNext: { [key: string]: any } = { + ldap: "secret_ldap", + gsuite: "secret_gsuite", + // Azure Active Directory was renamed to Entra ID, but we've kept the old property name + // to be backwards compatible with existing configurations. + azure: "secret_azure", + entra: "secret_entra", + okta: "secret_okta", + oneLogin: "secret_oneLogin", + userDelta: "userDeltaToken", + groupDelta: "groupDeltaToken", + lastUserSync: "lastUserSync", + lastGroupSync: "lastGroupSync", + lastSyncHash: "lastSyncHash", +}; + +// =================================================================== +// Legacy Storage Keys (Account-based hierarchy) +// =================================================================== + +export const SecureStorageKeysLegacy = { + ldap: "ldapPassword", + gsuite: "gsuitePrivateKey", + // Azure Active Directory was renamed to Entra ID, but we've kept the old property name + // to be backwards compatible with existing configurations. + azure: "azureKey", + entra: "entraKey", + okta: "oktaToken", + oneLogin: "oneLoginClientSecret", + userDelta: "userDeltaToken", + groupDelta: "groupDeltaToken", + lastUserSync: "lastUserSync", + lastGroupSync: "lastGroupSync", + lastSyncHash: "lastSyncHash", +}; + +export const TempKeys = { + tempAccountSettings: "tempAccountSettings", + tempDirectoryConfigs: "tempDirectoryConfigs", + tempDirectorySettings: "tempDirectorySettings", +}; + +// =================================================================== +// Migration Storage Keys +// =================================================================== + +export const SecureStorageKeysMigration: { [key: string]: any } = { + ldap: "ldapPassword", + gsuite: "gsuitePrivateKey", + azure: "azureKey", + entra: "entraIdKey", + okta: "oktaToken", + oneLogin: "oneLoginClientSecret", + directoryConfigPrefix: "directoryConfig_", + sync: "syncConfig", + directoryType: "directoryType", + organizationId: "organizationId", +}; + +export const MigrationKeys: { [key: string]: any } = { + entityId: "entityId", + directoryType: "directoryType", + organizationId: "organizationId", + lastUserSync: "lastUserSync", + lastGroupSync: "lastGroupSync", + lastSyncHash: "lastSyncHash", + syncingDir: "syncingDir", + syncConfig: "syncConfig", + userDelta: "userDeltaToken", + groupDelta: "groupDeltaToken", + tempDirectoryConfigs: "tempDirectoryConfigs", + tempDirectorySettings: "tempDirectorySettings", +}; + +export const MigrationStateKeys = { + global: "global", + authenticatedAccounts: "authenticatedAccounts", +}; + +export const MigrationClientKeys: { [key: string]: any } = { + clientIdOld: "clientId", + clientId: "apikey_clientId", + clientSecretOld: "clientSecret", + clientSecret: "apikey_clientSecret", +}; + +// =================================================================== +// Shared Constants +// =================================================================== + +export const StoredSecurely = "[STORED SECURELY]"; diff --git a/src/services/authService.spec.ts b/src/services/authService.spec.ts index 8a3a3345..97ecb2ac 100644 --- a/src/services/authService.spec.ts +++ b/src/services/authService.spec.ts @@ -15,7 +15,7 @@ import { MessagingService } from "../../jslib/common/src/abstractions/messaging. import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account"; import { AuthService } from "./auth.service"; -import { StateService } from "./state.service"; +import { StateService } from "./state-service/state.service"; const clientId = "organization.CLIENT_ID"; const clientSecret = "CLIENT_SECRET"; diff --git a/src/services/directory-factory.service.ts b/src/services/directory-factory.service.ts index eccea5c2..0d1e80a4 100644 --- a/src/services/directory-factory.service.ts +++ b/src/services/directory-factory.service.ts @@ -2,6 +2,7 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service"; import { LogService } from "@/jslib/common/src/abstractions/log.service"; import { DirectoryFactoryService } from "../abstractions/directory-factory.service"; +import { StateServiceVNext } from "../abstractions/state-vNext.service"; import { StateService } from "../abstractions/state.service"; import { DirectoryType } from "../enums/directoryType"; @@ -16,12 +17,18 @@ export class DefaultDirectoryFactoryService implements DirectoryFactoryService { private logService: LogService, private i18nService: I18nService, private stateService: StateService, + private stateServiceVNext: StateServiceVNext, ) {} createService(directoryType: DirectoryType) { switch (directoryType) { case DirectoryType.GSuite: - return new GSuiteDirectoryService(this.logService, this.i18nService, this.stateService); + return new GSuiteDirectoryService( + this.logService, + this.i18nService, + this.stateService, + this.stateServiceVNext, + ); case DirectoryType.EntraID: return new EntraIdDirectoryService(this.logService, this.i18nService, this.stateService); case DirectoryType.Ldap: diff --git a/src/services/directory-services/gsuite-directory.service.integration.spec.ts b/src/services/directory-services/gsuite-directory.service.integration.spec.ts index 397e594c..b2f567d5 100644 --- a/src/services/directory-services/gsuite-directory.service.integration.spec.ts +++ b/src/services/directory-services/gsuite-directory.service.integration.spec.ts @@ -1,6 +1,8 @@ import { config as dotenvConfig } from "dotenv"; import { mock, MockProxy } from "jest-mock-extended"; +import { StateServiceVNext } from "@/src/abstractions/state-vNext.service"; + import { I18nService } from "../../../jslib/common/src/abstractions/i18n.service"; import { LogService } from "../../../jslib/common/src/abstractions/log.service"; import { @@ -10,7 +12,7 @@ import { import { groupFixtures } from "../../../utils/google-workspace/group-fixtures"; import { userFixtures } from "../../../utils/google-workspace/user-fixtures"; import { DirectoryType } from "../../enums/directoryType"; -import { StateService } from "../state.service"; +import { StateService } from "../state-service/state.service"; import { GSuiteDirectoryService } from "./gsuite-directory.service"; @@ -35,6 +37,7 @@ describe("gsuiteDirectoryService", () => { let logService: MockProxy; let i18nService: MockProxy; let stateService: MockProxy; + let stateServiceVNext: MockProxy; let directoryService: GSuiteDirectoryService; @@ -42,12 +45,18 @@ describe("gsuiteDirectoryService", () => { logService = mock(); i18nService = mock(); stateService = mock(); + stateServiceVNext = mock(); stateService.getDirectoryType.mockResolvedValue(DirectoryType.GSuite); stateService.getLastUserSync.mockResolvedValue(null); // do not filter results by last modified date i18nService.t.mockImplementation((id) => id); // passthrough implementation for any error messages - directoryService = new GSuiteDirectoryService(logService, i18nService, stateService); + directoryService = new GSuiteDirectoryService( + logService, + i18nService, + stateService, + stateServiceVNext, + ); }); it("syncs without using filters (includes test data)", async () => { diff --git a/src/services/directory-services/gsuite-directory.service.ts b/src/services/directory-services/gsuite-directory.service.ts index 4e8be9a5..9e710e48 100644 --- a/src/services/directory-services/gsuite-directory.service.ts +++ b/src/services/directory-services/gsuite-directory.service.ts @@ -4,6 +4,8 @@ import { admin_directory_v1, google } from "googleapis"; import { I18nService } from "@/jslib/common/src/abstractions/i18n.service"; import { LogService } from "@/jslib/common/src/abstractions/log.service"; +import { StateServiceVNext } from "@/src/abstractions/state-vNext.service"; + import { StateService } from "../../abstractions/state.service"; import { DirectoryType } from "../../enums/directoryType"; import { GroupEntry } from "../../models/groupEntry"; @@ -25,25 +27,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir private logService: LogService, private i18nService: I18nService, private stateService: StateService, + private stateServiceVNext: StateServiceVNext, ) { super(); this.service = google.admin("directory_v1"); } async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> { - const type = await this.stateService.getDirectoryType(); + const type = await this.stateServiceVNext.getDirectoryType(); if (type !== DirectoryType.GSuite) { return; } - this.dirConfig = await this.stateService.getDirectory( + this.dirConfig = await this.stateServiceVNext.getDirectory( DirectoryType.GSuite, ); if (this.dirConfig == null) { return; } - this.syncConfig = await this.stateService.getSync(); + this.syncConfig = await this.stateServiceVNext.getSync(); if (this.syncConfig == null) { return; } diff --git a/src/services/directory-services/ldap-directory.service.integration.spec.ts b/src/services/directory-services/ldap-directory.service.integration.spec.ts index 67d1be55..c230b656 100644 --- a/src/services/directory-services/ldap-directory.service.integration.spec.ts +++ b/src/services/directory-services/ldap-directory.service.integration.spec.ts @@ -9,7 +9,7 @@ import { import { groupFixtures } from "../../../utils/openldap/group-fixtures"; import { userFixtures } from "../../../utils/openldap/user-fixtures"; import { DirectoryType } from "../../enums/directoryType"; -import { StateService } from "../state.service"; +import { StateService } from "../state-service/state.service"; import { LdapDirectoryService } from "./ldap-directory.service"; diff --git a/src/services/state-service/state-vNext.service.ts b/src/services/state-service/state-vNext.service.ts new file mode 100644 index 00000000..71a9156a --- /dev/null +++ b/src/services/state-service/state-vNext.service.ts @@ -0,0 +1,409 @@ +import { LogService } from "@/jslib/common/src/abstractions/log.service"; +import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service"; +import { StorageService } from "@/jslib/common/src/abstractions/storage.service"; +import { EnvironmentUrls } from "@/jslib/common/src/models/domain/environmentUrls"; +import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions"; + +import { StateServiceVNext as StateServiceVNextAbstraction } from "@/src/abstractions/state-vNext.service"; +import { DirectoryType } from "@/src/enums/directoryType"; +import { IConfiguration } from "@/src/models/IConfiguration"; +import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; +import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; +import { LdapConfiguration } from "@/src/models/ldapConfiguration"; +import { OktaConfiguration } from "@/src/models/oktaConfiguration"; +import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { + SecureStorageKeysVNext as SecureStorageKeys, + StorageKeysVNext as StorageKeys, + StoredSecurely, +} from "@/src/models/state.model"; +import { SyncConfiguration } from "@/src/models/syncConfiguration"; + +export class StateServiceVNextImplementation implements StateServiceVNextAbstraction { + constructor( + protected storageService: StorageService, + protected secureStorageService: StorageService, + protected logService: LogService, + protected stateMigrationService: StateMigrationService, + private useSecureStorageForSecrets = true, + ) {} + + async init(): Promise { + if (await this.stateMigrationService.needsMigration()) { + await this.stateMigrationService.migrate(); + } + } + + async clean(options?: StorageOptions): Promise { + // Clear all directory settings and configurations + // but preserve version and environment settings + await this.setDirectoryType(null); + await this.setOrganizationId(null); + await this.setSync(null); + await this.setLdapConfiguration(null); + await this.setGsuiteConfiguration(null); + await this.setEntraConfiguration(null); + await this.setOktaConfiguration(null); + await this.setOneLoginConfiguration(null); + await this.clearSyncSettings(true); + } + + // =================================================================== + // Directory Configuration Methods + // =================================================================== + + async getDirectory(type: DirectoryType): Promise { + const config = await this.getConfiguration(type); + if (config == null) { + return config as T; + } + + if (this.useSecureStorageForSecrets) { + // Create a copy to avoid modifying the cached config + const configWithSecrets = Object.assign({}, config); + + switch (type) { + case DirectoryType.Ldap: + (configWithSecrets as any).password = await this.getLdapSecret(); + break; + case DirectoryType.EntraID: + (configWithSecrets as any).key = await this.getEntraSecret(); + break; + case DirectoryType.Okta: + (configWithSecrets as any).token = await this.getOktaSecret(); + break; + case DirectoryType.GSuite: + (configWithSecrets as any).privateKey = await this.getGsuiteSecret(); + break; + case DirectoryType.OneLogin: + (configWithSecrets as any).clientSecret = await this.getOneLoginSecret(); + break; + } + + return configWithSecrets as T; + } + + return config as T; + } + + async setDirectory( + type: DirectoryType, + config: + | LdapConfiguration + | GSuiteConfiguration + | EntraIdConfiguration + | OktaConfiguration + | OneLoginConfiguration, + ): Promise { + if (this.useSecureStorageForSecrets) { + switch (type) { + case DirectoryType.Ldap: { + const ldapConfig = config as LdapConfiguration; + await this.setLdapSecret(ldapConfig.password); + ldapConfig.password = StoredSecurely; + await this.setLdapConfiguration(ldapConfig); + break; + } + case DirectoryType.EntraID: { + const entraConfig = config as EntraIdConfiguration; + await this.setEntraSecret(entraConfig.key); + entraConfig.key = StoredSecurely; + await this.setEntraConfiguration(entraConfig); + break; + } + case DirectoryType.Okta: { + const oktaConfig = config as OktaConfiguration; + await this.setOktaSecret(oktaConfig.token); + oktaConfig.token = StoredSecurely; + await this.setOktaConfiguration(oktaConfig); + break; + } + case DirectoryType.GSuite: { + const gsuiteConfig = config as GSuiteConfiguration; + if (gsuiteConfig.privateKey == null) { + await this.setGsuiteSecret(null); + } else { + const normalizedPrivateKey = gsuiteConfig.privateKey.replace(/\\n/g, "\n"); + await this.setGsuiteSecret(normalizedPrivateKey); + gsuiteConfig.privateKey = StoredSecurely; + } + await this.setGsuiteConfiguration(gsuiteConfig); + break; + } + case DirectoryType.OneLogin: { + const oneLoginConfig = config as OneLoginConfiguration; + await this.setOneLoginSecret(oneLoginConfig.clientSecret); + oneLoginConfig.clientSecret = StoredSecurely; + await this.setOneLoginConfiguration(oneLoginConfig); + break; + } + } + } + } + + async getConfiguration(type: DirectoryType): Promise { + switch (type) { + case DirectoryType.Ldap: + return await this.getLdapConfiguration(); + case DirectoryType.GSuite: + return await this.getGsuiteConfiguration(); + case DirectoryType.EntraID: + return await this.getEntraConfiguration(); + case DirectoryType.Okta: + return await this.getOktaConfiguration(); + case DirectoryType.OneLogin: + return await this.getOneLoginConfiguration(); + } + } + + // =================================================================== + // Secret Storage Methods (Secure Storage) + // =================================================================== + + private async getLdapSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.ldap); + } + + private async setLdapSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.ldap); + } else { + await this.secureStorageService.save(SecureStorageKeys.ldap, value); + } + } + + private async getGsuiteSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.gsuite); + } + + private async setGsuiteSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.gsuite); + } else { + await this.secureStorageService.save(SecureStorageKeys.gsuite, value); + } + } + + private async getEntraSecret(): Promise { + // Try new key first, fall back to old azure key for backwards compatibility + const entraKey = await this.secureStorageService.get(SecureStorageKeys.entra); + if (entraKey != null) { + return entraKey; + } + return await this.secureStorageService.get(SecureStorageKeys.azure); + } + + private async setEntraSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.entra); + await this.secureStorageService.remove(SecureStorageKeys.azure); + } else { + await this.secureStorageService.save(SecureStorageKeys.entra, value); + } + } + + private async getOktaSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.okta); + } + + private async setOktaSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.okta); + } else { + await this.secureStorageService.save(SecureStorageKeys.okta, value); + } + } + + private async getOneLoginSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.oneLogin); + } + + private async setOneLoginSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.oneLogin); + } else { + await this.secureStorageService.save(SecureStorageKeys.oneLogin, value); + } + } + + // =================================================================== + // Directory-Specific Configuration Methods + // =================================================================== + + async getLdapConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_ldap); + } + + async setLdapConfiguration(value: LdapConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.directory_ldap, value); + } + + async getGsuiteConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_gsuite); + } + + async setGsuiteConfiguration( + value: GSuiteConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_gsuite, value); + } + + async getEntraConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_entra); + } + + async setEntraConfiguration( + value: EntraIdConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_entra, value); + } + + async getOktaConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_okta); + } + + async setOktaConfiguration(value: OktaConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.directory_okta, value); + } + + async getOneLoginConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_onelogin); + } + + async setOneLoginConfiguration( + value: OneLoginConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_onelogin, value); + } + + // =================================================================== + // Directory Settings Methods + // =================================================================== + + async getOrganizationId(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.organizationId); + } + + async setOrganizationId(value: string, options?: StorageOptions): Promise { + const currentId = await this.getOrganizationId(); + if (currentId !== value) { + await this.clearSyncSettings(); + } + await this.storageService.save(StorageKeys.organizationId, value); + } + + async getSync(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.sync); + } + + async setSync(value: SyncConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.sync, value); + } + + async getDirectoryType(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directoryType); + } + + async setDirectoryType(value: DirectoryType, options?: StorageOptions): Promise { + const currentType = await this.getDirectoryType(); + if (value !== currentType) { + await this.clearSyncSettings(); + } + await this.storageService.save(StorageKeys.directoryType, value); + } + + async getLastUserSync(options?: StorageOptions): Promise { + const dateString = await this.storageService.get(SecureStorageKeys.lastUserSync); + return dateString ? new Date(dateString) : null; + } + + async setLastUserSync(value: Date, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastUserSync, value); + } + + async getLastGroupSync(options?: StorageOptions): Promise { + const dateString = await this.storageService.get(SecureStorageKeys.lastGroupSync); + return dateString ? new Date(dateString) : null; + } + + async setLastGroupSync(value: Date, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastGroupSync, value); + } + + async getLastSyncHash(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.lastSyncHash); + } + + async setLastSyncHash(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastSyncHash, value); + } + + async getSyncingDir(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.syncingDir); + } + + async setSyncingDir(value: boolean, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.syncingDir, value); + } + + async getUserDelta(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.userDelta); + } + + async setUserDelta(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.userDelta, value); + } + + async getGroupDelta(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.groupDelta); + } + + async setGroupDelta(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.groupDelta, value); + } + + async clearSyncSettings(hashToo = false): Promise { + await this.setUserDelta(null); + await this.setGroupDelta(null); + await this.setLastGroupSync(null); + await this.setLastUserSync(null); + if (hashToo) { + await this.setLastSyncHash(null); + } + } + + // =================================================================== + // Environment URLs (inherited from base, simplified implementation) + // =================================================================== + + async getEnvironmentUrls(options?: StorageOptions): Promise { + return await this.storageService.get("environmentUrls"); + } + + async setEnvironmentUrls(value: EnvironmentUrls): Promise { + await this.storageService.save("environmentUrls", value); + } + + // =================================================================== + // Additional State Methods + // =================================================================== + + async getLocale(options?: StorageOptions): Promise { + return await this.storageService.get("locale"); + } + + async setLocale(value: string, options?: StorageOptions): Promise { + await this.storageService.save("locale", value); + } + + async getInstalledVersion(options?: StorageOptions): Promise { + return await this.storageService.get("installedVersion"); + } + + async setInstalledVersion(value: string, options?: StorageOptions): Promise { + await this.storageService.save("installedVersion", value); + } +} diff --git a/src/services/state.service.ts b/src/services/state-service/state.service.ts similarity index 96% rename from src/services/state.service.ts rename to src/services/state-service/state.service.ts index 0e2ab574..02d04306 100644 --- a/src/services/state.service.ts +++ b/src/services/state-service/state.service.ts @@ -16,32 +16,13 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; import { LdapConfiguration } from "@/src/models/ldapConfiguration"; import { OktaConfiguration } from "@/src/models/oktaConfiguration"; import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { + SecureStorageKeysLegacy as SecureStorageKeys, + StoredSecurely, + TempKeys as keys, +} from "@/src/models/state.model"; import { SyncConfiguration } from "@/src/models/syncConfiguration"; -const SecureStorageKeys = { - ldap: "ldapPassword", - gsuite: "gsuitePrivateKey", - // Azure Active Directory was renamed to Entra ID, but we've kept the old property name - // to be backwards compatible with existing configurations. - azure: "azureKey", - entra: "entraKey", - okta: "oktaToken", - oneLogin: "oneLoginClientSecret", - userDelta: "userDeltaToken", - groupDelta: "groupDeltaToken", - lastUserSync: "lastUserSync", - lastGroupSync: "lastGroupSync", - lastSyncHash: "lastSyncHash", -}; - -const keys = { - tempAccountSettings: "tempAccountSettings", - tempDirectoryConfigs: "tempDirectoryConfigs", - tempDirectorySettings: "tempDirectorySettings", -}; - -const StoredSecurely = "[STORED SECURELY]"; - export class StateService extends BaseStateService implements StateServiceAbstraction diff --git a/src/services/state-service/stateMigration.service.ts b/src/services/state-service/stateMigration.service.ts new file mode 100644 index 00000000..e18796ee --- /dev/null +++ b/src/services/state-service/stateMigration.service.ts @@ -0,0 +1,290 @@ +import { StateVersion } from "@/jslib/common/src/enums/stateVersion"; +import { StateMigrationService as BaseStateMigrationService } from "@/jslib/common/src/services/stateMigration.service"; + +import { DirectoryType } from "@/src/enums/directoryType"; +import { Account, DirectoryConfigurations, DirectorySettings } from "@/src/models/account"; +import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; +import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; +import { LdapConfiguration } from "@/src/models/ldapConfiguration"; +import { OktaConfiguration } from "@/src/models/oktaConfiguration"; +import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { + MigrationClientKeys as ClientKeys, + MigrationKeys as Keys, + MigrationStateKeys as StateKeys, + SecureStorageKeysMigration as SecureStorageKeys, +} from "@/src/models/state.model"; +import { SyncConfiguration } from "@/src/models/syncConfiguration"; + +export class StateMigrationService extends BaseStateMigrationService { + async migrate(): Promise { + let currentStateVersion = await this.getCurrentStateVersion(); + while (currentStateVersion < StateVersion.Latest) { + switch (currentStateVersion) { + case StateVersion.One: + await this.migrateClientKeys(); + await this.migrateStateFrom1To2(); + break; + case StateVersion.Two: + await this.migrateStateFrom2To3(); + break; + case StateVersion.Three: + await this.migrateStateFrom3To4(); + break; + case StateVersion.Four: + await this.migrateStateFrom4To5(); + break; + } + currentStateVersion += 1; + } + } + + // TODO: remove this migration when we are confident existing api keys are all migrated. Probably 1-2 releases. + protected async migrateClientKeys() { + const oldClientId = await this.storageService.get(ClientKeys.clientIdOld); + const oldClientSecret = await this.storageService.get(ClientKeys.clientSecretOld); + + if (oldClientId != null) { + await this.storageService.save(ClientKeys.clientId, oldClientId); + await this.storageService.remove(ClientKeys.clientIdOld); + } + + if (oldClientSecret != null) { + await this.storageService.save(ClientKeys.clientSecret, oldClientSecret); + await this.storageService.remove(ClientKeys.clientSecretOld); + } + } + + protected async migrateStateFrom1To2(useSecureStorageForSecrets = true): Promise { + // Grabbing a couple of key settings before they get cleared by the base migration + const userId = await this.get(Keys.entityId); + const clientId = await this.get(ClientKeys.clientId); + const clientSecret = await this.get(ClientKeys.clientSecret); + + await super.migrateStateFrom1To2(); + + // Setup reusable method for clearing keys since we will want to do that regardless of if there is an active authenticated session + const clearDirectoryConnectorV1Keys = async () => { + for (const key in Keys) { + if (key == null) { + continue; + } + for (const directoryType in DirectoryType) { + if (directoryType == null) { + continue; + } + await this.set(SecureStorageKeys.directoryConfigPrefix + directoryType, null); + } + } + }; + + // Initialize typed objects from key/value pairs in storage to either be saved temporarily until an account is authed or applied to the active account + const getDirectoryConfig = async (type: DirectoryType) => + await this.get(SecureStorageKeys.directoryConfigPrefix + type); + const directoryConfigs: DirectoryConfigurations = { + ldap: await getDirectoryConfig(DirectoryType.Ldap), + gsuite: await getDirectoryConfig(DirectoryType.GSuite), + // Azure Active Directory was renamed to Entra ID, but we've kept the old property name + // to be backwards compatible with existing configurations. + azure: await getDirectoryConfig(DirectoryType.EntraID), + entra: await getDirectoryConfig(DirectoryType.EntraID), + okta: await getDirectoryConfig(DirectoryType.Okta), + oneLogin: await getDirectoryConfig(DirectoryType.OneLogin), + }; + + const directorySettings: DirectorySettings = { + directoryType: await this.get(Keys.directoryType), + organizationId: await this.get(Keys.organizationId), + lastUserSync: await this.get(Keys.lastUserSync), + lastGroupSync: await this.get(Keys.lastGroupSync), + lastSyncHash: await this.get(Keys.lastSyncHash), + syncingDir: await this.get(Keys.syncingDir), + sync: await this.get(Keys.syncConfig), + userDelta: await this.get(Keys.userDelta), + groupDelta: await this.get(Keys.groupDelta), + }; + + // (userId == null) = no authed account, stored data temporarily to be applied and cleared on next auth + // (userId != null) = authed account known, applied stored data to it and do not save temp data + if (userId == null) { + await this.set(Keys.tempDirectoryConfigs, directoryConfigs); + await this.set(Keys.tempDirectorySettings, directorySettings); + await clearDirectoryConnectorV1Keys(); + return; + } + + const account = await this.get(userId); + account.directoryConfigurations = directoryConfigs; + account.directorySettings = directorySettings; + account.profile = { + userId: userId, + entityId: userId, + apiKeyClientId: clientId, + }; + account.clientKeys = { + clientId: clientId, + clientSecret: clientSecret, + }; + + await this.set(userId, account); + await clearDirectoryConnectorV1Keys(); + + if (useSecureStorageForSecrets) { + for (const key in SecureStorageKeys) { + if (await this.secureStorageService.has(SecureStorageKeys[key])) { + await this.secureStorageService.save( + `${userId}_${SecureStorageKeys[key]}`, + await this.secureStorageService.get(SecureStorageKeys[key]), + ); + await this.secureStorageService.remove(SecureStorageKeys[key]); + } + } + } + } + protected async migrateStateFrom2To3(useSecureStorageForSecrets = true): Promise { + if (useSecureStorageForSecrets) { + const authenticatedUserIds = await this.get(StateKeys.authenticatedAccounts); + + await Promise.all( + authenticatedUserIds.map(async (userId) => { + const account = await this.get(userId); + + // Fix for userDelta and groupDelta being put into secure storage when they should not have + if (await this.secureStorageService.has(`${userId}_${Keys.userDelta}`)) { + account.directorySettings.userDelta = await this.secureStorageService.get( + `${userId}_${Keys.userDelta}`, + ); + await this.secureStorageService.remove(`${userId}_${Keys.userDelta}`); + } + if (await this.secureStorageService.has(`${userId}_${Keys.groupDelta}`)) { + account.directorySettings.groupDelta = await this.secureStorageService.get( + `${userId}_${Keys.groupDelta}`, + ); + await this.secureStorageService.remove(`${userId}_${Keys.groupDelta}`); + } + await this.set(userId, account); + }), + ); + } + + const globals = await this.getGlobals(); + globals.stateVersion = StateVersion.Three; + await this.set(StateKeys.global, globals); + } + + /** + * Migrate from State v4 (Account-based hierarchy) to v5 (flat key-value structure) + * + * This is a clean break from the Account-based structure. Data is extracted from + * the account and saved into flat keys for simpler access. + * + * Old structure: authenticatedAccounts -> userId -> account.directorySettings/directoryConfigurations + * New structure: flat keys like "directoryType", "organizationId", "directory_ldap", etc. + * + * Secrets migrate from: {userId}_{secretKey} -> secret_{secretKey} + */ + protected async migrateStateFrom4To5(useSecureStorageForSecrets = true): Promise { + // Get the authenticated user IDs from v3 structure + const authenticatedUserIds = await this.get(StateKeys.authenticatedAccounts); + + if (!authenticatedUserIds || authenticatedUserIds.length === 0) { + // No accounts to migrate, just update version + const globals = await this.getGlobals(); + globals.stateVersion = StateVersion.Four; + await this.set(StateKeys.global, globals); + return; + } + + // DC is single-user, so we take the first (and likely only) account + const userId = authenticatedUserIds[0]; + const account = await this.get(userId); + + if (!account) { + // No account data found, just update version + const globals = await this.getGlobals(); + globals.stateVersion = StateVersion.Four; + await this.set(StateKeys.global, globals); + return; + } + + // Migrate directory configurations to flat structure + if (account.directoryConfigurations) { + if (account.directoryConfigurations.ldap) { + await this.set("directory_ldap", account.directoryConfigurations.ldap); + } + if (account.directoryConfigurations.gsuite) { + await this.set("directory_gsuite", account.directoryConfigurations.gsuite); + } + if (account.directoryConfigurations.entra) { + await this.set("directory_entra", account.directoryConfigurations.entra); + } else if (account.directoryConfigurations.azure) { + // Backwards compatibility: migrate azure to entra + await this.set("directory_entra", account.directoryConfigurations.azure); + } + if (account.directoryConfigurations.okta) { + await this.set("directory_okta", account.directoryConfigurations.okta); + } + if (account.directoryConfigurations.oneLogin) { + await this.set("directory_onelogin", account.directoryConfigurations.oneLogin); + } + } + + // Migrate directory settings to flat structure + if (account.directorySettings) { + if (account.directorySettings.organizationId) { + await this.set("organizationId", account.directorySettings.organizationId); + } + if (account.directorySettings.directoryType != null) { + await this.set("directoryType", account.directorySettings.directoryType); + } + if (account.directorySettings.sync) { + await this.set("sync", account.directorySettings.sync); + } + if (account.directorySettings.lastUserSync) { + await this.set("lastUserSync", account.directorySettings.lastUserSync); + } + if (account.directorySettings.lastGroupSync) { + await this.set("lastGroupSync", account.directorySettings.lastGroupSync); + } + if (account.directorySettings.lastSyncHash) { + await this.set("lastSyncHash", account.directorySettings.lastSyncHash); + } + if (account.directorySettings.userDelta) { + await this.set("userDelta", account.directorySettings.userDelta); + } + if (account.directorySettings.groupDelta) { + await this.set("groupDelta", account.directorySettings.groupDelta); + } + if (account.directorySettings.syncingDir != null) { + await this.set("syncingDir", account.directorySettings.syncingDir); + } + } + + // Migrate secrets from {userId}_* to secret_* pattern + if (useSecureStorageForSecrets) { + const oldSecretKeys = [ + { old: `${userId}_${SecureStorageKeys.ldap}`, new: "secret_ldap" }, + { old: `${userId}_${SecureStorageKeys.gsuite}`, new: "secret_gsuite" }, + { old: `${userId}_${SecureStorageKeys.azure}`, new: "secret_azure" }, + { old: `${userId}_${SecureStorageKeys.entra}`, new: "secret_entra" }, + { old: `${userId}_${SecureStorageKeys.okta}`, new: "secret_okta" }, + { old: `${userId}_${SecureStorageKeys.oneLogin}`, new: "secret_onelogin" }, + ]; + + for (const { old: oldKey, new: newKey } of oldSecretKeys) { + if (await this.secureStorageService.has(oldKey)) { + const value = await this.secureStorageService.get(oldKey); + if (value) { + await this.secureStorageService.save(newKey, value); + } + // @TODO Keep old key for now - will remove in future release + // await this.secureStorageService.remove(oldKey); + } + } + } + + const globals = await this.getGlobals(); + globals.stateVersion = StateVersion.Five; + await this.set(StateKeys.global, globals); + } +} diff --git a/src/services/state-vNext.service.ts b/src/services/state-vNext.service.ts new file mode 100644 index 00000000..9e5746cf --- /dev/null +++ b/src/services/state-vNext.service.ts @@ -0,0 +1,435 @@ +import { LogService } from "@/jslib/common/src/abstractions/log.service"; +import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service"; +import { StorageService } from "@/jslib/common/src/abstractions/storage.service"; +import { EnvironmentUrls } from "@/jslib/common/src/models/domain/environmentUrls"; +import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions"; + +import { StateServiceVNext as StateServiceVNextAbstraction } from "@/src/abstractions/state-vNext.service"; +import { DirectoryType } from "@/src/enums/directoryType"; +import { IConfiguration } from "@/src/models/IConfiguration"; +import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; +import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; +import { LdapConfiguration } from "@/src/models/ldapConfiguration"; +import { OktaConfiguration } from "@/src/models/oktaConfiguration"; +import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { SyncConfiguration } from "@/src/models/syncConfiguration"; + +const StorageKeys = { + stateVersion: "stateVersion", + directoryType: "directoryType", + organizationId: "organizationId", + directory_ldap: "directory_ldap", + directory_gsuite: "directory_gsuite", + directory_entra: "directory_entra", + directory_okta: "directory_okta", + directory_onelogin: "directory_onelogin", + sync: "sync", + syncingDir: "syncingDir", +}; + +const SecureStorageKeys: { [key: string]: any } = { + ldap: "secret_ldap", + gsuite: "secret_gsuite", + // Azure Active Directory was renamed to Entra ID, but we've kept the old property name + // to be backwards compatible with existing configurations. + azure: "secret_azure", + entra: "secret_entra", + okta: "secret_okta", + oneLogin: "secret_oneLogin", + userDelta: "userDeltaToken", + groupDelta: "groupDeltaToken", + lastUserSync: "lastUserSync", + lastGroupSync: "lastGroupSync", + lastSyncHash: "lastSyncHash", +}; + +const StoredSecurely = "[STORED SECURELY]"; + +export class StateServiceVNextImplementation implements StateServiceVNextAbstraction { + constructor( + protected storageService: StorageService, + protected secureStorageService: StorageService, + protected logService: LogService, + protected stateMigrationService: StateMigrationService, + private useSecureStorageForSecrets = true, + ) {} + + async init(): Promise { + if (await this.stateMigrationService.needsMigration()) { + await this.stateMigrationService.migrate(); + } + } + + async clean(options?: StorageOptions): Promise { + // Clear all directory settings and configurations + // but preserve version and environment settings + await this.setDirectoryType(null); + await this.setOrganizationId(null); + await this.setSync(null); + await this.setLdapConfiguration(null); + await this.setGsuiteConfiguration(null); + await this.setEntraConfiguration(null); + await this.setOktaConfiguration(null); + await this.setOneLoginConfiguration(null); + await this.clearSyncSettings(true); + } + + // =================================================================== + // Directory Configuration Methods + // =================================================================== + + async getDirectory(type: DirectoryType): Promise { + const config = await this.getConfiguration(type); + if (config == null) { + return config as T; + } + + if (this.useSecureStorageForSecrets) { + // Create a copy to avoid modifying the cached config + const configWithSecrets = Object.assign({}, config); + + switch (type) { + case DirectoryType.Ldap: + (configWithSecrets as any).password = await this.getLdapSecret(); + break; + case DirectoryType.EntraID: + (configWithSecrets as any).key = await this.getEntraSecret(); + break; + case DirectoryType.Okta: + (configWithSecrets as any).token = await this.getOktaSecret(); + break; + case DirectoryType.GSuite: + (configWithSecrets as any).privateKey = await this.getGsuiteSecret(); + break; + case DirectoryType.OneLogin: + (configWithSecrets as any).clientSecret = await this.getOneLoginSecret(); + break; + } + + return configWithSecrets as T; + } + + return config as T; + } + + async setDirectory( + type: DirectoryType, + config: + | LdapConfiguration + | GSuiteConfiguration + | EntraIdConfiguration + | OktaConfiguration + | OneLoginConfiguration, + ): Promise { + if (this.useSecureStorageForSecrets) { + switch (type) { + case DirectoryType.Ldap: { + const ldapConfig = config as LdapConfiguration; + await this.setLdapSecret(ldapConfig.password); + ldapConfig.password = StoredSecurely; + await this.setLdapConfiguration(ldapConfig); + break; + } + case DirectoryType.EntraID: { + const entraConfig = config as EntraIdConfiguration; + await this.setEntraSecret(entraConfig.key); + entraConfig.key = StoredSecurely; + await this.setEntraConfiguration(entraConfig); + break; + } + case DirectoryType.Okta: { + const oktaConfig = config as OktaConfiguration; + await this.setOktaSecret(oktaConfig.token); + oktaConfig.token = StoredSecurely; + await this.setOktaConfiguration(oktaConfig); + break; + } + case DirectoryType.GSuite: { + const gsuiteConfig = config as GSuiteConfiguration; + if (gsuiteConfig.privateKey == null) { + await this.setGsuiteSecret(null); + } else { + const normalizedPrivateKey = gsuiteConfig.privateKey.replace(/\\n/g, "\n"); + await this.setGsuiteSecret(normalizedPrivateKey); + gsuiteConfig.privateKey = StoredSecurely; + } + await this.setGsuiteConfiguration(gsuiteConfig); + break; + } + case DirectoryType.OneLogin: { + const oneLoginConfig = config as OneLoginConfiguration; + await this.setOneLoginSecret(oneLoginConfig.clientSecret); + oneLoginConfig.clientSecret = StoredSecurely; + await this.setOneLoginConfiguration(oneLoginConfig); + break; + } + } + } + } + + async getConfiguration(type: DirectoryType): Promise { + switch (type) { + case DirectoryType.Ldap: + return await this.getLdapConfiguration(); + case DirectoryType.GSuite: + return await this.getGsuiteConfiguration(); + case DirectoryType.EntraID: + return await this.getEntraConfiguration(); + case DirectoryType.Okta: + return await this.getOktaConfiguration(); + case DirectoryType.OneLogin: + return await this.getOneLoginConfiguration(); + } + } + + // =================================================================== + // Secret Storage Methods (Secure Storage) + // =================================================================== + + private async getLdapSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.ldap); + } + + private async setLdapSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.ldap); + } else { + await this.secureStorageService.save(SecureStorageKeys.ldap, value); + } + } + + private async getGsuiteSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.gsuite); + } + + private async setGsuiteSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.gsuite); + } else { + await this.secureStorageService.save(SecureStorageKeys.gsuite, value); + } + } + + private async getEntraSecret(): Promise { + // Try new key first, fall back to old azure key for backwards compatibility + const entraKey = await this.secureStorageService.get(SecureStorageKeys.entra); + if (entraKey != null) { + return entraKey; + } + return await this.secureStorageService.get(SecureStorageKeys.azure); + } + + private async setEntraSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.entra); + await this.secureStorageService.remove(SecureStorageKeys.azure); + } else { + await this.secureStorageService.save(SecureStorageKeys.entra, value); + } + } + + private async getOktaSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.okta); + } + + private async setOktaSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.okta); + } else { + await this.secureStorageService.save(SecureStorageKeys.okta, value); + } + } + + private async getOneLoginSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.oneLogin); + } + + private async setOneLoginSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.oneLogin); + } else { + await this.secureStorageService.save(SecureStorageKeys.oneLogin, value); + } + } + + // =================================================================== + // Directory-Specific Configuration Methods + // =================================================================== + + async getLdapConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_ldap); + } + + async setLdapConfiguration(value: LdapConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.directory_ldap, value); + } + + async getGsuiteConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_gsuite); + } + + async setGsuiteConfiguration( + value: GSuiteConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_gsuite, value); + } + + async getEntraConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_entra); + } + + async setEntraConfiguration( + value: EntraIdConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_entra, value); + } + + async getOktaConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_okta); + } + + async setOktaConfiguration(value: OktaConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.directory_okta, value); + } + + async getOneLoginConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_onelogin); + } + + async setOneLoginConfiguration( + value: OneLoginConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_onelogin, value); + } + + // =================================================================== + // Directory Settings Methods + // =================================================================== + + async getOrganizationId(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.organizationId); + } + + async setOrganizationId(value: string, options?: StorageOptions): Promise { + const currentId = await this.getOrganizationId(); + if (currentId !== value) { + await this.clearSyncSettings(); + } + await this.storageService.save(StorageKeys.organizationId, value); + } + + async getSync(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.sync); + } + + async setSync(value: SyncConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.sync, value); + } + + async getDirectoryType(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directoryType); + } + + async setDirectoryType(value: DirectoryType, options?: StorageOptions): Promise { + const currentType = await this.getDirectoryType(); + if (value !== currentType) { + await this.clearSyncSettings(); + } + await this.storageService.save(StorageKeys.directoryType, value); + } + + async getLastUserSync(options?: StorageOptions): Promise { + const dateString = await this.storageService.get(SecureStorageKeys.lastUserSync); + return dateString ? new Date(dateString) : null; + } + + async setLastUserSync(value: Date, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastUserSync, value); + } + + async getLastGroupSync(options?: StorageOptions): Promise { + const dateString = await this.storageService.get(SecureStorageKeys.lastGroupSync); + return dateString ? new Date(dateString) : null; + } + + async setLastGroupSync(value: Date, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastGroupSync, value); + } + + async getLastSyncHash(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.lastSyncHash); + } + + async setLastSyncHash(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastSyncHash, value); + } + + async getSyncingDir(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.syncingDir); + } + + async setSyncingDir(value: boolean, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.syncingDir, value); + } + + async getUserDelta(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.userDelta); + } + + async setUserDelta(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.userDelta, value); + } + + async getGroupDelta(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.groupDelta); + } + + async setGroupDelta(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.groupDelta, value); + } + + async clearSyncSettings(hashToo = false): Promise { + await this.setUserDelta(null); + await this.setGroupDelta(null); + await this.setLastGroupSync(null); + await this.setLastUserSync(null); + if (hashToo) { + await this.setLastSyncHash(null); + } + } + + // =================================================================== + // Environment URLs (inherited from base, simplified implementation) + // =================================================================== + + async getEnvironmentUrls(options?: StorageOptions): Promise { + return await this.storageService.get("environmentUrls"); + } + + async setEnvironmentUrls(value: EnvironmentUrls): Promise { + await this.storageService.save("environmentUrls", value); + } + + // =================================================================== + // Additional State Methods + // =================================================================== + + async getLocale(options?: StorageOptions): Promise { + return await this.storageService.get("locale"); + } + + async setLocale(value: string, options?: StorageOptions): Promise { + await this.storageService.save("locale", value); + } + + async getInstalledVersion(options?: StorageOptions): Promise { + return await this.storageService.get("installedVersion"); + } + + async setInstalledVersion(value: string, options?: StorageOptions): Promise { + await this.storageService.save("installedVersion", value); + } +} diff --git a/src/services/stateMigration.service.ts b/src/services/stateMigration.service.ts index 98aae3fd..21261c39 100644 --- a/src/services/stateMigration.service.ts +++ b/src/services/stateMigration.service.ts @@ -61,6 +61,13 @@ export class StateMigrationService extends BaseStateMigrationService { break; case StateVersion.Two: await this.migrateStateFrom2To3(); + break; + case StateVersion.Three: + await this.migrateStateFrom3To4(); + break; + case StateVersion.Four: + await this.migrateStateFrom4To5(); + break; } currentStateVersion += 1; } @@ -198,4 +205,118 @@ export class StateMigrationService extends BaseStateMigrationService { globals.stateVersion = StateVersion.Three; await this.set(StateKeys.global, globals); } + + /** + * Migrate from State v4 (Account-based hierarchy) to v5 (flat key-value structure) + * + * This is a clean break from the Account-based structure. Data is extracted from + * the account and saved into flat keys for simpler access. + * + * Old structure: authenticatedAccounts -> userId -> account.directorySettings/directoryConfigurations + * New structure: flat keys like "directoryType", "organizationId", "directory_ldap", etc. + * + * Secrets migrate from: {userId}_{secretKey} -> secret_{secretKey} + */ + protected async migrateStateFrom4To5(useSecureStorageForSecrets = true): Promise { + // Get the authenticated user IDs from v3 structure + const authenticatedUserIds = await this.get(StateKeys.authenticatedAccounts); + + if (!authenticatedUserIds || authenticatedUserIds.length === 0) { + // No accounts to migrate, just update version + const globals = await this.getGlobals(); + globals.stateVersion = StateVersion.Four; + await this.set(StateKeys.global, globals); + return; + } + + // DC is single-user, so we take the first (and likely only) account + const userId = authenticatedUserIds[0]; + const account = await this.get(userId); + + if (!account) { + // No account data found, just update version + const globals = await this.getGlobals(); + globals.stateVersion = StateVersion.Four; + await this.set(StateKeys.global, globals); + return; + } + + // Migrate directory configurations to flat structure + if (account.directoryConfigurations) { + if (account.directoryConfigurations.ldap) { + await this.set("directory_ldap", account.directoryConfigurations.ldap); + } + if (account.directoryConfigurations.gsuite) { + await this.set("directory_gsuite", account.directoryConfigurations.gsuite); + } + if (account.directoryConfigurations.entra) { + await this.set("directory_entra", account.directoryConfigurations.entra); + } else if (account.directoryConfigurations.azure) { + // Backwards compatibility: migrate azure to entra + await this.set("directory_entra", account.directoryConfigurations.azure); + } + if (account.directoryConfigurations.okta) { + await this.set("directory_okta", account.directoryConfigurations.okta); + } + if (account.directoryConfigurations.oneLogin) { + await this.set("directory_onelogin", account.directoryConfigurations.oneLogin); + } + } + + // Migrate directory settings to flat structure + if (account.directorySettings) { + if (account.directorySettings.organizationId) { + await this.set("organizationId", account.directorySettings.organizationId); + } + if (account.directorySettings.directoryType != null) { + await this.set("directoryType", account.directorySettings.directoryType); + } + if (account.directorySettings.sync) { + await this.set("sync", account.directorySettings.sync); + } + if (account.directorySettings.lastUserSync) { + await this.set("lastUserSync", account.directorySettings.lastUserSync); + } + if (account.directorySettings.lastGroupSync) { + await this.set("lastGroupSync", account.directorySettings.lastGroupSync); + } + if (account.directorySettings.lastSyncHash) { + await this.set("lastSyncHash", account.directorySettings.lastSyncHash); + } + if (account.directorySettings.userDelta) { + await this.set("userDelta", account.directorySettings.userDelta); + } + if (account.directorySettings.groupDelta) { + await this.set("groupDelta", account.directorySettings.groupDelta); + } + if (account.directorySettings.syncingDir != null) { + await this.set("syncingDir", account.directorySettings.syncingDir); + } + } + + // Migrate secrets from {userId}_* to secret_* pattern + if (useSecureStorageForSecrets) { + const oldSecretKeys = [ + { old: `${userId}_${SecureStorageKeys.ldap}`, new: "secret_ldap" }, + { old: `${userId}_${SecureStorageKeys.gsuite}`, new: "secret_gsuite" }, + { old: `${userId}_${SecureStorageKeys.azure}`, new: "secret_azure" }, + { old: `${userId}_${SecureStorageKeys.entra}`, new: "secret_entra" }, + { old: `${userId}_${SecureStorageKeys.okta}`, new: "secret_okta" }, + { old: `${userId}_${SecureStorageKeys.oneLogin}`, new: "secret_onelogin" }, + ]; + + for (const { old: oldKey, new: newKey } of oldSecretKeys) { + if (await this.secureStorageService.has(oldKey)) { + const value = await this.secureStorageService.get(oldKey); + if (value) { + await this.secureStorageService.save(newKey, value); + } + } + } + } + + const globals = await this.getGlobals(); + globals.stateVersion = StateVersion.Five; + await this.set(StateKeys.global, globals); + } } diff --git a/src/services/sync.service.integration.spec.ts b/src/services/sync.service.integration.spec.ts index fa7f0f97..d844a6cb 100644 --- a/src/services/sync.service.integration.spec.ts +++ b/src/services/sync.service.integration.spec.ts @@ -14,7 +14,7 @@ import { DirectoryType } from "../enums/directoryType"; import { BatchRequestBuilder } from "./batch-request-builder"; import { LdapDirectoryService } from "./directory-services/ldap-directory.service"; import { SingleRequestBuilder } from "./single-request-builder"; -import { StateService } from "./state.service"; +import { StateService } from "./state-service/state.service"; import { SyncService } from "./sync.service"; import * as constants from "./sync.service"; diff --git a/src/services/sync.service.spec.ts b/src/services/sync.service.spec.ts index 3cff089c..be39175d 100644 --- a/src/services/sync.service.spec.ts +++ b/src/services/sync.service.spec.ts @@ -14,7 +14,7 @@ import { BatchRequestBuilder } from "./batch-request-builder"; import { LdapDirectoryService } from "./directory-services/ldap-directory.service"; import { I18nService } from "./i18n.service"; import { SingleRequestBuilder } from "./single-request-builder"; -import { StateService } from "./state.service"; +import { StateService } from "./state-service/state.service"; import { SyncService } from "./sync.service"; import * as constants from "./sync.service"; From 0bff38c4591c9ff1edefec633fec55092766a9e7 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 4 Feb 2026 16:00:01 -0500 Subject: [PATCH 2/4] add tests --- .../state-service/state-vNext.service.spec.ts | 451 ++++++++++++++++++ .../state-service/stateMigration.service.ts | 10 +- src/services/state-vNext.service.ts | 435 ----------------- src/services/stateMigration.service.ts | 322 ------------- 4 files changed, 458 insertions(+), 760 deletions(-) create mode 100644 src/services/state-service/state-vNext.service.spec.ts delete mode 100644 src/services/state-vNext.service.ts delete mode 100644 src/services/stateMigration.service.ts diff --git a/src/services/state-service/state-vNext.service.spec.ts b/src/services/state-service/state-vNext.service.spec.ts new file mode 100644 index 00000000..d3cb8e7a --- /dev/null +++ b/src/services/state-service/state-vNext.service.spec.ts @@ -0,0 +1,451 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { LogService } from "@/jslib/common/src/abstractions/log.service"; +import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service"; +import { StorageService } from "@/jslib/common/src/abstractions/storage.service"; + +import { DirectoryType } from "@/src/enums/directoryType"; +import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; +import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; +import { LdapConfiguration } from "@/src/models/ldapConfiguration"; +import { OktaConfiguration } from "@/src/models/oktaConfiguration"; +import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { StorageKeysVNext as StorageKeys, StoredSecurely } from "@/src/models/state.model"; +import { SyncConfiguration } from "@/src/models/syncConfiguration"; + +import { StateServiceVNextImplementation } from "./state-vNext.service"; + +describe("StateServiceVNextImplementation", () => { + let storageService: MockProxy; + let secureStorageService: MockProxy; + let logService: MockProxy; + let stateMigrationService: MockProxy; + let stateService: StateServiceVNextImplementation; + + beforeEach(() => { + storageService = mock(); + secureStorageService = mock(); + logService = mock(); + stateMigrationService = mock(); + + stateService = new StateServiceVNextImplementation( + storageService, + secureStorageService, + logService, + stateMigrationService, + true, // useSecureStorageForSecrets + ); + }); + + describe("init", () => { + it("should run migration if needed", async () => { + stateMigrationService.needsMigration.mockResolvedValue(true); + + await stateService.init(); + + expect(stateMigrationService.needsMigration).toHaveBeenCalled(); + expect(stateMigrationService.migrate).toHaveBeenCalled(); + }); + + it("should not run migration if not needed", async () => { + stateMigrationService.needsMigration.mockResolvedValue(false); + + await stateService.init(); + + expect(stateMigrationService.needsMigration).toHaveBeenCalled(); + expect(stateMigrationService.migrate).not.toHaveBeenCalled(); + }); + }); + + describe("clean", () => { + it("should clear all directory settings and configurations", async () => { + await stateService.clean(); + + // Verify all directory types are cleared + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.directoryType, null); + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, null); + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, null); + }); + }); + + describe("Directory Type", () => { + it("should store and retrieve directory type", async () => { + storageService.get.mockResolvedValue(DirectoryType.Ldap); + + await stateService.setDirectoryType(DirectoryType.Ldap); + const result = await stateService.getDirectoryType(); + + expect(storageService.save).toHaveBeenCalledWith( + StorageKeys.directoryType, + DirectoryType.Ldap, + ); + expect(result).toBe(DirectoryType.Ldap); + }); + + it("should return null when directory type is not set", async () => { + storageService.get.mockResolvedValue(null); + + const result = await stateService.getDirectoryType(); + + expect(result).toBeNull(); + }); + }); + + describe("Organization Id", () => { + it("should store and retrieve organization ID", async () => { + const orgId = "test-org-123"; + + storageService.get.mockResolvedValue(orgId); + + await stateService.setOrganizationId(orgId); + const result = await stateService.getOrganizationId(); + + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, orgId); + expect(result).toBe(orgId); + }); + }); + + describe("LDAP Configuration", () => { + it("should store and retrieve LDAP configuration with secrets in secure storage", async () => { + const config: LdapConfiguration = { + ssl: true, + startTls: false, + sslAllowUnauthorized: false, + hostname: "ldap.example.com", + port: 636, + ad: true, + username: "admin", + password: "secret-password", + currentUser: false, + }; + + secureStorageService.get.mockResolvedValue("secret-password"); + storageService.get.mockResolvedValue({ + ...config, + password: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.Ldap, config); + const result = await stateService.getDirectory(DirectoryType.Ldap); + + // Verify password is stored in secure storage + expect(secureStorageService.save).toHaveBeenCalled(); + + // Verify configuration is stored + expect(storageService.save).toHaveBeenCalled(); + + // Verify retrieved config has real password from secure storage + expect(result?.password).toBe("secret-password"); + }); + + it("should return null when LDAP configuration is not set", async () => { + storageService.get.mockResolvedValue(null); + + const result = await stateService.getLdapConfiguration(); + + expect(result).toBeNull(); + }); + + it("should handle null password in LDAP configuration", async () => { + const config: LdapConfiguration = { + ssl: true, + startTls: false, + sslAllowUnauthorized: false, + hostname: "ldap.example.com", + port: 636, + ad: true, + username: "admin", + password: null, + currentUser: false, + }; + + await stateService.setDirectory(DirectoryType.Ldap, config); + + // Null passwords should call remove on the secure storage secret key + expect(secureStorageService.remove).toHaveBeenCalled(); + }); + }); + + describe("GSuite Configuration", () => { + it("should store and retrieve GSuite configuration with privateKey in secure storage", async () => { + const config: GSuiteConfiguration = { + domain: "example.com", + clientEmail: "service@example.com", + adminUser: "admin@example.com", + privateKey: "private-key-content", + }; + + secureStorageService.get.mockResolvedValue("private-key-content"); + storageService.get.mockResolvedValue({ + ...config, + privateKey: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.GSuite, config); + const result = await stateService.getDirectory(DirectoryType.GSuite); + + expect(secureStorageService.save).toHaveBeenCalled(); + expect(result?.privateKey).toBe("private-key-content"); + }); + + it("should handle null privateKey in GSuite configuration", async () => { + const config: GSuiteConfiguration = { + domain: "example.com", + clientEmail: "service@example.com", + adminUser: "admin@example.com", + privateKey: null, + }; + + await stateService.setDirectory(DirectoryType.GSuite, config); + + // Null privateKey should call remove on the secure storage secret key + expect(secureStorageService.remove).toHaveBeenCalled(); + }); + }); + + describe("Entra ID Configuration", () => { + it("should store and retrieve Entra ID configuration with key in secure storage", async () => { + const config: EntraIdConfiguration = { + tenant: "tenant-id", + applicationId: "app-id", + key: "secret-key", + }; + + secureStorageService.get.mockResolvedValue("secret-key"); + storageService.get.mockResolvedValue({ + ...config, + key: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.EntraID, config); + const result = await stateService.getDirectory(DirectoryType.EntraID); + + expect(secureStorageService.save).toHaveBeenCalled(); + expect(result?.key).toBe("secret-key"); + }); + + it("should maintain backwards compatibility with Azure key storage", async () => { + const config: EntraIdConfiguration = { + tenant: "tenant-id", + applicationId: "app-id", + key: StoredSecurely, + }; + + storageService.get.mockResolvedValue(config); + secureStorageService.get.mockResolvedValueOnce(null); // entra key not found + secureStorageService.get.mockResolvedValueOnce("azure-secret-key"); // fallback to azure key + + const result = await stateService.getDirectory(DirectoryType.EntraID); + + expect(secureStorageService.get).toHaveBeenCalled(); + expect(result?.key).toBe("azure-secret-key"); + }); + }); + + describe("Okta Configuration", () => { + it("should store and retrieve Okta configuration with token in secure storage", async () => { + const config: OktaConfiguration = { + orgUrl: "https://example.okta.com", + token: "okta-token", + }; + + secureStorageService.get.mockResolvedValue("okta-token"); + storageService.get.mockResolvedValue({ + ...config, + token: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.Okta, config); + const result = await stateService.getDirectory(DirectoryType.Okta); + + expect(secureStorageService.save).toHaveBeenCalled(); + expect(result?.token).toBe("okta-token"); + }); + }); + + describe("OneLogin Configuration", () => { + it("should store and retrieve OneLogin configuration with clientSecret in secure storage", async () => { + const config: OneLoginConfiguration = { + region: "us", + clientId: "client-id", + clientSecret: "client-secret", + }; + + secureStorageService.get.mockResolvedValue("client-secret"); + storageService.get.mockResolvedValue({ + ...config, + clientSecret: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.OneLogin, config); + const result = await stateService.getDirectory(DirectoryType.OneLogin); + + expect(secureStorageService.save).toHaveBeenCalled(); + expect(result?.clientSecret).toBe("client-secret"); + }); + }); + + describe("Sync Configuration", () => { + it("should store and retrieve sync configuration", async () => { + const syncConfig: SyncConfiguration = { + removeDisabled: true, + overwriteExisting: false, + largeImport: false, + memberAttribute: "member", + creationDateAttribute: "whenCreated", + revisionDateAttribute: "whenChanged", + useEmailPrefixSuffix: false, + emailPrefixAttribute: null, + }; + + storageService.get.mockResolvedValue(syncConfig); + + await stateService.setSync(syncConfig); + const result = await stateService.getSync(); + + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, syncConfig); + expect(result).toEqual(syncConfig); + }); + }); + + describe("Sync Settings", () => { + it("should clear sync settings when clearSyncSettings is called", async () => { + await stateService.clearSyncSettings(false); + + // Should set delta and sync values to null + expect(storageService.save).toHaveBeenCalled(); + }); + + it("should clear lastSyncHash when hashToo is true", async () => { + await stateService.clearSyncSettings(true); + + // Should set all values including lastSyncHash to null + expect(storageService.save).toHaveBeenCalled(); + }); + + it("should not clear lastSyncHash when hashToo is false", async () => { + await stateService.clearSyncSettings(false); + + // Should set delta and sync values but not lastSyncHash + expect(storageService.save).toHaveBeenCalled(); + }); + }); + + describe("Last Sync Hash", () => { + it("should store and retrieve last sync hash", async () => { + const hash = "hash"; + + storageService.get.mockResolvedValue(hash); + + await stateService.setLastSyncHash(hash); + const result = await stateService.getLastSyncHash(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result).toBe(hash); + }); + }); + + describe("Delta Tokens", () => { + it("should store and retrieve user delta token", async () => { + const token = "user-delta-token"; + + storageService.get.mockResolvedValue(token); + + await stateService.setUserDelta(token); + const result = await stateService.getUserDelta(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result).toBe(token); + }); + + it("should store and retrieve group delta token", async () => { + const token = "group-delta-token"; + + storageService.get.mockResolvedValue(token); + + await stateService.setGroupDelta(token); + const result = await stateService.getGroupDelta(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result).toBe(token); + }); + }); + + describe("Last Sync Timestamps", () => { + it("should store and retrieve last user sync timestamp", async () => { + const timestamp = new Date("2024-01-01T00:00:00Z"); + + storageService.get.mockResolvedValue(timestamp.toISOString()); + + await stateService.setLastUserSync(timestamp); + const result = await stateService.getLastUserSync(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result?.toISOString()).toBe(timestamp.toISOString()); + }); + + it("should store and retrieve last group sync timestamp", async () => { + const timestamp = new Date("2024-01-01T00:00:00Z"); + + storageService.get.mockResolvedValue(timestamp.toISOString()); + + await stateService.setLastGroupSync(timestamp); + const result = await stateService.getLastGroupSync(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result?.toISOString()).toBe(timestamp.toISOString()); + }); + + it("should return null when last user sync timestamp is not set", async () => { + storageService.get.mockResolvedValue(null); + + const result = await stateService.getLastUserSync(); + + expect(result).toBeNull(); + }); + + it("should return null when last group sync timestamp is not set", async () => { + storageService.get.mockResolvedValue(null); + + const result = await stateService.getLastGroupSync(); + + expect(result).toBeNull(); + }); + }); + + describe("Secure Storage Flag", () => { + it("should not separate secrets when useSecureStorageForSecrets is false", async () => { + const insecureStateService = new StateServiceVNextImplementation( + storageService, + secureStorageService, + logService, + stateMigrationService, + false, // useSecureStorageForSecrets = false + ); + + const config: LdapConfiguration = { + ssl: true, + startTls: false, + sslAllowUnauthorized: false, + hostname: "ldap.example.com", + port: 636, + ad: true, + username: "admin", + password: "secret-password", + currentUser: false, + }; + + storageService.get.mockResolvedValue(config); + + // When useSecureStorageForSecrets is false, setDirectory doesn't process secrets + await insecureStateService.setDirectory(DirectoryType.Ldap, config); + + // Retrieve config - should return password as-is from storage (not from secure storage) + const result = await insecureStateService.getDirectory(DirectoryType.Ldap); + + // Password should be retrieved directly from storage, not secure storage + expect(result?.password).toBe("secret-password"); + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/state-service/stateMigration.service.ts b/src/services/state-service/stateMigration.service.ts index e18796ee..ee984b22 100644 --- a/src/services/state-service/stateMigration.service.ts +++ b/src/services/state-service/stateMigration.service.ts @@ -187,10 +187,14 @@ export class StateMigrationService extends BaseStateMigrationService { // Get the authenticated user IDs from v3 structure const authenticatedUserIds = await this.get(StateKeys.authenticatedAccounts); - if (!authenticatedUserIds || authenticatedUserIds.length === 0) { + if ( + !authenticatedUserIds || + !Array.isArray(authenticatedUserIds) || + authenticatedUserIds.length === 0 + ) { // No accounts to migrate, just update version const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Four; + globals.stateVersion = StateVersion.Five; await this.set(StateKeys.global, globals); return; } @@ -202,7 +206,7 @@ export class StateMigrationService extends BaseStateMigrationService { if (!account) { // No account data found, just update version const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Four; + globals.stateVersion = StateVersion.Five; await this.set(StateKeys.global, globals); return; } diff --git a/src/services/state-vNext.service.ts b/src/services/state-vNext.service.ts deleted file mode 100644 index 9e5746cf..00000000 --- a/src/services/state-vNext.service.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { LogService } from "@/jslib/common/src/abstractions/log.service"; -import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service"; -import { StorageService } from "@/jslib/common/src/abstractions/storage.service"; -import { EnvironmentUrls } from "@/jslib/common/src/models/domain/environmentUrls"; -import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions"; - -import { StateServiceVNext as StateServiceVNextAbstraction } from "@/src/abstractions/state-vNext.service"; -import { DirectoryType } from "@/src/enums/directoryType"; -import { IConfiguration } from "@/src/models/IConfiguration"; -import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; -import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; -import { LdapConfiguration } from "@/src/models/ldapConfiguration"; -import { OktaConfiguration } from "@/src/models/oktaConfiguration"; -import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; -import { SyncConfiguration } from "@/src/models/syncConfiguration"; - -const StorageKeys = { - stateVersion: "stateVersion", - directoryType: "directoryType", - organizationId: "organizationId", - directory_ldap: "directory_ldap", - directory_gsuite: "directory_gsuite", - directory_entra: "directory_entra", - directory_okta: "directory_okta", - directory_onelogin: "directory_onelogin", - sync: "sync", - syncingDir: "syncingDir", -}; - -const SecureStorageKeys: { [key: string]: any } = { - ldap: "secret_ldap", - gsuite: "secret_gsuite", - // Azure Active Directory was renamed to Entra ID, but we've kept the old property name - // to be backwards compatible with existing configurations. - azure: "secret_azure", - entra: "secret_entra", - okta: "secret_okta", - oneLogin: "secret_oneLogin", - userDelta: "userDeltaToken", - groupDelta: "groupDeltaToken", - lastUserSync: "lastUserSync", - lastGroupSync: "lastGroupSync", - lastSyncHash: "lastSyncHash", -}; - -const StoredSecurely = "[STORED SECURELY]"; - -export class StateServiceVNextImplementation implements StateServiceVNextAbstraction { - constructor( - protected storageService: StorageService, - protected secureStorageService: StorageService, - protected logService: LogService, - protected stateMigrationService: StateMigrationService, - private useSecureStorageForSecrets = true, - ) {} - - async init(): Promise { - if (await this.stateMigrationService.needsMigration()) { - await this.stateMigrationService.migrate(); - } - } - - async clean(options?: StorageOptions): Promise { - // Clear all directory settings and configurations - // but preserve version and environment settings - await this.setDirectoryType(null); - await this.setOrganizationId(null); - await this.setSync(null); - await this.setLdapConfiguration(null); - await this.setGsuiteConfiguration(null); - await this.setEntraConfiguration(null); - await this.setOktaConfiguration(null); - await this.setOneLoginConfiguration(null); - await this.clearSyncSettings(true); - } - - // =================================================================== - // Directory Configuration Methods - // =================================================================== - - async getDirectory(type: DirectoryType): Promise { - const config = await this.getConfiguration(type); - if (config == null) { - return config as T; - } - - if (this.useSecureStorageForSecrets) { - // Create a copy to avoid modifying the cached config - const configWithSecrets = Object.assign({}, config); - - switch (type) { - case DirectoryType.Ldap: - (configWithSecrets as any).password = await this.getLdapSecret(); - break; - case DirectoryType.EntraID: - (configWithSecrets as any).key = await this.getEntraSecret(); - break; - case DirectoryType.Okta: - (configWithSecrets as any).token = await this.getOktaSecret(); - break; - case DirectoryType.GSuite: - (configWithSecrets as any).privateKey = await this.getGsuiteSecret(); - break; - case DirectoryType.OneLogin: - (configWithSecrets as any).clientSecret = await this.getOneLoginSecret(); - break; - } - - return configWithSecrets as T; - } - - return config as T; - } - - async setDirectory( - type: DirectoryType, - config: - | LdapConfiguration - | GSuiteConfiguration - | EntraIdConfiguration - | OktaConfiguration - | OneLoginConfiguration, - ): Promise { - if (this.useSecureStorageForSecrets) { - switch (type) { - case DirectoryType.Ldap: { - const ldapConfig = config as LdapConfiguration; - await this.setLdapSecret(ldapConfig.password); - ldapConfig.password = StoredSecurely; - await this.setLdapConfiguration(ldapConfig); - break; - } - case DirectoryType.EntraID: { - const entraConfig = config as EntraIdConfiguration; - await this.setEntraSecret(entraConfig.key); - entraConfig.key = StoredSecurely; - await this.setEntraConfiguration(entraConfig); - break; - } - case DirectoryType.Okta: { - const oktaConfig = config as OktaConfiguration; - await this.setOktaSecret(oktaConfig.token); - oktaConfig.token = StoredSecurely; - await this.setOktaConfiguration(oktaConfig); - break; - } - case DirectoryType.GSuite: { - const gsuiteConfig = config as GSuiteConfiguration; - if (gsuiteConfig.privateKey == null) { - await this.setGsuiteSecret(null); - } else { - const normalizedPrivateKey = gsuiteConfig.privateKey.replace(/\\n/g, "\n"); - await this.setGsuiteSecret(normalizedPrivateKey); - gsuiteConfig.privateKey = StoredSecurely; - } - await this.setGsuiteConfiguration(gsuiteConfig); - break; - } - case DirectoryType.OneLogin: { - const oneLoginConfig = config as OneLoginConfiguration; - await this.setOneLoginSecret(oneLoginConfig.clientSecret); - oneLoginConfig.clientSecret = StoredSecurely; - await this.setOneLoginConfiguration(oneLoginConfig); - break; - } - } - } - } - - async getConfiguration(type: DirectoryType): Promise { - switch (type) { - case DirectoryType.Ldap: - return await this.getLdapConfiguration(); - case DirectoryType.GSuite: - return await this.getGsuiteConfiguration(); - case DirectoryType.EntraID: - return await this.getEntraConfiguration(); - case DirectoryType.Okta: - return await this.getOktaConfiguration(); - case DirectoryType.OneLogin: - return await this.getOneLoginConfiguration(); - } - } - - // =================================================================== - // Secret Storage Methods (Secure Storage) - // =================================================================== - - private async getLdapSecret(): Promise { - return await this.secureStorageService.get(SecureStorageKeys.ldap); - } - - private async setLdapSecret(value: string): Promise { - if (value == null) { - await this.secureStorageService.remove(SecureStorageKeys.ldap); - } else { - await this.secureStorageService.save(SecureStorageKeys.ldap, value); - } - } - - private async getGsuiteSecret(): Promise { - return await this.secureStorageService.get(SecureStorageKeys.gsuite); - } - - private async setGsuiteSecret(value: string): Promise { - if (value == null) { - await this.secureStorageService.remove(SecureStorageKeys.gsuite); - } else { - await this.secureStorageService.save(SecureStorageKeys.gsuite, value); - } - } - - private async getEntraSecret(): Promise { - // Try new key first, fall back to old azure key for backwards compatibility - const entraKey = await this.secureStorageService.get(SecureStorageKeys.entra); - if (entraKey != null) { - return entraKey; - } - return await this.secureStorageService.get(SecureStorageKeys.azure); - } - - private async setEntraSecret(value: string): Promise { - if (value == null) { - await this.secureStorageService.remove(SecureStorageKeys.entra); - await this.secureStorageService.remove(SecureStorageKeys.azure); - } else { - await this.secureStorageService.save(SecureStorageKeys.entra, value); - } - } - - private async getOktaSecret(): Promise { - return await this.secureStorageService.get(SecureStorageKeys.okta); - } - - private async setOktaSecret(value: string): Promise { - if (value == null) { - await this.secureStorageService.remove(SecureStorageKeys.okta); - } else { - await this.secureStorageService.save(SecureStorageKeys.okta, value); - } - } - - private async getOneLoginSecret(): Promise { - return await this.secureStorageService.get(SecureStorageKeys.oneLogin); - } - - private async setOneLoginSecret(value: string): Promise { - if (value == null) { - await this.secureStorageService.remove(SecureStorageKeys.oneLogin); - } else { - await this.secureStorageService.save(SecureStorageKeys.oneLogin, value); - } - } - - // =================================================================== - // Directory-Specific Configuration Methods - // =================================================================== - - async getLdapConfiguration(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.directory_ldap); - } - - async setLdapConfiguration(value: LdapConfiguration, options?: StorageOptions): Promise { - await this.storageService.save(StorageKeys.directory_ldap, value); - } - - async getGsuiteConfiguration(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.directory_gsuite); - } - - async setGsuiteConfiguration( - value: GSuiteConfiguration, - options?: StorageOptions, - ): Promise { - await this.storageService.save(StorageKeys.directory_gsuite, value); - } - - async getEntraConfiguration(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.directory_entra); - } - - async setEntraConfiguration( - value: EntraIdConfiguration, - options?: StorageOptions, - ): Promise { - await this.storageService.save(StorageKeys.directory_entra, value); - } - - async getOktaConfiguration(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.directory_okta); - } - - async setOktaConfiguration(value: OktaConfiguration, options?: StorageOptions): Promise { - await this.storageService.save(StorageKeys.directory_okta, value); - } - - async getOneLoginConfiguration(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.directory_onelogin); - } - - async setOneLoginConfiguration( - value: OneLoginConfiguration, - options?: StorageOptions, - ): Promise { - await this.storageService.save(StorageKeys.directory_onelogin, value); - } - - // =================================================================== - // Directory Settings Methods - // =================================================================== - - async getOrganizationId(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.organizationId); - } - - async setOrganizationId(value: string, options?: StorageOptions): Promise { - const currentId = await this.getOrganizationId(); - if (currentId !== value) { - await this.clearSyncSettings(); - } - await this.storageService.save(StorageKeys.organizationId, value); - } - - async getSync(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.sync); - } - - async setSync(value: SyncConfiguration, options?: StorageOptions): Promise { - await this.storageService.save(StorageKeys.sync, value); - } - - async getDirectoryType(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.directoryType); - } - - async setDirectoryType(value: DirectoryType, options?: StorageOptions): Promise { - const currentType = await this.getDirectoryType(); - if (value !== currentType) { - await this.clearSyncSettings(); - } - await this.storageService.save(StorageKeys.directoryType, value); - } - - async getLastUserSync(options?: StorageOptions): Promise { - const dateString = await this.storageService.get(SecureStorageKeys.lastUserSync); - return dateString ? new Date(dateString) : null; - } - - async setLastUserSync(value: Date, options?: StorageOptions): Promise { - await this.storageService.save(SecureStorageKeys.lastUserSync, value); - } - - async getLastGroupSync(options?: StorageOptions): Promise { - const dateString = await this.storageService.get(SecureStorageKeys.lastGroupSync); - return dateString ? new Date(dateString) : null; - } - - async setLastGroupSync(value: Date, options?: StorageOptions): Promise { - await this.storageService.save(SecureStorageKeys.lastGroupSync, value); - } - - async getLastSyncHash(options?: StorageOptions): Promise { - return await this.storageService.get(SecureStorageKeys.lastSyncHash); - } - - async setLastSyncHash(value: string, options?: StorageOptions): Promise { - await this.storageService.save(SecureStorageKeys.lastSyncHash, value); - } - - async getSyncingDir(options?: StorageOptions): Promise { - return await this.storageService.get(StorageKeys.syncingDir); - } - - async setSyncingDir(value: boolean, options?: StorageOptions): Promise { - await this.storageService.save(StorageKeys.syncingDir, value); - } - - async getUserDelta(options?: StorageOptions): Promise { - return await this.storageService.get(SecureStorageKeys.userDelta); - } - - async setUserDelta(value: string, options?: StorageOptions): Promise { - await this.storageService.save(SecureStorageKeys.userDelta, value); - } - - async getGroupDelta(options?: StorageOptions): Promise { - return await this.storageService.get(SecureStorageKeys.groupDelta); - } - - async setGroupDelta(value: string, options?: StorageOptions): Promise { - await this.storageService.save(SecureStorageKeys.groupDelta, value); - } - - async clearSyncSettings(hashToo = false): Promise { - await this.setUserDelta(null); - await this.setGroupDelta(null); - await this.setLastGroupSync(null); - await this.setLastUserSync(null); - if (hashToo) { - await this.setLastSyncHash(null); - } - } - - // =================================================================== - // Environment URLs (inherited from base, simplified implementation) - // =================================================================== - - async getEnvironmentUrls(options?: StorageOptions): Promise { - return await this.storageService.get("environmentUrls"); - } - - async setEnvironmentUrls(value: EnvironmentUrls): Promise { - await this.storageService.save("environmentUrls", value); - } - - // =================================================================== - // Additional State Methods - // =================================================================== - - async getLocale(options?: StorageOptions): Promise { - return await this.storageService.get("locale"); - } - - async setLocale(value: string, options?: StorageOptions): Promise { - await this.storageService.save("locale", value); - } - - async getInstalledVersion(options?: StorageOptions): Promise { - return await this.storageService.get("installedVersion"); - } - - async setInstalledVersion(value: string, options?: StorageOptions): Promise { - await this.storageService.save("installedVersion", value); - } -} diff --git a/src/services/stateMigration.service.ts b/src/services/stateMigration.service.ts deleted file mode 100644 index 21261c39..00000000 --- a/src/services/stateMigration.service.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { StateVersion } from "@/jslib/common/src/enums/stateVersion"; -import { StateMigrationService as BaseStateMigrationService } from "@/jslib/common/src/services/stateMigration.service"; - -import { DirectoryType } from "@/src/enums/directoryType"; -import { Account, DirectoryConfigurations, DirectorySettings } from "@/src/models/account"; -import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; -import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; -import { LdapConfiguration } from "@/src/models/ldapConfiguration"; -import { OktaConfiguration } from "@/src/models/oktaConfiguration"; -import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; -import { SyncConfiguration } from "@/src/models/syncConfiguration"; - -const SecureStorageKeys: { [key: string]: any } = { - ldap: "ldapPassword", - gsuite: "gsuitePrivateKey", - azure: "azureKey", - entra: "entraIdKey", - okta: "oktaToken", - oneLogin: "oneLoginClientSecret", - directoryConfigPrefix: "directoryConfig_", - sync: "syncConfig", - directoryType: "directoryType", - organizationId: "organizationId", -}; - -const Keys: { [key: string]: any } = { - entityId: "entityId", - directoryType: "directoryType", - organizationId: "organizationId", - lastUserSync: "lastUserSync", - lastGroupSync: "lastGroupSync", - lastSyncHash: "lastSyncHash", - syncingDir: "syncingDir", - syncConfig: "syncConfig", - userDelta: "userDeltaToken", - groupDelta: "groupDeltaToken", - tempDirectoryConfigs: "tempDirectoryConfigs", - tempDirectorySettings: "tempDirectorySettings", -}; - -const StateKeys = { - global: "global", - authenticatedAccounts: "authenticatedAccounts", -}; - -const ClientKeys: { [key: string]: any } = { - clientIdOld: "clientId", - clientId: "apikey_clientId", - clientSecretOld: "clientSecret", - clientSecret: "apikey_clientSecret", -}; - -export class StateMigrationService extends BaseStateMigrationService { - async migrate(): Promise { - let currentStateVersion = await this.getCurrentStateVersion(); - while (currentStateVersion < StateVersion.Latest) { - switch (currentStateVersion) { - case StateVersion.One: - await this.migrateClientKeys(); - await this.migrateStateFrom1To2(); - break; - case StateVersion.Two: - await this.migrateStateFrom2To3(); - break; - case StateVersion.Three: - await this.migrateStateFrom3To4(); - break; - case StateVersion.Four: - await this.migrateStateFrom4To5(); - break; - } - currentStateVersion += 1; - } - } - - // TODO: remove this migration when we are confident existing api keys are all migrated. Probably 1-2 releases. - protected async migrateClientKeys() { - const oldClientId = await this.storageService.get(ClientKeys.clientIdOld); - const oldClientSecret = await this.storageService.get(ClientKeys.clientSecretOld); - - if (oldClientId != null) { - await this.storageService.save(ClientKeys.clientId, oldClientId); - await this.storageService.remove(ClientKeys.clientIdOld); - } - - if (oldClientSecret != null) { - await this.storageService.save(ClientKeys.clientSecret, oldClientSecret); - await this.storageService.remove(ClientKeys.clientSecretOld); - } - } - - protected async migrateStateFrom1To2(useSecureStorageForSecrets = true): Promise { - // Grabbing a couple of key settings before they get cleared by the base migration - const userId = await this.get(Keys.entityId); - const clientId = await this.get(ClientKeys.clientId); - const clientSecret = await this.get(ClientKeys.clientSecret); - - await super.migrateStateFrom1To2(); - - // Setup reusable method for clearing keys since we will want to do that regardless of if there is an active authenticated session - const clearDirectoryConnectorV1Keys = async () => { - for (const key in Keys) { - if (key == null) { - continue; - } - for (const directoryType in DirectoryType) { - if (directoryType == null) { - continue; - } - await this.set(SecureStorageKeys.directoryConfigPrefix + directoryType, null); - } - } - }; - - // Initialize typed objects from key/value pairs in storage to either be saved temporarily until an account is authed or applied to the active account - const getDirectoryConfig = async (type: DirectoryType) => - await this.get(SecureStorageKeys.directoryConfigPrefix + type); - const directoryConfigs: DirectoryConfigurations = { - ldap: await getDirectoryConfig(DirectoryType.Ldap), - gsuite: await getDirectoryConfig(DirectoryType.GSuite), - // Azure Active Directory was renamed to Entra ID, but we've kept the old property name - // to be backwards compatible with existing configurations. - azure: await getDirectoryConfig(DirectoryType.EntraID), - entra: await getDirectoryConfig(DirectoryType.EntraID), - okta: await getDirectoryConfig(DirectoryType.Okta), - oneLogin: await getDirectoryConfig(DirectoryType.OneLogin), - }; - - const directorySettings: DirectorySettings = { - directoryType: await this.get(Keys.directoryType), - organizationId: await this.get(Keys.organizationId), - lastUserSync: await this.get(Keys.lastUserSync), - lastGroupSync: await this.get(Keys.lastGroupSync), - lastSyncHash: await this.get(Keys.lastSyncHash), - syncingDir: await this.get(Keys.syncingDir), - sync: await this.get(Keys.syncConfig), - userDelta: await this.get(Keys.userDelta), - groupDelta: await this.get(Keys.groupDelta), - }; - - // (userId == null) = no authed account, stored data temporarily to be applied and cleared on next auth - // (userId != null) = authed account known, applied stored data to it and do not save temp data - if (userId == null) { - await this.set(Keys.tempDirectoryConfigs, directoryConfigs); - await this.set(Keys.tempDirectorySettings, directorySettings); - await clearDirectoryConnectorV1Keys(); - return; - } - - const account = await this.get(userId); - account.directoryConfigurations = directoryConfigs; - account.directorySettings = directorySettings; - account.profile = { - userId: userId, - entityId: userId, - apiKeyClientId: clientId, - }; - account.clientKeys = { - clientId: clientId, - clientSecret: clientSecret, - }; - - await this.set(userId, account); - await clearDirectoryConnectorV1Keys(); - - if (useSecureStorageForSecrets) { - for (const key in SecureStorageKeys) { - if (await this.secureStorageService.has(SecureStorageKeys[key])) { - await this.secureStorageService.save( - `${userId}_${SecureStorageKeys[key]}`, - await this.secureStorageService.get(SecureStorageKeys[key]), - ); - await this.secureStorageService.remove(SecureStorageKeys[key]); - } - } - } - } - protected async migrateStateFrom2To3(useSecureStorageForSecrets = true): Promise { - if (useSecureStorageForSecrets) { - const authenticatedUserIds = await this.get(StateKeys.authenticatedAccounts); - - await Promise.all( - authenticatedUserIds.map(async (userId) => { - const account = await this.get(userId); - - // Fix for userDelta and groupDelta being put into secure storage when they should not have - if (await this.secureStorageService.has(`${userId}_${Keys.userDelta}`)) { - account.directorySettings.userDelta = await this.secureStorageService.get( - `${userId}_${Keys.userDelta}`, - ); - await this.secureStorageService.remove(`${userId}_${Keys.userDelta}`); - } - if (await this.secureStorageService.has(`${userId}_${Keys.groupDelta}`)) { - account.directorySettings.groupDelta = await this.secureStorageService.get( - `${userId}_${Keys.groupDelta}`, - ); - await this.secureStorageService.remove(`${userId}_${Keys.groupDelta}`); - } - await this.set(userId, account); - }), - ); - } - - const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Three; - await this.set(StateKeys.global, globals); - } - - /** - * Migrate from State v4 (Account-based hierarchy) to v5 (flat key-value structure) - * - * This is a clean break from the Account-based structure. Data is extracted from - * the account and saved into flat keys for simpler access. - * - * Old structure: authenticatedAccounts -> userId -> account.directorySettings/directoryConfigurations - * New structure: flat keys like "directoryType", "organizationId", "directory_ldap", etc. - * - * Secrets migrate from: {userId}_{secretKey} -> secret_{secretKey} - */ - protected async migrateStateFrom4To5(useSecureStorageForSecrets = true): Promise { - // Get the authenticated user IDs from v3 structure - const authenticatedUserIds = await this.get(StateKeys.authenticatedAccounts); - - if (!authenticatedUserIds || authenticatedUserIds.length === 0) { - // No accounts to migrate, just update version - const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Four; - await this.set(StateKeys.global, globals); - return; - } - - // DC is single-user, so we take the first (and likely only) account - const userId = authenticatedUserIds[0]; - const account = await this.get(userId); - - if (!account) { - // No account data found, just update version - const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Four; - await this.set(StateKeys.global, globals); - return; - } - - // Migrate directory configurations to flat structure - if (account.directoryConfigurations) { - if (account.directoryConfigurations.ldap) { - await this.set("directory_ldap", account.directoryConfigurations.ldap); - } - if (account.directoryConfigurations.gsuite) { - await this.set("directory_gsuite", account.directoryConfigurations.gsuite); - } - if (account.directoryConfigurations.entra) { - await this.set("directory_entra", account.directoryConfigurations.entra); - } else if (account.directoryConfigurations.azure) { - // Backwards compatibility: migrate azure to entra - await this.set("directory_entra", account.directoryConfigurations.azure); - } - if (account.directoryConfigurations.okta) { - await this.set("directory_okta", account.directoryConfigurations.okta); - } - if (account.directoryConfigurations.oneLogin) { - await this.set("directory_onelogin", account.directoryConfigurations.oneLogin); - } - } - - // Migrate directory settings to flat structure - if (account.directorySettings) { - if (account.directorySettings.organizationId) { - await this.set("organizationId", account.directorySettings.organizationId); - } - if (account.directorySettings.directoryType != null) { - await this.set("directoryType", account.directorySettings.directoryType); - } - if (account.directorySettings.sync) { - await this.set("sync", account.directorySettings.sync); - } - if (account.directorySettings.lastUserSync) { - await this.set("lastUserSync", account.directorySettings.lastUserSync); - } - if (account.directorySettings.lastGroupSync) { - await this.set("lastGroupSync", account.directorySettings.lastGroupSync); - } - if (account.directorySettings.lastSyncHash) { - await this.set("lastSyncHash", account.directorySettings.lastSyncHash); - } - if (account.directorySettings.userDelta) { - await this.set("userDelta", account.directorySettings.userDelta); - } - if (account.directorySettings.groupDelta) { - await this.set("groupDelta", account.directorySettings.groupDelta); - } - if (account.directorySettings.syncingDir != null) { - await this.set("syncingDir", account.directorySettings.syncingDir); - } - } - - // Migrate secrets from {userId}_* to secret_* pattern - if (useSecureStorageForSecrets) { - const oldSecretKeys = [ - { old: `${userId}_${SecureStorageKeys.ldap}`, new: "secret_ldap" }, - { old: `${userId}_${SecureStorageKeys.gsuite}`, new: "secret_gsuite" }, - { old: `${userId}_${SecureStorageKeys.azure}`, new: "secret_azure" }, - { old: `${userId}_${SecureStorageKeys.entra}`, new: "secret_entra" }, - { old: `${userId}_${SecureStorageKeys.okta}`, new: "secret_okta" }, - { old: `${userId}_${SecureStorageKeys.oneLogin}`, new: "secret_onelogin" }, - ]; - - for (const { old: oldKey, new: newKey } of oldSecretKeys) { - if (await this.secureStorageService.has(oldKey)) { - const value = await this.secureStorageService.get(oldKey); - if (value) { - await this.secureStorageService.save(newKey, value); - } - } - } - } - - const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Five; - await this.set(StateKeys.global, globals); - } -} From 9f8018e8f878c56b781893baeed72c59b61297f7 Mon Sep 17 00:00:00 2001 From: Brandon Date: Thu, 5 Feb 2026 11:53:24 -0500 Subject: [PATCH 3/4] fix type issues --- .../state-service/state-vNext.service.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/services/state-service/state-vNext.service.spec.ts b/src/services/state-service/state-vNext.service.spec.ts index d3cb8e7a..7f144737 100644 --- a/src/services/state-service/state-vNext.service.spec.ts +++ b/src/services/state-service/state-vNext.service.spec.ts @@ -110,13 +110,20 @@ describe("StateServiceVNextImplementation", () => { const config: LdapConfiguration = { ssl: true, startTls: false, + tlsCaPath: null, sslAllowUnauthorized: false, + sslCertPath: null, + sslKeyPath: null, + sslCaPath: null, hostname: "ldap.example.com", port: 636, + domain: null, + rootPath: null, ad: true, username: "admin", password: "secret-password", currentUser: false, + pagedSearch: true, }; secureStorageService.get.mockResolvedValue("secret-password"); @@ -150,13 +157,20 @@ describe("StateServiceVNextImplementation", () => { const config: LdapConfiguration = { ssl: true, startTls: false, + tlsCaPath: null, sslAllowUnauthorized: false, + sslCertPath: null, + sslKeyPath: null, + sslCaPath: null, hostname: "ldap.example.com", port: 636, + domain: null, + rootPath: null, ad: true, username: "admin", password: null, currentUser: false, + pagedSearch: true, }; await stateService.setDirectory(DirectoryType.Ldap, config); @@ -173,6 +187,7 @@ describe("StateServiceVNextImplementation", () => { clientEmail: "service@example.com", adminUser: "admin@example.com", privateKey: "private-key-content", + customer: "customer-id", }; secureStorageService.get.mockResolvedValue("private-key-content"); @@ -194,6 +209,7 @@ describe("StateServiceVNextImplementation", () => { clientEmail: "service@example.com", adminUser: "admin@example.com", privateKey: null, + customer: "customer-id", }; await stateService.setDirectory(DirectoryType.GSuite, config); @@ -206,6 +222,7 @@ describe("StateServiceVNextImplementation", () => { describe("Entra ID Configuration", () => { it("should store and retrieve Entra ID configuration with key in secure storage", async () => { const config: EntraIdConfiguration = { + identityAuthority: "https://login.microsoftonline.com", tenant: "tenant-id", applicationId: "app-id", key: "secret-key", @@ -226,6 +243,7 @@ describe("StateServiceVNextImplementation", () => { it("should maintain backwards compatibility with Azure key storage", async () => { const config: EntraIdConfiguration = { + identityAuthority: "https://login.microsoftonline.com", tenant: "tenant-id", applicationId: "app-id", key: StoredSecurely, @@ -288,14 +306,26 @@ describe("StateServiceVNextImplementation", () => { describe("Sync Configuration", () => { it("should store and retrieve sync configuration", async () => { const syncConfig: SyncConfiguration = { + users: true, + groups: true, + interval: 5, + userFilter: null, + groupFilter: null, removeDisabled: true, overwriteExisting: false, largeImport: false, + groupObjectClass: null, + userObjectClass: null, + groupPath: null, + userPath: null, + groupNameAttribute: null, + userEmailAttribute: null, memberAttribute: "member", creationDateAttribute: "whenCreated", revisionDateAttribute: "whenChanged", useEmailPrefixSuffix: false, emailPrefixAttribute: null, + emailSuffix: null, }; storageService.get.mockResolvedValue(syncConfig); @@ -426,13 +456,20 @@ describe("StateServiceVNextImplementation", () => { const config: LdapConfiguration = { ssl: true, startTls: false, + tlsCaPath: null, sslAllowUnauthorized: false, + sslCertPath: null, + sslKeyPath: null, + sslCaPath: null, hostname: "ldap.example.com", port: 636, + domain: null, + rootPath: null, ad: true, username: "admin", password: "secret-password", currentUser: false, + pagedSearch: true, }; storageService.get.mockResolvedValue(config); From a0e74948bd3dc3213f53e539d01d9ebe530144f8 Mon Sep 17 00:00:00 2001 From: Brandon Date: Thu, 5 Feb 2026 12:04:41 -0500 Subject: [PATCH 4/4] fix integration test --- .../gsuite-directory.service.integration.spec.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/services/directory-services/gsuite-directory.service.integration.spec.ts b/src/services/directory-services/gsuite-directory.service.integration.spec.ts index b2f567d5..07521a19 100644 --- a/src/services/directory-services/gsuite-directory.service.integration.spec.ts +++ b/src/services/directory-services/gsuite-directory.service.integration.spec.ts @@ -47,7 +47,7 @@ describe("gsuiteDirectoryService", () => { stateService = mock(); stateServiceVNext = mock(); - stateService.getDirectoryType.mockResolvedValue(DirectoryType.GSuite); + stateServiceVNext.getDirectoryType.mockResolvedValue(DirectoryType.GSuite); stateService.getLastUserSync.mockResolvedValue(null); // do not filter results by last modified date i18nService.t.mockImplementation((id) => id); // passthrough implementation for any error messages @@ -61,13 +61,15 @@ describe("gsuiteDirectoryService", () => { it("syncs without using filters (includes test data)", async () => { const directoryConfig = getGSuiteConfiguration(); - stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + stateServiceVNext.getDirectory + .calledWith(DirectoryType.GSuite) + .mockResolvedValue(directoryConfig); const syncConfig = getSyncConfiguration({ groups: true, users: true, }); - stateService.getSync.mockResolvedValue(syncConfig); + stateServiceVNext.getSync.mockResolvedValue(syncConfig); const result = await directoryService.getEntries(true, true); @@ -77,7 +79,9 @@ describe("gsuiteDirectoryService", () => { it("syncs using user and group filters (exact match for test data)", async () => { const directoryConfig = getGSuiteConfiguration(); - stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + stateServiceVNext.getDirectory + .calledWith(DirectoryType.GSuite) + .mockResolvedValue(directoryConfig); const syncConfig = getSyncConfiguration({ groups: true, @@ -85,7 +89,7 @@ describe("gsuiteDirectoryService", () => { userFilter: INTEGRATION_USER_FILTER, groupFilter: INTEGRATION_GROUP_FILTER, }); - stateService.getSync.mockResolvedValue(syncConfig); + stateServiceVNext.getSync.mockResolvedValue(syncConfig); const result = await directoryService.getEntries(true, true);