diff --git a/workers/main/package-lock.json b/workers/main/package-lock.json index 035b520..fb3574b 100644 --- a/workers/main/package-lock.json +++ b/workers/main/package-lock.json @@ -18,12 +18,14 @@ "axios-retry": "4.5.0", "mongoose": "8.15.1", "mysql2": "3.14.1", + "simple-oauth2": "5.1.0", "zod": "3.25.17" }, "devDependencies": { "@eslint/js": "9.27.0", "@temporalio/testing": "1.11.8", "@types/node": "22.15.21", + "@types/simple-oauth2": "5.0.7", "@vitest/coverage-v8": "3.1.3", "c8": "10.1.3", "dotenv": "16.5.0", @@ -814,6 +816,53 @@ "node": ">=6" } }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@hapi/topo/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1445,6 +1494,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/address/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, "node_modules/@slack/logger": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz", @@ -1951,6 +2027,13 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, + "node_modules/@types/simple-oauth2": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/simple-oauth2/-/simple-oauth2-5.0.7.tgz", + "integrity": "sha512-8JbWVJbiTSBQP/7eiyGKyXWAqp3dKQZpaA+pdW16FCi32ujkzRMG8JfjoAzdWt6W8U591ZNdHcPtP2D7ILTKuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -5501,6 +5584,25 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/joi/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -7001,6 +7103,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-oauth2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-5.1.0.tgz", + "integrity": "sha512-gWDa38Ccm4MwlG5U7AlcJxPv3lvr80dU7ARJWrGdgvOKyzSj1gr3GBPN1rABTedAYvC/LsGYoFuFxwDBPtGEbw==", + "license": "Apache-2.0", + "dependencies": { + "@hapi/hoek": "^11.0.4", + "@hapi/wreck": "^18.0.0", + "debug": "^4.3.4", + "joi": "^17.6.4" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", diff --git a/workers/main/package.json b/workers/main/package.json index e5afba2..e7df20b 100644 --- a/workers/main/package.json +++ b/workers/main/package.json @@ -11,6 +11,7 @@ "@eslint/js": "9.27.0", "@temporalio/testing": "1.11.8", "@types/node": "22.15.21", + "@types/simple-oauth2": "5.0.7", "@vitest/coverage-v8": "3.1.3", "c8": "10.1.3", "dotenv": "16.5.0", @@ -40,6 +41,7 @@ "axios-retry": "4.5.0", "mongoose": "8.15.1", "mysql2": "3.14.1", + "simple-oauth2": "5.1.0", "zod": "3.25.17" } } diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.test.ts new file mode 100644 index 0000000..12f50ff --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.test.ts @@ -0,0 +1,289 @@ +import { AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileUtilsError } from '../../common/errors'; +import * as fileUtils from '../../common/fileUtils'; +import { FileTokenStorage } from './FileTokenStorage'; +import { TokenData } from './types'; + +vi.mock('../../common/fileUtils'); +vi.mock('simple-oauth2'); + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +describe('FileTokenStorage Constructor', () => { + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, + }); + + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + }); + + it('should create instance with default service name', () => { + const storage = new FileTokenStorage(undefined, mockOAuth2Client); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + + it('should create instance with custom service name', () => { + const storage = new FileTokenStorage('custom-service', mockOAuth2Client); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); +}); + +describe('FileTokenStorage Save', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, + }); + + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + }); + + it('should save token data successfully', async () => { + const writeJsonFileSpy = vi + .spyOn(fileUtils, 'writeJsonFile') + .mockResolvedValue(); + + await fileTokenStorage.save(mockTokenData); + + expect(writeJsonFileSpy).toHaveBeenCalledWith( + expect.stringContaining('test-service.json'), + mockTokenData, + ); + }); + + it('should throw FileUtilsError when save fails', async () => { + const error = new Error('Write failed'); + + vi.spyOn(fileUtils, 'writeJsonFile').mockRejectedValue(error); + + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + FileUtilsError, + ); + }); + + it('should handle non-Error exceptions', async () => { + vi.spyOn(fileUtils, 'writeJsonFile').mockRejectedValue('String error'); + + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + FileUtilsError, + ); + }); +}); + +describe('FileTokenStorage Load', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, + }); + + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + }); + + it('should load valid token data successfully', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(mockTokenData); + + const result = await fileTokenStorage.load(); + + expect(result).toEqual(mockTokenData); + expect(mockCreateToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, + }); + }); + + it('should return null when file does not exist', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockRejectedValue( + new Error('File not found'), + ); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is invalid', async () => { + const invalidTokenData = { invalid: 'data' }; + + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is null', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(null); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is not an object', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue('string data'); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is empty object', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue({}); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); +}); + +describe('FileTokenStorage Clear', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, + }); + + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + }); + + it('should clear token data successfully', async () => { + const deleteJsonFileSpy = vi + .spyOn(fileUtils, 'deleteJsonFile') + .mockResolvedValue(); + + await fileTokenStorage.clear(); + + expect(deleteJsonFileSpy).toHaveBeenCalledWith( + expect.stringContaining('test-service.json'), + ); + }); + + it('should throw FileUtilsError when clear fails', async () => { + const error = new Error('Delete failed'); + + vi.spyOn(fileUtils, 'deleteJsonFile').mockRejectedValue(error); + + await expect(fileTokenStorage.clear()).rejects.toThrow(FileUtilsError); + }); + + it('should handle non-Error exceptions in clear', async () => { + vi.spyOn(fileUtils, 'deleteJsonFile').mockRejectedValue('String error'); + + await expect(fileTokenStorage.clear()).rejects.toThrow(FileUtilsError); + }); +}); + +describe('FileTokenStorage Validation', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, + }); + + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + }); + + it('should validate correct token data structure', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(mockTokenData); + + const result = await fileTokenStorage.load(); + + expect(result).toEqual(mockTokenData); + }); + + it('should reject token data with missing access_token', async () => { + const invalidTokenData = { + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should reject token data with missing refresh_token', async () => { + const invalidTokenData = { + access_token: 'test-access-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); +}); diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.ts b/workers/main/src/services/OAuth2/FileTokenStorage.ts new file mode 100644 index 0000000..1f315b0 --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.ts @@ -0,0 +1,81 @@ +import { join } from 'path'; +import { AuthorizationCode } from 'simple-oauth2'; + +import { FileUtilsError } from '../../common/errors'; +import { + deleteJsonFile, + readJsonFile, + writeJsonFile, +} from '../../common/fileUtils'; +import { TokenData, TokenStorageProvider } from './types'; + +export class FileTokenStorage implements TokenStorageProvider { + private readonly tokenFilePath: string; + + constructor( + serviceName: string = 'qbo', + private oauth2Client: AuthorizationCode, + ) { + this.tokenFilePath = join( + process.cwd(), + 'data', + 'oauth2_tokens', + `${serviceName}.json`, + ); + } + + async save(tokenData: TokenData): Promise { + try { + await writeJsonFile(this.tokenFilePath, tokenData); + } catch (error) { + throw new FileUtilsError( + `Failed to save token data to file: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + async load(): Promise { + try { + const tokenData = await readJsonFile(this.tokenFilePath); + + if (!this.isValidTokenData(tokenData)) { + return null; + } + + return tokenData; + } catch { + return null; + } + } + + async clear(): Promise { + try { + await deleteJsonFile(this.tokenFilePath); + } catch (error) { + throw new FileUtilsError( + `Failed to clear token data from file: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private isValidTokenData(data: unknown): data is TokenData { + if (!data || typeof data !== 'object') { + return false; + } + + const tokenData = data as Record; + + try { + this.oauth2Client.createToken({ + access_token: tokenData.access_token as string, + refresh_token: tokenData.refresh_token as string, + expires_at: new Date(tokenData.expires_at as number), + token_type: tokenData.token_type as string, + }); + + return true; + } catch { + return false; + } + } +} diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.accessToken.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.accessToken.test.ts new file mode 100644 index 0000000..5c60504 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.accessToken.test.ts @@ -0,0 +1,155 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', +}; + +describe('OAuth2Manager GetAccessToken', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockRefreshToken: ReturnType; + let mockExpired: ReturnType; + let mockLoad: ReturnType; + let mockSave: ReturnType; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRefreshToken = vi.fn(); + mockExpired = vi.fn(); + mockLoad = vi.fn(); + mockSave = vi.fn(); + mockCreateToken = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: mockSave, + load: mockLoad, + clear: vi.fn(), + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: mockCreateToken.mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + it('should return access token when token is valid and not expired', async () => { + mockExpired.mockReturnValue(false); + mockLoad.mockResolvedValue(mockTokenData); + + const result = await oauth2Manager.getAccessToken(); + + expect(result).toBe(mockTokenData.access_token); + expect(mockLoad).toHaveBeenCalled(); + expect(mockCreateToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, + }); + }); + + it('should refresh token when token is expired', async () => { + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + + const result = await oauth2Manager.getAccessToken(); + + expect(result).toBe(mockTokenData.access_token); + expect(mockRefreshToken).toHaveBeenCalled(); + expect(mockSave).toHaveBeenCalled(); + }); + + it('should throw OAuth2Error when no token is available', async () => { + mockLoad.mockResolvedValue(null); + + await expect(oauth2Manager.getAccessToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.getAccessToken()).rejects.toThrow( + 'No access token available', + ); + }); + + it('should throw OAuth2Error when token has invalid format', async () => { + const invalidTokenData = { + ...mockTokenDataWithDate, + access_token: null, + }; + + const invalidAccessToken = { + token: invalidTokenData, + expired: mockExpired, + } as unknown as AccessToken; + + mockExpired.mockReturnValue(false); + mockLoad.mockResolvedValue(mockTokenData); + mockCreateToken.mockReturnValue(invalidAccessToken); + + await expect(oauth2Manager.getAccessToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.getAccessToken()).rejects.toThrow( + 'Invalid access token format', + ); + }); + + it('should handle concurrent refresh requests', async () => { + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + + const promises = [ + oauth2Manager.getAccessToken(), + oauth2Manager.getAccessToken(), + oauth2Manager.getAccessToken(), + ]; + + const results = await Promise.all(promises); + + expect(results).toEqual([ + mockTokenData.access_token, + mockTokenData.access_token, + mockTokenData.access_token, + ]); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.clear.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.clear.test.ts new file mode 100644 index 0000000..5885b7c --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.clear.test.ts @@ -0,0 +1,113 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', +}; + +describe('OAuth2Manager ClearTokens', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockRevokeAll: ReturnType; + let mockExpired: ReturnType; + let mockLoad: ReturnType; + let mockClear: ReturnType; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRevokeAll = vi.fn(); + mockExpired = vi.fn(); + mockLoad = vi.fn(); + mockClear = vi.fn(); + mockCreateToken = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + revokeAll: mockRevokeAll, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: vi.fn(), + load: mockLoad, + clear: mockClear, + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: mockCreateToken.mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + it('should clear tokens successfully', async () => { + mockLoad.mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.clearTokens(); + + expect(mockRevokeAll).toHaveBeenCalled(); + expect(mockClear).toHaveBeenCalled(); + }); + + it('should handle revoke failure gracefully', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRevokeAll.mockRejectedValue(new Error('Revoke failed')); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.clearTokens(); + + expect(mockRevokeAll).toHaveBeenCalled(); + expect(mockClear).toHaveBeenCalled(); + }); + + it('should clear tokens even when no access token exists', async () => { + await oauth2Manager.clearTokens(); + + expect(mockClear).toHaveBeenCalled(); + }); + + it('should handle revoke error with non-Error object', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRevokeAll.mockRejectedValue('String error'); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.clearTokens(); + + expect(mockRevokeAll).toHaveBeenCalled(); + expect(mockClear).toHaveBeenCalled(); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.constructor.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.constructor.test.ts new file mode 100644 index 0000000..6b345be --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.constructor.test.ts @@ -0,0 +1,71 @@ +import { AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +describe('OAuth2Manager Constructor', () => { + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + + beforeEach(() => { + vi.clearAllMocks(); + + mockStorage = { + save: vi.fn(), + load: vi.fn(), + clear: vi.fn(), + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: vi.fn(), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + }); + + it('should create OAuth2Manager with correct configuration', () => { + const oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + + expect(oauth2Manager).toBeInstanceOf(OAuth2Manager); + expect(AuthorizationCode).toHaveBeenCalledWith({ + client: { + id: mockOAuth2Config.clientId, + secret: mockOAuth2Config.clientSecret, + }, + auth: { + tokenHost: mockOAuth2Config.tokenHost, + tokenPath: mockOAuth2Config.tokenPath, + }, + http: { + json: 'strict', + headers: { + 'User-Agent': 'TemporalWorker/1.0', + }, + }, + }); + }); + + it('should create FileTokenStorage with service name', () => { + new OAuth2Manager('test-service', mockOAuth2Config); + + expect(FileTokenStorage).toHaveBeenCalledWith( + 'test-service', + mockOAuth2Client, + ); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.refresh.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.refresh.test.ts new file mode 100644 index 0000000..46c595b --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.refresh.test.ts @@ -0,0 +1,150 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', +}; + +describe('OAuth2Manager RefreshToken', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockRefreshToken: ReturnType; + let mockExpired: ReturnType; + let mockLoad: ReturnType; + let mockSave: ReturnType; + let mockClear: ReturnType; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRefreshToken = vi.fn(); + mockExpired = vi.fn(); + mockLoad = vi.fn(); + mockSave = vi.fn(); + mockClear = vi.fn(); + mockCreateToken = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: mockSave, + load: mockLoad, + clear: mockClear, + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: mockCreateToken.mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + it('should refresh token successfully', async () => { + mockRefreshToken.mockResolvedValue(mockAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.refreshToken(); + + expect(mockRefreshToken).toHaveBeenCalled(); + expect(mockSave).toHaveBeenCalled(); + }); + + it('should handle concurrent refresh requests', async () => { + mockRefreshToken.mockResolvedValue(mockAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + + const promises = [ + oauth2Manager.refreshToken(), + oauth2Manager.refreshToken(), + oauth2Manager.refreshToken(), + ]; + + await Promise.all(promises); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('should throw OAuth2Error when no token to refresh', async () => { + await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'No access token to refresh', + ); + }); + + it('should handle invalid_grant error by clearing tokens', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRefreshToken.mockRejectedValue(new Error('invalid_grant')); + mockExpired.mockReturnValue(false); + + await oauth2Manager.getAccessToken(); + + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'Invalid refresh token - tokens cleared', + ); + + expect(mockClear).toHaveBeenCalled(); + }); + + it('should throw OAuth2Error for other refresh failures', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRefreshToken.mockRejectedValue(new Error('Network error')); + + await oauth2Manager.getAccessToken(); + + await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'Failed to refresh token: Network error', + ); + }); + + it('should handle refresh error with non-Error object', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRefreshToken.mockRejectedValue('String error'); + + await oauth2Manager.getAccessToken(); + + await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'Failed to refresh token: String error', + ); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.ts b/workers/main/src/services/OAuth2/OAuth2Manager.ts new file mode 100644 index 0000000..937e9c7 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.ts @@ -0,0 +1,155 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Config, TokenData } from './types'; + +export class OAuth2Manager { + private accessToken: AccessToken | null = null; + private refreshPromise: Promise | null = null; + private readonly storage: FileTokenStorage; + private readonly oauth2Client: AuthorizationCode; + + constructor( + serviceName: string, + private oauth2Config: OAuth2Config, + ) { + this.oauth2Client = new AuthorizationCode({ + client: { + id: oauth2Config.clientId, + secret: oauth2Config.clientSecret, + }, + auth: { + tokenHost: oauth2Config.tokenHost, + tokenPath: oauth2Config.tokenPath, + }, + http: { + json: 'strict', + headers: { + 'User-Agent': 'TemporalWorker/1.0', + }, + }, + }); + this.storage = new FileTokenStorage(serviceName, this.oauth2Client); + } + + async getAccessToken(): Promise { + await this.ensureTokenLoaded(); + + if (!this.accessToken) { + throw new OAuth2Error('No access token available'); + } + + const isExpired = this.accessToken.expired( + this.oauth2Config.tokenExpirationWindowSeconds, + ); + + if (isExpired) { + await this.refreshToken(); + } + + const token = this.accessToken.token; + + if (!token || typeof token.access_token !== 'string') { + throw new OAuth2Error('Invalid access token format'); + } + + return token.access_token; + } + + async refreshToken(): Promise { + if (this.refreshPromise) { + await this.refreshPromise; + + return; + } + + this.refreshPromise = this.performRefresh(); + try { + await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + async clearTokens(): Promise { + if (this.accessToken) { + try { + await this.accessToken.revokeAll(); + } catch (error) { + // Log error but don't throw - token revocation failure shouldn't prevent clearing + console.error( + 'Failed to revoke tokens:', + error instanceof Error ? error.message : String(error), + ); + } + } + + this.accessToken = null; + this.refreshPromise = null; + await this.storage.clear(); + } + + private async ensureTokenLoaded(): Promise { + if (this.accessToken) return; + + const tokenData = await this.storage.load(); + + if (tokenData) { + this.accessToken = this.oauth2Client.createToken({ + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: new Date(tokenData.expires_at), + token_type: tokenData.token_type, + }); + } + } + + private async performRefresh(): Promise { + if (!this.accessToken) { + throw new OAuth2Error('No access token to refresh'); + } + + try { + this.accessToken = await this.accessToken.refresh(); + await this.saveToken(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes('invalid_grant')) { + await this.clearTokens(); + throw new OAuth2Error('Invalid refresh token - tokens cleared'); + } + + throw new OAuth2Error(`Failed to refresh token: ${message}`); + } + } + + private async saveToken(): Promise { + if (!this.accessToken) return; + + const token = this.accessToken.token; + + if (!token) { + throw new OAuth2Error('Invalid token data'); + } + + const tokenData: TokenData = { + access_token: + typeof token.access_token === 'string' ? token.access_token : '', + refresh_token: + (typeof token.refresh_token === 'string' + ? token.refresh_token + : null) || this.oauth2Config.refreshToken, + expires_at: + (token.expires_at instanceof Date + ? token.expires_at.getTime() + : null) || Date.now() + 3600000, + token_type: + (typeof token.token_type === 'string' ? token.token_type : null) || + 'Bearer', + }; + + await this.storage.save(tokenData); + } +} diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.utils.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.utils.test.ts new file mode 100644 index 0000000..e2f37b1 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.utils.test.ts @@ -0,0 +1,173 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', +}; + +describe('OAuth2Manager Utils', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockExpired: ReturnType; + let mockLoad: ReturnType; + let mockSave: ReturnType; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockExpired = vi.fn(); + mockLoad = vi.fn(); + mockSave = vi.fn(); + mockCreateToken = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: mockSave, + load: mockLoad, + clear: vi.fn(), + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: mockCreateToken.mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + describe('ensureTokenLoaded', () => { + it('should load token from storage when not already loaded', async () => { + mockLoad.mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + + expect(mockLoad).toHaveBeenCalled(); + expect(mockCreateToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, + }); + }); + + it('should not reload token when already loaded', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(false); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.getAccessToken(); + + expect(mockLoad).toHaveBeenCalledTimes(1); + }); + }); + + describe('saveToken', () => { + let mockRefreshToken: ReturnType; + + beforeEach(() => { + mockRefreshToken = vi.fn(); + mockAccessToken = { + token: mockTokenDataWithDate, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + mockCreateToken.mockReturnValue(mockAccessToken); + }); + + it('should save token with correct data structure', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + + await oauth2Manager.getAccessToken(); + + expect(mockSave).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: mockTokenData.expires_at, + token_type: mockTokenData.token_type, + }); + }); + + it('should use fallback values when token properties are missing', async () => { + const incompleteTokenData = { + access_token: 'test-access-token', + refresh_token: undefined, + expires_at: undefined, + token_type: undefined, + }; + + const incompleteAccessToken = { + token: incompleteTokenData, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + + mockRefreshToken.mockResolvedValue(incompleteAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(true); + + await oauth2Manager.getAccessToken(); + + expect(mockSave).toHaveBeenCalledWith({ + access_token: 'test-access-token', + refresh_token: mockOAuth2Config.refreshToken, + expires_at: expect.any(Number) as number, + token_type: 'Bearer', + }); + }); + + it('should handle null token data', async () => { + const nullAccessToken = { + token: null, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + + mockRefreshToken.mockResolvedValue(nullAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(true); + + await expect(oauth2Manager.getAccessToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.getAccessToken()).rejects.toThrow( + 'Invalid token data', + ); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/index.ts b/workers/main/src/services/OAuth2/index.ts new file mode 100644 index 0000000..99b130e --- /dev/null +++ b/workers/main/src/services/OAuth2/index.ts @@ -0,0 +1,3 @@ +export { FileTokenStorage } from './FileTokenStorage'; +export { OAuth2Manager } from './OAuth2Manager'; +export * from './types'; diff --git a/workers/main/src/services/OAuth2/types.test.ts b/workers/main/src/services/OAuth2/types.test.ts new file mode 100644 index 0000000..b29ce77 --- /dev/null +++ b/workers/main/src/services/OAuth2/types.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import { OAuth2Config, TokenData, TokenStorageProvider } from './types'; + +describe('TokenData', () => { + it('should have required properties', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + expect(tokenData.access_token).toBe('test-access-token'); + expect(tokenData.refresh_token).toBe('test-refresh-token'); + expect(tokenData.expires_at).toBeGreaterThan(Date.now()); + expect(tokenData.token_type).toBe('Bearer'); + }); + + it('should allow different token types', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Basic', + }; + + expect(tokenData.token_type).toBe('Basic'); + }); + + it('should handle future expiration times', () => { + const futureTime = Date.now() + 86400000; // 24 hours from now + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: futureTime, + token_type: 'Bearer', + }; + + expect(tokenData.expires_at).toBe(futureTime); + expect(tokenData.expires_at).toBeGreaterThan(Date.now()); + }); +}); + +describe('TokenStorageProvider', () => { + it('should define required methods', () => { + const mockStorage: TokenStorageProvider = { + save: async () => {}, + load: async () => null, + clear: async () => {}, + }; + + expect(typeof mockStorage.save).toBe('function'); + expect(typeof mockStorage.load).toBe('function'); + expect(typeof mockStorage.clear).toBe('function'); + }); + + it('should allow async operations', async () => { + const mockStorage: TokenStorageProvider = { + save: async (tokenData: TokenData) => { + expect(tokenData.access_token).toBe('test-token'); + }, + load: async () => ({ + access_token: 'test-token', + refresh_token: 'test-refresh', + expires_at: Date.now(), + token_type: 'Bearer', + }), + clear: async () => {}, + }; + + await mockStorage.save({ + access_token: 'test-token', + refresh_token: 'test-refresh', + expires_at: Date.now(), + token_type: 'Bearer', + }); + + const result = await mockStorage.load(); + + expect(result).not.toBeNull(); + expect(result?.access_token).toBe('test-token'); + }); +}); + +describe('OAuth2Config', () => { + it('should have required properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.clientId).toBe('test-client-id'); + expect(config.clientSecret).toBe('test-client-secret'); + expect(config.refreshToken).toBe('test-refresh-token'); + expect(config.tokenHost).toBe('https://oauth.example.com'); + expect(config.tokenPath).toBe('/oauth/token'); + expect(config.tokenExpirationWindowSeconds).toBe(300); + }); + + it('should have optional authorize properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + authorizeHost: 'https://auth.example.com', + authorizePath: '/oauth/authorize', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.authorizeHost).toBe('https://auth.example.com'); + expect(config.authorizePath).toBe('/oauth/authorize'); + }); + + it('should work without optional authorize properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.clientId).toBe('test-client-id'); + expect(config.authorizeHost).toBeUndefined(); + expect(config.authorizePath).toBeUndefined(); + }); + + it('should handle different expiration window values', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 0, + }; + + expect(config.tokenExpirationWindowSeconds).toBe(0); + + const configWithWindow: OAuth2Config = { + ...config, + tokenExpirationWindowSeconds: 600, + }; + + expect(configWithWindow.tokenExpirationWindowSeconds).toBe(600); + }); +}); diff --git a/workers/main/src/services/OAuth2/types.ts b/workers/main/src/services/OAuth2/types.ts new file mode 100644 index 0000000..d45fd46 --- /dev/null +++ b/workers/main/src/services/OAuth2/types.ts @@ -0,0 +1,23 @@ +export interface TokenData { + access_token: string; + refresh_token: string; + expires_at: number; + token_type: string; +} + +export interface TokenStorageProvider { + save(tokenData: TokenData): Promise; + load(): Promise; + clear(): Promise; +} + +export interface OAuth2Config { + clientId: string; + clientSecret: string; + refreshToken: string; + tokenHost: string; + tokenPath: string; + authorizeHost?: string; + authorizePath?: string; + tokenExpirationWindowSeconds: number; +}