From e7682c42ded50f1b47eb3b078f9588e5e939bb9b Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 6 Aug 2026 10:07:37 +0100 Subject: [PATCH] feat: report the token endpoint response and read the WebID out of it Solid OIDC requires the ID Token to carry a webid claim, but the token endpoint response was discarded once its access token had been read. An app that signs in by picking an Authorization Server therefore had no way to learn who it signed in as, and had to ask the user for a WebID it could have been told. DPoPTokenProvider now keeps the processed token endpoint response on the issuer's session and reports it from tokenEndpointResponse(issuer), which never starts a flow of its own and reports nothing until one has completed. Reading the WebID out of that response is a separate exported function, webIdFrom, so the provider stays concerned with tokens alone and callers can reach the other claims the ID Token was validated for. --- README.md | 18 +++++++++++ src/DPoPTokenProvider.ts | 29 ++++++++++++++++++ src/mod.ts | 1 + src/webIdFrom.ts | 19 ++++++++++++ test/DPoPTokenProvider.test.ts | 53 +++++++++++++++++++++++++++++++++ test/fakeAuthorizationServer.ts | 4 +++ test/webIdFrom.test.ts | 46 ++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+) create mode 100644 src/webIdFrom.ts create mode 100644 test/webIdFrom.test.ts diff --git a/README.md b/README.md index c22a9a6..35a3b70 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,24 @@ manager.registerGlobally() const response = await fetch(requestUri) ``` +### Learn who is signed in + +Users sign in by picking an Authorization Server, so the app does not know their +WebID until the server asserts one. The `DPoPTokenProvider` reports the token +endpoint response its session rests on, and `webIdFrom` reads out of it the +`webid` claim that Solid OIDC requires the ID Token to carry: + +```js +import { webIdFrom } from "@solid/reactive-authentication" + +const issuer = await getIssuer(new Request(requestUri)) +const tokens = await provider.tokenEndpointResponse(issuer) +const webId = tokens === undefined ? undefined : webIdFrom(tokens) +``` + +There is nothing to report until a flow for that Authorization Server has +completed, and asking never starts one. + ## Run the demo To compile, diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index eac3cc5..cabd0ec 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -12,6 +12,8 @@ interface IssuerSession { authorizationServer: oauth.AuthorizationServer clientRegistration: ClientRegistration dpopKey: CryptoKeyPair + /** The token endpoint response the session was established from, kept whole so callers can read its claims. */ + tokenEndpointResponse: oauth.TokenEndpointResponse accessToken: string /** Epoch milliseconds after which the access token is considered expired, or undefined when the server gave no expiry. */ expiresAt: number | undefined @@ -64,6 +66,32 @@ export class DPoPTokenProvider implements TokenProvider { return new Request(request, {headers}) } + /** + * The token endpoint response the issuer's current session rests on, as + * processed by oauth4webapi. + * + * @remarks + * Resolves to undefined until a flow for this issuer has completed, and + * never starts one: an app that signed in with only an issuer calls this + * after its first authenticated request to read what the issuer said about + * the session — `webIdFrom` takes the WebID out of it. + * + * The response carries the session's tokens, so treat it as a secret. + */ + async tokenEndpointResponse(issuer: URL): Promise { + const pending = this.#sessions.get(issuer.href) + if (pending === undefined) { + return undefined + } + + try { + return (await pending).tokenEndpointResponse + } catch { + // A flow that failed established nothing to report; #session retries it. + return undefined + } + } + /** * Returns the cached session for the issuer, renewing it when expired and * establishing it when absent. A failed flow is not cached, so the next @@ -171,6 +199,7 @@ export class DPoPTokenProvider implements TokenProvider { authorizationServer, clientRegistration, dpopKey, + tokenEndpointResponse: tokenResult, accessToken: tokenResult.access_token, expiresAt: expiresAt(tokenResult), } diff --git a/src/mod.ts b/src/mod.ts index 0342cf6..cd6938f 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -8,6 +8,7 @@ export * from "./ReactiveAuthenticationError.js" export * from "./ClientCredentialsTokenProvider.js" export * from "./GetCodeCallback.js" export * from "./issuerFrom.js" +export * from "./webIdFrom.js" export * from "./TokenProvider.js" export * from "./GetIssuerCallback.js" export * from "./IdpPicker.js" diff --git a/src/webIdFrom.ts b/src/webIdFrom.ts new file mode 100644 index 0000000..f826876 --- /dev/null +++ b/src/webIdFrom.ts @@ -0,0 +1,19 @@ +import * as oauth from "oauth4webapi" + +/** + * The WebID an issuer asserted for the user, read from the `webid` claim that + * Solid-OIDC requires the id_token to carry. + * + * @param response A token endpoint response processed by oauth4webapi, such as + * the one `DPoPTokenProvider.tokenEndpointResponse` reports. + * + * @returns The asserted WebID, or undefined when the response carries no + * id_token or the id_token carries no `webid` claim. + * + * @see [Solid-OIDC ID Tokens](https://solidproject.org/TR/oidc#tokens-id) + */ +export function webIdFrom(response: oauth.TokenEndpointResponse): string | undefined { + const webId = oauth.getValidatedIdTokenClaims(response)?.webid + + return typeof webId === "string" ? webId : undefined +} diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index 470a45b..32896bb 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -91,3 +91,56 @@ describe("DPoPTokenProvider session cache", () => { expect(getCode).toHaveBeenCalledTimes(2) }) }) + +describe("DPoPTokenProvider token endpoint response", () => { + beforeEach(async () => { + as = await createFakeAuthorizationServer() + vi.stubGlobal("fetch", as.fetch) + }) + + it("reports the response the session rests on once a flow has completed", async () => { + const {provider} = makeProvider() + + const upgraded = await provider.upgrade(new Request("https://pod.test/private")) + const reported = await provider.tokenEndpointResponse(new URL(as.issuer)) + + expect(upgraded.headers.get("Authorization")).toBe(`DPoP ${reported?.access_token}`) + }) + + it("reports nothing before a flow has run, without starting one", async () => { + const {provider, getCode} = makeProvider() + + await expect(provider.tokenEndpointResponse(new URL(as.issuer))).resolves.toBeUndefined() + expect(getCode).not.toHaveBeenCalled() + }) + + it("reports the renewed response once the session has been re-established", async () => { + const {provider} = makeProvider() + + await provider.upgrade(new Request("https://pod.test/a")) + const first = await provider.tokenEndpointResponse(new URL(as.issuer)) + + // Step past the reported expiry (minus the skew allowance). + vi.useFakeTimers() + vi.setSystemTime(Date.now() + 3601 * 1000) + + await provider.upgrade(new Request("https://pod.test/b")) + const second = await provider.tokenEndpointResponse(new URL(as.issuer)) + + expect(second?.access_token).not.toBe(first?.access_token) + }) + + it("reports nothing for a flow that fails, rather than the failure", async () => { + let failCode!: (reason: Error) => void + const getCode = vi.fn(() => new Promise((_, reject) => {failCode = reject})) + const {provider} = makeProvider(getCode) + + const upgrade = provider.upgrade(new Request("https://pod.test/a")) + await vi.waitUntil(() => getCode.mock.calls.length === 1) + const reported = provider.tokenEndpointResponse(new URL(as.issuer)) + failCode(new Error("user closed the popup")) + + await expect(upgrade).rejects.toThrow("user closed the popup") + await expect(reported).resolves.toBeUndefined() + }) +}) diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts index 7f651f4..7d9946b 100644 --- a/test/fakeAuthorizationServer.ts +++ b/test/fakeAuthorizationServer.ts @@ -20,6 +20,8 @@ export interface FakeAuthorizationServerOptions { scopesSupported?: string[] /** `grant_types_supported` advertised by discovery. Default ["authorization_code"]. */ grantTypesSupported?: string[] + /** The `webid` claim put in the id_token, as Solid-OIDC requires. Pass null to omit it. */ + webId?: string | null } export interface AuthorizationRequestRecord { @@ -82,6 +84,8 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe const header = base64url(JSON.stringify({alg: "ES256", kid: "test"})) const now = Math.floor(Date.now() / 1000) const claims: Record = {iss: issuer, sub: "user", aud: clientId, iat: now, exp: now + 600} + const webId = options.webId === undefined ? "https://pod.test/profile/card#me" : options.webId + if (webId !== null) claims.webid = webId if (nonce !== null) claims.nonce = nonce const payload = base64url(JSON.stringify(claims)) const signature = await crypto.subtle.sign({name: "ECDSA", hash: "SHA-256"}, keys.privateKey, encoder.encode(`${header}.${payload}`)) diff --git a/test/webIdFrom.test.ts b/test/webIdFrom.test.ts new file mode 100644 index 0000000..3702857 --- /dev/null +++ b/test/webIdFrom.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import type { TokenEndpointResponse } from "oauth4webapi" +import { DPoPTokenProvider } from "../src/DPoPTokenProvider.js" +import { webIdFrom } from "../src/webIdFrom.js" +import { createFakeAuthorizationServer, type FakeAuthorizationServerOptions } from "./fakeAuthorizationServer.js" + +const callbackUri = "https://app.test/callback.html" + +/** + * oauth4webapi only hands out the claims of id_tokens it validated itself, so + * the response under test comes from a real flow rather than a literal. + */ +async function signIn(options: FakeAuthorizationServerOptions = {}): Promise { + const as = await createFakeAuthorizationServer(options) + vi.stubGlobal("fetch", as.fetch) + + const provider = new DPoPTokenProvider(callbackUri, url => as.authorize(url), async () => new URL(as.issuer)) + await provider.upgrade(new Request("https://pod.test/private")) + + const response = await provider.tokenEndpointResponse(new URL(as.issuer)) + if (response === undefined) { + throw new Error("the flow established no session") + } + + return response +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("webIdFrom", () => { + it("returns the WebID the id_token asserted", async () => { + expect(webIdFrom(await signIn())).toBe("https://pod.test/profile/card#me") + }) + + it("returns nothing when the id_token carries no webid claim", async () => { + expect(webIdFrom(await signIn({webId: null}))).toBeUndefined() + }) + + it("returns nothing when the response carries no id_token", () => { + const response: TokenEndpointResponse = {access_token: "at-1", token_type: "dpop"} + + expect(webIdFrom(response)).toBeUndefined() + }) +})