diff --git a/.changeset/empty-queens-float.md b/.changeset/empty-queens-float.md new file mode 100644 index 000000000..7e862a352 --- /dev/null +++ b/.changeset/empty-queens-float.md @@ -0,0 +1,5 @@ +--- + +## '@forgerock/effects': minor + +add token and local/session storage manager diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e2e60279..055887b22 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,6 @@ import js from '@eslint/js'; import packageJson from 'eslint-plugin-package-json/configs/recommended'; import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin'; import nxEslintPlugin from '@nx/eslint-plugin'; -import eslintPluginImport from 'eslint-plugin-import'; import typescriptEslintParser from '@typescript-eslint/parser'; const compat = new FlatCompat({ @@ -22,7 +21,6 @@ export default [ plugins: { '@typescript-eslint': typescriptEslintEslintPlugin, '@nx': nxEslintPlugin, - import: eslintPluginImport, }, }, { @@ -36,7 +34,6 @@ export default [ }, { rules: { - 'import/extensions': [2, 'ignorePackages'], '@typescript-eslint/indent': ['error', 2], '@typescript-eslint/no-use-before-define': 'warn', 'max-len': [ @@ -57,7 +54,19 @@ export default [ { files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], rules: { - 'import/extensions': [2, 'ignorePackages'], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + '@nx/enforce-module-boundaries': [ 'warn', { @@ -102,12 +111,6 @@ export default [ files: ['**/*.ts', '**/*.tsx', '!**/*.spec.ts', '!**/*.test*.ts', '**/*.cts', '**/*.mts'], rules: { ...config.rules, - '@typescript-eslint/no-unused-vars': [ - 'error', - { - ignoreRestSiblings: true, - }, - ], }, })), ...compat diff --git a/nx.json b/nx.json index ca10a2e4d..dd14808f4 100644 --- a/nx.json +++ b/nx.json @@ -70,7 +70,7 @@ }, "test": { "inputs": ["default", "^default", "noMarkdown", "^noMarkdown"], - "dependsOn": ["^test", "^build"], + "dependsOn": ["^test", "^build", "^build", "^build"], "outputs": ["{projectRoot}/coverage"], "cache": true }, diff --git a/packages/device-client/eslint.config.mjs b/packages/device-client/eslint.config.mjs index 7cbfa2e3e..0defbee6c 100644 --- a/packages/device-client/eslint.config.mjs +++ b/packages/device-client/eslint.config.mjs @@ -1,14 +1,5 @@ -import { FlatCompat } from '@eslint/eslintrc'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; -import js from '@eslint/js'; import baseConfig from '../../eslint.config.mjs'; -const compat = new FlatCompat({ - baseDirectory: dirname(fileURLToPath(import.meta.url)), - recommendedConfig: js.configs.recommended, -}); - export default [ { ignores: ['**/dist'], diff --git a/packages/device-client/package.json b/packages/device-client/package.json index 4e12099c1..d9cdf9c0b 100644 --- a/packages/device-client/package.json +++ b/packages/device-client/package.json @@ -2,6 +2,11 @@ "name": "@forgerock/device-client", "version": "0.0.1", "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/ForgeRock/ping-javascript-sdk.git", + "directory": "packages/device-client" + }, "sideEffects": false, "type": "module", "exports": { diff --git a/packages/effects/src/lib/local-storage.test.ts b/packages/effects/src/lib/local-storage.test.ts new file mode 100644 index 000000000..a93c3b659 --- /dev/null +++ b/packages/effects/src/lib/local-storage.test.ts @@ -0,0 +1,136 @@ +/** + * + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; +import { + getLocalStorageTokens, + setLocalStorageTokens, + removeTokensFromLocalStorage, + tokenFactory, +} from './local-storage.js'; +import { TOKEN_ERRORS, type ConfigOptions, type Tokens } from '@forgerock/shared-types'; + +describe('Token Storage Functions', () => { + // Mock config + const mockConfig: ConfigOptions = { + clientId: 'test-client', + prefix: 'test-prefix', + }; + + // Sample tokens + const sampleTokens: Tokens = { + accessToken: 'access-token-123', + idToken: 'id-token-456', + refreshToken: 'refresh-token-789', + tokenExpiry: 3600, + }; + + beforeEach(() => { + vi.clearAllMocks(); + const mockLocalStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + const mockSessionStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + + vi.stubGlobal('localStorage', mockLocalStorage); + vi.stubGlobal('sessionStorage', mockSessionStorage); + }); + + afterEach(() => { + // Restore the original implementations after tests + vi.unstubAllGlobals(); + }); + + describe('getLocalStorageTokens', () => { + it('should return undefined when no tokens exist', () => { + const tokens = getLocalStorageTokens(mockConfig); + + expect(localStorage.getItem).toHaveBeenCalledWith('test-prefix-test-client'); + expect(tokens).toEqual({ + error: TOKEN_ERRORS.NO_TOKENS_FOUND_LOCAL_STORAGE, + }); + }); + + it('should parse and return tokens when they exist', () => { + getLocalStorageTokens(mockConfig); + expect(localStorage.getItem).toHaveBeenCalledWith('test-prefix-test-client'); + }); + + it('should return error object when tokens exist but cannot be parsed', () => { + const result = getLocalStorageTokens(mockConfig); + + expect(localStorage.getItem).toHaveBeenCalledWith('test-prefix-test-client'); + expect(result).toEqual({ error: TOKEN_ERRORS.NO_TOKENS_FOUND_LOCAL_STORAGE }); + }); + }); + + describe('setTokens', () => { + it('should stringify and store tokens in localStorage', () => { + setLocalStorageTokens(mockConfig, sampleTokens); + + expect(localStorage.setItem).toHaveBeenCalledWith( + 'test-prefix-test-client', + JSON.stringify(sampleTokens), + ); + }); + }); + + describe('removeTokens', () => { + it('should remove tokens from localStorage', () => { + removeTokensFromLocalStorage(mockConfig); + + expect(localStorage.removeItem).toHaveBeenCalledWith('test-prefix-test-client'); + }); + }); + + describe('tokenFactory', () => { + it('should return an object with get, set, and remove methods', () => { + const tokenManager = tokenFactory(mockConfig); + + expect(tokenManager).toHaveProperty('get'); + expect(tokenManager).toHaveProperty('set'); + expect(tokenManager).toHaveProperty('remove'); + expect(typeof tokenManager.get).toBe('function'); + expect(typeof tokenManager.set).toBe('function'); + expect(typeof tokenManager.remove).toBe('function'); + }); + + it('get method should retrieve tokens', () => { + (localStorage.getItem as Mock).mockReturnValueOnce(JSON.stringify(sampleTokens)); + + const tokenManager = tokenFactory(mockConfig); + const tokens = tokenManager.get(); + + expect(localStorage.getItem).toHaveBeenCalledWith('test-prefix-test-client'); + expect(tokens).toEqual(sampleTokens); + }); + + it('set method should store tokens', () => { + const tokenManager = tokenFactory(mockConfig); + tokenManager.set(sampleTokens); + + expect(localStorage.setItem).toHaveBeenCalledWith( + 'test-prefix-test-client', + JSON.stringify(sampleTokens), + ); + }); + + it('remove method should remove tokens', () => { + const tokenManager = tokenFactory(mockConfig); + tokenManager.remove(); + + expect(localStorage.removeItem).toHaveBeenCalledWith('test-prefix-test-client'); + }); + }); +}); diff --git a/packages/effects/src/lib/local-storage.ts b/packages/effects/src/lib/local-storage.ts new file mode 100644 index 000000000..50f99e010 --- /dev/null +++ b/packages/effects/src/lib/local-storage.ts @@ -0,0 +1,43 @@ +/** + * + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { TOKEN_ERRORS, type ConfigOptions, type Tokens } from '@forgerock/shared-types'; + +export function getLocalStorageTokens(Config: ConfigOptions) { + const tokenString = localStorage.getItem(`${Config.prefix}-${Config.clientId}`); + if (!tokenString) { + return { + error: TOKEN_ERRORS.NO_TOKENS_FOUND_LOCAL_STORAGE, + }; + } + try { + const tokens = JSON.parse(tokenString) as Tokens; + return tokens; + } catch { + return { + error: TOKEN_ERRORS.PARSE_LOCAL_STORAGE, + }; + } +} + +export function setLocalStorageTokens(Config: ConfigOptions, tokens: Tokens) { + const tokenString = JSON.stringify(tokens); + localStorage.setItem(`${Config.prefix}-${Config.clientId}`, tokenString); +} + +export function removeTokensFromLocalStorage(Config: ConfigOptions) { + localStorage.removeItem(`${Config.prefix}-${Config.clientId}`); +} + +export function tokenFactory(config: ConfigOptions) { + return { + get: () => getLocalStorageTokens(config), + set: (tokens: Tokens) => setLocalStorageTokens(config, tokens), + remove: () => removeTokensFromLocalStorage(config), + }; +} diff --git a/packages/effects/src/lib/request.mock.ts b/packages/effects/src/lib/request.mock.ts index 814184723..f8b099325 100644 --- a/packages/effects/src/lib/request.mock.ts +++ b/packages/effects/src/lib/request.mock.ts @@ -98,7 +98,7 @@ const middleware: RequestMiddleware<ActionTypes>[] = [ }, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - (req: ModifiedFetchArgs, action: Action, next: NextFn): void => { + (_req: ModifiedFetchArgs, action: Action, next: NextFn): void => { switch (action.type) { case mutateAction: action.type = 'hello' as ActionTypes; diff --git a/packages/effects/src/lib/session-storage.test.ts b/packages/effects/src/lib/session-storage.test.ts new file mode 100644 index 000000000..ef5c81765 --- /dev/null +++ b/packages/effects/src/lib/session-storage.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. + * All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + getSessionStorage, + setSessionStorage, + removeKeyFromSessionStorage, + sessionStorageFactory, +} from './session-storage.js'; +import { TOKEN_ERRORS, type ConfigOptions, type Tokens } from '@forgerock/shared-types'; + +// Create a mock sessionStorage object for tests +const createMockSessionStorage = () => { + const sessionStorageMock = { + getItem: vi.fn((key: string) => sessionStorageMock.store[key] || null), + setItem: vi.fn((key: string, value: string) => { + sessionStorageMock.store[key] = value.toString(); + }), + removeItem: vi.fn((key: string) => { + delete sessionStorageMock.store[key]; + }), + clear: vi.fn(() => { + sessionStorageMock.store = {}; + }), + store: {} as Record<string, string>, + }; + return sessionStorageMock; +}; + +describe('Session Token Storage Functions', () => { + let sessionStorageMock: ReturnType<typeof createMockSessionStorage>; + + // Mock config + const mockConfig: ConfigOptions = { + clientId: 'test-client', + prefix: 'test-prefix', + }; + + // Sample tokens + const sampleTokens: Tokens = { + accessToken: 'access-token-123', + idToken: 'id-token-456', + refreshToken: 'refresh-token-789', + tokenExpiry: 3600, + }; + + beforeEach(() => { + // Setup mock sessionStorage + sessionStorageMock = createMockSessionStorage(); + + // Mock the sessionStorage globally + vi.stubGlobal('sessionStorage', sessionStorageMock); + + // Clear mock calls before each test + vi.clearAllMocks(); + }); + + afterEach(() => { + // Restore the original implementations after tests + vi.unstubAllGlobals(); + }); + + describe('getSessionStorage', () => { + it('should return undefined when no tokens exist', () => { + const tokens = getSessionStorage(mockConfig); + + expect(sessionStorageMock.getItem).toHaveBeenCalledWith('test-prefix-test-client'); + expect(tokens).toEqual({ error: TOKEN_ERRORS.NO_TOKENS_FOUND_SESSION_STORAGE }); + }); + + it('should parse and return tokens when they exist', () => { + // This test will actually fail because there's a bug in the implementation + // The parsed tokens are not returned in the getSessionStorage function + sessionStorageMock.getItem.mockReturnValueOnce(JSON.stringify(sampleTokens)); + + const tokens = getSessionStorage(mockConfig); + + expect(sessionStorageMock.getItem).toHaveBeenCalledWith('test-prefix-test-client'); + // This will fail because of the bug, but we're keeping it to show the issue + expect(tokens).toEqual(sampleTokens); + }); + + it('should return error object when tokens exist but cannot be parsed', () => { + sessionStorageMock.getItem.mockReturnValueOnce('invalid-json'); + + const result = getSessionStorage(mockConfig); + + expect(sessionStorageMock.getItem).toHaveBeenCalledWith('test-prefix-test-client'); + expect(result).toEqual({ error: TOKEN_ERRORS.PARSE_SESSION_STORAGE }); + }); + }); + + describe('setSessionStorage', () => { + it('should stringify and store tokens in sessionStorage', () => { + setSessionStorage(mockConfig, sampleTokens); + + expect(sessionStorageMock.setItem).toHaveBeenCalledWith( + 'test-prefix-test-client', + JSON.stringify(sampleTokens), + ); + }); + }); + + describe('removeKeyFromSessionStorage', () => { + it('should remove tokens from sessionStorage', () => { + removeKeyFromSessionStorage(mockConfig); + + expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('test-prefix-test-client'); + }); + }); + + describe('sessionStorageFactory', () => { + it('should return an object with get, set, and remove methods', () => { + const tokenManager = sessionStorageFactory(mockConfig); + + expect(tokenManager).toHaveProperty('get'); + expect(tokenManager).toHaveProperty('set'); + expect(tokenManager).toHaveProperty('remove'); + expect(typeof tokenManager.get).toBe('function'); + expect(typeof tokenManager.set).toBe('function'); + expect(typeof tokenManager.remove).toBe('function'); + }); + + it('get method should retrieve tokens', () => { + // This will also fail due to the bug in getSessionStorage + sessionStorageMock.getItem.mockReturnValueOnce(JSON.stringify(sampleTokens)); + + const tokenManager = sessionStorageFactory(mockConfig); + const tokens = tokenManager.get(); + + expect(sessionStorageMock.getItem).toHaveBeenCalledWith('test-prefix-test-client'); + expect(tokens).toEqual(sampleTokens); + }); + + it('set method should store tokens', () => { + const tokenManager = sessionStorageFactory(mockConfig); + tokenManager.set(sampleTokens); + + expect(sessionStorageMock.setItem).toHaveBeenCalledWith( + 'test-prefix-test-client', + JSON.stringify(sampleTokens), + ); + }); + + it('remove method should remove tokens', () => { + const tokenManager = sessionStorageFactory(mockConfig); + tokenManager.remove(); + + expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('test-prefix-test-client'); + }); + }); +}); diff --git a/packages/effects/src/lib/session-storage.ts b/packages/effects/src/lib/session-storage.ts new file mode 100644 index 000000000..109555989 --- /dev/null +++ b/packages/effects/src/lib/session-storage.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. + + * All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type { ConfigOptions, Tokens } from '@forgerock/shared-types'; +import { TOKEN_ERRORS } from '@forgerock/shared-types'; + +export function getSessionStorage(config: ConfigOptions) { + const tokenString = sessionStorage.getItem(`${config.prefix}-${config.clientId}`); + if (!tokenString) { + return { + error: TOKEN_ERRORS.NO_TOKENS_FOUND_SESSION_STORAGE, + }; + } + try { + const tokens = JSON.parse(tokenString) as Tokens; + return tokens; + } catch { + return { + error: TOKEN_ERRORS.PARSE_SESSION_STORAGE, + }; + } +} + +export function setSessionStorage(config: ConfigOptions, tokens: Tokens) { + const tokenString = JSON.stringify(tokens); + sessionStorage.setItem(`${config.prefix}-${config.clientId}`, tokenString); +} + +export function removeKeyFromSessionStorage(config: ConfigOptions) { + sessionStorage.removeItem(`${config.prefix}-${config.clientId}`); +} + +export function sessionStorageFactory(config: ConfigOptions) { + return { + get: () => getSessionStorage(config), + set: (tokens: Tokens) => setSessionStorage(config, tokens), + remove: () => removeKeyFromSessionStorage(config), + }; +} diff --git a/packages/effects/src/lib/token-storage.test.ts b/packages/effects/src/lib/token-storage.test.ts new file mode 100644 index 000000000..459aac2b0 --- /dev/null +++ b/packages/effects/src/lib/token-storage.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + TOKEN_ERRORS, + type ConfigOptions, + type TokenStoreObject, + type Tokens, +} from '@forgerock/shared-types'; +import { getTokens, setTokens, removeTokens, tokenStorageFactory } from './token-storage.js'; +import * as sessionStorage from './session-storage.js'; +import * as localStorage from './local-storage.js'; + +// Mock the storage modules +vi.mock('./session-storage.js', () => ({ + getSessionStorage: vi.fn(), + setSessionStorage: vi.fn(), + removeKeyFromSessionStorage: vi.fn(), +})); + +vi.mock('./local-storage.js', () => ({ + getLocalStorageTokens: vi.fn(), + setLocalStorageTokens: vi.fn(), + removeTokensFromLocalStorage: vi.fn(), +})); + +describe('token-storage', () => { + const mockTokens: Tokens = { accessToken: 'test-access-token' }; + const mockConfig: ConfigOptions = { + clientId: 'test-client', + tokenStore: 'localStorage' as const, + }; + + // Fix: Update mockTokenStore to match TokenStoreObject interface exactly + const mockTokenStore: TokenStoreObject = { + get: vi.fn().mockImplementation(async (_clientId: string) => mockTokens), + set: vi.fn().mockImplementation(async (_clientId: string, _tokens: Tokens) => undefined), + remove: vi.fn().mockImplementation(async (_clientId: string) => undefined), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getTokens', () => { + it('should get tokens from custom tokenStore when provided', async () => { + const result = await getTokens(mockConfig, mockTokenStore); + expect(result).toEqual(mockTokens); + expect(mockTokenStore.get).toHaveBeenCalledWith('test-client'); + }); + + // Fix: Update configWithoutClientId to use correct type + it('should return error when clientId is missing with custom tokenStore', async () => { + const configWithoutClientId: ConfigOptions = { + tokenStore: { + get: mockTokenStore.get, + set: mockTokenStore.set, + remove: mockTokenStore.remove, + }, + }; + const result = await getTokens(configWithoutClientId, mockTokenStore); + expect(result).toEqual({ error: TOKEN_ERRORS.CLIENT_ID_REQUIRED }); + }); + + it('should use localStorage by default', async () => { + const configWithoutStore: ConfigOptions = { clientId: 'test-client' }; + await getTokens(configWithoutStore); + expect(localStorage.getLocalStorageTokens).toHaveBeenCalledWith(configWithoutStore); + }); + + it('should use sessionStorage when specified', async () => { + const sessionConfig: ConfigOptions = { + clientId: 'test-client', + tokenStore: 'sessionStorage', + }; + await getTokens(sessionConfig); + expect(sessionStorage.getSessionStorage).toHaveBeenCalledWith(sessionConfig); + }); + }); + + describe('setTokens', () => { + it('should set tokens in custom tokenStore', async () => { + const result = await setTokens(mockTokens, mockConfig, mockTokenStore); + expect(result).toBeUndefined(); + expect(mockTokenStore.set).toHaveBeenCalledWith('test-client', mockTokens); + }); + + // Fix: Update configWithoutClientId to use correct type + it('should return error when clientId is missing with custom tokenStore', async () => { + const configWithoutClientId: ConfigOptions = { + tokenStore: { + get: mockTokenStore.get, + set: mockTokenStore.set, + remove: mockTokenStore.remove, + }, + }; + const result = await setTokens(mockTokens, configWithoutClientId, mockTokenStore); + expect(result).toEqual({ error: TOKEN_ERRORS.CLIENT_ID_REQUIRED }); + }); + + it('should use localStorage when specified', async () => { + await setTokens(mockTokens, mockConfig); + expect(localStorage.setLocalStorageTokens).toHaveBeenCalledWith(mockConfig, mockTokens); + }); + + it('should use sessionStorage when specified', async () => { + const sessionConfig: ConfigOptions = { + clientId: 'test-client', + tokenStore: 'sessionStorage', + }; + await setTokens(mockTokens, sessionConfig); + expect(sessionStorage.setSessionStorage).toHaveBeenCalledWith(sessionConfig, mockTokens); + }); + }); + + describe('removeTokens', () => { + it('should remove tokens from custom tokenStore', async () => { + const result = await removeTokens(mockConfig, mockTokenStore); + expect(result).toBeUndefined(); + expect(mockTokenStore.remove).toHaveBeenCalledWith('test-client'); + }); + + // Fix: Update configWithoutClientId to use correct type + it('should return error when clientId is missing with custom tokenStore', async () => { + const configWithoutClientId: ConfigOptions = { + tokenStore: { + get: mockTokenStore.get, + set: mockTokenStore.set, + remove: mockTokenStore.remove, + }, + }; + const result = await removeTokens(configWithoutClientId, mockTokenStore); + expect(result).toEqual({ error: TOKEN_ERRORS.CLIENT_ID_REQUIRED }); + }); + + it('should use localStorage when specified', async () => { + await removeTokens(mockConfig); + expect(localStorage.removeTokensFromLocalStorage).toHaveBeenCalledWith(mockConfig); + }); + + it('should use sessionStorage when specified', async () => { + const sessionConfig: ConfigOptions = { + clientId: 'test-client', + tokenStore: 'sessionStorage', + }; + await removeTokens(sessionConfig); + expect(sessionStorage.removeKeyFromSessionStorage).toHaveBeenCalledWith(sessionConfig); + }); + + it('should return error for invalid token store type', async () => { + const invalidConfig: ConfigOptions = { + clientId: 'test-client', + // @ts-expect-error: Testing invalid tokenStore type + tokenStore: 'invalid', + }; + const result = await removeTokens(invalidConfig); + expect(result).toEqual({ + error: TOKEN_ERRORS.INVALID_STORE, + }); + }); + }); + + describe('tokenStorageFactory', () => { + it('should return an object with get, set, and remove methods', () => { + const tokenStorage = tokenStorageFactory(mockConfig); + expect(tokenStorage).toHaveProperty('get'); + expect(tokenStorage).toHaveProperty('set'); + expect(tokenStorage).toHaveProperty('remove'); + expect(typeof tokenStorage.get).toBe('function'); + expect(typeof tokenStorage.set).toBe('function'); + expect(typeof tokenStorage.remove).toBe('function'); + }); + }); +}); diff --git a/packages/effects/src/lib/token-storage.ts b/packages/effects/src/lib/token-storage.ts new file mode 100644 index 000000000..fd1b95d76 --- /dev/null +++ b/packages/effects/src/lib/token-storage.ts @@ -0,0 +1,128 @@ +import type { ConfigOptions, TokensError, TokenStoreObject } from '@forgerock/shared-types'; +import type { Tokens } from '@forgerock/shared-types'; +import { TOKEN_ERRORS } from '@forgerock/shared-types'; + +import { + getSessionStorage, + removeKeyFromSessionStorage, + setSessionStorage, +} from './session-storage.js'; +import { + getLocalStorageTokens, + removeTokensFromLocalStorage, + setLocalStorageTokens, +} from './local-storage.js'; + +export type TokenStoreType = 'localStorage' | 'sessionStorage' | 'custom'; + +/** + * Gets tokens from the specified storage. + * @param config - Configuration options including clientId and storage type + * @param tokenStore - Optional custom token store implementation + * @returns Promise resolving to tokens or error + * @throws Never - Returns errors as objects instead + */ +export async function getTokens( + config: ConfigOptions, + tokenStore: TokenStoreObject, +): Promise<Tokens | TokensError>; +export async function getTokens(config: ConfigOptions): Promise<Tokens | TokensError>; +export async function getTokens( + config: ConfigOptions, + maybeTokenStore?: TokenStoreObject, +): Promise<Tokens | TokensError> { + if (maybeTokenStore) { + const clientId = config.clientId; + if (!clientId) { + return Promise.resolve({ error: 'Client ID is required.' }); + } + return maybeTokenStore.get(clientId); + } + + const tokenStore = config.tokenStore; + + switch (tokenStore) { + case 'sessionStorage': + return getSessionStorage(config); + case 'localStorage': + return getLocalStorageTokens(config); + default: + return getLocalStorageTokens(config); + } +} + +export function setTokens(tokens: Tokens, config: ConfigOptions): Promise<void>; +export function setTokens( + tokens: Tokens, + config: ConfigOptions, + tokenStore: TokenStoreObject, +): Promise<void | TokensError>; +export async function setTokens( + tokens: Tokens, + config: ConfigOptions, + tokenStore?: TokenStoreObject, +): Promise<void | TokensError> { + if (tokenStore) { + if (!config.clientId) { + return { error: 'Client ID is required.' }; + } + await tokenStore.set(config.clientId, tokens); + } else { + // Enforce sync-compatible config at runtime + if (config.tokenStore !== 'localStorage' && config.tokenStore !== 'sessionStorage') { + return { error: TOKEN_ERRORS.INVALID_STORE }; + } + // Wrap sync ops in Promise.resolve for consistency + if (config.tokenStore === 'sessionStorage') { + await Promise.resolve(setSessionStorage(config, tokens)); + } else { + await Promise.resolve(setLocalStorageTokens(config, tokens)); + } + } +} +/** + * Removes stored tokens. + */ +export async function removeTokens(config: ConfigOptions): Promise<void | TokensError>; +export async function removeTokens( + config: ConfigOptions, + tokenStore: TokenStoreObject, +): Promise<void | TokensError>; +export async function removeTokens( + config: ConfigOptions, + tokenStore?: TokenStoreObject, +): Promise<void | TokensError> { + if (!tokenStore) { + if (config.tokenStore === 'sessionStorage') { + return await removeKeyFromSessionStorage(config); + } else if (config.tokenStore === 'localStorage') { + return await removeTokensFromLocalStorage(config); + } else { + return Promise.resolve({ + error: TOKEN_ERRORS.INVALID_STORE, + }); + } + } else { + if (!config.clientId) { + return Promise.resolve({ + error: TOKEN_ERRORS.CLIENT_ID_REQUIRED, + }); + } + return await tokenStore.remove(config.clientId); + } +} + +export function tokenStorageFactory(config: ConfigOptions): TokenStoreObject { + return { + get: async (clientId: string) => getTokens({ ...config, clientId }), + set: async (clientId: string, tokens: Tokens) => setTokens(tokens, { ...config, clientId }), + remove: async (clientId: string) => removeTokens({ ...config, clientId }), + }; +} + +// Default export for backward compatibility +export default { + get: getTokens, + set: setTokens, + remove: removeTokens, +}; diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index 5bdc9a7ba..c3bd71c39 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -2,6 +2,11 @@ "name": "@forgerock/shared-types", "version": "0.0.1", "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/ForgeRock/ping-javascript-sdk.git", + "directory": "packages/shared-types" + }, "type": "module", "exports": { ".": { diff --git a/packages/shared-types/src/lib/config.types.ts b/packages/shared-types/src/lib/config.types.ts index ef411616f..0925d6eeb 100644 --- a/packages/shared-types/src/lib/config.types.ts +++ b/packages/shared-types/src/lib/config.types.ts @@ -1,6 +1,6 @@ import { Callback } from './callback.types.js'; import { RequestMiddleware } from './shared-types.js'; -import { Tokens } from './tokens.types.js'; +import { TokenStoreObject } from './tokens.types.js'; /** * Async ConfigOptions for well-known endpoint usage @@ -39,15 +39,6 @@ export interface AsyncServerConfig extends Omit<ServerConfig, 'baseUrl'> { wellknown?: string; } -/** - * API for implementing a custom token store - */ -export interface TokenStoreObject { - get: (clientId: string) => Promise<Tokens>; - set: (clientId: string, token: Tokens) => Promise<void>; - remove: (clientId: string) => Promise<void>; -} - /** * Configuration options with a server configuration specified. */ diff --git a/packages/shared-types/src/lib/tokens.types.ts b/packages/shared-types/src/lib/tokens.types.ts index 4d5bf8ab6..c3b25afe7 100644 --- a/packages/shared-types/src/lib/tokens.types.ts +++ b/packages/shared-types/src/lib/tokens.types.ts @@ -4,3 +4,29 @@ export interface Tokens { refreshToken?: string; tokenExpiry?: number; } + +export interface TokensError { + error: TOKEN_ERRORS; +} + +export const TOKEN_ERRORS = { + CLIENT_ID_REQUIRED: 'Client ID is required.', + INVALID_STORE: 'Invalid token store type. Expected "local storage" or "sessionStorage".', + STORAGE_REQUIRED: + 'Local storage or session storage is required when not passing in a custom store', + PARSE_LOCAL_STORAGE: 'Could not parse token from local storage', + PARSE_SESSION_STORAGE: 'Could not parse token from session storage', + NO_TOKENS_FOUND_SESSION_STORAGE: `No tokens found in session storage`, + NO_TOKENS_FOUND_LOCAL_STORAGE: `No tokens found in local storage`, +} as const; + +export type TOKEN_ERRORS = (typeof TOKEN_ERRORS)[keyof typeof TOKEN_ERRORS]; + +/** + * API for implementing a custom token store + */ +export interface TokenStoreObject { + get: (clientId: string) => Promise<Tokens | TokensError>; + set: (clientId: string, token: Tokens) => Promise<void>; + remove: (clientId: string) => Promise<void | TokensError>; +}