@gaballahtech/seba 0.0.0-stage → 0.2.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.
Files changed (86) hide show
  1. package/ARCHITECTURE.md +242 -0
  2. package/BUILD_REPORT.md +195 -0
  3. package/CHANGELOG.md +86 -0
  4. package/LICENSE +21 -0
  5. package/README.md +282 -2
  6. package/RESEARCH.md +180 -0
  7. package/SECURITY.md +224 -0
  8. package/VALIDATION_REPORT.md +187 -0
  9. package/dist/browser/index.d.ts +9 -0
  10. package/dist/browser/index.d.ts.map +1 -0
  11. package/dist/browser/index.js +9 -0
  12. package/dist/browser/index.js.map +1 -0
  13. package/dist/browser/platform.d.ts +14 -0
  14. package/dist/browser/platform.d.ts.map +1 -0
  15. package/dist/browser/platform.js +60 -0
  16. package/dist/browser/platform.js.map +1 -0
  17. package/dist/browser/provider-modal.d.ts +15 -0
  18. package/dist/browser/provider-modal.d.ts.map +1 -0
  19. package/dist/browser/provider-modal.js +126 -0
  20. package/dist/browser/provider-modal.js.map +1 -0
  21. package/dist/browser/storage.d.ts +32 -0
  22. package/dist/browser/storage.d.ts.map +1 -0
  23. package/dist/browser/storage.js +121 -0
  24. package/dist/browser/storage.js.map +1 -0
  25. package/dist/core/client.d.ts +49 -0
  26. package/dist/core/client.d.ts.map +1 -0
  27. package/dist/core/client.js +371 -0
  28. package/dist/core/client.js.map +1 -0
  29. package/dist/core/discovery.d.ts +82 -0
  30. package/dist/core/discovery.d.ts.map +1 -0
  31. package/dist/core/discovery.js +243 -0
  32. package/dist/core/discovery.js.map +1 -0
  33. package/dist/core/dpop.d.ts +67 -0
  34. package/dist/core/dpop.d.ts.map +1 -0
  35. package/dist/core/dpop.js +176 -0
  36. package/dist/core/dpop.js.map +1 -0
  37. package/dist/core/errors.d.ts +86 -0
  38. package/dist/core/errors.d.ts.map +1 -0
  39. package/dist/core/errors.js +104 -0
  40. package/dist/core/errors.js.map +1 -0
  41. package/dist/core/http.d.ts +68 -0
  42. package/dist/core/http.d.ts.map +1 -0
  43. package/dist/core/http.js +128 -0
  44. package/dist/core/http.js.map +1 -0
  45. package/dist/core/identity.d.ts +145 -0
  46. package/dist/core/identity.d.ts.map +1 -0
  47. package/dist/core/identity.js +735 -0
  48. package/dist/core/identity.js.map +1 -0
  49. package/dist/core/oidc.d.ts +175 -0
  50. package/dist/core/oidc.d.ts.map +1 -0
  51. package/dist/core/oidc.js +413 -0
  52. package/dist/core/oidc.js.map +1 -0
  53. package/dist/core/session.d.ts +116 -0
  54. package/dist/core/session.d.ts.map +1 -0
  55. package/dist/core/session.js +350 -0
  56. package/dist/core/session.js.map +1 -0
  57. package/dist/core/storage.d.ts +22 -0
  58. package/dist/core/storage.d.ts.map +1 -0
  59. package/dist/core/storage.js +48 -0
  60. package/dist/core/storage.js.map +1 -0
  61. package/dist/index.d.ts +18 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +3 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/node/index.d.ts +9 -0
  66. package/dist/node/index.d.ts.map +1 -0
  67. package/dist/node/index.js +9 -0
  68. package/dist/node/index.js.map +1 -0
  69. package/dist/node/platform.d.ts +25 -0
  70. package/dist/node/platform.d.ts.map +1 -0
  71. package/dist/node/platform.js +161 -0
  72. package/dist/node/platform.js.map +1 -0
  73. package/dist/seba.d.ts +39 -0
  74. package/dist/seba.d.ts.map +1 -0
  75. package/dist/seba.js +58 -0
  76. package/dist/seba.js.map +1 -0
  77. package/dist/types.d.ts +279 -0
  78. package/dist/types.d.ts.map +1 -0
  79. package/dist/types.js +8 -0
  80. package/dist/types.js.map +1 -0
  81. package/examples/node/index.ts +61 -0
  82. package/examples/react/App.tsx +127 -0
  83. package/examples/react/index.html +12 -0
  84. package/examples/react/main.tsx +14 -0
  85. package/examples/react/vite.config.ts +27 -0
  86. package/package.json +83 -4
@@ -0,0 +1,735 @@
1
+ /**
2
+ * Client identification and WebID-based issuer discovery.
3
+ *
4
+ * SEBA uses a static Solid Client ID Document as its `client_id`: the URL of the
5
+ * document *is* the client identifier. No dynamic client registration is performed and
6
+ * `registration_endpoint` is never called.
7
+ *
8
+ * WebID profiles are read as Turtle (documented subset), JSON-LD or, as a last resort,
9
+ * an HTML `Link` header. The parsed issuer is then validated as an issuer URL before it
10
+ * is trusted.
11
+ */
12
+ import { SebaConfigError, SebaDiscoveryError, SebaRedirectMismatchError } from "./errors.js";
13
+ import { isLoopbackHost, validateIssuerUrl } from "./discovery.js";
14
+ import { httpFetch, isOk, parseJson, readText } from "./http.js";
15
+ /** JSON-LD context every Solid Client ID Document must declare. */
16
+ export const SOLID_OIDC_CONTEXT = "https://www.w3.org/ns/solid/oidc-context.jsonld";
17
+ /** Solid terms namespace. */
18
+ const SOLID_TERMS = "http://www.w3.org/ns/solid/terms#";
19
+ /** Fully expanded predicate of `solid:oidcIssuer`. */
20
+ export const OIDC_ISSUER_PREDICATE = `${SOLID_TERMS}oidcIssuer`;
21
+ /** Predicate accepted in compacted JSON-LD documents. */
22
+ const COMPACT_ISSUER_PREDICATE = "solid:oidcIssuer";
23
+ /**
24
+ * Validate and normalize a WebID.
25
+ *
26
+ * @param webId - WebID supplied by the application or the user.
27
+ * @throws {@link SebaConfigError} when the value is not an absolute https (or loopback
28
+ * http) IRI without embedded credentials.
29
+ */
30
+ export function validateWebId(webId) {
31
+ let url;
32
+ try {
33
+ url = new URL(webId);
34
+ }
35
+ catch {
36
+ throw new SebaConfigError(`The WebID "${webId}" is not an absolute URL. A WebID looks like "https://alice.example/profile/card#me".`);
37
+ }
38
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) {
39
+ throw new SebaConfigError(`The WebID "${webId}" must use https. Plain http is only accepted for loopback development hosts.`);
40
+ }
41
+ if (url.username !== "" || url.password !== "") {
42
+ throw new SebaConfigError(`The WebID "${webId}" must not contain embedded credentials.`);
43
+ }
44
+ const normalized = url.href;
45
+ const document = new URL(normalized);
46
+ document.hash = "";
47
+ return { webId: normalized, documentUrl: document.href };
48
+ }
49
+ /**
50
+ * Validate a Client ID Document URL taken from configuration.
51
+ *
52
+ * @param clientId - Configured `client_id`.
53
+ * @throws {@link SebaConfigError} when the value cannot be a Client ID Document URL.
54
+ */
55
+ export function validateClientIdUrl(clientId) {
56
+ let url;
57
+ try {
58
+ url = new URL(clientId);
59
+ }
60
+ catch {
61
+ throw new SebaConfigError(`The clientId "${clientId}" is not an absolute URL. ` +
62
+ 'Use the HTTPS URL of your published Client ID Document, for example "https://app.example/client-id.jsonld".');
63
+ }
64
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) {
65
+ throw new SebaConfigError(`The clientId "${clientId}" must be served over https. ` +
66
+ "A Client ID Document is a public identity document; plain http is only accepted for loopback development hosts.");
67
+ }
68
+ if (url.hash !== "") {
69
+ throw new SebaConfigError(`The clientId "${clientId}" must not contain a fragment.`);
70
+ }
71
+ return clientId;
72
+ }
73
+ /**
74
+ * Parse and validate a Solid Client ID Document.
75
+ *
76
+ * @param raw - Parsed JSON body.
77
+ * @param documentUrl - URL the document was fetched from; `client_id` must equal it.
78
+ * @throws {@link SebaDiscoveryError} when the document is not a valid Client ID Document.
79
+ */
80
+ export function parseClientIdDocument(raw, documentUrl) {
81
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
82
+ throw new SebaDiscoveryError(`The Client ID Document at "${documentUrl}" is not a JSON object.`);
83
+ }
84
+ const document = raw;
85
+ const context = document["@context"];
86
+ const contexts = Array.isArray(context) ? context : [context];
87
+ if (!contexts.includes(SOLID_OIDC_CONTEXT)) {
88
+ throw new SebaDiscoveryError(`The Client ID Document at "${documentUrl}" does not declare the Solid-OIDC JSON-LD context. ` +
89
+ `Add "@context": ["${SOLID_OIDC_CONTEXT}"] to the document.`);
90
+ }
91
+ if (typeof document["client_id"] !== "string" || document["client_id"] !== documentUrl) {
92
+ const declared = typeof document["client_id"] === "string" ? document["client_id"] : "(missing)";
93
+ throw new SebaDiscoveryError(`The Client ID Document at "${documentUrl}" declares client_id "${declared}". ` +
94
+ `The client_id member must be identical to the document URL "${documentUrl}".`);
95
+ }
96
+ const redirectUris = document["redirect_uris"];
97
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
98
+ throw new SebaDiscoveryError(`The Client ID Document at "${documentUrl}" does not publish a non-empty redirect_uris array. ` +
99
+ 'Add every callback URL you use, for example "redirect_uris": ["https://app.example/auth/callback"].');
100
+ }
101
+ for (const uri of redirectUris) {
102
+ if (typeof uri !== "string") {
103
+ throw new SebaDiscoveryError(`The Client ID Document at "${documentUrl}" contains a non-string redirect URI.`);
104
+ }
105
+ let parsed;
106
+ try {
107
+ parsed = new URL(uri);
108
+ }
109
+ catch {
110
+ throw new SebaDiscoveryError(`The Client ID Document at "${documentUrl}" publishes the redirect URI "${uri}", which is not an absolute URL.`);
111
+ }
112
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) {
113
+ throw new SebaDiscoveryError(`The Client ID Document at "${documentUrl}" publishes the redirect URI "${uri}", which does not use https. ` +
114
+ "Only loopback http redirect URIs are accepted for development.");
115
+ }
116
+ if (parsed.hash !== "") {
117
+ throw new SebaDiscoveryError(`The Client ID Document at "${documentUrl}" publishes the redirect URI "${uri}", which contains a fragment.`);
118
+ }
119
+ }
120
+ const result = {
121
+ clientId: documentUrl,
122
+ redirectUris: redirectUris,
123
+ };
124
+ if (typeof document["client_name"] === "string") {
125
+ result.clientName = document["client_name"];
126
+ }
127
+ return result;
128
+ }
129
+ /**
130
+ * Fetch and validate the Client ID Document.
131
+ *
132
+ * @param clientId - Configured Client ID Document URL.
133
+ * @param fetchImpl - Runtime `fetch` implementation.
134
+ */
135
+ export async function loadClientIdDocument(clientId, fetchImpl) {
136
+ const url = validateClientIdUrl(clientId);
137
+ const response = await httpFetch(url, { headers: { accept: "application/ld+json, application/json;q=0.9" }, redirect: "follow" }, { fetchImpl });
138
+ if (!isOk(response)) {
139
+ throw new SebaDiscoveryError(`Fetching the Client ID Document at "${url}" failed with HTTP ${String(response.status)}. ` +
140
+ "The document must be publicly dereferenceable without authentication.");
141
+ }
142
+ const text = await readText(response, "Client ID Document");
143
+ const raw = parseJson(text, `The Client ID Document at "${url}"`);
144
+ return parseClientIdDocument(raw, url);
145
+ }
146
+ /**
147
+ * Assert that a redirect URI is published verbatim by the Client ID Document.
148
+ *
149
+ * Membership is compared as exact strings: no normalization is applied to either side.
150
+ *
151
+ * @param document - Validated Client ID Document.
152
+ * @param redirectUri - Configured redirect URI.
153
+ * @throws {@link SebaRedirectMismatchError} when the URI is not published.
154
+ */
155
+ export function assertRedirectUriPublished(document, redirectUri) {
156
+ if (!document.redirectUris.includes(redirectUri)) {
157
+ throw new SebaRedirectMismatchError(redirectUri, document.redirectUris);
158
+ }
159
+ }
160
+ /**
161
+ * Parse a Turtle document and return its triples.
162
+ *
163
+ * Supported subset: `@prefix`/`PREFIX`, `@base`/`BASE`, IRIs (relative IRIs resolve
164
+ * against the base), prefixed names, `a` as `rdf:type`, subject/predicate/object lists
165
+ * with `;` and `,`, comments, blank node labels, empty `[]` blank nodes, and literals with
166
+ * language tags or datatypes. Collections `( … )` and property lists `[ … ]` are rejected
167
+ * with a descriptive error instead of being mis-parsed.
168
+ *
169
+ * @param text - Turtle source.
170
+ * @param baseUrl - Base IRI for relative references (the document URL).
171
+ */
172
+ export function parseTurtle(text, baseUrl) {
173
+ return new TurtleParser(text, baseUrl).parse();
174
+ }
175
+ class TurtleParser {
176
+ text;
177
+ index = 0;
178
+ base;
179
+ prefixes = new Map();
180
+ triples = [];
181
+ blankCounter = 0;
182
+ constructor(text, baseUrl) {
183
+ this.text = text;
184
+ this.base = baseUrl;
185
+ }
186
+ parse() {
187
+ while (true) {
188
+ this.skipWhitespace();
189
+ if (this.atEnd()) {
190
+ break;
191
+ }
192
+ if (this.peek() === "@") {
193
+ this.parseDirective();
194
+ continue;
195
+ }
196
+ const keyword = this.peekWord();
197
+ if (keyword !== null && (keyword.toLowerCase() === "prefix" || keyword.toLowerCase() === "base")) {
198
+ this.parseDirective(true);
199
+ continue;
200
+ }
201
+ this.parseTriples();
202
+ }
203
+ return this.triples;
204
+ }
205
+ parseDirective(sparqlStyle = false) {
206
+ if (!sparqlStyle) {
207
+ this.expect("@");
208
+ }
209
+ const name = this.readWord().toLowerCase();
210
+ if (name !== "prefix" && name !== "base") {
211
+ throw this.error(`Unsupported Turtle directive "@${name}".`);
212
+ }
213
+ this.skipWhitespace();
214
+ if (name === "prefix") {
215
+ const prefix = this.readPrefixLabel();
216
+ this.expect(":");
217
+ this.skipWhitespace();
218
+ const iri = this.readIri();
219
+ this.prefixes.set(prefix, iri);
220
+ }
221
+ else {
222
+ this.base = this.readIri();
223
+ }
224
+ this.skipWhitespace();
225
+ if (!sparqlStyle) {
226
+ this.expect(".");
227
+ }
228
+ }
229
+ parseTriples() {
230
+ const subject = this.parseSubject();
231
+ this.parsePredicateObjectList(subject);
232
+ this.skipWhitespace();
233
+ this.expect(".");
234
+ }
235
+ parsePredicateObjectList(subject) {
236
+ while (true) {
237
+ this.skipWhitespace();
238
+ const predicate = this.parsePredicate();
239
+ while (true) {
240
+ this.skipWhitespace();
241
+ const object = this.parseObject();
242
+ this.triples.push({ subject, predicate, object });
243
+ this.skipWhitespace();
244
+ if (this.peek() === ",") {
245
+ this.index += 1;
246
+ continue;
247
+ }
248
+ break;
249
+ }
250
+ this.skipWhitespace();
251
+ if (this.peek() === ";") {
252
+ this.index += 1;
253
+ this.skipWhitespace();
254
+ if (this.peek() === ".") {
255
+ return;
256
+ }
257
+ continue;
258
+ }
259
+ return;
260
+ }
261
+ }
262
+ parseSubject() {
263
+ this.skipWhitespace();
264
+ const char = this.peek();
265
+ if (char === "[") {
266
+ this.index += 1;
267
+ this.skipWhitespace();
268
+ if (this.peek() !== "]") {
269
+ throw this.error("Property lists as subjects (\"[ … ]\") are not supported by SEBA's Turtle subset. " +
270
+ "Rewrite the profile using a labelled blank node or plain triples.");
271
+ }
272
+ this.index += 1;
273
+ return this.newBlankNode();
274
+ }
275
+ if (char === "(") {
276
+ throw this.error("RDF collections (\"( … )\") are not supported by SEBA's Turtle subset.");
277
+ }
278
+ if (char === "_" && this.text[this.index + 1] === ":") {
279
+ return this.readBlankNodeLabel();
280
+ }
281
+ return this.readIriOrPrefixedName();
282
+ }
283
+ parsePredicate() {
284
+ this.skipWhitespace();
285
+ if (this.peekWord() === "a") {
286
+ this.readWord();
287
+ return "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
288
+ }
289
+ return this.readIriOrPrefixedName();
290
+ }
291
+ parseObject() {
292
+ this.skipWhitespace();
293
+ const char = this.peek();
294
+ if (char === '"' || char === "'") {
295
+ return this.readLiteral();
296
+ }
297
+ if (char === "[") {
298
+ this.index += 1;
299
+ this.skipWhitespace();
300
+ if (this.peek() !== "]") {
301
+ throw this.error("Nested property lists (\"[ … ]\" as an object) are not supported by SEBA's Turtle subset. " +
302
+ "Write the nested triples separately.");
303
+ }
304
+ this.index += 1;
305
+ return { type: "bnode", id: this.newBlankNode() };
306
+ }
307
+ if (char === "(") {
308
+ throw this.error("RDF collections (\"( … )\") are not supported by SEBA's Turtle subset.");
309
+ }
310
+ const word = this.peekWord();
311
+ if (word === "true" || word === "false") {
312
+ this.readWord();
313
+ return { type: "literal", value: word };
314
+ }
315
+ if (word === null && (char === "-" || char === "+" || (char !== undefined && /\d/.test(char)))) {
316
+ return { type: "literal", value: this.readNumber() };
317
+ }
318
+ if (char === "_" && this.text[this.index + 1] === ":") {
319
+ return { type: "bnode", id: this.readBlankNodeLabel() };
320
+ }
321
+ const iri = this.readIriOrPrefixedName();
322
+ return { type: "iri", value: iri };
323
+ }
324
+ readLiteral() {
325
+ const value = this.readStringLiteral();
326
+ if (this.peek() === "@") {
327
+ this.index += 1;
328
+ const language = this.readLanguageTag();
329
+ return { type: "literal", value: `${value}@${language}` };
330
+ }
331
+ if (this.text.startsWith("^^", this.index)) {
332
+ this.index += 2;
333
+ const datatype = this.readIriOrPrefixedName();
334
+ return { type: "literal", value: datatype === "http://www.w3.org/2001/XMLSchema#string" ? value : `${value}^^${datatype}` };
335
+ }
336
+ return { type: "literal", value };
337
+ }
338
+ readStringLiteral() {
339
+ const quote = this.peek();
340
+ if (quote !== '"' && quote !== "'") {
341
+ throw this.error("Expected a string literal.");
342
+ }
343
+ const tripleQuoted = this.text.startsWith(String(quote).repeat(3), this.index);
344
+ this.index += tripleQuoted ? 3 : 1;
345
+ let result = "";
346
+ while (!this.atEnd()) {
347
+ if (tripleQuoted && this.text.startsWith(String(quote).repeat(3), this.index)) {
348
+ this.index += 3;
349
+ return result;
350
+ }
351
+ const char = this.text[this.index];
352
+ this.index += 1;
353
+ if (!tripleQuoted && char === quote) {
354
+ return result;
355
+ }
356
+ if (!tripleQuoted && (char === "\n" || char === "\r")) {
357
+ throw this.error("Unterminated string literal.");
358
+ }
359
+ if (char === "\\") {
360
+ result += this.readEscape();
361
+ continue;
362
+ }
363
+ result += char;
364
+ }
365
+ throw this.error("Unterminated string literal.");
366
+ }
367
+ readEscape() {
368
+ const char = this.text[this.index];
369
+ this.index += 1;
370
+ switch (char) {
371
+ case "t":
372
+ return "\t";
373
+ case "b":
374
+ return "\b";
375
+ case "n":
376
+ return "\n";
377
+ case "r":
378
+ return "\r";
379
+ case "f":
380
+ return "\f";
381
+ case '"':
382
+ return '"';
383
+ case "'":
384
+ return "'";
385
+ case "\\":
386
+ return "\\";
387
+ case "u":
388
+ return this.readUnicodeEscape(4);
389
+ case "U":
390
+ return this.readUnicodeEscape(8);
391
+ default:
392
+ throw this.error(`Unsupported escape sequence "\\${String(char)}" in Turtle input.`);
393
+ }
394
+ }
395
+ readUnicodeEscape(length) {
396
+ const hex = this.text.slice(this.index, this.index + length);
397
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== length) {
398
+ throw this.error("Invalid unicode escape in Turtle input.");
399
+ }
400
+ this.index += length;
401
+ return String.fromCodePoint(Number.parseInt(hex, 16));
402
+ }
403
+ readIriOrPrefixedName() {
404
+ this.skipWhitespace();
405
+ if (this.peek() === "<") {
406
+ return this.readIri();
407
+ }
408
+ const start = this.index;
409
+ const prefix = this.readPrefixLabel();
410
+ if (this.peek() !== ":") {
411
+ this.index = start;
412
+ throw this.error("Expected an IRI or a prefixed name.");
413
+ }
414
+ this.index += 1;
415
+ const local = this.readLocalName();
416
+ const namespace = this.prefixes.get(prefix);
417
+ if (namespace === undefined) {
418
+ throw this.error(`Unknown Turtle prefix "${prefix}:". Declare it with "@prefix ${prefix}: <…> ." before use.`);
419
+ }
420
+ return `${namespace}${local}`;
421
+ }
422
+ readPrefixLabel() {
423
+ const match = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(this.text.slice(this.index));
424
+ if (match === null) {
425
+ return "";
426
+ }
427
+ this.index += match[0].length;
428
+ return match[0];
429
+ }
430
+ readLocalName() {
431
+ const match = /^[A-Za-z0-9_.%~-]*/.exec(this.text.slice(this.index));
432
+ let local = match?.[0] ?? "";
433
+ if (local.endsWith(".")) {
434
+ local = local.slice(0, -1);
435
+ }
436
+ this.index += local.length;
437
+ return local;
438
+ }
439
+ readIri() {
440
+ this.skipWhitespace();
441
+ this.expect("<");
442
+ let value = "";
443
+ while (!this.atEnd()) {
444
+ const char = this.text[this.index];
445
+ this.index += 1;
446
+ if (char === ">") {
447
+ const resolved = resolveAbsoluteIri(value, this.base);
448
+ if (resolved === null) {
449
+ throw this.error(`The IRI "<${value}>" cannot be resolved against "${this.base}".`);
450
+ }
451
+ return resolved;
452
+ }
453
+ if (char === "\\") {
454
+ value += this.readEscape();
455
+ continue;
456
+ }
457
+ if (char === "<" || char === '"' || char === "{" || char === "}" || char === "|" || char === "^") {
458
+ throw this.error(`The IRI "<${value}${char}" contains an illegal character.`);
459
+ }
460
+ value += char;
461
+ }
462
+ throw this.error("Unterminated IRI.");
463
+ }
464
+ readBlankNodeLabel() {
465
+ this.expect("_");
466
+ this.expect(":");
467
+ const match = /^[A-Za-z0-9_][A-Za-z0-9_.-]*/.exec(this.text.slice(this.index));
468
+ if (match === null) {
469
+ throw this.error("Expected a blank node label after \"_:\".");
470
+ }
471
+ this.index += match[0].length;
472
+ return `_:${match[0]}`;
473
+ }
474
+ readNumber() {
475
+ const match = /^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?/.exec(this.text.slice(this.index));
476
+ if (match === null) {
477
+ throw this.error("Invalid numeric literal in Turtle input.");
478
+ }
479
+ this.index += match[0].length;
480
+ return match[0];
481
+ }
482
+ readLanguageTag() {
483
+ const match = /^[A-Za-z]+(-[A-Za-z0-9]+)*/.exec(this.text.slice(this.index));
484
+ if (match === null) {
485
+ throw this.error("Invalid language tag in Turtle input.");
486
+ }
487
+ this.index += match[0].length;
488
+ return match[0];
489
+ }
490
+ readWord() {
491
+ const match = /^[A-Za-z_][A-Za-z0-9_-]*/.exec(this.text.slice(this.index));
492
+ if (match === null) {
493
+ return "";
494
+ }
495
+ this.index += match[0].length;
496
+ return match[0];
497
+ }
498
+ peekWord() {
499
+ const match = /^[A-Za-z_][A-Za-z0-9_-]*/.exec(this.text.slice(this.index));
500
+ return match?.[0] ?? null;
501
+ }
502
+ newBlankNode() {
503
+ this.blankCounter += 1;
504
+ return `_:b${String(this.blankCounter)}`;
505
+ }
506
+ skipWhitespace() {
507
+ while (!this.atEnd()) {
508
+ const char = this.text[this.index];
509
+ if (char === "\uFEFF" || /\s/.test(char)) {
510
+ this.index += 1;
511
+ continue;
512
+ }
513
+ if (char === "#") {
514
+ while (!this.atEnd() && this.text[this.index] !== "\n") {
515
+ this.index += 1;
516
+ }
517
+ continue;
518
+ }
519
+ return;
520
+ }
521
+ }
522
+ expect(value) {
523
+ if (!this.text.startsWith(value, this.index)) {
524
+ throw this.error(`Expected "${value}".`);
525
+ }
526
+ this.index += value.length;
527
+ }
528
+ peek() {
529
+ return this.text[this.index];
530
+ }
531
+ atEnd() {
532
+ return this.index >= this.text.length;
533
+ }
534
+ error(message) {
535
+ const upTo = this.text.slice(0, this.index);
536
+ const line = upTo.split("\n").length;
537
+ const column = this.index - upTo.lastIndexOf("\n");
538
+ return new SebaDiscoveryError(`Unable to parse the Turtle document at line ${String(line)}, column ${String(column)}: ${message}`);
539
+ }
540
+ }
541
+ /**
542
+ * Extract `solid:oidcIssuer` statements from parsed triples.
543
+ *
544
+ * @param triples - Parsed triples.
545
+ */
546
+ export function extractIssuerStatements(triples) {
547
+ const statements = [];
548
+ for (const triple of triples) {
549
+ if (triple.predicate === OIDC_ISSUER_PREDICATE && triple.object.type === "iri") {
550
+ statements.push({ subject: triple.subject, issuer: triple.object.value });
551
+ }
552
+ }
553
+ return statements;
554
+ }
555
+ /**
556
+ * Extract `solid:oidcIssuer` statements from a JSON-LD document.
557
+ *
558
+ * Accepts the expanded predicate and the compacted `solid:oidcIssuer` form, string or
559
+ * `{ "@id": … }` values, and nested `@graph` containers.
560
+ *
561
+ * @param raw - Parsed JSON-LD document.
562
+ * @param documentUrl - Base URL for relative `@id` values.
563
+ */
564
+ export function extractJsonLdIssuers(raw, documentUrl) {
565
+ const statements = [];
566
+ const visit = (node) => {
567
+ if (Array.isArray(node)) {
568
+ for (const entry of node) {
569
+ visit(entry);
570
+ }
571
+ return;
572
+ }
573
+ if (typeof node !== "object" || node === null) {
574
+ return;
575
+ }
576
+ const record = node;
577
+ if (Array.isArray(record["@graph"])) {
578
+ visit(record["@graph"]);
579
+ }
580
+ const rawSubject = typeof record["@id"] === "string" ? record["@id"] : null;
581
+ if (rawSubject === null) {
582
+ return;
583
+ }
584
+ let subject;
585
+ try {
586
+ subject = new URL(rawSubject, documentUrl).href;
587
+ }
588
+ catch {
589
+ return;
590
+ }
591
+ for (const key of [OIDC_ISSUER_PREDICATE, COMPACT_ISSUER_PREDICATE]) {
592
+ const value = record[key];
593
+ if (value === undefined) {
594
+ continue;
595
+ }
596
+ for (const entry of Array.isArray(value) ? value : [value]) {
597
+ if (typeof entry === "string") {
598
+ statements.push({ subject, issuer: resolveIri(entry, documentUrl) });
599
+ }
600
+ else if (typeof entry === "object" && entry !== null) {
601
+ const id = entry["@id"];
602
+ if (typeof id === "string") {
603
+ statements.push({ subject, issuer: resolveIri(id, documentUrl) });
604
+ }
605
+ }
606
+ }
607
+ }
608
+ };
609
+ visit(raw);
610
+ return statements;
611
+ }
612
+ /**
613
+ * Resolve an IRI against a base while preserving already-absolute IRIs byte for byte.
614
+ *
615
+ * `new URL("https://idp.example").href` appends a trailing slash, which would silently
616
+ * change an issuer identifier. Responding issuer identifiers are compared exactly, so
617
+ * absolute IRIs are returned unchanged.
618
+ *
619
+ * @param value - IRI reference from the document.
620
+ * @param base - Base IRI for relative references.
621
+ */
622
+ export function resolveAbsoluteIri(value, base) {
623
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
624
+ try {
625
+ new URL(value);
626
+ return value;
627
+ }
628
+ catch {
629
+ return null;
630
+ }
631
+ }
632
+ try {
633
+ return new URL(value, base).href;
634
+ }
635
+ catch {
636
+ return null;
637
+ }
638
+ }
639
+ function resolveIri(value, base) {
640
+ return resolveAbsoluteIri(value, base) ?? value;
641
+ }
642
+ /**
643
+ * Parse an HTTP `Link` header and return the targets with the requested relation.
644
+ *
645
+ * @param header - Raw `Link` header value, when present.
646
+ * @param relation - Relation URI to match.
647
+ */
648
+ export function parseLinkHeader(header, relation) {
649
+ if (header === null || header.trim() === "") {
650
+ return [];
651
+ }
652
+ const targets = [];
653
+ const parts = header.split(/,(?=\s*<)/);
654
+ for (const part of parts) {
655
+ const match = /^\s*<([^>]*)>(.*)$/.exec(part);
656
+ if (match === null) {
657
+ continue;
658
+ }
659
+ const target = match[1];
660
+ const params = match[2] ?? "";
661
+ for (const param of params.split(";")) {
662
+ const [rawName, rawValue] = param.split("=");
663
+ if (rawName === undefined || rawValue === undefined) {
664
+ continue;
665
+ }
666
+ if (rawName.trim().toLowerCase() !== "rel") {
667
+ continue;
668
+ }
669
+ const value = rawValue.trim().replace(/^"|"$/g, "");
670
+ if (value.split(/\s+/).includes(relation)) {
671
+ targets.push(target);
672
+ }
673
+ }
674
+ }
675
+ return targets;
676
+ }
677
+ /**
678
+ * Resolve the OpenID Provider issuer for a WebID by reading its profile.
679
+ *
680
+ * Order: `solid:oidcIssuer` triple for the exact WebID (Turtle or JSON-LD), then a
681
+ * `rel="http://www.w3.org/ns/solid/terms#oidcIssuer"` `Link` header. Failures are
682
+ * discovered errors with actionable messages.
683
+ *
684
+ * @param webId - WebID supplied by the application or the user.
685
+ * @param fetchImpl - Runtime `fetch` implementation.
686
+ */
687
+ export async function discoverIssuerForWebId(webId, fetchImpl) {
688
+ const location = validateWebId(webId);
689
+ const response = await httpFetch(location.documentUrl, {
690
+ headers: {
691
+ accept: "text/turtle, application/ld+json;q=0.9, text/html;q=0.8, */*;q=0.1",
692
+ },
693
+ redirect: "follow",
694
+ }, { fetchImpl });
695
+ if (!isOk(response)) {
696
+ throw new SebaDiscoveryError(`Fetching the WebID profile for "${webId}" failed with HTTP ${String(response.status)}. ` +
697
+ `Requested ${location.documentUrl}; the profile must be publicly readable.`);
698
+ }
699
+ const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
700
+ const linkIssuers = parseLinkHeader(response.headers.get("link"), OIDC_ISSUER_PREDICATE);
701
+ const text = await readText(response, "WebID profile");
702
+ let statements = [];
703
+ let parseFailure = null;
704
+ if (contentType.includes("json")) {
705
+ try {
706
+ statements = extractJsonLdIssuers(JSON.parse(text), location.documentUrl);
707
+ }
708
+ catch (error) {
709
+ parseFailure = error instanceof Error ? error.message : "invalid JSON";
710
+ }
711
+ }
712
+ else if (contentType.includes("turtle") || contentType === "" || contentType.includes("text/plain")) {
713
+ try {
714
+ statements = extractIssuerStatements(parseTurtle(text, location.documentUrl));
715
+ }
716
+ catch (error) {
717
+ parseFailure = error instanceof Error ? error.message : "invalid Turtle";
718
+ }
719
+ }
720
+ const matching = statements.filter((statement) => statement.subject === location.webId || statement.subject === location.documentUrl);
721
+ const issuers = [...new Set(matching.map((statement) => statement.issuer))];
722
+ if (issuers.length > 1) {
723
+ throw new SebaDiscoveryError(`The WebID profile for "${webId}" declares more than one solid:oidcIssuer (${issuers.join(", ")}). ` +
724
+ "A WebID must trust exactly one identity provider.");
725
+ }
726
+ const chosen = issuers[0] ?? linkIssuers[0];
727
+ if (chosen === undefined) {
728
+ const detail = parseFailure === null ? "" : ` The profile could not be parsed: ${parseFailure}`;
729
+ throw new SebaDiscoveryError(`The WebID profile for "${webId}" does not state "<${location.webId}> <${OIDC_ISSUER_PREDICATE}> <issuer>".` +
730
+ `${detail} Content-Type was "${contentType || "missing"}". ` +
731
+ "Add the solid:oidcIssuer triple to the profile, or pass an explicit issuer in the login options.");
732
+ }
733
+ return validateIssuerUrl(chosen, "identity provider issuer");
734
+ }
735
+ //# sourceMappingURL=identity.js.map