Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 25 additions & 22 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines -11 to +13

Copy link
Copy Markdown
Collaborator Author

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.

}
}
</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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substantial change here is that in the demo, global fetch is no longer patched. Because now we're using it to get WebIDs too.

Fun fact: Some Solid servers return 401 for a bogus WebID (e.g. https://YOUR_USERNAME.solidcommunity.net/profile/card#me).

/* Page functionality: Buttons */
document.addEventListener("DOMContentLoaded", () =>
document.querySelectorAll("button.fetch").forEach(button =>
Expand All @@ -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)
Expand All @@ -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>
Expand All @@ -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>
Expand All @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting pattern of composing custom elements:

The new <webid-picker/> is a standalone web component.
But it also integrates this way with the existing <idp-picker/>.
Advantage over programmatic construction is customizability and stylability.

</idp-picker>
</body>
</html>
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 65 additions & 3 deletions src/IdpPicker.ts
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 = `
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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>
`
Expand All @@ -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() {
Expand All @@ -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

@langsamu langsamu Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 = ""
Expand All @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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
}
47 changes: 47 additions & 0 deletions src/SimpleDataset.ts
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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 readable-stream which is Node only, so here's this minimal implementation instead of N3.Store.

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]()
}
}
103 changes: 103 additions & 0 deletions src/WebIdPicker.ts
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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 datalist for soft select functionality, as well as slots for text customization and cancel support.
I'll add CSS parts later.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the options include the string "YOUR_USERNAME" in the WebID URI template then it will be selected for the user to type over.


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
}
}
8 changes: 8 additions & 0 deletions src/WebIdRequestCancelledError.ts
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"
}
}
2 changes: 2 additions & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading