@oneunit/auth 2.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.
Files changed (75) hide show
  1. package/ARCHITECTURE.md +465 -0
  2. package/CHANGELOG.md +214 -0
  3. package/LICENSE +21 -0
  4. package/README.md +647 -0
  5. package/dist/adapters.d.ts +51 -0
  6. package/dist/adapters.d.ts.map +1 -0
  7. package/dist/adapters.js +301 -0
  8. package/dist/adapters.js.map +1 -0
  9. package/dist/auth.d.ts +59 -0
  10. package/dist/auth.d.ts.map +1 -0
  11. package/dist/auth.js +560 -0
  12. package/dist/auth.js.map +1 -0
  13. package/dist/errors.d.ts +39 -0
  14. package/dist/errors.d.ts.map +1 -0
  15. package/dist/errors.js +65 -0
  16. package/dist/errors.js.map +1 -0
  17. package/dist/index.d.ts +11 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +10 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/jwt.d.ts +11 -0
  22. package/dist/jwt.d.ts.map +1 -0
  23. package/dist/jwt.js +125 -0
  24. package/dist/jwt.js.map +1 -0
  25. package/dist/oauth.d.ts +31 -0
  26. package/dist/oauth.d.ts.map +1 -0
  27. package/dist/oauth.js +178 -0
  28. package/dist/oauth.js.map +1 -0
  29. package/dist/password.d.ts +5 -0
  30. package/dist/password.d.ts.map +1 -0
  31. package/dist/password.js +90 -0
  32. package/dist/password.js.map +1 -0
  33. package/dist/providers.d.ts +37 -0
  34. package/dist/providers.d.ts.map +1 -0
  35. package/dist/providers.js +471 -0
  36. package/dist/providers.js.map +1 -0
  37. package/dist/rbac.d.ts +40 -0
  38. package/dist/rbac.d.ts.map +1 -0
  39. package/dist/rbac.js +240 -0
  40. package/dist/rbac.js.map +1 -0
  41. package/dist/roles.d.ts +2 -0
  42. package/dist/roles.d.ts.map +1 -0
  43. package/dist/roles.js +2 -0
  44. package/dist/roles.js.map +1 -0
  45. package/dist/token.d.ts +2 -0
  46. package/dist/token.d.ts.map +1 -0
  47. package/dist/token.js +2 -0
  48. package/dist/token.js.map +1 -0
  49. package/dist/types.d.ts +383 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +2 -0
  52. package/dist/types.js.map +1 -0
  53. package/dist/utils.d.ts +35 -0
  54. package/dist/utils.d.ts.map +1 -0
  55. package/dist/utils.js +192 -0
  56. package/dist/utils.js.map +1 -0
  57. package/examples/express.ts +111 -0
  58. package/examples/fastify.ts +59 -0
  59. package/examples/oauth-social.ts +83 -0
  60. package/examples/standalone.ts +67 -0
  61. package/examples/uwebsockets.ts +143 -0
  62. package/package.json +86 -0
  63. package/src/adapters.ts +333 -0
  64. package/src/auth.ts +684 -0
  65. package/src/errors.ts +76 -0
  66. package/src/index.ts +124 -0
  67. package/src/jwt.ts +159 -0
  68. package/src/oauth.ts +226 -0
  69. package/src/password.ts +111 -0
  70. package/src/providers.ts +551 -0
  71. package/src/rbac.ts +285 -0
  72. package/src/roles.ts +1 -0
  73. package/src/token.ts +1 -0
  74. package/src/types.ts +432 -0
  75. package/src/utils.ts +231 -0
@@ -0,0 +1,465 @@
1
+ # Architecture
2
+
3
+ `@oneunit/auth` is a framework-agnostic authentication package. It issues and
4
+ validates JWTs, evaluates RBAC, hashes passwords, and drives OAuth 2.0 / OIDC
5
+ social login, with thin adapters for Express, Fastify, Koa, and
6
+ uWebSockets.js.
7
+
8
+ It depends only on `jsonwebtoken` and Node's built-in `crypto`. It declares no
9
+ peer dependencies at all: the adapters never import Express, Fastify, or Koa,
10
+ they duck-type the request and response objects they are given. A consumer can
11
+ install `@oneunit/auth` in a worker with no HTTP framework present.
12
+
13
+ ## Module layout
14
+
15
+ ```text
16
+ src/
17
+ index.ts Public surface. Re-exports and the only file consumers should import.
18
+ types.ts Shared interfaces. No runtime code.
19
+ errors.ts AuthError hierarchy with `code` and `status`.
20
+ utils.ts Header/cookie/query parsing, TTL math, fetch helpers.
21
+ jwt.ts Thin wrapper over jsonwebtoken. encode / decode / PKCE-aware token types.
22
+ rbac.ts Roles, permission resolution, wildcard matching.
23
+ password.ts scrypt hashing with a self-describing hash format.
24
+ oauth.ts OAuth flow, state store, provider registry.
25
+ providers.ts Built-in provider definitions and the custom provider factory.
26
+ adapters.ts Express, Fastify, Koa, and uWS request/response glue.
27
+ auth.ts The Auth class: the composition point for everything above.
28
+ roles.ts Compatibility re-export of ./rbac.js.
29
+ token.ts Compatibility re-export of ./jwt.js.
30
+ ```
31
+
32
+ `auth.ts` is the only module that knows about all the others. Everything else is
33
+ independently importable, which is why the JWT, RBAC, and password helpers are
34
+ exported standalone — a worker that only needs to verify a token should not pull
35
+ in the OAuth machinery.
36
+
37
+ ## The Auth class
38
+
39
+ `createAuth(options)` returns an `Auth` instance. The constructor validates its
40
+ input, then wires four subsystems:
41
+
42
+ ```mermaid
43
+ graph LR
44
+ Auth["Auth"]
45
+ Auth --> RBAC["RBAC<br/>roles and permissions"]
46
+ Auth --> OAuth["OAuth<br/>providers and state"]
47
+ Auth --> RS["RefreshStore<br/>rotation and revocation"]
48
+ Auth --> JWT["jsonwebtoken<br/>sign and verify"]
49
+ Auth --> PWD["scrypt<br/>password hashing"]
50
+ Auth --> US["UserStore<br/>optional persistence"]
51
+ ```
52
+
53
+ `UserStore` is entirely optional. Without it, `login()` still mints tokens for
54
+ a user record you supply; only `register()`, `loginWithPassword()`, and the
55
+ OAuth user-provisioning paths require it.
56
+
57
+ ### Login pipeline
58
+
59
+ 1. Reject a record without an `id`
60
+ 2. Run `beforeLogin` hooks
61
+ 3. Normalize `roles` from `user.roles` and `user.role`
62
+ 4. Resolve permissions from those roles
63
+ 5. Run registered claim extractors
64
+ 6. Merge `sub`, `userId`, profile fields, roles, permissions, extra claims
65
+ 7. Sign the access token, stamping a `jti` and `typ: "access"`
66
+ 8. Unless `refresh: false`, sign the refresh token with its own `jti` and
67
+ persist the record to the `RefreshStore`
68
+ 9. Run `afterLogin` hooks and `onLogin`
69
+
70
+ Step 4 is the security-relevant one. Permissions are computed from roles only;
71
+ see [Trust boundaries](#trust-boundaries).
72
+
73
+ ## Token lifecycle
74
+
75
+ ```mermaid
76
+ sequenceDiagram
77
+ participant C as Client
78
+ participant A as Auth
79
+ participant S as RefreshStore
80
+
81
+ C->>A: login() / loginWithPassword()
82
+ A->>A: sign access token (typ: access, jti)
83
+ A->>A: sign refresh token (typ: refresh, jti)
84
+ A->>S: save(record)
85
+ A-->>C: { accessToken, refreshToken }
86
+
87
+ C->>A: request + Bearer access token
88
+ A->>A: verify signature, iss, aud, exp
89
+ A->>A: reject if typ is refresh
90
+ A-->>C: JwtPayload
91
+
92
+ C->>A: refresh(refreshToken)
93
+ A->>A: verify with refreshSecret
94
+ A->>S: consume(jti) — atomic
95
+ S-->>A: record | null
96
+ A-->>C: new token pair
97
+ Note over C,A: The presented token is now invalid.
98
+ ```
99
+
100
+ ### Two token types, one secret by default
101
+
102
+ Access and refresh tokens are both JWTs. `refreshSecret` defaults to `secret`,
103
+ so both are signed with the same key and distinguished only by the `typ` claim:
104
+
105
+ | | Access token | Refresh token |
106
+ | :--- | :--- | :--- |
107
+ | `typ` | `access` | `refresh` |
108
+ | Signed with | `secret` | `refreshSecret` |
109
+ | Default TTL | `15m` | `7d` |
110
+ | Carries | `roles`, `permissions`, profile | `sub`, `userId`, `roles` |
111
+ | Accepted by | `verify()` | `refresh()` only |
112
+ | Contains | identity claims | no permissions |
113
+
114
+ Set a distinct `refreshSecret` to get real key separation. Keeping one secret is
115
+ still safe, because `verify()` refuses a `typ: "refresh"` token outright.
116
+
117
+ ### Refresh rotation
118
+
119
+ Every `refresh()` call consumes the presented token. This is the reason
120
+ `RefreshStore` has a `consume()` method:
121
+
122
+ ```mermaid
123
+ sequenceDiagram
124
+ participant R1 as Request A
125
+ participant R2 as Request B
126
+ participant S as Store
127
+
128
+ R1->>S: consume(jti)
129
+ S-->>R1: record
130
+ R2->>S: consume(jti)
131
+ S-->>R2: null
132
+ Note over R2: rejected as revoked
133
+ ```
134
+
135
+ `get()` followed by `revoke()` is two round-trips, so two concurrent requests
136
+ can both observe a valid token and both receive a fresh session. `consume()` must
137
+ be a single atomic operation:
138
+
139
+ | Store | Atomic primitive |
140
+ | :--- | :--- |
141
+ | Redis | `GETDEL key` |
142
+ | PostgreSQL | `DELETE FROM sessions WHERE id = $1 RETURNING *` |
143
+ | MongoDB | `findOneAndDelete({ _id })` |
144
+ | In-memory | `Map.get` then `Map.delete` with no `await` between |
145
+
146
+ `revoke()` and `consume()` are not optional. If a store implements neither,
147
+ `refresh()` and `logout()` throw `ConfigurationError` rather than report a
148
+ logout that never happened. For logout the two are interchangeable — both remove
149
+ one id — so a `consume`-only store supports both operations. Rotation is stricter:
150
+ having reached that path, `consume` is already absent, so `revoke` is required.
151
+
152
+ ## Trust boundaries
153
+
154
+ The package draws a hard line between values it derived and values it was
155
+ handed. Three boundaries matter most.
156
+
157
+ ### Roles come from your code, not the request
158
+
159
+ `register()` discards a `roles` field in its input argument. A public sign-up
160
+ form posts straight into that argument, so honoring the field would let anyone
161
+ mint themselves an `admin` token. Pass roles through the second, server-side
162
+ argument:
163
+
164
+ ```js
165
+ await auth.register({ email, password }, { roles: ["member"] });
166
+ ```
167
+
168
+ `loginWithOAuth()` applies the same rule, taking roles from its options rather
169
+ than the provider profile.
170
+
171
+ ### `auth.login()` expects a verified user record
172
+
173
+ `login()` is a low-level primitive: it signs whatever record you hand it. It
174
+ does not check passwords and does not know whether the roles are legitimate.
175
+ Resolve the user through `loginWithPassword()` or your own store first.
176
+
177
+ Never pass a request body straight into `login()`:
178
+
179
+ ```js
180
+ // Vulnerable: the caller picks their own roles.
181
+ auth.login({ id: req.body.id, roles: req.body.roles });
182
+
183
+ // Correct: the roles come from your authorization rules.
184
+ const user = await store.findById(req.body.id);
185
+ auth.login({ ...user, roles: rolesFor(user) });
186
+ ```
187
+
188
+ ### Permissions are derived, not stored
189
+
190
+ A `permissions` array on the user record is **not** copied into the access
191
+ token. If it were, one attacker-writable database column would be equivalent to
192
+ full privilege escalation, and the RBAC configuration would be advisory.
193
+
194
+ Permissions come from `rbac` role definitions. Set `trustUserPermissions: true`
195
+ only when a separate write path guarantees the column is server-controlled.
196
+
197
+ Direct permissions still work for explicit subjects, where the caller supplies
198
+ them in code rather than from storage:
199
+
200
+ ```js
201
+ rbac.can({ roles: ["member"], permissions: ["beta.access"] }, "beta.access");
202
+ ```
203
+
204
+ ### `defaultRole` applies to a null subject too
205
+
206
+ A subject with no roles — **including `null` and `undefined`** — is assigned
207
+ `defaultRole`. This is intentional, and it means:
208
+
209
+ ```js
210
+ const rbac = createRBAC({ defaultRole: "admin", roles: { admin: { permissions: ["*"] } } });
211
+
212
+ rbac.can(null, "anything"); // true
213
+ rbac.can({}, "anything"); // true
214
+ ```
215
+
216
+ So `defaultRole` is a grant to anonymous callers, not just a convenience for
217
+ roled users. Two consequences:
218
+
219
+ 1. Keep `defaultRole` unprivileged. A guest/user default is the safe choice; an
220
+ admin default turns any missed null check into full privilege escalation.
221
+ 2. Always check for a subject before asking. The bundled adapters do this
222
+ (`if (!req.user) return 401`) before calling `can`, but your own middleware
223
+ may not:
224
+
225
+ ```js
226
+ // Grants the default role to anonymous callers.
227
+ if (auth.can(ctx.state.user, "post.write")) { ... }
228
+
229
+ // Correct: no subject, no grant.
230
+ if (ctx.state.user && auth.can(ctx.state.user, "post.write")) { ... }
231
+ ```
232
+
233
+ ### Role inheritance
234
+
235
+ A role can inherit from parents, and resolution is cycle-safe. Permissions
236
+ resolve as: direct grants on the role, then every ancestor's grants.
237
+
238
+ ```mermaid
239
+ graph TD
240
+ admin["admin<br/>user.manage"]
241
+ editor["editor<br/>inherits member<br/>post.write"]
242
+ member["member<br/>profile.read"]
243
+
244
+ admin --> editor
245
+ editor --> member
246
+ ```
247
+
248
+ ## Wildcard matching
249
+
250
+ `matchPermission(granted, needed)` supports two wildcards:
251
+
252
+ | Granted | Matches | Does not match |
253
+ | :--- | :--- | :--- |
254
+ | `*` | anything | — |
255
+ | `posts.*` | `posts.create` | `posts.a.b`, `postsx.create` |
256
+ | `posts.**` | `posts.create`, `posts.a.b` | `comments.create` |
257
+
258
+ `*` stays within one dot-separated segment. `**` spans any number of segments.
259
+ Wildcards never cross a segment boundary, so `post.*` does not match
260
+ `posts.create`.
261
+
262
+ `can()` requires every requested permission to be satisfied. A granted `*`
263
+ satisfies all of them.
264
+
265
+ ## Password hashing
266
+
267
+ Passwords use Node's `scrypt` with a self-describing format, so cost parameters
268
+ travel inside the hash and can be raised later without invalidating old ones:
269
+
270
+ ```text
271
+ scrypt$N$r$p$keyLength$salt$hash
272
+ ```
273
+
274
+ `needsRehash()` compares a stored hash against current defaults, and
275
+ `loginWithPassword()` transparently rehashes on a successful login when the
276
+ user store implements `updatePassword`.
277
+
278
+ Verification is constant-time via `timingSafeEqual`, and returns `false` rather
279
+ than throwing for any malformed or unparsable hash. Cost parameters are read
280
+ from the stored hash, so Node's own `maxmem` limit is what bounds a hostile
281
+ value — do not treat the hash column as attacker-controlled storage.
282
+
283
+ ## OAuth
284
+
285
+ ```mermaid
286
+ sequenceDiagram
287
+ participant U as User
288
+ participant A as App
289
+ participant P as Provider
290
+
291
+ A->>A: authorize() — generate state, optional PKCE verifier
292
+ A->>A: stateStore.set(state, record, ttl)
293
+ A-->>U: 302 to provider
294
+
295
+ U->>A: /callback?code=...&state=...
296
+ A->>A: stateStore.get(state), then delete
297
+ A->>P: exchangeCode(code, redirectUri, codeVerifier)
298
+ P-->>A: access + refresh tokens
299
+ A->>P: fetchProfile(tokens)
300
+ P-->>A: profile
301
+ A->>A: resolve or provision user, then login()
302
+ ```
303
+
304
+ State is single-use: it is read and deleted before the code exchange, so a
305
+ replayed callback fails. Public clients should enable `pkce`; without PKCE and
306
+ without state there is no CSRF binding on the callback.
307
+
308
+ `loginWithOAuth()` accepts the callback params as a full URL, a path with a
309
+ query, a leading `?code=...`, a bare query string, or a plain object.
310
+
311
+ ### How each provider establishes identity
312
+
313
+ Most providers return a profile by calling the provider's userinfo endpoint with
314
+ the access token, so the **provider itself** verifies the identity. Those are
315
+ server-verified by construction.
316
+
317
+ `apple` is the exception: Sign in with Apple returns the profile inside the
318
+ `id_token` from the code exchange, so the claims are read locally. `iss`,
319
+ `aud` (against your `clientId`), and `exp` are validated, and a mismatch throws
320
+ `ProviderError`.
321
+
322
+ **The `id_token` signature is not verified.** No JWKS fetch, no RS256 check.
323
+ This is not reachable through `loginWithOAuth()`, because the token is always
324
+ obtained by this library from Apple over TLS using your `clientSecret` — the
325
+ caller only ever supplies a `code`. It matters if you ever forward an
326
+ `id_token` from a native app or another service into this code path, where a
327
+ forged token would be accepted. If you do that, verify the signature against
328
+ `https://appleid.apple.com/auth/keys` and check `nonce` before calling
329
+ `loginWithOAuth()`.
330
+
331
+ A profile must carry a stable subject id. Without one, the fallback user id
332
+ would be `` `${provider}:${profile.id}` `` and every user of that provider would
333
+ share the subject `"google:undefined"`, so the call throws `ProviderError`
334
+ instead. Map the identifier explicitly:
335
+
336
+ ```js
337
+ const acme = createProvider({
338
+ id: "acme",
339
+ authorizationUrl: "...",
340
+ tokenUrl: "...",
341
+ userInfoUrl: "...",
342
+ profileMap: { id: "account_id", email: "email", name: "full_name" },
343
+ });
344
+ ```
345
+
346
+ The state store is in-memory by default. Provide your own `StateStore` backed by
347
+ Redis or your session layer to make the flow work across multiple instances.
348
+
349
+ ## Adapters
350
+
351
+ Adapters are thin. Each one extracts a token, calls `verify()`, and maps errors
352
+ to a response. They do not cache, and they do not hold state between requests.
353
+
354
+ | Adapter | Reads token from | Attaches to | Notes |
355
+ | :--- | :--- | :--- | :--- |
356
+ | Express | `req.headers`, `req.cookies`, `req.query`, `req.getHeader` | `req.user`, `req.token` | `next(error)` when `passthrough` |
357
+ | Fastify | request headers, cookies, query | `request.user` | `optional` honored from plugin or route |
358
+ | Koa | `ctx.request` | `ctx.state.user` | downstream errors propagate to Koa |
359
+ | uWS | `getHeader`, `getQuery`, `forEach` | snapshot object | request snapshotted before any `await` |
360
+
361
+ ### Header shapes
362
+
363
+ uWebSockets.js is not a normal Node request. It is single-threaded and its
364
+ `res` object is only valid until the next tick, so `snapshotUwsRequest()` copies
365
+ method, URL, query, and headers into a plain object before any `await`.
366
+
367
+ Two `forEach` conventions exist in the wild and both are supported:
368
+
369
+ | Source | Signature | Example |
370
+ | :--- | :--- | :--- |
371
+ | uWS `HttpRequest` | `(key, value)` | native to uWebSockets.js |
372
+ | WHATWG `Headers` | `(value, key)` | `fetch` / `Request` / `Response` |
373
+
374
+ `collectHeaders()` detects which one it is looking at. Conflating them silently
375
+ yields zero headers and a 401 on every request, so both paths have dedicated
376
+ tests.
377
+
378
+ ### Error mapping
379
+
380
+ Every error extends `AuthError` and carries `code` and `status`. Adapters send
381
+ `{ error, message }` and never leak a stack.
382
+
383
+ | Class | `code` | HTTP |
384
+ | :--- | :--- | :--- |
385
+ | `InvalidTokenError` | `INVALID_TOKEN` | 401 |
386
+ | `TokenExpiredError` | `TOKEN_EXPIRED` | 401 |
387
+ | `UnauthorizedError` | `UNAUTHORIZED` | 401 |
388
+ | `ForbiddenError` | `FORBIDDEN` | 403 |
389
+ | `OAuthError` | `OAUTH_ERROR` | 401 |
390
+ | `ProviderError` | `PROVIDER_ERROR` | 502 |
391
+ | `ValidationError` | `VALIDATION_ERROR` | 400 |
392
+ | `ConfigurationError` | `CONFIGURATION_ERROR` | 500 |
393
+
394
+ ## Extension points
395
+
396
+ | Hook | Signature | Use |
397
+ | :--- | :--- | :--- |
398
+ | `registerExtractor(name, fn)` | `(user) => value` | Add a claim derived from the user || `hook("beforeLogin", fn)` | `(payload, auth)` | Reject or annotate before signing |
399
+ | `hook("afterLogin", fn)` | `(result, auth)` | Audit a successful login |
400
+ | `hook("afterVerify", fn)` | `(claims, auth)` | Audit a verification |
401
+ | `onLogin(result)` | `() => unknown` | Fire-and-forget login event |
402
+ | `onLink(event)` | `({ user, provider, profile })` | React to account linking |
403
+
404
+ A claim extractor that throws sets its claim to `null` rather than failing the
405
+ login, so one bad extractor cannot take down authentication.
406
+
407
+ Extractors and `additionalClaims` run *after* the derived claims, so a name
408
+ collision would otherwise rewrite the token's identity. These names are
409
+ reserved and refused:
410
+
411
+ `sub`, `userId`, `roles`, `permissions`, `typ`, `iss`, `aud`, `exp`, `iat`,
412
+ `nbf`, `jti`
413
+
414
+ `registerExtractor()` throws on a reserved name, reserved keys inside an
415
+ object returned by an extractor are dropped, and `login()` rejects an
416
+ `additionalClaims` entry that names one. Everything else — `name`, `email`,
417
+ `tier`, `tenantId` — is yours to set.
418
+
419
+ ## Configuration reference
420
+
421
+ | Option | Default | Notes |
422
+ | :--- | :--- | :--- |
423
+ | `secret` | — | **Required.** Construction throws without it. |
424
+ | `refreshSecret` | `secret` | Set separately for key separation. |
425
+ | `issuer` / `audience` | — | Verified on every token. |
426
+ | `algorithm` | `HS256` | Pinned; never negotiated from the token header. |
427
+ | `accessTokenTtl` | `15m` | Also settable per login. |
428
+ | `refreshTokenTtl` | `7d` | Also settable per login. |
429
+ | `clockTolerance` | `0` | Seconds of leeway for `exp` / `nbf`. |
430
+ | `trustUserPermissions` | `false` | See [Trust boundaries](#trust-boundaries). |
431
+ | `rbac` / `roles` | `new RBAC()` | Accepts an `RBAC` instance or options. |
432
+ | `userStore` | `null` | Required only for register and password login. |
433
+ | `refreshStore` | in-memory | Should be shared and atomic. |
434
+ | `oauth` / `providers` | empty | Built-in or custom providers. |
435
+
436
+ A TTL must be a number of seconds or a timespan this package can parse: `s`,
437
+ `m`, `h`, `d`, `w`. Anything else (`"1y"`, `"2 hours"`) throws
438
+ `ValidationError` at signing time, so the signed token and any locally computed
439
+ expiry can never disagree.
440
+
441
+ ## Security invariants
442
+
443
+ The behaviors below are covered by `test/security.test.ts`. If a change breaks
444
+ one, that test should fail.
445
+
446
+ 1. `verify()` rejects a token whose `typ` is `refresh`
447
+ 2. `register()` ignores roles in its input argument
448
+ 3. Access token permissions come from roles, not the user record
449
+ 4. `revoke()` and `refresh()` throw rather than no-op on a store that cannot invalidate
450
+ 5. A refresh token is consumable exactly once, including under concurrency
451
+ 6. `revoke()` pins the configured algorithm and rejects access tokens
452
+ 7. The Koa adapter does not convert downstream errors into auth failures
453
+ 8. The Fastify adapter honors `optional` set on the plugin
454
+ 9. `alg: none`, cross-secret, and cross-algorithm tokens are rejected
455
+ 10. Unparsable TTLs throw instead of silently defaulting
456
+ 11. `loginWithOAuth()` refuses to mint a token without a provider subject id
457
+ 12. Hostile scrypt parameters return `false` rather than throwing or hanging
458
+ 13. RBAC inheritance cycles terminate, and `can()` denies an empty permission list
459
+ 14. Extractors and `additionalClaims` cannot set a reserved identity or permission claim
460
+
461
+ ## Related
462
+
463
+ - [README.md](./README.md) — API tour
464
+ - [CHANGELOG.md](./CHANGELOG.md) — release history
465
+ - [../../docs/security.md](../../docs/security.md) — workspace-wide security notes
package/CHANGELOG.md ADDED
@@ -0,0 +1,214 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## 2.0.0
8
+
9
+ Security hardening. Every breaking change below is a case where the previous
10
+ behavior was exploitable or silently incorrect. Upgrade notes follow the
11
+ release.
12
+
13
+ ### Security
14
+
15
+ - **Identity and permission claims can no longer be overridden.** Extractors and
16
+ `additionalClaims` are applied after the derived claims, so a name collision
17
+ silently replaced them: `login(user, { additionalClaims: { sub: "admin" } })`
18
+ minted a token claiming `sub: admin` for user 42, and an extractor named `sub`
19
+ rewrote the subject outright. `sub`, `userId`, `roles`, `permissions`, `typ`,
20
+ `iss`, `aud`, `exp`, `iat`, `nbf`, and `jti` are now reserved —
21
+ `registerExtractor()` throws on a reserved name, reserved keys returned in an
22
+ extractor's object are dropped, and `login()` rejects an `additionalClaims`
23
+ entry naming one. Non-reserved names such as `name`, `email`, `tier`, or
24
+ `tenantId` are unaffected.
25
+ - **Apple `id_token` claims are validated.** Apple is the only built-in provider
26
+ whose profile comes from a token in the code-exchange response rather than a
27
+ server-side userinfo call, so its `iss`, `aud`, and `exp` were read with no
28
+ validation at all — a token with a wrong issuer, a wrong audience, or an
29
+ expired `exp` was accepted. They are now checked against the configured
30
+ `clientId` and the current time, and a mismatch throws `ProviderError`. The
31
+ RS256 signature is still not verified; see `ARCHITECTURE.md` for why that is
32
+ not reachable through `loginWithOAuth()`, and what to do if you forward an
33
+ `id_token` from elsewhere.
34
+ - **Refresh tokens are no longer accepted as access tokens.** `verify()` and
35
+ `verifyRequest()` reject a token whose `typ` is `refresh`. Access and refresh
36
+ tokens share a signing key by default, so a 7-day refresh token previously
37
+ authenticated as a bearer credential on every protected route.
38
+ - **`register()` ignores caller-supplied roles.** A `roles` field in the input
39
+ argument is discarded. A public sign-up form posts directly into that
40
+ argument, so the field let any caller mint themselves a privileged role.
41
+ - **Access token permissions come from RBAC roles only.** A `permissions` array
42
+ on the user record is no longer copied into the token, where it would have
43
+ been indistinguishable from a role-derived grant.
44
+ - **`logout()` no longer reports success when nothing was revoked.** If the
45
+ configured `refreshStore` implements neither `consume` nor `revoke`,
46
+ `revoke()`, `logout()`, and `refresh()` throw `ConfigurationError` instead of
47
+ returning a `true` that invalidated nothing. A store implementing only
48
+ `consume()` can both rotate and log out, since the two are interchangeable for
49
+ removing a specific id.
50
+ - **Refresh rotation is atomic.** `refresh()` uses the new `RefreshStore.consume()`
51
+ when present, so one token can no longer be redeemed twice by concurrent
52
+ requests. Stores without `consume` fall back to `get()` + `revoke()` and now
53
+ require `revoke()`.
54
+ - **The Koa adapter no longer masks downstream errors.** `next()` moved outside
55
+ the `try` block, so a handler error propagates to Koa's error handling instead
56
+ of being rewritten as a `401 AUTH_ERROR` response.
57
+
58
+ ### Fixed
59
+
60
+ - Token extraction reads `Authorization` and `Cookie` from a WHATWG `Headers`
61
+ object, whose `forEach` passes `(value, key)`. Both arguments were previously
62
+ inverted, so such requests produced no headers and always returned 401.
63
+ uWebSockets.js `(key, value)` handling is unchanged.
64
+ - `fastifyAdapter` honors `optional` configured on the plugin, not only when
65
+ passed per route.
66
+ - `revoke()` pins `algorithm` and `clockTolerance` to the configured values
67
+ instead of defaulting to HS256 with no leeway, and rejects non-refresh tokens.
68
+ - `refresh()` now honors `clockTolerance`.
69
+ - Registering an aliased OAuth provider no longer overwrites the canonical
70
+ provider entry.
71
+ - `matchPermission` supports `**` as a multi-segment wildcard, so `posts.**`
72
+ matches `posts.a.b`. `*` still stays within a single segment.
73
+ - An unparsable `expiresIn` (`"1y"`, `"2 hours"`) throws `ValidationError`
74
+ instead of silently defaulting to 24 hours in one place and failing in
75
+ another, which let a token's real expiry diverge from a locally computed one.
76
+ - **OAuth login now fails closed when the profile carries no subject id.**
77
+ Previously the fallback user id was `` `${provider}:${profile.id}` ``, so a
78
+ provider response missing the id produced the subject `"google:undefined"` and
79
+ every social user collapsed onto one identity. `loginWithOAuth()` now throws
80
+ `ProviderError`. Map a stable id in the provider's `profileMap`.
81
+ - `loginWithOAuth()` accepts a bare query string (`"code=abc&state=xyz"`). The
82
+ `URL` constructor parsed it as a path with no query, so the code silently came
83
+ back empty and the call failed with "missing authorization code". Full URLs,
84
+ paths with a query, and a leading `?` continue to work.
85
+ - `package.json` and `.npmignore` end with a trailing newline.
86
+
87
+ ### Added
88
+
89
+ - `pkceVerifier()` and `pkceChallenge()` are now exported from the package entry
90
+ point. Public clients implementing PKCE by hand had no supported way to
91
+ generate a verifier or compute the S256 challenge, even though
92
+ `OAuthAuthorizeOptions.codeVerifier` and `authorize()`'s returned
93
+ `codeVerifier` are public API.
94
+ - `ARCHITECTURE.md` — module layout, token lifecycle, refresh-store contract,
95
+ trust boundaries, and the security invariants the test suite enforces.
96
+ - `RefreshStore.consume(id)` — optional atomic read-and-delete. Preferred over
97
+ `get()` + `revoke()`.
98
+ - `AuthOptions.trustUserPermissions` — opt back in to copying a user record's
99
+ `permissions` into the access token. Defaults to `false`.
100
+ - `JwtVerifyOptions.acceptTokenType` — accept a `refresh` token in `verify()`.
101
+ Defaults to rejecting it.
102
+ - `isValidExpiresIn()` — exported TTL validator.
103
+ - `test/security.test.ts` — regression tests for each fix above, plus
104
+ `alg: none`, cross-secret, cross-algorithm, concurrent-rotation, hostile
105
+ scrypt parameters, OAuth identity-collapse, callback parameter shapes,
106
+ RBAC cycle/deep-chain/wildcard-boundary, and PKCE RFC 7636 cases.
107
+ - GitHub Actions workflow running typecheck, tests, build, `pack:check`, and
108
+ `pnpm audit` on Node 20/22/24, then installing the packed tarball into a clean
109
+ project to verify the public API and the shipped examples.
110
+ - `npm run example`, `example:standalone`, and `example:oauth` scripts.
111
+
112
+ ### Changed
113
+
114
+ - **Shipped examples import `@oneunit/auth` instead of `../src/index.js`.**
115
+ `src/` is not published, so every example previously failed with
116
+ `ERR_MODULE_NOT_FOUND` for anyone who installed from npm. A `paths` mapping in
117
+ `tsconfig.json` keeps in-repo typechecking pointed at source, and Node's
118
+ package self-reference resolves the built `dist/` at runtime.
119
+ - **Removed the `express`, `fastify`, and `koa` peer dependencies.** The adapters
120
+ never import any of them — they duck-type the request and response objects —
121
+ so the peers misrepresented the contract. The package now has one runtime
122
+ dependency, `jsonwebtoken`.
123
+ - Replaced the `bootstrap-framework` npm keyword with `oneunit`.
124
+ - Deleted `.npmignore`. The `files` array in `package.json` is authoritative and
125
+ two competing lists would drift.
126
+
127
+ ### Migration
128
+
129
+ **If you call `auth.register(input)` and relied on `input.roles`:**
130
+
131
+ ```diff
132
+ - await auth.register({ email, password, roles: ["member"] });
133
+ + await auth.register({ email, password }, { roles: ["member"] });
134
+ ```
135
+
136
+ **If you store permissions on the user record and expect them in the token:**
137
+
138
+ ```diff
139
+ - const auth = createAuth({ secret });
140
+ + const auth = createAuth({ secret, trustUserPermissions: true });
141
+ ```
142
+
143
+ Prefer granting those permissions through an RBAC role instead.
144
+
145
+ **If you use a custom `refreshStore`:**
146
+
147
+ Implement `revoke()` at minimum. Implement `consume()` as a single atomic
148
+ operation (`GETDEL`, `DELETE ... RETURNING`, `findOneAndDelete`) so concurrent
149
+ refreshes cannot both succeed.
150
+
151
+ ```ts
152
+ const refreshStore = {
153
+ async save(record) { /* ... */ },
154
+ async get(id) { /* ... */ },
155
+ async consume(id) { /* atomic read + delete */ },
156
+ async revoke(id) { /* ... */ },
157
+ };
158
+ ```
159
+
160
+ **If you have `@oneunit/auth` in `peerDependencies` or `optionalDependencies`:**
161
+
162
+ Remove it. The adapters never import a web framework, so there is nothing to
163
+ satisfy. Keeping it forces an unnecessary install.
164
+
165
+ **If you vendor or fork the examples:**
166
+
167
+ They now import from `@oneunit/auth` rather than `../src/index.js`, because
168
+ `src/` is not published. Copy them into your own project and the import already
169
+ resolves.
170
+
171
+ **If you call `auth.verify()` on a refresh token:**
172
+
173
+ Use `auth.refresh()` instead. Pass `{ acceptTokenType: "refresh" }` only if you
174
+ genuinely need the raw claims.
175
+
176
+ **If your login route passes a request body into `auth.login()`:**
177
+
178
+ `login()` is a low-level primitive and never validated roles. Resolve the user
179
+ through a store first. See `examples/express.ts` for the correct shape.
180
+
181
+ ## 1.0.0
182
+
183
+ - Published independently as `@oneunit/auth` (renamed from `@bootstrap-framework/auth`)
184
+ - Node.js 20+, MIT, author mayank
185
+
186
+ ## Previous releases
187
+
188
+ Released as `@bootstrap-framework/auth`.
189
+
190
+ ### 2.2.0
191
+
192
+ - Convert the package to TypeScript with generated `.d.ts` declarations
193
+ - Publish compiled ESM from `dist/` (`main`, `types`, and `exports`)
194
+ - Add `typescript` / `@types/node` / `@types/jsonwebtoken` / `tsx` for build and tests
195
+ - Type adapters, tests, and examples
196
+
197
+ ### 2.1.0
198
+
199
+ - Add uWebSockets.js adapter with request snapshots (required after `await`)
200
+ - Read tokens from uWS `getHeader` / `getQuery` and header maps
201
+ - Optional `uWebSockets.js` peer dependency
202
+ - npm packaging: `exports`, `prepack`, and `package.json` export
203
+
204
+ ### 2.0.0
205
+
206
+ - Independent, framework-agnostic auth package
207
+ - Replace `@fastify/jwt` with `jsonwebtoken`
208
+ - Remove workspace and framework runtime dependencies
209
+ - Generic RBAC (consumer-defined roles and permissions)
210
+ - Social OAuth 2.0 / OIDC providers (Google, GitHub, Instagram, and others)
211
+ - Password hashing via Node.js `scrypt`
212
+ - Express, Fastify, and Koa adapters
213
+ - Access and refresh tokens
214
+ - Tests, examples, types, and npm package metadata