@sdxc/well-known 2026.10.9 → 2026.10.13
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 +345 -200
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -2,202 +2,192 @@
|
|
|
2
2
|
|
|
3
3
|
Typed documents for well-known URIs: security.txt, WebFinger, NodeInfo, OAuth and OIDC metadata, JWKS and more.
|
|
4
4
|
|
|
5
|
+
[RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves `/.well-known/` for documents a client
|
|
6
|
+
finds on any origin without being told where to look. Each registered name is one subpath of this
|
|
7
|
+
package that reads, writes and describes its document, with camelCase fields and `URL` values; the
|
|
8
|
+
specification's member names appear only in the serialized text.
|
|
9
|
+
|
|
5
10
|
## Installation
|
|
6
11
|
|
|
7
|
-
```
|
|
12
|
+
```sh
|
|
8
13
|
npm add @sdxc/well-known
|
|
9
14
|
```
|
|
10
15
|
|
|
11
|
-
Readers return [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) values,
|
|
12
|
-
|
|
16
|
+
Readers return [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) values, installed
|
|
17
|
+
alongside. Serving through `./response` and `./middleware` also needs
|
|
13
18
|
[`@sdxc/http`](https://www.npmjs.com/package/@sdxc/http) and
|
|
14
|
-
[`remix`](https://www.npmjs.com/package/remix)
|
|
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).
|
|
19
|
+
[`remix`](https://www.npmjs.com/package/remix).
|
|
22
20
|
|
|
23
21
|
## Usage
|
|
24
22
|
|
|
25
|
-
###
|
|
23
|
+
### Find a document's URL
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { wellKnownUrl } from "@sdxc/well-known";
|
|
27
|
+
|
|
28
|
+
wellKnownUrl("https://auth.example.com/tenant", "oauth-authorization-server");
|
|
29
|
+
// https://auth.example.com/.well-known/oauth-authorization-server/tenant
|
|
30
|
+
wellKnownUrl("https://auth.example.com/tenant", "openid-configuration", "append");
|
|
31
|
+
// https://auth.example.com/tenant/.well-known/openid-configuration
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
OAuth metadata inserts the suffix between the host and the path; OpenID Connect Discovery appends
|
|
35
|
+
it to the issuer.
|
|
36
|
+
|
|
37
|
+
### Read a fetched document
|
|
26
38
|
|
|
27
39
|
```typescript
|
|
28
40
|
import { isFailure } from "@sdxc/result";
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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";
|
|
41
|
+
import { wellKnownUrl } from "@sdxc/well-known";
|
|
42
|
+
import { parse } from "@sdxc/well-known/oauth-authorization-server";
|
|
34
43
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
};
|
|
44
|
+
let issuer = "https://auth.example.com";
|
|
45
|
+
let response = await fetch(wellKnownUrl(issuer, "oauth-authorization-server"));
|
|
46
|
+
let metadata = parse(await response.text(), { issuer });
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
});
|
|
48
|
+
if (isFailure(metadata)) {
|
|
49
|
+
metadata.error.issues; // [{ at: "/token_endpoint", message: "..." }, ...]
|
|
50
|
+
} else {
|
|
51
|
+
metadata.data.tokenEndpoint; // URL | null
|
|
52
|
+
}
|
|
59
53
|
```
|
|
60
54
|
|
|
61
|
-
|
|
55
|
+
Every reader returns a `Result`. Its failure, `WellKnownParseError`, lists every issue at once,
|
|
56
|
+
each located by a JSON Pointer (or a line number for security.txt).
|
|
57
|
+
|
|
58
|
+
### Write a document
|
|
62
59
|
|
|
63
60
|
```typescript
|
|
64
|
-
import { define,
|
|
65
|
-
import { respond } from "@sdxc/well-known/response";
|
|
66
|
-
import { createAction } from "remix/router";
|
|
61
|
+
import { define, stringify } from "@sdxc/well-known/oauth-protected-resource";
|
|
67
62
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
responseTypesSupported: ["code"],
|
|
73
|
-
subjectTypesSupported: ["public"],
|
|
74
|
-
idTokenSigningAlgValuesSupported: ["ES256"],
|
|
63
|
+
let metadata = define({
|
|
64
|
+
resource: new URL("https://api.example.com/v1"),
|
|
65
|
+
authorizationServers: [new URL("https://auth.example.com")],
|
|
66
|
+
scopesSupported: ["read", "write"],
|
|
75
67
|
});
|
|
76
68
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
69
|
+
stringify(metadata);
|
|
70
|
+
// {"resource":"https://api.example.com/v1","authorization_servers":[...],...}
|
|
80
71
|
```
|
|
81
72
|
|
|
82
|
-
|
|
73
|
+
`define` fills every member you leave out with its default, and `stringify` writes only what
|
|
74
|
+
differs from it.
|
|
75
|
+
|
|
76
|
+
### Serve it
|
|
83
77
|
|
|
84
78
|
```typescript
|
|
85
|
-
import {
|
|
86
|
-
import {
|
|
87
|
-
import {
|
|
79
|
+
import { securityTxt } from "@sdxc/well-known/security-txt";
|
|
80
|
+
import { serve, wellKnown } from "@sdxc/well-known/middleware";
|
|
81
|
+
import { createRouter } from "remix/router";
|
|
88
82
|
|
|
89
|
-
let
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
let router = createRouter({
|
|
84
|
+
middleware: [
|
|
85
|
+
wellKnown({
|
|
86
|
+
"security.txt": serve(securityTxt, () => ({
|
|
87
|
+
contact: [new URL("mailto:security@example.com")],
|
|
88
|
+
expires: new Date("2027-06-30T00:00:00Z"),
|
|
89
|
+
encryption: [],
|
|
90
|
+
acknowledgments: [],
|
|
91
|
+
preferredLanguages: ["en"],
|
|
92
|
+
canonical: [new URL("https://example.com/.well-known/security.txt")],
|
|
93
|
+
policy: [],
|
|
94
|
+
hiring: [],
|
|
95
|
+
extensions: {},
|
|
96
|
+
})),
|
|
97
|
+
}),
|
|
98
|
+
],
|
|
99
|
+
});
|
|
94
100
|
```
|
|
95
101
|
|
|
102
|
+
`wellKnown` answers the names it lists with the media type, a cache policy, an `ETag`, CORS where
|
|
103
|
+
the specification requires it and a 304 for a current copy; every other path reaches your routes.
|
|
104
|
+
|
|
96
105
|
## API
|
|
97
106
|
|
|
98
107
|
### `@sdxc/well-known`
|
|
99
108
|
|
|
100
|
-
#### `wellKnownUrl(identifier, name, placement
|
|
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.
|
|
109
|
+
#### `wellKnownUrl(identifier: URL | string, name: string, placement?: WellKnownPlacement): URL`
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
```
|
|
111
|
+
The URL a document is served at for an identifier. `"insert"`, the default, puts the suffix
|
|
112
|
+
between the host and the path and keeps the query; `"append"` adds it after the path.
|
|
112
113
|
|
|
113
114
|
#### `WellKnownParseError`
|
|
114
115
|
|
|
115
|
-
The failure every reader returns
|
|
116
|
-
(a JSON Pointer, `""` for the whole document; or a 1-based line
|
|
117
|
-
|
|
116
|
+
The failure every reader returns, with `format` (the registered name) and `issues`, each an `at`
|
|
117
|
+
(a JSON Pointer, `""` for the whole document; or a 1-based line, `0` for the whole file) and a
|
|
118
|
+
`message`.
|
|
118
119
|
|
|
119
|
-
#### `WellKnownFormat<Document>`
|
|
120
|
+
#### `WellKnownFormat<Document>`
|
|
120
121
|
|
|
121
122
|
The descriptor each document subpath exports: `name`, `mediaType`, `placement`, `cors`,
|
|
122
|
-
`stringify` and `parse`. `respond` and `serve` take one
|
|
123
|
+
`stringify` and `parse`. `respond` and `serve` take one; its `parse` reads structure only, so a
|
|
124
|
+
client checking a fetched document's issuer or resource calls the subpath's own `parse`.
|
|
123
125
|
|
|
124
|
-
### Every
|
|
126
|
+
### Every document subpath
|
|
125
127
|
|
|
126
|
-
Each exports `NAME`, `MEDIA_TYPE`, `parse(text)
|
|
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.
|
|
128
|
+
Each exports `NAME`, `MEDIA_TYPE`, `parse(text)`, `stringify(document)` and a descriptor.
|
|
129
129
|
|
|
130
130
|
### `@sdxc/well-known/security-txt` (RFC 9116)
|
|
131
131
|
|
|
132
|
-
- `
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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).
|
|
132
|
+
- `parse(text): Result<ParsedSecurityTxt, WellKnownParseError>` reads signed or unsigned text,
|
|
133
|
+
setting `signed` for an OpenPGP cleartext signature, which it leaves unverified. It fails on a
|
|
134
|
+
missing `Contact`, a missing or repeated `Expires`, a repeated `Preferred-Languages`, an `http`
|
|
135
|
+
URI and a malformed line; an expired file parses.
|
|
136
|
+
- `stringify(document, options?: StringifyOptions): string` writes unsigned text with LF endings,
|
|
137
|
+
with `options.comments` at the top.
|
|
138
|
+
- `isExpired(document, now?: Date): boolean` answers whether `expires` has passed.
|
|
143
139
|
- `securityTxt` is the descriptor.
|
|
144
140
|
|
|
145
141
|
### `@sdxc/well-known/webfinger` (RFC 7033)
|
|
146
142
|
|
|
147
|
-
- `
|
|
148
|
-
|
|
149
|
-
- `
|
|
150
|
-
|
|
151
|
-
- `
|
|
152
|
-
them all.
|
|
153
|
-
- `webFinger` is the descriptor, with `cors: true` as §5 requires.
|
|
143
|
+
- `readQuery(url: URL): Result<WebFingerQuery, MissingResourceError>` reads `resource` and every
|
|
144
|
+
`rel`; the failure is the request to answer with a 400.
|
|
145
|
+
- `select(document: Jrd, rels: string[]): Jrd` keeps only the links whose `rel` was asked for; an
|
|
146
|
+
empty list keeps them all.
|
|
147
|
+
- `parse` and `stringify` read and write a `Jrd`; `webFinger` is the descriptor, served with CORS.
|
|
154
148
|
|
|
155
149
|
### `@sdxc/well-known/oauth-authorization-server` (RFC 8414)
|
|
156
150
|
|
|
157
|
-
- `
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
`
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
- `
|
|
164
|
-
|
|
165
|
-
standard member wins over an extension of the same name.
|
|
151
|
+
- `parse(text, options?: ParseOptions)` fails on a missing `issuer` or `response_types_supported`,
|
|
152
|
+
a malformed member, or an `issuer` other than `options.issuer`. Members outside the
|
|
153
|
+
specification land in `extensions` under their serialized names, validated by
|
|
154
|
+
`options.extensions`, any synchronous [Standard Schema](https://standardschema.dev/).
|
|
155
|
+
- `define(document)` takes `issuer` and `responseTypesSupported` and defaults lists to `[]`, URLs
|
|
156
|
+
to `null` and flags to `false`.
|
|
157
|
+
- `stringify(document): string` writes extensions beside the standard members, which win a name
|
|
158
|
+
clash.
|
|
166
159
|
- `authorizationServerMetadata` is the descriptor.
|
|
167
160
|
|
|
168
161
|
### `@sdxc/well-known/openid-configuration` (OpenID Connect Discovery 1.0)
|
|
169
162
|
|
|
170
|
-
`OpenIdProviderMetadata`
|
|
171
|
-
`authorizationEndpoint
|
|
172
|
-
`
|
|
173
|
-
`openIdConfiguration` is the descriptor, with
|
|
163
|
+
`OpenIdProviderMetadata` adds the Discovery members to the RFC 8414 document. `define` also
|
|
164
|
+
requires `authorizationEndpoint`, `jwksUri`, `subjectTypesSupported` and
|
|
165
|
+
`idTokenSigningAlgValuesSupported`, and defaults `requestUriParameterSupported` to `true`. `parse`,
|
|
166
|
+
`stringify` and `ParseOptions` work as above; `openIdConfiguration` is the descriptor, with
|
|
167
|
+
`placement: "append"`.
|
|
174
168
|
|
|
175
169
|
### `@sdxc/well-known/oauth-protected-resource` (RFC 9728)
|
|
176
170
|
|
|
177
|
-
- `
|
|
178
|
-
`
|
|
179
|
-
`
|
|
180
|
-
- `
|
|
181
|
-
|
|
182
|
-
- `
|
|
183
|
-
|
|
184
|
-
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
- `
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
171
|
+
- `parse(text, options: ParseOptions)` requires the expected `options.resource`: the document's
|
|
172
|
+
`resource` must equal it, or with `match: "prefix"` be a same-origin, segment-aligned prefix of
|
|
173
|
+
it. `extensions` works as for RFC 8414.
|
|
174
|
+
- `define(document)` takes `resource` and defaults `bearerMethodsSupported` to `["header"]`.
|
|
175
|
+
- `metadataUrl(resource: URL | string): URL` is where a resource's metadata is served.
|
|
176
|
+
- `stringify` writes the document; `protectedResourceMetadata` is the descriptor.
|
|
177
|
+
|
|
178
|
+
### `@sdxc/well-known/jwks` (RFC 7517)
|
|
179
|
+
|
|
180
|
+
- `parse(text): Result<ParsedJwkSet, WellKnownParseError>` drops entries without a `kty` or the
|
|
181
|
+
members their `EC`, `RSA`, `OKP` or `oct` key type requires, counting them in `skipped`.
|
|
182
|
+
- `stringify(document: JwkSet): Result<string, WellKnownParseError>` fails on any private member
|
|
183
|
+
(`d`, `p`, `q`, `dp`, `dq`, `qi`, `oth`, `k`).
|
|
184
|
+
- `jwks` is the descriptor. Serving through it publishes each key's public half and leaves
|
|
185
|
+
symmetric keys out, so private keys can go in directly.
|
|
196
186
|
|
|
197
187
|
### `@sdxc/well-known/change-password` (W3C)
|
|
198
188
|
|
|
199
|
-
`redirect(target)` is a 302 to the page where a signed-in person changes
|
|
200
|
-
`NAME`
|
|
189
|
+
`redirect(target: URL | string): Response` is a 302 to the page where a signed-in person changes
|
|
190
|
+
their password. The name has no document, so it exports `NAME` and no descriptor.
|
|
201
191
|
|
|
202
192
|
### `@sdxc/well-known/passkey-endpoints` (W3C)
|
|
203
193
|
|
|
@@ -206,47 +196,146 @@ object is a valid document. `passkeyEndpoints` is the descriptor.
|
|
|
206
196
|
|
|
207
197
|
### `@sdxc/well-known/nodeinfo` (NodeInfo 2.0 and 2.1)
|
|
208
198
|
|
|
209
|
-
- `
|
|
210
|
-
a `rel` (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
letters, digits and hyphens, at least one protocol, every protocol and service a non-empty
|
|
219
|
-
string, and non-negative integer counts. Names outside the schema's registry, such as
|
|
220
|
-
`atproto`, are kept; the types autocomplete the registered ones. A 2.0 document reads with `repository` and `homepage`
|
|
221
|
-
`null`.
|
|
222
|
-
- `stringify(document)` writes 2.1 whatever `version` says, leaving out `null` members.
|
|
223
|
-
- `nodeInfo` is the descriptor for the document itself, served at the path the links
|
|
224
|
-
document names with `DOCUMENT_MEDIA_TYPE`, the 2.1 profile.
|
|
199
|
+
- `parseLinks(text)` and `stringifyLinks(document)` read and write the `/.well-known/nodeinfo`
|
|
200
|
+
discovery document, `{ links }`, each link a schema `rel` (`NODEINFO_2_0` or `NODEINFO_2_1`) and
|
|
201
|
+
an absolute `href`. `nodeInfoLinks` is its descriptor, served with CORS.
|
|
202
|
+
- `parse(text): Result<NodeInfo, WellKnownParseError>` reads 2.0 and 2.1 against the schema, a 2.0
|
|
203
|
+
document with `software.repository` and `software.homepage` as `null`. Protocol and service
|
|
204
|
+
names outside the schema's registry are kept.
|
|
205
|
+
- `stringify(document: NodeInfo): string` always writes 2.1, leaving out `null` members.
|
|
206
|
+
- `nodeInfo` is the descriptor for the document itself, served at the `href` the links name with
|
|
207
|
+
`DOCUMENT_MEDIA_TYPE`, the 2.1 profile.
|
|
225
208
|
|
|
226
209
|
### `@sdxc/well-known/response`
|
|
227
210
|
|
|
228
|
-
#### `respond(format, document, options
|
|
211
|
+
#### `respond(format, document, options?: RespondOptions): Promise<Response>`
|
|
229
212
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
`
|
|
233
|
-
`RespondOptions` takes `request`, `cache` and `headers`, which apply last.
|
|
213
|
+
The document with its media type, a `Cache-Control` (`options.cache`, default public for one hour),
|
|
214
|
+
an `ETag` over the body and CORS for a CORS format. With `options.request` it answers 304 when
|
|
215
|
+
`If-None-Match` still matches and an empty body for `HEAD`. `options.headers` apply last.
|
|
234
216
|
|
|
235
217
|
### `@sdxc/well-known/middleware`
|
|
236
218
|
|
|
237
|
-
#### `serve(format, produce, options?)`
|
|
219
|
+
#### `serve(format, produce: (ctx) => Document | null, options?): WellKnownEntry`
|
|
220
|
+
|
|
221
|
+
Answers with `produce(ctx)`'s document through `respond`, built per request; `null` falls through
|
|
222
|
+
to the next middleware. `produce` may be async, and `options` takes `cache` and `headers`.
|
|
223
|
+
|
|
224
|
+
#### `wellKnown(entries: Record<string, WellKnownEntry>): Middleware`
|
|
238
225
|
|
|
239
|
-
|
|
240
|
-
|
|
226
|
+
Answers `GET` and `HEAD` on `/.well-known/<name>` for each name given, and on
|
|
227
|
+
`/.well-known/<name>/<path>` for a `serve`d format that inserts its suffix. A CORS format also
|
|
228
|
+
answers `OPTIONS`; any other method gets a 405 with `Allow`. Every other path goes to `next()`.
|
|
241
229
|
|
|
242
|
-
|
|
230
|
+
### Types
|
|
243
231
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
232
|
+
`WellKnownPlacement` is `"insert" | "append"`. `WellKnownEntry` is any
|
|
233
|
+
`(ctx) => Response | null`, sync or async. Each document subpath exports its document type
|
|
234
|
+
(`SecurityTxt`, `Jrd`, `AuthorizationServerMetadata<Extensions>`, `OpenIdProviderMetadata<Extensions>`,
|
|
235
|
+
`ProtectedResourceMetadata<Extensions>`, `JwkSet`, `PasskeyEndpoints`, `NodeInfo`,
|
|
236
|
+
`NodeInfoLinks`) and the types of its members (`ParsedSecurityTxt`, `JrdLink`, `WebFingerQuery`,
|
|
237
|
+
`LocalizedString`, `BearerMethod`, `Jwk`, `ParsedJwkSet`, `NodeInfoSoftware`, `NodeInfoUsage` and
|
|
238
|
+
the rest).
|
|
248
239
|
|
|
249
|
-
## Pattern:
|
|
240
|
+
## Pattern: security.txt, WebFinger And Change-Password On One Router
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import { isFailure } from "@sdxc/result";
|
|
244
|
+
import { redirect } from "@sdxc/well-known/change-password";
|
|
245
|
+
import { serve, wellKnown } from "@sdxc/well-known/middleware";
|
|
246
|
+
import { isExpired, securityTxt } from "@sdxc/well-known/security-txt";
|
|
247
|
+
import type { SecurityTxt } from "@sdxc/well-known/security-txt";
|
|
248
|
+
import { readQuery, select, webFinger } from "@sdxc/well-known/webfinger";
|
|
249
|
+
import type { Jrd } from "@sdxc/well-known/webfinger";
|
|
250
|
+
import { createRouter } from "remix/router";
|
|
251
|
+
|
|
252
|
+
export const SECURITY_TXT: SecurityTxt = {
|
|
253
|
+
contact: [new URL("mailto:security@example.com")],
|
|
254
|
+
expires: new Date("2027-06-30T00:00:00Z"),
|
|
255
|
+
encryption: [],
|
|
256
|
+
acknowledgments: [],
|
|
257
|
+
preferredLanguages: ["en"],
|
|
258
|
+
canonical: [new URL("https://example.com/.well-known/security.txt")],
|
|
259
|
+
policy: [new URL("https://example.com/security-policy")],
|
|
260
|
+
hiring: [],
|
|
261
|
+
extensions: {},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
declare function findProfile(resource: string): Promise<Jrd | null>;
|
|
265
|
+
|
|
266
|
+
let router = createRouter({
|
|
267
|
+
middleware: [
|
|
268
|
+
wellKnown({
|
|
269
|
+
"security.txt": serve(securityTxt, () => SECURITY_TXT),
|
|
270
|
+
webfinger: async (ctx) => {
|
|
271
|
+
let query = readQuery(ctx.url);
|
|
272
|
+
if (isFailure(query)) return new Response(query.error.message, { status: 400 });
|
|
273
|
+
return serve(webFinger, async () => {
|
|
274
|
+
let profile = await findProfile(query.data.resource);
|
|
275
|
+
return profile === null ? null : select(profile, query.data.rels);
|
|
276
|
+
})(ctx);
|
|
277
|
+
},
|
|
278
|
+
"change-password": () => redirect("/account/password"),
|
|
279
|
+
}),
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let inThirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
284
|
+
isExpired(SECURITY_TXT, inThirtyDays); // assert false in a test
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Keep `expires` a literal date and assert in a test that it is more than 30 days away: a date
|
|
288
|
+
computed from the clock keeps the file fresh while its contact goes stale. A `null` profile falls
|
|
289
|
+
through to your 404.
|
|
290
|
+
|
|
291
|
+
## Pattern: OpenID Connect Discovery With Its Key Set
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
import { serve, wellKnown } from "@sdxc/well-known/middleware";
|
|
295
|
+
import { jwks } from "@sdxc/well-known/jwks";
|
|
296
|
+
import type { Jwk } from "@sdxc/well-known/jwks";
|
|
297
|
+
import { define, openIdConfiguration } from "@sdxc/well-known/openid-configuration";
|
|
298
|
+
import { respond } from "@sdxc/well-known/response";
|
|
299
|
+
import { createRouter } from "remix/router";
|
|
300
|
+
|
|
301
|
+
let issuer = "https://auth.example.com";
|
|
302
|
+
|
|
303
|
+
let discovery = define({
|
|
304
|
+
issuer,
|
|
305
|
+
authorizationEndpoint: new URL("/authorize", issuer),
|
|
306
|
+
tokenEndpoint: new URL("/token", issuer),
|
|
307
|
+
jwksUri: new URL("/.well-known/jwks.json", issuer),
|
|
308
|
+
responseTypesSupported: ["code"],
|
|
309
|
+
subjectTypesSupported: ["public"],
|
|
310
|
+
idTokenSigningAlgValuesSupported: ["ES256"],
|
|
311
|
+
codeChallengeMethodsSupported: ["S256"],
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
declare function signingKeys(): Promise<Jwk[]>;
|
|
315
|
+
|
|
316
|
+
let router = createRouter({
|
|
317
|
+
middleware: [
|
|
318
|
+
wellKnown({
|
|
319
|
+
"openid-configuration": serve(openIdConfiguration, () => discovery),
|
|
320
|
+
"jwks.json": serve(jwks, async () => ({ keys: await signingKeys() })),
|
|
321
|
+
}),
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// an issuer with a path serves discovery after it, from its own route
|
|
326
|
+
router.get("/tenant/.well-known/openid-configuration", (ctx) =>
|
|
327
|
+
respond(openIdConfiguration, define({ ...discovery, issuer: `${issuer}/tenant` }), {
|
|
328
|
+
request: ctx.request,
|
|
329
|
+
cache: { visibility: "public", maxAge: "5 minutes" },
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`signingKeys()` may answer private JWKs: the `jwks` descriptor serves each key's public half and
|
|
335
|
+
leaves symmetric keys out. Call `stringify` from `./jwks` instead when a private member should be
|
|
336
|
+
reported as an error.
|
|
337
|
+
|
|
338
|
+
## Pattern: Protected Resource Metadata For A Bearer-Token API
|
|
250
339
|
|
|
251
340
|
```typescript
|
|
252
341
|
import { serve, wellKnown } from "@sdxc/well-known/middleware";
|
|
@@ -256,34 +345,104 @@ import {
|
|
|
256
345
|
protectedResourceMetadata,
|
|
257
346
|
} from "@sdxc/well-known/oauth-protected-resource";
|
|
258
347
|
|
|
259
|
-
|
|
348
|
+
let api = define({
|
|
260
349
|
resource: new URL("https://api.example.com/v1"),
|
|
261
350
|
authorizationServers: [new URL("https://auth.example.com")],
|
|
262
351
|
scopesSupported: ["read", "write"],
|
|
352
|
+
resourceName: { value: "Example API", translations: { es: "API de ejemplo" } },
|
|
263
353
|
});
|
|
264
354
|
|
|
265
355
|
let middleware = wellKnown({
|
|
266
|
-
"oauth-protected-resource": serve(protectedResourceMetadata, () =>
|
|
356
|
+
"oauth-protected-resource": serve(protectedResourceMetadata, () => api),
|
|
267
357
|
});
|
|
268
358
|
|
|
269
|
-
|
|
359
|
+
function unauthorized() {
|
|
360
|
+
return new Response(null, {
|
|
361
|
+
status: 401,
|
|
362
|
+
headers: {
|
|
363
|
+
"WWW-Authenticate": `Bearer resource_metadata="${metadataUrl(api.resource)}"`,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
270
367
|
```
|
|
271
368
|
|
|
369
|
+
The document is served at `/.well-known/oauth-protected-resource/v1`, which `wellKnown` matches
|
|
370
|
+
because the format inserts its suffix before the resource's path.
|
|
371
|
+
|
|
372
|
+
## Pattern: Discovering An Authorization Server From A Resource
|
|
373
|
+
|
|
374
|
+
A client handed only an API URL follows the resource's metadata to its authorization server,
|
|
375
|
+
checking each document names the identifier it was fetched for.
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
import { isFailure } from "@sdxc/result";
|
|
379
|
+
import { wellKnownUrl } from "@sdxc/well-known";
|
|
380
|
+
import * as authorizationServer from "@sdxc/well-known/oauth-authorization-server";
|
|
381
|
+
import * as protectedResource from "@sdxc/well-known/oauth-protected-resource";
|
|
382
|
+
import * as s from "remix/data-schema";
|
|
383
|
+
|
|
384
|
+
let resource = "https://api.example.com/v1/items";
|
|
385
|
+
|
|
386
|
+
let resourceResponse = await fetch(protectedResource.metadataUrl("https://api.example.com/v1"));
|
|
387
|
+
let api = protectedResource.parse(await resourceResponse.text(), { resource, match: "prefix" });
|
|
388
|
+
if (isFailure(api)) throw api.error;
|
|
389
|
+
|
|
390
|
+
let issuer = api.data.authorizationServers[0];
|
|
391
|
+
if (issuer === undefined) throw new Error("The API names no authorization server");
|
|
392
|
+
|
|
393
|
+
let serverResponse = await fetch(wellKnownUrl(issuer, "oauth-authorization-server"));
|
|
394
|
+
let server = authorizationServer.parse(await serverResponse.text(), {
|
|
395
|
+
issuer,
|
|
396
|
+
extensions: s.object({ userinfo_endpoint: s.optional(s.string()) }),
|
|
397
|
+
});
|
|
398
|
+
if (isFailure(server)) throw server.error;
|
|
399
|
+
|
|
400
|
+
server.data.tokenEndpoint; // URL | null
|
|
401
|
+
server.data.extensions.userinfo_endpoint; // string | undefined
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## Pattern: Importing A Fetched Key Set
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
import { isFailure } from "@sdxc/result";
|
|
408
|
+
import { parse } from "@sdxc/well-known/jwks";
|
|
409
|
+
|
|
410
|
+
let response = await fetch("https://auth.example.com/.well-known/jwks.json");
|
|
411
|
+
let set = parse(await response.text());
|
|
412
|
+
if (isFailure(set)) throw set.error;
|
|
413
|
+
|
|
414
|
+
let keys = new Map<string, CryptoKey>();
|
|
415
|
+
for (let key of set.data.keys) {
|
|
416
|
+
if (key.kty !== "EC" || key.kid === undefined) continue;
|
|
417
|
+
let algorithm = { name: "ECDSA", namedCurve: key.crv ?? "P-256" };
|
|
418
|
+
keys.set(key.kid, await crypto.subtle.importKey("jwk", key, algorithm, false, ["verify"]));
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
A malformed entry is dropped and counted in `set.data.skipped`, so one bad key leaves the others
|
|
423
|
+
usable.
|
|
424
|
+
|
|
272
425
|
## Pattern: Advertising A Server Through NodeInfo
|
|
273
426
|
|
|
274
427
|
```typescript
|
|
275
428
|
import { serve, wellKnown } from "@sdxc/well-known/middleware";
|
|
276
429
|
import { NODEINFO_2_1, nodeInfo, nodeInfoLinks } from "@sdxc/well-known/nodeinfo";
|
|
277
430
|
import { respond } from "@sdxc/well-known/response";
|
|
278
|
-
import {
|
|
431
|
+
import { createRouter } from "remix/router";
|
|
279
432
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
433
|
+
declare function countUsers(): Promise<number>;
|
|
434
|
+
|
|
435
|
+
let router = createRouter({
|
|
436
|
+
middleware: [
|
|
437
|
+
wellKnown({
|
|
438
|
+
nodeinfo: serve(nodeInfoLinks, () => ({
|
|
439
|
+
links: [{ rel: NODEINFO_2_1, href: "https://example.com/nodeinfo/2.1" }],
|
|
440
|
+
})),
|
|
441
|
+
}),
|
|
442
|
+
],
|
|
284
443
|
});
|
|
285
444
|
|
|
286
|
-
|
|
445
|
+
router.get("/nodeinfo/2.1", async (ctx) =>
|
|
287
446
|
respond(
|
|
288
447
|
nodeInfo,
|
|
289
448
|
{
|
|
@@ -293,8 +452,8 @@ export default createAction(routes.nodeInfo, (ctx) =>
|
|
|
293
452
|
services: { inbound: [], outbound: ["rss2.0"] },
|
|
294
453
|
openRegistrations: false,
|
|
295
454
|
usage: {
|
|
296
|
-
users: { total:
|
|
297
|
-
localPosts:
|
|
455
|
+
users: { total: await countUsers(), activeMonth: null, activeHalfyear: null },
|
|
456
|
+
localPosts: null,
|
|
298
457
|
localComments: null,
|
|
299
458
|
},
|
|
300
459
|
metadata: {},
|
|
@@ -304,38 +463,24 @@ export default createAction(routes.nodeInfo, (ctx) =>
|
|
|
304
463
|
);
|
|
305
464
|
```
|
|
306
465
|
|
|
307
|
-
|
|
466
|
+
The links document lives under `/.well-known/`; the document it points to is an ordinary route,
|
|
467
|
+
answered through `respond`.
|
|
468
|
+
|
|
469
|
+
## Pattern: Passkey Endpoints For Password Managers
|
|
308
470
|
|
|
309
471
|
```typescript
|
|
310
|
-
import { redirect } from "@sdxc/well-known/change-password";
|
|
311
472
|
import { serve, wellKnown } from "@sdxc/well-known/middleware";
|
|
312
|
-
import {
|
|
473
|
+
import { passkeyEndpoints } from "@sdxc/well-known/passkey-endpoints";
|
|
313
474
|
|
|
314
475
|
let middleware = wellKnown({
|
|
315
|
-
"
|
|
316
|
-
|
|
476
|
+
"passkey-endpoints": serve(passkeyEndpoints, () => ({
|
|
477
|
+
enroll: new URL("https://example.com/account/passkeys/new"),
|
|
478
|
+
manage: new URL("https://example.com/account/passkeys"),
|
|
479
|
+
prfUsageDetails: null,
|
|
480
|
+
})),
|
|
317
481
|
});
|
|
318
482
|
```
|
|
319
483
|
|
|
320
|
-
Give security.txt a literal `expires` date and a test that fails when it is under 30 days
|
|
321
|
-
away; a date computed from the clock keeps the file fresh while its contact goes stale.
|
|
322
|
-
|
|
323
|
-
## Pattern: Importing A Fetched Key Set
|
|
324
|
-
|
|
325
|
-
```typescript
|
|
326
|
-
import { isFailure } from "@sdxc/result";
|
|
327
|
-
import { parse } from "@sdxc/well-known/jwks";
|
|
328
|
-
|
|
329
|
-
let response = await fetch("https://auth.example.com/.well-known/jwks.json");
|
|
330
|
-
let set = parse(await response.text());
|
|
331
|
-
if (isFailure(set)) return set;
|
|
332
|
-
|
|
333
|
-
let algorithm = { name: "ECDSA", namedCurve: "P-256" };
|
|
334
|
-
for (let key of set.data.keys) {
|
|
335
|
-
await crypto.subtle.importKey("jwk", key, algorithm, false, ["verify"]);
|
|
336
|
-
}
|
|
337
|
-
```
|
|
338
|
-
|
|
339
484
|
## Versioning
|
|
340
485
|
|
|
341
486
|
Releases are dated rather than semantic. A version is the UTC date it was published,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/well-known",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.13",
|
|
4
4
|
"description": "Typed documents for well-known URIs: security.txt, WebFinger, NodeInfo, OAuth and OIDC metadata, JWKS and more",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@remix-run/data-schema": "^1.0.0",
|
|
23
|
-
"@sdxc/result": "2026.10.
|
|
23
|
+
"@sdxc/result": "2026.10.13",
|
|
24
24
|
"@standard-schema/spec": "^1.1.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"@sdxc/http": "2026.10.
|
|
27
|
+
"@sdxc/http": "2026.10.13",
|
|
28
28
|
"remix": "3.0.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependenciesMeta": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"optional": true
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|