diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a387353..119a142 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,8 @@ concurrency: cancel-in-progress: true jobs: - test: - name: test + lint: + name: lint runs-on: ubuntu-latest steps: - name: Checkout @@ -53,8 +53,50 @@ jobs: - name: Lint run: pnpm run --if-present lint + build: + name: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.34.1 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build run: pnpm run --if-present build + test: + name: test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.34.1 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Test run: pnpm run --if-present test diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..215c69e --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,108 @@ +# Pluggable Authentication + +The GuildPass SDK supports a flexible authentication architecture via the `AuthenticationProvider` interface. By default, API key authentication is supported out of the box, but you can implement your own custom providers to support OAuth, JWTs, Sign-in with Ethereum (SIWE), or any other auth method. + +## The `AuthenticationProvider` Interface + +To implement a custom provider, you need to implement the `AuthenticationProvider` interface: + +```typescript +import { AuthenticationProvider } from '@guildpass/sdk'; + +export interface AuthenticationProvider { + /** + * Returns a dictionary of headers to inject into each outgoing request. + * Can be asynchronous to allow for token fetching before the request. + */ + getAuthorizationHeaders(): Promise> | Record; + + /** + * Optional hook called by the HTTP client when a 401 Unauthorized response is received. + * You can use this to refresh access tokens and return `true` to instruct the client + * to automatically retry the failed request. + * Return `false` to abort the retry and propagate the original 401 error. + */ + onUnauthorized?(): Promise; +} +``` + +## Example: OAuth Bearer Token Provider + +This provider automatically attaches a Bearer token and refreshes it if the SDK encounters a 401 response: + +```typescript +import { AuthenticationProvider, GuildPassClient } from '@guildpass/sdk'; + +class OAuthAuthenticationProvider implements AuthenticationProvider { + private accessToken: string | null = null; + private isRefreshing = false; + + constructor(private refreshToken: string) {} + + public async getAuthorizationHeaders(): Promise> { + if (!this.accessToken) { + await this.refresh(); + } + return { + 'Authorization': `Bearer ${this.accessToken}` + }; + } + + public async onUnauthorized(): Promise { + if (this.isRefreshing) { + // Prevent infinite loops or concurrent refresh races + return false; + } + + try { + this.isRefreshing = true; + await this.refresh(); + return true; // Token refreshed successfully, retry the request + } catch (error) { + return false; // Refresh failed, propagate the 401 + } finally { + this.isRefreshing = false; + } + } + + private async refresh(): Promise { + // Implement token fetching logic here... + this.accessToken = await fetchNewToken(this.refreshToken); + } +} + +// Usage: +const client = new GuildPassClient({ + apiUrl: 'https://api.guildpass.com', + authProvider: new OAuthAuthenticationProvider('my-refresh-token') +}); +``` + +## Registering via Builder + +You can also use the `GuildPassClientBuilder`: + +```typescript +import { GuildPassClientBuilder } from '@guildpass/sdk'; + +const client = new GuildPassClientBuilder('https://api.guildpass.com') + .withAuthProvider(new MyCustomProvider()) + .build(); +``` + +## Backwards Compatibility + +Passing `apiKey: string` to the client configuration remains fully supported. Under the hood, this will automatically use the `ApiKeyAuthenticationProvider`: + +```typescript +// These two are equivalent: +const client1 = new GuildPassClient({ + apiUrl: 'https://api.guildpass.com', + apiKey: 'gp_123' +}); + +const client2 = new GuildPassClient({ + apiUrl: 'https://api.guildpass.com', + authProvider: new ApiKeyAuthenticationProvider('gp_123') +}); +``` diff --git a/src/auth/ApiKeyAuthenticationProvider.ts b/src/auth/ApiKeyAuthenticationProvider.ts new file mode 100644 index 0000000..4cbbf11 --- /dev/null +++ b/src/auth/ApiKeyAuthenticationProvider.ts @@ -0,0 +1,9 @@ +import type { AuthenticationProvider } from './AuthenticationProvider'; + +export class ApiKeyAuthenticationProvider implements AuthenticationProvider { + constructor(private readonly apiKey: string) {} + + public getAuthorizationHeaders(): Record { + return { 'X-API-Key': this.apiKey }; + } +} diff --git a/src/auth/AuthenticationProvider.ts b/src/auth/AuthenticationProvider.ts new file mode 100644 index 0000000..ae068f1 --- /dev/null +++ b/src/auth/AuthenticationProvider.ts @@ -0,0 +1,15 @@ +export interface AuthenticationProvider { + /** + * Returns a map of headers to attach to outgoing requests. + * This is called immediately before each request. + */ + getAuthorizationHeaders(): Promise> | Record; + + /** + * Called when a request fails with a 401 Unauthorized status. + * Implementations can use this hook to refresh tokens. + * If this returns `true`, the HTTP client will retry the request. + * If this returns `false` or throws, the original 401 error is propagated. + */ + onUnauthorized?(): Promise; +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..26b6ecf --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,2 @@ +export * from './AuthenticationProvider'; +export * from './ApiKeyAuthenticationProvider'; diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index e632461..df06aac 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -110,7 +110,7 @@ export class GuildPassClient { this.http = new HttpClient( this.config.apiUrl, - this.config.apiKey, + this.config.authProvider ?? this.config.apiKey, this.config.defaultTimeoutMs ?? this.config.timeoutMs, { retry: this.config.retry, @@ -119,6 +119,7 @@ export class GuildPassClient { fetch: this.config.fetch, transport: this.config.transport, rateLimit: this.config.rateLimit, + authProvider: this.config.authProvider, metadata: { sdkVersion: SDK_VERSION, clientName: this.config.clientName, diff --git a/src/client/GuildPassClientBuilder.ts b/src/client/GuildPassClientBuilder.ts index 425e278..0a4558c 100644 --- a/src/client/GuildPassClientBuilder.ts +++ b/src/client/GuildPassClientBuilder.ts @@ -6,6 +6,7 @@ import type { ContractProvider } from '../contracts/providers/provider.types'; import type { FetchLike, HttpHooks, RateLimitConfig, RetryConfig } from '../http/http.types'; import type { Middleware } from '../middleware/middleware.types'; import type { ChainConfig, ContractReadConsensus } from '../contracts/contract.types'; +import type { AuthenticationProvider } from '../auth/AuthenticationProvider'; export class GuildPassClientBuilder { private config: Partial; @@ -27,6 +28,11 @@ export class GuildPassClientBuilder { return this; } + public withAuthProvider(authProvider: AuthenticationProvider): this { + this.config.authProvider = authProvider; + return this; + } + public withTimeout(timeoutMs: number): this { this.config.defaultTimeoutMs = timeoutMs; // Keep timeoutMs in sync for backward compatibility, although defaultTimeoutMs takes precedence diff --git a/src/config/sdkConfig.ts b/src/config/sdkConfig.ts index 3e9ca56..6d28da9 100644 --- a/src/config/sdkConfig.ts +++ b/src/config/sdkConfig.ts @@ -4,6 +4,7 @@ import { GuildPassConfigError } from '../errors/errorTypes'; import { GuildPassErrorCode } from '../errors/errorCodes'; import type { CacheAdapter } from '../cache/cache.types'; import type { Middleware } from '../middleware/middleware.types'; +import type { AuthenticationProvider } from '../auth/AuthenticationProvider'; import { ChainConfig, ContractReadConsensus } from '../contracts/contract.types'; import { ContractProvider } from '../contracts/providers/provider.types'; import { validateAddress } from '../utils/validation'; @@ -34,6 +35,7 @@ export type GuildPassClientConfig = { batchStrategy?: 'jsonrpc' | 'multicall3'; /** Per-chain RPC URL and contract address overrides, keyed by chain ID. */ chains?: Record; + authProvider?: AuthenticationProvider; apiKey?: string; timeoutMs?: number; /** @@ -135,7 +137,7 @@ export type GuildPassClientConfig = { */ export type PublicClientConfig = Omit< GuildPassClientConfig, - 'apiKey' | 'fetch' | 'transport' | 'hooks' | 'contractProvider' | 'cache' | 'middleware' + 'apiKey' | 'fetch' | 'transport' | 'hooks' | 'contractProvider' | 'cache' | 'middleware' | 'authProvider' >; /** @@ -325,6 +327,10 @@ export function validateConfig(config: GuildPassClientConfig): void { throwConfigError('apiKey must be a string', 'apiKey', 'invalid_type', config.apiKey); } + if (config.authProvider !== undefined && (typeof config.authProvider !== 'object' || config.authProvider === null)) { + throwConfigError('authProvider must be an object implementing AuthenticationProvider', 'authProvider', 'invalid_type', config.authProvider); + } + if (config.rpcUrls !== undefined) { if (!Array.isArray(config.rpcUrls) || config.rpcUrls.length === 0) { throwConfigError( diff --git a/src/contracts/contractClient.ts b/src/contracts/contractClient.ts index c0d8465..92c69cc 100644 --- a/src/contracts/contractClient.ts +++ b/src/contracts/contractClient.ts @@ -175,10 +175,11 @@ export class ContractClient { this.config = config; this.http = http ?? - new HttpClient(config.apiUrl, config.apiKey, config.timeoutMs, { + new HttpClient(config.apiUrl, config.authProvider ?? config.apiKey, config.timeoutMs, { retry: config.retry, hooks: config.hooks, fetch: config.fetch, + authProvider: config.authProvider, }); // GuildPass SDK: End of logic containment structure block. } diff --git a/src/http/http.types.ts b/src/http/http.types.ts index 1e0f9f5..ba9d36f 100644 --- a/src/http/http.types.ts +++ b/src/http/http.types.ts @@ -1,6 +1,7 @@ import type { AccessCheckParams, AccessCheckResult } from '../access/access.types'; import type { AccessRequirement, ResponseMeta } from '../types/common'; import type { Middleware } from '../middleware/middleware.types'; +import type { AuthenticationProvider } from '../auth/AuthenticationProvider'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -50,6 +51,7 @@ export type HttpClientConfig = { transport?: import('../network/transport.types').HttpTransport; metadata?: ClientMetadata; rateLimit?: RateLimitConfig; + authProvider?: AuthenticationProvider; }; export type HttpRequestOptions = { diff --git a/src/http/httpClient.ts b/src/http/httpClient.ts index cddaa3a..858b3fc 100644 --- a/src/http/httpClient.ts +++ b/src/http/httpClient.ts @@ -15,6 +15,7 @@ import { runRequestPipeline, runResponsePipeline, runErrorPipeline } from '../mi import type { RequestMiddlewarePayload, Middleware } from '../middleware/middleware.types'; import type { HttpTransport, TransportResponse } from '../network/transport.types'; import { FetchTransport } from '../network/fetchTransport'; +import type { AuthenticationProvider } from '../auth/AuthenticationProvider'; const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); const DEFAULT_RETRYABLE_STATUSES = [429, 500, 502, 503, 504]; @@ -198,7 +199,7 @@ function extractMeta(response: HttpResponse, durationMs: number): ResponseMetada export class HttpClient { private readonly baseUrl: string; - private readonly apiKey?: string; + private readonly authProvider?: AuthenticationProvider; private readonly timeoutMs: number; private readonly globalRetry?: RetryConfig; private readonly hooks?: HttpHooks; @@ -210,12 +211,19 @@ export class HttpClient { constructor( baseUrl: string, - apiKey?: string, + apiKeyOrProvider?: string | AuthenticationProvider, timeoutMs = 10000, configOrHooks?: RetryConfig | HttpHooks | HttpClientConfig, ) { this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; - this.apiKey = apiKey; + if (typeof apiKeyOrProvider === 'string' && apiKeyOrProvider.trim().length > 0) { + this.authProvider = { + getAuthorizationHeaders: () => ({ 'X-API-Key': apiKeyOrProvider }) + }; + } else if (typeof apiKeyOrProvider === 'object' && apiKeyOrProvider !== null) { + this.authProvider = apiKeyOrProvider; + } + this.timeoutMs = timeoutMs; if (configOrHooks) { @@ -226,6 +234,9 @@ export class HttpClient { this.fetchTransport = configOrHooks.fetch; this.transport = configOrHooks.transport ?? new FetchTransport(this.fetchTransport); this.metadata = configOrHooks.metadata; + if ((configOrHooks as HttpClientConfig).authProvider && !this.authProvider) { + this.authProvider = (configOrHooks as HttpClientConfig).authProvider; + } if (configOrHooks.rateLimit) this.tokenBucket = new TokenBucket(configOrHooks.rateLimit); } else if (isRetryConfig(configOrHooks)) { this.globalRetry = configOrHooks; @@ -267,9 +278,12 @@ export class HttpClient { const requestHeaders: Record = { 'Content-Type': 'application/json', - ...(this.apiKey && !isAbsolute ? { 'X-API-Key': this.apiKey } : {}), ...headers, }; + + if (!isAbsolute && this.authProvider) { + Object.assign(requestHeaders, await this.authProvider.getAuthorizationHeaders()); + } if (!isAbsolute && this.metadata?.sendClientMetadata !== false) { const sdkVersion = this.metadata?.sdkVersion; @@ -374,6 +388,14 @@ export class HttpClient { }; if (!response.ok) { + if (response.status === 401 && this.authProvider?.onUnauthorized) { + const shouldRetry = await this.authProvider.onUnauthorized(); + if (shouldRetry) { + Object.assign(requestHeaders, await this.authProvider.getAuthorizationHeaders()); + continue; + } + } + const isRetryable = canRetry && retryConfig.retryableStatuses.includes(response.status); if (isRetryable && attempt < retryConfig.maxRetries) { const retryAfter = getRetryAfter(); diff --git a/src/index.ts b/src/index.ts index add8bf4..1f413a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,9 @@ export * from './client/GuildPassClient'; export * from './client/GuildPassClientBuilder'; +// Auth +export * from './auth'; + // Cache export * from './cache/cache.types'; diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..b63cb8c --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GuildPassClient, AuthenticationProvider, ApiKeyAuthenticationProvider } from '../src'; + +describe('Pluggable Authentication Providers', () => { + let mockFetch: any; + + beforeEach(() => { + mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'g1', name: 'Test Guild' }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + vi.stubGlobal('fetch', mockFetch); + }); + + it('ApiKeyAuthenticationProvider generates correct headers', async () => { + const provider = new ApiKeyAuthenticationProvider('test-key'); + const headers = await provider.getAuthorizationHeaders(); + expect(headers).toEqual({ 'X-API-Key': 'test-key' }); + }); + + it('registers custom auth provider via builder', async () => { + const mockProvider: AuthenticationProvider = { + getAuthorizationHeaders: vi.fn().mockResolvedValue({ 'Authorization': 'Bearer custom-token' }), + }; + + const client = new GuildPassClient({ + apiUrl: 'https://api.test.com', + authProvider: mockProvider, + fetch: mockFetch, + strictInterfaceChecking: false, + }); + + await (client as any).http.get('/test'); + + expect(mockProvider.getAuthorizationHeaders).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('api.test.com'), + expect.objectContaining({ + headers: expect.objectContaining({ 'Authorization': 'Bearer custom-token' }), + }) + ); + }); + + it('respects onUnauthorized hook and retries when true', async () => { + let calls = 0; + const capturedHeaders: Record[] = []; + mockFetch.mockImplementation(async (url: any, init: any) => { + calls++; + capturedHeaders.push({ ...init.headers }); + if (calls === 1) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify({ id: 'g1', name: 'Test Guild' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + let tokenCalls = 0; + const mockProvider: AuthenticationProvider = { + getAuthorizationHeaders: vi.fn().mockImplementation(() => { + tokenCalls++; + return { 'Authorization': `Bearer token-${tokenCalls}` }; + }), + onUnauthorized: vi.fn().mockResolvedValue(true), + }; + + const client = new GuildPassClient({ + apiUrl: 'https://api.test.com', + authProvider: mockProvider, + fetch: mockFetch, + strictInterfaceChecking: false, + }); + + await (client as any).http.get('/test'); + + expect(mockProvider.onUnauthorized).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Initial call uses first token + expect(capturedHeaders[0]).toHaveProperty('Authorization', 'Bearer token-1'); + // Retry uses second token + expect(capturedHeaders[1]).toHaveProperty('Authorization', 'Bearer token-2'); + }); + + it('fails immediately on 401 if onUnauthorized returns false', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } })); + + const mockProvider: AuthenticationProvider = { + getAuthorizationHeaders: vi.fn().mockResolvedValue({ 'Authorization': 'Bearer invalid-token' }), + onUnauthorized: vi.fn().mockResolvedValue(false), + }; + + const client = new GuildPassClient({ + apiUrl: 'https://api.test.com', + authProvider: mockProvider, + fetch: mockFetch, + strictInterfaceChecking: false, + }); + + await expect((client as any).http.get('/test')).rejects.toThrow(); + + expect(mockProvider.onUnauthorized).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('falls back to default apiKey behaviour if no provider is passed', async () => { + const client = new GuildPassClient({ + apiUrl: 'https://api.test.com', + apiKey: 'legacy-key', + fetch: mockFetch, + strictInterfaceChecking: false, + }); + + await (client as any).http.get('/test'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('api.test.com'), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-API-Key': 'legacy-key' }), + }) + ); + }); +});