@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.
- package/ARCHITECTURE.md +465 -0
- package/CHANGELOG.md +214 -0
- package/LICENSE +21 -0
- package/README.md +647 -0
- package/dist/adapters.d.ts +51 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +301 -0
- package/dist/adapters.js.map +1 -0
- package/dist/auth.d.ts +59 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +560 -0
- package/dist/auth.js.map +1 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +65 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +11 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +125 -0
- package/dist/jwt.js.map +1 -0
- package/dist/oauth.d.ts +31 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +178 -0
- package/dist/oauth.js.map +1 -0
- package/dist/password.d.ts +5 -0
- package/dist/password.d.ts.map +1 -0
- package/dist/password.js +90 -0
- package/dist/password.js.map +1 -0
- package/dist/providers.d.ts +37 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +471 -0
- package/dist/providers.js.map +1 -0
- package/dist/rbac.d.ts +40 -0
- package/dist/rbac.d.ts.map +1 -0
- package/dist/rbac.js +240 -0
- package/dist/rbac.js.map +1 -0
- package/dist/roles.d.ts +2 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +2 -0
- package/dist/roles.js.map +1 -0
- package/dist/token.d.ts +2 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +2 -0
- package/dist/token.js.map +1 -0
- package/dist/types.d.ts +383 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +35 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +192 -0
- package/dist/utils.js.map +1 -0
- package/examples/express.ts +111 -0
- package/examples/fastify.ts +59 -0
- package/examples/oauth-social.ts +83 -0
- package/examples/standalone.ts +67 -0
- package/examples/uwebsockets.ts +143 -0
- package/package.json +86 -0
- package/src/adapters.ts +333 -0
- package/src/auth.ts +684 -0
- package/src/errors.ts +76 -0
- package/src/index.ts +124 -0
- package/src/jwt.ts +159 -0
- package/src/oauth.ts +226 -0
- package/src/password.ts +111 -0
- package/src/providers.ts +551 -0
- package/src/rbac.ts +285 -0
- package/src/roles.ts +1 -0
- package/src/token.ts +1 -0
- package/src/types.ts +432 -0
- package/src/utils.ts +231 -0
package/ARCHITECTURE.md
ADDED
|
@@ -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
|