@stefanobalocco/honosignedrequests
Version:
An hono middleware to manage signed requests, including a client implementation.
314 lines (232 loc) • 8.69 kB
Markdown
A Hono middleware for HMAC-SHA256 signed requests.
This library provides server-side session management and request signature validation for Hono applications, along with a client library for making signed requests.
Each session is associated with a cryptographic token (a random byte array) shared between client and server. Every request is authenticated by computing an HMAC-SHA256 signature using this token as the secret key.
The signature is computed over:
- Session ID
- Sequence number (monotonically increasing to prevent replay attacks)
- Timestamp (to limit signature validity window)
- Request parameters (sorted alphabetically)
The server validates the signature using the same token, verifies the timestamp falls within the allowed window, and checks that the sequence number is the expected next value for that session.
## Features
- HMAC-SHA256 request signing with shared secret token
- Replay attack protection via monotonic sequence numbers
- Timestamp validation with configurable tolerance, to prevent delayed replay
- Constant-time signature comparison, to prevent timing attacks
- Pluggable session storage architecture
- Works with Node.js, Cloudflare Workers, Deno, and Bun
## Installation
```bash
npm install @stefanobalocco/honosignedrequests
```
## Server-Side Usage
### Basic Setup
```typescript
import { Hono } from 'hono';
import { SignedRequestsManager, SessionsStorageLocal } from '@stefanobalocco/honosignedrequests';
const app = new Hono();
// SessionsStorageLocal is a simple in-memory storage implementation
// You can implement your own SessionsStorage (e.g., Redis-based) for production
const storage = new SessionsStorageLocal({
maxSessions: 65535, // Storage-specific: maximum concurrent sessions in memory
maxSessionsPerUser: 3 // Storage-specific: maximum sessions per user
});
// Generic parameters are passed to SignedRequestsManager
const signedRequests = new SignedRequestsManager(storage, {
validitySignature: 5000, // signature valid for 5 seconds
validityToken: 3600000, // session valid for 1 hour
tokenLength: 32 // token size in bytes
});
app.use('/api/*', signedRequests.middleware);
```
```typescript
app.post('/auth/login', async (c) => {
const { username, password } = await c.req.json();
// Your authentication logic here
const userId = await authenticateUser(username, password);
if (!userId) {
return c.json({ error: 'Invalid credentials' }, 401);
}
// Use the manager's createSession method
const session = await signedRequests.createSession(userId);
// Convert token to Base64URL for transmission to client
const tokenBase64 = btoa(String.fromCharCode(...session.token))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return c.json({
sessionId: session.id,
token: tokenBase64,
sequenceNumber: session.sequenceNumber
});
});
```
```typescript
app.post('/api/ping', (c) => {
const session = c.get('session');
return c.json({ pong: !!session });
});
```
```typescript
app.post('/api/protected', (c) => {
const session = c.get('session');
if (!session) {
return c.json({ error: 'Unauthorized' }, 401);
}
return c.json({
message: 'Authenticated',
userId: session.userId
});
});
```
The library separates **generic parameters** (common to all storage implementations) from **storage-specific parameters**.
These are passed to `SignedRequestsManager` constructor and apply to all storage implementations:
| Option | Default | Description |
|--------|---------|-------------|
| `validitySignature` | 5000 | Signature validity window in milliseconds |
| `validityToken` | 3600000 | Session token validity in milliseconds |
| `tokenLength` | 32 | Token length in bytes (cryptographic secret) |
#### SessionsStorageLocal Specific Parameters
These are specific to the in-memory implementation:
| Option | Default | Description |
|--------|---------|-------------|
| `maxSessions` | 65535 | Maximum concurrent sessions in memory |
| `maxSessionsPerUser` | 3 | Maximum sessions per user (enforced by removing oldest) |
## Client-Side Usage
### Browser Import (CDN)
```html
<script type="module">
import { SignedRequester } from 'https://cdn.jsdelivr.net/gh/StefanoBalocco/HonoSignedRequests/client/dist/SignedRequester.min.js';
const requester = new SignedRequester();
// You can also specify a base URL for request
//const requester = new SignedRequester('https://api.example.com');
// Check if we have session data stored
let needLogin = true;
if (requester.getSession()) {
// Try to verify the session is still valid
try {
const response = await requester.signedRequestJson('/api/ping', {});
if (response?.pong) {
needLogin = false;
}
} catch (error) {
}
}
if( needLogin ) {
// No session data, need to login
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'user@example.com',
password: 'password123'
})
});
const loginData = await response.json();
// Store session credentials
requester.setSession({
sessionId: loginData.sessionId,
token: loginData.token,
sequenceNumber: loginData.sequenceNumber
});
}
// Now we're authenticated, make protected requests
const data = await requester.signedRequestJson('/api/protected', {
action: 'getData'
});
</script>
```
Initialize session after login.
```javascript
requester.setSession({
sessionId: 12345,
token: 'base64url_encoded_token',
sequenceNumber: 1
});
```
Check if a valid session exists. Loads from localStorage if not in memory.
```javascript
if (requester.getSession()) {
// Session available
}
```
Make a signed request, returns the raw Response object.
```javascript
const response = await requester.signedRequest('/api/action', {
param1: 'value1',
param2: 123
});
```
Make a signed request and parse JSON response.
```javascript
const data = await requester.signedRequestJson('/api/data', {
query: 'example'
});
```
Clear session data (for example after you do a logout).
```javascript
requester.clearSession();
```
```typescript
{
baseUrl?: string; // Override base URL for this request
headers?: Record<string, string>; // Additional headers
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // HTTP method (default: POST)
}
```
The signature is computed over the following concatenated string:
```
sessionId={id};sequenceNumber={seq};timestamp={ts};{sorted_params}
```
The HMAC-SHA256 signature is computed using the session token as the secret key.
Parameters are sorted alphabetically by key. Values are serialized as:
- Primitives (string, number, boolean) and null: `String(value)`
- Objects and arrays: `JSON.stringify(value)`
To implement your own session storage (e.g., Redis-based), extend the `SessionsStorage` abstract class:
```typescript
import { SessionsStorage } from '@stefanobalocco/honosignedrequests';
import { Session } from '@stefanobalocco/honosignedrequests';
class RedisSessionsStorage extends SessionsStorage {
async create(
validityToken: number,
tokenLength: number,
userId: number
): Promise<Session> {
// Implement session creation with Redis
// Generate token with specified tokenLength
// Use validityToken for Redis TTL or expiration tracking
// Implement your own maxSessionsPerUser logic if needed
}
async getBySessionId(sessionId: number): Promise<Session | undefined> {
// Implement session lookup with Redis
}
async getByUserId(userId: number): Promise<Session[]> {
// Implement session lookup with Redis
}
async delete(sessionId: number): Promise<void> {
// Implement session deletion with Redis
}
}
```
- Storage should expire sessions
- Invalid sessions should return 401 and trigger client-side session clearing
- Client should be forced to serialize requests, otherwise out-of-order sequence number may arise triggering a session cleanup.
BSD-3-Clause