Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ concurrency:
cancel-in-progress: true

jobs:
test:
name: test
lint:
name: lint
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -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
108 changes: 108 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -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<string, string>> | Record<string, string>;

/**
* 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<boolean>;
}
```

## 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<Record<string, string>> {
if (!this.accessToken) {
await this.refresh();
}
return {
'Authorization': `Bearer ${this.accessToken}`
};
}

public async onUnauthorized(): Promise<boolean> {
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<void> {
// 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')
});
```
9 changes: 9 additions & 0 deletions src/auth/ApiKeyAuthenticationProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { AuthenticationProvider } from './AuthenticationProvider';

export class ApiKeyAuthenticationProvider implements AuthenticationProvider {
constructor(private readonly apiKey: string) {}

public getAuthorizationHeaders(): Record<string, string> {
return { 'X-API-Key': this.apiKey };
}
}
15 changes: 15 additions & 0 deletions src/auth/AuthenticationProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>> | Record<string, string>;

/**
* 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<boolean>;
}
2 changes: 2 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './AuthenticationProvider';
export * from './ApiKeyAuthenticationProvider';
3 changes: 2 additions & 1 deletion src/client/GuildPassClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
private readonly cache: CacheAdapter | undefined;
private readonly cacheTtl: number | undefined;
private readonly deduplication: boolean;
private readonly inFlightRequests = new Map<string, Promise<any>>();

Check warning on line 93 in src/client/GuildPassClient.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 93 in src/client/GuildPassClient.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

// GuildPass SDK: Class member structure property or constructor.
constructor(config: GuildPassClientConfig) {
Expand All @@ -110,7 +110,7 @@

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,
Expand All @@ -119,6 +119,7 @@
fetch: this.config.fetch,
transport: this.config.transport,
rateLimit: this.config.rateLimit,
authProvider: this.config.authProvider,
metadata: {
sdkVersion: SDK_VERSION,
clientName: this.config.clientName,
Expand Down
6 changes: 6 additions & 0 deletions src/client/GuildPassClientBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GuildPassClientConfig>;
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/config/sdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,6 +35,7 @@ export type GuildPassClientConfig = {
batchStrategy?: 'jsonrpc' | 'multicall3';
/** Per-chain RPC URL and contract address overrides, keyed by chain ID. */
chains?: Record<number, ChainConfig>;
authProvider?: AuthenticationProvider;
apiKey?: string;
timeoutMs?: number;
/**
Expand Down Expand Up @@ -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'
>;

/**
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/contracts/contractClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
Expand Down
2 changes: 2 additions & 0 deletions src/http/http.types.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -50,6 +51,7 @@ export type HttpClientConfig = {
transport?: import('../network/transport.types').HttpTransport;
metadata?: ClientMetadata;
rateLimit?: RateLimitConfig;
authProvider?: AuthenticationProvider;
};

export type HttpRequestOptions<TBody = unknown> = {
Expand Down
30 changes: 26 additions & 4 deletions src/http/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -267,9 +278,12 @@ export class HttpClient {

const requestHeaders: Record<string, string> = {
'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;
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
export * from './client/GuildPassClient';
export * from './client/GuildPassClientBuilder';

// Auth
export * from './auth';

// Cache
export * from './cache/cache.types';

Expand Down
Loading
Loading