@sdxc/well-known 0.0.0-pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +313 -0
- package/dist/change-password.d.ts +19 -0
- package/dist/change-password.js +21 -0
- package/dist/format.d.ts +34 -0
- package/dist/format.js +0 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -0
- package/dist/jwks.d.ts +80 -0
- package/dist/jwks.js +100 -0
- package/dist/lib/identifier.d.ts +15 -0
- package/dist/lib/identifier.js +32 -0
- package/dist/lib/json.d.ts +45 -0
- package/dist/lib/json.js +67 -0
- package/dist/lib/metadata.d.ts +66 -0
- package/dist/lib/metadata.js +242 -0
- package/dist/lib/oauth-fields.d.ts +291 -0
- package/dist/lib/oauth-fields.js +103 -0
- package/dist/middleware.d.ts +35 -0
- package/dist/middleware.js +88 -0
- package/dist/oauth-authorization-server.d.ts +99 -0
- package/dist/oauth-authorization-server.js +61 -0
- package/dist/oauth-protected-resource.d.ts +110 -0
- package/dist/oauth-protected-resource.js +136 -0
- package/dist/openid-configuration.d.ts +75 -0
- package/dist/openid-configuration.js +60 -0
- package/dist/parse-error.d.ts +34 -0
- package/dist/parse-error.js +29 -0
- package/dist/passkey-endpoints.d.ts +36 -0
- package/dist/passkey-endpoints.js +40 -0
- package/dist/response.d.ts +32 -0
- package/dist/response.js +35 -0
- package/dist/security-txt.d.ts +64 -0
- package/dist/security-txt.js +228 -0
- package/dist/webfinger.d.ts +75 -0
- package/dist/webfinger.js +205 -0
- package/dist/well-known-url.d.ts +26 -0
- package/dist/well-known-url.js +29 -0
- package/package.json +46 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { failure, success } from "@sdxc/result";
|
|
2
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
3
|
+
export const NAME = "security.txt";
|
|
4
|
+
export const MEDIA_TYPE = "text/plain; charset=utf-8";
|
|
5
|
+
/** The URI-valued fields, by lowercased field name, and whether a web URI is all they take. */
|
|
6
|
+
const URI_FIELDS = {
|
|
7
|
+
contact: { key: "contact", httpsOnly: false },
|
|
8
|
+
encryption: { key: "encryption", httpsOnly: false },
|
|
9
|
+
acknowledgments: { key: "acknowledgments", httpsOnly: true },
|
|
10
|
+
canonical: { key: "canonical", httpsOnly: true },
|
|
11
|
+
policy: { key: "policy", httpsOnly: true },
|
|
12
|
+
hiring: { key: "hiring", httpsOnly: true },
|
|
13
|
+
};
|
|
14
|
+
/** A field line: a name of visible characters other than `:`, then its value (§3). */
|
|
15
|
+
const FIELD_LINE = /^([!-9;-~]+):[ \t]*(.*?)[ \t]*$/;
|
|
16
|
+
/** An RFC 3339 `date-time`, which §2.5.5 requires of `Expires`. */
|
|
17
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
18
|
+
/** The armor line opening an OpenPGP cleartext signature (RFC 4880 §7). */
|
|
19
|
+
const SIGNED_HEADER = "-----BEGIN PGP SIGNED MESSAGE-----";
|
|
20
|
+
/** The armor line closing the signed body. */
|
|
21
|
+
const SIGNATURE_HEADER = "-----BEGIN PGP SIGNATURE-----";
|
|
22
|
+
/**
|
|
23
|
+
* The lines to read: every line of an unsigned file, or the dash-unescaped signed body
|
|
24
|
+
* of a cleartext-signed one, each keeping its number in the original text.
|
|
25
|
+
*
|
|
26
|
+
* @param text - The served text.
|
|
27
|
+
* @param issues - Where a signature with no body end is reported.
|
|
28
|
+
*/
|
|
29
|
+
function bodyLines(text, issues) {
|
|
30
|
+
let all = text
|
|
31
|
+
.replace(/^/, "")
|
|
32
|
+
.split(/\r?\n/)
|
|
33
|
+
.map((line, index) => ({ number: index + 1, text: line }));
|
|
34
|
+
let start = all.findIndex((line) => line.text.trim() !== "");
|
|
35
|
+
if (all[start]?.text.trim() !== SIGNED_HEADER)
|
|
36
|
+
return { lines: all, signed: false };
|
|
37
|
+
let headersEnd = all.findIndex((line, index) => index > start && line.text.trim() === "");
|
|
38
|
+
let end = all.findIndex((line, index) => index > headersEnd && line.text.trim() === SIGNATURE_HEADER);
|
|
39
|
+
if (headersEnd === -1 || end === -1) {
|
|
40
|
+
issues.push({
|
|
41
|
+
at: all[start]?.number ?? 0,
|
|
42
|
+
message: "The signed message has no signature block.",
|
|
43
|
+
});
|
|
44
|
+
return { lines: [], signed: true };
|
|
45
|
+
}
|
|
46
|
+
let lines = all.slice(headersEnd + 1, end).map((line) => ({
|
|
47
|
+
...line,
|
|
48
|
+
text: line.text.startsWith("- ") ? line.text.slice(2) : line.text,
|
|
49
|
+
}));
|
|
50
|
+
return { lines, signed: true };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reads a URI field's value, enforcing §2.5's rule that a web URI uses `https` and, for
|
|
54
|
+
* the fields that take only a web URI, that it is one.
|
|
55
|
+
*
|
|
56
|
+
* @param value - The field value.
|
|
57
|
+
* @param field - The field name as written.
|
|
58
|
+
* @param httpsOnly - Whether the field takes nothing but an `https` URI.
|
|
59
|
+
* @param line - The line number, for the issue.
|
|
60
|
+
* @param issues - Where problems are collected.
|
|
61
|
+
*/
|
|
62
|
+
function readUri(value, field, httpsOnly, line, issues) {
|
|
63
|
+
if (!URL.canParse(value)) {
|
|
64
|
+
issues.push({ at: line, message: `${field} is not a URI.` });
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
let url = new URL(value);
|
|
68
|
+
if (url.protocol === "http:" || (httpsOnly && url.protocol !== "https:")) {
|
|
69
|
+
issues.push({ at: line, message: `${field} must use https.` });
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return url;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reads a security.txt file. It fails on a missing `Contact`, a missing or repeated
|
|
76
|
+
* `Expires`, a repeated `Preferred-Languages`, an `http` URI (or a non-`https` one where
|
|
77
|
+
* §2.5 asks for a web URI), and a line that is neither a field, a comment nor blank.
|
|
78
|
+
* An expired file parses; `isExpired` answers staleness.
|
|
79
|
+
*
|
|
80
|
+
* @param text - The served text, signed or not; the signature is left unverified.
|
|
81
|
+
*/
|
|
82
|
+
export function parse(text) {
|
|
83
|
+
let issues = [];
|
|
84
|
+
let { lines, signed } = bodyLines(text, issues);
|
|
85
|
+
let uris = {
|
|
86
|
+
contact: [],
|
|
87
|
+
encryption: [],
|
|
88
|
+
acknowledgments: [],
|
|
89
|
+
canonical: [],
|
|
90
|
+
policy: [],
|
|
91
|
+
hiring: [],
|
|
92
|
+
};
|
|
93
|
+
let expires = [];
|
|
94
|
+
let expiresFields = 0;
|
|
95
|
+
let languages = [];
|
|
96
|
+
let extensions = {};
|
|
97
|
+
for (let line of lines) {
|
|
98
|
+
let trimmed = line.text.trim();
|
|
99
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
100
|
+
continue;
|
|
101
|
+
let match = FIELD_LINE.exec(line.text);
|
|
102
|
+
if (!match) {
|
|
103
|
+
issues.push({ at: line.number, message: "The line is not a field, a comment or blank." });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let field = match[1] ?? "";
|
|
107
|
+
let value = match[2] ?? "";
|
|
108
|
+
let key = field.toLowerCase();
|
|
109
|
+
if (key in URI_FIELDS) {
|
|
110
|
+
let spec = URI_FIELDS[key];
|
|
111
|
+
let url = readUri(value, field, spec.httpsOnly, line.number, issues);
|
|
112
|
+
if (url)
|
|
113
|
+
uris[spec.key].push(url);
|
|
114
|
+
}
|
|
115
|
+
else if (key === "expires") {
|
|
116
|
+
expiresFields += 1;
|
|
117
|
+
let date = new Date(value);
|
|
118
|
+
if (DATE_TIME.test(value) && !Number.isNaN(date.getTime()))
|
|
119
|
+
expires.push(date);
|
|
120
|
+
else
|
|
121
|
+
issues.push({ at: line.number, message: "Expires is not an RFC 3339 date-time." });
|
|
122
|
+
if (expiresFields > 1)
|
|
123
|
+
issues.push({ at: line.number, message: "Expires appears more than once." });
|
|
124
|
+
}
|
|
125
|
+
else if (key === "preferred-languages") {
|
|
126
|
+
languages.push(value
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((tag) => tag.trim())
|
|
129
|
+
.filter((tag) => tag !== ""));
|
|
130
|
+
if (languages.length > 1) {
|
|
131
|
+
issues.push({ at: line.number, message: "Preferred-Languages appears more than once." });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
(extensions[key] ??= []).push(value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let [firstContact, ...otherContacts] = uris.contact;
|
|
139
|
+
let [expiry] = expires;
|
|
140
|
+
if (firstContact === undefined)
|
|
141
|
+
issues.push({ at: 0, message: "The file has no Contact field." });
|
|
142
|
+
if (expiresFields === 0)
|
|
143
|
+
issues.push({ at: 0, message: "The file has no Expires field." });
|
|
144
|
+
if (issues.length > 0 || firstContact === undefined || expiry === undefined) {
|
|
145
|
+
return failure(new WellKnownParseError(NAME, issues));
|
|
146
|
+
}
|
|
147
|
+
return success({
|
|
148
|
+
contact: [firstContact, ...otherContacts],
|
|
149
|
+
expires: expiry,
|
|
150
|
+
encryption: uris.encryption,
|
|
151
|
+
acknowledgments: uris.acknowledgments,
|
|
152
|
+
preferredLanguages: languages[0] ?? [],
|
|
153
|
+
canonical: uris.canonical,
|
|
154
|
+
policy: uris.policy,
|
|
155
|
+
hiring: uris.hiring,
|
|
156
|
+
extensions,
|
|
157
|
+
signed,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A field value safe to write on one line, so a value carrying a line break can never
|
|
162
|
+
* start a field of its own.
|
|
163
|
+
*
|
|
164
|
+
* @param value - The value to write.
|
|
165
|
+
*/
|
|
166
|
+
function oneLine(value) {
|
|
167
|
+
return value.replace(/[\r\n]+/g, " ").trim();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The canonical form of a field name for writing: each hyphenated word capitalized, as
|
|
171
|
+
* RFC 9116 writes its own fields.
|
|
172
|
+
*
|
|
173
|
+
* @param key - A lowercased field name.
|
|
174
|
+
*/
|
|
175
|
+
function fieldName(key) {
|
|
176
|
+
return key.replace(/(^|-)([a-z])/g, (_, dash, letter) => `${dash}${letter.toUpperCase()}`);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Writes unsigned security.txt text: comments first, then the §2.5 fields, then the
|
|
180
|
+
* extensions, one per line with LF endings. `Expires` is written in UTC to the second.
|
|
181
|
+
*
|
|
182
|
+
* @param document - The fields to publish.
|
|
183
|
+
* @param options - Comment lines to write before the fields.
|
|
184
|
+
* @example
|
|
185
|
+
* stringify({ ...fields, expires: new Date("2027-01-01T00:00:00Z") }, { comments: ["Report issues here"] });
|
|
186
|
+
*/
|
|
187
|
+
export function stringify(document, options = {}) {
|
|
188
|
+
let lines = [];
|
|
189
|
+
for (let comment of options.comments ?? []) {
|
|
190
|
+
for (let part of comment.split(/\r?\n/))
|
|
191
|
+
lines.push(part === "" ? "#" : `# ${part}`);
|
|
192
|
+
}
|
|
193
|
+
let write = (field, values) => {
|
|
194
|
+
for (let value of values)
|
|
195
|
+
lines.push(`${field}: ${oneLine(String(value))}`);
|
|
196
|
+
};
|
|
197
|
+
write("Contact", document.contact);
|
|
198
|
+
lines.push(`Expires: ${document.expires.toISOString().replace(/\.\d{3}Z$/, "Z")}`);
|
|
199
|
+
write("Encryption", document.encryption);
|
|
200
|
+
write("Acknowledgments", document.acknowledgments);
|
|
201
|
+
if (document.preferredLanguages.length > 0) {
|
|
202
|
+
write("Preferred-Languages", [document.preferredLanguages.join(", ")]);
|
|
203
|
+
}
|
|
204
|
+
write("Canonical", document.canonical);
|
|
205
|
+
write("Policy", document.policy);
|
|
206
|
+
write("Hiring", document.hiring);
|
|
207
|
+
for (let [key, values] of Object.entries(document.extensions))
|
|
208
|
+
write(fieldName(key), values);
|
|
209
|
+
return `${lines.join("\n")}\n`;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* RFC 9116 §2.5.5: a file past its `Expires` is stale and SHOULD be ignored.
|
|
213
|
+
*
|
|
214
|
+
* @param document - The file's fields.
|
|
215
|
+
* @param now - The moment to judge against.
|
|
216
|
+
*/
|
|
217
|
+
export function isExpired(document, now = new Date()) {
|
|
218
|
+
return document.expires.getTime() <= now.getTime();
|
|
219
|
+
}
|
|
220
|
+
/** security.txt as a servable format. */
|
|
221
|
+
export const securityTxt = {
|
|
222
|
+
name: NAME,
|
|
223
|
+
mediaType: MEDIA_TYPE,
|
|
224
|
+
placement: "insert",
|
|
225
|
+
cors: false,
|
|
226
|
+
stringify: (document) => stringify(document),
|
|
227
|
+
parse,
|
|
228
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFinger (RFC 7033): the JRD document, reading the `resource` and `rel` query
|
|
3
|
+
* parameters, and selecting only the links asked for, so a server answers the way
|
|
4
|
+
* §4 requires and a client reads what any server returns.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { WellKnownFormat } from "./format.js";
|
|
11
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
12
|
+
export declare const NAME = "webfinger";
|
|
13
|
+
export declare const MEDIA_TYPE = "application/jrd+json";
|
|
14
|
+
/** The JSON Resource Descriptor (RFC 7033 §4.4). */
|
|
15
|
+
export interface Jrd {
|
|
16
|
+
subject: string | null;
|
|
17
|
+
aliases: string[];
|
|
18
|
+
/** Property URIs to values; RFC 7033 §4.4.3 allows `null`. */
|
|
19
|
+
properties: Record<string, string | null>;
|
|
20
|
+
links: JrdLink[];
|
|
21
|
+
}
|
|
22
|
+
/** One link of a JRD (RFC 7033 §4.4.4). */
|
|
23
|
+
export interface JrdLink {
|
|
24
|
+
rel: string;
|
|
25
|
+
type: string | null;
|
|
26
|
+
href: string | null;
|
|
27
|
+
/** Language tag, or `und`, to title. */
|
|
28
|
+
titles: Record<string, string>;
|
|
29
|
+
properties: Record<string, string | null>;
|
|
30
|
+
}
|
|
31
|
+
/** What a WebFinger request asks for. */
|
|
32
|
+
export interface WebFingerQuery {
|
|
33
|
+
resource: string;
|
|
34
|
+
/** Every `rel` parameter, in order; empty selects all links. */
|
|
35
|
+
rels: string[];
|
|
36
|
+
}
|
|
37
|
+
/** A WebFinger request without a `resource`, which §4.2 answers with a 400. */
|
|
38
|
+
export declare class MissingResourceError extends Error {
|
|
39
|
+
name: string;
|
|
40
|
+
constructor();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reads `resource` and every `rel` from a request URL, per RFC 7033 §4.1. A missing or
|
|
44
|
+
* empty `resource` fails, so the caller answers 400 as §4.2 requires.
|
|
45
|
+
*
|
|
46
|
+
* @param url - The request URL.
|
|
47
|
+
* @example
|
|
48
|
+
* let query = readQuery(ctx.url);
|
|
49
|
+
* if (isFailure(query)) return new Response(query.error.message, { status: 400 });
|
|
50
|
+
*/
|
|
51
|
+
export declare function readQuery(url: URL): Result<WebFingerQuery, MissingResourceError>;
|
|
52
|
+
/**
|
|
53
|
+
* The document with only the links whose `rel` was asked for, per §4.3; an empty list
|
|
54
|
+
* keeps every link, and every other member is kept either way.
|
|
55
|
+
*
|
|
56
|
+
* @param document - The full document.
|
|
57
|
+
* @param rels - The requested relation types.
|
|
58
|
+
*/
|
|
59
|
+
export declare function select(document: Jrd, rels: string[]): Jrd;
|
|
60
|
+
/**
|
|
61
|
+
* Reads a JRD. Absent members read as empty; a member of the wrong type or a link
|
|
62
|
+
* without `rel` fails, with every issue reported.
|
|
63
|
+
*
|
|
64
|
+
* @param text - The served JSON.
|
|
65
|
+
*/
|
|
66
|
+
export declare function parse(text: string): Result<Jrd, WellKnownParseError>;
|
|
67
|
+
/**
|
|
68
|
+
* Writes a JRD, leaving out a `null` subject, empty lists and empty maps, and a link's
|
|
69
|
+
* `null` members.
|
|
70
|
+
*
|
|
71
|
+
* @param document - The descriptor to publish.
|
|
72
|
+
*/
|
|
73
|
+
export declare function stringify(document: Jrd): string;
|
|
74
|
+
/** WebFinger as a servable format; every answer carries CORS `*`, as §5 requires. */
|
|
75
|
+
export declare const webFinger: WellKnownFormat<Jrd>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { failure, success } from "@sdxc/result";
|
|
2
|
+
import { isObject, pointer, readJsonObject } from "./lib/json.js";
|
|
3
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
4
|
+
export const NAME = "webfinger";
|
|
5
|
+
export const MEDIA_TYPE = "application/jrd+json";
|
|
6
|
+
/** A WebFinger request without a `resource`, which §4.2 answers with a 400. */
|
|
7
|
+
export class MissingResourceError extends Error {
|
|
8
|
+
name = "MissingResourceError";
|
|
9
|
+
constructor() {
|
|
10
|
+
super('The WebFinger request has no "resource" parameter.');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reads `resource` and every `rel` from a request URL, per RFC 7033 §4.1. A missing or
|
|
15
|
+
* empty `resource` fails, so the caller answers 400 as §4.2 requires.
|
|
16
|
+
*
|
|
17
|
+
* @param url - The request URL.
|
|
18
|
+
* @example
|
|
19
|
+
* let query = readQuery(ctx.url);
|
|
20
|
+
* if (isFailure(query)) return new Response(query.error.message, { status: 400 });
|
|
21
|
+
*/
|
|
22
|
+
export function readQuery(url) {
|
|
23
|
+
let resource = url.searchParams.get("resource");
|
|
24
|
+
if (resource === null || resource === "")
|
|
25
|
+
return failure(new MissingResourceError());
|
|
26
|
+
return success({ resource, rels: url.searchParams.getAll("rel") });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The document with only the links whose `rel` was asked for, per §4.3; an empty list
|
|
30
|
+
* keeps every link, and every other member is kept either way.
|
|
31
|
+
*
|
|
32
|
+
* @param document - The full document.
|
|
33
|
+
* @param rels - The requested relation types.
|
|
34
|
+
*/
|
|
35
|
+
export function select(document, rels) {
|
|
36
|
+
if (rels.length === 0)
|
|
37
|
+
return document;
|
|
38
|
+
return { ...document, links: document.links.filter((link) => rels.includes(link.rel)) };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reads a property map, whose values §4.4.3 allows to be strings or `null`.
|
|
42
|
+
*
|
|
43
|
+
* @param value - The decoded member.
|
|
44
|
+
* @param at - The member's pointer.
|
|
45
|
+
* @param issues - Where problems are collected.
|
|
46
|
+
*/
|
|
47
|
+
function readProperties(value, at, issues) {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return {};
|
|
50
|
+
if (!isObject(value)) {
|
|
51
|
+
issues.push({ at, message: "Properties are not an object." });
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
let result = {};
|
|
55
|
+
for (let [key, entry] of Object.entries(value)) {
|
|
56
|
+
if (typeof entry === "string" || entry === null)
|
|
57
|
+
result[key] = entry;
|
|
58
|
+
else
|
|
59
|
+
issues.push({
|
|
60
|
+
at: `${at}${pointer(key)}`,
|
|
61
|
+
message: "A property value is not a string or null.",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Reads a member that must be a string when present.
|
|
68
|
+
*
|
|
69
|
+
* @param body - The object holding it.
|
|
70
|
+
* @param member - The member name.
|
|
71
|
+
* @param at - The object's pointer.
|
|
72
|
+
* @param issues - Where problems are collected.
|
|
73
|
+
*/
|
|
74
|
+
function readOptionalString(body, member, at, issues) {
|
|
75
|
+
let value = body[member];
|
|
76
|
+
if (value === undefined || value === null)
|
|
77
|
+
return null;
|
|
78
|
+
if (typeof value === "string")
|
|
79
|
+
return value;
|
|
80
|
+
issues.push({ at: `${at}${pointer(member)}`, message: `"${member}" is not a string.` });
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads one link, which must name its `rel`.
|
|
85
|
+
*
|
|
86
|
+
* @param value - The decoded array element.
|
|
87
|
+
* @param at - The element's pointer.
|
|
88
|
+
* @param issues - Where problems are collected.
|
|
89
|
+
*/
|
|
90
|
+
function readLink(value, at, issues) {
|
|
91
|
+
if (!isObject(value)) {
|
|
92
|
+
issues.push({ at, message: "A link is not an object." });
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (typeof value.rel !== "string") {
|
|
96
|
+
issues.push({ at: `${at}/rel`, message: 'A link has no string "rel".' });
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
let titles = {};
|
|
100
|
+
if (value.titles !== undefined) {
|
|
101
|
+
if (isObject(value.titles)) {
|
|
102
|
+
for (let [tag, title] of Object.entries(value.titles)) {
|
|
103
|
+
if (typeof title === "string")
|
|
104
|
+
titles[tag] = title;
|
|
105
|
+
else
|
|
106
|
+
issues.push({ at: `${at}/titles${pointer(tag)}`, message: "A title is not a string." });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
issues.push({ at: `${at}/titles`, message: "Titles are not an object." });
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
rel: value.rel,
|
|
114
|
+
type: readOptionalString(value, "type", at, issues),
|
|
115
|
+
href: readOptionalString(value, "href", at, issues),
|
|
116
|
+
titles,
|
|
117
|
+
properties: readProperties(value.properties, `${at}/properties`, issues),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Reads a JRD. Absent members read as empty; a member of the wrong type or a link
|
|
122
|
+
* without `rel` fails, with every issue reported.
|
|
123
|
+
*
|
|
124
|
+
* @param text - The served JSON.
|
|
125
|
+
*/
|
|
126
|
+
export function parse(text) {
|
|
127
|
+
let decoded = readJsonObject(text, NAME);
|
|
128
|
+
if (decoded.status === "failure")
|
|
129
|
+
return decoded;
|
|
130
|
+
let body = decoded.data;
|
|
131
|
+
let issues = [];
|
|
132
|
+
let aliases = [];
|
|
133
|
+
if (body.aliases !== undefined) {
|
|
134
|
+
if (Array.isArray(body.aliases)) {
|
|
135
|
+
for (let [index, alias] of body.aliases.entries()) {
|
|
136
|
+
if (typeof alias === "string")
|
|
137
|
+
aliases.push(alias);
|
|
138
|
+
else
|
|
139
|
+
issues.push({ at: pointer("aliases", index), message: "An alias is not a string." });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else
|
|
143
|
+
issues.push({ at: "/aliases", message: '"aliases" is not an array.' });
|
|
144
|
+
}
|
|
145
|
+
let links = [];
|
|
146
|
+
if (body.links !== undefined) {
|
|
147
|
+
if (Array.isArray(body.links)) {
|
|
148
|
+
for (let [index, entry] of body.links.entries()) {
|
|
149
|
+
let link = readLink(entry, pointer("links", index), issues);
|
|
150
|
+
if (link)
|
|
151
|
+
links.push(link);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
issues.push({ at: "/links", message: '"links" is not an array.' });
|
|
156
|
+
}
|
|
157
|
+
let document = {
|
|
158
|
+
subject: readOptionalString(body, "subject", "", issues),
|
|
159
|
+
aliases,
|
|
160
|
+
properties: readProperties(body.properties, "/properties", issues),
|
|
161
|
+
links,
|
|
162
|
+
};
|
|
163
|
+
if (issues.length > 0)
|
|
164
|
+
return failure(new WellKnownParseError(NAME, issues));
|
|
165
|
+
return success(document);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Writes a JRD, leaving out a `null` subject, empty lists and empty maps, and a link's
|
|
169
|
+
* `null` members.
|
|
170
|
+
*
|
|
171
|
+
* @param document - The descriptor to publish.
|
|
172
|
+
*/
|
|
173
|
+
export function stringify(document) {
|
|
174
|
+
let output = {};
|
|
175
|
+
if (document.subject !== null)
|
|
176
|
+
output.subject = document.subject;
|
|
177
|
+
if (document.aliases.length > 0)
|
|
178
|
+
output.aliases = document.aliases;
|
|
179
|
+
if (Object.keys(document.properties).length > 0)
|
|
180
|
+
output.properties = document.properties;
|
|
181
|
+
if (document.links.length > 0) {
|
|
182
|
+
output.links = document.links.map((link) => {
|
|
183
|
+
let written = { rel: link.rel };
|
|
184
|
+
if (link.type !== null)
|
|
185
|
+
written.type = link.type;
|
|
186
|
+
if (link.href !== null)
|
|
187
|
+
written.href = link.href;
|
|
188
|
+
if (Object.keys(link.titles).length > 0)
|
|
189
|
+
written.titles = link.titles;
|
|
190
|
+
if (Object.keys(link.properties).length > 0)
|
|
191
|
+
written.properties = link.properties;
|
|
192
|
+
return written;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return JSON.stringify(output);
|
|
196
|
+
}
|
|
197
|
+
/** WebFinger as a servable format; every answer carries CORS `*`, as §5 requires. */
|
|
198
|
+
export const webFinger = {
|
|
199
|
+
name: NAME,
|
|
200
|
+
mediaType: MEDIA_TYPE,
|
|
201
|
+
placement: "insert",
|
|
202
|
+
cors: true,
|
|
203
|
+
stringify,
|
|
204
|
+
parse,
|
|
205
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the URL a well-known document lives at for an identifier, applying the
|
|
3
|
+
* placement rule its specification fixes, so an identifier carrying a path finds its
|
|
4
|
+
* document where the server actually serves it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { WellKnownPlacement } from "./format.js";
|
|
10
|
+
/**
|
|
11
|
+
* The URL a well-known document is served at for an identifier. Inserting keeps the
|
|
12
|
+
* identifier's path and query after the suffix and drops a terminating slash after the
|
|
13
|
+
* host (RFC 9728 §3.1); appending drops the query, fragment and trailing slashes.
|
|
14
|
+
*
|
|
15
|
+
* @param identifier - An absolute URL: an issuer, a resource, or an origin.
|
|
16
|
+
* @param name - The registered suffix, such as `oauth-authorization-server`.
|
|
17
|
+
* @param placement - Where the suffix goes; RFC 8414 and RFC 9728 insert, OIDC appends.
|
|
18
|
+
* @returns The document's URL.
|
|
19
|
+
* @example
|
|
20
|
+
* wellKnownUrl("https://as.example/tenant", "oauth-authorization-server");
|
|
21
|
+
* // https://as.example/.well-known/oauth-authorization-server/tenant
|
|
22
|
+
* @example
|
|
23
|
+
* wellKnownUrl("https://op.example/tenant", "openid-configuration", "append");
|
|
24
|
+
* // https://op.example/tenant/.well-known/openid-configuration
|
|
25
|
+
*/
|
|
26
|
+
export declare function wellKnownUrl(identifier: URL | string, name: string, placement?: WellKnownPlacement): URL;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Matches the slashes an identifier ends with before a suffix is appended. */
|
|
2
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
3
|
+
/**
|
|
4
|
+
* The URL a well-known document is served at for an identifier. Inserting keeps the
|
|
5
|
+
* identifier's path and query after the suffix and drops a terminating slash after the
|
|
6
|
+
* host (RFC 9728 §3.1); appending drops the query, fragment and trailing slashes.
|
|
7
|
+
*
|
|
8
|
+
* @param identifier - An absolute URL: an issuer, a resource, or an origin.
|
|
9
|
+
* @param name - The registered suffix, such as `oauth-authorization-server`.
|
|
10
|
+
* @param placement - Where the suffix goes; RFC 8414 and RFC 9728 insert, OIDC appends.
|
|
11
|
+
* @returns The document's URL.
|
|
12
|
+
* @example
|
|
13
|
+
* wellKnownUrl("https://as.example/tenant", "oauth-authorization-server");
|
|
14
|
+
* // https://as.example/.well-known/oauth-authorization-server/tenant
|
|
15
|
+
* @example
|
|
16
|
+
* wellKnownUrl("https://op.example/tenant", "openid-configuration", "append");
|
|
17
|
+
* // https://op.example/tenant/.well-known/openid-configuration
|
|
18
|
+
*/
|
|
19
|
+
export function wellKnownUrl(identifier, name, placement = "insert") {
|
|
20
|
+
let source = new URL(identifier);
|
|
21
|
+
if (placement === "append") {
|
|
22
|
+
let path = source.pathname.replace(TRAILING_SLASHES, "");
|
|
23
|
+
return new URL(`${path}/.well-known/${name}`, source.origin);
|
|
24
|
+
}
|
|
25
|
+
let path = source.pathname === "/" ? "" : source.pathname;
|
|
26
|
+
let url = new URL(`/.well-known/${name}${path}`, source.origin);
|
|
27
|
+
url.search = source.search;
|
|
28
|
+
return url;
|
|
29
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sdxc/well-known",
|
|
3
|
+
"version": "0.0.0-pre.1",
|
|
4
|
+
"description": "Typed documents for well-known URIs: security.txt, WebFinger, OAuth and OIDC metadata, JWKS and more",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js",
|
|
9
|
+
"./security-txt": "./dist/security-txt.js",
|
|
10
|
+
"./webfinger": "./dist/webfinger.js",
|
|
11
|
+
"./oauth-authorization-server": "./dist/oauth-authorization-server.js",
|
|
12
|
+
"./openid-configuration": "./dist/openid-configuration.js",
|
|
13
|
+
"./oauth-protected-resource": "./dist/oauth-protected-resource.js",
|
|
14
|
+
"./jwks": "./dist/jwks.js",
|
|
15
|
+
"./change-password": "./dist/change-password.js",
|
|
16
|
+
"./passkey-endpoints": "./dist/passkey-endpoints.js",
|
|
17
|
+
"./response": "./dist/response.js",
|
|
18
|
+
"./middleware": "./dist/middleware.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@remix-run/data-schema": "^0.3.0",
|
|
22
|
+
"@sdxc/result": "2026.9.15",
|
|
23
|
+
"@standard-schema/spec": "^1.1.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@sdxc/http": "2026.9.23",
|
|
27
|
+
"remix": "3.0.0-rc.2"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@sdxc/http": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"remix": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"gitHead": "f0a77f407e4c30389df65cd8138b150e367da94f",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
44
|
+
"directory": "packages/well-known"
|
|
45
|
+
}
|
|
46
|
+
}
|