# @hiyve/identity-client

Zero-dependency browser SDK for Hiyve Identity. Built on native `fetch` and `localStorage`.

## Features

### Authentication
- **Register, login, logout** -- email/password auth with automatic token storage
- **Automatic token refresh** -- refreshes JWT access tokens before they expire
- **Persistent sessions** -- tokens survive page reloads via `localStorage` (or in-memory for SSR)
- **Password reset** -- request reset emails and submit new passwords via token
- **Email verification** -- verify addresses and resend verification emails

### MCP Tokens (Programmatic Access)
- **Create tokens** -- long-lived API tokens for CI/CD, integrations, and MCP server connections
- **Permission scoping** -- restrict tokens to specific capabilities (`mcp:data:read`, `mcp:search:execute`, etc.)
- **IP restrictions** -- limit token usage to specific CIDR ranges
- **Lifecycle management** -- list, validate, and revoke tokens

### OAuth 2.1 (PKCE)
- **Client management** -- create, list, and delete OAuth clients with redirect URI validation
- **Authorization Code flow** -- full OAuth 2.1 with automatic PKCE S256 generation
- **Token exchange** -- exchange authorization codes for access/refresh token pairs
- **Token refresh** -- rotate OAuth refresh tokens

### Two-Factor Authentication (Adaptive)
- **Invisible device recognition** -- known devices bypass 2FA automatically
- **Email OTP verification** -- 6-digit one-time codes for unknown devices
- **Automatic device fingerprinting** -- screen resolution, timezone, language, and platform signals
- **TFA event system** -- subscribe to `tfaRequired` events for custom UI flows
- **Configurable per-org** -- TFA and email verification can be independently enabled or disabled via organization settings (see [Server Integration Guide](https://sdk.hiyve.dev/guides/server-integration#email-verification-and-tfa-configuration))

### Trusted Device Management
- **List devices** -- view all recognized devices for the authenticated user
- **Revoke devices** -- remove specific devices or revoke all at once
- **Automatic registration** -- devices are registered as trusted after successful OTP verification

### User Profile
- **Get profile** -- fetch the full user profile (name, picture, phone, organization, metadata, roles)
- **Update profile** -- update display name, picture, phone, organization, and custom metadata

### Developer Experience
- **Zero runtime dependencies** -- ships as ES module (~18 KB) and UMD (~9 KB)
- **TypeScript definitions** included
- **Structured errors** -- every failure is an `AuthError` with a typed `code` and optional `details`
- **Event system** -- subscribe to auth state changes, token refresh, and errors
- **Framework-agnostic** -- works with React, Vue, vanilla JS, or any browser environment

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Authentication Flows](#authentication-flows)
  - [Register](#register)
  - [Login](#login)
  - [Login with TFA](#login-with-tfa)
  - [Logout](#logout)
  - [Get Current User](#get-current-user)
  - [User Profile](#user-profile)
  - [Refresh Tokens](#refresh-tokens)
- [Password Reset](#password-reset)
- [Email Verification](#email-verification)
- [MCP Tokens](#mcp-tokens)
- [OAuth 2.1 (PKCE)](#oauth-21-pkce)
  - [Client Management](#client-management)
  - [Authorization Flow](#authorization-flow)
- [Two-Factor Authentication](#two-factor-authentication)
  - [Handling TFA Challenge](#handling-tfa-challenge)
  - [Verifying OTP](#verifying-otp)
  - [Resending OTP](#resending-otp)
- [Trusted Device Management](#trusted-device-management)
- [Auth State and Events](#auth-state-and-events)
- [Token Management](#token-management)
- [Error Handling](#error-handling)
- [Advanced Usage](#advanced-usage)
  - [Using HttpClient Directly](#using-httpclient-directly)
  - [Using TokenManager Directly](#using-tokenmandirectly)
  - [Using PKCEHelper Directly](#using-pkcehelper-directly)
  - [Using DeviceFingerprint Directly](#using-devicefingerprint-directly)
  - [Memory Storage (SSR / Tests)](#memory-storage)
  - [Custom Base Path](#custom-base-path)
  - [Framework Integration (React)](#framework-integration-react)
- [API Reference](#api-reference)
  - [HiyveAuth](#hiyveauth)
  - [TokenManager](#tokenmanager-1)
  - [HttpClient](#httpclient)
  - [PKCEHelper](#pkcehelper)
  - [DeviceFingerprint](#devicefingerprint)
  - [EventEmitter](#eventemitter)
  - [AuthError](#autherror)
  - [AUTH_ERRORS](#auth_errors)
- [Security & Compliance](#security--compliance)
- [Browser Support](#browser-support)
- [License](#license)

## Installation

```bash
npm install @hiyve/identity-client
```

### UMD (script tag)

```html
<script src="path/to/hiyve-identity-client.umd.cjs"></script>
<script>
  const auth = new HiyveIdentityClient.HiyveAuth({
    apiKey: 'pk_live_your_key',
  });
</script>
```

## Quick Start

```js
import { HiyveAuth } from '@hiyve/identity-client';

const auth = new HiyveAuth({
  apiKey: 'pk_live_your_api_key',
});

// Register a new user
await auth.register({
  email: 'user@example.com',
  password: 'securePassword123',
  name: 'Jane Doe',
});

// Log in (may trigger TFA on unknown devices)
const result = await auth.login({
  email: 'user@example.com',
  password: 'securePassword123',
});

if (result.tfaRequired) {
  // Unknown device -- OTP code sent to user's email
  console.log('Check your email for a verification code');

  // After user enters the code:
  const { user } = await auth.verifyTfa({ code: '123456' });
  console.log('Logged in as:', user.email);
} else {
  console.log('Logged in as:', result.user.email);
}

console.log('Authenticated:', auth.isAuthenticated()); // true

// Listen for auth state changes
const unsubscribe = auth.onAuthStateChange(({ authenticated, user }) => {
  console.log('Auth state changed:', authenticated, user);
});

// Log out
await auth.logout();

// Clean up when done (e.g. component unmount)
unsubscribe();
auth.destroy();
```

## Configuration

Pass a config object to the `HiyveAuth` constructor:

```js
const auth = new HiyveAuth({
  // Required
  apiKey: 'pk_live_your_api_key',      // Your public API key

  // Optional (defaults shown)
  environment: 'production',           // 'production' or 'development'
  basePath: '/identity/auth',          // API route prefix
  tokenStorage: 'localStorage',        // 'localStorage' or 'memory'
  autoRefresh: true,                   // Auto-refresh tokens before expiry
  refreshBuffer: 300,                  // Seconds before expiry to trigger refresh
  timeout: 30000,                      // Request timeout in milliseconds
});
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `apiKey` | `string` | *required** | Public API key (`pk_live_*` or `pk_test_*`) |
| `baseUrl` | `string` | — | Full base URL for proxy mode (e.g. `'/api/hiyve/identity'`). When set, `apiKey` is not required |
| `environment` | `string` | `'production'` | `'production'` or `'development'` |
| `basePath` | `string` | `'/identity/auth'` | API route prefix (ignored when `baseUrl` is set) |
| `tokenStorage` | `string` | `'localStorage'` | `'localStorage'` for persistent sessions, `'memory'` for ephemeral. **Production recommendation:** use `'memory'` to protect refresh tokens from XSS — see [Security & Compliance](#security--compliance) |
| `autoRefresh` | `boolean` | `true` | Automatically refresh tokens before they expire |
| `refreshBuffer` | `number` | `300` | How many seconds before expiry to trigger auto-refresh |
| `timeout` | `number` | `30000` | HTTP request timeout in milliseconds |

*`apiKey` is required unless `baseUrl` is provided for proxy mode.

## Authentication Flows

### Register

Create a new user account. Depending on the organization's `emailVerificationRequired` setting, the server may send a verification email or mark the user as verified immediately.

```js
try {
  const { user } = await auth.register({
    email: 'user@example.com',
    password: 'securePassword123',
    name: 'Jane Doe',               // optional
    metadata: { role: 'viewer' },    // optional -- custom data
  });

  console.log('Registered:', user.id);

  if (user.emailVerified) {
    // Email verification not required -- user can log in immediately
    console.log('Ready to log in');
  } else {
    // Email verification required -- tell user to check their inbox
    console.log('Check your email for a verification link');
  }
} catch (err) {
  if (err.code === 'USER_ALREADY_EXISTS') {
    console.log('Email already registered');
  }
}
```

**Note:** Registration does not automatically log the user in. Call `auth.login()` after registration.

### Login

Authenticate with email and password. On success, tokens are stored automatically and auto-refresh is started.

```js
try {
  const { user, accessToken, refreshToken, expiresIn } = await auth.login({
    email: 'user@example.com',
    password: 'securePassword123',
  });

  console.log('Welcome,', user.name);
  console.log('Token expires in', expiresIn, 'seconds');
} catch (err) {
  if (err.code === 'INVALID_CREDENTIALS') {
    console.log('Wrong email or password');
  } else if (err.code === 'EMAIL_NOT_VERIFIED') {
    console.log('Please verify your email first');
  }
}
```

### Login with TFA

When a user logs in from an unrecognized device, the server returns a TFA challenge instead of tokens. The SDK stores the challenge internally and emits a `tfaRequired` event.

```js
const result = await auth.login({
  email: 'user@example.com',
  password: 'securePassword123',
});

if (result.tfaRequired) {
  // The response includes:
  // - challengeToken: stored internally by the SDK
  // - methods: ['email_otp'] -- available verification methods
  // - expiresIn: seconds until the challenge expires (default: 600)
  // - user: { id, email, name } -- the user being authenticated

  console.log('TFA required, methods:', result.methods);
  console.log('Code expires in', result.expiresIn, 'seconds');

  // Prompt the user for the OTP code from their email, then:
  const { user, accessToken, deviceId } = await auth.verifyTfa({ code: '123456' });
  console.log('Logged in as:', user.email);
  // The device is now trusted -- future logins from this device skip TFA
} else {
  // Known device -- direct login (no TFA needed)
  const { user, accessToken } = result;
  console.log('Logged in as:', user.email);
}
```

Subsequent logins from the same device (within 90 days) will bypass TFA automatically.

### Logout

Invalidates the session server-side and clears all local tokens. The server call is best-effort -- local state is always cleared even if the network request fails.

```js
await auth.logout();
console.log(auth.isAuthenticated()); // false
```

### Get Current User

Fetch the authenticated user's profile from the server.

```js
const { user } = await auth.getUser();
console.log(user.email, user.name);
```

This requires a valid access token. If the token is expired and auto-refresh is enabled, the SDK will attempt to refresh it before the request.

### User Profile

Fetch or update the authenticated user's full profile, which includes additional fields beyond what `getUser()` returns (picture, phone, organization, metadata, roles, etc.).

#### Get Profile

```js
const { profile } = await auth.getProfile();
console.log(profile.name, profile.email);
console.log(profile.organization, profile.phone);
console.log(profile.metadata); // custom key-value data
```

#### Update Profile

```js
const { profile } = await auth.updateProfile({
  name: 'Jane Smith',
  phone: '+1234567890',
  organization: 'Acme Corp',
  metadata: { department: 'Engineering', theme: 'dark' },
});
console.log('Updated:', profile.name);
```

Updatable fields: `name`, `picture`, `phone`, `organization`, and `metadata`. Roles cannot be changed via this method.

### Refresh Tokens

Manually refresh access and refresh tokens. This is handled automatically when `autoRefresh` is enabled, but you can call it explicitly if needed.

```js
const { accessToken, refreshToken, expiresIn } = await auth.refreshTokens();
```

If the refresh token is invalid or expired, this throws an `AuthError` with code `TOKEN_REFRESH_FAILED` and clears the local auth state.

## Password Reset

### Request a Reset Email

```js
await auth.requestPasswordReset({ email: 'user@example.com' });
// Server sends an email with a reset link containing a token
```

### Reset Password with Token

Extract the token from the reset link (typically a URL query parameter) and submit the new password:

```js
await auth.resetPassword({
  token: 'reset_token_from_email_link',
  password: 'newSecurePassword456',
});
```

## Email Verification

### Verify Email

Extract the token from the verification link and submit it:

```js
await auth.verifyEmail({ token: 'verification_token_from_email' });
```

### Resend Verification Email

```js
await auth.resendVerification({ email: 'user@example.com' });
```

## MCP Tokens

MCP tokens are long-lived API tokens for programmatic access (CI/CD pipelines, integrations, MCP server connections). All MCP token methods require the user to be logged in.

### Create a Token

```js
const { rawToken, token } = await auth.createMCPToken({
  name: 'CI/CD Pipeline',
  permissions: ['mcp:data:read', 'mcp:search:execute'],
  expiresIn: '90d',
  ipRestriction: ['203.0.113.0/24'],
});

// Store rawToken securely -- it cannot be retrieved again
console.log('Token:', rawToken);
console.log('Token ID:', token.id);
```

### List Tokens

```js
const { tokens } = await auth.listMCPTokens();
tokens.forEach((t) => {
  console.log(t.id, t.metadata.name, t.expiresAt);
});
```

### Validate a Token

```js
const { valid, token } = await auth.validateMCPToken(rawToken);
if (valid) {
  console.log('Token is valid, permissions:', token.permissions);
}
```

### Revoke a Token

```js
await auth.revokeMCPToken(tokenId);
```

## OAuth 2.1 (PKCE)

The SDK supports the full OAuth 2.1 Authorization Code flow with PKCE (S256). This enables secure third-party integrations.

### Client Management

Create and manage OAuth clients. All client management methods require the user to be logged in.

```js
// Create a client
const { clientId, clientSecret, client } = await auth.createOAuthClient({
  clientName: 'My Integration',
  redirectUris: ['https://myapp.com/callback'],
  scopes: ['mcp:data:read'],
});

// List clients
const { clients } = await auth.listOAuthClients();

// Get a specific client
const { client: details } = await auth.getOAuthClient(clientId);

// Delete a client
await auth.deleteOAuthClient(clientId);
```

### Authorization Flow

The full OAuth 2.1 flow with PKCE:

```js
// Step 1: Create an authorization code (user must be logged in)
// PKCE verifier/challenge are generated automatically
const { code, codeVerifier, state } = await auth.createAuthorizationCode({
  clientId: 'your-client-id',
  redirectUri: 'https://myapp.com/callback',
  scope: 'mcp:data:read',
  state: 'random-state-value',
});

// Step 2: Exchange the code for tokens (no login required)
const tokens = await auth.exchangeAuthorizationCode({
  code,
  redirectUri: 'https://myapp.com/callback',
  codeVerifier,
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',
});

console.log(tokens.access_token);
console.log(tokens.refresh_token);
console.log(tokens.expires_in);

// Step 3: Refresh OAuth tokens when they expire
const newTokens = await auth.refreshOAuthToken({
  refreshToken: tokens.refresh_token,
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',
});
```

## Two-Factor Authentication

The SDK supports adaptive two-factor authentication. When TFA is enabled on the organization (`settings.tfa.enabled`), unknown devices trigger an email OTP challenge. Known (trusted) devices bypass TFA automatically. When TFA is disabled, `login()` always returns tokens directly.

TFA and email verification are independent settings -- you can use either, both, or neither. See the [Server Integration Guide](https://sdk.hiyve.dev/guides/server-integration#email-verification-and-tfa-configuration) for configuration details.

### Handling TFA Challenge

Use the `onTfaRequired` event to react when login triggers a TFA challenge:

```js
// Listen for TFA events before calling login
const unsubscribe = auth.onTfaRequired(({ methods, expiresIn, user }) => {
  console.log('TFA required for', user.email);
  console.log('Available methods:', methods);  // ['email_otp']
  console.log('Code expires in', expiresIn, 'seconds');

  // Show your OTP input UI here
});

const result = await auth.login({ email, password });

if (result.tfaRequired) {
  // The onTfaRequired callback has already fired
  // Wait for user to enter their OTP code...
}
```

### Verifying OTP

After the user receives and enters the 6-digit code from their email:

```js
try {
  const { user, accessToken, deviceId } = await auth.verifyTfa({
    code: '123456',
    type: 'email_otp',   // optional, defaults to 'email_otp'
  });

  console.log('Authenticated:', user.email);
  console.log('Device trusted:', deviceId);
  // This device is now registered as trusted for future logins
} catch (err) {
  switch (err.code) {
    case 'TFA_INVALID_CODE':
      console.log('Wrong code, try again');
      break;
    case 'TFA_CHALLENGE_EXPIRED':
      console.log('Code expired, please log in again');
      break;
    case 'TFA_CHALLENGE_LOCKED':
      console.log('Too many attempts, please log in again');
      break;
    case 'INVALID_STATE':
      console.log('No pending TFA challenge -- call login() first');
      break;
  }
}
```

### Resending OTP

If the user didn't receive the code or it expired:

```js
const { message, expiresIn } = await auth.resendOtp();
console.log(message);  // 'New code sent'
console.log('New code expires in', expiresIn, 'seconds');
```

## Trusted Device Management

After successful TFA verification, the device is automatically registered as trusted. Users can view and manage their trusted devices.

### List Trusted Devices

```js
const { devices } = await auth.listTrustedDevices();
devices.forEach((device) => {
  console.log(device.deviceId, device.deviceName, device.lastUsedAt);
});
```

### Revoke a Specific Device

```js
await auth.revokeTrustedDevice('device-id-to-revoke');
// The device will require TFA on next login
```

### Revoke All Devices

```js
const { count } = await auth.revokeAllTrustedDevices();
console.log(`Revoked ${count} devices`);
// All devices (including current) will require TFA on next login
```

## Auth State and Events

### Check Authentication Status

`isAuthenticated()` returns `true` if there is a non-expired access token stored locally. It does not make a network request.

```js
if (auth.isAuthenticated()) {
  // User has a valid token
}
```

### Get Access Token

Retrieve the raw access token string for use in custom API calls:

```js
const token = auth.getAccessToken();
if (token) {
  fetch('https://your-api-host.com/api/data', {
    headers: { Authorization: `Bearer ${token}` },
  });
}
```

### Listen for Auth State Changes

The `authStateChange` event fires on login, logout, and when a token refresh fails (which clears auth state):

```js
const unsubscribe = auth.onAuthStateChange(({ authenticated, user }) => {
  if (authenticated) {
    console.log('Signed in as', user.email);
  } else {
    console.log('Signed out');
  }
});

// Later: stop listening
unsubscribe();
```

### Events Reference

| Event | Payload | When |
|-------|---------|------|
| `authStateChange` | `{ authenticated: boolean, user: object \| null }` | Login, logout, or refresh failure |
| `tokenRefreshed` | `{ accessToken: string, expiresIn: number }` | Successful token refresh |
| `error` | `AuthError` | Token refresh failure |
| `tfaRequired` | `{ challengeToken: string, methods: string[], expiresIn: number, user: object }` | Login requires TFA verification |

## Token Management

The SDK handles tokens automatically, but here is a summary of the behavior:

- **Persistence:** Sessions survive page reloads and new tabs when using `localStorage` storage (the default).
- **Auto-refresh:** When enabled, tokens are refreshed `refreshBuffer` seconds before expiry. No action is needed from your code.
- **Expiry check:** `isAuthenticated()` returns whether the access token is still valid, without making a network request.
- **Failure handling:** If auto-refresh fails, auth state is cleared and an `authStateChange` event fires with `{ authenticated: false }`.
- **Memory storage:** Use `tokenStorage: 'memory'` for environments where `localStorage` is unavailable (SSR, tests, private browsing fallback). Tokens are lost on page reload. **For production apps, `'memory'` is recommended** — it keeps refresh tokens out of `localStorage`, where they could be exfiltrated by XSS. The trade-off is that users must re-authenticate after a page reload.

## Error Handling

All SDK methods throw `AuthError` instances on failure. Each error has a `code` property from the `AUTH_ERRORS` constant, a human-readable `message`, and optionally a `statusCode` and `details` object.

```js
import { HiyveAuth, AuthError, AUTH_ERRORS } from '@hiyve/identity-client';

try {
  await auth.login({ email: 'user@example.com', password: 'wrong' });
} catch (err) {
  if (err instanceof AuthError) {
    switch (err.code) {
      case AUTH_ERRORS.INVALID_CREDENTIALS:
        showError('Invalid email or password');
        break;
      case AUTH_ERRORS.EMAIL_NOT_VERIFIED:
        showError('Please verify your email');
        break;
      case AUTH_ERRORS.RATE_LIMITED:
        showError('Too many attempts. Please wait.');
        break;
      case AUTH_ERRORS.NETWORK_ERROR:
        showError('Network error. Check your connection.');
        break;
      case AUTH_ERRORS.TIMEOUT:
        showError('Request timed out. Try again.');
        break;
      default:
        showError(err.message);
    }
  }
}
```

### Error Codes

| Code | HTTP Status | Description |
|------|-------------|-------------|
| `NETWORK_ERROR` | -- | Network failure (no response received) |
| `TIMEOUT` | -- | Request exceeded the timeout |
| `INVALID_CREDENTIALS` | 401 | Wrong email or password |
| `TOKEN_EXPIRED` | 401 | Access token has expired |
| `TOKEN_REFRESH_FAILED` | varies | Refresh token is invalid or expired |
| `UNAUTHORIZED` | 401 | Generic authentication failure |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `RATE_LIMITED` | 429 | Too many requests |
| `VALIDATION_ERROR` | 400 | Invalid input (check `err.details`) |
| `EMAIL_NOT_VERIFIED` | 401 | Email address not yet verified |
| `USER_ALREADY_EXISTS` | 409 | Email already registered |
| `SERVER_ERROR` | 5xx | Unexpected server error |
| `INVALID_CONFIG` | -- | Bad SDK configuration or destroyed instance |
| `INVALID_TOKEN` | -- | Token is malformed or unreadable |
| `INVALID_GRANT` | 400 | Invalid authorization code or grant type |
| `INVALID_SCOPE` | 400 | Requested scope is not allowed |
| `TFA_REQUIRED` | 200 | Login requires two-factor authentication |
| `TFA_INVALID_CODE` | 401 | OTP code is incorrect |
| `TFA_CHALLENGE_EXPIRED` | 401 | TFA challenge has expired |
| `TFA_CHALLENGE_LOCKED` | 429 | Too many failed OTP attempts |
| `INVALID_STATE` | -- | No pending TFA challenge (call `login()` first) |

### Validation Error Details

When `code` is `VALIDATION_ERROR`, the `details` property contains field-level errors from the server:

```js
try {
  await auth.register({ email: 'bad', password: '123' });
} catch (err) {
  if (err.code === AUTH_ERRORS.VALIDATION_ERROR) {
    console.log(err.details);
    // e.g. { email: '"email" must be a valid email', password: 'minimum 8 characters' }
  }
}
```

### Serialization

`AuthError` instances have a `toJSON()` method for logging:

```js
catch (err) {
  console.log(JSON.stringify(err));
  // { "name": "AuthError", "code": "INVALID_CREDENTIALS", "message": "...", "statusCode": 401, "details": null }
}
```

## Advanced Usage

### Using HttpClient Directly

If you need to make custom requests to additional API endpoints that share the same API key and auth headers:

```js
import { HttpClient } from '@hiyve/identity-client';

const client = new HttpClient({
  baseUrl: 'https://your-api-host.com/api',
  apiKey: 'pk_live_your_api_key',
  timeout: 15000,
  getAccessToken: () => auth.getAccessToken(),
});

// Unauthenticated request
const data = await client.get('/public/info');

// Authenticated request
const profile = await client.get('/profile', { auth: true });

// POST with body
const result = await client.post('/items', { name: 'New Item' }, { auth: true });

// PUT with body
await client.put('/items/123', { name: 'Updated Item' }, { auth: true });

// DELETE request
await client.delete('/items/123', { auth: true });
```

Every request automatically includes the `X-Hiyve-Api-Key` header. When `{ auth: true }` is passed, the `Authorization: Bearer <token>` header is included. HTTP errors are mapped to `AuthError` instances.

### Using TokenManager Directly

For advanced use cases (e.g., building a custom auth flow or integrating with a state management library):

```js
import { TokenManager } from '@hiyve/identity-client';

const tokens = new TokenManager({
  storage: 'localStorage',       // or 'memory'
  autoRefresh: true,
  refreshBuffer: 120,            // refresh 2 minutes before expiry
});

// Store tokens from your own login endpoint
tokens.setTokens({ accessToken: 'eyJ...', refreshToken: 'eyJ...' });

// Check state
tokens.isAuthenticated();  // true if access token is not expired
tokens.getAccessToken();   // raw JWT string or null
tokens.getRefreshToken();  // raw refresh token string or null

// Decode JWT payload (no signature verification)
const payload = tokens.decodeToken(tokens.getAccessToken());
console.log(payload.sub, payload.email, payload.exp);

// Get expiry as Unix timestamp (seconds)
const exp = tokens.getTokenExpiry(tokens.getAccessToken());
console.log('Expires at:', new Date(exp * 1000));

// Clear everything
tokens.clear();
```

### Using PKCEHelper Directly

If you need to generate PKCE pairs for custom OAuth flows:

```js
import { PKCEHelper } from '@hiyve/identity-client';

// Generate a PKCE pair
const { codeVerifier, codeChallenge } = await PKCEHelper.generatePKCEPair();

// Or generate components individually
const verifier = PKCEHelper.generateCodeVerifier();
const challenge = await PKCEHelper.generateCodeChallenge(verifier);
```

Generates cryptographically secure PKCE pairs for OAuth 2.1 flows.

### Using DeviceFingerprint Directly

If you need to access device fingerprinting independently:

```js
import { DeviceFingerprint } from '@hiyve/identity-client';

// Collect browser signals
const fingerprint = DeviceFingerprint.collect();
console.log(fingerprint);
// { screenResolution: '1920x1080', timezone: 'America/New_York', language: 'en-US', platform: 'MacIntel' }

// Get or create a persistent device ID
const deviceId = DeviceFingerprint.getDeviceId('localStorage', 'myapp_');

// Encode fingerprint for transport
const encoded = DeviceFingerprint.encode(fingerprint);
// Base64-encoded JSON string
```

The SDK uses `DeviceFingerprint` automatically during `login()` to send device recognition headers.

### Memory Storage

Use in-memory storage for server-side rendering, testing, or environments without `localStorage`:

```js
const auth = new HiyveAuth({
  apiKey: 'pk_test_your_api_key',
  tokenStorage: 'memory',
});
```

If `localStorage` is configured but unavailable at runtime (e.g., private browsing restrictions), the SDK automatically falls back to memory storage.

### Custom Base Path

If your backend mounts the auth routes at a different path:

```js
const auth = new HiyveAuth({
  apiKey: 'pk_live_your_api_key',
  basePath: '/v2/auth',  // overrides the default /identity/auth path
});
```

### Framework Integration (React)

A typical pattern for React applications:

```jsx
import { useEffect, useState, createContext, useContext } from 'react';
import { HiyveAuth } from '@hiyve/identity-client';

// Create a singleton instance
const auth = new HiyveAuth({
  apiKey: 'pk_live_your_api_key',
});

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [state, setState] = useState({
    authenticated: auth.isAuthenticated(),
    user: null,
    loading: true,
  });

  useEffect(() => {
    // Listen for auth changes
    const unsubscribe = auth.onAuthStateChange(({ authenticated, user }) => {
      setState({ authenticated, user, loading: false });
    });

    // Load user if we have a token
    if (auth.isAuthenticated()) {
      auth.getUser()
        .then(({ user }) => setState({ authenticated: true, user, loading: false }))
        .catch(() => setState({ authenticated: false, user: null, loading: false }));
    } else {
      setState((s) => ({ ...s, loading: false }));
    }

    return () => {
      unsubscribe();
      auth.destroy();
    };
  }, []);

  return (
    <AuthContext.Provider value={{ ...state, auth }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  return useContext(AuthContext);
}
```

Usage in a component:

```jsx
function LoginPage() {
  const { auth } = useAuth();
  const [tfaPending, setTfaPending] = useState(false);

  useEffect(() => {
    return auth.onTfaRequired(() => setTfaPending(true));
  }, []);

  async function handleLogin(email, password) {
    try {
      const result = await auth.login({ email, password });
      if (result.tfaRequired) {
        // TFA UI will show via onTfaRequired callback
      }
      // If no TFA, AuthProvider updates state via onAuthStateChange
    } catch (err) {
      // Handle error
    }
  }

  async function handleVerifyOtp(code) {
    try {
      await auth.verifyTfa({ code });
      setTfaPending(false);
      // AuthProvider updates state via onAuthStateChange
    } catch (err) {
      // Handle TFA_INVALID_CODE, TFA_CHALLENGE_EXPIRED, etc.
    }
  }

  if (tfaPending) {
    return <OtpForm onSubmit={handleVerifyOtp} />;
  }

  // ... render login form
}

function Dashboard() {
  const { authenticated, user, loading } = useAuth();

  if (loading) return <div>Loading...</div>;
  if (!authenticated) return <Navigate to="/login" />;

  return <div>Welcome, {user.name}</div>;
}
```

## API Reference

### HiyveAuth

The main SDK class. Coordinates authentication flows, token storage, and event emission.

#### Constructor

```js
new HiyveAuth(config: HiyveAuthConfig)
```

Throws `AuthError` with code `INVALID_CONFIG` if neither `apiKey` nor `baseUrl` is provided, or if `environment` is invalid.

#### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `register({ email, password, name?, metadata? })` | `Promise<{ user }>` | Register a new user |
| `login({ email, password })` | `Promise<LoginResult>` | Log in (may return tokens or TFA challenge) |
| `logout()` | `Promise<void>` | Log out and clear tokens |
| `refreshTokens()` | `Promise<{ accessToken, refreshToken, expiresIn }>` | Manually refresh tokens |
| `getUser()` | `Promise<{ user }>` | Fetch authenticated user's basic identity |
| `getProfile()` | `Promise<{ profile }>` | Fetch the full user profile (name, picture, phone, organization, metadata, roles) |
| `updateProfile({ name?, picture?, phone?, organization?, metadata? })` | `Promise<{ profile }>` | Update the user's own profile |
| `requestPasswordReset({ email })` | `Promise<{ message }>` | Send password reset email |
| `resetPassword({ token, password })` | `Promise<{ message }>` | Reset password with token |
| `verifyEmail({ token })` | `Promise<{ message }>` | Verify email address |
| `resendVerification({ email })` | `Promise<{ message }>` | Resend verification email |
| `createMCPToken({ name?, permissions?, expiresIn?, ipRestriction? })` | `Promise<{ rawToken, token }>` | Create an MCP token |
| `listMCPTokens()` | `Promise<{ tokens }>` | List MCP tokens |
| `revokeMCPToken(tokenId)` | `Promise<{ message }>` | Revoke an MCP token |
| `validateMCPToken(token)` | `Promise<{ valid, token? }>` | Validate a raw MCP token |
| `createOAuthClient({ clientName, redirectUris, scopes? })` | `Promise<{ clientId, clientSecret, client }>` | Create an OAuth client |
| `listOAuthClients()` | `Promise<{ clients }>` | List OAuth clients |
| `getOAuthClient(clientId)` | `Promise<{ client }>` | Get an OAuth client |
| `deleteOAuthClient(clientId)` | `Promise<{ message }>` | Delete an OAuth client |
| `createAuthorizationCode({ clientId, redirectUri, scope?, state? })` | `Promise<{ code, codeVerifier, codeChallenge, state }>` | Create authorization code with auto-PKCE |
| `exchangeAuthorizationCode({ code, redirectUri, codeVerifier, clientId, clientSecret })` | `Promise<OAuthTokenResponse>` | Exchange code for tokens |
| `refreshOAuthToken({ refreshToken, clientId, clientSecret })` | `Promise<OAuthTokenResponse>` | Refresh OAuth tokens |
| `isAuthenticated()` | `boolean` | Check if access token exists and is not expired |
| `getAccessToken()` | `string \| null` | Get the raw access token |
| `onAuthStateChange(callback)` | `() => void` | Subscribe to auth state changes; returns unsubscribe function |
| `verifyTfa({ code, type? })` | `Promise<{ user, accessToken, refreshToken, expiresIn, deviceId }>` | Verify OTP code for pending TFA challenge |
| `resendOtp()` | `Promise<{ message, expiresIn }>` | Resend OTP email for pending challenge |
| `onTfaRequired(callback)` | `() => void` | Subscribe to TFA challenge events; returns unsubscribe function |
| `listTrustedDevices()` | `Promise<{ devices }>` | List trusted devices for authenticated user |
| `revokeTrustedDevice(deviceId)` | `Promise<{ message }>` | Revoke a specific trusted device |
| `revokeAllTrustedDevices()` | `Promise<{ message, count }>` | Revoke all trusted devices |
| `destroy()` | `void` | Stop auto-refresh, remove all listeners. Instance is unusable after this. |

### TokenManager

Manages token storage, JWT payload decoding, and auto-refresh scheduling.

#### Constructor

```js
new TokenManager(options?: TokenManagerOptions)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `storage` | `string` | `'localStorage'` | `'localStorage'` or `'memory'` |
| `storagePrefix` | `string` | -- | Optional key prefix for localStorage |
| `autoRefresh` | `boolean` | `true` | Enable auto-refresh scheduling |
| `refreshBuffer` | `number` | `300` | Seconds before expiry to trigger refresh |

#### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `setTokens({ accessToken, refreshToken })` | `void` | Store both tokens |
| `getAccessToken()` | `string \| null` | Retrieve the access token |
| `getRefreshToken()` | `string \| null` | Retrieve the refresh token |
| `clear()` | `void` | Remove all tokens and stop auto-refresh |
| `isAuthenticated()` | `boolean` | Check if access token exists and is not expired |
| `getTokenExpiry(token)` | `number \| null` | Decode `exp` from JWT (seconds since epoch) |
| `decodeToken(token)` | `object \| null` | Decode full JWT payload (no signature verification) |
| `startAutoRefresh(refreshFn)` | `void` | Start auto-refresh with the given async function |
| `stopAutoRefresh()` | `void` | Cancel the auto-refresh timer |

### HttpClient

Fetch wrapper that adds API key headers, authorization, timeouts, and error mapping.

#### Constructor

```js
new HttpClient(options: HttpClientOptions)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `baseUrl` | `string` | *required* | Full base URL for requests |
| `apiKey` | `string` | *required* | API key sent as `X-Hiyve-Api-Key` header |
| `timeout` | `number` | `30000` | Request timeout in milliseconds |
| `getAccessToken` | `() => string \| null` | `() => null` | Function returning current access token |

#### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `request(method, path, body?, options?)` | `Promise<object>` | Make an HTTP request |
| `get(path, options?)` | `Promise<object>` | GET request |
| `post(path, body?, options?)` | `Promise<object>` | POST request |
| `put(path, body?, options?)` | `Promise<object>` | PUT request |
| `delete(path, options?)` | `Promise<object>` | DELETE request |

**Request options:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `auth` | `boolean` | `false` | Include `Authorization: Bearer <token>` header |
| `timeout` | `number` | -- | Override the default timeout for this request |
| `headers` | `Record<string, string>` | -- | Additional headers to merge into the request |

All requests include `credentials: 'include'` for cookie-based flows and `Content-Type: application/json`.

### PKCEHelper

Static utility class for generating PKCE (Proof Key for Code Exchange) parameters for OAuth 2.1 authorization flows. Uses the Web Crypto API.

| Method | Returns | Description |
|--------|---------|-------------|
| `PKCEHelper.generateCodeVerifier()` | `string` | Generate a cryptographically random code verifier (43+ chars, base64url) |
| `PKCEHelper.generateCodeChallenge(verifier)` | `Promise<string>` | Compute S256 code challenge from a verifier (base64url-encoded SHA-256) |
| `PKCEHelper.generatePKCEPair()` | `Promise<{ codeVerifier, codeChallenge }>` | Generate both verifier and challenge |

### DeviceFingerprint

Static utility class for browser device fingerprinting. Used automatically by `HiyveAuth.login()` to send device recognition headers.

| Method | Returns | Description |
|--------|---------|-------------|
| `DeviceFingerprint.collect()` | `DeviceFingerprintData` | Collect browser signals (screen, timezone, language, platform) |
| `DeviceFingerprint.getDeviceId(storageType?, prefix?)` | `string \| null` | Get stored device ID from localStorage or memory |
| `DeviceFingerprint.setDeviceId(storageType?, prefix?, id?)` | `void` | Store a device ID |
| `DeviceFingerprint.encode(fingerprint)` | `string` | Base64-encode fingerprint data for HTTP headers |

### EventEmitter

Minimal pub/sub event system used internally by HiyveAuth. Also exported for custom use.

| Method | Returns | Description |
|--------|---------|-------------|
| `on(event, callback)` | `() => void` | Subscribe; returns unsubscribe function |
| `emit(event, data?)` | `void` | Emit an event to all subscribers |
| `removeAllListeners(event?)` | `void` | Remove listeners for one event, or all events |
| `listenerCount(event)` | `number` | Count of listeners for an event |

### AuthError

Custom error class extending `Error`. All SDK errors are `AuthError` instances.

| Property | Type | Description |
|----------|------|-------------|
| `name` | `string` | Always `'AuthError'` |
| `code` | `string` | Error code from `AUTH_ERRORS` |
| `message` | `string` | Human-readable message |
| `statusCode` | `number \| null` | HTTP status code (if applicable) |
| `details` | `object \| null` | Additional details (e.g., validation errors) |

Methods: `toJSON()` returns a plain object with all properties.

### AUTH_ERRORS

Constant object mapping error names to string codes. Use for `switch`/`if` comparisons:

```js
import { AUTH_ERRORS } from '@hiyve/identity-client';

AUTH_ERRORS.NETWORK_ERROR        // 'NETWORK_ERROR'
AUTH_ERRORS.TIMEOUT              // 'TIMEOUT'
AUTH_ERRORS.INVALID_CREDENTIALS  // 'INVALID_CREDENTIALS'
AUTH_ERRORS.TOKEN_EXPIRED        // 'TOKEN_EXPIRED'
AUTH_ERRORS.TOKEN_REFRESH_FAILED // 'TOKEN_REFRESH_FAILED'
AUTH_ERRORS.UNAUTHORIZED         // 'UNAUTHORIZED'
AUTH_ERRORS.FORBIDDEN            // 'FORBIDDEN'
AUTH_ERRORS.NOT_FOUND            // 'NOT_FOUND'
AUTH_ERRORS.RATE_LIMITED         // 'RATE_LIMITED'
AUTH_ERRORS.VALIDATION_ERROR     // 'VALIDATION_ERROR'
AUTH_ERRORS.EMAIL_NOT_VERIFIED   // 'EMAIL_NOT_VERIFIED'
AUTH_ERRORS.USER_ALREADY_EXISTS  // 'USER_ALREADY_EXISTS'
AUTH_ERRORS.SERVER_ERROR         // 'SERVER_ERROR'
AUTH_ERRORS.INVALID_CONFIG       // 'INVALID_CONFIG'
AUTH_ERRORS.INVALID_TOKEN        // 'INVALID_TOKEN'
AUTH_ERRORS.INVALID_GRANT        // 'INVALID_GRANT'
AUTH_ERRORS.INVALID_SCOPE        // 'INVALID_SCOPE'
AUTH_ERRORS.TFA_REQUIRED         // 'TFA_REQUIRED'
AUTH_ERRORS.TFA_INVALID_CODE     // 'TFA_INVALID_CODE'
AUTH_ERRORS.TFA_CHALLENGE_EXPIRED // 'TFA_CHALLENGE_EXPIRED'
AUTH_ERRORS.TFA_CHALLENGE_LOCKED // 'TFA_CHALLENGE_LOCKED'
AUTH_ERRORS.INVALID_STATE        // 'INVALID_STATE'
```

## Security & Compliance

The Hiyve Identity system passes 9 of 10 OWASP Top 10 (2021) categories with full compliance, covering broken access control, cryptographic controls, injection prevention, adaptive TFA, multi-tenant isolation, and layered rate limiting. One category (Security Logging & Monitoring) has partial compliance with structured audit logging on the roadmap.

Key security highlights:

- **Layered rate limiting** — Redis-backed, per-tenant, enforced across clustered deployments
- **Adaptive TFA** — device fingerprinting with automatic OTP challenge for unknown devices
- **CSRF protection** — HMAC-SHA256 double-submit cookies with timing-safe validation
- **Zero client dependencies** — no supply-chain risk from transitive packages
- **Multi-tenant isolation** — organization scoping on all queries, tokens, and rate-limit keys

> **Token storage advisory:** The default `tokenStorage: 'localStorage'` persists refresh tokens in `localStorage`, making them accessible to any JavaScript on the same origin. If your site includes third-party scripts (analytics, ads, support widgets), consider using `tokenStorage: 'memory'` to keep refresh tokens out of reach of XSS. The trade-off is that sessions will not survive page reloads. When using server-side proxy mode (`baseUrl`), you can implement `httpOnly` cookie-based refresh on your server for the strongest protection.

See [COMPLIANCE_REPORT.md](./COMPLIANCE_REPORT.md) for the full compliance report including OWASP mapping, cryptographic controls, architecture diagrams, open items, and a consumer security checklist.

## Requirements

### Browser Support

The SDK uses standard web APIs available in all modern browsers:

- `fetch` and `Response`
- `AbortController`
- `atob`
- `localStorage` (with automatic fallback to memory)
- `setTimeout` / `clearTimeout`
- `crypto.subtle` and `crypto.getRandomValues` (for PKCE generation)

**Minimum browser versions:** Chrome 66+, Firefox 57+, Safari 12+, Edge 79+

No polyfills are required for modern browsers. For legacy environments, you may need polyfills for `fetch` and `AbortController`.

## License

Commercial - IWantToPractice, LLC
