@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 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
+ [![npm](https://img.shields.io/npm/v/@exortek/challenge.svg?color=cb3837)](https://www.npmjs.com/package/@exortek/challenge)
6
+ [![node](https://img.shields.io/node/v/@exortek/challenge.svg?color=339933)](https://nodejs.org)
7
+ [![install size](https://packagephobia.com/badge?p=@exortek/challenge)](https://packagephobia.com/result?p=@exortek/challenge)
8
+ [![license](https://img.shields.io/npm/l/@exortek/challenge.svg?color=blue)](./LICENSE)
9
+ [![tests](https://img.shields.io/badge/tests-passing-brightgreen)](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
@@ -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
+ }