From b215f9626de06b379d84c1cdb4e4d88771a7a690 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Sun, 14 Jun 2026 11:50:31 +0100 Subject: [PATCH 1/5] A WebIdPicker element --- src/WebIdPicker.ts | 103 ++++++++++++++++++++++++++++++ src/WebIdRequestCancelledError.ts | 8 +++ src/mod.ts | 2 + src/registerElements.ts | 3 + 4 files changed, 116 insertions(+) create mode 100644 src/WebIdPicker.ts create mode 100644 src/WebIdRequestCancelledError.ts diff --git a/src/WebIdPicker.ts b/src/WebIdPicker.ts new file mode 100644 index 0000000..a78b1ad --- /dev/null +++ b/src/WebIdPicker.ts @@ -0,0 +1,103 @@ +import { Mutex } from "./Mutex.js" +import { WebIdRequestCancelledError } from "./WebIdRequestCancelledError.js" + +const onlyOnce = {once: true} +const searchString = "YOUR_USERNAME" +const html = ` + +
+ + WebID required + +

+ + The application needs a WebID so it can derive an Authorization Server URI from the profile to handle the following request URI. + +

+ + Request URI + + +
+

+
+ + + +
+
+
+` + +export class WebIdPicker extends HTMLElement { + readonly #mutex = new Mutex + #dialog!: HTMLDialogElement + #input!: HTMLInputElement + #code!: HTMLElement + + /** @ignore */ + connectedCallback() { + const template = this.ownerDocument.createElement("template") + template.innerHTML = html + + const shadow = this.attachShadow({mode: "closed"}) + shadow.appendChild(this.ownerDocument.importNode(template.content, true)) + + this.#dialog = shadow.querySelector("dialog")! + this.#input = shadow.querySelector("input")! + this.#code = shadow.querySelector("code")! + + shadow.querySelector("form")!.addEventListener("submit", () => this.#dialog.returnValue = this.#input.value) + shadow.querySelector("#cancel")!.addEventListener("click", () => this.#dialog.close()) + this.#input.addEventListener("change", () => { + if (this.#input.value.includes(searchString)) { + const start = this.#input.value.indexOf(searchString) + this.#input.setSelectionRange(start, start + searchString.length, "forward") + } + }) + + for (const option of this.querySelectorAll(":scope > option")) { + shadow.querySelector("datalist")!.appendChild(option.cloneNode()) + } + } + + async getWebId(request: Request): Promise { + using _ = await this.#mutex.acquire() + + this.#input.value = "" + this.#code.innerText = request.url + this.#dialog.returnValue = "" + this.#dialog.showModal() + + const {promise, reject, resolve} = Promise.withResolvers() + + const onClose = () => { + request.signal.removeEventListener("abort", onAbort) + if (this.#dialog.returnValue !== "") { + resolve(new URL(this.#dialog.returnValue)) + } else { + reject(new WebIdRequestCancelledError(request)) + } + } + const onAbort = () => { + this.#dialog.removeEventListener("close", onClose) + this.#dialog.close() + reject(request.signal.reason) + } + + request.signal.addEventListener("abort", onAbort, onlyOnce) + this.#dialog.addEventListener("close", onClose, onlyOnce) + + return await promise + } +} diff --git a/src/WebIdRequestCancelledError.ts b/src/WebIdRequestCancelledError.ts new file mode 100644 index 0000000..c9d3fa7 --- /dev/null +++ b/src/WebIdRequestCancelledError.ts @@ -0,0 +1,8 @@ +import { ReactiveAuthenticationError } from "./ReactiveAuthenticationError.js" + +export class WebIdRequestCancelledError extends ReactiveAuthenticationError { + constructor(public request: Request, cause?: any) { + super("WebID request cancelled", cause) + this.name = "WebIdRequestCancelledError" + } +} diff --git a/src/mod.ts b/src/mod.ts index 268eee3..0342cf6 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -11,4 +11,6 @@ export * from "./issuerFrom.js" export * from "./TokenProvider.js" export * from "./GetIssuerCallback.js" export * from "./IdpPicker.js" +export * from "./WebIdPicker.js" +export * from "./WebIdRequestCancelledError.js" export * from "./IssuerRequestCancelledError.js" diff --git a/src/registerElements.ts b/src/registerElements.ts index ac41111..9bdf24d 100644 --- a/src/registerElements.ts +++ b/src/registerElements.ts @@ -11,6 +11,7 @@ * * * + * * ``` * * @module @@ -18,6 +19,8 @@ import { AuthorizationCodeFlow } from "./AuthorizationCodeFlow.js" import { IdpPicker } from "./IdpPicker.js" +import { WebIdPicker } from "./WebIdPicker.js" customElements.define("authorization-code-flow", AuthorizationCodeFlow) customElements.define("idp-picker", IdpPicker) +customElements.define("webid-picker", WebIdPicker) From 3d62761518f13ef73b953fd907532d97ad47405e Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Sun, 14 Jun 2026 11:53:27 +0100 Subject: [PATCH 2/5] Integrate with IdpPicker --- package.json | 6 +++- src/IdpPicker.ts | 68 ++++++++++++++++++++++++++++++++++++++++++-- src/SimpleDataset.ts | 47 ++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 src/SimpleDataset.ts diff --git a/package.json b/package.json index 0efe8d0..a1e22d1 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,13 @@ "license": "MIT", "dependencies": { "oauth4webapi": "^3", - "dpop": "^2" + "dpop": "^2", + "@rdfjs/wrapper": "^0.34", + "n3": "^2" }, "devDependencies": { + "@rdfjs/types": "^2", + "@types/n3": "^1", "typedoc": "^0.28.18", "typedoc-plugin-mdn-links": "^5.1.1", "typescript": "^6" diff --git a/src/IdpPicker.ts b/src/IdpPicker.ts index 1d84019..7ece335 100644 --- a/src/IdpPicker.ts +++ b/src/IdpPicker.ts @@ -1,5 +1,11 @@ +import { SimpleDataset } from "./SimpleDataset.js" +import { NamedNodeAs, OptionalFrom, TermWrapper } from "@rdfjs/wrapper" +import { DataFactory, Parser } from "n3" +import type { Quad } from "@rdfjs/types" +import { ReactiveAuthenticationError } from "./ReactiveAuthenticationError.js" import { Mutex } from "./Mutex.js" import { IssuerRequestCancelledError } from "./IssuerRequestCancelledError.js" +import type { WebIdPicker } from "./WebIdPicker.js" const onlyOnce = {once: true} const html = ` @@ -30,10 +36,14 @@ const html = ` - + + ` @@ -51,6 +61,9 @@ export class IdpPicker extends HTMLElement { #dialog!: HTMLDialogElement #input!: HTMLInputElement #code!: HTMLElement + #webIdButton!: HTMLButtonElement + #webIdPicker: WebIdPicker | null = null + #request?: Request /** @ignore */ connectedCallback() { @@ -63,18 +76,25 @@ export class IdpPicker extends HTMLElement { this.#dialog = shadow.querySelector("dialog")! this.#input = shadow.querySelector("input")! this.#code = shadow.querySelector("code")! + this.#webIdPicker = this.querySelector(":scope > webid-picker[slot='webid-picker']") + this.#webIdButton = shadow.querySelector("#webid")! shadow.querySelector("form")!.addEventListener("submit", e => this.#dialog.returnValue = this.#input.value) - shadow.querySelector("button[type = 'button']")!.addEventListener("click", () => this.#dialog.close()) + shadow.querySelector("#cancel")!.addEventListener("click", () => this.#dialog.close()) + this.#webIdButton.addEventListener("click", this.#useWebId.bind(this)) - for (const option of this.querySelectorAll("option")) { + // Options cannot be slotted into a datalist + for (const option of this.querySelectorAll(":scope > option")) { shadow.querySelector("datalist")!.appendChild(option.cloneNode()) } + + this.#webIdButton.disabled = this.#webIdButton.hidden = this.#webIdPicker === null } async getIssuer(request: Request): Promise { using _ = await this.#mutex.acquire() + this.#request = request this.#input.value = "" this.#code.innerText = request.url this.#dialog.returnValue = "" @@ -101,4 +121,46 @@ export class IdpPicker extends HTMLElement { return await promise } + + async #useWebId() { + this.#webIdButton.disabled = true + try { + const webid = await this.#webIdPicker!.getWebId(this.#request!) + const issuer = await issuerFromWebId(webid, this.#request!.signal) + this.#input.value = issuer.href + } finally { + this.#webIdButton.disabled = false + this.#input.focus() + } + } +} + +class WebIdAgent extends TermWrapper { + get oidcIssuer(): URL | undefined { + return OptionalFrom.subjectPredicate(this, "http://www.w3.org/ns/solid/terms#oidcIssuer", NamedNodeAs.url) + } +} + +async function issuerFromWebId(webId: URL, signal: AbortSignal): Promise { + const response = await fetch(webId, {headers: {accept: "text/turtle"}, signal}) + if (!response.ok) { + throw new ReactiveAuthenticationError("WebID profile could not be retrieved") + } + + const text = await response.text() + const parser = new Parser({baseIRI: response.url || webId.href}) // Base is from response if redirected, from WebID otherwise + let quads: Quad[] + try { + quads = parser.parse(text) + } catch (error) { + throw new ReactiveAuthenticationError("WebID profile could not be parsed", error) + } + + const agent = new WebIdAgent(webId.href, new SimpleDataset(quads), DataFactory) + const issuer = agent.oidcIssuer + if (issuer === undefined) { + throw new ReactiveAuthenticationError("WebID profile lacks OIDC issuer") + } + + return issuer } diff --git a/src/SimpleDataset.ts b/src/SimpleDataset.ts new file mode 100644 index 0000000..49a6f02 --- /dev/null +++ b/src/SimpleDataset.ts @@ -0,0 +1,47 @@ +import type { DatasetCore, Quad, Term } from "@rdfjs/types" + +// TODO: Eliminate once N3 stops trying to nodejs the browser +export class SimpleDataset implements DatasetCore { + readonly #quads: Quad[] + + constructor(quads: Quad[]) { + this.#quads = quads + } + + get size() { + return this.#quads.length + } + + add(quad: Quad) { + if (!this.has(quad)) { + this.#quads.push(quad) + } + + return this + } + + delete(quad: Quad) { + const index = this.#quads.findIndex(q => q.equals(quad)) + if (index >= 0) { + this.#quads.splice(index, 1) + } + + return this + } + + has(quad: Quad) { + return this.#quads.some(q => q.equals(quad)) + } + + match(subject?: Term | null, predicate?: Term | null, object?: Term | null, graph?: Term | null) { + return new SimpleDataset(this.#quads.filter(q => + (!subject || q.subject.equals(subject)) && + (!predicate || q.predicate.equals(predicate)) && + (!object || q.object.equals(object)) && + (!graph || q.graph.equals(graph)))) + } + + [Symbol.iterator]() { + return this.#quads[Symbol.iterator]() + } +} From 07891852332116e253deea76a7abd9af7f572ae2 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Sun, 14 Jun 2026 11:55:20 +0100 Subject: [PATCH 3/5] Adjust demo --- index.html | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index 406fb45..b083828 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,9 @@ { "imports": { "oauth4webapi": "https://cdn.jsdelivr.net/npm/oauth4webapi@3.8.6/+esm", - "dpop": "https://cdn.jsdelivr.net/npm/dpop@2.1.1/+esm" + "dpop": "https://cdn.jsdelivr.net/npm/dpop@2.1.1/+esm", + "n3": "https://esm.sh/n3@2.0.3", + "@rdfjs/wrapper": "https://cdn.jsdelivr.net/npm/@rdfjs/wrapper@0.34.0/+esm" } } @@ -16,6 +18,15 @@ import { DPoPTokenProvider, ReactiveFetchManager } from "./dist/mod.js" import "./dist/registerElements.js" + /* Reactive fetch infrastructure */ + const ui = document.querySelector("authorization-code-flow") + const issuerUi = document.querySelector("idp-picker") + const callbackUri = new URL("/callback.html", location.href).toString() + + const dPoPTokenProvider = new DPoPTokenProvider(callbackUri, ui.getCode.bind(ui), issuerUi.getIssuer.bind(issuerUi)) + + const fetch = new ReactiveFetchManager([dPoPTokenProvider]).fetch + /* Page functionality: Buttons */ document.addEventListener("DOMContentLoaded", () => document.querySelectorAll("button.fetch").forEach(button => @@ -46,26 +57,6 @@ // f("http://localhost:3000/a/profile/"), ]) }) - - /* Reactive fetch infrastructure */ - // const mapping = [ - // {storage: /.solidcommunity.net/, idp: "https://solidcommunity.net"}, - // {storage: /datapod.igrant.io/, idp: "https://datapod.igrant.io"}, - // {storage: /.solidweb.app/, idp: "https://solidweb.app"}, - // {storage: /storage.inrupt.com/, idp: "https://login.inrupt.com"}, - // {storage: /teamid.live/, idp: "https://teamid.live"}, - // {storage: /.solidweb.org/, idp: "https://solidweb.org"}, - // {storage: /.privatedatapod.com/, idp: "https://privatedatapod.com"}, - // {storage: /localhost:3000/, idp: "http://localhost:3000"}, - // ] - - const ui = document.querySelector("authorization-code-flow") - const issuerUi = document.querySelector("idp-picker") - const callbackUri = new URL("/callback.html", location.href).toString() - - const dPoPTokenProvider = new DPoPTokenProvider(callbackUri, ui.getCode.bind(ui), issuerUi.getIssuer.bind(issuerUi)) - - new ReactiveFetchManager([dPoPTokenProvider]).registerGlobally() @@ -95,6 +86,14 @@ + + + + + + + + \ No newline at end of file From 5e4d46af16b555aeb23bc958593cf37ae29d602b Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Sun, 14 Jun 2026 12:41:11 +0100 Subject: [PATCH 4/5] New provider in demo --- index.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/index.html b/index.html index b083828..e882189 100644 --- a/index.html +++ b/index.html @@ -68,6 +68,7 @@ + USER INTERACTION NEEDED TO LAUNCH AUTHORIZATION CODE FLOW IN NEW WINDOW. @@ -85,6 +86,7 @@ + @@ -93,6 +95,7 @@ + From a10c0110035a6d01007eecb8cf6ca87cf5674bcc Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Sun, 14 Jun 2026 12:41:56 +0100 Subject: [PATCH 5/5] Remove timeout in demo --- index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index e882189..c835634 100644 --- a/index.html +++ b/index.html @@ -38,7 +38,8 @@ console.log(`Start fetch to ${uri}`) try { - const response = await fetch(uri, {signal: AbortSignal.timeout(5000)}) + // const response = await fetch(uri, {signal: AbortSignal.timeout(5000)}) + const response = await fetch(uri) console.log(`Fetch succeeded to ${uri}`, response) } catch (e) { console.error(`Fetch failed to ${uri}`, e)