@sdxc/jwt 2026.10.24 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @sdxc/jwt
2
2
 
3
- JWT payload classes and the ES256, RS256 and EdDSA keys that sign them.
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 `.EdDSA` (`OKP`, deterministic nonces). The
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", "EdDSA"]
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
@@ -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
- * Derives its nonce from the key and message, keeping the ECDSA nonce-reuse
23
- * leak that exposes a private key out of reach.
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 EdDSA = "EdDSA";
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
- * Derives its nonce from the key and message, keeping the ECDSA nonce-reuse
23
- * leak that exposes a private key out of reach.
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 EdDSA = "EdDSA";
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, the `JWK` key namespace,
3
- * and the storage contract signing-key rotation is written against.
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, the `JWK` key namespace,
3
- * and the storage contract signing-key rotation is written against.
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; EdDSA derives its nonce from the key and message, keeping the
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
- /** Algorithm the pair was generated for. */
57
- alg: Algorithm;
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; EdDSA derives its nonce from the key and message, keeping the
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 publicKey = await jose.importSPKI(value.publicKey, value.alg, { extractable: true });
87
- let privateKey = await jose.importPKCS8(value.privateKey, value.alg);
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: value.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.kty;
177
- let publish = kty ? PUBLISHED_JWK_FIELDS[kty] : undefined;
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`, letting a retired key keep verifying
173
- * what it already signed through a rotation; `algorithms` pins accepted ones.
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({ alg: algorithm, typ: "JWT", kid: key.id })
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`, letting a retired key keep verifying
284
- * what it already signed through a rotation; `algorithms` pins accepted ones.
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, options);
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.24",
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
16
  "jose": "^6.2.13"
16
17
  },
17
- "gitHead": "f9db0f8f1f51a256e74b94b0f4b1db4be9880370",
18
+ "gitHead": "1440b969c0a3e3808dfcee3f17d2ef43915f32a8",
18
19
  "publishConfig": {
19
20
  "access": "public"
20
21
  },