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); - } -}