# React Authentication Library

[![CI](https://github.com/PhillipAWells/workspace/actions/workflows/ci.yml/badge.svg)](https://github.com/PhillipAWells/workspace/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/@pawells/react-auth.svg)](https://www.npmjs.com/package/@pawells/react-auth)
[![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)

## Description

`@pawells/react-auth` is a Keycloak Authorization Code + PKCE authentication library for React SPAs, built on [`oidc-client-ts`](https://github.com/authts/oidc-client-ts). It provides:

- PKCE (S256) enforcement on every sign-in flow — no additional configuration required
- Automatic silent token renewal via refresh token, wired to the `oidc-client-ts` event system
- Configurable session storage: `sessionStorage` (default, cleared on tab close, per-tab isolated) or `localStorage`
- Popup authentication flow for sign-in without a full-page redirect
- An Axios hook (`useAuthAxios`) and an Apollo Link factory (`createAuthApolloLink`) for authenticated API and GraphQL requests

## Requirements

- **Node.js** `>=22`
- **react** `>=19.0.0` *(required peer)*
- **react-dom** `>=19.0.0` *(required peer)*
- **@apollo/client** `>=4.0.0` *(optional peer — required only for `createAuthApolloLink`)*
- **axios** `>=1.0.0` *(optional peer — required only for `useAuthAxios`)*

## Installation

```sh
npm install @pawells/react-auth
```

To use the Axios integration:

```sh
npm install axios
```

To use the Apollo Link integration:

```sh
npm install @apollo/client
```

## Quick Start

Place `AuthProvider` inside your router so that `onSigninCallback` can call `useNavigate`:

```tsx
import { BrowserRouter, useNavigate } from 'react-router-dom';
import { AuthProvider } from '@pawells/react-auth';
import App from './App';

function AppWithAuth() {
  const navigate = useNavigate();
  return (
    <AuthProvider
      authority="https://keycloak.example.com/realms/my-realm"
      client_id="my-spa-client"
      redirect_uri={`${window.location.origin}/auth/callback`}
      post_logout_redirect_uri={window.location.origin}
      onSigninCallback={() => navigate('/')}
    >
      <App />
    </AuthProvider>
  );
}

export default function Root() {
  return (
    <BrowserRouter>
      <AppWithAuth />
    </BrowserRouter>
  );
}
```

`AuthProvider` handles the redirect callback automatically on mount. When the browser lands on `redirect_uri` with a `code` query parameter, the provider exchanges the code, stores the session, and calls `onSigninCallback` — no extra route or component is required.

## API Reference

### Components

#### `AuthProvider`

Provides Keycloak Authorization Code + PKCE authentication to the component tree. Silent token renewal via refresh token is enabled automatically. The `storageType` prop is read only on initial render; changes after mount are ignored.

**Props** (`AuthProviderProps` — extends `KeycloakAuthConfig` and accepts `children`):

| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| `authority` | `string` | Yes | — | Keycloak realm base URL. Format: `https://{host}/realms/{realm}` (Keycloak 17+) |
| `client_id` | `string` | Yes | — | Client ID of a public (non-confidential) Keycloak client |
| `redirect_uri` | `string` | Yes | — | URI to redirect to after a successful login; must be registered in Keycloak |
| `post_logout_redirect_uri` | `string` | No | — | URI to redirect to after logout; must be registered in Keycloak's "Valid post logout redirect URIs" |
| `popup_redirect_uri` | `string` | No | — | URI for the popup authentication callback window; required for `loginWithPopup()` and must be registered in Keycloak |
| `scope` | `string` | No | `'openid profile email'` | OAuth2/OIDC scopes to request. Add `'offline_access'` for long-lived refresh tokens |
| `storageType` | `StorageType` | No | `'sessionStorage'` | Where to persist the OIDC user session. See [`StorageType`](#storagetype) |
| `onSigninCallback` | `(user: User) => void` | No | — | Invoked after a successful signin redirect callback. Use to navigate the user to their intended destination |
| `children` | `React.ReactNode` | Yes | — | Component subtree that receives the auth context |

### Hooks

#### `useAuth(): AuthContextValue`

Returns the current auth context value from the nearest `AuthProvider`. Provides access to authentication state, login/logout methods, and token retrieval.

Throws an `AuthError` (extends `BaseError` from `@pawells/typescript-common`) when called outside an `AuthProvider`.

```tsx
function Profile() {
  const { user, logout, isAuthenticated } = useAuth();
  if (!isAuthenticated) return <div>Not logged in</div>;
  return <button onClick={logout}>{user?.profile.name}</button>;
}
```

The returned `AuthContextValue` exposes all `AuthState` fields plus five methods:

| Member | Type | Description |
|---|---|---|
| `isAuthenticated` | `boolean` | Whether the user holds a valid, non-expired access token |
| `isLoading` | `boolean` | Whether an auth operation is in progress (hydration, callback, or silent renew) |
| `user` | `User \| null` | The authenticated OIDC user object, or `null` when not authenticated |
| `error` | `Error \| null` | The most recent auth error, or `null` if none |
| `login()` | `() => Promise<void>` | Initiates the Keycloak Authorization Code + PKCE redirect flow |
| `loginWithPopup()` | `() => Promise<void>` | Opens a Keycloak login popup without navigating the main page. Requires `popup_redirect_uri`. Throws if the popup is blocked |
| `logout()` | `() => Promise<void>` | Redirects to the Keycloak `end_session_endpoint` and clears the local session |
| `getAccessToken()` | `() => Promise<string \| null>` | Returns the current access token, triggering silent renewal if expired. Returns `null` if not authenticated or if renewal fails |
| `clearSession()` | `() => Promise<void>` | Removes the user from storage and resets auth state without redirecting to Keycloak |

#### `useAuthAxios(instance?: AxiosInstance): AxiosInstance`

Returns an Axios instance with a request interceptor that automatically attaches a valid Keycloak Bearer token to same-origin requests. The token is attached only when the resolved request URL has the same origin as `window.location`; cross-origin requests do not receive an Authorization header (CWE-319 mitigation). If the access token is expired, silent renewal via the refresh token is attempted before the request is dispatched. If the user is not authenticated, the request proceeds without an `Authorization` header.

Pass an existing Axios instance to attach the interceptor to it; omit the argument for a new `axios.create()` instance. The returned instance reference is stable across re-renders unless the passed `instance` reference changes.

```tsx
// New instance (default)
function MediaService() {
  const api = useAuthAxios();
  const fetchItems = () => api.get('/api/items');
}

// Shared instance
const sharedAxios = axios.create({ baseURL: 'https://api.example.com' });

function MediaService() {
  const api = useAuthAxios(sharedAxios);
}
```

> **Note:** Interceptor registration is reference-counted per `AxiosInstance` — if multiple components call `useAuthAxios` with the same shared instance, they share a single request interceptor rather than each registering a new one. The interceptor is ejected once the last consumer unmounts.

### Integrations

#### `createAuthApolloLink(getAccessToken: AuthContextValue['getAccessToken']): ApolloLink`

Creates an Apollo Link that prepends a valid Keycloak Bearer token to the `Authorization` header of same-origin GraphQL operations. The token is attached only when the GraphQL endpoint URI has the same origin as `window.location`; cross-origin requests do not receive an Authorization header (CWE-319 mitigation). Silent token renewal is triggered transparently when the access token is expired. If `getAccessToken` throws, the error is propagated to the GraphQL operation's error handler. If the subscription is cancelled before the token resolves, no error is emitted.

```tsx
import { useMemo } from 'react';
import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache } from '@apollo/client';
import { createAuthApolloLink, useAuth } from '@pawells/react-auth';

function ApolloSetup({ children }: { children: React.ReactNode }) {
  const { getAccessToken } = useAuth();

  const client = useMemo(() => {
    const authLink = createAuthApolloLink(getAccessToken);
    const httpLink = new HttpLink({ uri: '/graphql' });
    return new ApolloClient({ link: authLink.concat(httpLink), cache: new InMemoryCache() });
  }, [getAccessToken]);

  return <ApolloProvider client={client}>{children}</ApolloProvider>;
}
```

### Sub-Path Exports

For convenience, you can import from sub-paths to reduce bundle size when using only specific modules:

```tsx
// Import from specific sub-paths
import { AuthProvider, useAuth } from '@pawells/react-auth/context';
import { useAuthAxios } from '@pawells/react-auth/hooks';
import { createAuthApolloLink } from '@pawells/react-auth/integrations';
import { parseJwt, isTokenExpired } from '@pawells/react-auth/utils';
```

### Utilities

#### `parseJwt<T extends Record<string, unknown>>(token: string, isValid?: (value: Record<string, unknown>) => value is T): T | null`

Decodes a JWT payload without verifying the signature. Intended for reading client-visible claims only — signature verification must always be performed server-side. Returns `null` if the token is malformed, does not have exactly three parts, the payload cannot be parsed as a plain object, or (when `isValid` is supplied) the payload fails `isValid`.

```tsx
const payload = parseJwt<{ sub: string; exp: number }>(token);
if (!payload) throw new Error('Invalid token');
console.log(payload.sub); // typed as string
```

Pass `isValid` to get a runtime-checked result instead of a bare type assertion:

```tsx
const isClaims = (value: Record<string, unknown>): value is { sub: string; exp: number } =>
  typeof value.sub === 'string' && typeof value.exp === 'number';
const payload = parseJwt(token, isClaims);
if (!payload) throw new Error('Invalid or malformed token claims');
```

#### `isTokenExpired(token: string, clockSkewSeconds?: number): boolean`

Returns `true` if a JWT is expired or malformed. An optional `clockSkewSeconds` buffer adjusts the validity window: a token with `exp = T` is considered expired only when `now > T + clockSkewSeconds`. Positive values extend the validity window (token treated as valid for up to that many seconds past its stated expiry); negative values make the token expire earlier (stricter validation). Default is 0 (no buffer).

### Types

#### `KeycloakAuthConfig`

Configuration interface for `AuthProvider`. Extends `oidc-client-ts` `UserManagerSettings` (omitting `userStore` and `stateStore`, which are managed internally) with Keycloak-specific required fields and the `storageType` option.

#### `AuthContextValue`

Value exposed by the auth context and returned by `useAuth()`. Extends `AuthState` with five methods: `login()`, `loginWithPopup()`, `logout()`, `getAccessToken()`, and `clearSession()`.

#### `AuthState`

Snapshot of the current authentication state. Fields: `isAuthenticated: boolean`, `isLoading: boolean`, `user: User | null`, `error: Error | null`.

#### `AuthProviderProps`

Props accepted by `AuthProvider`. Extends `KeycloakAuthConfig` with `children: React.ReactNode`.

#### `StorageType`

`'sessionStorage' | 'localStorage'` — controls where the OIDC user session is persisted.

- `'sessionStorage'` (default) — cleared when the tab closes; isolated per tab.
- `'localStorage'` — survives page reloads; shared across same-origin tabs. Use with caution in XSS-prone environments; a security warning is logged to the console when this option is active.

## License

MIT — See [LICENSE](./LICENSE) for details.
