@sdxc/jwt 2026.10.13 → 2026.10.25
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/README.md +55 -5
- package/dist/algorithm.d.ts +4 -3
- package/dist/algorithm.js +4 -3
- package/dist/fixtures/rfc8417.d.ts +105 -0
- package/dist/fixtures/rfc8417.js +113 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/jwk.d.ts +28 -3
- package/dist/jwk.js +35 -9
- package/dist/jwt.d.ts +65 -4
- package/dist/jwt.js +122 -5
- package/dist/security-event-token.d.ts +139 -0
- package/dist/security-event-token.js +187 -0
- package/dist/thumbprint.d.ts +20 -0
- package/dist/thumbprint.js +27 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @sdxc/jwt
|
|
2
2
|
|
|
3
|
-
JWT payload classes and the ES256, RS256 and
|
|
3
|
+
JWT payload classes and the ES256, RS256 and Ed25519 keys that sign them.
|
|
4
4
|
|
|
5
5
|
Each kind of token is a class: the registered claims arrive as typed accessors, a subclass adds
|
|
6
6
|
its own, and signing, verifying and decoding all hand back an instance of that class. The `JWK`
|
|
@@ -15,7 +15,7 @@ npm add @sdxc/jwt
|
|
|
15
15
|
|
|
16
16
|
Signing and verification run on [`jose`](https://www.npmjs.com/package/jose), and duration strings
|
|
17
17
|
for the time claims come from [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration);
|
|
18
|
-
both install with the package. Three entry points: `@sdxc/jwt` (`JWT`, `JWK`, `KeyStorage`),
|
|
18
|
+
both install with the package. Three entry points: `@sdxc/jwt` (`JWT`, `SecurityEventToken`, `JWK`, `KeyStorage`),
|
|
19
19
|
`@sdxc/jwt/algorithm` (the algorithm names alone) and `@sdxc/jwt/r2` (key storage over a
|
|
20
20
|
Cloudflare R2 bucket).
|
|
21
21
|
|
|
@@ -95,6 +95,29 @@ idToken.subject; // "user-123"
|
|
|
95
95
|
idToken.email; // "ada@example.com"
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
### Issue a Security Event Token
|
|
99
|
+
|
|
100
|
+
`SecurityEventToken` is an RFC 8417 SET: typed `secevent+jwt`, with `iss`, `iat`, `jti`, `aud`
|
|
101
|
+
and a map of event URI to payload. `create` fills `jti` and `iat`; `verify` refuses a SET with no
|
|
102
|
+
events or with an `exp`.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { JWK, SecurityEventToken } from "@sdxc/jwt";
|
|
106
|
+
|
|
107
|
+
let set = await SecurityEventToken.create({
|
|
108
|
+
issuer: "https://idp.example.com/",
|
|
109
|
+
audience: "https://rp.example.com/",
|
|
110
|
+
subjectId: { format: "email", email: "user@example.com" },
|
|
111
|
+
events: {
|
|
112
|
+
"https://schemas.openid.net/secevent/risc/event-type/account-disabled": { reason: "hijacking" },
|
|
113
|
+
},
|
|
114
|
+
}).sign(JWK.Algorithm.ES256, keys);
|
|
115
|
+
|
|
116
|
+
let event = await SecurityEventToken.verify(set, issuerKeys, { issuer, audience });
|
|
117
|
+
event.events; // { "https://schemas.openid.net/…/account-disabled": { reason: "hijacking" } }
|
|
118
|
+
event.subjectId; // { format: "email", email: "user@example.com" }
|
|
119
|
+
```
|
|
120
|
+
|
|
98
121
|
## API
|
|
99
122
|
|
|
100
123
|
### `JWT`
|
|
@@ -143,6 +166,12 @@ expiry.
|
|
|
143
166
|
Signs with the first key generated for `algorithm`, writes its id as the header `kid`, and answers
|
|
144
167
|
the compact token. Throws when no key matches. `JWT.sign(jwt, algorithm, jwks)` is the static form.
|
|
145
168
|
|
|
169
|
+
#### `jwt.signEmbedded(keyPair: JWT.EmbeddableKey): Promise<string>`
|
|
170
|
+
|
|
171
|
+
Signs with the pair's own algorithm and carries its public JWK in the header instead of a `kid`,
|
|
172
|
+
as an RFC 9449 DPoP proof does. `JWT.verifyEmbedded(token, options?)` verifies such a token
|
|
173
|
+
against that header key and answers `{ token, jwk }`; a header carrying a private key fails.
|
|
174
|
+
|
|
146
175
|
#### `jwt.toJSON(): Record<string, unknown>`
|
|
147
176
|
|
|
148
177
|
The getters declared on the instance's own class, as a plain object.
|
|
@@ -151,25 +180,41 @@ The getters declared on the instance's own class, as a plain object.
|
|
|
151
180
|
|
|
152
181
|
Checks the signature, `exp`, `nbf` and any `issuer`/`audience` given, and answers an instance of
|
|
153
182
|
the calling class; any failure throws. The key is picked per token by its `kid`, key type and
|
|
154
|
-
algorithm, so a set can hold several keys. Pass `algorithms` to accept only the ones you expect
|
|
183
|
+
algorithm, so a set can hold several keys. Pass `algorithms` to accept only the ones you expect;
|
|
184
|
+
left out, it is every `JWK.Algorithm`, so `none`, HMAC and anything else is refused. `Ed25519`
|
|
185
|
+
also accepts tokens and key sets that use the deprecated `EdDSA` name (RFC 9864).
|
|
186
|
+
|
|
187
|
+
A subclass that sets `static override type = "at+jwt"` signs with that `typ` header, and its
|
|
188
|
+
`verify` refuses a token of any other type (explicit typing, RFC 8725).
|
|
155
189
|
|
|
156
190
|
#### `JWT.decode(token: string): JWT`
|
|
157
191
|
|
|
158
192
|
Reads the claims without verifying them, as an instance of the calling class. Use it to decide how
|
|
159
193
|
to verify (reading `iss` to pick a key set), then `verify`.
|
|
160
194
|
|
|
195
|
+
### `SecurityEventToken`
|
|
196
|
+
|
|
197
|
+
A `JWT` subclass for RFC 8417 Security Event Tokens; subclass it for a SET profile.
|
|
198
|
+
|
|
199
|
+
- `SecurityEventToken.create(input)`: `issuer`, `audience` and `events`, plus optional `id`
|
|
200
|
+
(`jti`), `issuedAt`, `subject`, `subjectId`, `transaction` (`txn`) and `timeOfEvent` (`toe`)
|
|
201
|
+
- `SecurityEventToken.verify(...)`: `JWT.verify` plus RFC 8417's claim rules; a failure throws
|
|
202
|
+
jose's `JWTClaimValidationFailed` naming the claim
|
|
203
|
+
- `events`, `transaction`, `timeOfEvent` and `subjectId` accessors; `subjectId` types RFC 9493's
|
|
204
|
+
`email`, `iss_sub` and `opaque` formats and reads any other format as given
|
|
205
|
+
|
|
161
206
|
### `JWK`
|
|
162
207
|
|
|
163
208
|
#### `JWK.Algorithm`
|
|
164
209
|
|
|
165
210
|
The supported algorithms: `JWK.Algorithm.ES256` (`EC`, what `signingKeys` mints), `.RS256`
|
|
166
|
-
(`RSA`, common among upstream identity providers) and `.
|
|
211
|
+
(`RSA`, common among upstream identity providers) and `.Ed25519` (`OKP`, deterministic nonces). The
|
|
167
212
|
names also ship alone from `@sdxc/jwt/algorithm`:
|
|
168
213
|
|
|
169
214
|
```typescript
|
|
170
215
|
import * as Algorithm from "@sdxc/jwt/algorithm";
|
|
171
216
|
|
|
172
|
-
Object.values(Algorithm); // ["ES256", "RS256", "
|
|
217
|
+
Object.values(Algorithm); // ["ES256", "RS256", "Ed25519"]
|
|
173
218
|
```
|
|
174
219
|
|
|
175
220
|
#### `JWK.generateKeyPair(alg: JWK.Algorithm): Promise<JWK.SerializedKeyPair>`
|
|
@@ -204,6 +249,11 @@ The JWKS document for `/.well-known/jwks.json`: each entry carries `kty`, `kid`,
|
|
|
204
249
|
public parameters (`crv`/`x`/`y` for `EC`, `crv`/`x` for `OKP`, `e`/`n` for `RSA`). Throws on any
|
|
205
250
|
other key type.
|
|
206
251
|
|
|
252
|
+
#### `JWK.thumbprint(jwk: jose.JWK): Promise<Result<string, Error>>`
|
|
253
|
+
|
|
254
|
+
The key's RFC 7638 SHA-256 thumbprint, base64url, which Web Bot Auth uses as `keyid`. A private
|
|
255
|
+
JWK and its public half share one thumbprint; a key missing a required member is a failure.
|
|
256
|
+
|
|
207
257
|
#### Types
|
|
208
258
|
|
|
209
259
|
```typescript
|
package/dist/algorithm.d.ts
CHANGED
|
@@ -19,7 +19,8 @@ export declare const ES256 = "ES256";
|
|
|
19
19
|
/** Verifies tokens from upstream identity providers. */
|
|
20
20
|
export declare const RS256 = "RS256";
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* EdDSA over the Ed25519 curve, under the fully-specified name RFC 9864 registers;
|
|
23
|
+
* its nonce derives from the key and message, keeping the ECDSA nonce-reuse leak
|
|
24
|
+
* that exposes a private key out of reach.
|
|
24
25
|
*/
|
|
25
|
-
export declare const
|
|
26
|
+
export declare const Ed25519 = "Ed25519";
|
package/dist/algorithm.js
CHANGED
|
@@ -19,7 +19,8 @@ export const ES256 = "ES256";
|
|
|
19
19
|
/** Verifies tokens from upstream identity providers. */
|
|
20
20
|
export const RS256 = "RS256";
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* EdDSA over the Ed25519 curve, under the fully-specified name RFC 9864 registers;
|
|
23
|
+
* its nonce derives from the key and message, keeping the ECDSA nonce-reuse leak
|
|
24
|
+
* that exposes a private key out of reach.
|
|
24
25
|
*/
|
|
25
|
-
export const
|
|
26
|
+
export const Ed25519 = "Ed25519";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Event Tokens from RFC 8417's figures, RFC 8935's transmission example and RFC
|
|
3
|
+
* 9493's subject identifiers, as the RFCs print them, so `SecurityEventToken` is checked
|
|
4
|
+
* against the specifications' own examples.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** RFC 8417 Figure 1: a SCIM password reset with an extension event. */
|
|
10
|
+
export declare const SCIM_PASSWORD_RESET: {
|
|
11
|
+
iss: string;
|
|
12
|
+
iat: number;
|
|
13
|
+
jti: string;
|
|
14
|
+
aud: string[];
|
|
15
|
+
sub: string;
|
|
16
|
+
events: {
|
|
17
|
+
"urn:ietf:params:scim:event:passwordReset": {
|
|
18
|
+
id: string;
|
|
19
|
+
};
|
|
20
|
+
"https://example.com/scim/event/passwordResetExt": {
|
|
21
|
+
resetAttempts: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** RFC 8417 Figure 2: an OpenID Back-Channel Logout event with an empty payload. */
|
|
26
|
+
export declare const BACKCHANNEL_LOGOUT: {
|
|
27
|
+
iss: string;
|
|
28
|
+
sub: string;
|
|
29
|
+
aud: string;
|
|
30
|
+
iat: number;
|
|
31
|
+
jti: string;
|
|
32
|
+
sid: string;
|
|
33
|
+
events: {
|
|
34
|
+
"http://schemas.openid.net/event/backchannel-logout": {};
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
/** RFC 8417 Figure 3: a consent event whose payload `iss` names the subject's issuer. */
|
|
38
|
+
export declare const CONSENT: {
|
|
39
|
+
iss: string;
|
|
40
|
+
iat: number;
|
|
41
|
+
jti: string;
|
|
42
|
+
aud: string[];
|
|
43
|
+
events: {
|
|
44
|
+
"https://openid.net/heart/specs/consent.html": {
|
|
45
|
+
iss: string;
|
|
46
|
+
sub: string;
|
|
47
|
+
consentUri: string[];
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/** RFC 8417 Figure 4: a RISC account-disabled event. */
|
|
52
|
+
export declare const RISC_ACCOUNT_DISABLED: {
|
|
53
|
+
iss: string;
|
|
54
|
+
jti: string;
|
|
55
|
+
iat: number;
|
|
56
|
+
aud: string;
|
|
57
|
+
events: {
|
|
58
|
+
"https://schemas.openid.net/secevent/risc/event-type/account-disabled": {
|
|
59
|
+
subject: {
|
|
60
|
+
subject_type: string;
|
|
61
|
+
iss: string;
|
|
62
|
+
sub: string;
|
|
63
|
+
};
|
|
64
|
+
reason: string;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
/** RFC 8417 Figure 5: the SCIM create event Figure 6 encodes. */
|
|
69
|
+
export declare const SCIM_CREATE: {
|
|
70
|
+
iss: string;
|
|
71
|
+
iat: number;
|
|
72
|
+
jti: string;
|
|
73
|
+
aud: string[];
|
|
74
|
+
events: {
|
|
75
|
+
"urn:ietf:params:scim:event:create": {
|
|
76
|
+
ref: string;
|
|
77
|
+
attributes: string[];
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
/** RFC 8417 Figure 6: Figure 5 as an unsecured SET typed `secevent+jwt`. */
|
|
82
|
+
export declare const UNSECURED_SCIM_CREATE: string;
|
|
83
|
+
/** RFC 8935 Figure 1: the HS256 RISC SET a transmitter POSTs, carrying Figure 4's claims. */
|
|
84
|
+
export declare const TRANSMITTED_RISC_EVENT: string;
|
|
85
|
+
/** RFC 9493 Figure 5: an `email` subject identifier. */
|
|
86
|
+
export declare const EMAIL_SUBJECT: {
|
|
87
|
+
readonly format: "email";
|
|
88
|
+
readonly email: "user@example.com";
|
|
89
|
+
};
|
|
90
|
+
/** RFC 9493 Figure 6: an `iss_sub` subject identifier. */
|
|
91
|
+
export declare const ISSUER_SUBJECT: {
|
|
92
|
+
readonly format: "iss_sub";
|
|
93
|
+
readonly iss: "https://issuer.example.com/";
|
|
94
|
+
readonly sub: "145234573";
|
|
95
|
+
};
|
|
96
|
+
/** RFC 9493 Figure 7: an `opaque` subject identifier. */
|
|
97
|
+
export declare const OPAQUE_SUBJECT: {
|
|
98
|
+
readonly format: "opaque";
|
|
99
|
+
readonly id: "11112222333344445555";
|
|
100
|
+
};
|
|
101
|
+
/** RFC 9493 Figure 8: a `phone_number` subject identifier, a format read as given. */
|
|
102
|
+
export declare const PHONE_SUBJECT: {
|
|
103
|
+
readonly format: "phone_number";
|
|
104
|
+
readonly phone_number: "+12065550100";
|
|
105
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Event Tokens from RFC 8417's figures, RFC 8935's transmission example and RFC
|
|
3
|
+
* 9493's subject identifiers, as the RFCs print them, so `SecurityEventToken` is checked
|
|
4
|
+
* against the specifications' own examples.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** RFC 8417 Figure 1: a SCIM password reset with an extension event. */
|
|
10
|
+
export const SCIM_PASSWORD_RESET = {
|
|
11
|
+
iss: "https://scim.example.com",
|
|
12
|
+
iat: 1458496025,
|
|
13
|
+
jti: "3d0c3cf797584bd193bd0fb1bd4e7d30",
|
|
14
|
+
aud: [
|
|
15
|
+
"https://jhub.example.com/Feeds/98d52461fa5bbc879593b7754",
|
|
16
|
+
"https://jhub.example.com/Feeds/5d7604516b1d08641d7676ee7",
|
|
17
|
+
],
|
|
18
|
+
sub: "https://scim.example.com/Users/44f6142df96bd6ab61e7521d9",
|
|
19
|
+
events: {
|
|
20
|
+
"urn:ietf:params:scim:event:passwordReset": { id: "44f6142df96bd6ab61e7521d9" },
|
|
21
|
+
"https://example.com/scim/event/passwordResetExt": { resetAttempts: 5 },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
/** RFC 8417 Figure 2: an OpenID Back-Channel Logout event with an empty payload. */
|
|
25
|
+
export const BACKCHANNEL_LOGOUT = {
|
|
26
|
+
iss: "https://server.example.com",
|
|
27
|
+
sub: "248289761001",
|
|
28
|
+
aud: "s6BhdRkqt3",
|
|
29
|
+
iat: 1471566154,
|
|
30
|
+
jti: "bWJq",
|
|
31
|
+
sid: "08a5019c-17e1-4977-8f42-65a12843ea02",
|
|
32
|
+
events: { "http://schemas.openid.net/event/backchannel-logout": {} },
|
|
33
|
+
};
|
|
34
|
+
/** RFC 8417 Figure 3: a consent event whose payload `iss` names the subject's issuer. */
|
|
35
|
+
export const CONSENT = {
|
|
36
|
+
iss: "https://my.med.example.org",
|
|
37
|
+
iat: 1458496025,
|
|
38
|
+
jti: "fb4e75b5411e4e19b6c0fe87950f7749",
|
|
39
|
+
aud: ["https://rp.example.com"],
|
|
40
|
+
events: {
|
|
41
|
+
"https://openid.net/heart/specs/consent.html": {
|
|
42
|
+
iss: "https://connect.example.com",
|
|
43
|
+
sub: "248289761001",
|
|
44
|
+
consentUri: ["https://terms.med.example.org/labdisclosure.html#Agree"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
/** RFC 8417 Figure 4: a RISC account-disabled event. */
|
|
49
|
+
export const RISC_ACCOUNT_DISABLED = {
|
|
50
|
+
iss: "https://idp.example.com/",
|
|
51
|
+
jti: "756E69717565206964656E746966696572",
|
|
52
|
+
iat: 1508184845,
|
|
53
|
+
aud: "636C69656E745F6964",
|
|
54
|
+
events: {
|
|
55
|
+
"https://schemas.openid.net/secevent/risc/event-type/account-disabled": {
|
|
56
|
+
subject: { subject_type: "iss-sub", iss: "https://idp.example.com/", sub: "7375626A656374" },
|
|
57
|
+
reason: "hijacking",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
/** RFC 8417 Figure 5: the SCIM create event Figure 6 encodes. */
|
|
62
|
+
export const SCIM_CREATE = {
|
|
63
|
+
iss: "https://scim.example.com",
|
|
64
|
+
iat: 1458496404,
|
|
65
|
+
jti: "4d3559ec67504aaba65d40b0363faad8",
|
|
66
|
+
aud: [
|
|
67
|
+
"https://scim.example.com/Feeds/98d52461fa5bbc879593b7754",
|
|
68
|
+
"https://scim.example.com/Feeds/5d7604516b1d08641d7676ee7",
|
|
69
|
+
],
|
|
70
|
+
events: {
|
|
71
|
+
"urn:ietf:params:scim:event:create": {
|
|
72
|
+
ref: "https://scim.example.com/Users/44f6142df96bd6ab61e7521d9",
|
|
73
|
+
attributes: ["id", "name", "userName", "password", "emails"],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
/** RFC 8417 Figure 6: Figure 5 as an unsecured SET typed `secevent+jwt`. */
|
|
78
|
+
export const UNSECURED_SCIM_CREATE = [
|
|
79
|
+
"eyJ0eXAiOiJzZWNldmVudCtqd3QiLCJhbGciOiJub25lIn0",
|
|
80
|
+
"eyJpc3MiOiJodHRwczovL3NjaW0uZXhhbXBsZS5jb20iLCJpYXQiOjE0NTg0OTY0M" +
|
|
81
|
+
"DQsImp0aSI6IjRkMzU1OWVjNjc1MDRhYWJhNjVkNDBiMDM2M2ZhYWQ4IiwiYXVkIj" +
|
|
82
|
+
"pbImh0dHBzOi8vc2NpbS5leGFtcGxlLmNvbS9GZWVkcy85OGQ1MjQ2MWZhNWJiYzg" +
|
|
83
|
+
"3OTU5M2I3NzU0IiwiaHR0cHM6Ly9zY2ltLmV4YW1wbGUuY29tL0ZlZWRzLzVkNzYw" +
|
|
84
|
+
"NDUxNmIxZDA4NjQxZDc2NzZlZTciXSwiZXZlbnRzIjp7InVybjppZXRmOnBhcmFtc" +
|
|
85
|
+
"zpzY2ltOmV2ZW50OmNyZWF0ZSI6eyJyZWYiOiJodHRwczovL3NjaW0uZXhhbXBsZS" +
|
|
86
|
+
"5jb20vVXNlcnMvNDRmNjE0MmRmOTZiZDZhYjYxZTc1MjFkOSIsImF0dHJpYnV0ZXM" +
|
|
87
|
+
"iOlsiaWQiLCJuYW1lIiwidXNlck5hbWUiLCJwYXNzd29yZCIsImVtYWlscyJdfX19",
|
|
88
|
+
"",
|
|
89
|
+
].join(".");
|
|
90
|
+
/** RFC 8935 Figure 1: the HS256 RISC SET a transmitter POSTs, carrying Figure 4's claims. */
|
|
91
|
+
export const TRANSMITTED_RISC_EVENT = [
|
|
92
|
+
"eyJ0eXAiOiJzZWNldmVudCtqd3QiLCJhbGciOiJIUzI1NiJ9Cg",
|
|
93
|
+
"eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbS8iLCJqdGkiOiI3NTZFNjk" +
|
|
94
|
+
"3MTc1NjUyMDY5NjQ2NTZFNzQ2OTY2Njk2NTcyIiwiaWF0IjoxNTA4MTg0ODQ1LC" +
|
|
95
|
+
"JhdWQiOiI2MzZDNjk2NTZFNzQ1RjY5NjQiLCJldmVudHMiOnsiaHR0cHM6Ly9zY" +
|
|
96
|
+
"2hlbWFzLm9wZW5pZC5uZXQvc2VjZXZlbnQvcmlzYy9ldmVudC10eXBlL2FjY291" +
|
|
97
|
+
"bnQtZGlzYWJsZWQiOnsic3ViamVjdCI6eyJzdWJqZWN0X3R5cGUiOiJpc3Mtc3V" +
|
|
98
|
+
"iIiwiaXNzIjoiaHR0cHM6Ly9pZHAuZXhhbXBsZS5jb20vIiwic3ViIjoiNzM3NT" +
|
|
99
|
+
"YyNkE2NTYzNzQifSwicmVhc29uIjoiaGlqYWNraW5nIn19fQ",
|
|
100
|
+
"Y4rXxMD406P2edv00cr9Wf3_XwNtLjB9n-jTqN1_lLc",
|
|
101
|
+
].join(".");
|
|
102
|
+
/** RFC 9493 Figure 5: an `email` subject identifier. */
|
|
103
|
+
export const EMAIL_SUBJECT = { format: "email", email: "user@example.com" };
|
|
104
|
+
/** RFC 9493 Figure 6: an `iss_sub` subject identifier. */
|
|
105
|
+
export const ISSUER_SUBJECT = {
|
|
106
|
+
format: "iss_sub",
|
|
107
|
+
iss: "https://issuer.example.com/",
|
|
108
|
+
sub: "145234573",
|
|
109
|
+
};
|
|
110
|
+
/** RFC 9493 Figure 7: an `opaque` subject identifier. */
|
|
111
|
+
export const OPAQUE_SUBJECT = { format: "opaque", id: "11112222333344445555" };
|
|
112
|
+
/** RFC 9493 Figure 8: a `phone_number` subject identifier, a format read as given. */
|
|
113
|
+
export const PHONE_SUBJECT = { format: "phone_number", phone_number: "+12065550100" };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public surface of `@sdxc/jwt`: the `JWT` payload class
|
|
3
|
-
*
|
|
2
|
+
* Public surface of `@sdxc/jwt`: the `JWT` payload class and its RFC 8417
|
|
3
|
+
* `SecurityEventToken` subclass, the `JWK` key namespace, and the storage contract
|
|
4
|
+
* signing-key rotation is written against.
|
|
4
5
|
*
|
|
5
6
|
* The claim parser behind `JWT`'s accessors stays internal, existing solely for
|
|
6
7
|
* token classes to read their own claims through.
|
|
@@ -11,3 +12,4 @@
|
|
|
11
12
|
export type { KeyStorage, KeyStorageListOptions, KeyStorageListResult } from "./key-storage.js";
|
|
12
13
|
export { JWK } from "./jwk.js";
|
|
13
14
|
export { JWT } from "./jwt.js";
|
|
15
|
+
export { SecurityEventToken } from "./security-event-token.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public surface of `@sdxc/jwt`: the `JWT` payload class
|
|
3
|
-
*
|
|
2
|
+
* Public surface of `@sdxc/jwt`: the `JWT` payload class and its RFC 8417
|
|
3
|
+
* `SecurityEventToken` subclass, the `JWK` key namespace, and the storage contract
|
|
4
|
+
* signing-key rotation is written against.
|
|
4
5
|
*
|
|
5
6
|
* The claim parser behind `JWT`'s accessors stays internal, existing solely for
|
|
6
7
|
* token classes to read their own claims through.
|
|
@@ -10,3 +11,4 @@
|
|
|
10
11
|
*/
|
|
11
12
|
export { JWK } from "./jwk.js";
|
|
12
13
|
export { JWT } from "./jwt.js";
|
|
14
|
+
export { SecurityEventToken } from "./security-event-token.js";
|
package/dist/jwk.d.ts
CHANGED
|
@@ -12,6 +12,21 @@
|
|
|
12
12
|
import * as jose from "jose";
|
|
13
13
|
import type { KeyStorage } from "./key-storage.js";
|
|
14
14
|
import * as SignatureAlgorithm from "./algorithm.js";
|
|
15
|
+
import { thumbprint as jwkThumbprint } from "./thumbprint.js";
|
|
16
|
+
/**
|
|
17
|
+
* The polymorphic name RFC 9864 deprecates in favour of `Ed25519`. Keys and tokens
|
|
18
|
+
* written under it before the rename still import and verify as Ed25519.
|
|
19
|
+
*/
|
|
20
|
+
export declare const LEGACY_EDDSA = "EdDSA";
|
|
21
|
+
/**
|
|
22
|
+
* A key's public half as a header or a key set carries it: `kty` and the members its type
|
|
23
|
+
* publishes, so `kid`, `use` and every private member stay out.
|
|
24
|
+
*
|
|
25
|
+
* @param jwk - A public or private JWK.
|
|
26
|
+
* @returns The public members.
|
|
27
|
+
* @throws When the key type has no published shape on record.
|
|
28
|
+
*/
|
|
29
|
+
export declare function publicJwk(jwk: jose.JWK): jose.JWK;
|
|
15
30
|
/**
|
|
16
31
|
* Everything about the keys a token is signed with or verified against.
|
|
17
32
|
*
|
|
@@ -21,12 +36,19 @@ import * as SignatureAlgorithm from "./algorithm.js";
|
|
|
21
36
|
export declare namespace JWK {
|
|
22
37
|
/**
|
|
23
38
|
* ES256 is issued here; RS256 verifies tokens from upstream identity
|
|
24
|
-
* providers;
|
|
39
|
+
* providers; Ed25519 derives its nonce from the key and message, keeping the
|
|
25
40
|
* ECDSA nonce-reuse leak that exposes a private key out of reach.
|
|
26
41
|
*/
|
|
27
42
|
export const Algorithm: typeof SignatureAlgorithm;
|
|
28
43
|
/** One of the supported signature algorithms. */
|
|
29
44
|
export type Algorithm = (typeof SignatureAlgorithm)[keyof typeof SignatureAlgorithm];
|
|
45
|
+
/**
|
|
46
|
+
* A key's RFC 7638 SHA-256 thumbprint, base64url: the `keyid` Web Bot Auth signs under
|
|
47
|
+
* and the `kid` its key directory publishes. A key pair's halves share one thumbprint.
|
|
48
|
+
*
|
|
49
|
+
* @example let keyid = await JWK.thumbprint(keyPair.jwk);
|
|
50
|
+
*/
|
|
51
|
+
export const thumbprint: typeof jwkThumbprint;
|
|
30
52
|
/** A key pair imported into a usable form, with the public half in JWK format. */
|
|
31
53
|
export interface KeyPair {
|
|
32
54
|
/** Identifier published as the `kid`, and written into every token header. */
|
|
@@ -53,8 +75,11 @@ export declare namespace JWK {
|
|
|
53
75
|
export interface SerializedKeyPair {
|
|
54
76
|
/** Identifier that becomes the `kid`. */
|
|
55
77
|
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
56
|
-
/**
|
|
57
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Algorithm the pair was generated for. A pair stored as `EdDSA`, the name RFC
|
|
80
|
+
* 9864 deprecates, imports as `Ed25519`, so stored Edwards keys keep working.
|
|
81
|
+
*/
|
|
82
|
+
alg: Algorithm | typeof LEGACY_EDDSA;
|
|
58
83
|
/** Public key in SPKI PEM form. */
|
|
59
84
|
publicKey: string;
|
|
60
85
|
/** Private key in PKCS#8 PEM form. */
|
package/dist/jwk.js
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
import { generateUUID } from "@sdxc/uuid/v4";
|
|
13
13
|
import * as jose from "jose";
|
|
14
14
|
import * as SignatureAlgorithm from "./algorithm.js";
|
|
15
|
+
import { thumbprint as jwkThumbprint } from "./thumbprint.js";
|
|
16
|
+
/**
|
|
17
|
+
* The polymorphic name RFC 9864 deprecates in favour of `Ed25519`. Keys and tokens
|
|
18
|
+
* written under it before the rename still import and verify as Ed25519.
|
|
19
|
+
*/
|
|
20
|
+
export const LEGACY_EDDSA = "EdDSA";
|
|
15
21
|
/**
|
|
16
22
|
* Prefix every stored signing key is written under.
|
|
17
23
|
*
|
|
@@ -34,6 +40,21 @@ const PUBLISHED_JWK_FIELDS = {
|
|
|
34
40
|
OKP: ({ crv, x }) => ({ crv, x }),
|
|
35
41
|
RSA: ({ e, n }) => ({ e, n }),
|
|
36
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* A key's public half as a header or a key set carries it: `kty` and the members its type
|
|
45
|
+
* publishes, so `kid`, `use` and every private member stay out.
|
|
46
|
+
*
|
|
47
|
+
* @param jwk - A public or private JWK.
|
|
48
|
+
* @returns The public members.
|
|
49
|
+
* @throws When the key type has no published shape on record.
|
|
50
|
+
*/
|
|
51
|
+
export function publicJwk(jwk) {
|
|
52
|
+
let kty = jwk.kty;
|
|
53
|
+
let publish = kty ? PUBLISHED_JWK_FIELDS[kty] : undefined;
|
|
54
|
+
if (!kty || !publish)
|
|
55
|
+
throw new Error(`Cannot publish a key of type ${String(kty)}`);
|
|
56
|
+
return { kty, ...publish(jwk) };
|
|
57
|
+
}
|
|
37
58
|
/**
|
|
38
59
|
* Everything about the keys a token is signed with or verified against.
|
|
39
60
|
*
|
|
@@ -44,10 +65,17 @@ export var JWK;
|
|
|
44
65
|
(function (JWK) {
|
|
45
66
|
/**
|
|
46
67
|
* ES256 is issued here; RS256 verifies tokens from upstream identity
|
|
47
|
-
* providers;
|
|
68
|
+
* providers; Ed25519 derives its nonce from the key and message, keeping the
|
|
48
69
|
* ECDSA nonce-reuse leak that exposes a private key out of reach.
|
|
49
70
|
*/
|
|
50
71
|
JWK.Algorithm = SignatureAlgorithm;
|
|
72
|
+
/**
|
|
73
|
+
* A key's RFC 7638 SHA-256 thumbprint, base64url: the `keyid` Web Bot Auth signs under
|
|
74
|
+
* and the `kid` its key directory publishes. A key pair's halves share one thumbprint.
|
|
75
|
+
*
|
|
76
|
+
* @example let keyid = await JWK.thumbprint(keyPair.jwk);
|
|
77
|
+
*/
|
|
78
|
+
JWK.thumbprint = jwkThumbprint;
|
|
51
79
|
/**
|
|
52
80
|
* Generates a new key pair in serialized form.
|
|
53
81
|
*
|
|
@@ -83,14 +111,15 @@ export var JWK;
|
|
|
83
111
|
* let keyPair = await JWK.importKeyPair({ id, alg, publicKey, privateKey, created });
|
|
84
112
|
*/
|
|
85
113
|
async function importKeyPair(value) {
|
|
86
|
-
let
|
|
87
|
-
let
|
|
114
|
+
let alg = value.alg === LEGACY_EDDSA ? JWK.Algorithm.Ed25519 : value.alg;
|
|
115
|
+
let publicKey = await jose.importSPKI(value.publicKey, alg, { extractable: true });
|
|
116
|
+
let privateKey = await jose.importPKCS8(value.privateKey, alg);
|
|
88
117
|
let jwk = await jose.exportJWK(publicKey);
|
|
89
118
|
jwk.kid = value.id;
|
|
90
119
|
jwk.use = "sig";
|
|
91
120
|
return {
|
|
92
121
|
id: value.id,
|
|
93
|
-
alg
|
|
122
|
+
alg,
|
|
94
123
|
created: new Date(value.created),
|
|
95
124
|
expired: value.expired,
|
|
96
125
|
public: publicKey,
|
|
@@ -173,11 +202,8 @@ export var JWK;
|
|
|
173
202
|
function toJSON(keys) {
|
|
174
203
|
return {
|
|
175
204
|
keys: keys.map(({ alg, id, jwk }) => {
|
|
176
|
-
let kty = jwk
|
|
177
|
-
|
|
178
|
-
if (!kty || !publish)
|
|
179
|
-
throw new Error(`Cannot publish a key of type ${String(kty)}`);
|
|
180
|
-
return { ...publish(jwk), kty, kid: id, alg };
|
|
205
|
+
let { kty, ...members } = publicJwk(jwk);
|
|
206
|
+
return { ...members, kty: kty, kid: id, alg };
|
|
181
207
|
}),
|
|
182
208
|
};
|
|
183
209
|
}
|
package/dist/jwt.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ import { ObjectParser } from "./lib/parser.js";
|
|
|
32
32
|
* }
|
|
33
33
|
*/
|
|
34
34
|
export declare class JWT implements jose.JWTPayload {
|
|
35
|
+
/**
|
|
36
|
+
* The `typ` header this class signs with. A subclass naming its own media type
|
|
37
|
+
* (`static override type = "at+jwt"`) is explicitly typed per RFC 8725: `verify`
|
|
38
|
+
* on it refuses a token of any other type, so one kind never passes as another.
|
|
39
|
+
*
|
|
40
|
+
* @default "JWT"
|
|
41
|
+
*/
|
|
42
|
+
static type: string;
|
|
35
43
|
/** The raw claim set, readable whole for callers that want the payload itself. */
|
|
36
44
|
readonly payload: JWT.Payload;
|
|
37
45
|
/** Type-checked reads over `payload`, for the accessors a subclass defines. */
|
|
@@ -144,6 +152,17 @@ export declare class JWT implements jose.JWTPayload {
|
|
|
144
152
|
* let signed = await token.sign(JWK.Algorithm.ES256, await JWK.signingKeys(storage));
|
|
145
153
|
*/
|
|
146
154
|
sign(algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>;
|
|
155
|
+
/**
|
|
156
|
+
* Signs with a key pair's own algorithm, carrying the pair's public half in the `jwk`
|
|
157
|
+
* header (RFC 7515 §4.1.3) in place of a `kid`, for a token whose verifier learns the
|
|
158
|
+
* key from the token itself, as with an RFC 9449 DPoP proof.
|
|
159
|
+
*
|
|
160
|
+
* @param keyPair - The pair to sign with; only its public members reach the header.
|
|
161
|
+
* @returns The compact-serialized token.
|
|
162
|
+
* @example
|
|
163
|
+
* let proof = await new DPoPProof({ htm: "POST", htu, iat: 0, jti }).signEmbedded(keyPair);
|
|
164
|
+
*/
|
|
165
|
+
signEmbedded(keyPair: JWT.EmbeddableKey): Promise<string>;
|
|
147
166
|
/**
|
|
148
167
|
* The claims a subclass exposes, as a plain object.
|
|
149
168
|
*
|
|
@@ -157,7 +176,8 @@ export declare class JWT implements jose.JWTPayload {
|
|
|
157
176
|
* Signs a token with the first key matching the algorithm.
|
|
158
177
|
*
|
|
159
178
|
* The keys arrive newest first, so the newest signs while older ones stay
|
|
160
|
-
* published to verify what they already signed, keyed by the `kid` header.
|
|
179
|
+
* published to verify what they already signed, keyed by the `kid` header. The
|
|
180
|
+
* `typ` header is the token class's {@link JWT.type}.
|
|
161
181
|
*
|
|
162
182
|
* @param jwt - The token to sign.
|
|
163
183
|
* @param algorithm - Algorithm to sign with; also selects the key.
|
|
@@ -166,11 +186,33 @@ export declare class JWT implements jose.JWTPayload {
|
|
|
166
186
|
* @throws When no key in the set was generated for that algorithm.
|
|
167
187
|
*/
|
|
168
188
|
static sign(jwt: JWT, algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>;
|
|
189
|
+
/**
|
|
190
|
+
* Signs a token with the key pair's own algorithm and its public JWK in the header.
|
|
191
|
+
*
|
|
192
|
+
* @param jwt - The token to sign.
|
|
193
|
+
* @param keyPair - The pair to sign with.
|
|
194
|
+
* @returns The compact-serialized token.
|
|
195
|
+
*/
|
|
196
|
+
static signEmbedded(jwt: JWT, keyPair: JWT.EmbeddableKey): Promise<string>;
|
|
197
|
+
/**
|
|
198
|
+
* Verifies a token against the public key its own `jwk` header carries, answering the
|
|
199
|
+
* key beside the token so the caller can hold it to a binding, such as the `cnf.jkt`
|
|
200
|
+
* thumbprint a DPoP-bound access token names. A header carrying a private key fails.
|
|
201
|
+
*
|
|
202
|
+
* @param token - The compact-serialized token.
|
|
203
|
+
* @param options - Expected algorithms, clock tolerance, `typ`, and so on.
|
|
204
|
+
* @returns The verified token and the public JWK it was signed under.
|
|
205
|
+
* @throws When the header has no usable public key, or a check on the token fails.
|
|
206
|
+
* @example
|
|
207
|
+
* let { token: proof, jwk } = await DPoPProof.verifyEmbedded(header, { algorithms });
|
|
208
|
+
*/
|
|
209
|
+
static verifyEmbedded<M extends JWT>(this: (new (payload: JWT.Payload) => M) & Pick<typeof JWT, "type">, token: string, options?: JWT.VerifyOptions): Promise<JWT.Embedded<M>>;
|
|
169
210
|
/**
|
|
170
211
|
* Verifies a token and returns it as an instance of the class this was called on.
|
|
171
212
|
*
|
|
172
|
-
* The key is picked by the token's `kid`,
|
|
173
|
-
*
|
|
213
|
+
* The key is picked by the token's `kid`, so a retired key keeps verifying what it
|
|
214
|
+
* signed. `algorithms` defaults to every supported one; `Ed25519` also accepts the
|
|
215
|
+
* deprecated `EdDSA`. An explicitly typed class requires its `typ` by default.
|
|
174
216
|
*
|
|
175
217
|
* @param token - The compact-serialized token.
|
|
176
218
|
* @param jwks - The keys themselves, or a resolver from `JWK.importLocal` /
|
|
@@ -186,7 +228,7 @@ export declare class JWT implements jose.JWTPayload {
|
|
|
186
228
|
* algorithms: [JWK.Algorithm.ES256],
|
|
187
229
|
* });
|
|
188
230
|
*/
|
|
189
|
-
static verify<M extends JWT>(this: new (payload: JWT.Payload) => M, token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<M>;
|
|
231
|
+
static verify<M extends JWT>(this: (new (payload: JWT.Payload) => M) & Pick<typeof JWT, "type">, token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<M>;
|
|
190
232
|
/**
|
|
191
233
|
* Reads a token's claims as the token presents them, prior to verification.
|
|
192
234
|
*
|
|
@@ -214,4 +256,23 @@ export declare namespace JWT {
|
|
|
214
256
|
};
|
|
215
257
|
/** The checks `JWT.verify` can be asked to run beyond the signature. */
|
|
216
258
|
type VerifyOptions = jose.JWTVerifyOptions;
|
|
259
|
+
/** A key pair that signs with its public half embedded: `JWK.KeyPair` satisfies it. */
|
|
260
|
+
interface EmbeddableKey {
|
|
261
|
+
/** Algorithm the pair was generated for, written as the header's `alg`. */
|
|
262
|
+
alg: string;
|
|
263
|
+
/** The private half. */
|
|
264
|
+
private: jose.CryptoKey;
|
|
265
|
+
/** The public half, whose public members become the header's `jwk`. */
|
|
266
|
+
jwk: jose.JWK;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* A token verified against the key its header carried.
|
|
270
|
+
*
|
|
271
|
+
* @template M - The token class it was verified as.
|
|
272
|
+
*/
|
|
273
|
+
interface Embedded<M extends JWT> {
|
|
274
|
+
token: M;
|
|
275
|
+
/** The public members of the key the token was signed under. */
|
|
276
|
+
jwk: jose.JWK;
|
|
277
|
+
}
|
|
217
278
|
}
|
package/dist/jwt.js
CHANGED
|
@@ -12,9 +12,58 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { toSeconds } from "@sdxc/duration";
|
|
14
14
|
import * as jose from "jose";
|
|
15
|
+
import * as SignatureAlgorithm from "./algorithm.js";
|
|
16
|
+
import { LEGACY_EDDSA, publicJwk } from "./jwk.js";
|
|
15
17
|
import { ObjectParser } from "./lib/parser.js";
|
|
16
18
|
/** Milliseconds in a second, for converting between epoch claims and `Date`. */
|
|
17
19
|
const MS_PER_SECOND = 1000;
|
|
20
|
+
/** The `typ` header a token carries when its class names no media type of its own. */
|
|
21
|
+
const DEFAULT_TYPE = "JWT";
|
|
22
|
+
/**
|
|
23
|
+
* Algorithms `verify` accepts when the caller pins none: every supported one plus
|
|
24
|
+
* the deprecated `EdDSA` name, so a token presenting `none`, an HMAC or any other
|
|
25
|
+
* algorithm is refused before its key is looked up.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_ALGORITHMS = [...Object.values(SignatureAlgorithm), LEGACY_EDDSA];
|
|
28
|
+
/**
|
|
29
|
+
* The two names an Ed25519 signature goes by, each mapped to the other. RFC 9864
|
|
30
|
+
* deprecates `EdDSA` for `Ed25519`; tokens and key sets written under either name
|
|
31
|
+
* verify against keys published under the other.
|
|
32
|
+
*/
|
|
33
|
+
const EDWARDS_ALIASES = {
|
|
34
|
+
[SignatureAlgorithm.Ed25519]: LEGACY_EDDSA,
|
|
35
|
+
[LEGACY_EDDSA]: SignatureAlgorithm.Ed25519,
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The accepted algorithms with each Ed25519 name joined by its alias, so pinning
|
|
39
|
+
* `Ed25519` keeps accepting tokens signed before the rename.
|
|
40
|
+
*
|
|
41
|
+
* @param algorithms - What the caller pinned, if anything.
|
|
42
|
+
*/
|
|
43
|
+
function acceptedAlgorithms(algorithms = DEFAULT_ALGORITHMS) {
|
|
44
|
+
let aliases = algorithms.flatMap((alg) => EDWARDS_ALIASES[alg] ?? []);
|
|
45
|
+
return [...new Set([...algorithms, ...aliases])];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Retries a key lookup under a token's other Ed25519 name when the first finds no
|
|
49
|
+
* key, since a key set matches `alg` exactly and one published before or after the
|
|
50
|
+
* rename names the key differently than the token does.
|
|
51
|
+
*
|
|
52
|
+
* @param resolver - The key set the token is verified against.
|
|
53
|
+
*/
|
|
54
|
+
function withEdwardsAliases(resolver) {
|
|
55
|
+
return async (header, token) => {
|
|
56
|
+
try {
|
|
57
|
+
return await resolver(header, token);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
let alias = header.alg === undefined ? undefined : EDWARDS_ALIASES[header.alg];
|
|
61
|
+
if (!alias || !(error instanceof jose.errors.JWKSNoMatchingKey))
|
|
62
|
+
throw error;
|
|
63
|
+
return await resolver({ ...header, alg: alias }, token);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
18
67
|
/** Claims that may be written as a length of time from now. */
|
|
19
68
|
const RELATIVE_CLAIMS = ["exp", "iat", "nbf"];
|
|
20
69
|
/**
|
|
@@ -53,6 +102,14 @@ function resolveRelativeClaims(payload) {
|
|
|
53
102
|
* }
|
|
54
103
|
*/
|
|
55
104
|
export class JWT {
|
|
105
|
+
/**
|
|
106
|
+
* The `typ` header this class signs with. A subclass naming its own media type
|
|
107
|
+
* (`static override type = "at+jwt"`) is explicitly typed per RFC 8725: `verify`
|
|
108
|
+
* on it refuses a token of any other type, so one kind never passes as another.
|
|
109
|
+
*
|
|
110
|
+
* @default "JWT"
|
|
111
|
+
*/
|
|
112
|
+
static type = DEFAULT_TYPE;
|
|
56
113
|
/** The raw claim set, readable whole for callers that want the payload itself. */
|
|
57
114
|
payload;
|
|
58
115
|
/** Type-checked reads over `payload`, for the accessors a subclass defines. */
|
|
@@ -241,6 +298,19 @@ export class JWT {
|
|
|
241
298
|
sign(algorithm, jwks) {
|
|
242
299
|
return JWT.sign(this, algorithm, jwks);
|
|
243
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Signs with a key pair's own algorithm, carrying the pair's public half in the `jwk`
|
|
303
|
+
* header (RFC 7515 §4.1.3) in place of a `kid`, for a token whose verifier learns the
|
|
304
|
+
* key from the token itself, as with an RFC 9449 DPoP proof.
|
|
305
|
+
*
|
|
306
|
+
* @param keyPair - The pair to sign with; only its public members reach the header.
|
|
307
|
+
* @returns The compact-serialized token.
|
|
308
|
+
* @example
|
|
309
|
+
* let proof = await new DPoPProof({ htm: "POST", htu, iat: 0, jti }).signEmbedded(keyPair);
|
|
310
|
+
*/
|
|
311
|
+
signEmbedded(keyPair) {
|
|
312
|
+
return JWT.signEmbedded(this, keyPair);
|
|
313
|
+
}
|
|
244
314
|
/**
|
|
245
315
|
* The claims a subclass exposes, as a plain object.
|
|
246
316
|
*
|
|
@@ -261,7 +331,8 @@ export class JWT {
|
|
|
261
331
|
* Signs a token with the first key matching the algorithm.
|
|
262
332
|
*
|
|
263
333
|
* The keys arrive newest first, so the newest signs while older ones stay
|
|
264
|
-
* published to verify what they already signed, keyed by the `kid` header.
|
|
334
|
+
* published to verify what they already signed, keyed by the `kid` header. The
|
|
335
|
+
* `typ` header is the token class's {@link JWT.type}.
|
|
265
336
|
*
|
|
266
337
|
* @param jwt - The token to sign.
|
|
267
338
|
* @param algorithm - Algorithm to sign with; also selects the key.
|
|
@@ -274,14 +345,56 @@ export class JWT {
|
|
|
274
345
|
if (!key)
|
|
275
346
|
throw new Error(`No key available to sign JWT with algorithm ${algorithm}`);
|
|
276
347
|
return new jose.SignJWT(jwt.payload)
|
|
277
|
-
.setProtectedHeader({
|
|
348
|
+
.setProtectedHeader({
|
|
349
|
+
alg: algorithm,
|
|
350
|
+
typ: jwt.constructor.type,
|
|
351
|
+
kid: key.id,
|
|
352
|
+
})
|
|
278
353
|
.sign(key.private);
|
|
279
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Signs a token with the key pair's own algorithm and its public JWK in the header.
|
|
357
|
+
*
|
|
358
|
+
* @param jwt - The token to sign.
|
|
359
|
+
* @param keyPair - The pair to sign with.
|
|
360
|
+
* @returns The compact-serialized token.
|
|
361
|
+
*/
|
|
362
|
+
static signEmbedded(jwt, keyPair) {
|
|
363
|
+
return new jose.SignJWT(jwt.payload)
|
|
364
|
+
.setProtectedHeader({
|
|
365
|
+
alg: keyPair.alg,
|
|
366
|
+
typ: jwt.constructor.type,
|
|
367
|
+
jwk: publicJwk(keyPair.jwk),
|
|
368
|
+
})
|
|
369
|
+
.sign(keyPair.private);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Verifies a token against the public key its own `jwk` header carries, answering the
|
|
373
|
+
* key beside the token so the caller can hold it to a binding, such as the `cnf.jkt`
|
|
374
|
+
* thumbprint a DPoP-bound access token names. A header carrying a private key fails.
|
|
375
|
+
*
|
|
376
|
+
* @param token - The compact-serialized token.
|
|
377
|
+
* @param options - Expected algorithms, clock tolerance, `typ`, and so on.
|
|
378
|
+
* @returns The verified token and the public JWK it was signed under.
|
|
379
|
+
* @throws When the header has no usable public key, or a check on the token fails.
|
|
380
|
+
* @example
|
|
381
|
+
* let { token: proof, jwk } = await DPoPProof.verifyEmbedded(header, { algorithms });
|
|
382
|
+
*/
|
|
383
|
+
static async verifyEmbedded(token, options) {
|
|
384
|
+
let result = await jose.jwtVerify(token, jose.EmbeddedJWK, {
|
|
385
|
+
...options,
|
|
386
|
+
algorithms: acceptedAlgorithms(options?.algorithms),
|
|
387
|
+
typ: options?.typ ?? (this.type === DEFAULT_TYPE ? undefined : this.type),
|
|
388
|
+
});
|
|
389
|
+
let jwk = publicJwk(result.protectedHeader.jwk ?? {});
|
|
390
|
+
return { token: new this(result.payload), jwk };
|
|
391
|
+
}
|
|
280
392
|
/**
|
|
281
393
|
* Verifies a token and returns it as an instance of the class this was called on.
|
|
282
394
|
*
|
|
283
|
-
* The key is picked by the token's `kid`,
|
|
284
|
-
*
|
|
395
|
+
* The key is picked by the token's `kid`, so a retired key keeps verifying what it
|
|
396
|
+
* signed. `algorithms` defaults to every supported one; `Ed25519` also accepts the
|
|
397
|
+
* deprecated `EdDSA`. An explicitly typed class requires its `typ` by default.
|
|
285
398
|
*
|
|
286
399
|
* @param token - The compact-serialized token.
|
|
287
400
|
* @param jwks - The keys themselves, or a resolver from `JWK.importLocal` /
|
|
@@ -304,7 +417,11 @@ export class JWT {
|
|
|
304
417
|
let key = Array.isArray(jwks)
|
|
305
418
|
? jose.createLocalJWKSet({ keys: jwks.map((candidate) => candidate.jwk) })
|
|
306
419
|
: jwks;
|
|
307
|
-
let result = await jose.jwtVerify(token, key,
|
|
420
|
+
let result = await jose.jwtVerify(token, withEdwardsAliases(key), {
|
|
421
|
+
...options,
|
|
422
|
+
algorithms: acceptedAlgorithms(options?.algorithms),
|
|
423
|
+
typ: options?.typ ?? (this.type === DEFAULT_TYPE ? undefined : this.type),
|
|
424
|
+
});
|
|
308
425
|
return new this(result.payload);
|
|
309
426
|
}
|
|
310
427
|
/**
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Security Event Token of RFC 8417: a JWT typed `secevent+jwt` whose `events` claim
|
|
3
|
+
* states what happened to a subject, with `txn`, `toe` and RFC 9493's `sub_id` as typed
|
|
4
|
+
* accessors, so every SET profile builds and verifies its tokens through one class.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { JWK } from "./jwk.js";
|
|
10
|
+
import { JWT } from "./jwt.js";
|
|
11
|
+
/**
|
|
12
|
+
* A Security Event Token. Build one with `SecurityEventToken.create`, sign it with
|
|
13
|
+
* `token.sign(algorithm, keys)`, and verify a received one with `SecurityEventToken.verify`,
|
|
14
|
+
* which requires the `secevent+jwt` type, `iss`, `iat`, `jti`, `aud` and a non-empty
|
|
15
|
+
* `events` claim, and refuses an `exp`: a SET records something that already happened.
|
|
16
|
+
*
|
|
17
|
+
* Subclass it for a SET profile, adding accessors for the profile's own claims; `create`
|
|
18
|
+
* and `verify` then produce the subclass.
|
|
19
|
+
*
|
|
20
|
+
* @example let set = await SecurityEventToken.create({ issuer, audience, events }).sign(JWK.Algorithm.ES256, keys);
|
|
21
|
+
* @example let event = await SecurityEventToken.verify(raw, jwks, { issuer, audience });
|
|
22
|
+
*/
|
|
23
|
+
export declare class SecurityEventToken extends JWT {
|
|
24
|
+
/**
|
|
25
|
+
* RFC 8417 §2.3's media type, which keeps a SET from verifying as an access token or
|
|
26
|
+
* any other explicitly typed JWT.
|
|
27
|
+
*/
|
|
28
|
+
static type: string;
|
|
29
|
+
/**
|
|
30
|
+
* Builds an unsigned SET with every claim RFC 8417 requires: a fresh random `jti` and
|
|
31
|
+
* `iat` now unless given, and each event payload under its event URI.
|
|
32
|
+
*
|
|
33
|
+
* @param input - Issuer, audience, events, and the optional envelope claims
|
|
34
|
+
* @returns The token, ready to sign, as the class this is called on
|
|
35
|
+
* @example
|
|
36
|
+
* SecurityEventToken.create({
|
|
37
|
+
* issuer: "https://idp.example.com/",
|
|
38
|
+
* audience: "https://rp.example.com/",
|
|
39
|
+
* subjectId: { format: "email", email: "user@example.com" },
|
|
40
|
+
* events: { "https://schemas.openid.net/secevent/risc/event-type/account-disabled": { reason: "hijacking" } },
|
|
41
|
+
* });
|
|
42
|
+
*/
|
|
43
|
+
static create<M extends SecurityEventToken>(this: new (payload: JWT.Payload) => M, input: SecurityEventToken.Input): M;
|
|
44
|
+
/**
|
|
45
|
+
* Verifies a SET as {@link JWT.verify} does, then holds it to RFC 8417: `iss`, `iat`,
|
|
46
|
+
* `jti` and `aud` present, `events` an object of one or more event objects, and no
|
|
47
|
+
* `exp`. A failed check throws jose's `JWTClaimValidationFailed` naming the claim.
|
|
48
|
+
*
|
|
49
|
+
* @param token - The compact-serialized SET
|
|
50
|
+
* @param jwks - The issuer's keys, or a resolver over them
|
|
51
|
+
* @param options - Expected issuer, audience, algorithms and clock tolerance
|
|
52
|
+
* @returns The verified token, as the class this is called on
|
|
53
|
+
* @throws When the signature, the type or any claim check fails
|
|
54
|
+
*/
|
|
55
|
+
static verify<M extends JWT>(this: (new (payload: JWT.Payload) => M) & Pick<typeof JWT, "type">, token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<M>;
|
|
56
|
+
/**
|
|
57
|
+
* The `events` claim: each event's payload under its event URI, every member describing
|
|
58
|
+
* an aspect of the same state change.
|
|
59
|
+
*
|
|
60
|
+
* @returns The payloads by URI; empty when the claim is absent or malformed
|
|
61
|
+
*/
|
|
62
|
+
get events(): SecurityEventToken.Events;
|
|
63
|
+
/** @param value - The events to carry, replacing any already set. */
|
|
64
|
+
set events(value: SecurityEventToken.Events);
|
|
65
|
+
/**
|
|
66
|
+
* The `txn` claim, shared by every token one transaction produces so a receiver can
|
|
67
|
+
* correlate them.
|
|
68
|
+
*
|
|
69
|
+
* @returns The transaction identifier, or `null` when absent
|
|
70
|
+
*/
|
|
71
|
+
get transaction(): string | null;
|
|
72
|
+
/** @param value - The transaction identifier, or `null` to drop the claim. */
|
|
73
|
+
set transaction(value: string | null);
|
|
74
|
+
/**
|
|
75
|
+
* The `toe` claim: when the event happened, which may precede `iat` by any amount.
|
|
76
|
+
*
|
|
77
|
+
* @returns The time of the event, or `null` when the issuer shares none
|
|
78
|
+
*/
|
|
79
|
+
get timeOfEvent(): Date | null;
|
|
80
|
+
/** @param value - The time of the event, or `null` to drop the claim. */
|
|
81
|
+
set timeOfEvent(value: Date | null);
|
|
82
|
+
/**
|
|
83
|
+
* The `sub_id` claim (RFC 9493 §4.1). An `email`, `iss_sub` or `opaque` identifier
|
|
84
|
+
* missing its required members reads as `null`; any other format is returned as given.
|
|
85
|
+
*
|
|
86
|
+
* @returns The subject identifier, or `null` when absent or malformed
|
|
87
|
+
*/
|
|
88
|
+
get subjectId(): SecurityEventToken.SubjectIdentifier | null;
|
|
89
|
+
/** @param value - The subject identifier, or `null` to drop the claim. */
|
|
90
|
+
set subjectId(value: SecurityEventToken.SubjectIdentifier | null);
|
|
91
|
+
}
|
|
92
|
+
export declare namespace SecurityEventToken {
|
|
93
|
+
/** Each event's payload under its event URI; an event with no claims is `{}`. */
|
|
94
|
+
type Events = Record<string, Record<string, unknown>>;
|
|
95
|
+
/** An RFC 9493 `email` identifier. */
|
|
96
|
+
interface EmailSubject {
|
|
97
|
+
format: "email";
|
|
98
|
+
email: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* An RFC 9493 `iss_sub` identifier: a subject as an ID token names it, where `iss` may
|
|
102
|
+
* differ from the SET's own issuer.
|
|
103
|
+
*/
|
|
104
|
+
interface IssuerSubject {
|
|
105
|
+
format: "iss_sub";
|
|
106
|
+
iss: string;
|
|
107
|
+
sub: string;
|
|
108
|
+
}
|
|
109
|
+
/** An RFC 9493 `opaque` identifier, carrying no meaning beyond identifying the subject. */
|
|
110
|
+
interface OpaqueSubject {
|
|
111
|
+
format: "opaque";
|
|
112
|
+
id: string;
|
|
113
|
+
}
|
|
114
|
+
/** Any other registered or profile-defined format, such as `phone_number` or `scim`. */
|
|
115
|
+
interface OtherSubject {
|
|
116
|
+
format: string;
|
|
117
|
+
[member: string]: unknown;
|
|
118
|
+
}
|
|
119
|
+
/** An RFC 9493 subject identifier, as `sub_id` carries it. */
|
|
120
|
+
type SubjectIdentifier = EmailSubject | IssuerSubject | OpaqueSubject | OtherSubject;
|
|
121
|
+
/** What `SecurityEventToken.create` builds a token from. */
|
|
122
|
+
interface Input {
|
|
123
|
+
issuer: string;
|
|
124
|
+
audience: string | string[];
|
|
125
|
+
/** One or more aspects of the same state change, each payload under its event URI. */
|
|
126
|
+
events: Events;
|
|
127
|
+
/** The token's `jti`, unique within the event feed; a fresh random one when omitted. */
|
|
128
|
+
id?: string;
|
|
129
|
+
/** @default new Date() */
|
|
130
|
+
issuedAt?: Date;
|
|
131
|
+
/** The `sub` claim, for a profile that defines what it holds. */
|
|
132
|
+
subject?: string;
|
|
133
|
+
subjectId?: SubjectIdentifier;
|
|
134
|
+
/** The `txn` claim, shared by every token one transaction produces. */
|
|
135
|
+
transaction?: string;
|
|
136
|
+
/** The `toe` claim; omitted, the token shares no time of event. */
|
|
137
|
+
timeOfEvent?: Date;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Security Event Token of RFC 8417: a JWT typed `secevent+jwt` whose `events` claim
|
|
3
|
+
* states what happened to a subject, with `txn`, `toe` and RFC 9493's `sub_id` as typed
|
|
4
|
+
* accessors, so every SET profile builds and verifies its tokens through one class.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { generateUUID } from "@sdxc/uuid/v4";
|
|
10
|
+
import * as jose from "jose";
|
|
11
|
+
import { JWT } from "./jwt.js";
|
|
12
|
+
/** Milliseconds in a second, for converting between epoch claims and `Date`. */
|
|
13
|
+
const MS_PER_SECOND = 1000;
|
|
14
|
+
/** The claims RFC 8417 §2.2 requires, plus the audience every SET here is addressed to. */
|
|
15
|
+
const REQUIRED_CLAIMS = ["iss", "iat", "jti", "aud"];
|
|
16
|
+
/**
|
|
17
|
+
* The members each RFC 9493 format this class types must carry as a non-empty string; a
|
|
18
|
+
* `sub_id` of one of these formats missing any of them reads as `null`.
|
|
19
|
+
*/
|
|
20
|
+
const SUBJECT_MEMBERS = {
|
|
21
|
+
email: ["email"],
|
|
22
|
+
iss_sub: ["iss", "sub"],
|
|
23
|
+
opaque: ["id"],
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A Security Event Token. Build one with `SecurityEventToken.create`, sign it with
|
|
27
|
+
* `token.sign(algorithm, keys)`, and verify a received one with `SecurityEventToken.verify`,
|
|
28
|
+
* which requires the `secevent+jwt` type, `iss`, `iat`, `jti`, `aud` and a non-empty
|
|
29
|
+
* `events` claim, and refuses an `exp`: a SET records something that already happened.
|
|
30
|
+
*
|
|
31
|
+
* Subclass it for a SET profile, adding accessors for the profile's own claims; `create`
|
|
32
|
+
* and `verify` then produce the subclass.
|
|
33
|
+
*
|
|
34
|
+
* @example let set = await SecurityEventToken.create({ issuer, audience, events }).sign(JWK.Algorithm.ES256, keys);
|
|
35
|
+
* @example let event = await SecurityEventToken.verify(raw, jwks, { issuer, audience });
|
|
36
|
+
*/
|
|
37
|
+
export class SecurityEventToken extends JWT {
|
|
38
|
+
/**
|
|
39
|
+
* RFC 8417 §2.3's media type, which keeps a SET from verifying as an access token or
|
|
40
|
+
* any other explicitly typed JWT.
|
|
41
|
+
*/
|
|
42
|
+
static type = "secevent+jwt";
|
|
43
|
+
/**
|
|
44
|
+
* Builds an unsigned SET with every claim RFC 8417 requires: a fresh random `jti` and
|
|
45
|
+
* `iat` now unless given, and each event payload under its event URI.
|
|
46
|
+
*
|
|
47
|
+
* @param input - Issuer, audience, events, and the optional envelope claims
|
|
48
|
+
* @returns The token, ready to sign, as the class this is called on
|
|
49
|
+
* @example
|
|
50
|
+
* SecurityEventToken.create({
|
|
51
|
+
* issuer: "https://idp.example.com/",
|
|
52
|
+
* audience: "https://rp.example.com/",
|
|
53
|
+
* subjectId: { format: "email", email: "user@example.com" },
|
|
54
|
+
* events: { "https://schemas.openid.net/secevent/risc/event-type/account-disabled": { reason: "hijacking" } },
|
|
55
|
+
* });
|
|
56
|
+
*/
|
|
57
|
+
static create(input) {
|
|
58
|
+
let payload = {
|
|
59
|
+
iss: input.issuer,
|
|
60
|
+
iat: toEpochSeconds(input.issuedAt ?? new Date()),
|
|
61
|
+
jti: input.id ?? generateUUID(),
|
|
62
|
+
aud: input.audience,
|
|
63
|
+
events: input.events,
|
|
64
|
+
};
|
|
65
|
+
if (input.subject !== undefined)
|
|
66
|
+
payload.sub = input.subject;
|
|
67
|
+
if (input.subjectId !== undefined)
|
|
68
|
+
payload.sub_id = input.subjectId;
|
|
69
|
+
if (input.transaction !== undefined)
|
|
70
|
+
payload.txn = input.transaction;
|
|
71
|
+
if (input.timeOfEvent !== undefined)
|
|
72
|
+
payload.toe = toEpochSeconds(input.timeOfEvent);
|
|
73
|
+
return new this(payload);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Verifies a SET as {@link JWT.verify} does, then holds it to RFC 8417: `iss`, `iat`,
|
|
77
|
+
* `jti` and `aud` present, `events` an object of one or more event objects, and no
|
|
78
|
+
* `exp`. A failed check throws jose's `JWTClaimValidationFailed` naming the claim.
|
|
79
|
+
*
|
|
80
|
+
* @param token - The compact-serialized SET
|
|
81
|
+
* @param jwks - The issuer's keys, or a resolver over them
|
|
82
|
+
* @param options - Expected issuer, audience, algorithms and clock tolerance
|
|
83
|
+
* @returns The verified token, as the class this is called on
|
|
84
|
+
* @throws When the signature, the type or any claim check fails
|
|
85
|
+
*/
|
|
86
|
+
static async verify(token, jwks, options) {
|
|
87
|
+
let requiredClaims = [...new Set([...REQUIRED_CLAIMS, ...(options?.requiredClaims ?? [])])];
|
|
88
|
+
let verified = (await JWT.verify.call(this, token, jwks, { ...options, requiredClaims }));
|
|
89
|
+
assertSecurityEventClaims(verified.payload);
|
|
90
|
+
return verified;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The `events` claim: each event's payload under its event URI, every member describing
|
|
94
|
+
* an aspect of the same state change.
|
|
95
|
+
*
|
|
96
|
+
* @returns The payloads by URI; empty when the claim is absent or malformed
|
|
97
|
+
*/
|
|
98
|
+
get events() {
|
|
99
|
+
let value = this.payload.events;
|
|
100
|
+
if (!isObject(value))
|
|
101
|
+
return {};
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
/** @param value - The events to carry, replacing any already set. */
|
|
105
|
+
set events(value) {
|
|
106
|
+
this.payload.events = value;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The `txn` claim, shared by every token one transaction produces so a receiver can
|
|
110
|
+
* correlate them.
|
|
111
|
+
*
|
|
112
|
+
* @returns The transaction identifier, or `null` when absent
|
|
113
|
+
*/
|
|
114
|
+
get transaction() {
|
|
115
|
+
return typeof this.payload.txn === "string" ? this.payload.txn : null;
|
|
116
|
+
}
|
|
117
|
+
/** @param value - The transaction identifier, or `null` to drop the claim. */
|
|
118
|
+
set transaction(value) {
|
|
119
|
+
this.payload.txn = value ?? undefined;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The `toe` claim: when the event happened, which may precede `iat` by any amount.
|
|
123
|
+
*
|
|
124
|
+
* @returns The time of the event, or `null` when the issuer shares none
|
|
125
|
+
*/
|
|
126
|
+
get timeOfEvent() {
|
|
127
|
+
if (typeof this.payload.toe !== "number")
|
|
128
|
+
return null;
|
|
129
|
+
return new Date(this.payload.toe * MS_PER_SECOND);
|
|
130
|
+
}
|
|
131
|
+
/** @param value - The time of the event, or `null` to drop the claim. */
|
|
132
|
+
set timeOfEvent(value) {
|
|
133
|
+
this.payload.toe = value === null ? undefined : toEpochSeconds(value);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The `sub_id` claim (RFC 9493 §4.1). An `email`, `iss_sub` or `opaque` identifier
|
|
137
|
+
* missing its required members reads as `null`; any other format is returned as given.
|
|
138
|
+
*
|
|
139
|
+
* @returns The subject identifier, or `null` when absent or malformed
|
|
140
|
+
*/
|
|
141
|
+
get subjectId() {
|
|
142
|
+
let value = this.payload.sub_id;
|
|
143
|
+
if (!isObject(value) || typeof value.format !== "string")
|
|
144
|
+
return null;
|
|
145
|
+
let members = SUBJECT_MEMBERS[value.format] ?? [];
|
|
146
|
+
let complete = members.every((name) => typeof value[name] === "string" && value[name] !== "");
|
|
147
|
+
return complete ? value : null;
|
|
148
|
+
}
|
|
149
|
+
/** @param value - The subject identifier, or `null` to drop the claim. */
|
|
150
|
+
set subjectId(value) {
|
|
151
|
+
this.payload.sub_id = value ?? undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Holds a verified claim set to the parts of RFC 8417 a signature check cannot see.
|
|
156
|
+
*
|
|
157
|
+
* @param payload - The verified claims
|
|
158
|
+
* @throws `JWTClaimValidationFailed` naming `exp` or `events`
|
|
159
|
+
*/
|
|
160
|
+
function assertSecurityEventClaims(payload) {
|
|
161
|
+
if (payload.exp !== undefined) {
|
|
162
|
+
throw new jose.errors.JWTClaimValidationFailed('a SET must not carry an "exp" claim', payload, "exp", "check_failed");
|
|
163
|
+
}
|
|
164
|
+
let events = payload.events;
|
|
165
|
+
let valid = isObject(events) &&
|
|
166
|
+
Object.keys(events).length > 0 &&
|
|
167
|
+
Object.values(events).every((event) => isObject(event));
|
|
168
|
+
if (!valid) {
|
|
169
|
+
throw new jose.errors.JWTClaimValidationFailed('"events" claim must be an object of one or more event objects', payload, "events", "invalid");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Whether a value is a JSON object, the shape RFC 8417 requires of `events` and each event.
|
|
174
|
+
*
|
|
175
|
+
* @param value - The claim value
|
|
176
|
+
*/
|
|
177
|
+
function isObject(value) {
|
|
178
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* A `Date` as the whole seconds since the epoch a NumericDate claim holds.
|
|
182
|
+
*
|
|
183
|
+
* @param date - The instant
|
|
184
|
+
*/
|
|
185
|
+
function toEpochSeconds(date) {
|
|
186
|
+
return Math.floor(date.getTime() / MS_PER_SECOND);
|
|
187
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RFC 7638 JWK thumbprint: a key's identifier derived from its public members, so a
|
|
3
|
+
* signer and a verifier name the same key without agreeing on a `kid` first. Web Bot
|
|
4
|
+
* Auth and DPoP both identify keys this way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import * as jose from "jose";
|
|
11
|
+
/**
|
|
12
|
+
* The base64url SHA-256 thumbprint of a key (RFC 7638 §3, and RFC 8037 Appendix A.3 for
|
|
13
|
+
* `OKP` keys). Only the members the key type requires are hashed, so a private JWK and its
|
|
14
|
+
* public half share one thumbprint, and `kid`, `alg` or `use` never change it.
|
|
15
|
+
*
|
|
16
|
+
* @param jwk - A JWK of type `EC`, `RSA`, `OKP` or `oct`, public or private.
|
|
17
|
+
* @returns The thumbprint, or a failure naming the missing or unsupported member.
|
|
18
|
+
* @example await thumbprint({ kty: "OKP", crv: "Ed25519", x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" })
|
|
19
|
+
*/
|
|
20
|
+
export declare function thumbprint(jwk: jose.JWK): Promise<Result<string, Error>>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RFC 7638 JWK thumbprint: a key's identifier derived from its public members, so a
|
|
3
|
+
* signer and a verifier name the same key without agreeing on a `kid` first. Web Bot
|
|
4
|
+
* Auth and DPoP both identify keys this way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { failure, success } from "@sdxc/result";
|
|
10
|
+
import * as jose from "jose";
|
|
11
|
+
/**
|
|
12
|
+
* The base64url SHA-256 thumbprint of a key (RFC 7638 §3, and RFC 8037 Appendix A.3 for
|
|
13
|
+
* `OKP` keys). Only the members the key type requires are hashed, so a private JWK and its
|
|
14
|
+
* public half share one thumbprint, and `kid`, `alg` or `use` never change it.
|
|
15
|
+
*
|
|
16
|
+
* @param jwk - A JWK of type `EC`, `RSA`, `OKP` or `oct`, public or private.
|
|
17
|
+
* @returns The thumbprint, or a failure naming the missing or unsupported member.
|
|
18
|
+
* @example await thumbprint({ kty: "OKP", crv: "Ed25519", x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" })
|
|
19
|
+
*/
|
|
20
|
+
export async function thumbprint(jwk) {
|
|
21
|
+
try {
|
|
22
|
+
return success(await jose.calculateJwkThumbprint(jwk, "sha256"));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return failure(error instanceof Error ? error : new Error(String(error)));
|
|
26
|
+
}
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/jwt",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.25",
|
|
4
4
|
"description": "JWT payload classes and the keys that sign them",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@sdxc/duration": "2026.10.13",
|
|
14
|
+
"@sdxc/result": "2026.10.13",
|
|
14
15
|
"@sdxc/uuid": "2026.10.13",
|
|
15
|
-
"jose": "^6.2.
|
|
16
|
+
"jose": "^6.2.13"
|
|
16
17
|
},
|
|
17
|
-
"gitHead": "
|
|
18
|
+
"gitHead": "1440b969c0a3e3808dfcee3f17d2ef43915f32a8",
|
|
18
19
|
"publishConfig": {
|
|
19
20
|
"access": "public"
|
|
20
21
|
},
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
24
25
|
"directory": "packages/jwt"
|
|
25
26
|
},
|
|
26
|
-
"homepage": "https://sdxc.
|
|
27
|
+
"homepage": "https://sdxc.dev/api/jwt",
|
|
27
28
|
"author": {
|
|
28
29
|
"name": "Sergio Xalambrí",
|
|
29
30
|
"url": "https://sergiodxa.com"
|