@tailor-platform/auth-public-client
v0.5.0
Published
Tailor Platform OAuth2 public client with DPoP+PKCE support
Readme
@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
npm install @tailor-platform/auth-public-clientBasic Usage
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.
// 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
// 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>
);// 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:
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:
// 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.
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
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
createAuthClient(config: AuthClientConfig): AuthClientConfiguration
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.
await authClient.login();logout(): Promise<void>
Logs out the user, clears tokens from storage, and resets authentication state.
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.
const { isAuthenticated, error, isReady } = authClient.getState();
// isAuthenticated indicates whether the user is authenticatedcheckAuthStatus(): Promise<AuthState>
Checks authentication status by verifying stored tokens and refreshing if needed.
const authState = await authClient.checkAuthStatus();getAuthUrl(): Promise<string>
Generates an authentication URL without triggering redirect (useful for popup flows).
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.
// 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.
// 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.
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.
authClient.configure({
redirectUri: "https://your-app.com/new-callback",
});refreshTokens(): Promise<void>
Manually refreshes the access token using the refresh token.
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.
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,URLobject, orRequestobjectinit: StandardRequestInitoptions (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
RequestInitoptions (signal,credentials,mode,cache, etc.) are preserved and forwarded to nativefetch
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.
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 requestmethod: HTTP method (default: 'GET')
Returns: Promise<AuthHeaders> containing:
Authorization: DPoP token in formatDPoP {access_token}DPoP: Signed JWT proof
Internal Behavior:
This method performs the following steps internally:
- Token Expiry Check: Checks if the current access token will expire within 60 seconds
- Automatic Token Refresh: If the token is expiring soon, automatically refreshes it using the stored refresh token
- State Update: If a token refresh occurred, updates the internal state and emits a
token_refreshevent - DPoP Proof Generation: Generates a fresh DPoP proof JWT bound to:
- The HTTP method (
htmclaim) - The request URI (
htuclaim) - The access token hash (
athclaim) - A unique identifier (
jticlaim) - The current timestamp (
iatclaim) - The server-provided nonce (
nonceclaim, if available)
- The HTTP method (
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
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
interface AuthHeaders {
Authorization: string; // "DPoP {access_token}"
DPoP: string; // DPoP proof JWT
}AuthEvent
interface AuthEvent {
type: "login" | "logout" | "token_refresh" | "auth_error" | "auth_state_changed";
data?: any;
}Event Types:
login: Fired when user successfully logs inlogout: Fired when user logs outtoken_refresh: Fired when tokens are successfully refreshedauth_error: Fired when authentication errors occurauth_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+jwtalg:ES256(ECDSA P-256)jwk: Public key in JWK formatjti: Unique identifierhtm: HTTP methodhtu: Request URIiat: Issued at timestampath: 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
# Install dependencies
npm install
# Build
npm run build
# Run tests
npm test
# Type check
npm run typecheckProject 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 fileCopyright © 2026 Tailor Inc.
