npm package: @tailor-platform/auth-public-client
Description: Tailor Platform OAuth2 public client with DPoP+PKCE support
Author: Tailor Inc.
Latest version: 0.5.0

# @tailor-platform/auth-public-client

Browser client library for Tailor Platform authentication using the OAuth 2.0 public client flow with DPoP (Demonstrating Proof of Possession).

## Overview

This library provides an authentication solution for browser-based applications integrating with the Tailor Platform. It implements OAuth 2.0 Authorization Code Flow with PKCE and DPoP token binding (RFC 9449) for enhanced security.

**Key Features:**

- OAuth 2.0 Authorization Code Flow with PKCE
- DPoP (Demonstrating Proof of Possession) token binding for enhanced security
- Automatic token refresh
- IndexedDB-based secure storage
- TypeScript support
- Event-based state management

The library provides a functional approach with the `createAuthClient` function and has been designed with a modular architecture for maintainability.

## Installation

```bash
npm install @tailor-platform/auth-public-client
```

## Basic Usage

```typescript
import { createAuthClient } from "@tailor-platform/auth-public-client";

const authClient = createAuthClient({
  clientId: "your-client-id",
  appUri: "https://your-tailor-app-xxxxxxxx.erp.dev",
  redirectUri: "https://your-app.com/callback", // Optional: defaults to current origin
});
```

### React Integration Example with Suspense

The library is designed to work with React's `Suspense` for optimal performance and consistency. The `AuthState` includes an `isReady` field that indicates whether the initial authentication check has completed.

```typescript
// useAuth.ts
import { useSyncExternalStore, useCallback } from "react";
import { createAuthClient } from "@tailor-platform/auth-public-client";

// Create auth client instance
const authClient = createAuthClient({
  clientId: "your-client-id",
  appUri: "https://your-tailor-app-xxxxxxxx.erp.dev",
  redirectUri: window.location.origin + "/callback",
});

// Subscribe function for useSyncExternalStore
const subscribe = (callback: () => void) => {
  return authClient.addEventListener((event) => {
    if (event.type === "auth_state_changed") {
      callback();
    }
  });
};

const getSnapshot = () => authClient.getState();

// Suspense-compatible initialization
let initPromise: Promise<void> | null = null;
let initStatus: "pending" | "fulfilled" | "rejected" = "pending";
let initError: Error | null = null;

function getInitPromise(): Promise<void> {
  if (initPromise === null) {
    const params = new URLSearchParams(window.location.search);
    if (params.has("code")) {
      initPromise = authClient.handleCallback().then(() => {
        window.history.replaceState({}, "", window.location.pathname);
      });
    } else {
      initPromise = authClient.checkAuthStatus().then(() => {});
    }

    initPromise
      .then(() => {
        initStatus = "fulfilled";
      })
      .catch((error) => {
        initStatus = "rejected";
        initError = error;
      });
  }
  return initPromise;
}

// React 18+ compatible Suspense - throws Promise while pending
function useSuspenseInit(): void {
  getInitPromise();
  if (initStatus === "pending") throw initPromise;
  if (initStatus === "rejected") throw initError;
}

export function useAuth() {
  // Suspense: throw Promise while initialization is pending
  useSuspenseInit();

  const authState = useSyncExternalStore(subscribe, getSnapshot);

  const login = useCallback(async () => {
    await authClient.login();
  }, []);
  const logout = useCallback(async () => {
    await authClient.logout();
  }, []);

  return { ...authState, login, logout };
}
```

**Note**: The `isReady` field in `AuthState` indicates whether the initial authentication check has completed. When using Suspense, the loading state is handled automatically by the Suspense boundary.

#### App Setup with Suspense

```typescript
// main.tsx
import { StrictMode, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <Suspense fallback={<div>Loading...</div>}>
      <App />
    </Suspense>
  </StrictMode>
);
```

```typescript
// App.tsx
import React from 'react';
import { useAuth } from './useAuth';

const App: React.FC = () => {
  const { isAuthenticated, error, login, logout } = useAuth();

  if (error) {
    return <div>Error: {error}</div>;
  }

  if (!isAuthenticated) {
    return (
      <div>
        <h1>Welcome</h1>
        <button onClick={login}>
          Log In
        </button>
      </div>
    );
  }

  return (
    <div>
      <h1>You are authenticated!</h1>
      <button onClick={logout}>
        Log Out
      </button>
    </div>
  );
};

export default App;
```

### Making Authenticated API Requests

Use `authClient.fetch()` to make authenticated requests. It has the same signature as the standard `fetch` API and transparently handles DPoP proof generation and token refresh:

```typescript
const response = await authClient.fetch("https://your-app.erp.dev/query", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ query: `query { ... }` }),
});
```

It can also be passed directly to GraphQL clients that accept a custom `fetch` option:

```typescript
// urql
const urqlClient = new Client({
  url: graphqlEndpoint,
  exchanges: [cacheExchange, fetchExchange],
  fetch: authClient.fetch,
});

// Apollo
const link = createHttpLink({
  uri: graphqlEndpoint,
  fetch: authClient.fetch,
});
```

#### Using `getAuthHeaders()` (Advanced)

If you need lower-level control, `getAuthHeaders()` returns raw DPoP headers. **Important:** it must be called for every request — do NOT reuse the returned headers.

```typescript
const headers = await authClient.getAuthHeaders("https://your-app.erp.dev/query", "POST");

const response = await fetch("https://your-app.erp.dev/query", {
  method: "POST",
  headers: {
    ...headers,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ query: `query { ... }` }),
});
```

### Vanilla JavaScript Example

```javascript
import { createAuthClient } from "@tailor-platform/auth-public-client";

const authClient = createAuthClient({
  clientId: "your-client-id",
  appUri: "https://your-tailor-app-xxxxxxxx.erp.dev",
  redirectUri: window.location.origin + "/callback",
});

// Listen for auth state changes
authClient.addEventListener((event) => {
  if (event.type === "auth_state_changed") {
    const { isAuthenticated, error, isReady } = event.data;

    if (!isReady) {
      document.getElementById("user-info").textContent = "Loading...";
      return;
    }

    if (error) {
      document.getElementById("error-info").textContent = `Error: ${error}`;
      return;
    }

    if (isAuthenticated) {
      document.getElementById("user-info").textContent = "Authenticated";
      document.getElementById("login-btn").style.display = "none";
      document.getElementById("logout-btn").style.display = "block";
    } else {
      document.getElementById("user-info").textContent = "Not signed in";
      document.getElementById("login-btn").style.display = "block";
      document.getElementById("logout-btn").style.display = "none";
    }
  }
});

// Initialize authentication
async function initAuth() {
  const params = new URLSearchParams(window.location.search);
  if (params.has("code")) {
    // Handle OAuth callback
    await authClient.handleCallback();
    window.history.replaceState({}, "", window.location.pathname);
  } else {
    // Check existing auth status
    await authClient.checkAuthStatus();
  }
}

initAuth();

// Login button
document.getElementById("login-btn").addEventListener("click", () => {
  authClient.login();
});

// Logout button
document.getElementById("logout-btn").addEventListener("click", () => {
  authClient.logout();
});

// Make authenticated API request
async function fetchData() {
  const response = await authClient.fetch("https://your-app.erp.dev/query", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: `query { ... }`,
    }),
  });

  return response.json();
}
```

## API Reference

### createAuthClient

The main function for creating an authentication client.

#### Function Signature

```typescript
createAuthClient(config: AuthClientConfig): AuthClient
```

#### Configuration

```typescript
interface AuthClientConfig {
  clientId: string; // OAuth client ID (required)
  appUri: string; // Tailor Platform App URI (required)
  redirectUri?: string; // Callback URL after authentication (optional, defaults to current origin)
}
```

### Methods

#### `login(): Promise<void>`

Initiates the OAuth authentication flow by redirecting to the authorization server.

```typescript
await authClient.login();
```

#### `logout(): Promise<void>`

Logs out the user, clears tokens from storage, and resets authentication state.

```typescript
await authClient.logout();
```

#### `getState(): Readonly<AuthState>`

Returns the current authentication state (read-only reference). The `isReady` field indicates whether the initial authentication check has completed.

```typescript
const { isAuthenticated, error, isReady } = authClient.getState();
// isAuthenticated indicates whether the user is authenticated
```

#### `checkAuthStatus(): Promise<AuthState>`

Checks authentication status by verifying stored tokens and refreshing if needed.

```typescript
const authState = await authClient.checkAuthStatus();
```

#### `getAuthUrl(): Promise<string>`

Generates an authentication URL without triggering redirect (useful for popup flows).

```typescript
const authUrl = await authClient.getAuthUrl();
window.open(authUrl, "auth-popup", "width=500,height=600");
```

#### `handleCallback(): Promise<void>`

Handles OAuth callback after redirect. Call this when the user returns from the authorization server.

```typescript
// In your callback route/page
if (window.location.search.includes("code=")) {
  await authClient.handleCallback();
}
```

#### `ready(): Promise<void>`

Returns a Promise that resolves when the initial authentication check has completed. Useful for Suspense integration.

```typescript
// With React 19's use() hook
import { use } from "react";
use(authClient.ready());

// Or with await
await authClient.ready();
```

#### `addEventListener(listener: AuthEventListener): () => void`

Adds an event listener for authentication events. Returns an unsubscribe function.

```typescript
const unsubscribe = authClient.addEventListener((event) => {
  console.log("Auth event:", event.type, event.data);
});

// Later, unsubscribe
unsubscribe();
```

#### `configure(newConfig: Partial<AuthClientConfig>): void`

Updates the client configuration.

```typescript
authClient.configure({
  redirectUri: "https://your-app.com/new-callback",
});
```

#### `refreshTokens(): Promise<void>`

Manually refreshes the access token using the refresh token.

```typescript
await authClient.refreshTokens();
```

#### `fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>`

Fetches a protected resource with automatic DPoP authentication. Has the same signature as the standard `fetch` API.

DPoP proof generation, token refresh, and header injection are handled transparently. Can be passed directly to GraphQL clients (urql, Apollo) as a custom `fetch` option.

```typescript
const response = await authClient.fetch("https://your-app.erp.dev/query", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ query: `query { ... }` }),
});
```

**Parameters:**

- `input`: URL string, `URL` object, or `Request` object
- `init`: Standard `RequestInit` options (method, headers, body, etc.)

**Returns:** `Promise<Response>` — standard `fetch` Response

**Error Handling:**

- Throws `Error('No valid access token')` if no valid token is available and refresh fails

**Notes:**

- Absolute URLs are required — relative URLs (e.g. `'/api'`) are not supported because DPoP proof binding requires a full URI
- All `RequestInit` options (`signal`, `credentials`, `mode`, `cache`, etc.) are preserved and forwarded to native `fetch`

#### `getAuthHeaders(url: string | URL, method?: string): Promise<AuthHeaders>`

Generates Authorization and DPoP headers for protected resource requests.

**Important:** This method MUST be called for every individual HTTP request. Do NOT cache or reuse the returned headers. Each DPoP proof is unique and single-use.

```typescript
const headers = await authClient.getAuthHeaders("https://your-app.erp.dev/query", "POST");
// Returns:
// {
//   Authorization: 'DPoP eyJhbGci...',
//   DPoP: 'eyJ0eXAi...'
// }
```

**Parameters:**

- `url`: The target URL for the request
- `method`: HTTP method (default: 'GET')

**Returns:** `Promise<AuthHeaders>` containing:

- `Authorization`: DPoP token in format `DPoP {access_token}`
- `DPoP`: Signed JWT proof

**Internal Behavior:**

This method performs the following steps internally:

1. **Token Expiry Check**: Checks if the current access token will expire within 60 seconds
2. **Automatic Token Refresh**: If the token is expiring soon, automatically refreshes it using the stored refresh token
3. **State Update**: If a token refresh occurred, updates the internal state and emits a `token_refresh` event
4. **DPoP Proof Generation**: Generates a fresh DPoP proof JWT bound to:
   - The HTTP method (`htm` claim)
   - The request URI (`htu` claim)
   - The access token hash (`ath` claim)
   - A unique identifier (`jti` claim)
   - The current timestamp (`iat` claim)
   - The server-provided nonce (`nonce` claim, if available)

**Error Handling:**

- Throws `Error('No valid access token')` if no valid token is available and refresh fails
- If token refresh fails, the method will not return headers (ensure proper error handling in your code)

## Types

### AuthState

```typescript
interface AuthState {
  isAuthenticated: boolean; // Whether the user is authenticated
  error: string | null; // Error message (if any)
  isReady: boolean; // Whether initial auth check has completed
}
```

**Note**: The `isReady` field indicates whether the initial authentication check has completed. When using React's Suspense, the loading state is handled automatically by the Suspense boundary.

### AuthHeaders

```typescript
interface AuthHeaders {
  Authorization: string; // "DPoP {access_token}"
  DPoP: string; // DPoP proof JWT
}
```

### AuthEvent

```typescript
interface AuthEvent {
  type: "login" | "logout" | "token_refresh" | "auth_error" | "auth_state_changed";
  data?: any;
}
```

**Event Types:**

- `login`: Fired when user successfully logs in
- `logout`: Fired when user logs out
- `token_refresh`: Fired when tokens are successfully refreshed
- `auth_error`: Fired when authentication errors occur
- `auth_state_changed`: Fired when authentication state changes

## Security Features

### OAuth 2.0 with PKCE

The library implements OAuth 2.0 Authorization Code Flow with PKCE (Proof Key for Code Exchange) for secure authorization without requiring a client secret.

### DPoP Token Binding (RFC 9449)

DPoP (Demonstrating Proof of Possession) binds access tokens to a cryptographic key pair, preventing token theft and replay attacks.

Each DPoP proof JWT contains:

- `typ`: `dpop+jwt`
- `alg`: `ES256` (ECDSA P-256)
- `jwk`: Public key in JWK format
- `jti`: Unique identifier
- `htm`: HTTP method
- `htu`: Request URI
- `iat`: Issued at timestamp
- `ath`: Access token hash (SHA-256, base64url encoded)

**Note:** DPoP nonce handling for token requests is automatically managed by the underlying `openid-client` library (RFC 9449 Section 8).

### State Parameter Validation

Built-in CSRF protection through state parameter validation during authentication callback handling.

### IndexedDB Storage

Tokens and DPoP key pairs are stored in IndexedDB for persistence across sessions.

### Automatic Token Refresh

Access tokens are automatically refreshed before expiry to ensure uninterrupted API access.

## Browser Compatibility

This library requires:

- Web Crypto API (for DPoP key generation and signing)
- IndexedDB (for secure storage)
- ES2020+ support

**Supported Browsers:**

- Chrome 80+
- Firefox 78+
- Safari 14+
- Edge 80+

## Development

### Building the Library

```bash
# Install dependencies
npm install

# Build
npm run build

# Run tests
npm test

# Type check
npm run typecheck
```

### Project Structure

```
auth-public-client/
├── src/
│   ├── auth-client.ts     # Main authentication function (createAuthClient)
│   ├── index.ts           # Entry point and exports
│   ├── types/             # Type definitions
│   │   ├── auth.ts        # Authentication-related types
│   │   ├── config.ts      # Configuration types
│   │   └── index.ts       # Type exports
│   ├── internal/          # Internal implementation modules
│   │   ├── store.ts       # State management
│   │   ├── auth-operations.ts  # Authentication operations
│   │   ├── event-system.ts     # Event handling system
│   │   ├── config-management.ts # Configuration management
│   │   └── dpop-manager.ts     # DPoP proof generation
│   └── utils/             # Utility functions
│       ├── storage.ts     # IndexedDB storage
│       └── oauth.ts       # OAuth/OIDC utilities (openid-client wrapper)
├── tests/                 # Test files
├── dist/                  # Built files (generated)
├── package.json           # Package configuration
├── tsconfig.json          # TypeScript config
└── README.md              # This file
```

---

Copyright © 2026 Tailor Inc.