@exortek/challenge 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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/errors.d.ts +26 -0
- package/dist/index.cjs +1250 -0
- package/dist/index.d.ts +252 -0
- package/dist/index.mjs +1245 -0
- package/dist/internal/guards.d.ts +8 -0
- package/dist/stores/index.d.ts +28 -0
- package/dist/stores/memory.d.ts +50 -0
- package/dist/stores/redis.d.ts +31 -0
- package/dist/stores.cjs +710 -0
- package/dist/stores.mjs +707 -0
- package/dist/token.d.ts +64 -0
- package/package.json +71 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @exortek/challenge
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Initial release
|
|
6
|
+
|
|
7
|
+
- **`createChallenge(options)`** — issue an HMAC-signed challenge token
|
|
8
|
+
carrying `userId` / `method` / `step` / `nextStep` / `metadata` across
|
|
9
|
+
a multi-step auth flow. Optional `ipBinding` stamps the origin IP into
|
|
10
|
+
the payload; optional `singleUse` marks the token for one-shot
|
|
11
|
+
consumption via a caller-supplied store.
|
|
12
|
+
- **`verifyChallenge(token, options)`** — HMAC-verify, expiry-check,
|
|
13
|
+
and optionally match `expectedUserId` / `expectedMethod` /
|
|
14
|
+
`expectedStep` / `expectedNextStep`, plus IP match when the token was
|
|
15
|
+
IP-bound. Returns `{ valid: true, payload }` on success or
|
|
16
|
+
`{ valid: false, reason }` on any expected failure; never throws on
|
|
17
|
+
bad tokens, only on programmer errors.
|
|
18
|
+
- **Stores** — ships `memoryStore()` (single-node / dev, LRU + TTL
|
|
19
|
+
sweep) and `redisStore(client)` (cluster-safe, single Lua round-trip
|
|
20
|
+
per verify) under the `@exortek/challenge/stores` subpath. Any object
|
|
21
|
+
exposing `incr(key, ttlMs) → { count }` also works — e.g.
|
|
22
|
+
`@exortek/security`'s rate-limit stores.
|
|
23
|
+
- **Token format:** `<prefix>.<base64url(payload)>.<base64url(hmac)>`
|
|
24
|
+
— deliberately not a JWT so the two token families cannot be confused
|
|
25
|
+
at a call site. Prefix defaults to `chall_v1`; callers can override
|
|
26
|
+
via `options.prefix` (e.g. `'server_challenge'`, `'myapp_v1'`) to
|
|
27
|
+
brand the wire format for their service. Must match
|
|
28
|
+
`/^[A-Za-z0-9_-]{1,32}$/`, and the same prefix must be used at create
|
|
29
|
+
and verify time.
|
|
30
|
+
- **Errors:** stable `ErrorCode.INVALID_ARGUMENT` /
|
|
31
|
+
`ErrorCode.INVALID_SECRET` codes on the `ChallengeError` class.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ExorTek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# @exortek/challenge
|
|
2
|
+
|
|
3
|
+
> Signed, single-use challenge tokens for multi-step auth flows on Node.js 22+ — HMAC-SHA256, opt-in single-use enforcement, opt-in IP binding, zero non-`@exortek/*` runtime dependencies. Built on `node:crypto`.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@exortek/challenge)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](https://packagephobia.com/result?p=@exortek/challenge)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
[](https://github.com/ExorTek/auth/actions/workflows/ci.yml)
|
|
10
|
+
|
|
11
|
+
A challenge is a small, HMAC-signed envelope that carries flow context — **who** is being challenged, **how** they proved themselves so far, **which step** of the flow they've cleared — from one HTTP request to the next without a server-side session record. It fills the awkward middle between an OTP code (single value, no context) and a JWT (heavier, meant for actual auth material).
|
|
12
|
+
|
|
13
|
+
Typical shape of a multi-step flow:
|
|
14
|
+
|
|
15
|
+
1. User posts password → server verifies → server issues a **challenge** with `method: 'password'`, `step: 'pw_verified'`, `nextStep: 'mfa'`.
|
|
16
|
+
2. Browser follows a redirect / opens an MFA prompt.
|
|
17
|
+
3. User submits a TOTP code + the challenge token.
|
|
18
|
+
4. Server verifies TOTP, then verifies the challenge (`expectedMethod: 'password'`, `expectedStep: 'pw_verified'`) — only *then* issues the real session.
|
|
19
|
+
|
|
20
|
+
The token is **stateless by default**. Single-use enforcement and IP binding are opt-in.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm i @exortek/challenge
|
|
26
|
+
# or
|
|
27
|
+
yarn add @exortek/challenge
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Node.js 22 LTS or newer.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { createChallenge, verifyChallenge } from '@exortek/challenge';
|
|
36
|
+
|
|
37
|
+
// One 32-byte secret, per environment. Rotate via env var, not code.
|
|
38
|
+
const SECRET = process.env.CHALLENGE_SECRET; // ≥ 32 UTF-8 bytes
|
|
39
|
+
|
|
40
|
+
// 1) After password succeeds
|
|
41
|
+
const token = await createChallenge({
|
|
42
|
+
secret: SECRET,
|
|
43
|
+
userId: 'usr_123',
|
|
44
|
+
method: 'password',
|
|
45
|
+
step: 'pw_verified',
|
|
46
|
+
nextStep: 'mfa',
|
|
47
|
+
expiresIn: '5m',
|
|
48
|
+
});
|
|
49
|
+
res.redirect(`/mfa?c=${token}`);
|
|
50
|
+
|
|
51
|
+
// 2) On the /mfa POST handler
|
|
52
|
+
const res = await verifyChallenge(req.body.c, {
|
|
53
|
+
secret: SECRET,
|
|
54
|
+
expectedMethod: 'password',
|
|
55
|
+
expectedStep: 'pw_verified',
|
|
56
|
+
expectedUserId: pendingUserIdFromCookie,
|
|
57
|
+
});
|
|
58
|
+
if (!res.valid) {
|
|
59
|
+
return reply.code(401).send({ error: res.reason });
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Token format
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
<prefix>.<base64url(JSON payload)>.<base64url(HMAC-SHA256 tag)>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The default prefix is `chall_v1`. It's deliberately **not** a JWT: the versioned prefix lets a caller cheaply refuse a non-challenge token (or a future `chall_v2`) before ever running the HMAC. HMAC covers `<prefix>.<b64u payload>` — any change to prefix or payload invalidates the signature.
|
|
70
|
+
|
|
71
|
+
You can override the prefix at both `createChallenge` and `verifyChallenge` — e.g. `prefix: 'server_challenge'` or `prefix: 'myapp_v1'` — to brand the wire format for your service. The value must match `/^[A-Za-z0-9_-]{1,32}$/` (no `.` since it's the delimiter), and the same prefix must be used on both sides or verification returns `reason: 'malformed'`.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
### `createChallenge(options)`
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
createChallenge({
|
|
79
|
+
secret: string | Buffer | Uint8Array, // ≥ 32 bytes
|
|
80
|
+
expiresIn: string | number, // '5m' / '15m' / 300000
|
|
81
|
+
|
|
82
|
+
// Claims (all optional)
|
|
83
|
+
userId?: string,
|
|
84
|
+
method?: 'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code'
|
|
85
|
+
| 'passkey' | 'magic_link' | 'password' | 'webauthn'
|
|
86
|
+
| 'oauth' | 'oidc' | string,
|
|
87
|
+
step?: string,
|
|
88
|
+
nextStep?: string,
|
|
89
|
+
ua?: string,
|
|
90
|
+
metadata?: Record<string, unknown>,
|
|
91
|
+
|
|
92
|
+
// Security options
|
|
93
|
+
singleUse?: boolean, // requires `store`
|
|
94
|
+
store?: IncrStore,
|
|
95
|
+
ipBinding?: boolean, // requires `ip`
|
|
96
|
+
ip?: string,
|
|
97
|
+
|
|
98
|
+
// Wire format
|
|
99
|
+
prefix?: string, // default 'chall_v1'; /^[A-Za-z0-9_-]{1,32}$/
|
|
100
|
+
|
|
101
|
+
// Testing
|
|
102
|
+
now?: number, // override Date.now()
|
|
103
|
+
}): Promise<string>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Returns the compact token string. Throws `ChallengeError` with `ErrorCode.INVALID_ARGUMENT` on bad options or `ErrorCode.INVALID_SECRET` when the secret is under 32 bytes.
|
|
107
|
+
|
|
108
|
+
### `verifyChallenge(token, options)`
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
verifyChallenge(token, {
|
|
112
|
+
secret: string | Buffer | Uint8Array,
|
|
113
|
+
consume?: boolean, // requires `store`
|
|
114
|
+
store?: IncrStore,
|
|
115
|
+
|
|
116
|
+
expectedUserId?: string,
|
|
117
|
+
expectedMethod?: string,
|
|
118
|
+
expectedStep?: string,
|
|
119
|
+
expectedNextStep?: string,
|
|
120
|
+
ip?: string, // required if token was IP-bound
|
|
121
|
+
|
|
122
|
+
now?: number,
|
|
123
|
+
}): Promise<
|
|
124
|
+
| { valid: true, payload: ChallengePayload }
|
|
125
|
+
| { valid: false, reason:
|
|
126
|
+
'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid'
|
|
127
|
+
| 'user_mismatch' | 'method_mismatch' | 'step_mismatch'
|
|
128
|
+
| 'next_step_mismatch' | 'ip_mismatch' | 'ip_missing'
|
|
129
|
+
| 'replay' | 'store_unavailable'
|
|
130
|
+
}
|
|
131
|
+
>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Never throws on user-input problems — a wrong or stale token is a normal auth outcome, not an error. Only throws `ChallengeError` on programmer bugs (bad options, wrong secret shape).
|
|
135
|
+
|
|
136
|
+
The 60-second clock-skew tolerance on `iat` covers ordinary NTP drift — verify only rejects tokens whose `iat` is clearly future-dated.
|
|
137
|
+
|
|
138
|
+
## Single-use enforcement
|
|
139
|
+
|
|
140
|
+
By default, a valid token can be replayed inside its expiry window. Pass `singleUse: true` at create time and `consume: true` at verify time — with the *same* store on both sides — and the second verify fails with `reason: 'replay'`:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
import { memoryStore } from '@exortek/challenge/stores';
|
|
144
|
+
|
|
145
|
+
const store = memoryStore(); // or redisStore(client) for multi-worker
|
|
146
|
+
const token = await createChallenge({
|
|
147
|
+
secret: SECRET,
|
|
148
|
+
expiresIn: '5m',
|
|
149
|
+
singleUse: true,
|
|
150
|
+
store,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const first = await verifyChallenge(token, {
|
|
154
|
+
secret: SECRET, consume: true, store,
|
|
155
|
+
}); // { valid: true, payload: {...} }
|
|
156
|
+
|
|
157
|
+
const second = await verifyChallenge(token, {
|
|
158
|
+
secret: SECRET, consume: true, store,
|
|
159
|
+
}); // { valid: false, reason: 'replay' }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The store is any object exposing `incr(key, ttlMs) → Promise<{ count }>`. `@exortek/security`'s rate-limit stores fit exactly — reuse yours if you already have one.
|
|
163
|
+
|
|
164
|
+
## IP binding
|
|
165
|
+
|
|
166
|
+
Bind the token to the origin IP so a stolen token from a different network refuses to verify:
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
const token = await createChallenge({
|
|
170
|
+
secret: SECRET,
|
|
171
|
+
expiresIn: '5m',
|
|
172
|
+
ipBinding: true,
|
|
173
|
+
ip: req.ip,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const res = await verifyChallenge(token, {
|
|
177
|
+
secret: SECRET,
|
|
178
|
+
ip: req.ip,
|
|
179
|
+
}); // rejects with reason: 'ip_mismatch' from any other IP
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Consider the trust boundary: mobile users on cellular networks change IPs frequently. Use for high-value flows (admin escalation, payment confirmation), not routine login.
|
|
183
|
+
|
|
184
|
+
## Stores
|
|
185
|
+
|
|
186
|
+
`@exortek/challenge/stores` ships two thin implementations:
|
|
187
|
+
|
|
188
|
+
- **`memoryStore(options?)`** — in-process Map with true LRU eviction (`maxKeys`, default 10,000; every `incr` refreshes the entry's position so a hot replay-guard tombstone can't be evicted before its TTL) and a background TTL sweep (`sweepMs`, default 60,000). Not cluster-safe: multi-worker deploys will double-consume. Fine for dev, single-node prod, sticky-session behind an LB, tests.
|
|
189
|
+
- **`redisStore(client, options?)`** — one Lua round-trip per verify (`INCR` + conditional `PEXPIRE`). Works with `ioredis`, `node-redis@4+`, `@upstash/redis`. `options.keyPrefix` defaults to `'chall:'`.
|
|
190
|
+
|
|
191
|
+
Both expose `incr(key, ttlMs)` and nothing else — the surface is intentionally tiny.
|
|
192
|
+
|
|
193
|
+
## Errors
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
import { ChallengeError, ErrorCode } from '@exortek/challenge';
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await createChallenge({ /* … */ });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err instanceof ChallengeError) {
|
|
202
|
+
if (err.code === ErrorCode.INVALID_SECRET) {
|
|
203
|
+
// e.g. secret < 32 bytes
|
|
204
|
+
}
|
|
205
|
+
if (err.code === ErrorCode.INVALID_ARGUMENT) {
|
|
206
|
+
// programmer bug — bad options shape
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Branch on `err.code`, never on the message. Expected verify failures return a `reason` in the result object rather than throw.
|
|
213
|
+
|
|
214
|
+
## When to reach for something else
|
|
215
|
+
|
|
216
|
+
- **You need a full session with rotation, revocation, sudo mode.** Use `@exortek/session`.
|
|
217
|
+
- **You need an interoperable, third-party-verifiable token.** Use `@exortek/jwt`.
|
|
218
|
+
- **You just need a rate-limit key.** Use `@exortek/security`'s rate-limit — `challenge` is about carrying auth flow state, not counting requests.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable machine-readable codes for every programmer-error failure that
|
|
3
|
+
* `@exortek/challenge` can raise. Branch on `code`, never on the
|
|
4
|
+
* message. Note: expected verify failures (bad signature, expired,
|
|
5
|
+
* mismatched claim) are NOT thrown — `verifyChallenge` returns
|
|
6
|
+
* `{ valid: false, reason }` for those so a wrong or stale token is a
|
|
7
|
+
* normal auth outcome, not an exception. Errors below fire only when
|
|
8
|
+
* the caller configured something wrong.
|
|
9
|
+
*/
|
|
10
|
+
import { BaseError } from '@exortek/shared/errors';
|
|
11
|
+
export declare const ErrorCode: Readonly<{
|
|
12
|
+
INVALID_ARGUMENT: "INVALID_ARGUMENT";
|
|
13
|
+
INVALID_SECRET: "INVALID_SECRET";
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* Every recoverable failure raised by this package. Carries a stable
|
|
17
|
+
* `code` (from {@link ErrorCode}) and a `status` — the HTTP response
|
|
18
|
+
* status a middleware layer would use when translating the error.
|
|
19
|
+
*/
|
|
20
|
+
export declare class ChallengeError extends BaseError {
|
|
21
|
+
static statuses: {
|
|
22
|
+
INVALID_ARGUMENT: number;
|
|
23
|
+
INVALID_SECRET: number;
|
|
24
|
+
};
|
|
25
|
+
static defaultStatus: number;
|
|
26
|
+
}
|