lacewing 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 +11 -0
- package/LICENSE +9 -0
- package/NOTICE.md +1 -0
- package/README.md +313 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/src/cookbook/access-refresh.js +86 -0
- package/dist/src/cookbook/access-refresh.js.map +1 -0
- package/dist/src/http/bearer.js +44 -0
- package/dist/src/http/bearer.js.map +1 -0
- package/dist/src/http/cookies.js +96 -0
- package/dist/src/http/cookies.js.map +1 -0
- package/dist/src/jwks/local.js +85 -0
- package/dist/src/jwks/local.js.map +1 -0
- package/dist/src/jwks/remote.js +121 -0
- package/dist/src/jwks/remote.js.map +1 -0
- package/dist/src/jwt/decrypt.js +126 -0
- package/dist/src/jwt/decrypt.js.map +1 -0
- package/dist/src/jwt/decryption_profile.js +78 -0
- package/dist/src/jwt/decryption_profile.js.map +1 -0
- package/dist/src/jwt/encrypt.js +149 -0
- package/dist/src/jwt/encrypt.js.map +1 -0
- package/dist/src/jwt/profile.js +102 -0
- package/dist/src/jwt/profile.js.map +1 -0
- package/dist/src/jwt/sign.js +173 -0
- package/dist/src/jwt/sign.js.map +1 -0
- package/dist/src/jwt/verify.js +118 -0
- package/dist/src/jwt/verify.js.map +1 -0
- package/dist/src/key/encryption.js +132 -0
- package/dist/src/key/encryption.js.map +1 -0
- package/dist/src/key/export.js +46 -0
- package/dist/src/key/export.js.map +1 -0
- package/dist/src/key/generate.js +47 -0
- package/dist/src/key/generate.js.map +1 -0
- package/dist/src/key/import.js +136 -0
- package/dist/src/key/import.js.map +1 -0
- package/dist/src/legacy/rs256.js +11 -0
- package/dist/src/legacy/rs256.js.map +1 -0
- package/dist/src/legacy/rsa.js +37 -0
- package/dist/src/legacy/rsa.js.map +1 -0
- package/dist/src/lib/algorithms.js +60 -0
- package/dist/src/lib/algorithms.js.map +1 -0
- package/dist/src/lib/base64url.js +28 -0
- package/dist/src/lib/base64url.js.map +1 -0
- package/dist/src/lib/claims.js +111 -0
- package/dist/src/lib/claims.js.map +1 -0
- package/dist/src/lib/duration.js +24 -0
- package/dist/src/lib/duration.js.map +1 -0
- package/dist/src/lib/entropy.js +70 -0
- package/dist/src/lib/entropy.js.map +1 -0
- package/dist/src/lib/headers.js +75 -0
- package/dist/src/lib/headers.js.map +1 -0
- package/dist/src/lib/invariants.js +12 -0
- package/dist/src/lib/invariants.js.map +1 -0
- package/dist/src/lib/json.js +140 -0
- package/dist/src/lib/json.js.map +1 -0
- package/dist/src/lib/jwe_algorithms.js +81 -0
- package/dist/src/lib/jwe_algorithms.js.map +1 -0
- package/dist/src/lib/payload_hygiene.js +116 -0
- package/dist/src/lib/payload_hygiene.js.map +1 -0
- package/dist/src/lib/utf8.js +27 -0
- package/dist/src/lib/utf8.js.map +1 -0
- package/dist/src/revocation/memory.js +67 -0
- package/dist/src/revocation/memory.js.map +1 -0
- package/dist/src/revocation/store.js +25 -0
- package/dist/src/revocation/store.js.map +1 -0
- package/dist/src/types.js +51 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/util/errors.js +96 -0
- package/dist/src/util/errors.js.map +1 -0
- package/dist/src/util/unsafe-decode.js +45 -0
- package/dist/src/util/unsafe-decode.js.map +1 -0
- package/dist/types/index.d.ts +29 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/src/cookbook/access-refresh.d.ts +77 -0
- package/dist/types/src/cookbook/access-refresh.d.ts.map +1 -0
- package/dist/types/src/http/bearer.d.ts +20 -0
- package/dist/types/src/http/bearer.d.ts.map +1 -0
- package/dist/types/src/http/cookies.d.ts +38 -0
- package/dist/types/src/http/cookies.d.ts.map +1 -0
- package/dist/types/src/jwks/local.d.ts +16 -0
- package/dist/types/src/jwks/local.d.ts.map +1 -0
- package/dist/types/src/jwks/remote.d.ts +23 -0
- package/dist/types/src/jwks/remote.d.ts.map +1 -0
- package/dist/types/src/jwt/decrypt.d.ts +23 -0
- package/dist/types/src/jwt/decrypt.d.ts.map +1 -0
- package/dist/types/src/jwt/decryption_profile.d.ts +32 -0
- package/dist/types/src/jwt/decryption_profile.d.ts.map +1 -0
- package/dist/types/src/jwt/encrypt.d.ts +34 -0
- package/dist/types/src/jwt/encrypt.d.ts.map +1 -0
- package/dist/types/src/jwt/profile.d.ts +37 -0
- package/dist/types/src/jwt/profile.d.ts.map +1 -0
- package/dist/types/src/jwt/sign.d.ts +59 -0
- package/dist/types/src/jwt/sign.d.ts.map +1 -0
- package/dist/types/src/jwt/verify.d.ts +41 -0
- package/dist/types/src/jwt/verify.d.ts.map +1 -0
- package/dist/types/src/key/encryption.d.ts +37 -0
- package/dist/types/src/key/encryption.d.ts.map +1 -0
- package/dist/types/src/key/export.d.ts +10 -0
- package/dist/types/src/key/export.d.ts.map +1 -0
- package/dist/types/src/key/generate.d.ts +27 -0
- package/dist/types/src/key/generate.d.ts.map +1 -0
- package/dist/types/src/key/import.d.ts +20 -0
- package/dist/types/src/key/import.d.ts.map +1 -0
- package/dist/types/src/legacy/rs256.d.ts +11 -0
- package/dist/types/src/legacy/rs256.d.ts.map +1 -0
- package/dist/types/src/legacy/rsa.d.ts +25 -0
- package/dist/types/src/legacy/rsa.d.ts.map +1 -0
- package/dist/types/src/lib/algorithms.d.ts +34 -0
- package/dist/types/src/lib/algorithms.d.ts.map +1 -0
- package/dist/types/src/lib/base64url.d.ts +8 -0
- package/dist/types/src/lib/base64url.d.ts.map +1 -0
- package/dist/types/src/lib/claims.d.ts +15 -0
- package/dist/types/src/lib/claims.d.ts.map +1 -0
- package/dist/types/src/lib/duration.d.ts +7 -0
- package/dist/types/src/lib/duration.d.ts.map +1 -0
- package/dist/types/src/lib/entropy.d.ts +21 -0
- package/dist/types/src/lib/entropy.d.ts.map +1 -0
- package/dist/types/src/lib/headers.d.ts +19 -0
- package/dist/types/src/lib/headers.d.ts.map +1 -0
- package/dist/types/src/lib/invariants.d.ts +6 -0
- package/dist/types/src/lib/invariants.d.ts.map +1 -0
- package/dist/types/src/lib/json.d.ts +24 -0
- package/dist/types/src/lib/json.d.ts.map +1 -0
- package/dist/types/src/lib/jwe_algorithms.d.ts +46 -0
- package/dist/types/src/lib/jwe_algorithms.d.ts.map +1 -0
- package/dist/types/src/lib/payload_hygiene.d.ts +20 -0
- package/dist/types/src/lib/payload_hygiene.d.ts.map +1 -0
- package/dist/types/src/lib/utf8.d.ts +7 -0
- package/dist/types/src/lib/utf8.d.ts.map +1 -0
- package/dist/types/src/revocation/memory.d.ts +22 -0
- package/dist/types/src/revocation/memory.d.ts.map +1 -0
- package/dist/types/src/revocation/store.d.ts +17 -0
- package/dist/types/src/revocation/store.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +196 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/src/util/errors.d.ts +89 -0
- package/dist/types/src/util/errors.d.ts.map +1 -0
- package/dist/types/src/util/unsafe-decode.d.ts +21 -0
- package/dist/types/src/util/unsafe-decode.d.ts.map +1 -0
- package/package.json +70 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Smiduweorc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This project includes code adapted from the [jose](https://github.com/panva/jose) repository, created by Panva. We are grateful for their work!
|
package/README.md
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Lacewing
|
|
2
|
+

|
|
3
|
+
|
|
4
|
+
**Lacewing is an opinionated JWT library that makes common JWT security mistakes impossible by construction.**
|
|
5
|
+
|
|
6
|
+
Instead of exposing low-level primitives and trusting you to compose them correctly, Lacewing provides secure verification profiles, safe defaults, mandatory claim validation, built-in revocation support, secure cookie helpers, and a curated algorithm set - everything [RFC 8725 (JWT Best Current Practices)](https://datatracker.ietf.org/doc/html/rfc8725) says an application MUST or SHOULD do is enforced by the type system, enforced at runtime, or impossible to express.
|
|
7
|
+
|
|
8
|
+
**Batteries loaded (RFC 8725).** Out of the box:
|
|
9
|
+
|
|
10
|
+
- **Verification profiles** - the only way to verify; `typ`, issuer, audience, algorithm allowlist and key source are structurally mandatory
|
|
11
|
+
- **Remote JWKS** with caching, rotation handling, and `kid` hygiene
|
|
12
|
+
- **Token revocation** - unique `jti` on every token, pluggable `RevocationStore`, in-memory store included
|
|
13
|
+
- **Secure cookie + bearer helpers** - `HttpOnly; Secure; SameSite` enforced, strict RFC 6750 parsing
|
|
14
|
+
- **Access/refresh token presets** - mutually exclusive by `typ`, shipped rather than left as homework
|
|
15
|
+
- **Encrypted JWTs (JWE)** with the same profile discipline
|
|
16
|
+
- **Payload hygiene scanning** at sign time (passwords, card numbers, PEM keys never leave in plaintext)
|
|
17
|
+
- **Typed errors** with machine-readable codes and no attacker-facing oracle
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Node **>= 24**
|
|
22
|
+
- **ESM** only
|
|
23
|
+
- **TypeScript-first** (full types shipped; the branded `VerifiedJwt`/`UntrustedJwt` types are part of the security model)
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install lacewing
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
The complete lifecycle - generate a key, sign, define a profile once, verify everywhere:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { SignJWT, defineProfile, jwtVerify, generateKeyPair } from "lacewing";
|
|
37
|
+
|
|
38
|
+
const { publicKey, privateKey } = await generateKeyPair(); // EdDSA by default
|
|
39
|
+
|
|
40
|
+
const token = await new SignJWT("at+jwt")
|
|
41
|
+
.issuer("https://auth.example.com")
|
|
42
|
+
.audience("https://api.example.com")
|
|
43
|
+
.subject("user-42")
|
|
44
|
+
.expiresIn("10m")
|
|
45
|
+
.sign(privateKey);
|
|
46
|
+
|
|
47
|
+
const profile = defineProfile({
|
|
48
|
+
typ: "at+jwt",
|
|
49
|
+
issuer: "https://auth.example.com",
|
|
50
|
+
audience: "https://api.example.com",
|
|
51
|
+
algorithms: ["EdDSA"],
|
|
52
|
+
keys: publicKey,
|
|
53
|
+
maxTokenAge: "10m",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const { payload } = await jwtVerify(token, profile); // VerifiedJwt - every check passed
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A runnable version of everything in this README ships with the repo: `npm run demo`.
|
|
60
|
+
|
|
61
|
+
## Concepts
|
|
62
|
+
|
|
63
|
+
**Profiles are the central abstraction.** Configuration is something you create once - not something every endpoint reinvents. A profile names the expected `typ`, the trusted issuer, the required audience, an explicit algorithm allowlist, and the key source bound to that issuer. `jwtVerify(token, profile)` is the *only* verify path: there is no `decode()`, no `ignoreExpiration`, no "accept whatever the header says" mode. If any check fails you get a typed error (`JWTExpired`, `AlgorithmNotAllowed`, `JWTClaimValidationFailed`, ...) and no partial result.
|
|
64
|
+
|
|
65
|
+
**Built on jose.** Lacewing does not reimplement cryptography. It is a hardened policy layer over [jose](https://github.com/panva/jose) - the audited, maintained, runtime-portable JOSE implementation - so the novel code is the policy, not the crypto. Tokens Lacewing signs or encrypts are standard JWTs/JWEs that jose (and any other conforming implementation) can consume, and the conformance suite proves it against the RFC 7515/7516/7519 worked examples plus tokens produced by OpenSSL and python-cryptography.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
### Verify: a profile backed by a remote JWKS
|
|
70
|
+
|
|
71
|
+
This is what real deployments should look like, so it's the first example:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { defineProfile, jwtVerify, createRemoteJWKSet } from "lacewing";
|
|
75
|
+
|
|
76
|
+
const accessToken = defineProfile({
|
|
77
|
+
typ: "at+jwt",
|
|
78
|
+
issuer: "https://auth.example.com",
|
|
79
|
+
audience: "https://api.example.com",
|
|
80
|
+
algorithms: ["EdDSA"],
|
|
81
|
+
keys: createRemoteJWKSet("https://auth.example.com/jwks"), // cached, rate-limited, rotation-aware, HTTPS-only
|
|
82
|
+
maxTokenAge: "10m",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const { payload } = await jwtVerify(token, accessToken); // VerifiedJwt - every check passed
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Sign
|
|
89
|
+
|
|
90
|
+
`typ` is a constructor argument, and `.sign()` refuses to run without
|
|
91
|
+
`issuer`, `audience` and `expiresIn` (waivable only via grep-loud
|
|
92
|
+
`unsafeAllowMissing*` calls). Every token gets a unique `jti`, lifetimes are
|
|
93
|
+
capped (default 1h), and a hygiene scanner rejects payloads that contain
|
|
94
|
+
things like passwords, card numbers, PEM keys, or other JWTs - because **a
|
|
95
|
+
JWS payload is base64url-encoded plaintext, readable by anyone who holds the
|
|
96
|
+
token**. Never put secrets in it.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { SignJWT, generateKeyPair } from "lacewing";
|
|
100
|
+
|
|
101
|
+
const { publicKey, privateKey } = await generateKeyPair(); // EdDSA by default
|
|
102
|
+
|
|
103
|
+
const token = await new SignJWT("at+jwt")
|
|
104
|
+
.issuer("https://auth.example.com")
|
|
105
|
+
.audience("https://api.example.com")
|
|
106
|
+
.subject("user-42")
|
|
107
|
+
.claim("scope", "read")
|
|
108
|
+
.expiresIn("10m")
|
|
109
|
+
.sign(privateKey);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A note on HMAC (`HS256/384/512`): it is supported, but every verifier of an
|
|
113
|
+
HMAC token holds the same secret and can therefore also **mint** them. For
|
|
114
|
+
anything beyond a single service, use asymmetric keys + JWKS. HMAC secrets
|
|
115
|
+
are entropy-checked at import - `"my-secret"` will not import, ever;
|
|
116
|
+
`generateSecret()` gives you a proper one.
|
|
117
|
+
|
|
118
|
+
### Transport: the paved road
|
|
119
|
+
|
|
120
|
+
Do **not** put tokens in `localStorage` or `sessionStorage` - both are
|
|
121
|
+
readable by any script on the page, so one XSS means token theft. Use an
|
|
122
|
+
`HttpOnly` cookie (browsers) or an in-memory bearer token (services):
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { setTokenCookie, readTokenCookie, parseBearer } from "lacewing";
|
|
126
|
+
|
|
127
|
+
setTokenCookie(response.headers, token); // always HttpOnly; Secure; SameSite - weaker is unrepresentable
|
|
128
|
+
const fromCookie = readTokenCookie(request);
|
|
129
|
+
const fromHeader = parseBearer(request); // strict RFC 6750; no query-string tokens, ever
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Access vs refresh (the cookbook)
|
|
133
|
+
|
|
134
|
+
The single most common token-confusion bug is letting a refresh token buy API
|
|
135
|
+
access, or an access token mint new sessions. Lacewing **ships** the two
|
|
136
|
+
profiles rather than leaving them as homework - they are mutually exclusive by
|
|
137
|
+
`typ` (RFC 8725 §3.12), so presenting one where the other is expected fails,
|
|
138
|
+
even with identical keys, claims and audience:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { accessTokenProfile, refreshTokenProfile, newAccessToken } from "lacewing";
|
|
142
|
+
|
|
143
|
+
const api = accessTokenProfile({ // typ: "at+jwt", 10m default cap
|
|
144
|
+
issuer: "https://auth.example.com",
|
|
145
|
+
audience: "https://api.example.com", // the API
|
|
146
|
+
algorithms: ["EdDSA"],
|
|
147
|
+
keys: { jwksUri: "https://auth.example.com/jwks" },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const refresh = refreshTokenProfile({ // typ: "rt+jwt", 30d default cap
|
|
151
|
+
issuer: "https://auth.example.com",
|
|
152
|
+
audience: "https://auth.example.com/token", // the auth server, never the API
|
|
153
|
+
algorithms: ["EdDSA"],
|
|
154
|
+
keys: privateJwks,
|
|
155
|
+
revocation, // long-lived tokens must be revocable
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const token = await newAccessToken()
|
|
159
|
+
.issuer("https://auth.example.com")
|
|
160
|
+
.audience("https://api.example.com")
|
|
161
|
+
.subject("user-42")
|
|
162
|
+
.expiresIn("10m")
|
|
163
|
+
.sign(privateKey);
|
|
164
|
+
|
|
165
|
+
await jwtVerify(token, refresh); // -> JWTClaimValidationFailed (wrong typ)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Revocation is built-in, not homework
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { MemoryRevocationStore } from "lacewing";
|
|
172
|
+
|
|
173
|
+
const revocation = new MemoryRevocationStore(); // or your Redis/DB adapter (RevocationStore)
|
|
174
|
+
const profile = defineProfile({ /* ...as above... */ revocation });
|
|
175
|
+
|
|
176
|
+
revocation.revoke(payload.jti, payload.exp); // e.g. on logout
|
|
177
|
+
await jwtVerify(token, profile); // -> JWTRevoked
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The store is consulted only *after* signature and claims pass, and store
|
|
181
|
+
errors fail closed.
|
|
182
|
+
|
|
183
|
+
## Advanced
|
|
184
|
+
|
|
185
|
+
### Revocation is not replay protection
|
|
186
|
+
|
|
187
|
+
Be precise about what revocation buys you: it lets you kill a
|
|
188
|
+
token you *know about* (logout, compromise). It does **not** stop replay - a
|
|
189
|
+
valid, non-revoked token that leaks can be replayed by anyone who holds it
|
|
190
|
+
until `exp`. That is the deliberate cost of stateless verification, and no
|
|
191
|
+
JWT library can remove it without becoming a session store.
|
|
192
|
+
|
|
193
|
+
Your levers, in order of cheapness:
|
|
194
|
+
|
|
195
|
+
- **Short lifetimes.** The default caps (10m access tokens) exist exactly to
|
|
196
|
+
shrink the replay window. Prefer shortening `exp` over building state.
|
|
197
|
+
- **Revocation on every logout/refresh**, so a stolen token dies with the
|
|
198
|
+
session it came from.
|
|
199
|
+
- **A jti-seen cache** when an endpoint must be strictly once-only (e.g. a
|
|
200
|
+
password-reset action token): every Lacewing token carries a unique `jti`,
|
|
201
|
+
so you can reject repeats with a `claimValidators` entry backed by a
|
|
202
|
+
store with the token's remaining TTL:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
const profile = defineProfile({
|
|
206
|
+
/* ...as above... */
|
|
207
|
+
claimValidators: {
|
|
208
|
+
jti: async (jti) => {
|
|
209
|
+
// setnx-style: returns false if the jti was already seen
|
|
210
|
+
if (!(await seenCache.addIfAbsent(String(jti), remainingTtl))) {
|
|
211
|
+
throw new Error("token replayed");
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
This is intentionally not built in: a once-only cache is a distributed-state
|
|
219
|
+
decision (which store, which TTL, what happens when it's down) that your
|
|
220
|
+
deployment has to own, not something a library should silently half-do.
|
|
221
|
+
- **Proof-of-possession** (mTLS-bound or DPoP-bound tokens) when replay by a
|
|
222
|
+
network eavesdropper is in your threat model. That is beyond Lacewing's
|
|
223
|
+
bearer-token scope today.
|
|
224
|
+
|
|
225
|
+
### Encrypting (JWE): when the payload really is a secret
|
|
226
|
+
|
|
227
|
+
A signed JWT is readable plaintext. When you genuinely need the payload
|
|
228
|
+
*hidden* - not just tamper-evident - use an encrypted JWT. Same discipline as
|
|
229
|
+
signing (mandatory `typ`, required `iss`/`aud`/`exp`, unique `jti`, capped
|
|
230
|
+
lifetime), but with a curated set of encryption algorithms and no `none`. The
|
|
231
|
+
sign-time hygiene scanner is deliberately off here - hiding secrets is the
|
|
232
|
+
whole point:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import {
|
|
236
|
+
EncryptJWT, jwtDecrypt, defineDecryptionProfile, generateEncryptionKeyPair,
|
|
237
|
+
} from "lacewing";
|
|
238
|
+
|
|
239
|
+
const { publicKey, privateKey } = await generateEncryptionKeyPair("ECDH-ES+A256KW");
|
|
240
|
+
|
|
241
|
+
const token = await new EncryptJWT("at+jwt", { contentEncryption: "A256GCM" })
|
|
242
|
+
.issuer("https://auth.example.com")
|
|
243
|
+
.audience("https://api.example.com")
|
|
244
|
+
.subject("user-42")
|
|
245
|
+
.claim("ssn", "123-45-6789") // fine - it's encrypted, not just encoded
|
|
246
|
+
.expiresIn("10m")
|
|
247
|
+
.encrypt(publicKey);
|
|
248
|
+
|
|
249
|
+
const profile = defineDecryptionProfile({
|
|
250
|
+
typ: "at+jwt",
|
|
251
|
+
issuer: "https://auth.example.com",
|
|
252
|
+
audience: "https://api.example.com",
|
|
253
|
+
keyManagementAlgorithms: ["ECDH-ES+A256KW"], // the header never chooses
|
|
254
|
+
contentEncryptionAlgorithms: ["A256GCM"],
|
|
255
|
+
key: privateKey,
|
|
256
|
+
maxTokenAge: "10m",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const { payload } = await jwtDecrypt(token, profile); // DecryptedJwt - distinct from VerifiedJwt
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`none`, `RSA1_5`, `RSA-OAEP` (SHA-1) and the password-based `PBES2*` family are
|
|
263
|
+
absent by construction. A JWS handed to `jwtDecrypt` (or a JWE handed to
|
|
264
|
+
`jwtVerify`) is rejected - the two formats never cross over.
|
|
265
|
+
|
|
266
|
+
**Portability caveat - 192-bit AES:** the registry includes the `A192*`
|
|
267
|
+
algorithms (`A192KW`, `A192GCMKW`, `A192GCM`, `A192CBC-HS384`) for JOSE
|
|
268
|
+
completeness, and they work on Node ≥ 24 (Lacewing's floor). But WebCrypto
|
|
269
|
+
implementations in browsers and some edge runtimes do not implement 192-bit
|
|
270
|
+
AES at all. If tokens must be decrypted outside Node, stick to the `A128*` /
|
|
271
|
+
`A256*` variants - there is no security reason to prefer 192-bit anyway.
|
|
272
|
+
|
|
273
|
+
### Debugging
|
|
274
|
+
|
|
275
|
+
`unsafeDecode(token)` parses without verifying and returns an `UntrustedJwt`
|
|
276
|
+
that is type-incompatible with `VerifiedJwt` - useful for inspecting expired
|
|
277
|
+
tokens, useless (by design) for auth logic.
|
|
278
|
+
|
|
279
|
+
### Legacy interop
|
|
280
|
+
|
|
281
|
+
The `RS*` (RSASSA-PKCS1-v1_5) family exists only behind an explicit import, for
|
|
282
|
+
verifying tokens from issuers you don't control and can't move off PKCS#1 v1.5:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
import { enableLegacyRS256 } from "lacewing/legacy/rs256";
|
|
286
|
+
enableLegacyRS256(); // grep for this in code review
|
|
287
|
+
|
|
288
|
+
// Or, for an issuer that rotates across hash sizes:
|
|
289
|
+
import { enableLegacyRSA } from "lacewing/legacy/rsa"; // RS256 + RS384 + RS512
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Prefer `PS256`/`EdDSA` everywhere you control both sides.
|
|
293
|
+
|
|
294
|
+
## Why Lacewing exists
|
|
295
|
+
|
|
296
|
+
Lacewing is not trying to compete with general-purpose JWT libraries. It started as the hardened setup I kept rebuilding for my own projects, packaged so I'd stop rebuilding it.
|
|
297
|
+
|
|
298
|
+
The other reason is irritation. Generic JWT libraries are fine, but they leave enough rope for misinformed developers (and lazy AI slop) to ship something unsafe. It pisses me off when people implement something genuinely safe wrongly and then blame the technology instead of accepting operator error. These are the mistakes Lacewing makes unrepresentable or loudly explicit:
|
|
299
|
+
|
|
300
|
+
- Decoding instead of verifying
|
|
301
|
+
- Accepting `"none"` for the algorithm
|
|
302
|
+
- No explicit allowlist on the server that enforces expected algorithms
|
|
303
|
+
- Putting sensitive data in the token payload
|
|
304
|
+
- No way to revoke stateless JWTs
|
|
305
|
+
- Storing tokens in `localStorage` or `sessionStorage`
|
|
306
|
+
- Not configuring `iss` or `aud`
|
|
307
|
+
- Over-caching a JWKS when an endpoint goes down
|
|
308
|
+
|
|
309
|
+
Insecure usage is still *possible* - but only unmistakably deliberate, through `unsafe*`-prefixed escape hatches that are easy to grep for in code review. Secure usage is the path of least resistance.
|
|
310
|
+
|
|
311
|
+
## Attribution
|
|
312
|
+
|
|
313
|
+
Lacewing depends on and includes code adapted from the [jose](https://github.com/panva/jose) library, created by Filip Skokan (panva). We are grateful for their work!
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lacewing - an opinionated JWT library where RFC 8725 (JWT Best Current
|
|
3
|
+
* Practices) is the default behavior, not an optional configuration.
|
|
4
|
+
*
|
|
5
|
+
* This barrel is the public API. Internals under `src/lib/` are policy
|
|
6
|
+
* machinery and stay private; legacy interop algorithms live behind the
|
|
7
|
+
* explicit `lacewing/legacy/*` imports.
|
|
8
|
+
*/
|
|
9
|
+
// Verification profiles + the one verification path
|
|
10
|
+
export { defineProfile } from "./src/jwt/profile.js";
|
|
11
|
+
export { jwtVerify } from "./src/jwt/verify.js";
|
|
12
|
+
// Signing
|
|
13
|
+
export { SignJWT } from "./src/jwt/sign.js";
|
|
14
|
+
// Encryption (JWE) - same profile discipline as sign/verify
|
|
15
|
+
export { EncryptJWT } from "./src/jwt/encrypt.js";
|
|
16
|
+
export { jwtDecrypt } from "./src/jwt/decrypt.js";
|
|
17
|
+
export { defineDecryptionProfile, } from "./src/jwt/decryption_profile.js";
|
|
18
|
+
export { generateEncryptionKeyPair, generateEncryptionSecret, generateDirectKey, importEncryptionKey, } from "./src/key/encryption.js";
|
|
19
|
+
// Cookbook: the access-vs-refresh split, shipped rather than left as homework
|
|
20
|
+
export { accessTokenProfile, refreshTokenProfile, newAccessToken, newRefreshToken, ACCESS_TOKEN_TYP, REFRESH_TOKEN_TYP, } from "./src/cookbook/access-refresh.js";
|
|
21
|
+
// Keys
|
|
22
|
+
export { importKey } from "./src/key/import.js";
|
|
23
|
+
export { generateKeyPair, generateSecret, } from "./src/key/generate.js";
|
|
24
|
+
export { exportKeyJWK, exportKeyPEM } from "./src/key/export.js";
|
|
25
|
+
// JWKS key sources
|
|
26
|
+
export { createLocalJWKSet } from "./src/jwks/local.js";
|
|
27
|
+
export { createRemoteJWKSet } from "./src/jwks/remote.js";
|
|
28
|
+
// Revocation
|
|
29
|
+
export { buildRevocationContext } from "./src/revocation/store.js";
|
|
30
|
+
export { MemoryRevocationStore } from "./src/revocation/memory.js";
|
|
31
|
+
// HTTP transport helpers (the paved road: no localStorage, ever)
|
|
32
|
+
export { buildTokenCookie, setTokenCookie, clearTokenCookie, readTokenCookie, } from "./src/http/cookies.js";
|
|
33
|
+
export { parseBearer } from "./src/http/bearer.js";
|
|
34
|
+
// Debugging escape hatch - branded untrusted, incompatible with VerifiedJwt
|
|
35
|
+
export { unsafeDecode } from "./src/util/unsafe-decode.js";
|
|
36
|
+
// Typed errors
|
|
37
|
+
export { JWTError, JWTInvalid, JWTExpired, JWTClaimValidationFailed, MissingClaim, AlgorithmNotAllowed, KeyImportFailed, KeyExportFailed, KeyTypeMismatch, EntropyCheckFailed, JWKSFetchFailed, JWKSNoMatchingKey, JWTRevoked, RevocationCheckFailed, PayloadHygieneViolation, MaxLifetimeExceeded, BearerParseFailed, } from "./src/util/errors.js";
|
|
38
|
+
// Public types
|
|
39
|
+
export { toSeconds, } from "./src/types.js";
|
|
40
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,oDAAoD;AACpD,OAAO,EAAE,aAAa,EAAuB,MAAM,sBAAsB,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAEhD,UAAU;AACV,OAAO,EAAE,OAAO,EAAuB,MAAM,mBAAmB,CAAC;AAEjE,4DAA4D;AAC5D,OAAO,EAAE,UAAU,EAA0B,MAAM,sBAAsB,CAAC;AAC1E,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAClD,OAAO,EACN,uBAAuB,GAEvB,MAAM,iCAAiC,CAAC;AACzC,OAAO,EACN,yBAAyB,EACzB,wBAAwB,EACxB,iBAAiB,EACjB,mBAAmB,GAGnB,MAAM,yBAAyB,CAAC;AAEjC,8EAA8E;AAC9E,OAAO,EACN,kBAAkB,EAClB,mBAAmB,EACnB,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAEjB,MAAM,kCAAkC,CAAC;AAE1C,OAAO;AACP,OAAO,EAAE,SAAS,EAAoB,MAAM,qBAAqB,CAAC;AAClE,OAAO,EACN,eAAe,EACf,cAAc,GAEd,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAEjE,mBAAmB;AACnB,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAA4B,MAAM,sBAAsB,CAAC;AAEpF,aAAa;AACb,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAEnE,iEAAiE;AACjE,OAAO,EACN,gBAAgB,EAChB,cAAc,EACd,gBAAgB,EAChB,eAAe,GAEf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEnD,4EAA4E;AAC5E,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAE3D,eAAe;AACf,OAAO,EACN,QAAQ,EACR,UAAU,EACV,UAAU,EACV,wBAAwB,EACxB,YAAY,EACZ,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,eAAe,EACf,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,iBAAiB,GACjB,MAAM,sBAAsB,CAAC;AAE9B,eAAe;AACf,OAAO,EACN,SAAS,GAuBT,MAAM,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookbook: access tokens vs refresh tokens (LW-kind.1).
|
|
3
|
+
*
|
|
4
|
+
* The single most common token-confusion bug is letting a refresh token buy
|
|
5
|
+
* access to an API (or letting a stolen access token mint new sessions). RFC
|
|
6
|
+
* 8725 §3.12 says the validation rules for different kinds of JWT must be
|
|
7
|
+
* mutually exclusive; Lacewing makes that concrete by shipping the two
|
|
8
|
+
* profiles rather than leaving them as an exercise.
|
|
9
|
+
*
|
|
10
|
+
* The separation is enforced on three axes at once:
|
|
11
|
+
*
|
|
12
|
+
* | | access | refresh |
|
|
13
|
+
* |---|---|---|
|
|
14
|
+
* | `typ` | `at+jwt` | `rt+jwt` |
|
|
15
|
+
* | audience | the API | the auth server's token endpoint |
|
|
16
|
+
* | lifetime | minutes (default 10m) | days (default 30d) |
|
|
17
|
+
* | key source | usually a public JWKS | usually a private, server-only key |
|
|
18
|
+
* | revocation | optional | **strongly recommended** |
|
|
19
|
+
*
|
|
20
|
+
* `typ` alone is load-bearing: even with identical keys, claims and audience,
|
|
21
|
+
* each profile refuses the other's tokens. The rest is defense in depth.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { accessTokenProfile, refreshTokenProfile, newAccessToken } from "lacewing";
|
|
26
|
+
*
|
|
27
|
+
* const api = accessTokenProfile({
|
|
28
|
+
* issuer: "https://auth.example.com",
|
|
29
|
+
* audience: "https://api.example.com",
|
|
30
|
+
* algorithms: ["EdDSA"],
|
|
31
|
+
* keys: { jwksUri: "https://auth.example.com/jwks" },
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* const token = await newAccessToken()
|
|
35
|
+
* .issuer("https://auth.example.com")
|
|
36
|
+
* .audience("https://api.example.com")
|
|
37
|
+
* .subject("user-42")
|
|
38
|
+
* .expiresIn("10m")
|
|
39
|
+
* .sign(privateKey);
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
import { defineProfile } from "../jwt/profile.js";
|
|
43
|
+
import { SignJWT } from "../jwt/sign.js";
|
|
44
|
+
/** The `typ` of an OAuth 2.0 JWT access token (RFC 9068). */
|
|
45
|
+
export const ACCESS_TOKEN_TYP = "at+jwt";
|
|
46
|
+
/** The `typ` Lacewing uses for refresh tokens - anything but the access `typ`. */
|
|
47
|
+
export const REFRESH_TOKEN_TYP = "rt+jwt";
|
|
48
|
+
const DEFAULT_ACCESS_MAX_AGE = "10m";
|
|
49
|
+
const DEFAULT_REFRESH_MAX_AGE = "30d";
|
|
50
|
+
/**
|
|
51
|
+
* An access-token profile: short-lived, audience-scoped to your API, and
|
|
52
|
+
* pinned to `typ: "at+jwt"`. Verifies tokens presented by clients.
|
|
53
|
+
*/
|
|
54
|
+
export function accessTokenProfile(options) {
|
|
55
|
+
return defineProfile({
|
|
56
|
+
...options,
|
|
57
|
+
maxTokenAge: options.maxTokenAge ?? DEFAULT_ACCESS_MAX_AGE,
|
|
58
|
+
typ: ACCESS_TOKEN_TYP,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A refresh-token profile: long-lived, audience-scoped to the *auth server*
|
|
63
|
+
* (never the API), and pinned to `typ: "rt+jwt"`. Pass a `revocation` store -
|
|
64
|
+
* a long-lived token you cannot revoke is a long-lived incident.
|
|
65
|
+
*/
|
|
66
|
+
export function refreshTokenProfile(options) {
|
|
67
|
+
return defineProfile({
|
|
68
|
+
...options,
|
|
69
|
+
maxTokenAge: options.maxTokenAge ?? DEFAULT_REFRESH_MAX_AGE,
|
|
70
|
+
typ: REFRESH_TOKEN_TYP,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** A {@link SignJWT} builder pre-set to the access-token `typ` and a 1h cap. */
|
|
74
|
+
export function newAccessToken() {
|
|
75
|
+
return new SignJWT(ACCESS_TOKEN_TYP, { maxLifetime: "1h" });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A {@link SignJWT} builder pre-set to the refresh-token `typ`. The cap is
|
|
79
|
+
* raised to 90d because that is the point of a refresh token - but the
|
|
80
|
+
* lifetime you actually pass to `.expiresIn()` should be as short as your UX
|
|
81
|
+
* tolerates, and the token should be revocable.
|
|
82
|
+
*/
|
|
83
|
+
export function newRefreshToken() {
|
|
84
|
+
return new SignJWT(REFRESH_TOKEN_TYP, { maxLifetime: "90d" });
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=access-refresh.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access-refresh.js","sourceRoot":"","sources":["../../../src/cookbook/access-refresh.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,EAAE,aAAa,EAAuB,MAAM,mBAAmB,CAAC;AACvE,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAGzC,6DAA6D;AAC7D,MAAM,CAAC,MAAM,gBAAgB,GAAG,QAAQ,CAAC;AACzC,kFAAkF;AAClF,MAAM,CAAC,MAAM,iBAAiB,GAAG,QAAQ,CAAC;AAE1C,MAAM,sBAAsB,GAAG,KAAK,CAAC;AACrC,MAAM,uBAAuB,GAAG,KAAK,CAAC;AAWtC;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAA4B;IAC9D,OAAO,aAAa,CAAC;QACpB,GAAG,OAAO;QACV,WAAW,EAAE,OAAO,CAAC,WAAW,IAAI,sBAAsB;QAC1D,GAAG,EAAE,gBAAgB;KACrB,CAAC,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAA4B;IAC/D,OAAO,aAAa,CAAC;QACpB,GAAG,OAAO;QACV,WAAW,EAAE,OAAO,CAAC,WAAW,IAAI,uBAAuB;QAC3D,GAAG,EAAE,iBAAiB;KACtB,CAAC,CAAC;AACJ,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,cAAc;IAC7B,OAAO,IAAI,OAAO,CAAC,gBAAgB,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;AAC7D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe;IAC9B,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC,CAAC;AAC/D,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict `Authorization: Bearer` parsing (RFC 6750 §2.1, LW-http.2).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately unsupported: tokens in query strings (they leak through
|
|
5
|
+
* logs and the Referer header), multiple tokens, alternate casings and
|
|
6
|
+
* whitespace tricks. One header, one scheme, one token.
|
|
7
|
+
*/
|
|
8
|
+
import { BearerParseFailed } from "../util/errors.js";
|
|
9
|
+
// RFC 6750 credentials: exact-case scheme, a single SP, one token68.
|
|
10
|
+
const BEARER = /^Bearer ([A-Za-z0-9\-._~+/]+=*)$/;
|
|
11
|
+
const MAX_HEADER_LENGTH = 16384;
|
|
12
|
+
/**
|
|
13
|
+
* Extract the bearer token from an `Authorization` header. Accepts a
|
|
14
|
+
* `Headers` object, anything with a `.headers` (e.g. `Request`), or the
|
|
15
|
+
* raw header value. Throws {@link BearerParseFailed} on anything that
|
|
16
|
+
* isn't exactly one well-formed `Bearer <token>`.
|
|
17
|
+
*
|
|
18
|
+
* Note: when multiple `Authorization` headers were sent, `Headers`
|
|
19
|
+
* joins them with `", "` - which this parser rejects, by design.
|
|
20
|
+
*/
|
|
21
|
+
export function parseBearer(source) {
|
|
22
|
+
let value;
|
|
23
|
+
if (typeof source === "string") {
|
|
24
|
+
value = source;
|
|
25
|
+
}
|
|
26
|
+
else if (source instanceof Headers) {
|
|
27
|
+
value = source.get("authorization");
|
|
28
|
+
}
|
|
29
|
+
else if (typeof source === "object" && source !== null && source.headers instanceof Headers) {
|
|
30
|
+
value = source.headers.get("authorization");
|
|
31
|
+
}
|
|
32
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
33
|
+
throw new BearerParseFailed("Missing Authorization header");
|
|
34
|
+
}
|
|
35
|
+
if (value.length > MAX_HEADER_LENGTH) {
|
|
36
|
+
throw new BearerParseFailed("Authorization header exceeds the maximum length");
|
|
37
|
+
}
|
|
38
|
+
const match = BEARER.exec(value);
|
|
39
|
+
if (match === null) {
|
|
40
|
+
throw new BearerParseFailed("Authorization header failed strict Bearer parsing (expected exactly \"Bearer <token>\")");
|
|
41
|
+
}
|
|
42
|
+
return match[1];
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=bearer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bearer.js","sourceRoot":"","sources":["../../../src/http/bearer.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEtD,qEAAqE;AACrE,MAAM,MAAM,GAAG,kCAAkC,CAAC;AAClD,MAAM,iBAAiB,GAAG,KAAK,CAAC;AAEhC;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAC1B,MAAkE;IAElE,IAAI,KAAgC,CAAC;IACrC,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAChC,KAAK,GAAG,MAAM,CAAC;IAChB,CAAC;SAAM,IAAI,MAAM,YAAY,OAAO,EAAE,CAAC;QACtC,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IACrC,CAAC;SAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,CAAC,OAAO,YAAY,OAAO,EAAE,CAAC;QAC/F,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACrD,MAAM,IAAI,iBAAiB,CAAC,8BAA8B,CAAC,CAAC;IAC7D,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,GAAG,iBAAiB,EAAE,CAAC;QACtC,MAAM,IAAI,iBAAiB,CAAC,iDAAiD,CAAC,CAAC;IAChF,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACjC,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACpB,MAAM,IAAI,iBAAiB,CAC1B,yFAAyF,CACzF,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC,CAAC,CAAW,CAAC;AAC3B,CAAC"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie transport helpers (LW-http.1).
|
|
3
|
+
*
|
|
4
|
+
* The paved road for browser token storage: `HttpOnly; Secure; SameSite`
|
|
5
|
+
* are always emitted and cannot be configured away - weaker cookies are
|
|
6
|
+
* unrepresentable. `localStorage`/`sessionStorage` are script-readable
|
|
7
|
+
* and therefore one XSS away from token theft; don't put tokens there.
|
|
8
|
+
*
|
|
9
|
+
* Framework-agnostic: works on WHATWG `Headers` (and anything exposing
|
|
10
|
+
* them, e.g. `Request`/`Response`).
|
|
11
|
+
*/
|
|
12
|
+
// RFC 6265 cookie-name token; value charset covers compact JWTs.
|
|
13
|
+
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/;
|
|
14
|
+
const COOKIE_VALUE = /^[A-Za-z0-9._~+/=-]*$/;
|
|
15
|
+
const DEFAULT_COOKIE_NAME = "__Host-token";
|
|
16
|
+
/** Build a hardened `Set-Cookie` value. Prefer {@link setTokenCookie}. */
|
|
17
|
+
export function buildTokenCookie(token, options = {}) {
|
|
18
|
+
if (typeof token !== "string" || !COOKIE_VALUE.test(token) || token.length === 0) {
|
|
19
|
+
throw new TypeError("Token contains characters that cannot be stored in a cookie");
|
|
20
|
+
}
|
|
21
|
+
const name = options.name ?? DEFAULT_COOKIE_NAME;
|
|
22
|
+
if (!COOKIE_NAME.test(name)) {
|
|
23
|
+
throw new TypeError("Invalid cookie name");
|
|
24
|
+
}
|
|
25
|
+
const sameSite = options.sameSite ?? "Lax";
|
|
26
|
+
if (sameSite !== "Lax" && sameSite !== "Strict") {
|
|
27
|
+
throw new TypeError("SameSite must be \"Lax\" or \"Strict\" - \"None\" is not supported");
|
|
28
|
+
}
|
|
29
|
+
const path = options.path ?? "/";
|
|
30
|
+
if (path.includes(";") || !path.startsWith("/")) {
|
|
31
|
+
throw new TypeError("Invalid cookie path");
|
|
32
|
+
}
|
|
33
|
+
if (name.startsWith("__Host-")) {
|
|
34
|
+
if (options.domain !== undefined) {
|
|
35
|
+
throw new TypeError("__Host- cookies must not set a Domain");
|
|
36
|
+
}
|
|
37
|
+
if (path !== "/") {
|
|
38
|
+
throw new TypeError("__Host- cookies require Path=/");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const parts = [`${name}=${token}`, `Path=${path}`];
|
|
42
|
+
if (options.domain !== undefined) {
|
|
43
|
+
if (!/^[A-Za-z0-9.-]+$/.test(options.domain)) {
|
|
44
|
+
throw new TypeError("Invalid cookie domain");
|
|
45
|
+
}
|
|
46
|
+
parts.push(`Domain=${options.domain}`);
|
|
47
|
+
}
|
|
48
|
+
if (options.maxAgeSeconds !== undefined) {
|
|
49
|
+
if (!Number.isInteger(options.maxAgeSeconds) || options.maxAgeSeconds < 0) {
|
|
50
|
+
throw new TypeError("maxAgeSeconds must be a non-negative integer");
|
|
51
|
+
}
|
|
52
|
+
parts.push(`Max-Age=${options.maxAgeSeconds}`);
|
|
53
|
+
}
|
|
54
|
+
// Non-negotiable (LW-http.1). There is no option to remove these.
|
|
55
|
+
parts.push("HttpOnly", "Secure", `SameSite=${sameSite}`);
|
|
56
|
+
return parts.join("; ");
|
|
57
|
+
}
|
|
58
|
+
/** Append a hardened token cookie to a response's headers. */
|
|
59
|
+
export function setTokenCookie(headers, token, options = {}) {
|
|
60
|
+
headers.append("Set-Cookie", buildTokenCookie(token, options));
|
|
61
|
+
}
|
|
62
|
+
/** Expire a previously set token cookie. */
|
|
63
|
+
export function clearTokenCookie(headers, options = {}) {
|
|
64
|
+
headers.append("Set-Cookie", buildTokenCookie("x", { ...options, maxAgeSeconds: 0 }).replace(/^([^=]+)=x;/, "$1=;"));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Read a token cookie from a request. Accepts a `Headers` object,
|
|
68
|
+
* anything with a `.headers` (e.g. `Request`), or the raw `Cookie`
|
|
69
|
+
* header string. Returns `undefined` when absent or malformed.
|
|
70
|
+
*/
|
|
71
|
+
export function readTokenCookie(source, name = DEFAULT_COOKIE_NAME) {
|
|
72
|
+
let cookieHeader;
|
|
73
|
+
if (typeof source === "string") {
|
|
74
|
+
cookieHeader = source;
|
|
75
|
+
}
|
|
76
|
+
else if (source instanceof Headers) {
|
|
77
|
+
cookieHeader = source.get("cookie");
|
|
78
|
+
}
|
|
79
|
+
else if (typeof source === "object" && source !== null && source.headers instanceof Headers) {
|
|
80
|
+
cookieHeader = source.headers.get("cookie");
|
|
81
|
+
}
|
|
82
|
+
if (typeof cookieHeader !== "string" || cookieHeader.length > 16384) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
for (const pair of cookieHeader.split(";")) {
|
|
86
|
+
const eq = pair.indexOf("=");
|
|
87
|
+
if (eq === -1)
|
|
88
|
+
continue;
|
|
89
|
+
if (pair.slice(0, eq).trim() !== name)
|
|
90
|
+
continue;
|
|
91
|
+
const value = pair.slice(eq + 1).trim();
|
|
92
|
+
return COOKIE_VALUE.test(value) && value.length > 0 ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=cookies.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookies.js","sourceRoot":"","sources":["../../../src/http/cookies.ts"],"names":[],"mappings":"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"}
|