Tailor Platform OAuth2 public client with DPoP+PKCE support
npm install @tailor-platform/auth-public-clientBrowser client library for Tailor Platform authentication using the OAuth 2.0 public client flow with DPoP (Demonstrating Proof of Possession).
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.
``bash`
npm install @tailor-platform/auth-public-client
`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
});
`
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
let initStatus: 'pending' | 'fulfilled' | 'rejected' = 'pending';
let initError: Error | null = null;
function getInitPromise(): Promise
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(
`typescript
// App.tsx
import React from 'react';
import { useAuth } from './useAuth';const App: React.FC = () => {
const { isAuthenticated, error, login, logout } = useAuth();
if (error) {
return
Error: {error};
} if (!isAuthenticated) {
return (
Welcome
);
} return (
You are authenticated!
);
};export default App;
`$3
This library uses DPoP token binding, which requires generating fresh headers for each API request:
`typescript
// Get Authorization and DPoP headers for protected resource requests
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, // Contains Authorization and DPoP headers
'Content-Type': 'application/json'
},
body: JSON.stringify({
query:
query { ... }
})
});
`Important: Each request requires a fresh DPoP proof because the proof is bound to:
- The HTTP method (htm claim)
- The request URI (htu claim)
- The access token (ath claim)
- A unique identifier (jti claim)
- The current timestamp (iat claim)
$3
`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 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 { ... }
})
}); return response.json();
}
`API Reference
$3
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)
}
`$3
####
login(): Promise
Initiates the OAuth authentication flow by redirecting to the authorization server.`typescript
await authClient.login();
`####
logout(): Promise
Logs out the user, clears tokens from storage, and resets authentication state.`typescript
await authClient.logout();
`####
getState(): Readonly
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
Checks authentication status by verifying stored tokens and refreshing if needed.`typescript
const authState = await authClient.checkAuthStatus();
`####
getAuthUrl(): Promise
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
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
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
Updates the client configuration.`typescript
authClient.configure({
redirectUri: 'https://your-app.com/new-callback'
});
`####
refreshTokens(): Promise
Manually refreshes the access token using the refresh token.`typescript
await authClient.refreshTokens();
`####
getAuthHeaders(url: string | URL, method?: string): Promise
Generates Authorization and DPoP headers for protected resource requests.`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 containing:
- Authorization: DPoP token in format DPoP {access_token}
- DPoP: Signed JWT proofInternal 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
$3
`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.$3
`typescript
interface AuthHeaders {
Authorization: string; // "DPoP {access_token}"
DPoP: string; // DPoP proof JWT
}
`$3
`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 changesSecurity Features
$3
The library implements OAuth 2.0 Authorization Code Flow with PKCE (Proof Key for Code Exchange) for secure authorization without requiring a client secret.$3
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).$3
Built-in CSRF protection through state parameter validation during authentication callback handling.$3
Tokens and DPoP key pairs are stored in IndexedDB for persistence across sessions.$3
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
$3
`bash
Install dependencies
npm installBuild
npm run buildRun tests
npm testType check
npm run typecheck
`$3
`
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.