@sdxc/well-known 0.0.0-pre.1

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,313 @@
1
+ # @sdxc/well-known
2
+
3
+ Typed documents for well-known URIs: security.txt, WebFinger, OAuth and OIDC metadata, JWKS and more.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm add @sdxc/well-known
9
+ ```
10
+
11
+ Readers return [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) values, which
12
+ install alongside this package. `./response` and `./middleware` also need
13
+ [`@sdxc/http`](https://www.npmjs.com/package/@sdxc/http) and
14
+ [`remix`](https://www.npmjs.com/package/remix), optional peer dependencies you add when you
15
+ serve documents; the document subpaths work without them.
16
+
17
+ [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves `/.well-known/` for documents a
18
+ client finds on any origin without being told where to look. Each registered name is one
19
+ subpath, with camelCase fields and `URL` values; the specification's member names appear only
20
+ in the serialized text. Every reader returns a `Result` whose failure, `WellKnownParseError`,
21
+ lists every issue with a JSON Pointer (or a line number for security.txt).
22
+
23
+ ## Usage
24
+
25
+ ### Serve security.txt And WebFinger From A Router
26
+
27
+ ```typescript
28
+ import { isFailure } from "@sdxc/result";
29
+ import { serve, wellKnown } from "@sdxc/well-known/middleware";
30
+ import { securityTxt } from "@sdxc/well-known/security-txt";
31
+ import { readQuery, select, webFinger } from "@sdxc/well-known/webfinger";
32
+ import type { SecurityTxt } from "@sdxc/well-known/security-txt";
33
+ import { createRouter } from "remix/router";
34
+
35
+ const SECURITY_TXT: SecurityTxt = {
36
+ contact: [new URL("mailto:security@example.com")],
37
+ expires: new Date("2027-06-30T00:00:00Z"),
38
+ encryption: [],
39
+ acknowledgments: [],
40
+ preferredLanguages: ["en"],
41
+ canonical: [new URL("https://example.com/.well-known/security.txt")],
42
+ policy: [],
43
+ hiring: [],
44
+ extensions: {},
45
+ };
46
+
47
+ let router = createRouter({
48
+ middleware: [
49
+ wellKnown({
50
+ "security.txt": serve(securityTxt, () => SECURITY_TXT),
51
+ webfinger: serve(webFinger, (ctx) => {
52
+ let query = readQuery(ctx.url);
53
+ if (isFailure(query)) return null;
54
+ return select(profileFor(query.data.resource), query.data.rels);
55
+ }),
56
+ }),
57
+ ],
58
+ });
59
+ ```
60
+
61
+ ### Publish OpenID Connect Discovery From An Action
62
+
63
+ ```typescript
64
+ import { define, openIdConfiguration } from "@sdxc/well-known/openid-configuration";
65
+ import { respond } from "@sdxc/well-known/response";
66
+ import { createAction } from "remix/router";
67
+
68
+ const DISCOVERY = define({
69
+ issuer: "https://auth.example.com",
70
+ authorizationEndpoint: new URL("https://auth.example.com/authorize"),
71
+ jwksUri: new URL("https://auth.example.com/.well-known/jwks.json"),
72
+ responseTypesSupported: ["code"],
73
+ subjectTypesSupported: ["public"],
74
+ idTokenSigningAlgValuesSupported: ["ES256"],
75
+ });
76
+
77
+ export default createAction(routes.discovery, (ctx) =>
78
+ respond(openIdConfiguration, DISCOVERY, { request: ctx.request }),
79
+ );
80
+ ```
81
+
82
+ ### Read A Provider's Metadata
83
+
84
+ ```typescript
85
+ import { isFailure } from "@sdxc/result";
86
+ import { wellKnownUrl } from "@sdxc/well-known";
87
+ import { parse } from "@sdxc/well-known/oauth-authorization-server";
88
+
89
+ let issuer = "https://auth.example.com";
90
+ let url = wellKnownUrl(issuer, "oauth-authorization-server");
91
+ let metadata = parse(await (await fetch(url)).text(), { issuer });
92
+ if (isFailure(metadata)) return metadata;
93
+ metadata.data.tokenEndpoint; // URL | null
94
+ ```
95
+
96
+ ## API
97
+
98
+ ### `@sdxc/well-known`
99
+
100
+ #### `wellKnownUrl(identifier, name, placement = "insert")`
101
+
102
+ The URL a document is served at for an identifier. `"insert"` (RFC 8414, RFC 9728) puts the
103
+ suffix between the host and the path, keeping the query; `"append"` (OIDC Discovery) adds it
104
+ after the path.
105
+
106
+ ```typescript
107
+ wellKnownUrl("https://as.example/tenant", "oauth-authorization-server");
108
+ // https://as.example/.well-known/oauth-authorization-server/tenant
109
+ wellKnownUrl("https://op.example/tenant", "openid-configuration", "append");
110
+ // https://op.example/tenant/.well-known/openid-configuration
111
+ ```
112
+
113
+ #### `WellKnownParseError`
114
+
115
+ The failure every reader returns: `format` (the registered name) and `issues`, each with `at`
116
+ (a JSON Pointer, `""` for the whole document; or a 1-based line number, `0` for the whole
117
+ file) and `message`.
118
+
119
+ #### `WellKnownFormat<Document>` and `WellKnownPlacement`
120
+
121
+ The descriptor each document subpath exports: `name`, `mediaType`, `placement`, `cors`,
122
+ `stringify` and `parse`. `respond` and `serve` take one.
123
+
124
+ ### Every Document Subpath
125
+
126
+ Each exports `NAME`, `MEDIA_TYPE`, `parse(text)` returning a `Result`, `stringify(document)`
127
+ and a descriptor. The descriptor's `parse` reads structure only; a client reading a fetched
128
+ discovery document calls the subpath's `parse` with the identifier it expected.
129
+
130
+ ### `@sdxc/well-known/security-txt` (RFC 9116)
131
+
132
+ - `SecurityTxt` has `contact` (at least one), `expires`, `encryption`, `acknowledgments`,
133
+ `preferredLanguages`, `canonical`, `policy`, `hiring`, and `extensions` for other fields,
134
+ keyed by lowercased name.
135
+ - `parse(text)` returns a `ParsedSecurityTxt`, with `signed` set when the text arrived inside
136
+ an OpenPGP cleartext signature (read from its signed body; the signature is left
137
+ unverified). It fails on a missing `Contact`, a missing or repeated `Expires`, a repeated
138
+ `Preferred-Languages`, an `http` URI, and a line that is neither a field, a comment nor
139
+ blank. An expired file parses.
140
+ - `stringify(document, options?)` writes unsigned text with LF endings; `StringifyOptions`
141
+ takes `comments` to write at the top.
142
+ - `isExpired(document, now?)` answers staleness (§2.5.5).
143
+ - `securityTxt` is the descriptor.
144
+
145
+ ### `@sdxc/well-known/webfinger` (RFC 7033)
146
+
147
+ - `Jrd` and `JrdLink` are the descriptor and its links; absent members read as `null`, `[]`
148
+ or `{}`.
149
+ - `readQuery(url)` returns a `WebFingerQuery` (`{ resource, rels }`), or a
150
+ `MissingResourceError` to answer with a 400.
151
+ - `select(document, rels)` keeps only the links whose `rel` was asked for; an empty list keeps
152
+ them all.
153
+ - `webFinger` is the descriptor, with `cors: true` as §5 requires.
154
+
155
+ ### `@sdxc/well-known/oauth-authorization-server` (RFC 8414)
156
+
157
+ - `AuthorizationServerMetadata<Extensions>` has every §2 member plus RFC 8628, RFC 9126,
158
+ RFC 9207 and RFC 9728 §4 members. `issuer` is a string, compared as published.
159
+ - `parse(text, options?)` takes `ParseOptions` (`issuer`, `extensions`) and fails on a missing
160
+ `issuer` or `response_types_supported`, a malformed member, or a different issuer (§3.3).
161
+ Unknown members land in `extensions`, validated by a synchronous
162
+ [Standard Schema](https://standardschema.dev/) when one is given.
163
+ - `define(document)` defaults lists to `[]`, URLs to `null` and flags to `false`.
164
+ - `stringify(document)` leaves out `null` members, empty lists and flags at their default; the
165
+ standard member wins over an extension of the same name.
166
+ - `authorizationServerMetadata` is the descriptor.
167
+
168
+ ### `@sdxc/well-known/openid-configuration` (OpenID Connect Discovery 1.0)
169
+
170
+ `OpenIdProviderMetadata` extends the RFC 8414 document with the §3 members, and makes
171
+ `authorizationEndpoint` and `jwksUri` required. `parse`, `define`, `stringify` and
172
+ `ParseOptions` work as above; `requestUriParameterSupported` defaults to `true`, as §3 states.
173
+ `openIdConfiguration` is the descriptor, with `placement: "append"`.
174
+
175
+ ### `@sdxc/well-known/oauth-protected-resource` (RFC 9728)
176
+
177
+ - `ProtectedResourceMetadata<Extensions>` has every §2 member; `resourceName` is a
178
+ `LocalizedString` (`value` plus `#`-tagged `translations`), and `BearerMethod` is
179
+ `"header" | "body" | "query"`.
180
+ - `parse(text, options)` requires `ParseOptions.resource`: the document's `resource` must
181
+ equal it, or with `match: "prefix"` be a same-origin, segment-aligned prefix of it (§3.3).
182
+ - `define(document)` defaults `bearerMethodsSupported` to `["header"]`.
183
+ - `metadataUrl(resource)` inserts the suffix before the resource's path.
184
+ - `protectedResourceMetadata` is the descriptor.
185
+
186
+ ### `@sdxc/well-known/jwks` (RFC 7517 §5)
187
+
188
+ - `Jwk` keeps the registered member names Web Crypto imports; `JwkSet` is `{ keys }`.
189
+ - `parse(text)` returns a `ParsedJwkSet`, dropping entries that are not objects, lack `kty`,
190
+ or lack a member their `EC`, `RSA`, `OKP` or `oct` key type requires, and counting them in
191
+ `skipped`.
192
+ - `stringify(document)` returns a `Result` and fails on any private member (`d`, `p`, `q`,
193
+ `dp`, `dq`, `qi`, `oth`, `k`).
194
+ - `jwks` is the descriptor; serving through it publishes each key's public half and leaves
195
+ symmetric keys out.
196
+
197
+ ### `@sdxc/well-known/change-password` (W3C)
198
+
199
+ `redirect(target)` is a 302 to the page where a signed-in person changes their password.
200
+ `NAME` is `"change-password"`.
201
+
202
+ ### `@sdxc/well-known/passkey-endpoints` (W3C)
203
+
204
+ `PasskeyEndpoints` has `enroll`, `manage` and `prfUsageDetails`, each `URL | null`; an empty
205
+ object is a valid document. `passkeyEndpoints` is the descriptor.
206
+
207
+ ### `@sdxc/well-known/response`
208
+
209
+ #### `respond(format, document, options?)`
210
+
211
+ A `Promise<Response>` with the document's media type, a `Cache-Control` (default
212
+ `public, max-age=3600`), an `ETag` over the body, CORS for a CORS format, a 304 when
213
+ `options.request`'s `If-None-Match` still matches, and an empty body for `HEAD`.
214
+ `RespondOptions` takes `request`, `cache` and `headers`, which apply last.
215
+
216
+ ### `@sdxc/well-known/middleware`
217
+
218
+ #### `serve(format, produce, options?)`
219
+
220
+ A `WellKnownEntry` answering with `produce(ctx)`'s document through `respond`; a `null`
221
+ document falls through to the next middleware.
222
+
223
+ #### `wellKnown(entries)`
224
+
225
+ Answers `GET` and `HEAD` on `/.well-known/<name>` for the names given (and on
226
+ `/.well-known/<name>/<path>` for a `serve`d format that inserts its suffix), `OPTIONS` for a
227
+ CORS format, and 405 with `Allow` for other methods. Every other path goes to `next()`, so a
228
+ name of your own stays an ordinary route. An entry is any `(ctx) => Response | null`.
229
+
230
+ ## Pattern: Metadata For An API Accepting Bearer Tokens
231
+
232
+ ```typescript
233
+ import { serve, wellKnown } from "@sdxc/well-known/middleware";
234
+ import {
235
+ define,
236
+ metadataUrl,
237
+ protectedResourceMetadata,
238
+ } from "@sdxc/well-known/oauth-protected-resource";
239
+
240
+ const API_METADATA = define({
241
+ resource: new URL("https://api.example.com/v1"),
242
+ authorizationServers: [new URL("https://auth.example.com")],
243
+ scopesSupported: ["read", "write"],
244
+ });
245
+
246
+ let middleware = wellKnown({
247
+ "oauth-protected-resource": serve(protectedResourceMetadata, () => API_METADATA),
248
+ });
249
+
250
+ let challenge = `Bearer resource_metadata="${metadataUrl(API_METADATA.resource)}"`;
251
+ ```
252
+
253
+ ## Pattern: A Change-Password Redirect Beside Documents
254
+
255
+ ```typescript
256
+ import { redirect } from "@sdxc/well-known/change-password";
257
+ import { serve, wellKnown } from "@sdxc/well-known/middleware";
258
+ import { securityTxt } from "@sdxc/well-known/security-txt";
259
+
260
+ let middleware = wellKnown({
261
+ "security.txt": serve(securityTxt, () => SECURITY_TXT),
262
+ "change-password": () => redirect("/account/password"),
263
+ });
264
+ ```
265
+
266
+ Give security.txt a literal `expires` date and a test that fails when it is under 30 days
267
+ away; a date computed from the clock keeps the file fresh while its contact goes stale.
268
+
269
+ ## Pattern: Importing A Fetched Key Set
270
+
271
+ ```typescript
272
+ import { isFailure } from "@sdxc/result";
273
+ import { parse } from "@sdxc/well-known/jwks";
274
+
275
+ let response = await fetch("https://auth.example.com/.well-known/jwks.json");
276
+ let set = parse(await response.text());
277
+ if (isFailure(set)) return set;
278
+
279
+ let algorithm = { name: "ECDSA", namedCurve: "P-256" };
280
+ for (let key of set.data.keys) {
281
+ await crypto.subtle.importKey("jwk", key, algorithm, false, ["verify"]);
282
+ }
283
+ ```
284
+
285
+ ## Versioning
286
+
287
+ Releases are dated rather than semantic. A version is the UTC date it was published,
288
+ written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
289
+ release goes out per day.
290
+
291
+ Those numbers say when, not what: a later date means a later release and carries no
292
+ compatibility promise. Any release may change or remove an export.
293
+
294
+ Depend on one exact date, and move it when you are ready to take the change:
295
+
296
+ ```json
297
+ {
298
+ "dependencies": {
299
+ "@sdxc/well-known": "2026.9.4"
300
+ }
301
+ }
302
+ ```
303
+
304
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every
305
+ later release in the same year. An exact version keeps the upgrade yours to schedule.
306
+
307
+ ## License
308
+
309
+ MIT
310
+
311
+ ## Author
312
+
313
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The W3C well-known URL for changing passwords: a redirect a password manager
3
+ * follows to the page where a signed-in person changes their password. The name
4
+ * serves no document, only the redirect.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export declare const NAME = "change-password";
10
+ /**
11
+ * A 302 to the page where a signed-in person changes their password. A relative
12
+ * target stays relative in `Location`, which the client resolves against the
13
+ * well-known URL's origin.
14
+ *
15
+ * @param target - The change-password page.
16
+ * @example
17
+ * wellKnown({ "change-password": () => redirect("/account/password") });
18
+ */
19
+ export declare function redirect(target: URL | string): Response;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The W3C well-known URL for changing passwords: a redirect a password manager
3
+ * follows to the page where a signed-in person changes their password. The name
4
+ * serves no document, only the redirect.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export const NAME = "change-password";
10
+ /**
11
+ * A 302 to the page where a signed-in person changes their password. A relative
12
+ * target stays relative in `Location`, which the client resolves against the
13
+ * well-known URL's origin.
14
+ *
15
+ * @param target - The change-password page.
16
+ * @example
17
+ * wellKnown({ "change-password": () => redirect("/account/password") });
18
+ */
19
+ export function redirect(target) {
20
+ return new Response(null, { status: 302, headers: { Location: String(target) } });
21
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The description every well-known document shares: its registered name, media type,
3
+ * URL placement and CORS rule beside its reader and writer, which is what lets one
4
+ * response helper and one middleware serve any of them.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { WellKnownParseError } from "./parse-error.js";
11
+ /**
12
+ * RFC 8414 and RFC 9728 insert the suffix between host and path; OpenID Connect
13
+ * Discovery appends it to the identifier.
14
+ */
15
+ export type WellKnownPlacement = "insert" | "append";
16
+ /**
17
+ * A registered well-known document: how to name, serve, read and write it.
18
+ *
19
+ * @template Document - The typed document the reader produces and the writer takes.
20
+ */
21
+ export interface WellKnownFormat<Document> {
22
+ /** The IANA-registered suffix, as it appears after `/.well-known/`. */
23
+ readonly name: string;
24
+ /** The `Content-Type` every answer carries. */
25
+ readonly mediaType: string;
26
+ /** Where the suffix goes relative to an identifier carrying a path. */
27
+ readonly placement: WellKnownPlacement;
28
+ /** Whether every answer carries `Access-Control-Allow-Origin: *`. */
29
+ readonly cors: boolean;
30
+ /** The document as the text served under the name. */
31
+ stringify(document: Document): string;
32
+ /** Reads served text back, reporting every place it breaks the specification. */
33
+ parse(text: string): Result<Document, WellKnownParseError>;
34
+ }
package/dist/format.js ADDED
File without changes
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared rules for the documents served under `/.well-known/` (RFC 8615): the format
3
+ * description every document subpath fills in, the URL placement rule, and the error
4
+ * every reader returns. Each document lives in its own subpath.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export type { WellKnownFormat, WellKnownPlacement } from "./format.js";
10
+ export { WellKnownParseError } from "./parse-error.js";
11
+ export { wellKnownUrl } from "./well-known-url.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared rules for the documents served under `/.well-known/` (RFC 8615): the format
3
+ * description every document subpath fills in, the URL placement rule, and the error
4
+ * every reader returns. Each document lives in its own subpath.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export { WellKnownParseError } from "./parse-error.js";
10
+ export { wellKnownUrl } from "./well-known-url.js";
package/dist/jwks.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The JSON Web Key Set document (RFC 7517 §5): a reader that drops unusable entries
3
+ * and still reads the set, and a writer that refuses private key material, the one
4
+ * mistake that turns publishing keys into leaking one.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { WellKnownFormat } from "./format.js";
11
+ import { WellKnownParseError } from "./parse-error.js";
12
+ export declare const NAME = "jwks.json";
13
+ export declare const MEDIA_TYPE = "application/json";
14
+ /**
15
+ * One key, with the members RFC 7517 §4 and the key types of RFC 7518 §6 and RFC 8037
16
+ * register, as Web Crypto imports it. Any interface-typed JWK with those members fits.
17
+ */
18
+ export interface Jwk {
19
+ kty: string;
20
+ kid?: string;
21
+ use?: string;
22
+ key_ops?: string[];
23
+ alg?: string;
24
+ ext?: boolean;
25
+ x5u?: string;
26
+ x5c?: string[];
27
+ x5t?: string;
28
+ "x5t#S256"?: string;
29
+ crv?: string;
30
+ x?: string;
31
+ y?: string;
32
+ n?: string;
33
+ e?: string;
34
+ d?: string;
35
+ p?: string;
36
+ q?: string;
37
+ dp?: string;
38
+ dq?: string;
39
+ qi?: string;
40
+ oth?: {
41
+ r?: string;
42
+ d?: string;
43
+ t?: string;
44
+ }[];
45
+ k?: string;
46
+ }
47
+ export interface JwkSet {
48
+ keys: Jwk[];
49
+ }
50
+ export interface ParsedJwkSet extends JwkSet {
51
+ /**
52
+ * Entries dropped per RFC 7517 §5: not objects, lacking `kty`, or lacking a member
53
+ * their `EC`, `RSA`, `OKP` or `oct` key type requires.
54
+ */
55
+ skipped: number;
56
+ }
57
+ /**
58
+ * Reads a key set. A missing or non-array `keys` fails; an unusable entry is dropped
59
+ * and counted in `skipped`, so one malformed key never hides the others.
60
+ *
61
+ * @param text - The served JSON.
62
+ * @example
63
+ * let set = parse(await response.text());
64
+ * if (isSuccess(set)) for (let key of set.data.keys) await crypto.subtle.importKey("jwk", key, …);
65
+ */
66
+ export declare function parse(text: string): Result<ParsedJwkSet, WellKnownParseError>;
67
+ /**
68
+ * Writes a key set for publishing. Any private member (`d`, `p`, `q`, `dp`, `dq`, `qi`,
69
+ * `oth`, `k`) fails with an issue at that member, so a key pair passed in place of its
70
+ * public half is caught before it is served.
71
+ *
72
+ * @param document - The public keys.
73
+ */
74
+ export declare function stringify(document: JwkSet): Result<string, WellKnownParseError>;
75
+ /**
76
+ * A key set as a servable format. Serving publishes the public half of every key and
77
+ * leaves symmetric keys out, so a response can never carry private material; use
78
+ * `stringify` to have a private member reported instead.
79
+ */
80
+ export declare const jwks: WellKnownFormat<JwkSet>;
package/dist/jwks.js ADDED
@@ -0,0 +1,100 @@
1
+ import { failure, success } from "@sdxc/result";
2
+ import { isObject, pointer, readJsonObject } from "./lib/json.js";
3
+ import { WellKnownParseError } from "./parse-error.js";
4
+ export const NAME = "jwks.json";
5
+ export const MEDIA_TYPE = "application/json";
6
+ /** Members that carry private or symmetric key material (RFC 7518 §6). */
7
+ const PRIVATE_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
8
+ /** The members each key type RFC 7518 and RFC 8037 register cannot be imported without. */
9
+ const REQUIRED_MEMBERS = {
10
+ EC: ["crv", "x", "y"],
11
+ RSA: ["n", "e"],
12
+ OKP: ["crv", "x"],
13
+ oct: ["k"],
14
+ };
15
+ /**
16
+ * Whether an entry can stand as a key: an object with a string `kty` and, for a
17
+ * registered key type, its required members. An unregistered `kty` is kept, since
18
+ * whether it is understood is the importing code's decision.
19
+ *
20
+ * @param entry - One element of `keys`.
21
+ */
22
+ function isUsableKey(entry) {
23
+ if (!isObject(entry) || typeof entry.kty !== "string")
24
+ return false;
25
+ let required = REQUIRED_MEMBERS[entry.kty] ?? [];
26
+ return required.every((member) => typeof entry[member] === "string");
27
+ }
28
+ /**
29
+ * Reads a key set. A missing or non-array `keys` fails; an unusable entry is dropped
30
+ * and counted in `skipped`, so one malformed key never hides the others.
31
+ *
32
+ * @param text - The served JSON.
33
+ * @example
34
+ * let set = parse(await response.text());
35
+ * if (isSuccess(set)) for (let key of set.data.keys) await crypto.subtle.importKey("jwk", key, …);
36
+ */
37
+ export function parse(text) {
38
+ let decoded = readJsonObject(text, NAME);
39
+ if (decoded.status === "failure")
40
+ return decoded;
41
+ let entries = decoded.data.keys;
42
+ if (!Array.isArray(entries)) {
43
+ return failure(new WellKnownParseError(NAME, [{ at: "/keys", message: '"keys" is not an array.' }]));
44
+ }
45
+ let keys = entries.filter(isUsableKey);
46
+ return success({ keys, skipped: entries.length - keys.length });
47
+ }
48
+ /**
49
+ * Writes a key set for publishing. Any private member (`d`, `p`, `q`, `dp`, `dq`, `qi`,
50
+ * `oth`, `k`) fails with an issue at that member, so a key pair passed in place of its
51
+ * public half is caught before it is served.
52
+ *
53
+ * @param document - The public keys.
54
+ */
55
+ export function stringify(document) {
56
+ let issues = [];
57
+ for (let [index, key] of document.keys.entries()) {
58
+ for (let member of PRIVATE_MEMBERS) {
59
+ if (member in key) {
60
+ issues.push({
61
+ at: pointer("keys", index, member),
62
+ message: `Key ${index} carries the private member "${member}".`,
63
+ });
64
+ }
65
+ }
66
+ }
67
+ if (issues.length > 0)
68
+ return failure(new WellKnownParseError(NAME, issues));
69
+ return success(JSON.stringify({ keys: document.keys }));
70
+ }
71
+ /**
72
+ * The public half of a key: every private member removed, or `null` for a symmetric
73
+ * key, which has no public half.
74
+ *
75
+ * @param key - A key, public or private.
76
+ */
77
+ function publicHalf(key) {
78
+ if (key.kty === "oct")
79
+ return null;
80
+ let result = { ...key };
81
+ for (let member of PRIVATE_MEMBERS)
82
+ delete result[member];
83
+ return result;
84
+ }
85
+ /**
86
+ * A key set as a servable format. Serving publishes the public half of every key and
87
+ * leaves symmetric keys out, so a response can never carry private material; use
88
+ * `stringify` to have a private member reported instead.
89
+ */
90
+ export const jwks = {
91
+ name: NAME,
92
+ mediaType: MEDIA_TYPE,
93
+ placement: "insert",
94
+ cors: false,
95
+ stringify(document) {
96
+ let keys = document.keys.map(publicHalf).filter((key) => key !== null);
97
+ return JSON.stringify({ keys });
98
+ },
99
+ parse,
100
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The identity comparison every discovery check rests on (RFC 8414 §3.3, OpenID
3
+ * Connect Discovery §4.3, RFC 9728 §3.3): whether the identifier a document names is
4
+ * the one its URL was built from.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Whether two identifiers name the same issuer or resource.
11
+ *
12
+ * @param left - One identifier.
13
+ * @param right - The other identifier.
14
+ */
15
+ export declare function sameIdentifier(left: URL | string, right: URL | string): boolean;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The identity comparison every discovery check rests on (RFC 8414 §3.3, OpenID
3
+ * Connect Discovery §4.3, RFC 9728 §3.3): whether the identifier a document names is
4
+ * the one its URL was built from.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Matches the trailing slashes a URL identifier carries interchangeably. */
10
+ const TRAILING_SLASHES = /\/+$/;
11
+ /**
12
+ * An identifier in the form it compares. A URL identifier is read as a URL, so host
13
+ * case and a trailing slash carry no meaning; any other identifier compares byte for
14
+ * byte, as published.
15
+ *
16
+ * @param identifier - The identifier to read.
17
+ */
18
+ function comparable(identifier) {
19
+ let text = String(identifier);
20
+ if (!URL.canParse(text))
21
+ return text;
22
+ return new URL(text).href.replace(TRAILING_SLASHES, "");
23
+ }
24
+ /**
25
+ * Whether two identifiers name the same issuer or resource.
26
+ *
27
+ * @param left - One identifier.
28
+ * @param right - The other identifier.
29
+ */
30
+ export function sameIdentifier(left, right) {
31
+ return comparable(left) === comparable(right);
32
+ }