A type-safe, multi-provider OAuth 2.0 toolkit for H3 apps. Handles login, callback, token refresh, and protected route middleware — all with automatic cookie storage and typed provider extensions.
⚠️ This package is experimental and currently supports a small number of providers (azure
,clio
,intuit
). It's built for internal use but is published publicly for ease of consumption and iteration.
- 🔐 OAuth 2.0 Authorization Code flow support
- 🍞 Token storage via secure, HTTP-only cookies
- 🔁 Automatic token refresh on protected routes
- 🧠 State validation & metadata preservation
- 🛠️ Utility-first API with full TypeScript safety
npm install @sasha-milenkovic/h3-oauth-kit
Or using yarn:
yarn add @sasha-milenkovic/h3-oauth-kit
Or using pnpm:
pnpm add @sasha-milenkovic/h3-oauth-kit
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');
Registers an OAuth provider configuration. Supports both global and scoped (multi-tenant) configurations.
import { registerOAuthProvider } from '@sasha-milenkovic/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'],
});
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.
// Route Handler (redirects immediately)
export default handleOAuthLogin('azure', { redirect: true });
// Utility Usage (returns URL for manual redirect)
const { url } = await handleOAuthLogin('azure', {}, event);
// 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);
import { defineEventHandler, getQuery } from 'h3';
import { handleOAuthLogin } from '@sasha-milenkovic/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,
);
});
- 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).
// Works for both global and scoped providers
export default handleOAuthCallback('azure', {
redirectTo: '/dashboard',
});
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 '@sasha-milenkovic/h3-oauth-kit';
export default defineEventHandler(async (event) => {
const { state, callbackQueryData } = await handleOAuthCallback(
'azure',
{ redirect: false },
event,
);
return sendRedirect(event, state.redirectTo || '/');
});
- 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.
import { defineProtectedRoute } from '@sasha-milenkovic/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;
}
});
import { defineProtectedRoute } from '@sasha-milenkovic/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 };
},
);
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.
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 '@sasha-milenkovic/h3-oauth-kit';
import { getRouterParams, createError } from 'h3';
const getClioAccountIds = () => ['811014901', '12345', '810204859'];
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: "811014901" | "12345" | "810204859"
// 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 "811014901" | "12345" | "810204859"
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; // "811014901" | "12345" | "810204859" (typed union from 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 '@sasha-milenkovic/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
});
- 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.
// server/api/auth/logout.get.ts
import { handleOAuthLogout } from '@sasha-milenkovic/h3-oauth-kit';
export default handleOAuthLogout(['azure', 'clio'], {
redirectTo: '/login',
});
// Logout specific tenant/instance combinations
export default handleOAuthLogout(
[
{ provider: 'azure', instanceKey: 'tenant-a' },
{ provider: 'clio', instanceKey: 'smithlaw' },
],
{
redirectTo: '/login',
},
);
// Logout global azure + scoped clio
export default handleOAuthLogout(
[
'azure', // Global
{ provider: 'clio', instanceKey: 'smithlaw' }, // Scoped
],
{
redirectTo: '/login',
},
);
import { defineEventHandler } from 'h3';
import { handleOAuthLogout } from '@sasha-milenkovic/h3-oauth-kit';
export default defineEventHandler(async (event) => {
const result = await handleOAuthLogout(['azure'], {}, event);
return {
message: 'User logged out',
...result,
};
});
// server/api/auth/logout.get.ts
import { defineEventHandler, getQuery } from 'h3';
import { handleOAuthLogout } from '@sasha-milenkovic/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
When using scoped providers (multi-tenant), the keys follow a specific format:
// 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
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
- 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
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.
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.
Made with ❤️ by @sasha-milenkovic