npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@milencode/h3-oauth-kit

v1.2.2

Published

[![codecov](https://codecov.io/github/sashamilenkovic/h3-oauth-kit/graph/badge.svg?token=Y4JAJQWYCQ)](https://codecov.io/github/sashamilenkovic/h3-oauth-kit) ![Run Vitest](https://github.com/sashamilenkovic/h3-oauth-kit/actions/workflows/ci.yml/badge.svg)

Readme

h3-oauth-kit

codecov Run Vitest

A cookie-based, type-safe OAuth 2.0 toolkit for H3 apps. Handles login, callback, token refresh, and protected route middleware — all with automatic HTTP-only cookie storage and typed provider extensions.

Built-in providers: azure, clio, intuit, mycase
Custom providers: Add support for any OAuth 2.0 provider (Google, GitHub, Facebook, etc.) with full type safety via module augmentation.
See CUSTOM_PROVIDERS.md for details.


Philosophy

h3-oauth-kit is designed around HTTP-only cookies for token storage. This approach:

  • Serverless-friendly — No Redis, database, or external dependencies required
  • Secure by default — HTTP-only cookies prevent XSS attacks; refresh tokens are AES-256 encrypted
  • Simple architecture — Works out of the box with zero infrastructure setup
  • Fast — No network calls to retrieve tokens from external storage
  • Multi-tenant ready — Instance keys enable per-tenant OAuth configurations

When h3-oauth-kit Might Not Be the Right Fit

This library stores OAuth tokens in HTTP-only cookies. If your use case requires:

  • ❌ Centralized token storage (Redis/database-backed sessions)
  • ❌ Tokens must never touch client devices (even encrypted)
  • ❌ Immediate token revocation across all devices/instances
  • ❌ Cross-device session sharing (one login, multiple devices)
  • ❌ Tokens larger than 4KB (browser cookie size limits)

...you may need a different solution or should implement OAuth with custom session management.

For the vast majority of H3/Nuxt applications, cookie-based storage is the ideal balance of security, simplicity, and performance.


Features

  • 🔐 OAuth 2.0 Authorization Code flow support
  • 🤖 NEW: Client Credentials flow for machine-to-machine auth
  • 🔍 NEW: Token Introspection (RFC 7662) - validate token status
  • 📱 NEW: Device Authorization Flow (RFC 8628) - for CLI tools & IoT
  • 🔒 NEW: JWT Validation (RFC 7519) - decode, validate, verify signatures
  • 🍪 Secure HTTP-only cookie storage (AES-256 encrypted refresh tokens)
  • 🔁 Automatic token refresh on protected routes
  • 🧠 State validation & metadata preservation
  • 🛠️ Utility-first API with full TypeScript safety
  • 🎨 Extensible type system for custom OAuth providers
  • 🏢 Multi-tenant / multi-instance support
  • ⚡ In-memory caching (client credentials tokens + JWKS keys)
  • 🌲 Tree-shakable - import only what you need

Tree-Shakable Imports

v1.1.0+ introduces tree-shakable subpath exports for optimal bundle size.

Why Tree-Shaking Matters

Each optional feature (introspection, device flow) adds ~2-3KB to your bundle. With subpath exports, you only include what you use:

// ❌ Imports everything (even if you don't use introspection/device flow)
import { handleOAuthLogin, introspectToken } from '@milencode/h3-oauth-kit';

// ✅ Tree-shakable: only imports introspection (~2KB)
import { introspectToken } from '@milencode/h3-oauth-kit/introspection';

// ✅ Tree-shakable: only imports device flow (~3KB)
import { initiateDeviceFlow } from '@milencode/h3-oauth-kit/device-flow';

Available Subpaths

| Subpath | What it includes | When to use | |---------|------------------|-------------| | @milencode/h3-oauth-kit | Core OAuth (login, callback, refresh, protected routes) | Always - this is the main package | | @milencode/h3-oauth-kit/introspection | Token introspection (RFC 7662) | When you need to validate tokens with the provider | | @milencode/h3-oauth-kit/device-flow | Device authorization (RFC 8628) | For CLI tools, TV apps, IoT devices | | @milencode/h3-oauth-kit/jwt | JWT validation (RFC 7519) | When you need to decode/validate JWTs locally |

Example: Keeping Your Bundle Lean

// server/routes/auth/login.post.ts
import { handleOAuthLogin } from '@milencode/h3-oauth-kit';

export default defineEventHandler((event) => 
  handleOAuthLogin('azure', {}, event)
);
// server/routes/admin/validate-token.ts
// Only imported in this route - not included in other routes' bundles
import { introspectToken } from '@milencode/h3-oauth-kit/introspection';

export default defineEventHandler(async (event) => {
  const token = getHeader(event, 'authorization')?.split(' ')[1];
  const isValid = await introspectToken('azure', token);
  return { valid: isValid };
});
// cli-tool.ts - No web dependencies needed
import { authenticateDevice } from '@milencode/h3-oauth-kit/device-flow';

const tokens = await authenticateDevice('github', {
  scopes: ['repo', 'user'],
  onStart: (auth) => {
    console.log(`Visit: ${auth.verification_uri}`);
    console.log(`Code: ${auth.user_code}`);
  },
});

Result: Your H3 API routes stay lean - introspection code only loads when that route is called!


Installation

npm install @milencode/h3-oauth-kit

Or using yarn:

yarn add @milencode/h3-oauth-kit

Or using pnpm:

pnpm add @milencode/h3-oauth-kit

Environment Variables

To enable secure encryption of refresh tokens, you must define the following environment variable:

H3_OAUTH_ENCRYPTION_KEY=your_64_char_hex_string

This must be a 64-character hex string, which corresponds to a 32-byte encryption key for AES-256-CBC.

You can generate a key using Node.js:

crypto.randomBytes(32).toString('hex');

⚠️ H3_OAUTH_ENCRYPTION_KEY is required. If it's missing or invalid, the package will throw an error at runtime.


Custom OAuth Providers

NEW in v0.12.0! Add support for any OAuth 2.0 provider with full type safety.

// types/h3-oauth-kit.d.ts
declare module '@milencode/h3-oauth-kit' {
  interface CustomOAuthProviders {
    google: 'google';
  }
  interface CustomProviderTokenMap {
    google: GoogleAuthTokens;
  }
  // ... other type maps
}

// server/plugins/oauthProviders.ts
registerOAuthProvider('google', {
  clientId: 'YOUR_CLIENT_ID',
  clientSecret: 'YOUR_CLIENT_SECRET',
  authorizeEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
  tokenEndpoint: 'https://oauth2.googleapis.com/token',
  redirectUri: 'http://localhost:3000/api/auth/google/callback',
  scopes: ['openid', 'email', 'profile'],
});

// Use exactly like built-in providers - no `as any` needed!
export default defineProtectedRoute(['google'], async (event) => {
  const token = event.context.h3OAuthKit.google.access_token;
  // Full type safety!
});

📖 Read the full Custom Providers guide for Google, GitHub, Facebook, and more.


API Overview

registerOAuthProvider(provider, config) / registerOAuthProvider(provider, instanceKey, config)

Registers an OAuth provider configuration. Supports both global and scoped (multi-tenant) configurations.

Global Registration (Single-Tenant)

import { registerOAuthProvider } from '@milencode/h3-oauth-kit';

registerOAuthProvider('azure', {
  clientId: 'YOUR_CLIENT_ID',
  clientSecret: 'YOUR_CLIENT_SECRET',
  redirectUri: 'http://localhost:3000/api/auth/azure/callback',
  tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
  authorizeEndpoint:
    'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
  scopes: ['openid', 'profile', 'email'],
});

Scoped Registration (Multi-Tenant)

For multi-tenant applications, you can register multiple configurations for the same provider using an instanceKey:

// Register different Azure configurations for different tenants
registerOAuthProvider('azure', 'tenant-a', {
  clientId: 'TENANT_A_CLIENT_ID',
  clientSecret: 'TENANT_A_CLIENT_SECRET',
  redirectUri: 'http://localhost:3000/api/auth/azure/callback',
  tokenEndpoint: 'https://login.microsoftonline.com/tenant-a/oauth2/v2.0/token',
  authorizeEndpoint:
    'https://login.microsoftonline.com/tenant-a/oauth2/v2.0/authorize',
  scopes: ['openid', 'profile', 'email'],
});

registerOAuthProvider('azure', 'tenant-b', {
  clientId: 'TENANT_B_CLIENT_ID',
  clientSecret: 'TENANT_B_CLIENT_SECRET',
  redirectUri: 'http://localhost:3000/api/auth/azure/callback',
  tokenEndpoint: 'https://login.microsoftonline.com/tenant-b/oauth2/v2.0/token',
  authorizeEndpoint:
    'https://login.microsoftonline.com/tenant-b/oauth2/v2.0/authorize',
  scopes: ['openid', 'profile', 'email'],
});

// Or register different Clio configurations for different law firms
registerOAuthProvider('clio', 'smithlaw', {
  clientId: 'SMITHLAW_CLIENT_ID',
  clientSecret: 'SMITHLAW_CLIENT_SECRET',
  // ... other config
});

registerOAuthProvider('clio', 'johnsonlegal', {
  clientId: 'JOHNSONLEGAL_CLIENT_ID',
  clientSecret: 'JOHNSONLEGAL_CLIENT_SECRET',
  // ... other config
});

handleOAuthLogin(provider, options?, event?) / handleOAuthLogin(provider, instanceKey, options?, event?)

  • Can be used as a route handler or utility.
  • Supports automatic or manual redirection.
  • Supports both global and scoped (multi-tenant) provider configurations.
  • If state is not provided, a unique identifier is automatically generated.

Global Provider Usage

// Route Handler (redirects immediately)
export default handleOAuthLogin('azure', { redirect: true });

// Utility Usage (returns URL for manual redirect)
const { url } = await handleOAuthLogin('azure', {}, event);

Scoped Provider Usage (Multi-Tenant)

// Route Handler for specific tenant
export default handleOAuthLogin('azure', 'tenant-a', { redirect: true });

// Utility Usage for specific law firm
const { url } = await handleOAuthLogin('clio', 'smithlaw', {}, event);

Advanced Usage with Custom State

import { defineEventHandler, getQuery } from 'h3';
import { handleOAuthLogin } from '@milencode/h3-oauth-kit';

export default defineEventHandler(async (event) => {
  const { tenant } = getQuery(event);

  return await handleOAuthLogin(
    'azure',
    tenant as string, // Use dynamic instanceKey
    {
      state: (event) => {
        const { redirectTo } = getQuery(event);
        return {
          redirectTo: redirectTo ?? '/',
          requestId: crypto.randomUUID(),
        };
      },
    },
    event,
  );
});

handleOAuthCallback(provider, options?, event?)

  • Exchanges code for tokens, verifies state, and stores tokens in cookies.
  • Can auto-redirect or return structured result.
  • Automatically detects scoped providers from the state parameter (no need to pass instanceKey manually).

Route Handler (with redirect):

// Works for both global and scoped providers
export default handleOAuthCallback('azure', {
  redirectTo: '/dashboard',
});

Utility Usage (custom logic after callback):

This example demonstrates how to handle the callback, where state represents the data passed during login (including instanceKey for scoped providers), and callbackQueryData contains additional data returned by the provider:

import { defineEventHandler, sendRedirect } from 'h3';
import { handleOAuthCallback } from '@milencode/h3-oauth-kit';

export default defineEventHandler(async (event) => {
  const { state, callbackQueryData } = await handleOAuthCallback(
    'azure',
    { redirect: false },
    event,
  );

  return sendRedirect(event, state.redirectTo || '/');
});

defineProtectedRoute(providers, handler, options?)

  • Declares that one or more providers must be authenticated before the route handler runs.
  • Automatically checks cookie presence and token freshness.
  • If expired, the access token is refreshed (if possible).
  • If tokens are missing or invalid, a 401 is returned.
  • Supports both global and scoped (multi-tenant) providers.
  • Injects validated token data into event.context.h3OAuthKit with type-safe provider keys.

Global Provider Example:

import { defineProtectedRoute } from '@milencode/h3-oauth-kit';

export default defineProtectedRoute(['azure'], async (event) => {
  const token = event.context.h3OAuthKit.azure.access_token;

  try {
    return await $fetch(`https://graph.microsoft.com/v1.0/me`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
  } catch (error) {
    console.error('Error fetching azure user profile:', error);
    throw error;
  }
});

Scoped Provider Example (Multi-Tenant):

import { defineProtectedRoute } from '@milencode/h3-oauth-kit';

export default defineProtectedRoute(
  [
    { provider: 'azure', instanceKey: 'tenant-a' },
    { provider: 'clio', instanceKey: 'smithlaw' },
  ],
  async (event) => {
    // Access tokens for specific instances (note the bracket notation for scoped keys)
    const azureToken = event.context.h3OAuthKit['azure:tenant-a'].access_token;
    const clioToken = event.context.h3OAuthKit['clio:smithlaw'].access_token;

    // Make API calls with instance-specific tokens
    const [azureProfile, clioUser] = await Promise.all([
      $fetch('https://graph.microsoft.com/v1.0/me', {
        headers: { Authorization: `Bearer ${azureToken}` },
      }),
      $fetch('https://app.clio.com/api/v4/users/who_am_i.json', {
        headers: { Authorization: `Bearer ${clioToken}` },
      }),
    ]);

    return { azureProfile, clioUser };
  },
);

Mixed Global and Scoped Providers:

export default defineProtectedRoute(
  [
    'azure', // Global azure config
    { provider: 'clio', instanceKey: 'smithlaw' }, // Scoped clio config
  ],
  async (event) => {
    const globalAzureToken = event.context.h3OAuthKit.azure.access_token;
    const scopedClioToken =
      event.context.h3OAuthKit['clio:smithlaw'].access_token;

    // Use both tokens...
  },
);

💡 This is especially powerful because all tokens are type-safe — you get full IntelliSense and validation for each provider's token fields, and the context keys automatically reflect whether you're using global (azure) or scoped (azure:tenant-a) providers.

Accessing Resolved Instance Keys

When using withInstanceKeys for dynamic instance resolution, you can access the resolved instance keys directly from the event context via h3OAuthKitInstances. This provides full type safety and eliminates the need to re-extract router parameters:

import {
  defineProtectedRoute,
  withInstanceKeys,
} from '@milencode/h3-oauth-kit';
import { getRouterParams, createError } from 'h3';

const getClioAccountIds = () => ['123', '12345', '123456'];
const isValidClioAccountId = (id: string) => getClioAccountIds().includes(id);

export default defineProtectedRoute(
  [
    'azure',
    withInstanceKeys('clio', getClioAccountIds(), (event) => {
      const { clioId } = getRouterParams(event);

      if (!clioId) {
        throw createError({
          statusCode: 400,
          message: 'Clio account ID is required',
        });
      }

      if (!isValidClioAccountId(clioId)) {
        throw createError({
          statusCode: 400,
          message: 'Invalid Clio account ID',
        });
      }

      return clioId; // Returns typed instance key
    }),
  ],
  async (event) => {
    // ✨ NEW: Access the typed instance key directly from context!
    const clioId = event.context.h3OAuthKitInstances.clio; // Type: "123" | "12345" | "123456"

    // No need to re-extract from router params!
    if (!clioId) {
      throw createError({ statusCode: 400, message: 'Missing clio instance' });
    }

    // This now works with full type safety!
    const clioTokens = event.context.h3OAuthKit[`clio:${clioId}`];
    const azureTokens = event.context.h3OAuthKit.azure;

    return {
      clioId, // Fully typed as "123" | "12345" | "123456"
      hasClioTokens: !!clioTokens,
      hasAzureTokens: !!azureTokens,
    };
  },
);

Benefits:

  • Full type safety - TypeScript knows the exact possible instance keys
  • No re-extraction needed - The resolved key is already validated and typed
  • Zero breaking changes - Existing code continues to work
  • Better developer experience - IntelliSense shows available instance keys

Context Properties:

// For global providers
event.context.h3OAuthKitInstances.azure; // undefined (no instance key)

// For scoped providers with explicit instanceKey
event.context.h3OAuthKitInstances.clio; // "smithlaw" (from { provider: "clio", instanceKey: "smithlaw" })

// For scoped providers with withInstanceKeys resolver
event.context.h3OAuthKitInstances.clio; // "123" | "12345" | "123456" (typed union from resolver)

withInstanceKeys(provider, instanceKeys, resolver)

A utility for creating typed provider definitions with explicit instance keys. This enables better TypeScript support when working with dynamic instance resolution.

import { withInstanceKeys } from '@milencode/h3-oauth-kit';

// Define possible instance keys and resolution logic
const clioProvider = withInstanceKeys(
  'clio',
  ['smithlaw', 'johnsonlegal', 'LOAG'],
  (event) => {
    const { firmId } = getRouterParams(event);
    return firmId; // TypeScript knows this must be one of the defined keys
  },
);

// Use in defineProtectedRoute
export default defineProtectedRoute([clioProvider], async (event) => {
  // TypeScript knows about all possible instance keys
  const instanceKey = event.context.h3OAuthKitInstances.clio; // 'smithlaw' | 'johnsonlegal' | 'LOAG'
  const tokens = event.context.h3OAuthKit[`clio:${instanceKey}`]; // Fully typed
});

handleOAuthLogout(providers, options?, event?)

  • Clears secure HTTP-only cookies for one or more providers.
  • Can be used as a route handler or as a utility in a custom H3 route.
  • Supports both global and scoped (multi-tenant) providers.
  • Optionally redirects the user after logout, or returns a structured result.

Global Providers:

// server/api/auth/logout.get.ts
import { handleOAuthLogout } from '@milencode/h3-oauth-kit';

export default handleOAuthLogout(['azure', 'clio'], {
  redirectTo: '/login',
});

Scoped Providers (Multi-Tenant):

// Logout specific tenant/instance combinations
export default handleOAuthLogout(
  [
    { provider: 'azure', instanceKey: 'tenant-a' },
    { provider: 'clio', instanceKey: 'smithlaw' },
  ],
  {
    redirectTo: '/login',
  },
);

Mixed Global and Scoped:

// Logout global azure + scoped clio
export default handleOAuthLogout(
  [
    'azure', // Global
    { provider: 'clio', instanceKey: 'smithlaw' }, // Scoped
  ],
  {
    redirectTo: '/login',
  },
);

Utility Usage (e.g., inside a custom route handler)

import { defineEventHandler } from 'h3';
import { handleOAuthLogout } from '@milencode/h3-oauth-kit';

export default defineEventHandler(async (event) => {
  const result = await handleOAuthLogout(['azure'], {}, event);

  return {
    message: 'User logged out',
    ...result,
  };
});

Dynamic Usage (providers via query param)

// server/api/auth/logout.get.ts
import { defineEventHandler, getQuery } from 'h3';
import { handleOAuthLogout } from '@milencode/h3-oauth-kit';

export default defineEventHandler((event) => {
  const { providers } = getQuery(event);

  const providersArray = Array.isArray(providers)
    ? providers
    : [providers].filter(Boolean);

  if (!providersArray.length) {
    throw createError({
      statusCode: 400,
      statusMessage: "Missing or invalid 'providers' query parameter",
    });
  }

  return handleOAuthLogout(providersArray, { redirectTo: '/login' }, event);
});

💡 Supports query strings like: /api/auth/logout?providers=azure&providers=clio


getClientCredentialsToken(provider, options?)

NEW! Retrieves an access token using the OAuth 2.0 Client Credentials flow (RFC 6749 Section 4.4).

This is for machine-to-machine authentication where no user is involved. Perfect for:

  • Backend services calling APIs
  • Scheduled jobs/cron tasks
  • CI/CD pipelines
  • Microservices authentication

Features:

  • Automatic caching: Tokens are cached in memory until they expire
  • Multi-tenant support: Works with scoped provider configurations
  • Custom scopes: Override default scopes per request
  • Zero user interaction: Pure service-to-service auth

Basic Usage:

import { getClientCredentialsToken } from '@milencode/h3-oauth-kit';

// Get a token for calling Microsoft Graph API
const token = await getClientCredentialsToken('azure', {
  scopes: ['https://graph.microsoft.com/.default'],
});

// Use the token to make API calls
const users = await $fetch('https://graph.microsoft.com/v1.0/users', {
  headers: {
    Authorization: `Bearer ${token.access_token}`,
  },
});

Multi-Tenant Example:

// Different tokens for different tenants
const tenantAToken = await getClientCredentialsToken('azure', {
  instanceKey: 'tenant-a',
  scopes: ['api://myapp/.default'],
});

const tenantBToken = await getClientCredentialsToken('azure', {
  instanceKey: 'tenant-b',
  scopes: ['api://myapp/.default'],
});

Background Job Example:

// server/cron/syncAccounting.ts
import { getClientCredentialsToken } from '@milencode/h3-oauth-kit';

export async function syncAccountingData() {
  // Get token without any user context
  const token = await getClientCredentialsToken('intuit', {
    scopes: ['com.intuit.quickbooks.accounting'],
  });

  // Fetch invoices from QuickBooks
  const invoices = await $fetch(
    'https://quickbooks.api.intuit.com/v3/company/123/query?query=select * from Invoice',
    {
      headers: {
        Authorization: `Bearer ${token.access_token}`,
        Accept: 'application/json',
      },
    },
  );

  // Process invoices...
  return { synced: invoices.length };
}

Options:

interface ClientCredentialsOptions {
  /** OAuth scopes to request. Overrides provider's default scopes */
  scopes?: string[];
  /** Force a new token even if cached token is valid */
  forceRefresh?: boolean;
  /** Instance key for multi-tenant configurations */
  instanceKey?: string;
}

Cache Management:

import {
  clearClientCredentialsCache,
  getClientCredentialsCacheSize,
} from '@milencode/h3-oauth-kit';

// Clear all cached tokens
clearClientCredentialsCache();

// Clear tokens for specific provider
clearClientCredentialsCache('azure');

// Clear tokens for specific provider instance
clearClientCredentialsCache('azure', 'tenant-a');

// Check cache size (useful for monitoring)
const size = getClientCredentialsCacheSize();
console.log(`Cached tokens: ${size}`);

How Caching Works:

Tokens are cached in memory based on:

  1. Provider name (e.g., azure)
  2. Instance key (if multi-tenant)
  3. Requested scopes

This means different scope combinations get separate cache entries, ensuring you always get the right token for your use case.

Tokens are automatically refreshed when they expire (with a 60-second buffer to prevent edge cases).

When to Use Client Credentials vs Authorization Code:

| Use Client Credentials when... | Use Authorization Code when... | |--------------------------------|--------------------------------| | ✅ Backend service calling APIs | ✅ User needs to login | | ✅ Scheduled jobs/cron tasks | ✅ Accessing user-specific data | | ✅ No user context needed | ✅ OAuth requires user consent | | ✅ M2M (machine-to-machine) auth | ✅ User session management |


introspectToken(provider, token, options?) 🌲

NEW! Validates a token with the OAuth provider using Token Introspection (RFC 7662).

Tree-shakable: Import from @milencode/h3-oauth-kit/introspection

Token introspection allows you to check if an access token or refresh token is still active, hasn't been revoked, and retrieve metadata about the token.

Use Cases:

  • Security-critical applications: Verify tokens haven't been revoked
  • Token validation: Check token status before making API calls
  • Audit logging: Get token metadata for compliance
  • Token debugging: Inspect token details during development

Basic Usage:

import { introspectToken } from '@milencode/h3-oauth-kit/introspection';

const result = await introspectToken('azure', 'access_token_here');

if (result.active) {
  console.log('Token is valid!');
  console.log('Expires at:', new Date(result.exp! * 1000));
  console.log('Scopes:', result.scope);
} else {
  console.log('Token is invalid or revoked');
}

In a Protected Route:

import { defineProtectedRoute } from '@milencode/h3-oauth-kit';
import { introspectToken } from '@milencode/h3-oauth-kit/introspection';

export default defineProtectedRoute(['azure'], async (event) => {
  const token = event.context.h3OAuthKit.azure.access_token;

  // Double-check token is still active (e.g., not revoked by admin)
  const introspection = await introspectToken('azure', token);

  if (!introspection.active) {
    throw createError({
      statusCode: 401,
      message: 'Token has been revoked',
    });
  }

  // Proceed with API call...
  return { status: 'authorized', scopes: introspection.scope };
});

Options:

interface IntrospectionOptions {
  /** Instance key for multi-tenant configurations */
  instanceKey?: string;
  /** Hint about the type of token being introspected */
  tokenTypeHint?: 'access_token' | 'refresh_token';
}

// With type hint (optimization for provider)
const result = await introspectToken('clio', refreshToken, {
  tokenTypeHint: 'refresh_token',
});

// Multi-tenant
const result = await introspectToken('azure', token, {
  instanceKey: 'tenant-a',
});

Response Format (RFC 7662):

interface TokenIntrospectionResponse {
  /** REQUIRED - Boolean indicator of whether or not the token is currently active */
  active: boolean;
  /** OAuth 2.0 scope values for this token */
  scope?: string;
  /** Client identifier for the OAuth 2.0 client that requested this token */
  client_id?: string;
  /** Human-readable identifier for the resource owner */
  username?: string;
  /** Type of the token (e.g., "Bearer") */
  token_type?: string;
  /** Timestamp when token expires (seconds since epoch) */
  exp?: number;
  /** Timestamp when token was issued (seconds since epoch) */
  iat?: number;
  /** Subject of the token */
  sub?: string;
  /** Intended audience */
  aud?: string | string[];
  // ... additional provider-specific fields
}

Helper Function: isTokenActive

import { isTokenActive } from '@milencode/h3-oauth-kit/introspection';

// Simplified check - just returns true/false
const isValid = await isTokenActive('azure', accessToken);
if (isValid) {
  // Token is good to use
}

Important Notes:

  • Not all OAuth providers support introspection (requires RFC 7662 support)
  • Provider must have introspectionEndpoint configured:
    registerOAuthProvider('azure', {
      // ... other config
      introspectionEndpoint: 'https://provider.com/oauth2/v2.0/introspect',
    });

Device Authorization Flow 🌲

NEW! Authenticate devices without a web browser using Device Authorization Flow (RFC 8628).

Tree-shakable: Import from @milencode/h3-oauth-kit/device-flow

The Device Authorization Flow is designed for devices that lack a web browser or have limited input capabilities (TVs, CLI tools, IoT devices, etc.).

How it Works:

  1. Device calls initiateDeviceFlow() → receives user_code and verification_uri
  2. Device displays these to the user
  3. User opens the URL on another device (phone/computer) and enters the code
  4. Device polls for token using pollForDeviceToken()

Use Cases:

  • CLI tools: Authenticate users from terminal
  • Smart TVs: Login flow for streaming apps
  • IoT devices: Devices without browsers
  • CI/CD pipelines: Authenticate build processes

initiateDeviceFlow(provider, options?)

Starts the device authorization flow.

import { initiateDeviceFlow, pollForDeviceToken } from '@milencode/h3-oauth-kit/device-flow';

const deviceAuth = await initiateDeviceFlow('azure', {
  scopes: ['User.Read', 'Mail.Send'],
});

console.log('Please visit:', deviceAuth.verification_uri);
console.log('And enter code:', deviceAuth.user_code);

Response:

interface DeviceAuthorizationResponse {
  /** The device verification code */
  device_code: string;
  /** The end-user verification code (show to user) */
  user_code: string;
  /** The verification URL on the authorization server */
  verification_uri: string;
  /** Optional: URL that includes the user_code for easier UX */
  verification_uri_complete?: string;
  /** Lifetime in seconds of the codes */
  expires_in: number;
  /** Minimum polling interval in seconds */
  interval?: number;
}

pollForDeviceToken(provider, deviceCode, options?)

Waits for the user to authorize the device and returns tokens.

// After showing the user code
const tokens = await pollForDeviceToken('azure', deviceAuth.device_code, {
  maxWaitTime: 300, // Wait up to 5 minutes
  pollInterval: 5,  // Poll every 5 seconds
  onPoll: (attempt, secondsElapsed) => {
    console.log(`Waiting for authorization... (${secondsElapsed}s elapsed)`);
  },
});

console.log('Success! Access token:', tokens.access_token);

Options:

interface DeviceTokenPollOptions {
  /** Maximum time to wait in seconds (default: 300 = 5 minutes) */
  maxWaitTime?: number;
  /** Custom polling interval in seconds */
  pollInterval?: number;
  /** Instance key for multi-tenant configurations */
  instanceKey?: string;
  /** Callback invoked on each poll attempt */
  onPoll?: (attempt: number, secondsElapsed: number) => void | Promise<void>;
}

Error Handling:

  • Throws "User denied authorization" if user denies access
  • Throws "Device code expired" if codes expire before authorization
  • Throws "timed out after N seconds" if maxWaitTime is reached
  • Automatically handles slow_down errors by increasing poll interval

authenticateDevice(provider, options?)

Convenience function that combines initiateDeviceFlow and pollForDeviceToken.

Perfect for CLI tools:

import { authenticateDevice } from '@milencode/h3-oauth-kit/device-flow';

const tokens = await authenticateDevice('github', {
  scopes: ['repo', 'user'],
  onStart: (deviceAuth) => {
    console.log('Visit:', deviceAuth.verification_uri);
    console.log('Code:', deviceAuth.user_code);
  },
  onPoll: (attempt, seconds) => {
    console.log(`Waiting... (${seconds}s)`);
  },
});

console.log('Authenticated!', tokens.access_token);

QR Code Example:

import QRCode from 'qrcode';
import { authenticateDevice } from '@milencode/h3-oauth-kit/device-flow';

const tokens = await authenticateDevice('azure', {
  onStart: async (auth) => {
    // Generate QR code for mobile scanning
    const qr = await QRCode.toString(
      auth.verification_uri_complete || auth.verification_uri,
      { type: 'terminal' }
    );
    
    console.log(qr);
    console.log('\nOr manually visit:', auth.verification_uri);
    console.log('And enter code:', auth.user_code);
  },
});

Configuration:

Provider must have deviceAuthorizationEndpoint configured:

registerOAuthProvider('azure', {
  // ... other config
  deviceAuthorizationEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode',
  tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
});

JWT Validation 🌲

NEW! Decode and validate JWT tokens locally without API calls (RFC 7519).

Tree-shakable: Import from @milencode/h3-oauth-kit/jwt

JWT validation lets you verify tokens locally, validate claims, and optionally verify signatures using JWKS. This is significantly faster than token introspection since it doesn't require network calls.

Why JWT Validation?

  • Fast - No network calls needed for validation
  • 🔒 Secure - Verify signatures with public keys (JWKS)
  • 📦 Offline-capable - Works without internet access
  • 🎯 Flexible - Validate specific claims (iss, aud, exp, etc.)

decodeJWT(token)

Decodes a JWT without any validation.

⚠️ Warning: This does NOT validate the token! Use validateJWT() for secure validation.

import { decodeJWT } from '@milencode/h3-oauth-kit/jwt';

const decoded = decodeJWT(token);
console.log('User ID:', decoded.payload.sub);
console.log('Expires:', new Date(decoded.payload.exp! * 1000));
console.log('Algorithm:', decoded.header.alg);

Returns:

interface DecodedJWT {
  header: {
    alg: string;      // e.g., "RS256"
    typ?: string;     // e.g., "JWT"
    kid?: string;     // Key ID
  };
  payload: {
    sub?: string;     // Subject
    iss?: string;     // Issuer
    aud?: string | string[];  // Audience
    exp?: number;     // Expiration (seconds since epoch)
    iat?: number;     // Issued at
    nbf?: number;     // Not before
    [key: string]: unknown;  // Custom claims
  };
  signature: string;
}

getJWTPayload(token)

Convenience function that returns only the payload.

import { getJWTPayload } from '@milencode/h3-oauth-kit/jwt';

const payload = getJWTPayload(token);
console.log('User:', payload.sub);
console.log('Email:', payload.email);

validateJWT(token, options?)

Validates a JWT with comprehensive checks.

Features:

  • ✅ Claims validation (exp, nbf, iat, iss, aud, sub)
  • ✅ Signature verification (using JWKS)
  • ✅ Clock tolerance for time-based checks
  • ✅ Custom validation logic
  • ✅ JWKS caching for performance

Basic Usage:

import { validateJWT } from '@milencode/h3-oauth-kit/jwt';

const result = await validateJWT(token, {
  issuer: 'https://login.microsoftonline.com',
  audience: 'api://myapp',
});

if (result.valid) {
  console.log('Valid token!', result.payload);
} else {
  console.error('Invalid:', result.error);
  console.error('Code:', result.errorCode); // EXPIRED, INVALID_ISSUER, etc.
}

With Signature Verification:

const result = await validateJWT(token, {
  issuer: 'https://accounts.google.com',
  audience: 'my-client-id',
  validateSignature: true,
  jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
});

Validation Options:

interface JWTValidationOptions {
  /** Expected issuer (iss claim) */
  issuer?: string | string[];
  /** Expected audience (aud claim) */
  audience?: string | string[];
  /** Expected subject (sub claim) */
  subject?: string;
  /** Clock tolerance in seconds (default: 0) */
  clockTolerance?: number;
  /** Validate signature (default: true if jwksUri provided) */
  validateSignature?: boolean;
  /** JWKS URI for public keys */
  jwksUri?: string;
  /** Allowed signing algorithms (default: ['RS256', 'ES256', ...]) */
  algorithms?: string[];
  /** Maximum token age in seconds */
  maxTokenAge?: number;
  /** Custom validation function */
  customValidation?: (payload) => void | Promise<void>;
}

In a Protected API Route:

import { defineEventHandler, getHeader, createError } from 'h3';
import { validateJWT } from '@milencode/h3-oauth-kit/jwt';

export default defineEventHandler(async (event) => {
  const authHeader = getHeader(event, 'authorization');
  const token = authHeader?.split(' ')[1];
  
  if (!token) {
    throw createError({ statusCode: 401, message: 'No token provided' });
  }
  
  const result = await validateJWT(token, {
    issuer: 'https://login.microsoftonline.com',
    audience: 'api://myapp',
    clockTolerance: 60, // 1 minute tolerance for clock skew
    jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
    validateSignature: true,
  });
  
  if (!result.valid) {
    throw createError({
      statusCode: 401,
      message: `Invalid token: ${result.error}`,
    });
  }
  
  // Token is valid - use the payload
  const userId = result.payload!.sub;
  const email = result.payload!.email;
  
  return { userId, email };
});

Custom Validation:

const result = await validateJWT(token, {
  issuer: 'https://provider.com',
  customValidation: (payload) => {
    // Require admin role
    if (payload.role !== 'admin') {
      throw new Error('Admin role required');
    }
    
    // Check custom claim
    if (!payload.organization_verified) {
      throw new Error('Organization not verified');
    }
  },
});

Validation Result:

interface JWTValidationResult {
  valid: boolean;
  payload?: JWTPayload;  // If valid
  error?: string;         // If invalid
  errorCode?: 'EXPIRED' | 'NOT_YET_VALID' | 'INVALID_SIGNATURE' 
    | 'INVALID_ISSUER' | 'INVALID_AUDIENCE' | 'INVALID_FORMAT'
    | 'CUSTOM_VALIDATION_FAILED';
}

JWKS Cache Management:

import { clearJWKSCache, getJWKSCacheSize } from '@milencode/h3-oauth-kit/jwt';

// JWKS keys are cached for 1 hour by default

// Clear all cached JWKS
clearJWKSCache();

// Clear specific JWKS URI
clearJWKSCache('https://login.microsoftonline.com/common/discovery/v2.0/keys');

// Check cache size
console.log('Cached JWKS entries:', getJWKSCacheSize());

Real-World Example: API Gateway:

// server/middleware/auth.ts
import { defineEventHandler, getHeader, createError } from 'h3';
import { validateJWT } from '@milencode/h3-oauth-kit/jwt';

export default defineEventHandler(async (event) => {
  // Skip auth for public routes
  if (event.path.startsWith('/api/public')) {
    return;
  }
  
  const token = getHeader(event, 'authorization')?.split(' ')[1];
  
  if (!token) {
    throw createError({ statusCode: 401, message: 'Unauthorized' });
  }
  
  const result = await validateJWT(token, {
    issuer: ['https://accounts.google.com', 'https://login.microsoftonline.com'],
    audience: 'api://myapp',
    clockTolerance: 60,
    validateSignature: true,
    jwksUri: 
      token.includes('google') 
        ? 'https://www.googleapis.com/oauth2/v3/certs'
        : 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
  });
  
  if (!result.valid) {
    throw createError({
      statusCode: 401,
      message: `Token validation failed: ${result.error}`,
    });
  }
  
  // Attach user info to context
  event.context.user = {
    id: result.payload!.sub,
    email: result.payload!.email,
    name: result.payload!.name,
  };
});

Multi-Tenant Key Format

When using scoped providers (multi-tenant), the keys follow a specific format:

Context Access Patterns

// Tokens - Global providers use dot notation
event.context.h3OAuthKit.azure.access_token;
event.context.h3OAuthKit.clio.access_token;

// Tokens - Scoped providers use bracket notation (because of the colon)
event.context.h3OAuthKit['azure:tenant-a'].access_token;
event.context.h3OAuthKit['clio:smithlaw'].access_token;
event.context.h3OAuthKit['intuit:company-123'].access_token;

// Instance Keys - Access resolved instance keys (helpful for dynamic resolution)
event.context.h3OAuthKitInstances.azure; // undefined | string
event.context.h3OAuthKitInstances.clio; // undefined | string (typed when using withInstanceKeys)
event.context.h3OAuthKitInstances.intuit; // undefined | string

Cookie Names

Cookies follow the same pattern:

// Global providers
azure_access_token
clio_refresh_token

// Scoped providers
azure:tenant-a_access_token
clio:smithlaw_refresh_token
intuit:company-123_access_token_expires_at

Tokens & Cookies

  • Access tokens stored in: *_access_token
  • Expiration (absolute): *_access_token_expires_at
  • Refresh tokens (optional): *_refresh_token
  • Custom provider fields: e.g., azure_ext_expires_in, azure_token_type

Provider Configuration

You can define provider-specific behavior (e.g., which fields to store as cookies) via providerConfig. Fields like token_type, ext_expires_in, or id_token can be persisted automatically across sessions and refreshes.

These custom fields are automatically read and rehydrated as part of the token refresh and route protection workflows.


Type Safety

Each method is fully typed for provider-specific behavior:

  • All tokens returned are strongly typed by provider.

  • Token cookies and refresh responses are parsed into provider-aware shapes.

  • Context is augmented in protected routes:

    event.context.h3OAuthKit.azure; // full Azure token object
    event.context.azure_access_token; // just the raw access token string

This makes integration seamless and safe across complex authentication workflows.


License

MIT


Author

Made with ❤️ by @sasha-milenkovic