-
Notifications
You must be signed in to change notification settings - Fork 1
WebID UI #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
WebID UI #16
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,14 +8,25 @@ | |
| { | ||
| "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" | ||
| } | ||
| } | ||
| </script> | ||
| <script type="module"> | ||
| 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 | ||
|
|
||
|
Comment on lines
+21
to
+29
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Substantial change here is that in the demo, global Fun fact: Some Solid servers return |
||
| /* Page functionality: Buttons */ | ||
| document.addEventListener("DOMContentLoaded", () => | ||
| document.querySelectorAll("button.fetch").forEach(button => | ||
|
|
@@ -27,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) | ||
|
|
@@ -46,26 +58,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() | ||
| </script> | ||
| </head> | ||
| <body> | ||
|
|
@@ -77,6 +69,7 @@ | |
| <button class="fetch" value="https://teamid.live/langsamu/settings/">teamid.live (CSS)</button> | ||
| <button class="fetch" value="https://langsamu.solidweb.org/settings/">solidweb.org (NSS 6.0.0)</button> | ||
| <button class="fetch" value="https://langsamu.privatedatapod.com/settings/">privatedatapod (CSS)</button> | ||
| <button class="fetch" value="https://solidweb.me/langsamu/profile/">solidweb.me (CSS)</button> | ||
| <button class="fetch" value="http://localhost:3000/a/profile/">localhost (CSS)</button> | ||
| <authorization-code-flow> | ||
| <span slot="new-text">USER INTERACTION NEEDED TO LAUNCH AUTHORIZATION CODE FLOW IN NEW WINDOW.</span> | ||
|
|
@@ -94,7 +87,17 @@ | |
| <option value="https://teamid.live"></option> | ||
| <option value="https://solidweb.org"></option> | ||
| <option value="https://privatedatapod.com"></option> | ||
| <option value="https://solidweb.me/"></option> | ||
| <option value="http://localhost:3000"></option> | ||
| <webid-picker slot="webid-picker"> | ||
| <option value="https://YOUR_USERNAME.solidcommunity.net/profile/card#me"></option> | ||
| <option value="https://id.inrupt.com/YOUR_USERNAME"></option> | ||
| <option value="https://YOUR_USERNAME.datapod.igrant.io/profile/card#me"></option> | ||
| <option value="https://YOUR_USERNAME.solidweb.app/profile/card.jsonld#me"></option> | ||
| <option value="https://teamid.live/YOUR_USERNAME/profile/card#me"></option> | ||
| <option value="https://YOUR_USERNAME.solidweb.org/profile/card#me"></option> | ||
| <option value="https://solidweb.me/YOUR_USERNAME/profile/card#me"></option> | ||
| </webid-picker> | ||
|
Comment on lines
+92
to
+100
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting pattern of composing custom elements: The new |
||
| </idp-picker> | ||
| </body> | ||
| </html> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ` | |
| <button> | ||
| <slot name="ok-button">OK</slot> | ||
| </button> | ||
| <button type="button"> | ||
| <button type="button" id="webid" hidden disabled accesskey="w"> | ||
| <slot name="webid-button">Use <u>W</u>ebID</slot> | ||
| </button> | ||
|
Comment on lines
+39
to
+41
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New button on existing UI that allows invoking the new element to get WebID from there. |
||
| <button type="button" id="cancel"> | ||
| <slot name="cancel-button">Cancel</slot> | ||
| </button> | ||
| </form> | ||
| <slot name="webid-picker"></slot> | ||
| </fieldset> | ||
| </dialog> | ||
| ` | ||
|
|
@@ -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<HTMLButtonElement>("#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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IdP selector works as before if there's no WebID picker child. But if there is then it can be invoked here. |
||
| } | ||
|
|
||
| async getIssuer(request: Request): Promise<URL> { | ||
| 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() | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+125
to
+136
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React to click of new button. Get URI, load, parse, extract issuer and populate our own input. |
||
|
|
||
| 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<URL> { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Very simple and intentionally paranoid method to load and parse WebID and extract issuer. |
||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure what the proper way to do this is. I find N3 unusable in a modern module way. Maybe I just don't know how to configure package dependencies and/or import maps and/or unpackagers. Specifically N3 seems to be pulling in |
||
| 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]() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { Mutex } from "./Mutex.js" | ||
| import { WebIdRequestCancelledError } from "./WebIdRequestCancelledError.js" | ||
|
|
||
| const onlyOnce = {once: true} | ||
| const searchString = "YOUR_USERNAME" | ||
| const html = ` | ||
| <dialog> | ||
| <fieldset> | ||
| <legend> | ||
| <slot name="legend">WebID required</slot> | ||
| </legend> | ||
| <p> | ||
| <span> | ||
| <slot name="description">The application needs a WebID so it can derive an Authorization Server URI from the profile to handle the following request URI.</slot> | ||
| </span> | ||
| <details> | ||
| <summary> | ||
| <slot name="summary">Request URI</slot> | ||
| </summary> | ||
| <code></code> | ||
| </details> | ||
| </p> | ||
| <form method="dialog"> | ||
| <label> | ||
| <span> | ||
| <slot name="label">WebID</slot> | ||
| </span> | ||
| <input autofocus required type="url" list="autocomplete"> | ||
| <datalist id="autocomplete"></datalist> | ||
| </label> | ||
| <button> | ||
| <slot name="ok-button">OK</slot> | ||
| </button> | ||
| <button type="button" id="cancel"> | ||
| <slot name="cancel-button">Cancel</slot> | ||
| </button> | ||
| </form> | ||
| </fieldset> | ||
| </dialog> | ||
| ` | ||
|
|
||
| export class WebIdPicker extends HTMLElement { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A very simple custom element that is quite similar to the existing IdP picker. It's a modal that asks for a WebID URI, with an optional |
||
| 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") | ||
| } | ||
| }) | ||
|
Comment on lines
+62
to
+67
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the |
||
|
|
||
| for (const option of this.querySelectorAll(":scope > option")) { | ||
| shadow.querySelector("datalist")!.appendChild(option.cloneNode()) | ||
| } | ||
| } | ||
|
|
||
| async getWebId(request: Request): Promise<URL> { | ||
| 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<URL>() | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As before, this is for the demo only. Just so I don't have to worry about transpilation and bundling at the moment.