@sdxc/well-known 2026.10.24 → 2026.10.26
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 +8 -3
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { success } from "@sdxc/result";
|
|
2
|
+
import { jwks } from "./jwks.js";
|
|
3
|
+
import { isObject, pointer } from "./lib/json.js";
|
|
4
|
+
import { defaults, readMetadata, writeMetadata } from "./lib/metadata.js";
|
|
5
|
+
export const NAME = "client-id-metadata-document";
|
|
6
|
+
export const MEDIA_TYPE = "application/json";
|
|
7
|
+
/**
|
|
8
|
+
* The client authentication methods a document may declare (§4.1): `none` and the
|
|
9
|
+
* methods proven with a key pair, since a published document can establish no shared
|
|
10
|
+
* secret.
|
|
11
|
+
*/
|
|
12
|
+
export const TOKEN_ENDPOINT_AUTH_METHODS = [
|
|
13
|
+
"none",
|
|
14
|
+
"private_key_jwt",
|
|
15
|
+
"tls_client_auth",
|
|
16
|
+
"self_signed_tls_client_auth",
|
|
17
|
+
];
|
|
18
|
+
/** The methods whose key the authorization server discovers through `jwks` or `jwks_uri`. */
|
|
19
|
+
const KEYED_METHODS = ["private_key_jwt", "self_signed_tls_client_auth"];
|
|
20
|
+
/** The registered methods built on a shared secret, refused by name (§4.1). */
|
|
21
|
+
const SECRET_METHODS = [
|
|
22
|
+
"client_secret_post",
|
|
23
|
+
"client_secret_basic",
|
|
24
|
+
"client_secret_jwt",
|
|
25
|
+
];
|
|
26
|
+
/** Members that carry a shared secret, which a document must never hold (§4.1). */
|
|
27
|
+
const SECRET_MEMBERS = ["client_secret", "client_secret_expires_at"];
|
|
28
|
+
/** Grant types that send the user back through a registered redirect URI (§4.2). */
|
|
29
|
+
const REDIRECT_GRANTS = ["authorization_code", "implicit"];
|
|
30
|
+
/** Schemes a redirect URI may never use, since following one runs or embeds content. */
|
|
31
|
+
const UNSAFE_REDIRECT_SCHEMES = ["javascript:", "data:", "vbscript:", "file:"];
|
|
32
|
+
/** The schemes a URL shown to the user or linked from the consent screen may use (§8.6). */
|
|
33
|
+
const WEB_SCHEMES = ["https:", "http:"];
|
|
34
|
+
/** Every RFC 7591 §2 member this document types, by camelCase field. */
|
|
35
|
+
const FIELDS = {
|
|
36
|
+
clientId: { wire: "client_id", kind: "text", required: true },
|
|
37
|
+
redirectUris: { wire: "redirect_uris", kind: "strings" },
|
|
38
|
+
tokenEndpointAuthMethod: { wire: "token_endpoint_auth_method", kind: "text" },
|
|
39
|
+
grantTypes: { wire: "grant_types", kind: "strings", default: ["authorization_code"] },
|
|
40
|
+
responseTypes: { wire: "response_types", kind: "strings", default: ["code"] },
|
|
41
|
+
clientName: { wire: "client_name", kind: "localized" },
|
|
42
|
+
clientUri: { wire: "client_uri", kind: "url" },
|
|
43
|
+
logoUri: { wire: "logo_uri", kind: "url" },
|
|
44
|
+
scope: { wire: "scope", kind: "tokens" },
|
|
45
|
+
contacts: { wire: "contacts", kind: "strings" },
|
|
46
|
+
tosUri: { wire: "tos_uri", kind: "url" },
|
|
47
|
+
policyUri: { wire: "policy_uri", kind: "url" },
|
|
48
|
+
jwksUri: { wire: "jwks_uri", kind: "url" },
|
|
49
|
+
jwks: { wire: "jwks", kind: "object" },
|
|
50
|
+
softwareId: { wire: "software_id", kind: "text" },
|
|
51
|
+
softwareVersion: { wire: "software_version", kind: "text" },
|
|
52
|
+
softwareStatement: { wire: "software_statement", kind: "text" },
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Whether a string is a valid Client Identifier URL (§3): `https`, a path, and no
|
|
56
|
+
* userinfo, dot segments or fragment. It reads the raw string, so a dot segment the URL
|
|
57
|
+
* parser would resolve away still fails; a query is allowed, though discouraged.
|
|
58
|
+
*
|
|
59
|
+
* @param value - A `client_id` as presented.
|
|
60
|
+
* @example
|
|
61
|
+
* isClientIdentifierUrl("https://app.example.com/client.json"); // true
|
|
62
|
+
*/
|
|
63
|
+
export function isClientIdentifierUrl(value) {
|
|
64
|
+
if (!/^https:\/\//i.test(value) || !URL.canParse(value))
|
|
65
|
+
return false;
|
|
66
|
+
if (value.includes("#"))
|
|
67
|
+
return false;
|
|
68
|
+
let rest = value.slice("https://".length);
|
|
69
|
+
let end = rest.search(/[/?]/);
|
|
70
|
+
if (end === -1 || rest[end] !== "/")
|
|
71
|
+
return false;
|
|
72
|
+
if (rest.slice(0, end).includes("@"))
|
|
73
|
+
return false;
|
|
74
|
+
let path = rest.slice(end).split("?")[0] ?? "";
|
|
75
|
+
return path.split("/").every((segment) => !/^(\.|%2e){1,2}$/i.test(segment));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether a redirect URI can be registered: an absolute URL without a fragment (RFC 6749
|
|
79
|
+
* §3.1.2) whose scheme leads somewhere a browser navigates to safely.
|
|
80
|
+
*
|
|
81
|
+
* @param value - One entry of `redirect_uris`.
|
|
82
|
+
*/
|
|
83
|
+
function isRedirectUri(value) {
|
|
84
|
+
if (!URL.canParse(value) || value.includes("#"))
|
|
85
|
+
return false;
|
|
86
|
+
return !UNSAFE_REDIRECT_SCHEMES.includes(new URL(value).protocol);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Collects every §4.1 key material problem in `jwks`: no `keys` array, or a key carrying
|
|
90
|
+
* a private or symmetric member.
|
|
91
|
+
*
|
|
92
|
+
* @param set - The decoded `jwks` member.
|
|
93
|
+
* @param issues - Where problems are collected.
|
|
94
|
+
*/
|
|
95
|
+
function checkKeys(set, issues) {
|
|
96
|
+
let keys = set.keys;
|
|
97
|
+
if (!Array.isArray(keys)) {
|
|
98
|
+
issues.push({ at: "/jwks/keys", message: '"jwks" has no "keys" array.' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (let [index, key] of keys.entries()) {
|
|
102
|
+
if (!isObject(key)) {
|
|
103
|
+
issues.push({
|
|
104
|
+
at: pointer("jwks", "keys", index),
|
|
105
|
+
message: `Key ${index} is not an object.`,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (let member of ["d", "p", "q", "dp", "dq", "qi", "oth", "k"]) {
|
|
110
|
+
if (member in key)
|
|
111
|
+
issues.push({
|
|
112
|
+
at: pointer("jwks", "keys", index, member),
|
|
113
|
+
message: `Key ${index} carries the private member "${member}".`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Collects the §4.1 authentication problems: a secret member, a method outside
|
|
120
|
+
* {@link TOKEN_ENDPOINT_AUTH_METHODS}, both or neither key source for a keyed method, a
|
|
121
|
+
* non-`https` `jwks_uri` and private key material.
|
|
122
|
+
*
|
|
123
|
+
* @param fields - The read fields.
|
|
124
|
+
* @param issues - Where problems are collected.
|
|
125
|
+
* @param body - The decoded document.
|
|
126
|
+
*/
|
|
127
|
+
function checkAuthentication(fields, issues, body) {
|
|
128
|
+
for (let member of SECRET_MEMBERS) {
|
|
129
|
+
if (member in body)
|
|
130
|
+
issues.push({
|
|
131
|
+
at: pointer(member),
|
|
132
|
+
message: `"${member}" is a shared secret, which a Client ID Metadata Document cannot carry.`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
let method = fields.tokenEndpointAuthMethod ?? "none";
|
|
136
|
+
if (SECRET_METHODS.includes(method)) {
|
|
137
|
+
issues.push({
|
|
138
|
+
at: "/token_endpoint_auth_method",
|
|
139
|
+
message: `"${method}" authenticates with a shared secret, which a Client ID Metadata Document cannot establish.`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else if (!TOKEN_ENDPOINT_AUTH_METHODS.includes(method)) {
|
|
143
|
+
issues.push({
|
|
144
|
+
at: "/token_endpoint_auth_method",
|
|
145
|
+
message: `"${method}" is not one of ${TOKEN_ENDPOINT_AUTH_METHODS.join(", ")}.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
let jwksUri = fields.jwksUri;
|
|
149
|
+
let set = fields.jwks;
|
|
150
|
+
if (jwksUri !== null && set !== null)
|
|
151
|
+
issues.push({ at: "", message: 'The document sets both "jwks" and "jwks_uri".' });
|
|
152
|
+
if (KEYED_METHODS.includes(method) && jwksUri === null && set === null)
|
|
153
|
+
issues.push({
|
|
154
|
+
at: "/token_endpoint_auth_method",
|
|
155
|
+
message: `"${method}" needs the client's public key in "jwks" or "jwks_uri".`,
|
|
156
|
+
});
|
|
157
|
+
if (jwksUri !== null && jwksUri.protocol !== "https:")
|
|
158
|
+
issues.push({ at: "/jwks_uri", message: '"jwks_uri" is not an https URL.' });
|
|
159
|
+
if (set !== null)
|
|
160
|
+
checkKeys(set, issues);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Collects the §4.2 and §8.6 URL problems: a redirect URI that cannot be registered, no
|
|
164
|
+
* redirect URI for a redirect-based grant, and a display URL outside `http`/`https`.
|
|
165
|
+
*
|
|
166
|
+
* @param fields - The read fields.
|
|
167
|
+
* @param issues - Where problems are collected.
|
|
168
|
+
*/
|
|
169
|
+
function checkUrls(fields, issues) {
|
|
170
|
+
let redirectUris = fields.redirectUris;
|
|
171
|
+
for (let [index, uri] of redirectUris.entries()) {
|
|
172
|
+
if (!isRedirectUri(uri))
|
|
173
|
+
issues.push({
|
|
174
|
+
at: pointer("redirect_uris", index),
|
|
175
|
+
message: `"${uri}" is not an absolute redirect URI without a fragment.`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
let grantTypes = fields.grantTypes;
|
|
179
|
+
if (redirectUris.length === 0 && grantTypes.some((grant) => REDIRECT_GRANTS.includes(grant)))
|
|
180
|
+
issues.push({
|
|
181
|
+
at: "/redirect_uris",
|
|
182
|
+
message: `"redirect_uris" is empty, and grant types ${grantTypes.join(", ")} need one.`,
|
|
183
|
+
});
|
|
184
|
+
for (let name of ["clientUri", "logoUri", "tosUri", "policyUri"]) {
|
|
185
|
+
let url = fields[name];
|
|
186
|
+
if (url !== null && !WEB_SCHEMES.includes(url.protocol))
|
|
187
|
+
issues.push({
|
|
188
|
+
at: pointer(FIELDS[name].wire),
|
|
189
|
+
message: `"${FIELDS[name].wire}" is not an http or https URL.`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Builds the read's draft check: the `client_id` shape (§3), its match with the fetched
|
|
195
|
+
* URL when one is given (§4), and the credential and URL rules.
|
|
196
|
+
*
|
|
197
|
+
* @param expected - The URL the document was fetched from.
|
|
198
|
+
*/
|
|
199
|
+
function documentCheck(expected) {
|
|
200
|
+
return (fields, issues, body) => {
|
|
201
|
+
let clientId = String(fields.clientId);
|
|
202
|
+
if (!isClientIdentifierUrl(clientId))
|
|
203
|
+
issues.push({
|
|
204
|
+
at: "/client_id",
|
|
205
|
+
message: `"${clientId}" is not an https URL with a path and no userinfo, dot segments or fragment.`,
|
|
206
|
+
});
|
|
207
|
+
else if (expected !== undefined && clientId !== expected)
|
|
208
|
+
issues.push({
|
|
209
|
+
at: "/client_id",
|
|
210
|
+
message: `The document names client_id "${clientId}", not "${expected}".`,
|
|
211
|
+
});
|
|
212
|
+
checkAuthentication(fields, issues, body);
|
|
213
|
+
checkUrls(fields, issues);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Reads a document through the field table and the draft's rules.
|
|
218
|
+
*
|
|
219
|
+
* @param text - The served JSON.
|
|
220
|
+
* @param expected - The URL it was fetched from, when the match is checked.
|
|
221
|
+
* @param extensions - The extension schema.
|
|
222
|
+
* @template Extensions - The extension members the schema produces.
|
|
223
|
+
*/
|
|
224
|
+
function read(text, expected, extensions) {
|
|
225
|
+
let result = readMetadata(text, {
|
|
226
|
+
format: NAME,
|
|
227
|
+
table: FIELDS,
|
|
228
|
+
extensions,
|
|
229
|
+
check: documentCheck(expected),
|
|
230
|
+
});
|
|
231
|
+
if (result.status === "failure")
|
|
232
|
+
return result;
|
|
233
|
+
return success({
|
|
234
|
+
...result.data,
|
|
235
|
+
tokenEndpointAuthMethod: result.data.tokenEndpointAuthMethod ?? "none",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Reads a fetched document. It fails on a `client_id` other than `options.clientId`, a
|
|
240
|
+
* `client_secret*` member, a secret-based or unknown `token_endpoint_auth_method`, a
|
|
241
|
+
* keyed method without exactly one key source, private key material, or a redirect URI
|
|
242
|
+
* the client could not register; every RFC 7591 member outside the list lands in `extensions`.
|
|
243
|
+
*
|
|
244
|
+
* @param text - The served JSON.
|
|
245
|
+
* @param options - The fetched URL and an extension schema.
|
|
246
|
+
* @template Extensions - The extension members the schema produces.
|
|
247
|
+
* @example
|
|
248
|
+
* let client = parse(await response.text(), { clientId: url });
|
|
249
|
+
*/
|
|
250
|
+
export function parse(text, options) {
|
|
251
|
+
return read(text, options.clientId, options.extensions);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Writes the document as its registered member names, leaving out `null` members, empty
|
|
255
|
+
* optional lists and default grant and response types. `token_endpoint_auth_method` is
|
|
256
|
+
* always written, since RFC 7591 reads an absent one as `client_secret_basic`, and
|
|
257
|
+
* `jwks` carries each key's public half only.
|
|
258
|
+
*
|
|
259
|
+
* @param document - The metadata to publish.
|
|
260
|
+
*/
|
|
261
|
+
export function stringify(document) {
|
|
262
|
+
let set = document.jwks === null ? null : JSON.parse(jwks.stringify(document.jwks));
|
|
263
|
+
return writeMetadata({ ...document, jwks: set }, FIELDS);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Builds a document from the members a client sets: `none` authentication, the RFC 7591
|
|
267
|
+
* default grant and response types, empty lists, `null` single values and no extensions.
|
|
268
|
+
*
|
|
269
|
+
* @param document - The members the client publishes.
|
|
270
|
+
* @template Extensions - Members outside the typed list the client also publishes.
|
|
271
|
+
* @example
|
|
272
|
+
* define({ clientId: "https://app.example.com/client.json", redirectUris: ["https://app.example.com/callback"] });
|
|
273
|
+
*/
|
|
274
|
+
export function define(document) {
|
|
275
|
+
return {
|
|
276
|
+
...defaults(FIELDS),
|
|
277
|
+
tokenEndpointAuthMethod: "none",
|
|
278
|
+
extensions: {},
|
|
279
|
+
...document,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The document as a servable format, for `respond` from `./response` at the route behind
|
|
284
|
+
* the client's own URL. Its `parse` applies every rule but the URL match, which a
|
|
285
|
+
* fetching authorization server checks by calling `parse` with `clientId`.
|
|
286
|
+
*/
|
|
287
|
+
export const clientIdMetadataDocument = {
|
|
288
|
+
name: NAME,
|
|
289
|
+
mediaType: MEDIA_TYPE,
|
|
290
|
+
placement: "insert",
|
|
291
|
+
cors: false,
|
|
292
|
+
stringify,
|
|
293
|
+
parse: (text) => read(text, undefined, undefined),
|
|
294
|
+
};
|
package/dist/format.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The description every well-known document shares: its registered name, media type,
|
|
3
|
-
* URL placement
|
|
3
|
+
* URL placement, CORS rule and fixed headers beside its reader and writer, which is what lets one
|
|
4
4
|
* response helper and one middleware serve any of them.
|
|
5
5
|
*
|
|
6
6
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
@@ -27,6 +27,8 @@ export interface WellKnownFormat<Document> {
|
|
|
27
27
|
readonly placement: WellKnownPlacement;
|
|
28
28
|
/** Whether every answer carries `Access-Control-Allow-Origin: *`. */
|
|
29
29
|
readonly cors: boolean;
|
|
30
|
+
/** Headers every answer carries, `HEAD` included, such as the `Link` an API catalog requires. */
|
|
31
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
30
32
|
/** The document as the text served under the name. */
|
|
31
33
|
stringify(document: Document): string;
|
|
32
34
|
/** Reads served text back, reporting every place it breaks the specification. */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP Message Signatures Directory of Web Bot Auth: the JWK Set an automated client
|
|
3
|
+
* publishes at `/.well-known/http-message-signatures-directory` so any origin can verify
|
|
4
|
+
* the requests it signs, each key labelled with its RFC 7638 thumbprint as `kid`.
|
|
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 type { Jwk } from "./jwks.js";
|
|
12
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
13
|
+
export declare const NAME = "http-message-signatures-directory";
|
|
14
|
+
export declare const MEDIA_TYPE = "application/http-message-signatures-directory+json";
|
|
15
|
+
/** A directory key: a public JWK, optionally bounded by `nbf` and `exp` in epoch seconds. */
|
|
16
|
+
export interface DirectoryKey extends Jwk {
|
|
17
|
+
nbf?: number;
|
|
18
|
+
exp?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface SignaturesDirectory {
|
|
21
|
+
keys: DirectoryKey[];
|
|
22
|
+
}
|
|
23
|
+
export interface ParsedSignaturesDirectory extends SignaturesDirectory {
|
|
24
|
+
/**
|
|
25
|
+
* Entries dropped: unusable as a JWK, symmetric, carrying private members, declaring an
|
|
26
|
+
* `alg` outside the HTTP Signature Algorithms registry, or with a non-numeric `nbf`/`exp`.
|
|
27
|
+
*/
|
|
28
|
+
skipped: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Reads a directory. A missing or non-array `keys` fails; every entry a verifier could not
|
|
32
|
+
* use is dropped and counted in `skipped`, so one bad key never hides the others.
|
|
33
|
+
*
|
|
34
|
+
* @param text - The served JSON.
|
|
35
|
+
* @example let directory = parse(await response.text());
|
|
36
|
+
*/
|
|
37
|
+
export declare function parse(text: string): Result<ParsedSignaturesDirectory, WellKnownParseError>;
|
|
38
|
+
/**
|
|
39
|
+
* Builds a directory from keys, public or private: each entry keeps its public members,
|
|
40
|
+
* `kid`, `alg`, `nbf` and `exp`, and gets `use: "sig"`. Verifiers match a signature's
|
|
41
|
+
* `keyid` against `kid`, so set it to the key's RFC 7638 thumbprint (`JWK.thumbprint`).
|
|
42
|
+
*
|
|
43
|
+
* @param keys - The signing keys; a private JWK publishes only its public half.
|
|
44
|
+
* @returns The directory, or an issue at every symmetric key, every key without `kid`, and
|
|
45
|
+
* every `alg` outside the HTTP Signature Algorithms registry, such as JOSE's `EdDSA`.
|
|
46
|
+
* @example build([{ ...publicJwk, kid: await thumbprintOf(publicJwk) }])
|
|
47
|
+
*/
|
|
48
|
+
export declare function build(keys: DirectoryKey[]): Result<SignaturesDirectory, WellKnownParseError>;
|
|
49
|
+
/**
|
|
50
|
+
* A directory as a servable format, answered with the registered media type and
|
|
51
|
+
* `Access-Control-Allow-Origin: *` so a browser-based verifier can read it. Serving keeps
|
|
52
|
+
* the public half of every asymmetric key; pass what `build` answers so each carries its `kid`.
|
|
53
|
+
*/
|
|
54
|
+
export declare const httpMessageSignaturesDirectory: WellKnownFormat<SignaturesDirectory>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
2
|
+
import { parse as parseJwks } from "./jwks.js";
|
|
3
|
+
import { pointer } from "./lib/json.js";
|
|
4
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
5
|
+
export const NAME = "http-message-signatures-directory";
|
|
6
|
+
export const MEDIA_TYPE = "application/http-message-signatures-directory+json";
|
|
7
|
+
/** The asymmetric algorithms the HTTP Signature Algorithms registry (RFC 9421 §6.2) lists. */
|
|
8
|
+
const SIGNATURE_ALGORITHMS = new Set([
|
|
9
|
+
"rsa-pss-sha512",
|
|
10
|
+
"rsa-v1_5-sha256",
|
|
11
|
+
"ecdsa-p256-sha256",
|
|
12
|
+
"ecdsa-p384-sha384",
|
|
13
|
+
"ed25519",
|
|
14
|
+
]);
|
|
15
|
+
/** Members that carry private or symmetric key material (RFC 7518 §6). */
|
|
16
|
+
const PRIVATE_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
|
|
17
|
+
/**
|
|
18
|
+
* Whether a usable JWK may stand in a directory: asymmetric and public, since the draft
|
|
19
|
+
* forbids shared secrets, with a registered `alg` when it names one.
|
|
20
|
+
*
|
|
21
|
+
* @param key - An entry the JWK Set reader kept.
|
|
22
|
+
*/
|
|
23
|
+
function isDirectoryKey(key) {
|
|
24
|
+
if (key.kty === "oct")
|
|
25
|
+
return false;
|
|
26
|
+
if (PRIVATE_MEMBERS.some((member) => member in key))
|
|
27
|
+
return false;
|
|
28
|
+
if (key.alg !== undefined && !SIGNATURE_ALGORITHMS.has(key.alg))
|
|
29
|
+
return false;
|
|
30
|
+
let { nbf, exp } = key;
|
|
31
|
+
return [nbf, exp].every((bound) => bound === undefined || typeof bound === "number");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A key without its private members, `key_ops` and `ext`, which a published key never needs, or
|
|
35
|
+
* `null` for a symmetric key, which has no public half.
|
|
36
|
+
*
|
|
37
|
+
* @param key - A key, public or private.
|
|
38
|
+
*/
|
|
39
|
+
function publicHalf(key) {
|
|
40
|
+
if (key.kty === "oct")
|
|
41
|
+
return null;
|
|
42
|
+
let half = { ...key };
|
|
43
|
+
for (let member of PRIVATE_MEMBERS)
|
|
44
|
+
delete half[member];
|
|
45
|
+
delete half.key_ops;
|
|
46
|
+
delete half.ext;
|
|
47
|
+
return half;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Reads a directory. A missing or non-array `keys` fails; every entry a verifier could not
|
|
51
|
+
* use is dropped and counted in `skipped`, so one bad key never hides the others.
|
|
52
|
+
*
|
|
53
|
+
* @param text - The served JSON.
|
|
54
|
+
* @example let directory = parse(await response.text());
|
|
55
|
+
*/
|
|
56
|
+
export function parse(text) {
|
|
57
|
+
let set = parseJwks(text);
|
|
58
|
+
if (isFailure(set)) {
|
|
59
|
+
return failure(new WellKnownParseError(NAME, set.error.issues));
|
|
60
|
+
}
|
|
61
|
+
let keys = set.data.keys.filter(isDirectoryKey);
|
|
62
|
+
let skipped = set.data.skipped + set.data.keys.length - keys.length;
|
|
63
|
+
return success({ keys, skipped });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Builds a directory from keys, public or private: each entry keeps its public members,
|
|
67
|
+
* `kid`, `alg`, `nbf` and `exp`, and gets `use: "sig"`. Verifiers match a signature's
|
|
68
|
+
* `keyid` against `kid`, so set it to the key's RFC 7638 thumbprint (`JWK.thumbprint`).
|
|
69
|
+
*
|
|
70
|
+
* @param keys - The signing keys; a private JWK publishes only its public half.
|
|
71
|
+
* @returns The directory, or an issue at every symmetric key, every key without `kid`, and
|
|
72
|
+
* every `alg` outside the HTTP Signature Algorithms registry, such as JOSE's `EdDSA`.
|
|
73
|
+
* @example build([{ ...publicJwk, kid: await thumbprintOf(publicJwk) }])
|
|
74
|
+
*/
|
|
75
|
+
export function build(keys) {
|
|
76
|
+
let issues = [];
|
|
77
|
+
let published = [];
|
|
78
|
+
for (let [index, key] of keys.entries()) {
|
|
79
|
+
let half = publicHalf(key);
|
|
80
|
+
if (half === null) {
|
|
81
|
+
issues.push({ at: pointer("keys", index), message: `Key ${index} is symmetric.` });
|
|
82
|
+
}
|
|
83
|
+
else if (half.kid === undefined) {
|
|
84
|
+
issues.push({ at: pointer("keys", index, "kid"), message: `Key ${index} has no kid.` });
|
|
85
|
+
}
|
|
86
|
+
else if (half.alg !== undefined && !SIGNATURE_ALGORITHMS.has(half.alg)) {
|
|
87
|
+
issues.push({
|
|
88
|
+
at: pointer("keys", index, "alg"),
|
|
89
|
+
message: `Key ${index}'s alg "${half.alg}" is no HTTP signature algorithm.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
published.push({ ...half, use: "sig" });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (issues.length > 0)
|
|
97
|
+
return failure(new WellKnownParseError(NAME, issues));
|
|
98
|
+
return success({ keys: published });
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A directory as a servable format, answered with the registered media type and
|
|
102
|
+
* `Access-Control-Allow-Origin: *` so a browser-based verifier can read it. Serving keeps
|
|
103
|
+
* the public half of every asymmetric key; pass what `build` answers so each carries its `kid`.
|
|
104
|
+
*/
|
|
105
|
+
export const httpMessageSignaturesDirectory = {
|
|
106
|
+
name: NAME,
|
|
107
|
+
mediaType: MEDIA_TYPE,
|
|
108
|
+
placement: "append",
|
|
109
|
+
cors: true,
|
|
110
|
+
stringify(document) {
|
|
111
|
+
let keys = document.keys.map(publicHalf).filter((key) => key !== null);
|
|
112
|
+
return JSON.stringify({ keys });
|
|
113
|
+
},
|
|
114
|
+
parse,
|
|
115
|
+
};
|
package/dist/lib/metadata.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The field-mapping engine behind the OAuth metadata documents (RFC 8414, OpenID
|
|
3
|
-
* Connect Discovery, RFC 9728): one table maps each camelCase
|
|
4
|
-
* member name and kind, and drives reading, writing and defaults from
|
|
3
|
+
* Connect Discovery, RFC 9728, RFC 7591 client metadata): one table maps each camelCase
|
|
4
|
+
* field to its member name and kind, and drives reading, writing and defaults from it.
|
|
5
5
|
*
|
|
6
6
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
7
|
* @copyright Sergio Xalambrí 2026
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
import type { Result } from "@sdxc/result";
|
|
10
10
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
11
11
|
import { WellKnownParseError } from "../parse-error.js";
|
|
12
|
+
import type { JsonObject } from "./json.js";
|
|
12
13
|
/**
|
|
13
14
|
* How a member's JSON value maps to its field: `text` is a string, `url` one absolute
|
|
14
|
-
* URL, `urls` and `strings` lists, `
|
|
15
|
-
*
|
|
15
|
+
* URL, `urls` and `strings` lists, `tokens` a space-delimited string read as a list (RFC
|
|
16
|
+
* 7591 `scope`), `object` a JSON object kept as decoded, `boolean` a flag, and `localized`
|
|
17
|
+
* a human-readable string with `#`-tagged language variants (RFC 9728 §2.1).
|
|
16
18
|
*/
|
|
17
|
-
export type FieldKind = "text" | "url" | "urls" | "strings" | "boolean" | "localized";
|
|
19
|
+
export type FieldKind = "text" | "url" | "urls" | "strings" | "tokens" | "object" | "boolean" | "localized";
|
|
18
20
|
/** One row of the mapping table. */
|
|
19
21
|
export interface Field {
|
|
20
22
|
/** The member name the specification registers. */
|
|
@@ -22,8 +24,8 @@ export interface Field {
|
|
|
22
24
|
kind: FieldKind;
|
|
23
25
|
/** A missing member fails the parse instead of taking the default. */
|
|
24
26
|
required?: boolean;
|
|
25
|
-
/** The value an absent boolean member means, which `stringify` leaves unwritten. */
|
|
26
|
-
default?: boolean;
|
|
27
|
+
/** The value an absent boolean or `strings` member means, which `stringify` leaves unwritten. */
|
|
28
|
+
default?: boolean | readonly string[];
|
|
27
29
|
/** The only values a `strings` member may list. */
|
|
28
30
|
values?: readonly string[];
|
|
29
31
|
}
|
|
@@ -40,8 +42,11 @@ export interface ReadOptions<Extensions extends object> {
|
|
|
40
42
|
format: string;
|
|
41
43
|
table: FieldTable;
|
|
42
44
|
extensions?: StandardSchemaV1<unknown, Extensions>;
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Checks the read fields against what the caller expected, adding issues on a mismatch;
|
|
47
|
+
* `body` is the decoded document, for a rule about a member outside the table.
|
|
48
|
+
*/
|
|
49
|
+
check?: (fields: Record<string, unknown>, issues: WellKnownParseError.Issue[], body: JsonObject) => void;
|
|
45
50
|
}
|
|
46
51
|
/**
|
|
47
52
|
* Reads a metadata document through its table. Members outside the table become the
|
package/dist/lib/metadata.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { failure, success } from "@sdxc/result";
|
|
2
2
|
import { WellKnownParseError } from "../parse-error.js";
|
|
3
|
-
import { pointer, readExtensions, readJsonObject } from "./json.js";
|
|
3
|
+
import { isObject, pointer, readExtensions, readJsonObject } from "./json.js";
|
|
4
4
|
/**
|
|
5
5
|
* The value an absent member takes: `null` for single values, an empty list for
|
|
6
6
|
* lists, and the specification's default for flags.
|
|
@@ -8,12 +8,25 @@ import { pointer, readExtensions, readJsonObject } from "./json.js";
|
|
|
8
8
|
* @param field - The row to default.
|
|
9
9
|
*/
|
|
10
10
|
function defaultOf(field) {
|
|
11
|
-
if (field.kind === "urls" || field.kind === "strings")
|
|
12
|
-
return [];
|
|
11
|
+
if (field.kind === "urls" || field.kind === "strings" || field.kind === "tokens")
|
|
12
|
+
return Array.isArray(field.default) ? [...field.default] : [];
|
|
13
13
|
if (field.kind === "boolean")
|
|
14
14
|
return field.default ?? false;
|
|
15
15
|
return null;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether a list holds exactly a field's default list, in order, so the writer leaves a
|
|
19
|
+
* member out only when reading it back gives the same value.
|
|
20
|
+
*
|
|
21
|
+
* @param value - The field's list.
|
|
22
|
+
* @param field - The row it belongs to.
|
|
23
|
+
*/
|
|
24
|
+
function isDefaultList(value, field) {
|
|
25
|
+
if (!Array.isArray(field.default))
|
|
26
|
+
return false;
|
|
27
|
+
let list = field.default;
|
|
28
|
+
return value.length === list.length && value.every((entry, index) => entry === list[index]);
|
|
29
|
+
}
|
|
17
30
|
/**
|
|
18
31
|
* Every field of a table at its default, for `define` to lay the app's values over.
|
|
19
32
|
*
|
|
@@ -81,6 +94,18 @@ function readField(body, field, issues) {
|
|
|
81
94
|
issues.push({ at, message: `"${field.wire}" is not an absolute URL.` });
|
|
82
95
|
return null;
|
|
83
96
|
}
|
|
97
|
+
case "tokens": {
|
|
98
|
+
if (typeof value === "string")
|
|
99
|
+
return value.split(" ").filter(Boolean);
|
|
100
|
+
issues.push({ at, message: `"${field.wire}" is not a space-delimited string.` });
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
case "object": {
|
|
104
|
+
if (isObject(value))
|
|
105
|
+
return value;
|
|
106
|
+
issues.push({ at, message: `"${field.wire}" is not a JSON object.` });
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
84
109
|
case "boolean": {
|
|
85
110
|
if (typeof value === "boolean")
|
|
86
111
|
return value;
|
|
@@ -184,7 +209,7 @@ export function readMetadata(text, options) {
|
|
|
184
209
|
for (let [name, field] of Object.entries(options.table))
|
|
185
210
|
fields[name] = readField(body, field, issues);
|
|
186
211
|
if (issues.length === 0)
|
|
187
|
-
options.check?.(fields, issues);
|
|
212
|
+
options.check?.(fields, issues, body);
|
|
188
213
|
let members = {};
|
|
189
214
|
for (let [member, value] of Object.entries(body)) {
|
|
190
215
|
if (!isStandardMember(options.table, member))
|
|
@@ -219,8 +244,14 @@ export function writeMetadata(document, table) {
|
|
|
219
244
|
if (value === null || value === undefined)
|
|
220
245
|
continue;
|
|
221
246
|
if (Array.isArray(value)) {
|
|
222
|
-
if (value
|
|
247
|
+
if (isDefaultList(value, field))
|
|
248
|
+
continue;
|
|
249
|
+
if (value.length === 0 && !field.required && !Array.isArray(field.default))
|
|
250
|
+
continue;
|
|
251
|
+
if (field.kind === "tokens") {
|
|
252
|
+
output[field.wire] = value.join(" ");
|
|
223
253
|
continue;
|
|
254
|
+
}
|
|
224
255
|
output[field.wire] = value.map((entry) => (entry instanceof URL ? entry.href : entry));
|
|
225
256
|
continue;
|
|
226
257
|
}
|