@hawcx/oauth-client
v5.2.0
Published
Simple OAuth client for Hawcx authentication with delegation support
Readme
@hawcx/oauth-client
Simple, production-ready OAuth client SDK for Hawcx authentication.
Features
- OIDC Discovery -
HawcxOAuth.fromIssuer(...)resolves endpoints from/.well-known/openid-configurationand enforces signature +iss+aud+exp+nbfon verify - Simple JWT Verification - Uses
joselibrary for automatic JWKS caching and signature handling - PKCE Support - Native PKCE (RFC 7636) support for enhanced OAuth security
- Delegation API - Server-to-server user management (MFA, devices)
- Step-Up Auth - Step-up authentication flows for sensitive operations
- TypeScript First - Full type definitions included
- Zero Config Crypto - All cryptographic complexity hidden behind a single secret key
Installation
npm install @hawcx/oauth-clientQuick Start (OIDC discovery, recommended)
HawcxOAuth.fromIssuer(...) does the discovery fetch once at startup and
gives you a client whose exchangeCode + verifyToken enforce all the
standard OIDC claim checks. Construct once and reuse.
import { HawcxOAuth } from '@hawcx/oauth-client';
const oauth = await HawcxOAuth.fromIssuer({
issuer: 'https://api.hawcx.com', // → discovery + expected `iss`
configId: process.env.HAWCX_CONFIG_ID!, // → X-Config-Id header
clientId: process.env.HAWCX_CLIENT_ID!, // → expected `aud` on id_tokens
});
const { idToken, claims } = await oauth.exchangeCode(code, codeVerifier);
console.log(claims.sub); // user ID
console.log(claims.email); // user email
configIdvsclientId— they are different values. Hawcx separates the tenant routing key from the audience claim:
configId— the per-tenant config identifier. Sent as theX-Config-Idheader on the token-exchange POST so the OAuth server can look up the tenant.clientId— the value the tenant has configured (in the Hawcx admin console) as theaudclaim on issued id_tokens. The verifier matches the token'saudagainst this exact string.Passing the same value for both fails verification with
Unexpected JWT audienceunless your tenant happens to use identical strings.
Quick Start (legacy)
The legacy single-arg constructor still works for backward compatibility
with 4.x callers. It verifies the JWT signature only — iss and aud
are not checked. Prefer fromIssuer for new code.
import { HawcxOAuth } from '@hawcx/oauth-client';
const oauth = new HawcxOAuth({ configId: 'your-config-id' });
const { idToken, claims } = await oauth.exchangeCode(code, codeVerifier);Confidential clients (private_key_jwt)
If your project is registered in the Hawcx admin console with Client
authentication = Signing key (private_key_jwt), attach an Ed25519 signer.
The SDK then signs an RFC 7523 client_assertion on every token exchange
(PKCE is still sent). Public clients omit this and keep using PKCE only.
import { HawcxOAuth, ClientAssertionSigner } from '@hawcx/oauth-client';
const privateJwk = JSON.parse(process.env.HAWCX_PRIVATE_JWK!); // OKP/Ed25519, with kid
const oauth = (
await HawcxOAuth.fromIssuer({
issuer: 'https://dev-demo-api.hawcx.com',
configId: process.env.HAWCX_CONFIG_ID!,
clientId: process.env.HAWCX_CLIENT_ID!,
})
).withClientAssertion(ClientAssertionSigner.ed25519FromJwk(privateJwk));
const { idToken, claims } = await oauth.exchangeCode(code, codeVerifier);The private key must be Ed25519 (kty: 'OKP', crv: 'Ed25519', with a kid);
its public JWK is what you register in the admin console. The assertion's aud
is the discovered token_endpoint, and EdDSA is the only accepted algorithm.
clientId must be the confidential client's id — it becomes the assertion
iss/sub and the id_token aud.
If you bound a
nonceat/authorize, pass it as the 4th argument toexchangeCode(code, verifier, redirectUri?, expectedNonce)— verification refuses a nonce-bearing token when none is supplied (OIDC Core §3.1.3.7).
Modules
| Module | Purpose | |--------|---------| | OAuth | Token exchange and JWT verification | | Delegation | Server-to-server user management (MFA, devices) | | Step-Up | Step-up authentication flows |
OAuth Module
Core module for OAuth 2.1 + PKCE authentication.
Construction
Discovery mode (recommended)
import { HawcxOAuth } from '@hawcx/oauth-client';
const oauth = await HawcxOAuth.fromIssuer({
issuer: 'https://api.hawcx.com', // Required: discovery + expected `iss`
configId: 'your-config-id', // Required: X-Config-Id header
clientId: 'your-client-id', // Required: expected `aud` on id_tokens
timeout: 10000, // Optional: per-request timeout in ms
discoveryTimeout: 5000, // Optional: discovery fetch timeout in ms
});
oauth.discoveryMetadata; // resolved OIDC discovery doc, for diagnosticsThrows DiscoveryError if the discovery document is unreachable, returns
non-2xx, isn't valid JSON, or is missing any of token_endpoint,
jwks_uri, id_token_signing_alg_values_supported.
Legacy mode
const oauth = new HawcxOAuth({
configId: 'your-config-id', // Required
baseUrl: 'https://api.hawcx.com', // Optional: defaults to api.hawcx.com
timeout: 10000, // Optional
});Hits the fixed endpoints <baseUrl>/oauth2/token and <baseUrl>/keys
directly. Signature-only verification — iss and aud are not
checked. Kept for backward compatibility with 4.x callers.
Methods
exchangeCode(code, codeVerifier, redirectUri?): Promise<ExchangeResult>
Exchange an authorization code for tokens.
const { idToken, claims } = await oauth.exchangeCode(
'authorization-code',
'pkce-code-verifier'
);
// With redirect_uri (RFC 6749 §4.1.3) when your auth request registered one:
const { idToken, claims } = await oauth.exchangeCode(
'authorization-code',
'pkce-code-verifier',
'https://app.example.com/auth/callback'
);Returns:
interface ExchangeResult {
idToken: string; // Raw JWT token
claims: JwtClaims; // Verified claims
}verifyToken(token, options?): Promise<JwtClaims>
Verify an existing JWT token. In discovery mode, enforces signature + iss +
aud + exp + nbf. In legacy mode, only the signature is checked.
const claims = await oauth.verifyToken(idToken);
// With expected nonce (useful for flows that bind a nonce to the auth request):
const claims = await oauth.verifyToken(idToken, { nonce: 'expected-nonce' });Returns:
interface JwtClaims {
sub?: string; // User ID
iss?: string; // Issuer
aud?: string | string[]; // Audience
iat?: number; // Issued at (unix timestamp)
exp?: number; // Expiration (unix timestamp)
email?: string; // User email
name?: string; // User name
amr?: string[]; // Authentication methods
mfa_method?: string; // MFA method used
nonce?: string; // Nonce (if bound)
}refreshJwks(): void
Reset the JWKS cache so the next verification refetches keys (useful for testing or key rotation).
oauth.refreshJwks();Deprecated:
clearCache()is a deprecated passthrough that callsrefreshJwks()(and emits a one-timeconsole.warn). It still works but will be removed in 6.0.0 — preferrefreshJwks().
Errors
import {
HawcxOAuthError, // Base error class
DiscoveryError, // OIDC discovery resolution failed (fromIssuer only)
TokenExchangeError, // Code exchange failed
TokenVerificationError // JWT verification failed
} from '@hawcx/oauth-client';
try {
const oauth = await HawcxOAuth.fromIssuer({ issuer, configId, clientId });
await oauth.exchangeCode(code, verifier);
} catch (error) {
if (error instanceof DiscoveryError) {
console.log(error.cause); // original network / parse error
}
if (error instanceof TokenExchangeError) {
console.log(error.statusCode); // HTTP status code
}
if (error instanceof TokenVerificationError) {
// "Token expired", "Invalid signature", "Unexpected JWT audience: ...", etc.
console.log(error.message);
}
}Delegation Module
Server-to-server API for managing users, MFA, and devices. Requires a Hawcx secret key.
DelegationClient
import { DelegationClient, MfaMethod } from '@hawcx/oauth-client';
const client = DelegationClient.fromSecretKey({
baseUrl: 'https://api.hawcx.com',
secretKey: process.env.HAWCX_SECRET_KEY!,
apiKey: 'optional-api-key', // Optional
timeoutSeconds: 15, // Optional (default: 15)
clockSkewSeconds: 300, // Optional (default: 300)
});MFA Operations
client.mfa.initiate(options): Promise<object>
Initiate MFA setup or change for a user.
// Email MFA
const result = await client.mfa.initiate({
userId: '[email protected]',
mfaMethod: MfaMethod.EMAIL,
});
// SMS MFA (requires phone number)
const result = await client.mfa.initiate({
userId: '[email protected]',
mfaMethod: MfaMethod.SMS,
phoneNumber: '+1234567890',
});
// TOTP MFA
const result = await client.mfa.initiate({
userId: '[email protected]',
mfaMethod: MfaMethod.TOTP,
});
// Change MFA method
const result = await client.mfa.initiate({
userId: '[email protected]',
mfaMethod: MfaMethod.EMAIL, // Current method for verification
mfaChangeTo: MfaMethod.TOTP, // New method to switch to
});MFA Methods:
enum MfaMethod {
EMAIL = 'email',
SMS = 'sms',
TOTP = 'totp',
}client.mfa.verify(options): Promise<object>
Verify MFA setup with OTP.
const result = await client.mfa.verify({
userId: '[email protected]',
sessionId: 'session-from-initiate',
otp: '123456', // 6-digit code
});User Operations
client.users.getCredentials(userId): Promise<object>
Get user credentials/metadata.
const creds = await client.users.getCredentials('[email protected]');
console.log(creds.mfa_method);Device Operations
client.devices.list(userId): Promise<object>
List all devices for a user.
const { devices } = await client.devices.list('[email protected]');client.devices.revoke(options): Promise<object>
Revoke a device (block it from authenticating).
await client.devices.revoke({
userId: '[email protected]',
deviceId: 'device-h2index',
});client.devices.unrevoke(options): Promise<object>
Unrevoke a previously revoked device.
await client.devices.unrevoke({
userId: '[email protected]',
deviceId: 'device-h2index',
});client.devices.delete(options): Promise<object>
Permanently delete a device.
await client.devices.delete({
userId: '[email protected]',
deviceId: 'device-h2index',
});Generic Request
client.request<TReq, TRes>(options): Promise<TRes>
Send a custom encrypted request to any delegation endpoint.
const result = await client.request({
endpoint: '/hc_auth/v5/custom/endpoint',
payload: { userid: '[email protected]', custom_field: 'value' },
headers: { 'X-Custom-Header': 'value' },
includeBaseUrl: true, // Optional (default: true)
});Credential Utilities
parseHawcxSecretKey(secretKey): ParsedCredentials
Parse a secret key into usable key material (advanced usage).
import { parseHawcxSecretKey } from '@hawcx/oauth-client';
const creds = parseHawcxSecretKey(process.env.HAWCX_SECRET_KEY!);
// Returns: { kid, hkid, signingKeyPem, verifyKeyPem, encryptKeyPem, decryptKeyPem }generateCredentialBlob(options): string
Generate a new credential blob (for admin tooling).
import { generateCredentialBlob } from '@hawcx/oauth-client';
const secretKey = generateCredentialBlob({
kid: 'customer-key-id',
hkid: 'hawcx-key-id',
edPrivate: Buffer.alloc(32), // Ed25519 private key (32 bytes)
xPrivate: Buffer.alloc(32), // X25519 private key (32 bytes)
hawcxEdPublic: Buffer.alloc(32), // Hawcx Ed25519 public key (32 bytes)
hawcxXPublic: Buffer.alloc(32), // Hawcx X25519 public key (32 bytes)
});
// Returns: "hwx_sk_v1_<base64url>"Delegation Errors
import {
DelegationError, // Base delegation error
DelegationCryptoError, // Encryption/signing failed
DelegationRequestError, // Request to IDP failed
DelegationResponseError, // Invalid response from IDP
} from '@hawcx/oauth-client';
try {
await client.mfa.initiate({ userId: '[email protected]', mfaMethod: MfaMethod.EMAIL });
} catch (error) {
if (error instanceof DelegationRequestError) {
console.log(error.statusCode); // HTTP status
console.log(error.responseBody); // Response body
}
}Step-Up Module
For step-up authentication flows (e.g., changing MFA method requires re-authentication).
StepUpClient (recommended: withPrivateKeyJwt)
StepUpClient.withPrivateKeyJwt is the recommended construction path. It reuses
the same Ed25519 private_key_jwt signing key already registered in the Hawcx admin
console for OIDC — one standards-based key, no separate credential blob required.
import { StepUpClient } from '@hawcx/oauth-client';
const privateJwk = JSON.parse(process.env.HAWCX_PRIVATE_JWK!); // OKP/Ed25519, with kid
const stepUp = StepUpClient.withPrivateKeyJwt({
baseUrl: 'https://api.hawcx.com',
configId: process.env.HAWCX_CONFIG_ID!, // X-Config-Id tenant routing
clientId: process.env.HAWCX_CLIENT_ID!, // iss/sub in per-request JWT
oidcSigningKey: privateJwk, // same key as ClientAssertionSigner
kid: privateJwk.kid,
relyingParty: 'your-app.com', // Optional
apiKey: 'optional-api-key', // Optional
apiPrefix: '/v1', // Optional (default: '/v1')
});The signing key must be Ed25519 (kty: 'OKP', crv: 'Ed25519'). It accepts a JWK
object, a PKCS#8 PEM string, or a pre-imported jose KeyLike.
Each request is signed with a short-lived per-request EdDSA JWT whose aud is the
full request URL and whose body_sha256 claim covers the exact bytes sent.
Methods
stepUp.startToken(options): Promise<StepUpStartTokenResponse>
Start a step-up authentication flow.
const { start_token, expires_in } = await stepUp.startToken({
userId: '[email protected]',
purpose: 'change_mfa_method',
newMfaMethod: 'totp', // 'email_otp' | 'sms_otp' | 'totp'
});
// Send start_token to client for step-up authenticationstepUp.consumeReceipt(options): Promise<StepUpConsumeResponse>
Consume a step-up receipt after user completes authentication.
const { ok } = await stepUp.consumeReceipt({
receipt: 'step-up-receipt-from-client',
});
if (ok) {
// Step-up verified, proceed with sensitive operation
}Types
type StepUpPurpose = 'change_mfa_method';
type HxAuthMfaMethod = 'sms_otp' | 'email_otp' | 'totp';
interface StepUpStartTokenResponse {
start_token: string;
expires_in: number;
}
interface StepUpConsumeResponse {
ok: boolean;
}Legacy (deprecated): fromSecretKey
StepUpClient.fromSecretKey and StepUpClient.fromKeys are deprecated since 5.2.0
and will be removed in a future major version. They use a proprietary ECIES credential
format. Migrate to withPrivateKeyJwt.
// DEPRECATED — migrate to withPrivateKeyJwt above
import { StepUpClient } from '@hawcx/oauth-client';
const stepUp = StepUpClient.fromSecretKey({
baseUrl: 'https://api.hawcx.com',
secretKey: process.env.HAWCX_SECRET_KEY!, // hwx_sk_v1_… blob
relyingParty: 'your-app.com',
});Examples
Next.js Auth Callback
// app/api/auth/callback/route.ts
import { HawcxOAuth, TokenExchangeError } from '@hawcx/oauth-client';
import { cookies } from 'next/headers';
const oauth = new HawcxOAuth({
configId: process.env.HAWCX_CONFIG_ID!,
});
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const cookieStore = cookies();
const codeVerifier = cookieStore.get('code_verifier')?.value;
if (!code || !codeVerifier) {
return Response.redirect('/login?error=missing_params');
}
try {
const { idToken, claims } = await oauth.exchangeCode(code, codeVerifier);
// Create session with claims.sub, claims.email, etc.
return Response.redirect('/dashboard');
} catch (error) {
if (error instanceof TokenExchangeError) {
console.error('Token exchange failed:', error.message);
}
return Response.redirect('/login?error=auth_failed');
}
}Admin MFA Management
import { DelegationClient, MfaMethod } from '@hawcx/oauth-client';
const admin = DelegationClient.fromSecretKey({
baseUrl: process.env.HAWCX_API_URL!,
secretKey: process.env.HAWCX_SECRET_KEY!,
});
// Reset user MFA to email
async function resetUserMfa(userId: string, otp: string) {
const initResult = await admin.mfa.initiate({
userId,
mfaMethod: MfaMethod.EMAIL,
});
const verifyResult = await admin.mfa.verify({
userId,
sessionId: initResult.session_id,
otp,
});
return verifyResult;
}
// Revoke all user devices
async function revokeAllDevices(userId: string) {
const { devices } = await admin.devices.list(userId);
for (const device of devices) {
await admin.devices.revoke({
userId,
deviceId: device.h2index,
});
}
}Step-Up for MFA Change
import { StepUpClient, DelegationClient, MfaMethod } from '@hawcx/oauth-client';
const privateJwk = JSON.parse(process.env.HAWCX_PRIVATE_JWK!);
const stepUp = StepUpClient.withPrivateKeyJwt({
baseUrl: process.env.HAWCX_API_URL!,
configId: process.env.HAWCX_CONFIG_ID!,
clientId: process.env.HAWCX_CLIENT_ID!,
oidcSigningKey: privateJwk,
kid: privateJwk.kid,
relyingParty: 'myapp.com',
});
const delegation = DelegationClient.fromSecretKey({
baseUrl: process.env.HAWCX_API_URL!,
secretKey: process.env.HAWCX_SECRET_KEY!,
});
async function changeMfaMethod(userId: string, newMethod: 'totp' | 'sms_otp' | 'email_otp') {
// 1. Start step-up flow
const { start_token } = await stepUp.startToken({
userId,
purpose: 'change_mfa_method',
newMfaMethod: newMethod,
});
// 2. Send start_token to client, user completes step-up auth
// 3. Client sends back receipt
// 4. Verify step-up receipt
const { ok } = await stepUp.consumeReceipt({ receipt: 'receipt-from-client' });
if (!ok) {
throw new Error('Step-up verification failed');
}
// 5. Now safe to change MFA
await delegation.mfa.initiate({
userId,
mfaMethod: MfaMethod.EMAIL,
mfaChangeTo: newMethod,
});
}Environment Variables
| Variable | Description |
|----------|-------------|
| HAWCX_CONFIG_ID | Your Hawcx OAuth config ID (for token verification) |
| HAWCX_SECRET_KEY | Your Hawcx secret key (for delegation/step-up APIs) |
| HAWCX_API_URL | API base URL (default: https://api.hawcx.com) |
Secret Key Format
The HAWCX_SECRET_KEY is a compact credential blob:
hwx_sk_v1_<base64url-encoded-json>It contains:
- Your Ed25519 signing key (for request authentication)
- Your X25519 decryption key (for response decryption)
- Hawcx's Ed25519 public key (for response verification)
- Hawcx's X25519 public key (for request encryption)
- Key IDs for rotation tracking
Generate via the Hawcx Dashboard or admin tools.
JWKS Caching
The SDK automatically caches JWKS (JSON Web Key Set) for JWT verification:
- Keys are fetched lazily on first
verifyToken()call - Cached in memory for the lifetime of the
HawcxOAuthinstance - Auto-refreshes if a key ID isn't found (handles key rotation)
- Call
refreshJwks()to force a refresh (clearCache()is a deprecated alias — see above)
// Keys fetched once, cached thereafter
const claims1 = await oauth.verifyToken(token1); // Fetches JWKS
const claims2 = await oauth.verifyToken(token2); // Uses cache
const claims3 = await oauth.verifyToken(token3); // Uses cache
// Force refresh if needed
oauth.refreshJwks();
const claims4 = await oauth.verifyToken(token4); // Fetches JWKS againRequirements
- Node.js >= 18.0.0
License
MIT
