@stefanobalocco/honosignedrequests 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +22 -0
- package/README.md +313 -0
- package/dist/Common.d.ts +6 -0
- package/dist/Common.js +45 -0
- package/dist/Session.d.ts +8 -0
- package/dist/Session.js +1 -0
- package/dist/SessionsStorage.d.ts +8 -0
- package/dist/SessionsStorage.js +2 -0
- package/dist/SessionsStorageLocal.d.ts +20 -0
- package/dist/SessionsStorageLocal.js +88 -0
- package/dist/SignedRequestsManager.d.ts +21 -0
- package/dist/SignedRequestsManager.js +91 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/package.json +42 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2025, Stefano Balocco <stefano.balocco@gmail.com>
|
|
2
|
+
All rights reserved.
|
|
3
|
+
Redistribution and use in source and binary forms, with or without
|
|
4
|
+
modification, are permitted provided that the following conditions are met:
|
|
5
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
6
|
+
list of conditions and the following disclaimer.
|
|
7
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
8
|
+
this list of conditions and the following disclaimer in the documentation
|
|
9
|
+
and/or other materials provided with the distribution.
|
|
10
|
+
* Neither the name of Stefano Balocco nor the names of its contributors may
|
|
11
|
+
be used to endorse or promote products derived from this software without
|
|
12
|
+
specific prior written permission.
|
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
14
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
15
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
16
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
17
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
18
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
19
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
20
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
21
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
22
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# @stefanobalocco/honosignedrequests
|
|
2
|
+
|
|
3
|
+
A Hono middleware for HMAC-SHA256 signed requests.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This library provides server-side session management and request signature validation for Hono applications, along with a client library for making signed requests.
|
|
8
|
+
|
|
9
|
+
### Authentication Mechanism
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
The signature is computed over:
|
|
14
|
+
- Session ID
|
|
15
|
+
- Sequence number (monotonically increasing to prevent replay attacks)
|
|
16
|
+
- Timestamp (to limit signature validity window)
|
|
17
|
+
- Request parameters (sorted alphabetically)
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
- HMAC-SHA256 request signing with shared secret token
|
|
23
|
+
- Replay attack protection via monotonic sequence numbers
|
|
24
|
+
- Timestamp validation with configurable tolerance, to prevent delayed replay
|
|
25
|
+
- Constant-time signature comparison, to prevent timing attacks
|
|
26
|
+
- Pluggable session storage architecture
|
|
27
|
+
- Works with Node.js, Cloudflare Workers, Deno, and Bun
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install @stefanobalocco/honosignedrequests
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Server-Side Usage
|
|
36
|
+
|
|
37
|
+
### Basic Setup
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { Hono } from 'hono';
|
|
41
|
+
import { SignedRequestsManager, SessionsStorageLocal } from '@stefanobalocco/honosignedrequests';
|
|
42
|
+
|
|
43
|
+
const app = new Hono();
|
|
44
|
+
|
|
45
|
+
// SessionsStorageLocal is a simple in-memory storage implementation
|
|
46
|
+
// You can implement your own SessionsStorage (e.g., Redis-based) for production
|
|
47
|
+
const storage = new SessionsStorageLocal({
|
|
48
|
+
maxSessions: 65535, // Storage-specific: maximum concurrent sessions in memory
|
|
49
|
+
maxSessionsPerUser: 3 // Storage-specific: maximum sessions per user
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Generic parameters are passed to SignedRequestsManager
|
|
53
|
+
const signedRequests = new SignedRequestsManager(storage, {
|
|
54
|
+
validitySignature: 5000, // signature valid for 5 seconds
|
|
55
|
+
validityToken: 3600000, // session valid for 1 hour
|
|
56
|
+
tokenLength: 32 // token size in bytes
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.use('/api/*', signedRequests.middleware);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Session Creation (Login Endpoint)
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
app.post('/auth/login', async (c) => {
|
|
66
|
+
const { username, password } = await c.req.json();
|
|
67
|
+
|
|
68
|
+
// Your authentication logic here
|
|
69
|
+
const userId = await authenticateUser(username, password);
|
|
70
|
+
|
|
71
|
+
if (!userId) {
|
|
72
|
+
return c.json({ error: 'Invalid credentials' }, 401);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Use the manager's createSession method
|
|
76
|
+
const session = await signedRequests.createSession(userId);
|
|
77
|
+
|
|
78
|
+
// Convert token to Base64URL for transmission to client
|
|
79
|
+
const tokenBase64 = btoa(String.fromCharCode(...session.token))
|
|
80
|
+
.replace(/\+/g, '-')
|
|
81
|
+
.replace(/\//g, '_')
|
|
82
|
+
.replace(/=+$/, '');
|
|
83
|
+
|
|
84
|
+
return c.json({
|
|
85
|
+
sessionId: session.id,
|
|
86
|
+
token: tokenBase64,
|
|
87
|
+
sequenceNumber: session.sequenceNumber
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Ping Endpoint (Verify Authentication)
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
app.post('/api/ping', (c) => {
|
|
96
|
+
const session = c.get('session');
|
|
97
|
+
return c.json({ pong: !!session });
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Protected Endpoint
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
app.post('/api/protected', (c) => {
|
|
105
|
+
const session = c.get('session');
|
|
106
|
+
|
|
107
|
+
if (!session) {
|
|
108
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return c.json({
|
|
112
|
+
message: 'Authenticated',
|
|
113
|
+
userId: session.userId
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Configuration
|
|
119
|
+
|
|
120
|
+
The library separates **generic parameters** (common to all storage implementations) from **storage-specific parameters**.
|
|
121
|
+
|
|
122
|
+
#### Generic Parameters (SignedRequestsManagerConfig)
|
|
123
|
+
|
|
124
|
+
These are passed to `SignedRequestsManager` constructor and apply to all storage implementations:
|
|
125
|
+
|
|
126
|
+
| Option | Default | Description |
|
|
127
|
+
|--------|---------|-------------|
|
|
128
|
+
| `validitySignature` | 5000 | Signature validity window in milliseconds |
|
|
129
|
+
| `validityToken` | 3600000 | Session token validity in milliseconds |
|
|
130
|
+
| `tokenLength` | 32 | Token length in bytes (cryptographic secret) |
|
|
131
|
+
|
|
132
|
+
#### SessionsStorageLocal Specific Parameters
|
|
133
|
+
|
|
134
|
+
These are specific to the in-memory implementation:
|
|
135
|
+
|
|
136
|
+
| Option | Default | Description |
|
|
137
|
+
|--------|---------|-------------|
|
|
138
|
+
| `maxSessions` | 65535 | Maximum concurrent sessions in memory |
|
|
139
|
+
| `maxSessionsPerUser` | 3 | Maximum sessions per user (enforced by removing oldest) |
|
|
140
|
+
|
|
141
|
+
## Client-Side Usage
|
|
142
|
+
|
|
143
|
+
### Browser Import (CDN)
|
|
144
|
+
|
|
145
|
+
```html
|
|
146
|
+
<script type="module">
|
|
147
|
+
import { SignedRequester } from 'https://cdn.jsdelivr.net/gh/StefanoBalocco/HonoSignedRequests/client/dist/SignedRequester.min.js';
|
|
148
|
+
|
|
149
|
+
const requester = new SignedRequester();
|
|
150
|
+
// You can also specify a base URL for request
|
|
151
|
+
//const requester = new SignedRequester('https://api.example.com');
|
|
152
|
+
|
|
153
|
+
// Check if we have session data stored
|
|
154
|
+
let needLogin = true;
|
|
155
|
+
if (requester.getSession()) {
|
|
156
|
+
// Try to verify the session is still valid
|
|
157
|
+
try {
|
|
158
|
+
const response = await requester.signedRequestJson('/api/ping', {});
|
|
159
|
+
|
|
160
|
+
if (response?.pong) {
|
|
161
|
+
needLogin = false;
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if( needLogin ) {
|
|
168
|
+
// No session data, need to login
|
|
169
|
+
const response = await fetch('/auth/login', {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({
|
|
173
|
+
username: 'user@example.com',
|
|
174
|
+
password: 'password123'
|
|
175
|
+
})
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const loginData = await response.json();
|
|
179
|
+
|
|
180
|
+
// Store session credentials
|
|
181
|
+
requester.setSession({
|
|
182
|
+
sessionId: loginData.sessionId,
|
|
183
|
+
token: loginData.token,
|
|
184
|
+
sequenceNumber: loginData.sequenceNumber
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Now we're authenticated, make protected requests
|
|
189
|
+
const data = await requester.signedRequestJson('/api/protected', {
|
|
190
|
+
action: 'getData'
|
|
191
|
+
});
|
|
192
|
+
</script>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Client API
|
|
196
|
+
|
|
197
|
+
#### `setSession(config)`
|
|
198
|
+
|
|
199
|
+
Initialize session after login.
|
|
200
|
+
|
|
201
|
+
```javascript
|
|
202
|
+
requester.setSession({
|
|
203
|
+
sessionId: 12345,
|
|
204
|
+
token: 'base64url_encoded_token',
|
|
205
|
+
sequenceNumber: 1
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### `getSession()`
|
|
210
|
+
|
|
211
|
+
Check if a valid session exists. Loads from localStorage if not in memory.
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
if (requester.getSession()) {
|
|
215
|
+
// Session available
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### `signedRequest(path, parameters, options?)`
|
|
220
|
+
|
|
221
|
+
Make a signed request, returns the raw Response object.
|
|
222
|
+
|
|
223
|
+
```javascript
|
|
224
|
+
const response = await requester.signedRequest('/api/action', {
|
|
225
|
+
param1: 'value1',
|
|
226
|
+
param2: 123
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
#### `signedRequestJson<T>(path, parameters, options?)`
|
|
231
|
+
|
|
232
|
+
Make a signed request and parse JSON response.
|
|
233
|
+
|
|
234
|
+
```javascript
|
|
235
|
+
const data = await requester.signedRequestJson('/api/data', {
|
|
236
|
+
query: 'example'
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
#### `clearSession()`
|
|
241
|
+
|
|
242
|
+
Clear session data (for example after you do a logout).
|
|
243
|
+
|
|
244
|
+
```javascript
|
|
245
|
+
requester.clearSession();
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Request Options
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
{
|
|
252
|
+
baseUrl?: string; // Override base URL for this request
|
|
253
|
+
headers?: Record<string, string>; // Additional headers
|
|
254
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // HTTP method (default: POST)
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Signature Format
|
|
259
|
+
|
|
260
|
+
The signature is computed over the following concatenated string:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
sessionId={id};sequenceNumber={seq};timestamp={ts};{sorted_params}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The HMAC-SHA256 signature is computed using the session token as the secret key.
|
|
267
|
+
|
|
268
|
+
Parameters are sorted alphabetically by key. Values are serialized as:
|
|
269
|
+
- Primitives (string, number, boolean) and null: `String(value)`
|
|
270
|
+
- Objects and arrays: `JSON.stringify(value)`
|
|
271
|
+
|
|
272
|
+
## Implementing Custom Storage
|
|
273
|
+
|
|
274
|
+
To implement your own session storage (e.g., Redis-based), extend the `SessionsStorage` abstract class:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
import { SessionsStorage } from '@stefanobalocco/honosignedrequests';
|
|
278
|
+
import { Session } from '@stefanobalocco/honosignedrequests';
|
|
279
|
+
|
|
280
|
+
class RedisSessionsStorage extends SessionsStorage {
|
|
281
|
+
async create(
|
|
282
|
+
validityToken: number,
|
|
283
|
+
tokenLength: number,
|
|
284
|
+
userId: number
|
|
285
|
+
): Promise<Session> {
|
|
286
|
+
// Implement session creation with Redis
|
|
287
|
+
// Generate token with specified tokenLength
|
|
288
|
+
// Use validityToken for Redis TTL or expiration tracking
|
|
289
|
+
// Implement your own maxSessionsPerUser logic if needed
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async getBySessionId(sessionId: number): Promise<Session | undefined> {
|
|
293
|
+
// Implement session lookup with Redis
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async getByUserId(userId: number): Promise<Session[]> {
|
|
297
|
+
// Implement session lookup with Redis
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async delete(sessionId: number): Promise<void> {
|
|
301
|
+
// Implement session deletion with Redis
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Considerations
|
|
307
|
+
- Storage should expire sessions
|
|
308
|
+
- Invalid sessions should return 401 and trigger client-side session clearing
|
|
309
|
+
- Client should be forced to serialize requests, otherwise out-of-order sequence number may arise triggering a session cleanup.
|
|
310
|
+
|
|
311
|
+
## License
|
|
312
|
+
|
|
313
|
+
BSD-3-Clause
|
package/dist/Common.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type Undefinedable<T> = T | undefined;
|
|
2
|
+
export declare function fromBase64Url(b64url: string): Uint8Array<ArrayBuffer>;
|
|
3
|
+
export declare function randomBytes(bytes: number): Uint8Array<ArrayBuffer>;
|
|
4
|
+
export declare function randomInt(min: number, max: number): number;
|
|
5
|
+
export declare function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean;
|
|
6
|
+
export declare function hmacSha256(keyBytes: Uint8Array<ArrayBuffer>, data: string): Promise<Uint8Array<ArrayBuffer>>;
|
package/dist/Common.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function fromBase64Url(b64url) {
|
|
2
|
+
const pad = (4 - (b64url.length % 4)) % 4;
|
|
3
|
+
const b64 = (b64url + "=".repeat(pad)).replace(/-/g, "+").replace(/_/g, "/");
|
|
4
|
+
const binary = atob(b64);
|
|
5
|
+
const cFL = binary.length;
|
|
6
|
+
const returnValue = new Uint8Array(cFL);
|
|
7
|
+
for (let iFL = 0; iFL < cFL; iFL++) {
|
|
8
|
+
returnValue[iFL] = binary.charCodeAt(iFL);
|
|
9
|
+
}
|
|
10
|
+
return returnValue;
|
|
11
|
+
}
|
|
12
|
+
export function randomBytes(bytes) {
|
|
13
|
+
const returnValue = new Uint8Array(bytes);
|
|
14
|
+
crypto.getRandomValues(returnValue);
|
|
15
|
+
return returnValue;
|
|
16
|
+
}
|
|
17
|
+
export function randomInt(min, max) {
|
|
18
|
+
let returnValue;
|
|
19
|
+
const range = max - min;
|
|
20
|
+
if (range > 0) {
|
|
21
|
+
const randomBuffer = new Uint32Array(randomBytes(4).buffer);
|
|
22
|
+
returnValue = min + (randomBuffer[0] % range);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
throw new Error('max must be > min');
|
|
26
|
+
}
|
|
27
|
+
return returnValue;
|
|
28
|
+
}
|
|
29
|
+
export function constantTimeEqual(a, b) {
|
|
30
|
+
let returnValue = false;
|
|
31
|
+
if (a.length === b.length) {
|
|
32
|
+
let diff = 0;
|
|
33
|
+
for (let i = 0; i < a.length; i++) {
|
|
34
|
+
diff |= a[i] ^ b[i];
|
|
35
|
+
}
|
|
36
|
+
returnValue = (diff === 0);
|
|
37
|
+
}
|
|
38
|
+
return returnValue;
|
|
39
|
+
}
|
|
40
|
+
export async function hmacSha256(keyBytes, data) {
|
|
41
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
42
|
+
const textEncoder = new TextEncoder();
|
|
43
|
+
const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(data));
|
|
44
|
+
return new Uint8Array(signature);
|
|
45
|
+
}
|
package/dist/Session.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Undefinedable } from './Common';
|
|
2
|
+
import { Session } from './Session';
|
|
3
|
+
export declare abstract class SessionsStorage {
|
|
4
|
+
abstract create(validityToken: number, tokenLength: number, userId: number): Promise<Session>;
|
|
5
|
+
abstract getBySessionId(sessionId: number): Promise<Undefinedable<Session>>;
|
|
6
|
+
abstract getByUserId(userId: number): Promise<Session[]>;
|
|
7
|
+
abstract delete(sessionId: number): Promise<boolean>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Undefinedable } from './Common';
|
|
2
|
+
import { Session } from './Session';
|
|
3
|
+
import { SessionsStorage } from './SessionsStorage';
|
|
4
|
+
type SessionStorageLocalConfig = {
|
|
5
|
+
maxSessions: number;
|
|
6
|
+
maxSessionsPerUser: number;
|
|
7
|
+
};
|
|
8
|
+
export declare class SessionsStorageLocal implements SessionsStorage {
|
|
9
|
+
private readonly _maxSessions;
|
|
10
|
+
private readonly _maxSessionsPerUser;
|
|
11
|
+
private readonly _cleanupSessionLimit;
|
|
12
|
+
private _sessionsById;
|
|
13
|
+
private _sessionsByUserId;
|
|
14
|
+
constructor(options?: Partial<SessionStorageLocalConfig>);
|
|
15
|
+
create(validityToken: number, tokenLength: number, userId: number): Promise<Session>;
|
|
16
|
+
delete(sessionId: number): Promise<boolean>;
|
|
17
|
+
getByUserId(userId: number): Promise<Session[]>;
|
|
18
|
+
getBySessionId(sessionId: number): Promise<Undefinedable<Session>>;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomBytes, randomInt } from './Common';
|
|
2
|
+
export class SessionsStorageLocal {
|
|
3
|
+
_maxSessions;
|
|
4
|
+
_maxSessionsPerUser;
|
|
5
|
+
_cleanupSessionLimit;
|
|
6
|
+
_sessionsById = new Map();
|
|
7
|
+
_sessionsByUserId = new Map();
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this._maxSessions = options?.maxSessions ?? 0xFFFF;
|
|
10
|
+
this._maxSessionsPerUser = options?.maxSessionsPerUser ?? 3;
|
|
11
|
+
this._cleanupSessionLimit = Math.floor(this._maxSessions * 0.75);
|
|
12
|
+
}
|
|
13
|
+
async create(validityToken, tokenLength, userId) {
|
|
14
|
+
let returnValue;
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
if (this._sessionsById.size > this._cleanupSessionLimit) {
|
|
17
|
+
await Promise.all(Array.from(this._sessionsById.entries()).filter(([_, session]) => now > (session.lastUsed + validityToken)).map(([sessionId, _]) => this.delete(sessionId)));
|
|
18
|
+
}
|
|
19
|
+
const usedIds = [...this._sessionsById.keys()].filter((sessionId) => (now <= (this._sessionsById.get(sessionId).lastUsed + validityToken))).sort((a, b) => a - b);
|
|
20
|
+
const sessionsRange = this._maxSessions - usedIds.length;
|
|
21
|
+
if (sessionsRange > 0) {
|
|
22
|
+
let sessionId = randomInt(0, this._maxSessions - usedIds.length);
|
|
23
|
+
let left = 0;
|
|
24
|
+
let right = usedIds.length;
|
|
25
|
+
while (left < right) {
|
|
26
|
+
const mid = Math.floor((left + right) / 2);
|
|
27
|
+
if (usedIds[mid] <= sessionId + mid) {
|
|
28
|
+
left = mid + 1;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
right = mid;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
sessionId = (sessionId + left) >>> 0;
|
|
35
|
+
const session = this._sessionsById.get(sessionId);
|
|
36
|
+
if (session) {
|
|
37
|
+
if (now > session.lastUsed + validityToken) {
|
|
38
|
+
await this.delete(sessionId);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
throw new Error(`Session ${sessionId} already in use`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const token = randomBytes(tokenLength);
|
|
45
|
+
returnValue = {
|
|
46
|
+
id: sessionId,
|
|
47
|
+
userId,
|
|
48
|
+
sequenceNumber: 1,
|
|
49
|
+
token,
|
|
50
|
+
lastUsed: now,
|
|
51
|
+
data: []
|
|
52
|
+
};
|
|
53
|
+
this._sessionsById.set(sessionId, returnValue);
|
|
54
|
+
const sessionsByUserId = this._sessionsByUserId.get(userId) ?? [];
|
|
55
|
+
sessionsByUserId.push(returnValue);
|
|
56
|
+
if (sessionsByUserId.length > this._maxSessionsPerUser) {
|
|
57
|
+
const oldestIndex = sessionsByUserId.reduce((minimumIndex, session, index) => session.lastUsed < sessionsByUserId[minimumIndex].lastUsed ? index : minimumIndex, 0);
|
|
58
|
+
const old = sessionsByUserId.splice(oldestIndex, 1)[0];
|
|
59
|
+
this._sessionsById.delete(old.id);
|
|
60
|
+
}
|
|
61
|
+
this._sessionsByUserId.set(userId, sessionsByUserId);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
throw new Error(`Session array full`);
|
|
65
|
+
}
|
|
66
|
+
return returnValue;
|
|
67
|
+
}
|
|
68
|
+
async delete(sessionId) {
|
|
69
|
+
let returnValue = false;
|
|
70
|
+
const session = this._sessionsById.get(sessionId);
|
|
71
|
+
if (session) {
|
|
72
|
+
returnValue = true;
|
|
73
|
+
this._sessionsById.delete(sessionId);
|
|
74
|
+
const userSessions = this._sessionsByUserId.get(session.userId) ?? [];
|
|
75
|
+
const sessionIndex = userSessions.findIndex((session) => session.id === sessionId);
|
|
76
|
+
if (-1 !== sessionIndex) {
|
|
77
|
+
userSessions.splice(sessionIndex, 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return returnValue;
|
|
81
|
+
}
|
|
82
|
+
async getByUserId(userId) {
|
|
83
|
+
return this._sessionsByUserId.get(userId) ?? [];
|
|
84
|
+
}
|
|
85
|
+
async getBySessionId(sessionId) {
|
|
86
|
+
return this._sessionsById.get(sessionId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { MiddlewareHandler } from 'hono';
|
|
2
|
+
import { Undefinedable } from './Common';
|
|
3
|
+
import { Session } from './Session';
|
|
4
|
+
import { SessionsStorage } from './SessionsStorage';
|
|
5
|
+
type SignedRequestsManagerConfig = {
|
|
6
|
+
validitySignature: number;
|
|
7
|
+
validityToken: number;
|
|
8
|
+
tokenLength: number;
|
|
9
|
+
};
|
|
10
|
+
export declare class SignedRequestsManager {
|
|
11
|
+
private static readonly _primitives;
|
|
12
|
+
private readonly _storage;
|
|
13
|
+
private readonly _validitySignature;
|
|
14
|
+
private readonly _validityToken;
|
|
15
|
+
private readonly _tokenLength;
|
|
16
|
+
constructor(storage?: SessionsStorage, options?: Partial<SignedRequestsManagerConfig>);
|
|
17
|
+
createSession(userId: number): Promise<Session>;
|
|
18
|
+
validate(sessionId: number, timestamp: number, parameters: [string, any][], signature: Uint8Array<ArrayBuffer>): Promise<Undefinedable<Session>>;
|
|
19
|
+
middleware: MiddlewareHandler;
|
|
20
|
+
}
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { constantTimeEqual, fromBase64Url, hmacSha256 } from './Common';
|
|
2
|
+
import { SessionsStorageLocal } from './SessionsStorageLocal';
|
|
3
|
+
export class SignedRequestsManager {
|
|
4
|
+
static _primitives = new Set(['string', 'number', 'boolean']);
|
|
5
|
+
_storage;
|
|
6
|
+
_validitySignature;
|
|
7
|
+
_validityToken;
|
|
8
|
+
_tokenLength;
|
|
9
|
+
constructor(storage, options) {
|
|
10
|
+
this._validitySignature = options?.validitySignature ?? 5000;
|
|
11
|
+
this._validityToken = options?.validityToken ?? 60 * 60000;
|
|
12
|
+
this._tokenLength = options?.tokenLength ?? 32;
|
|
13
|
+
if (!storage) {
|
|
14
|
+
storage = new SessionsStorageLocal();
|
|
15
|
+
}
|
|
16
|
+
this._storage = storage;
|
|
17
|
+
}
|
|
18
|
+
async createSession(userId) {
|
|
19
|
+
return await this._storage.create(this._validityToken, this._tokenLength, userId);
|
|
20
|
+
}
|
|
21
|
+
async validate(sessionId, timestamp, parameters, signature) {
|
|
22
|
+
let returnValue;
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if ((now > timestamp) && (now < timestamp + this._validitySignature)) {
|
|
25
|
+
const session = await this._storage.getBySessionId(sessionId);
|
|
26
|
+
if (session) {
|
|
27
|
+
if (now < session.lastUsed + this._validityToken) {
|
|
28
|
+
const parametersOrdered = [
|
|
29
|
+
['sessionId', session.id],
|
|
30
|
+
['sequenceNumber', session.sequenceNumber],
|
|
31
|
+
['timestamp', timestamp],
|
|
32
|
+
...parameters.sort((a, b) => a[0].localeCompare(b[0]))
|
|
33
|
+
];
|
|
34
|
+
const dataToSign = parametersOrdered.map(([name, value]) => {
|
|
35
|
+
const serializedValue = (SignedRequestsManager._primitives.has(typeof value) || null === value) ? String(value) : JSON.stringify(value);
|
|
36
|
+
return `${name}=${serializedValue}`;
|
|
37
|
+
}).join(';');
|
|
38
|
+
const signatureExpected = await hmacSha256(session.token, dataToSign);
|
|
39
|
+
if (constantTimeEqual(signature, signatureExpected)) {
|
|
40
|
+
session.lastUsed = now;
|
|
41
|
+
session.sequenceNumber++;
|
|
42
|
+
returnValue = session;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
await this._storage.delete(sessionId);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return returnValue;
|
|
51
|
+
}
|
|
52
|
+
middleware = async (context, next) => {
|
|
53
|
+
let session;
|
|
54
|
+
try {
|
|
55
|
+
const parameters = {};
|
|
56
|
+
switch (context.req.method) {
|
|
57
|
+
case 'GET': {
|
|
58
|
+
Object.assign(parameters, context.req.query());
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case 'POST': {
|
|
62
|
+
switch (context.req.header('Content-Type')) {
|
|
63
|
+
case 'application/json': {
|
|
64
|
+
Object.assign(parameters, await context.req.json());
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
default: {
|
|
68
|
+
Object.assign(parameters, await context.req.parseBody());
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const sessionId = parseInt(parameters.sessionId, 10);
|
|
75
|
+
const timestamp = parseInt(parameters.timestamp, 10);
|
|
76
|
+
const signature = fromBase64Url(parameters.signature);
|
|
77
|
+
if (sessionId && timestamp && signature) {
|
|
78
|
+
const { sessionId: _, timestamp: __, signature: ___, ...other } = parameters;
|
|
79
|
+
const otherParameters = Object.entries(other);
|
|
80
|
+
session = await this.validate(sessionId, timestamp, otherParameters, signature);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
console.error('Session validation error:', error);
|
|
85
|
+
}
|
|
86
|
+
if (session) {
|
|
87
|
+
context.set('session', session);
|
|
88
|
+
}
|
|
89
|
+
await next();
|
|
90
|
+
};
|
|
91
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stefanobalocco/honosignedrequests",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "An hono middleware to manage signed requests, including a client implementation.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./client": {
|
|
13
|
+
"types": "./client/dist/SignedRequester.d.ts",
|
|
14
|
+
"import": "./client/dist/SignedRequester.min.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"hono": "^4.10.6",
|
|
19
|
+
"terser": "^5.44.1",
|
|
20
|
+
"typescript": "^5.5.3"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"hono": "^4.0.0"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"hono",
|
|
30
|
+
"middleware",
|
|
31
|
+
"hmac",
|
|
32
|
+
"signed-requests",
|
|
33
|
+
"session",
|
|
34
|
+
"authentication"
|
|
35
|
+
],
|
|
36
|
+
"license": "BSD-3-Clause",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "npm run build:server && npm run build:client",
|
|
39
|
+
"build:server": "tsc",
|
|
40
|
+
"build:client": "tsc -p client/tsconfig.json && terser client/dist/SignedRequester.js -o client/dist/SignedRequester.min.js --toplevel -m -c --mangle-props regex=/^_/"
|
|
41
|
+
}
|
|
42
|
+
}
|