Package Exports
- @sp-uvb/hono
Readme
@sp-uvb/hono
Production-grade Hono middleware for Universal Verification Broker (UVB) authentication. Built for modern JavaScript runtimes including Node.js, Bun, Deno, Cloudflare Workers, and edge environments.
Features
- 🔐 Automatic Session Validation - Validates sessions on every request
- 🌐 Multi-Runtime Support - Works on Node.js, Bun, Deno, Cloudflare Workers, and edge runtimes
- 🎯 Context-Based API - Access session via
c.get('uvbSession') - 🛡️ MFA Factor Guards - Require specific authentication factors per route
- 👤 Resource Ownership - Verify users own resources they're accessing
- ⚡ Edge-Ready - Optimized for Cloudflare Workers and edge compute
- 🔧 Fully Customizable - Override error handlers, customize behavior
- 💪 TypeScript First - Complete type safety with Hono's type system
Installation
npm install @sp-uvb/hono hono
# or
bun add @sp-uvb/hono hono
# or
deno add @sp-uvb/hono honoQuick Start
Basic Setup
import { Hono } from 'hono';
import { uvbAuth } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
app.get('/', (c) => 'Hello UVB!');
app.get('/profile', (c) => {
const session = c.get('uvbSession');
if (!session) {
return c.json({ error: 'Not authenticated' }, 401);
}
return c.json({
userId: session.userId,
factors: session.factorsVerified,
});
});
export default app;Required Authentication
import { Hono } from 'hono';
import { uvbAuth, uvbRequire } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
app.get('/dashboard', uvbRequire(), (c) => {
const session = c.get('uvbSession')!;
return c.json({
welcome: `Hello, user ${session.userId}!`,
sessionId: session.sessionId,
});
});
export default app;Configuration Options
UvbMiddlewareOptions
interface UvbMiddlewareOptions {
// Required: UVB server URL
uvbUrl: string;
// Required: Your tenant ID
tenantId: string;
// Optional: Cookie name for session token (default: 'uvb_session')
cookieName?: string;
// Optional: Header name for session token (default: 'x-uvb-session')
headerName?: string;
// Optional: Paths to exclude from authentication (default: [])
excludePaths?: string[];
// Optional: Require authentication on all routes (default: false)
required?: boolean;
// Optional: Environment variable accessor for edge runtimes
getEnv?: (c: Context) => { UVB_URL?: string; UVB_TENANT_ID?: string };
// Optional: Custom error handler
onError?: (c: Context, error: Error) => Response | Promise<Response>;
// Optional: Custom unauthorized handler
onUnauthorized?: (c: Context) => Response | Promise<Response>;
}Session Object
The uvbSession is attached to context via c.get('uvbSession'):
interface UvbSession {
userId: string; // Unique user identifier
tenantId: string; // Tenant identifier
sessionId: string; // Session identifier
factorsVerified: string[]; // List of verified authentication factors
expiresAt: Date; // Session expiration timestamp
metadata?: Record<string, any>; // Optional session metadata
}Authentication Guards
Basic Guard
Require authentication for specific routes:
import { Hono } from 'hono';
import { uvbAuth, uvbRequire } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
app.get('/public', (c) => c.text('Anyone can access'));
app.get('/protected', uvbRequire(), (c) => {
const session = c.get('uvbSession')!;
return c.json({ userId: session.userId });
});
export default app;MFA Factor Requirements
Require All Factors
import { Hono } from 'hono';
import { uvbAuth, uvbRequireAllFactors } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
app.post('/transfer', uvbRequireAllFactors(['password', 'totp', 'webauthn']), async (c) => {
const session = c.get('uvbSession')!;
const body = await c.req.json();
return c.json({
transfer: 'authorized',
amount: body.amount,
userId: session.userId,
});
});
export default app;Require Any Factor
import { uvbRequireAnyFactor } from '@sp-uvb/hono';
app.get('/settings', uvbRequireAnyFactor(['totp', 'webauthn', 'sms']), (c) => {
const session = c.get('uvbSession')!;
return c.json({
userId: session.userId,
mfaEnabled: true,
});
});Resource Ownership Verification
Ensure users can only access their own resources:
import { Hono } from 'hono';
import { uvbAuth, uvbRequire, uvbRequireOwnership } from '@sp-uvb/hono';
// Mock database
const posts = new Map([
['post_1', { id: 'post_1', title: 'Hello', authorId: 'user_123' }],
['post_2', { id: 'post_2', title: 'World', authorId: 'user_456' }],
]);
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
required: true,
})
);
app.delete(
'/posts/:id',
uvbRequireOwnership({
getUserId: async (c) => {
const postId = c.req.param('id');
const post = posts.get(postId);
if (!post) throw new Error('Post not found');
return post.authorId;
},
}),
(c) => {
const postId = c.req.param('id');
posts.delete(postId);
return c.json({ deleted: postId });
}
);
export default app;Session Management Routes
Add built-in session management endpoints:
import { Hono } from 'hono';
import { uvbAuth, uvbSessionRoutes } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
app.route(
'/uvb',
uvbSessionRoutes({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
// Adds:
// GET /uvb/session - Get current session info
// POST /uvb/logout - Revoke session and clear cookie
export default app;Now you can:
# Get session info
curl http://localhost:8787/uvb/session \
-H "Authorization: Bearer <session_token>"
# Logout
curl -X POST http://localhost:8787/uvb/logout \
-H "Authorization: Bearer <session_token>"Runtime-Specific Examples
Cloudflare Workers
import { Hono } from 'hono';
import { uvbAuth } from '@sp-uvb/hono';
type Bindings = {
UVB_URL: string;
UVB_TENANT_ID: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.use(
'*',
uvbAuth({
uvbUrl: '', // Will be overridden by env
tenantId: '', // Will be overridden by env
getEnv: (c) => ({
UVB_URL: c.env.UVB_URL,
UVB_TENANT_ID: c.env.UVB_TENANT_ID,
}),
})
);
app.get('/api/user', (c) => {
const session = c.get('uvbSession');
return c.json({
authenticated: !!session,
userId: session?.userId,
});
});
export default app;Bun
import { Hono } from 'hono';
import { uvbAuth, uvbRequire } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
app.get('/dashboard', uvbRequire(), (c) => {
const session = c.get('uvbSession')!;
return c.json({ userId: session.userId });
});
export default {
port: 3000,
fetch: app.fetch,
};Deno
import { Hono } from 'hono';
import { uvbAuth } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: Deno.env.get('UVB_URL')!,
tenantId: Deno.env.get('UVB_TENANT_ID')!,
})
);
app.get('/', (c) => c.text('Hello from Deno!'));
Deno.serve(app.fetch);Node.js
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { uvbAuth } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
app.get('/', (c) => c.text('Hello from Node!'));
serve(app);Advanced Examples
Conditional MFA Requirements
import { Hono } from 'hono';
import { uvbAuth, uvbRequire, hasAllFactors } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
required: true,
})
);
app.post('/api/transactions', uvbRequire(), async (c) => {
const session = c.get('uvbSession')!;
const body = await c.req.json();
const amount = parseFloat(body.amount);
// Require MFA for large transactions
if (amount > 10000 && !hasAllFactors(session, ['totp', 'webauthn'])) {
return c.json(
{
error: 'MFA required for large transactions',
required: ['totp', 'webauthn'],
verified: session.factorsVerified,
},
403
);
}
return c.json({
transaction: 'processed',
amount,
userId: session.userId,
});
});
export default app;Custom Error Handlers
import { Hono } from 'hono';
import { uvbAuth } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
required: true,
onUnauthorized: (c) => {
return c.json(
{
error: 'Authentication required',
message: 'Please log in to continue',
loginUrl: '/auth/login',
},
401
);
},
onError: (c, error) => {
console.error('UVB error:', error);
return c.json(
{
error: 'Internal authentication error',
requestId: crypto.randomUUID(),
},
500
);
},
})
);
export default app;Path Exclusions
import { Hono } from 'hono';
import { uvbAuth } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
required: true,
excludePaths: ['/health', '/metrics', '/public', '/auth'],
})
);
app.get('/health', (c) => c.json({ status: 'ok' }));
app.get('/public/terms', (c) => c.text('Terms of Service...'));
app.get('/dashboard', (c) => {
const session = c.get('uvbSession')!;
return c.json({ userId: session.userId });
});
export default app;Multiple Authentication Schemes
import { Hono } from 'hono';
import { uvbAuth } from '@sp-uvb/hono';
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
app.get('/api/data', (c) => {
// Try UVB session first
const session = c.get('uvbSession');
if (session) {
return c.json({
data: 'authenticated via UVB',
userId: session.userId,
});
}
// Fallback to API key
const apiKey = c.req.header('x-api-key');
if (apiKey && isValidApiKey(apiKey)) {
return c.json({
data: 'authenticated via API key',
apiKey: apiKey.substring(0, 8) + '...',
});
}
return c.json({ error: 'Authentication required' }, 401);
});
function isValidApiKey(key: string): boolean {
// Your API key validation logic
return key.startsWith('sk_');
}
export default app;Real-World Patterns
E-commerce API
import { Hono } from 'hono';
import { uvbAuth, uvbRequire, uvbRequireAllFactors, uvbRequireOwnership } from '@sp-uvb/hono';
// Mock database
const orders = new Map<string, { id: string; userId: string; total: number }>();
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
// Public product listing
app.get('/products', (c) => {
return c.json([
{ id: 'prod_1', name: 'Widget', price: 29.99 },
{ id: 'prod_2', name: 'Gadget', price: 49.99 },
]);
});
// Cart requires authentication
app.post('/cart', uvbRequire(), async (c) => {
const session = c.get('uvbSession')!;
const body = await c.req.json();
return c.json({
cart: body,
userId: session.userId,
});
});
// Checkout requires MFA
app.post('/checkout', uvbRequireAllFactors(['password', 'totp']), async (c) => {
const session = c.get('uvbSession')!;
const body = await c.req.json();
const orderId = crypto.randomUUID();
orders.set(orderId, {
id: orderId,
userId: session.userId,
total: body.total,
});
return c.json({ orderId, status: 'confirmed' });
});
// View order (must be owner)
app.get(
'/orders/:id',
uvbRequire(),
uvbRequireOwnership({
getUserId: async (c) => {
const orderId = c.req.param('id');
const order = orders.get(orderId);
if (!order) throw new Error('Order not found');
return order.userId;
},
}),
(c) => {
const orderId = c.req.param('id');
const order = orders.get(orderId);
return c.json(order);
}
);
export default app;Multi-Tenant SaaS
import { Hono } from 'hono';
import { uvbAuth, uvbRequire } from '@sp-uvb/hono';
interface User {
id: string;
tenantId: string;
role: 'admin' | 'member';
}
const users = new Map<string, User>([
['user_1', { id: 'user_1', tenantId: 'tenant_123', role: 'admin' }],
['user_2', { id: 'user_2', tenantId: 'tenant_123', role: 'member' }],
['user_3', { id: 'user_3', tenantId: 'tenant_456', role: 'admin' }],
]);
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
required: true,
})
);
// Tenant-scoped data access
app.get('/api/workspace/:workspaceId', uvbRequire(), (c) => {
const session = c.get('uvbSession')!;
const user = users.get(session.userId);
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
// Verify tenant access
if (user.tenantId !== session.tenantId) {
return c.json({ error: 'Forbidden' }, 403);
}
return c.json({
workspaceId: c.req.param('workspaceId'),
tenantId: user.tenantId,
role: user.role,
});
});
// Admin-only endpoint
app.post('/api/workspace/:workspaceId/settings', uvbRequire(), async (c) => {
const session = c.get('uvbSession')!;
const user = users.get(session.userId);
if (!user || user.role !== 'admin') {
return c.json({ error: 'Admin access required' }, 403);
}
return c.json({
updated: true,
workspaceId: c.req.param('workspaceId'),
});
});
export default app;Social Media API
import { Hono } from 'hono';
import { uvbAuth, uvbRequire, uvbRequireOwnership, uvbRequireAllFactors } from '@sp-uvb/hono';
interface Post {
id: string;
authorId: string;
content: string;
visibility: 'public' | 'private';
}
const posts = new Map<string, Post>();
const app = new Hono();
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);
// Public feed (no auth required)
app.get('/feed', (c) => {
return c.json(
Array.from(posts.values())
.filter((p) => p.visibility === 'public')
.slice(0, 20)
);
});
// Create post (auth required)
app.post('/posts', uvbRequire(), async (c) => {
const session = c.get('uvbSession')!;
const body = await c.req.json();
const postId = crypto.randomUUID();
const post: Post = {
id: postId,
authorId: session.userId,
content: body.content,
visibility: body.visibility || 'public',
};
posts.set(postId, post);
return c.json(post);
});
// Edit post (must be author)
app.patch(
'/posts/:id',
uvbRequireOwnership({
getUserId: async (c) => {
const postId = c.req.param('id');
const post = posts.get(postId);
if (!post) throw new Error('Post not found');
return post.authorId;
},
}),
async (c) => {
const postId = c.req.param('id');
const body = await c.req.json();
const post = posts.get(postId)!;
post.content = body.content;
return c.json(post);
}
);
// Delete post (must be author + MFA)
app.delete(
'/posts/:id',
uvbRequireAllFactors(['password', 'totp']),
uvbRequireOwnership({
getUserId: async (c) => {
const postId = c.req.param('id');
const post = posts.get(postId);
if (!post) throw new Error('Post not found');
return post.authorId;
},
}),
(c) => {
const postId = c.req.param('id');
posts.delete(postId);
return c.json({ deleted: postId });
}
);
export default app;Helper Functions
Check Individual Factor
import { hasFactor } from '@sp-uvb/hono';
app.get('/settings/mfa', (c) => {
const session = c.get('uvbSession');
return c.json({
totpEnabled: hasFactor(session, 'totp'),
webauthnEnabled: hasFactor(session, 'webauthn'),
smsEnabled: hasFactor(session, 'sms'),
});
});Check All Factors
import { hasAllFactors } from '@sp-uvb/hono';
app.get('/admin/panel', (c) => {
const session = c.get('uvbSession');
if (!hasAllFactors(session, ['password', 'totp', 'webauthn'])) {
return c.text('Admin requires full MFA', 403);
}
return c.json({ admin: 'panel' });
});Check Any Factor
import { hasAnyFactor } from '@sp-uvb/hono';
app.get('/settings', (c) => {
const session = c.get('uvbSession');
const hasMFA = hasAnyFactor(session, ['totp', 'webauthn', 'sms']);
return c.json({
mfaEnabled: hasMFA,
message: hasMFA ? 'MFA is active' : 'Enable MFA for better security',
});
});API Reference
Middleware
uvbAuth(options: UvbMiddlewareOptions)
Main authentication middleware. Validates sessions and attaches to context.
uvbRequire()
Require authentication for a route. Returns 401 if not authenticated.
uvbRequireFactors(options: UvbFactorOptions)
Require specific authentication factors. Returns 403 if factors not verified.
uvbRequireAllFactors(factors: string[])
Require all specified factors. Returns 403 if any factor missing.
uvbRequireAnyFactor(factors: string[])
Require at least one of the specified factors. Returns 403 if no factors match.
uvbRequireOwnership(options: UvbOwnershipOptions)
Verify user owns a resource. Returns 403 if not owner.
Utilities
uvbSessionRoutes(options)
Create session management routes (GET /session, POST /logout).
hasFactor(session, factor): boolean
Check if session has specific factor.
hasAllFactors(session, factors): boolean
Check if session has all specified factors.
hasAnyFactor(session, factors): boolean
Check if session has any of the specified factors.
Best Practices
1. Environment Variables
Always use environment variables for sensitive configuration:
app.use(
'*',
uvbAuth({
uvbUrl: process.env.UVB_URL!,
tenantId: process.env.UVB_TENANT_ID!,
})
);2. Path Exclusions
Exclude health checks and public endpoints:
app.use(
'*',
uvbAuth({
// ...
excludePaths: ['/health', '/metrics', '/public'],
})
);3. Cloudflare Workers
Use getEnv for environment bindings:
app.use(
'*',
uvbAuth({
uvbUrl: '',
tenantId: '',
getEnv: (c) => ({
UVB_URL: c.env.UVB_URL,
UVB_TENANT_ID: c.env.UVB_TENANT_ID,
}),
})
);4. Factor Requirements
Use appropriate MFA levels for sensitive operations:
// Low risk: basic auth
app.get('/profile', uvbRequire(), handler);
// Medium risk: any MFA
app.post('/settings', uvbRequireAnyFactor(['totp', 'webauthn']), handler);
// High risk: all factors
app.post('/delete-account', uvbRequireAllFactors(['password', 'totp', 'webauthn']), handler);Troubleshooting
Session Not Found
Problem: uvbSession is always null
Solutions:
- Check UVB server is running and accessible
- Verify
uvbUrlandtenantIdare correct - Ensure session token is being sent (cookie or header)
- Check session hasn't expired
401 Errors
Problem: All routes return 401
Solutions:
- Set
required: falsefor optional auth - Add public paths to
excludePaths - Verify session token is valid
403 Errors
Problem: Factor requirements failing
Solutions:
- Check user has completed required MFA factors
- Use
hasAllFactors()to debug which factors are verified - Consider using
uvbRequireAnyFactorinstead
Cloudflare Workers
Problem: Environment variables not accessible
Solutions:
- Use
getEnvoption to accessc.env - Define bindings in
wrangler.toml - Use type-safe environment bindings
License
MIT
Support
For issues and questions:
- GitHub: https://github.com/uvb/uvb
- Documentation: https://docs.uvb.dev