diff --git a/.changeset/angry-crews-push.md b/.changeset/angry-crews-push.md new file mode 100644 index 0000000..9679dd8 --- /dev/null +++ b/.changeset/angry-crews-push.md @@ -0,0 +1,12 @@ +--- +"@growi/sdk-typescript": patch +--- + +Add authorization header override functionality + +- Add `authorizationHeader` option to `AxiosInstanceManager.addAxiosInstance()` +- Allow custom authorization headers instead of default Bearer token authentication +- When `authorizationHeader` is specified, GROWI access token is sent via `X-GROWI-ACCESS-TOKEN` header +- Support for Digest authentication, Basic authentication, and custom proxy authentication +- Add detailed usage examples and documentation to README +- Add comprehensive test cases covering error handling and edge cases diff --git a/README.md b/README.md index 19d0e5d..a009a36 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ axiosInstanceManager.addAxiosInstance({ baseURL: 'https://your-growi-instance-2.com', token: 'your-api-token-2', }) + +// Example with Digest authentication when GROWI is behind Digest auth protection +axiosInstanceManager.addAxiosInstance({ + name: 'app-3', + baseURL: 'https://your-growi-instance-3.com', + token: 'your-growi-access-token', + authorizationHeader: 'Digest username="user", realm="Protected Area", nonce="abc123", uri="/", response="xyz789"', // Digest auth header +}) ``` ### API v3 Usage Example @@ -127,6 +135,49 @@ src/ - **API v3**: Contains new features and improved API endpoints. We recommend using v3 whenever possible. - **API v1**: Use when you need features not available in v3 or for legacy compatibility. +### Authentication Header Override + +The SDK now supports overriding the default Bearer token authorization method. When the `authorizationHeader` option is provided: + +- The `Authorization` header will be set to the provided custom value +- The GROWI access token will be sent via the `X-GROWI-ACCESS-TOKEN` header + +This feature is particularly useful when GROWI is behind authentication systems such as Digest authentication, Basic authentication, or custom proxy authentication that require specific authorization header formats. + +**Default behavior (Bearer token):** +```typescript +axiosInstanceManager.addAxiosInstance({ + name: 'default-auth', + baseURL: 'https://growi.example.com', + token: 'your-growi-api-token', + // Authorization header will be: "Bearer your-growi-api-token" +}); +``` + +**Digest authentication example:** +```typescript +axiosInstanceManager.addAxiosInstance({ + name: 'digest-auth', + baseURL: 'https://growi.example.com', + token: 'growi-api-token', + authorizationHeader: 'Digest username="admin", realm="GROWI Protected", nonce="abc123def456", uri="/", response="calculated-response-hash"', + // Authorization header will be set to the Digest auth string + // X-GROWI-ACCESS-TOKEN header will be: "growi-api-token" +}); +``` + +**Basic authentication example:** +```typescript +axiosInstanceManager.addAxiosInstance({ + name: 'basic-auth', + baseURL: 'https://growi.example.com', + token: 'growi-api-token', + authorizationHeader: 'Basic ' + btoa('username:password'), + // Authorization header will be: "Basic base64-encoded-credentials" + // X-GROWI-ACCESS-TOKEN header will be: "growi-api-token" +}); +``` + ## Type Definition All API requests and responses are type-safe: diff --git a/README_JP.md b/README_JP.md index f1a64d6..6b63416 100644 --- a/README_JP.md +++ b/README_JP.md @@ -59,6 +59,14 @@ axiosInstanceManager.addAxiosInstance({ baseURL: 'https://your-growi-instance-2.com', token: 'your-api-token-2', }) + +// GROWIがDigest認証で保護されている場合の例 +axiosInstanceManager.addAxiosInstance({ + name: 'app-3', + baseURL: 'https://your-growi-instance-3.com', + token: 'your-growi-access-token', + authorizationHeader: 'Digest username="user", realm="Protected Area", nonce="abc123", uri="/", response="xyz789"', // Digest認証ヘッダー +}) ``` ### API v3 の使用例 @@ -127,6 +135,49 @@ src/ - **API v3**: 新機能や改良された API エンドポイントが含まれています。可能な限り v3 の使用を推奨します。 - **API v1**: v3 で提供されていない機能や、レガシー対応が必要な場合に使用してください。 +### 認証ヘッダーの上書き + +SDK は、デフォルトの Bearer トークン認証方法を上書きする機能をサポートしています。`authorizationHeader` オプションが提供された場合: + +- `Authorization` ヘッダーは提供されたカスタム値に設定されます +- GROWI アクセストークンは `X-GROWI-ACCESS-TOKEN` ヘッダー経由で送信されます + +この機能は、特に GROWI が Digest認証、Basic認証、またはカスタムプロキシ認証などの認証システムの背後にある場合に、特定の認証ヘッダー形式が必要な時に便利です。 + +**デフォルトの動作(Bearer トークン):** +```typescript +axiosInstanceManager.addAxiosInstance({ + name: 'default-auth', + baseURL: 'https://growi.example.com', + token: 'your-growi-api-token', + // Authorization ヘッダーは: "Bearer your-growi-api-token" に設定されます +}); +``` + +**Digest認証の例:** +```typescript +axiosInstanceManager.addAxiosInstance({ + name: 'digest-auth', + baseURL: 'https://growi.example.com', + token: 'growi-api-token', + authorizationHeader: 'Digest username="admin", realm="GROWI Protected", nonce="abc123def456", uri="/", response="calculated-response-hash"', + // Authorization ヘッダーは Digest認証文字列に設定されます + // X-GROWI-ACCESS-TOKEN ヘッダーは: "growi-api-token" に設定されます +}); +``` + +**Basic認証の例:** +```typescript +axiosInstanceManager.addAxiosInstance({ + name: 'basic-auth', + baseURL: 'https://growi.example.com', + token: 'growi-api-token', + authorizationHeader: 'Basic ' + btoa('username:password'), + // Authorization ヘッダーは: "Basic base64エンコードされた認証情報" に設定されます + // X-GROWI-ACCESS-TOKEN ヘッダーは: "growi-api-token" に設定されます +}); +``` + ## 型定義 すべての API リクエスト・レスポンスは型安全です: diff --git a/src/utils/axios-instance-manager.test.ts b/src/utils/axios-instance-manager.test.ts new file mode 100644 index 0000000..e46dab2 --- /dev/null +++ b/src/utils/axios-instance-manager.test.ts @@ -0,0 +1,337 @@ +import type { AxiosInstance } from 'axios'; +import Axios from 'axios'; +import { type MockedFunction, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { axiosInstanceManager } from './axios-instance-manager.js'; + +// Mock Axios +vi.mock('axios'); + +const mockedAxios = vi.mocked(Axios); + +describe('AxiosInstanceManager', () => { + let mockAxiosInstance: AxiosInstance; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock Axios instance + mockAxiosInstance = { + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + + // Mock Axios.create to return our mock instance + mockedAxios.create = vi.fn().mockReturnValue(mockAxiosInstance); + }); + + afterEach(() => { + // Clear all instances after each test to ensure clean state + // Since we can't access the private instances directly, we'll test each scenario independently + vi.restoreAllMocks(); + }); + + describe('addAxiosInstance', () => { + it('should create an Axios instance with correct baseURL', () => { + const config = { + appName: 'test-app', + baseURL: 'https://api.example.com', + token: 'test-token', + }; + + axiosInstanceManager.addAxiosInstance(config); + + expect(mockedAxios.create).toHaveBeenCalledWith({ + baseURL: 'https://api.example.com', + }); + }); + + it('should set Bearer token authorization when authorizationHeader is undefined', () => { + const config = { + appName: 'test-app', + baseURL: 'https://api.example.com', + token: 'test-token-123', + }; + + axiosInstanceManager.addAxiosInstance(config); + + expect(mockAxiosInstance.defaults.headers.common.Authorization).toBe('Bearer test-token-123'); + expect(mockAxiosInstance.defaults.headers['X-GROWI-ACCESS-TOKEN']).toBeUndefined(); + }); + + it('should set custom authorization header and GROWI access token when authorizationHeader is provided', () => { + const config = { + appName: 'test-app', + baseURL: 'https://api.example.com', + token: 'growi-token-456', + authorizationHeader: 'Custom custom-auth-value', + }; + + axiosInstanceManager.addAxiosInstance(config); + + expect(mockAxiosInstance.defaults.headers.common.Authorization).toBe('Custom custom-auth-value'); + expect(mockAxiosInstance.defaults.headers['X-GROWI-ACCESS-TOKEN']).toBe('growi-token-456'); + }); + + it('should handle multiple instances with different names', () => { + const config1 = { + appName: 'app1', + baseURL: 'https://api1.example.com', + token: 'token1', + }; + + const config2 = { + appName: 'app2', + baseURL: 'https://api2.example.com', + token: 'token2', + authorizationHeader: 'Bearer custom-token', + }; + + // Create first instance + const mockInstance1 = { + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + + // Create second instance + const mockInstance2 = { + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + + (mockedAxios.create as MockedFunction).mockReturnValueOnce(mockInstance1).mockReturnValueOnce(mockInstance2); + + axiosInstanceManager.addAxiosInstance(config1); + axiosInstanceManager.addAxiosInstance(config2); + + expect(mockedAxios.create).toHaveBeenCalledTimes(2); + expect(mockedAxios.create).toHaveBeenNthCalledWith(1, { baseURL: 'https://api1.example.com' }); + expect(mockedAxios.create).toHaveBeenNthCalledWith(2, { baseURL: 'https://api2.example.com' }); + + expect(mockInstance1.defaults.headers.common.Authorization).toBe('Bearer token1'); + expect(mockInstance2.defaults.headers.common.Authorization).toBe('Bearer custom-token'); + expect(mockInstance2.defaults.headers['X-GROWI-ACCESS-TOKEN']).toBe('token2'); + }); + + it('should overwrite existing instance when using same appName', () => { + const config = { + appName: 'same-app', + baseURL: 'https://api.example.com', + token: 'original-token', + }; + + const updatedConfig = { + appName: 'same-app', + baseURL: 'https://api-updated.example.com', + token: 'updated-token', + authorizationHeader: 'Bearer updated-auth', + }; + + // Create first instance + const mockInstance1 = { + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + + // Create second instance + const mockInstance2 = { + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + + (mockedAxios.create as MockedFunction).mockReturnValueOnce(mockInstance1).mockReturnValueOnce(mockInstance2); + + axiosInstanceManager.addAxiosInstance(config); + axiosInstanceManager.addAxiosInstance(updatedConfig); + + expect(mockedAxios.create).toHaveBeenCalledTimes(2); + expect(mockedAxios.create).toHaveBeenNthCalledWith(2, { baseURL: 'https://api-updated.example.com' }); + + expect(mockInstance2.defaults.headers.common.Authorization).toBe('Bearer updated-auth'); + expect(mockInstance2.defaults.headers['X-GROWI-ACCESS-TOKEN']).toBe('updated-token'); + }); + + it('should handle edge cases with empty appName', () => { + const config = { + appName: '', + baseURL: 'https://api-v2.example.com', + token: 'updated-token', + }; + + expect(() => { + axiosInstanceManager.addAxiosInstance(config); + }).toThrow('appName must be a non-empty string'); + }); + + it('should handle edge cases with empty baseURL', () => { + const config = { + appName: 'app-name', + baseURL: '', + token: 'updated-token', + }; + + expect(() => { + axiosInstanceManager.addAxiosInstance(config); + }).toThrow('baseURL must be a non-empty string'); + }); + + it('should handle edge cases with empty token', () => { + const config = { + appName: 'app-name', + baseURL: 'https://api.example.com', + token: '', + }; + + expect(() => { + axiosInstanceManager.addAxiosInstance(config); + }).toThrow('token must be a non-empty string'); + }); + }); + + describe('getAxiosInstance', () => { + it('should return the correct instance for a valid appName', () => { + const config = { + appName: 'valid-app', + baseURL: 'https://api.example.com', + token: 'test-token', + }; + + axiosInstanceManager.addAxiosInstance(config); + const instance = axiosInstanceManager.getAxiosInstance('valid-app'); + + expect(instance).toBe(mockAxiosInstance); + }); + + it('should throw an error for non-existent appName', () => { + expect(() => { + axiosInstanceManager.getAxiosInstance('non-existent-app'); + }).toThrow('No Axios instance found for appName: non-existent-app'); + }); + + it('should throw an error with correct message format', () => { + const invalidAppName = 'invalid-app-name-123'; + + expect(() => { + axiosInstanceManager.getAxiosInstance(invalidAppName); + }).toThrow(`No Axios instance found for appName: ${invalidAppName}`); + }); + + it('should return different instances for different appNames', () => { + const config1 = { + appName: 'app1', + baseURL: 'https://api1.example.com', + token: 'token1', + }; + + const config2 = { + appName: 'app2', + baseURL: 'https://api2.example.com', + token: 'token2', + }; + + const mockInstance1 = { + id: 'instance1', + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + const mockInstance2 = { + id: 'instance2', + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + + (mockedAxios.create as MockedFunction).mockReturnValueOnce(mockInstance1).mockReturnValueOnce(mockInstance2); + + axiosInstanceManager.addAxiosInstance(config1); + axiosInstanceManager.addAxiosInstance(config2); + + const instance1 = axiosInstanceManager.getAxiosInstance('app1'); + const instance2 = axiosInstanceManager.getAxiosInstance('app2'); + + expect(instance1).toBe(mockInstance1); + expect(instance2).toBe(mockInstance2); + expect(instance1).not.toBe(instance2); + }); + }); + + describe('integration tests', () => { + it('should correctly manage the complete lifecycle of instances', () => { + // Add first instance + const config1 = { + appName: 'lifecycle-app', + baseURL: 'https://api.example.com', + token: 'initial-token', + }; + + const mockInstance1 = { + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + + (mockedAxios.create as MockedFunction).mockReturnValueOnce(mockInstance1); + + axiosInstanceManager.addAxiosInstance(config1); + let instance = axiosInstanceManager.getAxiosInstance('lifecycle-app'); + + expect(instance).toBe(mockInstance1); + expect(instance.defaults.headers.common.Authorization).toBe('Bearer initial-token'); + + // Update the same instance + const config2 = { + appName: 'lifecycle-app', + baseURL: 'https://api-v2.example.com', + token: 'updated-token', + authorizationHeader: 'Custom auth-header', + }; + + const mockInstance2 = { + defaults: { + headers: { + common: {}, + 'X-GROWI-ACCESS-TOKEN': undefined, + }, + }, + } as unknown as AxiosInstance; + + (mockedAxios.create as MockedFunction).mockReturnValueOnce(mockInstance2); + + axiosInstanceManager.addAxiosInstance(config2); + instance = axiosInstanceManager.getAxiosInstance('lifecycle-app'); + + expect(instance).toBe(mockInstance2); + expect(instance.defaults.headers.common.Authorization).toBe('Custom auth-header'); + expect(instance.defaults.headers['X-GROWI-ACCESS-TOKEN']).toBe('updated-token'); + }); + }); +}); diff --git a/src/utils/axios-instance-manager.ts b/src/utils/axios-instance-manager.ts index 24ba29b..1c011dd 100644 --- a/src/utils/axios-instance-manager.ts +++ b/src/utils/axios-instance-manager.ts @@ -11,13 +11,22 @@ class AxiosInstanceManager { * @param appName The name/key for the instance. * @param config The configuration for the Axios instance. */ - addAxiosInstance(config: { appName: string; baseURL: string; token: string }) { + addAxiosInstance(config: { appName: string; baseURL: string; token: string; authorizationHeader?: string | undefined }) { + if (config.appName.length === 0) throw new Error('appName must be a non-empty string'); + if (config.baseURL.length === 0) throw new Error('baseURL must be a non-empty string'); + if (config.token.length === 0) throw new Error('token must be a non-empty string'); + const axiosInstance = Axios.create({ baseURL: config.baseURL, }); // Set the Authorization header - axiosInstance.defaults.headers.common.Authorization = `Bearer ${config.token}`; + if (config.authorizationHeader) { + axiosInstance.defaults.headers.common.Authorization = config.authorizationHeader; + axiosInstance.defaults.headers['X-GROWI-ACCESS-TOKEN'] = config.token; + } else { + axiosInstance.defaults.headers.common.Authorization = `Bearer ${config.token}`; + } this.instances.set(config.appName, axiosInstance); }