No description
Find a file
autocommit e098caeb97
Some checks failed
Build and Publish / build-and-publish (push) Failing after 44s
deps-upgrade(deps): ⬆️ Update dependencies to latest versions for security patches and bug fixes
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-10 04:29:26 -07:00
.forgejo/workflows ci: initial commit with Forgejo publish workflow 2026-01-30 17:32:06 -08:00
examples ci: initial commit with Forgejo publish workflow 2026-01-30 17:32:06 -08:00
src ci: initial commit with Forgejo publish workflow 2026-01-30 17:32:06 -08:00
.gitignore ci: initial commit with Forgejo publish workflow 2026-01-30 17:32:06 -08:00
package.json deps-upgrade(deps): ⬆️ Update dependencies to latest versions for security patches and bug fixes 2026-06-10 04:29:26 -07:00
README.md ci: initial commit with Forgejo publish workflow 2026-01-30 17:32:06 -08:00
tsconfig.json ci: initial commit with Forgejo publish workflow 2026-01-30 17:32:06 -08:00
tsup.config.ts ci: initial commit with Forgejo publish workflow 2026-01-30 17:32:06 -08:00

@lilith/http-auth-interceptor

JWT authentication interceptor for HTTP clients. Handles token injection, automatic refresh on 401 errors, and retry logic.

Purpose

Separates authentication concerns from HTTP transport layer, enabling:

  • Clean composition with any HTTP client (axios, fetch wrappers, etc.)
  • Testing auth logic independently from transport
  • Reusable auth patterns across different clients

Features

  • Token Injection: Automatically adds JWT to Authorization header
  • 401 Handling: Detects unauthorized responses, refreshes token, retries request
  • Refresh Queue: Prevents "thundering herd" - only one refresh at a time
  • URL Exclusion: Skip auth for public endpoints
  • Customizable: Configure headers, token prefix, refresh behavior

Installation

pnpm add @lilith/http-auth-interceptor

Usage

Basic Setup

import axios from 'axios';
import { createAuthInterceptor } from '@lilith/http-auth-interceptor';

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

createAuthInterceptor(client, {
  // Get current token
  getToken() {
    return localStorage.getItem('auth_token');
  },

  // Refresh token when expired
  async refreshToken() {
    const refresh = localStorage.getItem('refresh_token');
    const response = await axios.post('/auth/refresh', { refreshToken: refresh });

    localStorage.setItem('auth_token', response.data.accessToken);
    localStorage.setItem('refresh_token', response.data.refreshToken);

    return response.data.accessToken;
  },

  // Handle refresh failure (redirect to login)
  onRefreshFailed() {
    localStorage.clear();
    window.location.href = '/login';
  }
});

// Now all requests automatically include auth token
const response = await client.get('/users');

Advanced Options

createAuthInterceptor(client, tokenSource, {
  // Disable automatic token refresh (manual handling only)
  enableTokenRefresh: false,

  // Exclude URLs from token injection
  excludeUrls: [
    '/public',           // String match
    /^\/auth\//,        // RegExp pattern
  ],

  // Custom header name
  authHeader: 'X-Auth-Token',

  // Custom token prefix
  tokenPrefix: 'JWT'
});

Manual Control

For more control, use AuthInterceptor class directly:

import { AuthInterceptor } from '@lilith/http-auth-interceptor';

const interceptor = new AuthInterceptor(tokenSource, client, options);

// Attach interceptors manually
client.interceptors.request.use(
  interceptor.onRequest.bind(interceptor),
  interceptor.onRequestError.bind(interceptor)
);

client.interceptors.response.use(
  interceptor.onResponse.bind(interceptor),
  interceptor.onResponseError.bind(interceptor)
);

// Cleanup (useful for testing)
interceptor.clear();

How It Works

Request Flow

  1. Request Interceptor: Before each request, calls tokenSource.getToken()
  2. If token exists, injects into Authorization: Bearer <token> header
  3. Skips injection for URLs in excludeUrls list

401 Handling Flow

  1. Response Interceptor: Detects 401 Unauthorized response
  2. Refresh Queue: Checks if refresh already in progress
    • If yes: Wait for existing refresh to complete
    • If no: Start new refresh by calling tokenSource.refreshToken()
  3. Retry: Updates request with new token, retries original request
  4. Failure: If refresh fails, calls tokenSource.onRefreshFailed(), throws error

Thundering Herd Prevention

The RefreshQueue ensures only ONE token refresh happens at a time:

// 10 requests fail with 401 simultaneously
const promises = Array(10).fill(null).map(() => client.get('/protected'));

// Only 1 refresh call happens
// Other 9 requests wait for same refresh
// All 10 retry with new token
await Promise.all(promises);

Architecture

┌─────────────────────────────────────┐
│  Application Code                   │
│  (import { createApiClient })       │
└──────────────┬──────────────────────┘
               │
               ▼
┌─────────────────────────────────────┐
│  @lilith/http-client                │
│  (Transport: Axios wrapper)         │
└──────────────┬──────────────────────┘
               │
               ▼
┌─────────────────────────────────────┐
│  @lilith/http-auth-interceptor      │
│  (Auth: Token injection + refresh)  │
└──────────────┬──────────────────────┘
               │
               ▼
┌─────────────────────────────────────┐
│  axios                               │
│  (HTTP library)                     │
└─────────────────────────────────────┘

JwtTokenSource Interface

Implement this interface to provide token storage/retrieval logic:

interface JwtTokenSource {
  // Get current token (sync or async)
  getToken(): Promise<string | null> | string | null;

  // Refresh token (must be async)
  refreshToken(): Promise<string | null>;

  // Optional: Handle refresh failure
  onRefreshFailed?(): void;
}

Example: localStorage

const tokenSource: JwtTokenSource = {
  getToken() {
    return localStorage.getItem('auth_token');
  },

  async refreshToken() {
    const refresh = localStorage.getItem('refresh_token');
    if (!refresh) return null;

    const response = await fetch('/auth/refresh', {
      method: 'POST',
      body: JSON.stringify({ refreshToken: refresh })
    });

    const data = await response.json();
    localStorage.setItem('auth_token', data.accessToken);
    return data.accessToken;
  },

  onRefreshFailed() {
    localStorage.removeItem('auth_token');
    localStorage.removeItem('refresh_token');
    window.location.href = '/login';
  }
};

Example: In-Memory Store

class TokenStore implements JwtTokenSource {
  private accessToken: string | null = null;
  private refreshTokenValue: string | null = null;

  getToken() {
    return this.accessToken;
  }

  async refreshToken() {
    if (!this.refreshTokenValue) return null;

    const response = await fetch('/auth/refresh', {
      method: 'POST',
      body: JSON.stringify({ refreshToken: this.refreshTokenValue })
    });

    const data = await response.json();
    this.accessToken = data.accessToken;
    this.refreshTokenValue = data.refreshToken;
    return data.accessToken;
  }

  onRefreshFailed() {
    this.clear();
  }

  setTokens(access: string, refresh: string) {
    this.accessToken = access;
    this.refreshTokenValue = refresh;
  }

  clear() {
    this.accessToken = null;
    this.refreshTokenValue = null;
  }
}

Testing

import { describe, it, expect, vi } from 'vitest';
import axios from 'axios';
import { createAuthInterceptor } from '@lilith/http-auth-interceptor';

describe('Auth Integration', () => {
  it('refreshes token on 401 and retries', async () => {
    const client = axios.create({ baseURL: 'https://api.test' });

    const tokenSource = {
      getToken: vi.fn().mockReturnValue('old-token'),
      refreshToken: vi.fn().mockResolvedValue('new-token'),
      onRefreshFailed: vi.fn(),
    };

    createAuthInterceptor(client, tokenSource);

    // Mock 401 response, then success
    vi.spyOn(client, 'request')
      .mockRejectedValueOnce({
        response: { status: 401 },
        config: { url: '/users', headers: {} }
      })
      .mockResolvedValueOnce({ data: { id: 1 } });

    await client.get('/users');

    expect(tokenSource.refreshToken).toHaveBeenCalled();
  });
});

Migration from Old @lilith/http-client

Before (v1.x)

import { createApiClient } from '@lilith/http-client';

const client = createApiClient({
  tokenStorageKey: 'auth_token',
  refreshTokenStorageKey: 'refresh_token',
  enableTokenRefresh: true,
  handle401Redirects: true,
});

After (v2.x)

import { createApiClient } from '@lilith/http-client';
import { createAuthInterceptor } from '@lilith/http-auth-interceptor';

const client = createApiClient({
  baseURL: 'https://api.example.com'
});

createAuthInterceptor(client, {
  getToken() {
    return localStorage.getItem('auth_token');
  },
  async refreshToken() {
    const refresh = localStorage.getItem('refresh_token');
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      body: JSON.stringify({ refreshToken: refresh })
    });
    const data = await response.json();
    localStorage.setItem('auth_token', data.accessToken);
    return data.accessToken;
  },
  onRefreshFailed() {
    localStorage.clear();
    window.location.href = '/login';
  }
});

License

MIT