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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<oauth.TokenEndpointResponse | undefined> {
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
Expand Down Expand Up @@ -171,6 +199,7 @@ export class DPoPTokenProvider implements TokenProvider {
authorizationServer,
clientRegistration,
dpopKey,
tokenEndpointResponse: tokenResult,
accessToken: tokenResult.access_token,
expiresAt: expiresAt(tokenResult),
}
Expand Down
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions src/webIdFrom.ts
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions test/DPoPTokenProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>((_, 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()
})
})
4 changes: 4 additions & 0 deletions test/fakeAuthorizationServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown> = {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}`))
Expand Down
46 changes: 46 additions & 0 deletions test/webIdFrom.test.ts
Original file line number Diff line number Diff line change
@@ -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<TokenEndpointResponse> {
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()
})
})