@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 +21 -0
- package/README.md +313 -0
- package/dist/change-password.d.ts +19 -0
- package/dist/change-password.js +21 -0
- package/dist/format.d.ts +34 -0
- package/dist/format.js +0 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -0
- package/dist/jwks.d.ts +80 -0
- package/dist/jwks.js +100 -0
- package/dist/lib/identifier.d.ts +15 -0
- package/dist/lib/identifier.js +32 -0
- package/dist/lib/json.d.ts +45 -0
- package/dist/lib/json.js +67 -0
- package/dist/lib/metadata.d.ts +66 -0
- package/dist/lib/metadata.js +242 -0
- package/dist/lib/oauth-fields.d.ts +291 -0
- package/dist/lib/oauth-fields.js +103 -0
- package/dist/middleware.d.ts +35 -0
- package/dist/middleware.js +88 -0
- package/dist/oauth-authorization-server.d.ts +99 -0
- package/dist/oauth-authorization-server.js +61 -0
- package/dist/oauth-protected-resource.d.ts +110 -0
- package/dist/oauth-protected-resource.js +136 -0
- package/dist/openid-configuration.d.ts +75 -0
- package/dist/openid-configuration.js +60 -0
- package/dist/parse-error.d.ts +34 -0
- package/dist/parse-error.js +29 -0
- package/dist/passkey-endpoints.d.ts +36 -0
- package/dist/passkey-endpoints.js +40 -0
- package/dist/response.d.ts +32 -0
- package/dist/response.js +35 -0
- package/dist/security-txt.d.ts +64 -0
- package/dist/security-txt.js +228 -0
- package/dist/webfinger.d.ts +75 -0
- package/dist/webfinger.js +205 -0
- package/dist/well-known-url.d.ts +26 -0
- package/dist/well-known-url.js +29 -0
- package/package.json +46 -0
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
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|