Skip to content

@ahoo-wang/fetcher-cosec

The @ahoo-wang/fetcher-cosec package integrates CoSec (Corporate Security) authentication with the Fetcher HTTP client. It provides JWT token management with automatic refresh, persistent token and device ID storage via Storage, multi-tenant request attribution, and a complete interceptor chain that handles authorization, 401 retry, and 403 error propagation.

Installation

bash
pnpm add @ahoo-wang/fetcher-cosec

Architecture Overview

mermaid
graph TB
    subgraph sg_1 ["CoSec Interceptor Chain"]
        direction TB
        REQ1["CoSecRequestInterceptor<br>(order: MIN_SAFE_INTEGER + 1000)"]
        REQ2["ResourceAttributionRequestInterceptor<br>(order: URL_RESOLVE - 1000)"]
        REQ3["AuthorizationRequestInterceptor<br>(order: MIN_SAFE_INTEGER + 2000)"]
        RES1["AuthorizationResponseInterceptor<br>(order: MIN_SAFE_INTEGER + 1000)"]
        ERR1["UnauthorizedErrorInterceptor<br>401 handler"]
        ERR2["ForbiddenErrorInterceptor<br>403 handler"]
    end

    subgraph sg_2 ["Storage Layer"]
        direction LR
        TS["TokenStorage<br>extends KeyStorage&lt;JwtCompositeToken&gt;"]
        DS["DeviceIdStorage<br>extends KeyStorage&lt;string&gt;"]
    end

    subgraph sg_3 ["Token Management"]
        direction LR
        TM["JwtTokenManager<br>refresh orchestration"]
        TR["TokenRefresher<br>refresh strategy"]
        JT["JwtCompositeToken<br>access + refresh tokens"]
    end

    REQ1 --> |"CoSec-App-Id<br>CoSec-Device-Id<br>CoSec-Request-Id<br>CoSec-Space-Id"| DS
    REQ2 --> |"tenantId, ownerId<br>path params"| TS
    REQ3 --> |"Authorization: Bearer"| TM
    RES1 --> |"401 -> refresh -> retry"| TM
    TM --> TS
    TM --> TR
    TS --> JT

    style REQ1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style REQ2 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style REQ3 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style RES1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style ERR1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style ERR2 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style TS fill:#161b22,stroke:#30363d,color:#e6edf3
    style DS fill:#161b22,stroke:#30363d,color:#e6edf3
    style TM fill:#161b22,stroke:#30363d,color:#e6edf3
    style TR fill:#161b22,stroke:#30363d,color:#e6edf3
    style JT fill:#161b22,stroke:#30363d,color:#e6edf3

Quick Start

typescript
import { Fetcher } from '@ahoo-wang/fetcher';
import { CoSecConfigurer, CoSecTokenRefresher } from '@ahoo-wang/fetcher-cosec';

const fetcher = new Fetcher({ baseUrl: 'https://api.example.com' });

const configurer = new CoSecConfigurer({
  appId: 'my-web-app',
  tokenRefresher: new CoSecTokenRefresher({
    fetcher,
    endpoint: '/auth/refresh',
  }),
  onUnauthorized: () => (window.location.href = '/login'),
  onForbidden: () => alert('Access Denied'),
});

configurer.applyTo(fetcher);

// All requests now include CoSec headers and Bearer token
const users = await fetcher.get('/api/users');

Configuration

CoSecConfig

The CoSecConfigurer accepts a CoSecConfig object with three configuration levels:

LevelRequired PropertiesFeatures
MinimalappId onlySecurity headers only (device ID, request ID, app ID)
StandardappId + tokenRefresherFull JWT authentication with auto-refresh
EnterpriseAll optionsMulti-tenant, custom storage, custom error handlers
PropertyTypeRequiredDefaultDescription
appIdstringYes--Application identifier for CoSec-App-Id header
tokenRefresherTokenRefresherNo--Token refresh strategy. Enables JWT auth when provided.
tokenStorageTokenStorageNonew TokenStorage()Token persistence backend
deviceIdStorageDeviceIdStorageNonew DeviceIdStorage()Device ID persistence
spaceIdProviderSpaceIdProviderNoNoneSpaceIdProviderMulti-tenant space resolver
onUnauthorized(exchange) => Promise<void>No--Custom 401 error handler
onForbidden(exchange) => Promise<void>No--Custom 403 error handler

Source: packages/cosec/src/cosecConfigurer.ts:86-298

Interceptors

CoSecRequestInterceptor

Injects security headers into every outgoing request:

HeaderValueDescription
CoSec-App-IdConfigured appIdApplication identifier
CoSec-Device-IdFrom DeviceIdStoragePersistent device identifier (nanoid)
CoSec-Request-IdGenerated per-requestUnique request correlation ID
CoSec-Space-IdFrom SpaceIdProviderSpace identifier (if resolved)

Source: packages/cosec/src/cosecRequestInterceptor.ts:214-382

AuthorizationRequestInterceptor

Adds the Authorization: Bearer <token> header. Before injecting, it checks if the access token needs refresh and performs a proactive refresh if possible. Respects the Ignore-Refresh-Token attribute to prevent recursive refresh during the refresh request itself.

Source: packages/cosec/src/authorizationRequestInterceptor.ts:41-92

AuthorizationResponseInterceptor

Handles 401 responses by:

  1. Checking if the response status is 401
  2. Verifying the refresh token is still valid
  3. Refreshing the access token
  4. Retrying the original request with the new token
  5. Clearing tokens on refresh failure

Source: packages/cosec/src/authorizationResponseInterceptor.ts:42-81

ResourceAttributionRequestInterceptor

Automatically injects tenantId and ownerId path parameters from the current JWT payload when the URL template contains {tenantId} or {ownerId} placeholders. Runs before URL resolution.

Source: packages/cosec/src/resourceAttributionRequestInterceptor.ts:58-127

Error Interceptors

InterceptorStatusBehavior
UnauthorizedErrorInterceptor401Calls onUnauthorized callback from config
ForbiddenErrorInterceptor403Calls onForbidden callback from config

Source: packages/cosec/src/unauthorizedErrorInterceptor.ts, packages/cosec/src/forbiddenErrorInterceptor.ts

Authentication Flow

mermaid
sequenceDiagram
autonumber

    participant App as Application
    participant F as Fetcher
    participant CRI as CoSecRequestInterceptor
    participant ARI as AuthorizationRequestInterceptor
    participant API as Remote API
    participant ARS as AuthorizationResponseInterceptor
    participant TM as JwtTokenManager
    participant TS as TokenStorage
    participant REF as TokenRefresher

    App->>F: fetcher.get('/api/data')
    F->>CRI: intercept(exchange)
    CRI->>CRI: Add CoSec-App-Id, CoSec-Device-Id, CoSec-Request-Id
    CRI->>ARI: intercept(exchange)
    ARI->>TS: get current token
    TS-->>ARI: JwtCompositeToken
    alt Token expired but refreshable
        ARI->>TM: refresh()
        TM->>REF: refresh(currentToken)
        Note over REF: POST /auth/refresh<br>with Ignore-Refresh-Token
        REF-->>TM: new CompositeToken
        TM->>TS: setCompositeToken(newToken)
    end
    ARI->>ARI: Set Authorization: Bearer <token>
    F->>API: HTTP Request with headers
    alt 401 Unauthorized
        API-->>ARS: 401 Response
        ARS->>TM: refresh()
        TM->>REF: refresh(currentToken)
        REF-->>TM: new CompositeToken
        TM->>TS: setCompositeToken(newToken)
        ARS->>F: retry with new token
        F->>API: HTTP Request (retried)
    end
    API-->>App: Response

JWT Token Management

JwtToken

Parses a JWT string and provides typed payload access with expiration checking. Supports an earlyPeriod to trigger proactive refresh before actual expiration.

typescript
import { JwtToken } from '@ahoo-wang/fetcher-cosec';

const token = new JwtToken<CoSecJwtPayload>('eyJ...', 300000); // 5 min early period
console.log(token.isExpired);     // false if not yet expired
console.log(token.payload?.sub);  // user ID from payload

JwtCompositeToken

Manages access and refresh token pairs together:

typescript
import { JwtCompositeToken } from '@ahoo-wang/fetcher-cosec';

const composite = new JwtCompositeToken({
  accessToken: 'access.jwt.token',
  refreshToken: 'refresh.jwt.token',
}, 300000);

console.log(composite.authenticated);    // true if access token valid
console.log(composite.isRefreshNeeded);  // true if access token expired
console.log(composite.isRefreshable);    // true if refresh token still valid

TokenStorage

Extends KeyStorage<JwtCompositeToken> with authentication-specific methods and cross-tab synchronization:

MethodDescription
signIn(compositeToken)Store a new composite token
signOut()Remove the stored token
authenticatedCheck if a valid token is present
currentUserGet the JWT payload if authenticated

Source: packages/cosec/src/tokenStorage.ts:43-121

JwtTokenManager

Orchestrates token refresh operations with deduplication (prevents concurrent refresh requests):

Property / MethodDescription
currentTokenGet the current composite token from storage
refresh()Refresh the token. Deduplicates concurrent calls.
isRefreshNeededCheck if access token needs refresh
isRefreshableCheck if refresh token is still valid

Source: packages/cosec/src/jwtTokenManager.ts:33-105

CoSecTokenRefresher

A built-in TokenRefresher implementation that sends a POST request to a configured endpoint:

typescript
import { CoSecTokenRefresher } from '@ahoo-wang/fetcher-cosec';

const refresher = new CoSecTokenRefresher({
  fetcher: myFetcher,
  endpoint: '/auth/refresh',
});

// The refresher automatically sets IGNORE_REFRESH_TOKEN_ATTRIBUTE_KEY
// to prevent infinite refresh loops

Important: The refresh request includes IGNORE_REFRESH_TOKEN_ATTRIBUTE_KEY attribute to prevent the AuthorizationRequestInterceptor from triggering another refresh cycle.

Source: packages/cosec/src/tokenRefresher.ts:141-207

JWT Payload Types

mermaid
classDiagram
    class JwtPayload {
        +jti: string
        +sub: string
        +iss: string
        +aud: string | string[]
        +exp: number
        +nbf: number
        +iat: number
    }

    class CoSecJwtPayload {
        +tenantId: string
        +policies: string[]
        +roles: string[]
        +attributes: Record~string, any~
    }

    JwtPayload <|-- CoSecJwtPayload

    style JwtPayload fill:#161b22,stroke:#30363d,color:#e6edf3
    style CoSecJwtPayload fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
InterfaceKey Fields
JwtPayloadjti, sub, iss, aud, exp, nbf, iat
CoSecJwtPayloadInherits all + tenantId, policies, roles, attributes

Source: packages/cosec/src/jwts.ts:17-80

Device Tracking

DeviceIdStorage extends KeyStorage<string> with a getOrCreate() method that generates a unique device ID (via nanoid) on first use and persists it:

typescript
import { DeviceIdStorage } from '@ahoo-wang/fetcher-cosec';

const deviceStorage = new DeviceIdStorage();
const deviceId = deviceStorage.getOrCreate();
// First call: generates and stores a new nanoid
// Subsequent calls: returns the stored ID

Source: packages/cosec/src/deviceIdStorage.ts:35-71

Key Exports

ExportDescription
CoSecConfigurerFetcherConfigurer that registers all CoSec interceptors
CoSecConfigConfiguration interface for CoSec setup
CoSecHeadersHeader name constants (DEVICE_ID, APP_ID, SPACE_ID, AUTHORIZATION, REQUEST_ID)
CoSecRequestInterceptorAdds security headers to requests
AuthorizationRequestInterceptorAdds Bearer token with proactive refresh
AuthorizationResponseInterceptorHandles 401 with token refresh and retry
ResourceAttributionRequestInterceptorInjects tenantId/ownerId from JWT
UnauthorizedErrorInterceptorCustom 401 handler
ForbiddenErrorInterceptorCustom 403 handler
TokenStorageJWT token persistence with auth methods
DeviceIdStorageDevice ID persistence with getOrCreate()
JwtTokenManagerToken refresh orchestration with deduplication
JwtToken<Payload>Parsed JWT with typed payload
JwtCompositeTokenAccess + refresh token pair
JwtCompositeTokenSerializerSerializer for composite tokens
CoSecTokenRefresherBuilt-in POST-based token refresher
TokenRefresherInterface for custom refresh strategies
JwtPayloadStandard JWT payload interface
CoSecJwtPayloadCoSec-extended JWT payload with tenantId, roles, policies
AuthorizeResultsAuthorization result constants (ALLOW, EXPLICIT_DENY, IMPLICIT_DENY, etc.)
SpaceIdProviderInterface for multi-tenant space resolution
NoneSpaceIdProviderNo-op space provider

Cross-References

  • Fetcher -- CoSecConfigurer implements FetcherConfigurer to integrate with Fetcher's interceptor chain
  • Storage -- TokenStorage and DeviceIdStorage extend KeyStorage for persistence
  • React -- useSecurity, RouteGuard, and RefreshableRouteGuard provide React integration
  • EventBus -- Token and device storage use BroadcastTypedEventBus for cross-tab sync

Released under the Apache License 2.0.