@tozielinski/next-upstash-nonce 1.3.1 → 1.4.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/CHANGELOG.md +7 -0
- package/README.md +45 -7
- package/dist/index.d.ts +30 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0](https://github.com/tozielinski/next-upstash-nonce/compare/v1.3.1...v1.4.0) (2025-11-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add rate limiter for API endpoints ([5990ac4](https://github.com/tozielinski/next-upstash-nonce/commit/5990ac49da90984769aa92ab338e6876af30a4b6))
|
|
9
|
+
|
|
3
10
|
## [1.3.1](https://github.com/tozielinski/next-upstash-nonce/compare/v1.3.0...v1.3.1) (2025-11-22)
|
|
4
11
|
|
|
5
12
|
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
[](https://opensource.org/licenses/MIT)
|
|
3
3
|
[](https://www.npmjs.com/package/@tozielinski/next-upstash-nonce)
|
|
4
4
|
|
|
5
|
-
## Create, store, verify and delete nonces in Redis by Upstash for Next.js
|
|
5
|
+
## Create, store, verify and delete nonces in Redis by Upstash for Next.js and/or secure your API endpoints with a rate limiter
|
|
6
6
|
|
|
7
7
|
# Quick Start
|
|
8
8
|
### Install the package:
|
|
@@ -33,7 +33,7 @@ export async function createNonce(): Promise<string> {
|
|
|
33
33
|
return await nonceManager.create();
|
|
34
34
|
}
|
|
35
35
|
```
|
|
36
|
-
### Secure your API endpoint
|
|
36
|
+
### Secure your API endpoint with a Nonce
|
|
37
37
|
```typescript
|
|
38
38
|
'use server'
|
|
39
39
|
|
|
@@ -41,7 +41,7 @@ import {NextResponse} from "next/server";
|
|
|
41
41
|
import {nonceManager} from "@/[wherever you store your nonceManager instance]";
|
|
42
42
|
|
|
43
43
|
export async function POST(req: Request) {
|
|
44
|
-
const nonce = req.headers.get("x-api-nonce");
|
|
44
|
+
const nonce: boolean = req.headers.get("x-api-nonce");
|
|
45
45
|
|
|
46
46
|
if (!nonce) {
|
|
47
47
|
return NextResponse.json(
|
|
@@ -61,15 +61,25 @@ export async function POST(req: Request) {
|
|
|
61
61
|
or more simple
|
|
62
62
|
```typescript
|
|
63
63
|
'use server'
|
|
64
|
-
|
|
64
|
+
import {NextResponse} from "next/server";
|
|
65
65
|
import {nonceManager} from "@/[wherever you store your nonceManager instance]";
|
|
66
|
+
import {NonceCheckResult} from "@tozielinski/next-upstash-nonce";
|
|
66
67
|
|
|
67
68
|
export async function POST(req: Request) {
|
|
68
|
-
const result = await nonceManager.verifyAndDeleteNonceFromRequest(req);
|
|
69
|
+
const result: NonceCheckResult = await nonceManager.verifyAndDeleteNonceFromRequest(req);
|
|
69
70
|
|
|
70
71
|
// result will be {nonce: string, valid: true} or
|
|
71
72
|
// {valid false, reason: string, response: NextResponse}
|
|
72
|
-
// if nonce was not found or expired
|
|
73
|
+
// if nonce was not found or expired:
|
|
74
|
+
//
|
|
75
|
+
// export type NonceCheckResult = | {
|
|
76
|
+
// valid: true;
|
|
77
|
+
// nonce: string
|
|
78
|
+
// } | {
|
|
79
|
+
// valid: false;
|
|
80
|
+
// reason: 'missing-header' | 'invalid-or-expired';
|
|
81
|
+
// response: Response
|
|
82
|
+
// };
|
|
73
83
|
|
|
74
84
|
return NextResponse.json({nonce: result.nonce, valid: result.valid});
|
|
75
85
|
}
|
|
@@ -83,7 +93,7 @@ import {createNonce} from "@/[wherever you store your server action]";
|
|
|
83
93
|
|
|
84
94
|
export default function NonceSecuredComponent() {
|
|
85
95
|
const [running, setRunning] = useState(false);
|
|
86
|
-
const [message, setMessage] = useState(
|
|
96
|
+
const [message, setMessage] = useState('');
|
|
87
97
|
|
|
88
98
|
const handleClick = async () => {
|
|
89
99
|
if (running) return;
|
|
@@ -123,4 +133,32 @@ export default function NonceSecuredComponent() {
|
|
|
123
133
|
)
|
|
124
134
|
}
|
|
125
135
|
```
|
|
136
|
+
### Secure your API endpoint with a Rate Limiter
|
|
137
|
+
```typescript
|
|
138
|
+
'use server'
|
|
139
|
+
import {NextResponse} from "next/server";
|
|
140
|
+
import {nonceManager} from "@/[wherever you store your nonceManager instance]";
|
|
141
|
+
import {RateLimitResult} from "@tozielinski/next-upstash-nonce";
|
|
126
142
|
|
|
143
|
+
export async function POST(req: Request) {
|
|
144
|
+
const rateLimiter: RateLimitResult = await nonceManager.rateLimiter(req!);
|
|
145
|
+
|
|
146
|
+
// result will be {valid: true, ip: string, requests: number} or
|
|
147
|
+
// {valid false, ip:string, requests: number, reason: string, response: NextResponse}
|
|
148
|
+
// if rate limit was reached or broken:
|
|
149
|
+
//
|
|
150
|
+
// export type RateLimitResult = | {
|
|
151
|
+
// valid: true;
|
|
152
|
+
// ip: string;
|
|
153
|
+
// requests: number;
|
|
154
|
+
// } | {
|
|
155
|
+
// valid: false;
|
|
156
|
+
// ip: string;
|
|
157
|
+
// requests: number;
|
|
158
|
+
// reason: `too-many-requests: ${number}`;
|
|
159
|
+
// response: Response;
|
|
160
|
+
// };
|
|
161
|
+
|
|
162
|
+
return NextResponse.json({valid: result.valid, ip: result.ip, requests: result.requests});
|
|
163
|
+
}
|
|
164
|
+
```
|
package/dist/index.d.ts
CHANGED
|
@@ -1,23 +1,44 @@
|
|
|
1
1
|
import { Redis } from "@upstash/redis";
|
|
2
2
|
export type NonceOptions = {
|
|
3
|
-
|
|
4
|
-
ttlSeconds?: number;
|
|
3
|
+
ttlNonce?: number;
|
|
5
4
|
prefix?: string;
|
|
5
|
+
ttlRateLimit?: number;
|
|
6
|
+
countRateLimit?: number;
|
|
6
7
|
};
|
|
7
8
|
export type NonceCheckResult = {
|
|
8
9
|
valid: true;
|
|
9
10
|
nonce: string;
|
|
10
11
|
} | {
|
|
11
12
|
valid: false;
|
|
12
|
-
reason:
|
|
13
|
+
reason: 'missing-header' | 'invalid-or-expired';
|
|
14
|
+
response: Response;
|
|
15
|
+
};
|
|
16
|
+
export type RateLimitResult = {
|
|
17
|
+
valid: true;
|
|
18
|
+
ip: string;
|
|
19
|
+
requests: number;
|
|
20
|
+
} | {
|
|
21
|
+
valid: false;
|
|
22
|
+
ip: string;
|
|
23
|
+
requests: number;
|
|
24
|
+
reason: `too-many-requests: ${number}`;
|
|
13
25
|
response: Response;
|
|
14
26
|
};
|
|
15
27
|
export declare class NonceManager {
|
|
16
28
|
private redis;
|
|
17
|
-
private
|
|
18
|
-
private ttlSeconds;
|
|
29
|
+
private ttlNonce;
|
|
19
30
|
private prefix;
|
|
31
|
+
private ttlRateLimit;
|
|
32
|
+
private countRateLimit;
|
|
20
33
|
constructor(redis: Redis, opts?: NonceOptions);
|
|
34
|
+
/**
|
|
35
|
+
* makes option parameters available in the server in environment files
|
|
36
|
+
*/
|
|
37
|
+
private getEnvValue;
|
|
38
|
+
/**
|
|
39
|
+
* extracts client IP from request headers
|
|
40
|
+
*/
|
|
41
|
+
private getClientIp;
|
|
21
42
|
/**
|
|
22
43
|
* generates a new, secure nonce,
|
|
23
44
|
* inserts it into Redis with a TTL,
|
|
@@ -48,6 +69,10 @@ export declare class NonceManager {
|
|
|
48
69
|
* Optional: delete a nonce from Redis manually
|
|
49
70
|
*/
|
|
50
71
|
delete(nonce: string): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* rate limits requests from the same IP address
|
|
74
|
+
*/
|
|
75
|
+
rateLimiter(req: Request): Promise<RateLimitResult>;
|
|
51
76
|
}
|
|
52
77
|
export default NonceManager;
|
|
53
78
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAGvC,MAAM,MAAM,YAAY,GAAG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAGvC,MAAM,MAAM,YAAY,GAAG;IAEvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAK;IAC7B,KAAK,EAAE,IAAI,CAAC;IACZ,KAAK,EAAE,MAAM,CAAA;CAChB,GAAG;IACA,KAAK,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,gBAAgB,GAAG,oBAAoB,CAAC;IAChD,QAAQ,EAAE,QAAQ,CAAA;CACrB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAK;IAC5B,KAAK,EAAE,IAAI,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;CACpB,GAAG;IACA,KAAK,EAAE,KAAK,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,sBAAsB,MAAM,EAAE,CAAC;IACvC,QAAQ,EAAE,QAAQ,CAAC;CACtB,CAAC;AAEF,qBAAa,YAAY;IACrB,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,cAAc,CAAS;gBAGnB,KAAK,EAAE,KAAK,EAAE,IAAI,GAAE,YAAiB;IAQjD;;OAEG;IACH,OAAO,CAAC,WAAW;IAWnB;;OAEG;IACH,OAAO,CAAC,WAAW;IAanB;;;;OAIG;IACG,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC;IAU/B;;;OAGG;IACG,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAa7C;;;OAGG;IACG,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAetD;;;OAGG;IACG,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAoCrE;;;OAGG;IACG,+BAA+B,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAoC9E;;OAEG;IACG,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM1C;;OAEG;IACG,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;CAgB5D;AAED,eAAe,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -5,14 +5,41 @@ exports.NonceManager = void 0;
|
|
|
5
5
|
const uuid_1 = require("uuid");
|
|
6
6
|
class NonceManager {
|
|
7
7
|
redis;
|
|
8
|
-
|
|
9
|
-
ttlSeconds;
|
|
8
|
+
ttlNonce;
|
|
10
9
|
prefix;
|
|
10
|
+
ttlRateLimit;
|
|
11
|
+
countRateLimit;
|
|
11
12
|
constructor(redis, opts = {}) {
|
|
12
13
|
this.redis = redis;
|
|
13
|
-
this.
|
|
14
|
-
this.ttlSeconds = opts.ttlSeconds ?? 60 * 5; // default 5 minutes
|
|
14
|
+
this.ttlNonce = opts.ttlNonce ?? 60; // default 1 minute
|
|
15
15
|
this.prefix = opts.prefix ?? "nonce:";
|
|
16
|
+
this.ttlRateLimit = opts.ttlRateLimit ?? 60; // default 1 minute
|
|
17
|
+
this.countRateLimit = opts.countRateLimit ?? 5; // default 5 times
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* makes option parameters available in the server in environment files
|
|
21
|
+
*/
|
|
22
|
+
getEnvValue(name, fallback) {
|
|
23
|
+
const val = process.env[name];
|
|
24
|
+
const num = Number(val);
|
|
25
|
+
if (val === undefined || isNaN(num)) {
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
return num;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* extracts client IP from request headers
|
|
32
|
+
*/
|
|
33
|
+
getClientIp(req) {
|
|
34
|
+
const forwardedFor = req.headers.get("x-forwarded-for");
|
|
35
|
+
if (forwardedFor) {
|
|
36
|
+
return forwardedFor.split(",")[0].trim();
|
|
37
|
+
}
|
|
38
|
+
const realIp = req.headers.get("x-real-ip");
|
|
39
|
+
if (realIp) {
|
|
40
|
+
return realIp;
|
|
41
|
+
}
|
|
42
|
+
return "unknown";
|
|
16
43
|
}
|
|
17
44
|
/**
|
|
18
45
|
* generates a new, secure nonce,
|
|
@@ -22,7 +49,8 @@ class NonceManager {
|
|
|
22
49
|
async create() {
|
|
23
50
|
const nonce = (0, uuid_1.v4)();
|
|
24
51
|
const key = this.prefix + nonce;
|
|
25
|
-
|
|
52
|
+
const ttl = this.getEnvValue("NONCE_TTL_SECONDS", this.ttlNonce);
|
|
53
|
+
await this.redis.set(key, "1", { ex: ttl });
|
|
26
54
|
return nonce;
|
|
27
55
|
}
|
|
28
56
|
/**
|
|
@@ -123,6 +151,22 @@ class NonceManager {
|
|
|
123
151
|
const key = this.prefix + nonce;
|
|
124
152
|
await this.redis.del(key);
|
|
125
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* rate limits requests from the same IP address
|
|
156
|
+
*/
|
|
157
|
+
async rateLimiter(req) {
|
|
158
|
+
const ip = this.getClientIp(req);
|
|
159
|
+
const key = `rate:${ip}`;
|
|
160
|
+
const requests = (await this.redis.incr(key)) ?? 0;
|
|
161
|
+
if (requests === 1) {
|
|
162
|
+
await this.redis.expire(key, this.getEnvValue("RATE_LIMIT_TTL_SECONDS", this.ttlRateLimit)); // counter runs 60s
|
|
163
|
+
}
|
|
164
|
+
if (requests > this.getEnvValue("RATE_LIMIT_COUNT", this.countRateLimit)) {
|
|
165
|
+
const response = Response.json({ error: "Too many requests" }, { status: 429 });
|
|
166
|
+
return { valid: false, ip, requests, reason: `too-many-requests: ${requests}`, response: response };
|
|
167
|
+
}
|
|
168
|
+
return { valid: true, ip, requests };
|
|
169
|
+
}
|
|
126
170
|
}
|
|
127
171
|
exports.NonceManager = NonceManager;
|
|
128
172
|
exports.default = NonceManager;
|
package/package.json
CHANGED