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() + }) +})