@sdxc/well-known 2026.10.23 → 2026.10.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -7
- package/dist/api-catalog.d.ts +88 -0
- package/dist/api-catalog.js +106 -0
- package/dist/client-id-metadata-document.d.ts +115 -0
- package/dist/client-id-metadata-document.js +294 -0
- package/dist/format.d.ts +3 -1
- package/dist/http-message-signatures-directory.d.ts +54 -0
- package/dist/http-message-signatures-directory.js +115 -0
- package/dist/lib/metadata.d.ts +14 -9
- package/dist/lib/metadata.js +36 -5
- package/dist/lib/oauth-fields.d.ts +64 -12
- package/dist/lib/oauth-fields.js +26 -9
- package/dist/oauth-authorization-server.d.ts +14 -0
- package/dist/openid-configuration.d.ts +0 -3
- package/dist/response.d.ts +12 -2
- package/dist/response.js +11 -4
- package/dist/security-txt.d.ts +28 -2
- package/dist/security-txt.js +42 -1
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ The failure every reader returns, with `format` (the registered name) and `issue
|
|
|
120
120
|
#### `WellKnownFormat<Document>`
|
|
121
121
|
|
|
122
122
|
The descriptor each document subpath exports: `name`, `mediaType`, `placement`, `cors`,
|
|
123
|
-
`stringify` and `parse`. `respond` and `serve` take one; its `parse` reads structure only, so a
|
|
123
|
+
optional fixed `headers`, `stringify` and `parse`. `respond` and `serve` take one; its `parse` reads structure only, so a
|
|
124
124
|
client checking a fetched document's issuer or resource calls the subpath's own `parse`.
|
|
125
125
|
|
|
126
126
|
### Every document subpath
|
|
@@ -136,6 +136,11 @@ Each exports `NAME`, `MEDIA_TYPE`, `parse(text)`, `stringify(document)` and a de
|
|
|
136
136
|
- `stringify(document, options?: StringifyOptions): string` writes unsigned text with LF endings,
|
|
137
137
|
with `options.comments` at the top.
|
|
138
138
|
- `isExpired(document, now?: Date): boolean` answers whether `expires` has passed.
|
|
139
|
+
- `cleartextSigner({ key, passphrase?, onError? })` is a `transform` for `serve`/`respond` that
|
|
140
|
+
wraps the file in an OpenPGP cleartext signature through
|
|
141
|
+
[`@sdxc/openpgp`](https://www.npmjs.com/package/@sdxc/openpgp). `key` reads an armored private
|
|
142
|
+
key per call; with none, or one that fails (reported to `onError`), the file is served unsigned.
|
|
143
|
+
Each body is signed once, so the `ETag` stays stable.
|
|
139
144
|
- `securityTxt` is the descriptor.
|
|
140
145
|
|
|
141
146
|
### `@sdxc/well-known/webfinger` (RFC 7033)
|
|
@@ -153,7 +158,11 @@ Each exports `NAME`, `MEDIA_TYPE`, `parse(text)`, `stringify(document)` and a de
|
|
|
153
158
|
specification land in `extensions` under their serialized names, validated by
|
|
154
159
|
`options.extensions`, any synchronous [Standard Schema](https://standardschema.dev/).
|
|
155
160
|
- `define(document)` takes `issuer` and `responseTypesSupported` and defaults lists to `[]`, URLs
|
|
156
|
-
to `null` and flags to `false
|
|
161
|
+
to `null` and flags to `false`, except `requestUriParameterSupported`, which is `true` as the
|
|
162
|
+
registry defines it. The PAR (RFC 9126) and request object (RFC 9101) members are typed fields:
|
|
163
|
+
`pushedAuthorizationRequestEndpoint`, `requirePushedAuthorizationRequests`,
|
|
164
|
+
`requestParameterSupported`, `requestUriParameterSupported`,
|
|
165
|
+
`requestObjectSigningAlgValuesSupported` and `requireSignedRequestObject`.
|
|
157
166
|
- `stringify(document): string` writes extensions beside the standard members, which win a name
|
|
158
167
|
clash.
|
|
159
168
|
- `authorizationServerMetadata` is the descriptor.
|
|
@@ -162,7 +171,7 @@ Each exports `NAME`, `MEDIA_TYPE`, `parse(text)`, `stringify(document)` and a de
|
|
|
162
171
|
|
|
163
172
|
`OpenIdProviderMetadata` adds the Discovery members to the RFC 8414 document. `define` also
|
|
164
173
|
requires `authorizationEndpoint`, `jwksUri`, `subjectTypesSupported` and
|
|
165
|
-
`idTokenSigningAlgValuesSupported
|
|
174
|
+
`idTokenSigningAlgValuesSupported`. `parse`,
|
|
166
175
|
`stringify` and `ParseOptions` work as above; `openIdConfiguration` is the descriptor, with
|
|
167
176
|
`placement: "append"`.
|
|
168
177
|
|
|
@@ -184,6 +193,50 @@ requires `authorizationEndpoint`, `jwksUri`, `subjectTypesSupported` and
|
|
|
184
193
|
- `jwks` is the descriptor. Serving through it publishes each key's public half and leaves
|
|
185
194
|
symmetric keys out, so private keys can go in directly.
|
|
186
195
|
|
|
196
|
+
### `@sdxc/well-known/client-id-metadata-document` (OAuth Client ID Metadata Document, draft 02)
|
|
197
|
+
|
|
198
|
+
The RFC 7591 metadata a client serves at the `https` URL it uses as `client_id`: `parse(text, { clientId })`
|
|
199
|
+
applies the draft's `client_id`, credential and redirect URI rules, `define` and `stringify` write
|
|
200
|
+
one, and `respond(clientIdMetadataDocument, document)` serves it.
|
|
201
|
+
|
|
202
|
+
### `@sdxc/well-known/http-message-signatures-directory` (Web Bot Auth)
|
|
203
|
+
|
|
204
|
+
The key directory a signing bot publishes, served as `MEDIA_TYPE`
|
|
205
|
+
(`application/http-message-signatures-directory+json`) with CORS.
|
|
206
|
+
|
|
207
|
+
- `parse(text)` keeps public, asymmetric keys with a registered HTTP signature `alg` (or none) and
|
|
208
|
+
numeric `nbf`/`exp`, counting the rest in `skipped`.
|
|
209
|
+
- `build(keys)` publishes each key's public half with `use: "sig"`, failing for a symmetric key or
|
|
210
|
+
one without `kid`; set `kid` to the key's RFC 7638 thumbprint (`JWK.thumbprint` in `@sdxc/jwt`).
|
|
211
|
+
- `httpMessageSignaturesDirectory` is the descriptor. `@sdxc/http-signatures/web-bot-auth` signs
|
|
212
|
+
the response through `respond`'s `sign` option.
|
|
213
|
+
|
|
214
|
+
### `@sdxc/well-known/api-catalog` (RFC 9727)
|
|
215
|
+
|
|
216
|
+
The link set listing the APIs a host publishes, served as `MEDIA_TYPE`
|
|
217
|
+
(`application/linkset+json` with the RFC 9727 `profile`) with CORS, and with
|
|
218
|
+
`Link: </.well-known/api-catalog>; rel="api-catalog"` on every answer, `HEAD` included.
|
|
219
|
+
|
|
220
|
+
- `build({ url, apis, catalogs? })` writes an `item` link from the catalog to each API, then each
|
|
221
|
+
API's `serviceDesc`, `serviceDoc`, `serviceMeta` and `status` targets anchored at its endpoint,
|
|
222
|
+
every path resolved against `url`. `catalogs` nests other catalogs.
|
|
223
|
+
- `parse(text)` reads any RFC 9264 JSON link set with at least one link, as `{ links }` in the
|
|
224
|
+
shape `@sdxc/link-header/linkset` reads.
|
|
225
|
+
- `apiCatalog` is the descriptor:
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
wellKnown({
|
|
229
|
+
"api-catalog": serve(apiCatalog, (ctx) =>
|
|
230
|
+
build({
|
|
231
|
+
url: new URL("/.well-known/api-catalog", ctx.url),
|
|
232
|
+
apis: [
|
|
233
|
+
{ url: "/v1", serviceDesc: [{ href: "/v1/openapi.json" }], status: [{ href: "/health" }] },
|
|
234
|
+
],
|
|
235
|
+
}),
|
|
236
|
+
),
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
187
240
|
### `@sdxc/well-known/change-password` (W3C)
|
|
188
241
|
|
|
189
242
|
`redirect(target: URL | string): Response` is a 302 to the page where a signed-in person changes
|
|
@@ -213,13 +266,16 @@ object is a valid document. `passkeyEndpoints` is the descriptor.
|
|
|
213
266
|
The document with its media type, a `Cache-Control` (`options.cache`, default public for one hour),
|
|
214
267
|
an `ETag` over the body and CORS for a CORS format. With `options.request` it answers 304 when
|
|
215
268
|
`If-None-Match` still matches and an empty body for `HEAD`. `options.headers` apply last.
|
|
269
|
+
`options.transform(body)` rewrites the serialized body before the `ETag` is computed, such as
|
|
270
|
+
`cleartextSigner`. `options.sign(response, request)` runs over the full 200 response first, for a
|
|
271
|
+
signature over the body.
|
|
216
272
|
|
|
217
273
|
### `@sdxc/well-known/middleware`
|
|
218
274
|
|
|
219
275
|
#### `serve(format, produce: (ctx) => Document | null, options?): WellKnownEntry`
|
|
220
276
|
|
|
221
277
|
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 `
|
|
278
|
+
to the next middleware. `produce` may be async, and `options` takes `cache`, `headers`, `transform` and `sign`.
|
|
223
279
|
|
|
224
280
|
#### `wellKnown(entries: Record<string, WellKnownEntry>): Middleware`
|
|
225
281
|
|
|
@@ -232,7 +288,7 @@ answers `OPTIONS`; any other method gets a 405 with `Allow`. Every other path go
|
|
|
232
288
|
`WellKnownPlacement` is `"insert" | "append"`. `WellKnownEntry` is any
|
|
233
289
|
`(ctx) => Response | null`, sync or async. Each document subpath exports its document type
|
|
234
290
|
(`SecurityTxt`, `Jrd`, `AuthorizationServerMetadata<Extensions>`, `OpenIdProviderMetadata<Extensions>`,
|
|
235
|
-
`ProtectedResourceMetadata<Extensions>`, `JwkSet`, `PasskeyEndpoints`, `NodeInfo`,
|
|
291
|
+
`ProtectedResourceMetadata<Extensions>`, `JwkSet`, `ApiCatalog`, `PasskeyEndpoints`, `NodeInfo`,
|
|
236
292
|
`NodeInfoLinks`) and the types of its members (`ParsedSecurityTxt`, `JrdLink`, `WebFingerQuery`,
|
|
237
293
|
`LocalizedString`, `BearerMethod`, `Jwk`, `ParsedJwkSet`, `NodeInfoSoftware`, `NodeInfoUsage` and
|
|
238
294
|
the rest).
|
|
@@ -243,7 +299,8 @@ the rest).
|
|
|
243
299
|
import { isFailure } from "@sdxc/result";
|
|
244
300
|
import { redirect } from "@sdxc/well-known/change-password";
|
|
245
301
|
import { serve, wellKnown } from "@sdxc/well-known/middleware";
|
|
246
|
-
import { isExpired, securityTxt } from "@sdxc/well-known/security-txt";
|
|
302
|
+
import { cleartextSigner, isExpired, securityTxt } from "@sdxc/well-known/security-txt";
|
|
303
|
+
import { env } from "cloudflare:workers";
|
|
247
304
|
import type { SecurityTxt } from "@sdxc/well-known/security-txt";
|
|
248
305
|
import { readQuery, select, webFinger } from "@sdxc/well-known/webfinger";
|
|
249
306
|
import type { Jrd } from "@sdxc/well-known/webfinger";
|
|
@@ -266,7 +323,9 @@ declare function findProfile(resource: string): Promise<Jrd | null>;
|
|
|
266
323
|
let router = createRouter({
|
|
267
324
|
middleware: [
|
|
268
325
|
wellKnown({
|
|
269
|
-
"security.txt": serve(securityTxt, () => SECURITY_TXT
|
|
326
|
+
"security.txt": serve(securityTxt, () => SECURITY_TXT, {
|
|
327
|
+
transform: cleartextSigner({ key: () => env.SECURITY_TXT_SIGNING_KEY }),
|
|
328
|
+
}),
|
|
270
329
|
webfinger: async (ctx) => {
|
|
271
330
|
let query = readQuery(ctx.url);
|
|
272
331
|
if (isFailure(query)) return new Response(query.error.message, { status: 400 });
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RFC 9727 API catalog served at `/.well-known/api-catalog`: an RFC 9264 link set
|
|
3
|
+
* naming every API a host publishes, each with its machine-readable description, human
|
|
4
|
+
* documentation, policies and health endpoint, so a client discovers them from the origin.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { LinksetLink } from "@sdxc/link-header/linkset";
|
|
10
|
+
import type { Result } from "@sdxc/result";
|
|
11
|
+
import type { WellKnownFormat } from "./format.js";
|
|
12
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
13
|
+
export declare const NAME = "api-catalog";
|
|
14
|
+
/** The profile URI RFC 9727 §7.3 registers, marking a link set as an API catalog. */
|
|
15
|
+
export declare const PROFILE = "https://www.rfc-editor.org/info/rfc9727";
|
|
16
|
+
export declare const MEDIA_TYPE = "application/linkset+json; profile=\"https://www.rfc-editor.org/info/rfc9727\"";
|
|
17
|
+
/** The relation type that points at an API catalog, from a page or from another catalog. */
|
|
18
|
+
export declare const LINK_RELATION = "api-catalog";
|
|
19
|
+
/** A catalog: the links of its link set, read and written through `@sdxc/link-header/linkset`. */
|
|
20
|
+
export interface ApiCatalog {
|
|
21
|
+
links: LinksetLink[];
|
|
22
|
+
}
|
|
23
|
+
/** One resource an API's metadata link points at. */
|
|
24
|
+
export interface ApiCatalogTarget {
|
|
25
|
+
/** Resolved against the catalog's URL, so a path names a resource on the catalog's host. */
|
|
26
|
+
href: URL | string;
|
|
27
|
+
/** The media type a client gets, such as `application/json` for an OpenAPI document. */
|
|
28
|
+
type?: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
}
|
|
31
|
+
/** One API, with the RFC 8631 relations RFC 9727 Appendix A.1 describes it through. */
|
|
32
|
+
export interface ApiDescription {
|
|
33
|
+
/** The API's endpoint: the catalog's `item` link points here, and its metadata hangs off it. */
|
|
34
|
+
url: URL | string;
|
|
35
|
+
/** A name for the API, carried on its `item` link. */
|
|
36
|
+
title?: string;
|
|
37
|
+
/** Machine-readable descriptions, such as an OpenAPI document (`service-desc`). */
|
|
38
|
+
serviceDesc?: ApiCatalogTarget[];
|
|
39
|
+
/** Documentation for people (`service-doc`). */
|
|
40
|
+
serviceDoc?: ApiCatalogTarget[];
|
|
41
|
+
/** Other machine-readable metadata, such as usage policies (`service-meta`). */
|
|
42
|
+
serviceMeta?: ApiCatalogTarget[];
|
|
43
|
+
/** The API's health or status endpoint (`status`). */
|
|
44
|
+
status?: ApiCatalogTarget[];
|
|
45
|
+
}
|
|
46
|
+
/** What a catalog lists. */
|
|
47
|
+
export interface ApiCatalogInput {
|
|
48
|
+
/** The catalog's own URL: the anchor of its `item` links and the base for every relative href. */
|
|
49
|
+
url: URL | string;
|
|
50
|
+
apis: ApiDescription[];
|
|
51
|
+
/** Other catalogs this one nests, for APIs grouped by domain or product (RFC 9727 §4.3). */
|
|
52
|
+
catalogs?: Array<URL | string>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Builds a catalog combining both shapes RFC 9727 Appendix A shows: an `item` link from the
|
|
56
|
+
* catalog to each API, then each API's own metadata links anchored at its endpoint. Every
|
|
57
|
+
* reference is made absolute against `url`.
|
|
58
|
+
*
|
|
59
|
+
* @param input - The catalog's URL, its APIs and any nested catalogs.
|
|
60
|
+
* @returns The catalog, ready for `stringify` or `serve`.
|
|
61
|
+
* @example
|
|
62
|
+
* build({
|
|
63
|
+
* url: new URL("/.well-known/api-catalog", ctx.url),
|
|
64
|
+
* apis: [{ url: "/api/v1", serviceDesc: [{ href: "/api/v1/openapi.json", type: "application/json" }], status: [{ href: "/health" }] }],
|
|
65
|
+
* });
|
|
66
|
+
*/
|
|
67
|
+
export declare function build(input: ApiCatalogInput): ApiCatalog;
|
|
68
|
+
/**
|
|
69
|
+
* Reads a catalog. The link set must follow RFC 9264's JSON form and hold at least one
|
|
70
|
+
* link, since RFC 9727 §4.1 requires a catalog to link to its APIs.
|
|
71
|
+
*
|
|
72
|
+
* @param text - The served JSON.
|
|
73
|
+
* @returns The catalog, or every place it breaks either RFC.
|
|
74
|
+
* @example let catalog = parse(await (await fetch(wellKnownUrl(origin, NAME, "append"))).text());
|
|
75
|
+
*/
|
|
76
|
+
export declare function parse(text: string): Result<ApiCatalog, WellKnownParseError>;
|
|
77
|
+
/**
|
|
78
|
+
* Writes a catalog as its `application/linkset+json` document.
|
|
79
|
+
*
|
|
80
|
+
* @param document - The catalog.
|
|
81
|
+
*/
|
|
82
|
+
export declare function stringify(document: ApiCatalog): string;
|
|
83
|
+
/**
|
|
84
|
+
* A catalog as a servable format: the link set media type with the RFC 9727 profile, CORS so
|
|
85
|
+
* a browser-based API explorer can read it, and on every answer, `HEAD` included, the
|
|
86
|
+
* `Link: <…>; rel="api-catalog"` that RFC 9727 §2 requires.
|
|
87
|
+
*/
|
|
88
|
+
export declare const apiCatalog: WellKnownFormat<ApiCatalog>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { serializeLink } from "@sdxc/link-header";
|
|
2
|
+
import { JSON_MEDIA_TYPE, parseJson, stringifyJson } from "@sdxc/link-header/linkset";
|
|
3
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
4
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
5
|
+
export const NAME = "api-catalog";
|
|
6
|
+
/** The profile URI RFC 9727 §7.3 registers, marking a link set as an API catalog. */
|
|
7
|
+
export const PROFILE = "https://www.rfc-editor.org/info/rfc9727";
|
|
8
|
+
export const MEDIA_TYPE = `${JSON_MEDIA_TYPE}; profile="${PROFILE}"`;
|
|
9
|
+
/** The relation type that points at an API catalog, from a page or from another catalog. */
|
|
10
|
+
export const LINK_RELATION = "api-catalog";
|
|
11
|
+
/** The metadata relations an {@link ApiDescription} carries, in the order the catalog lists them. */
|
|
12
|
+
const METADATA_RELATIONS = [
|
|
13
|
+
["serviceDesc", "service-desc"],
|
|
14
|
+
["serviceDoc", "service-doc"],
|
|
15
|
+
["serviceMeta", "service-meta"],
|
|
16
|
+
["status", "status"],
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* A reference as an absolute URL against the catalog's, since RFC 9264 asks a link set to
|
|
20
|
+
* carry no relative references; one that cannot be resolved stays as written.
|
|
21
|
+
*
|
|
22
|
+
* @param reference - A URL or a path.
|
|
23
|
+
* @param base - The catalog's URL.
|
|
24
|
+
*/
|
|
25
|
+
function absolute(reference, base) {
|
|
26
|
+
return URL.parse(String(reference), base)?.href ?? String(reference);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Builds a catalog combining both shapes RFC 9727 Appendix A shows: an `item` link from the
|
|
30
|
+
* catalog to each API, then each API's own metadata links anchored at its endpoint. Every
|
|
31
|
+
* reference is made absolute against `url`.
|
|
32
|
+
*
|
|
33
|
+
* @param input - The catalog's URL, its APIs and any nested catalogs.
|
|
34
|
+
* @returns The catalog, ready for `stringify` or `serve`.
|
|
35
|
+
* @example
|
|
36
|
+
* build({
|
|
37
|
+
* url: new URL("/.well-known/api-catalog", ctx.url),
|
|
38
|
+
* apis: [{ url: "/api/v1", serviceDesc: [{ href: "/api/v1/openapi.json", type: "application/json" }], status: [{ href: "/health" }] }],
|
|
39
|
+
* });
|
|
40
|
+
*/
|
|
41
|
+
export function build(input) {
|
|
42
|
+
let anchor = String(input.url);
|
|
43
|
+
let links = [];
|
|
44
|
+
for (let api of input.apis) {
|
|
45
|
+
let link = { anchor, rel: "item", href: absolute(api.url, anchor) };
|
|
46
|
+
if (api.title !== undefined)
|
|
47
|
+
link.title = api.title;
|
|
48
|
+
links.push(link);
|
|
49
|
+
}
|
|
50
|
+
for (let catalog of input.catalogs ?? []) {
|
|
51
|
+
links.push({ anchor, rel: LINK_RELATION, href: absolute(catalog, anchor) });
|
|
52
|
+
}
|
|
53
|
+
for (let api of input.apis) {
|
|
54
|
+
let endpoint = absolute(api.url, anchor);
|
|
55
|
+
for (let [member, rel] of METADATA_RELATIONS) {
|
|
56
|
+
for (let target of api[member] ?? []) {
|
|
57
|
+
let link = { anchor: endpoint, rel, href: absolute(target.href, anchor) };
|
|
58
|
+
if (target.type !== undefined)
|
|
59
|
+
link.type = target.type;
|
|
60
|
+
if (target.title !== undefined)
|
|
61
|
+
link.title = target.title;
|
|
62
|
+
links.push(link);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { links };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reads a catalog. The link set must follow RFC 9264's JSON form and hold at least one
|
|
70
|
+
* link, since RFC 9727 §4.1 requires a catalog to link to its APIs.
|
|
71
|
+
*
|
|
72
|
+
* @param text - The served JSON.
|
|
73
|
+
* @returns The catalog, or every place it breaks either RFC.
|
|
74
|
+
* @example let catalog = parse(await (await fetch(wellKnownUrl(origin, NAME, "append"))).text());
|
|
75
|
+
*/
|
|
76
|
+
export function parse(text) {
|
|
77
|
+
let links = parseJson(text);
|
|
78
|
+
if (isFailure(links))
|
|
79
|
+
return failure(new WellKnownParseError(NAME, links.error.issues));
|
|
80
|
+
if (links.data.length === 0) {
|
|
81
|
+
return failure(new WellKnownParseError(NAME, [{ at: "/linkset", message: "The catalog links to no API." }]));
|
|
82
|
+
}
|
|
83
|
+
return success({ links: links.data });
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Writes a catalog as its `application/linkset+json` document.
|
|
87
|
+
*
|
|
88
|
+
* @param document - The catalog.
|
|
89
|
+
*/
|
|
90
|
+
export function stringify(document) {
|
|
91
|
+
return stringifyJson(document.links);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A catalog as a servable format: the link set media type with the RFC 9727 profile, CORS so
|
|
95
|
+
* a browser-based API explorer can read it, and on every answer, `HEAD` included, the
|
|
96
|
+
* `Link: <…>; rel="api-catalog"` that RFC 9727 §2 requires.
|
|
97
|
+
*/
|
|
98
|
+
export const apiCatalog = {
|
|
99
|
+
name: NAME,
|
|
100
|
+
mediaType: MEDIA_TYPE,
|
|
101
|
+
placement: "append",
|
|
102
|
+
cors: true,
|
|
103
|
+
headers: { Link: serializeLink(`/.well-known/${NAME}`, LINK_RELATION) },
|
|
104
|
+
stringify,
|
|
105
|
+
parse,
|
|
106
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document-02):
|
|
3
|
+
* the RFC 7591 client metadata a client publishes at the `https` URL it uses as its
|
|
4
|
+
* `client_id`, with the reader an authorization server checks a fetched copy through.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
11
|
+
import type { WellKnownFormat } from "./format.js";
|
|
12
|
+
import type { JwkSet } from "./jwks.js";
|
|
13
|
+
import type { LocalizedString } from "./oauth-protected-resource.js";
|
|
14
|
+
import type { WellKnownParseError } from "./parse-error.js";
|
|
15
|
+
export declare const NAME = "client-id-metadata-document";
|
|
16
|
+
export declare const MEDIA_TYPE = "application/json";
|
|
17
|
+
/**
|
|
18
|
+
* The client authentication methods a document may declare (§4.1): `none` and the
|
|
19
|
+
* methods proven with a key pair, since a published document can establish no shared
|
|
20
|
+
* secret.
|
|
21
|
+
*/
|
|
22
|
+
export declare const TOKEN_ENDPOINT_AUTH_METHODS: readonly ["none", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth"];
|
|
23
|
+
export type TokenEndpointAuthMethod = (typeof TOKEN_ENDPOINT_AUTH_METHODS)[number];
|
|
24
|
+
/**
|
|
25
|
+
* A client's published metadata. `clientId` and every redirect URI stay strings because
|
|
26
|
+
* both are matched by simple string comparison (§3, §4.2); `scope` is the space-delimited
|
|
27
|
+
* member read as a list.
|
|
28
|
+
*
|
|
29
|
+
* @template Extensions - The RFC 7591 members outside this list, validated by a schema on parse.
|
|
30
|
+
*/
|
|
31
|
+
export interface ClientIdMetadataDocument<Extensions extends object = {}> {
|
|
32
|
+
/** The Client Identifier URL, exactly the URL the document is served at. */
|
|
33
|
+
clientId: string;
|
|
34
|
+
redirectUris: string[];
|
|
35
|
+
/** An absent member reads as `none`: with no secret to present, the client is public. */
|
|
36
|
+
tokenEndpointAuthMethod: TokenEndpointAuthMethod;
|
|
37
|
+
/** @default ["authorization_code"] */
|
|
38
|
+
grantTypes: string[];
|
|
39
|
+
/** @default ["code"] */
|
|
40
|
+
responseTypes: string[];
|
|
41
|
+
clientName: LocalizedString | null;
|
|
42
|
+
clientUri: URL | null;
|
|
43
|
+
logoUri: URL | null;
|
|
44
|
+
scope: string[];
|
|
45
|
+
contacts: string[];
|
|
46
|
+
tosUri: URL | null;
|
|
47
|
+
policyUri: URL | null;
|
|
48
|
+
jwksUri: URL | null;
|
|
49
|
+
/** Public keys only; serving the document publishes each key's public half. */
|
|
50
|
+
jwks: JwkSet | null;
|
|
51
|
+
softwareId: string | null;
|
|
52
|
+
softwareVersion: string | null;
|
|
53
|
+
/** A JWT asserting the client's provenance (§4.3), kept opaque. */
|
|
54
|
+
softwareStatement: string | null;
|
|
55
|
+
extensions: Extensions;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* How a fetched document is read.
|
|
59
|
+
*
|
|
60
|
+
* @template Extensions - The members the schema produces.
|
|
61
|
+
*/
|
|
62
|
+
export interface ParseOptions<Extensions extends object> {
|
|
63
|
+
/** The URL the document was fetched from; its `client_id` must equal it, byte for byte (§4). */
|
|
64
|
+
clientId: string;
|
|
65
|
+
/** Validates the members outside the typed list; without one they stay unchecked. */
|
|
66
|
+
extensions?: StandardSchemaV1<unknown, Extensions>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether a string is a valid Client Identifier URL (§3): `https`, a path, and no
|
|
70
|
+
* userinfo, dot segments or fragment. It reads the raw string, so a dot segment the URL
|
|
71
|
+
* parser would resolve away still fails; a query is allowed, though discouraged.
|
|
72
|
+
*
|
|
73
|
+
* @param value - A `client_id` as presented.
|
|
74
|
+
* @example
|
|
75
|
+
* isClientIdentifierUrl("https://app.example.com/client.json"); // true
|
|
76
|
+
*/
|
|
77
|
+
export declare function isClientIdentifierUrl(value: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Reads a fetched document. It fails on a `client_id` other than `options.clientId`, a
|
|
80
|
+
* `client_secret*` member, a secret-based or unknown `token_endpoint_auth_method`, a
|
|
81
|
+
* keyed method without exactly one key source, private key material, or a redirect URI
|
|
82
|
+
* the client could not register; every RFC 7591 member outside the list lands in `extensions`.
|
|
83
|
+
*
|
|
84
|
+
* @param text - The served JSON.
|
|
85
|
+
* @param options - The fetched URL and an extension schema.
|
|
86
|
+
* @template Extensions - The extension members the schema produces.
|
|
87
|
+
* @example
|
|
88
|
+
* let client = parse(await response.text(), { clientId: url });
|
|
89
|
+
*/
|
|
90
|
+
export declare function parse<Extensions extends object = Record<string, unknown>>(text: string, options: ParseOptions<Extensions>): Result<ClientIdMetadataDocument<Extensions>, WellKnownParseError>;
|
|
91
|
+
/**
|
|
92
|
+
* Writes the document as its registered member names, leaving out `null` members, empty
|
|
93
|
+
* optional lists and default grant and response types. `token_endpoint_auth_method` is
|
|
94
|
+
* always written, since RFC 7591 reads an absent one as `client_secret_basic`, and
|
|
95
|
+
* `jwks` carries each key's public half only.
|
|
96
|
+
*
|
|
97
|
+
* @param document - The metadata to publish.
|
|
98
|
+
*/
|
|
99
|
+
export declare function stringify(document: ClientIdMetadataDocument<object>): string;
|
|
100
|
+
/**
|
|
101
|
+
* Builds a document from the members a client sets: `none` authentication, the RFC 7591
|
|
102
|
+
* default grant and response types, empty lists, `null` single values and no extensions.
|
|
103
|
+
*
|
|
104
|
+
* @param document - The members the client publishes.
|
|
105
|
+
* @template Extensions - Members outside the typed list the client also publishes.
|
|
106
|
+
* @example
|
|
107
|
+
* define({ clientId: "https://app.example.com/client.json", redirectUris: ["https://app.example.com/callback"] });
|
|
108
|
+
*/
|
|
109
|
+
export declare function define<Extensions extends object = {}>(document: Pick<ClientIdMetadataDocument<Extensions>, "clientId"> & Partial<ClientIdMetadataDocument<Extensions>>): ClientIdMetadataDocument<Extensions>;
|
|
110
|
+
/**
|
|
111
|
+
* The document as a servable format, for `respond` from `./response` at the route behind
|
|
112
|
+
* the client's own URL. Its `parse` applies every rule but the URL match, which a
|
|
113
|
+
* fetching authorization server checks by calling `parse` with `clientId`.
|
|
114
|
+
*/
|
|
115
|
+
export declare const clientIdMetadataDocument: WellKnownFormat<ClientIdMetadataDocument<object>>;
|