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
80 changes: 74 additions & 6 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ interface IssuerSession {
authorizationServer: oauth.AuthorizationServer
clientRegistration: ClientRegistration
dpopKey: CryptoKeyPair
/** The oauth4webapi DPoP handle for token-endpoint requests. Reused so refreshed tokens stay bound to the same key (RFC 9449 §4.3) and server-provided nonces are remembered. */
dpopHandle: oauth.DPoPHandle
accessToken: string
/** The refresh token (RFC 6749 §6), when the server issued one. Updated in place when the server rotates it. */
refreshToken: string | undefined
/** 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 @@ -83,12 +87,55 @@ export class DPoPTokenProvider implements TokenProvider {
// Renew, unless a concurrent caller already replaced the expired session.
if (this.#sessions.get(issuer.href) === pending) {
this.#sessions.delete(issuer.href)
return this.#begin(issuer, this.#authenticate(issuer))
return this.#begin(issuer, this.#renew(issuer, session))
}

return this.#session(issuer)
}

/** Prefers a transparent refresh-token grant; falls back to a new authorization-code flow when there is no refresh token or the grant fails (expired, revoked, rotation reuse, …). */
async #renew(issuer: URL, expired: IssuerSession): Promise<IssuerSession> {
if (expired.refreshToken === undefined) {
return this.#authenticate(issuer)
}

try {
return await this.#refresh(expired, expired.refreshToken)
} catch {
// Deliberately not logging the error: oauth4webapi errors can carry the
// token-endpoint request/response (tokens included).
console.debug("Refresh token grant failed, falling back to a new authorization")
return this.#authenticate(issuer)
}
}

/** The refresh-token grant (RFC 6749 §6), DPoP-bound to the session's key, adopting the rotated refresh token when the server issues one (RFC 9700 §4.14.2). */
async #refresh(session: IssuerSession, refreshToken: string): Promise<IssuerSession> {
const {authorizationServer, clientRegistration, dpopHandle} = session
const clientAuth = this.getClientAuth(authorizationServer.issuer, clientRegistration)

const grant = () => oauth.refreshTokenGrantRequest(authorizationServer, clientRegistration, clientAuth, refreshToken, {DPoP: dpopHandle, signal: this.#authSignal})

let tokenResult
try {
tokenResult = await oauth.processRefreshTokenResponse(authorizationServer, clientRegistration, await grant())
} catch (e) {
if (!oauth.isDPoPNonceError(e)) {
throw e
}

// The handle has captured the server's DPoP nonce from the error response; retry once.
tokenResult = await oauth.processRefreshTokenResponse(authorizationServer, clientRegistration, await grant())
}
Comment on lines +120 to +129

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 8a5e8ae: the fake AS can now challenge refresh grants with use_dpop_nonce + a DPoP-Nonce header, and a new test drives the challenge-then-retry handshake end to end (exactly two refresh requests, no popup).


return {
...session,
accessToken: tokenResult.access_token,
refreshToken: tokenResult.refresh_token ?? refreshToken,
expiresAt: expiresAt(tokenResult),
}
}

/** Caches the in-flight work; evicts it on failure so the flow can be retried. */
async #begin(issuer: URL, work: Promise<IssuerSession>): Promise<IssuerSession> {
this.#sessions.set(issuer.href, work)
Expand All @@ -109,7 +156,18 @@ export class DPoPTokenProvider implements TokenProvider {
const discoveryResponse = await oauth.discoveryRequest(issuer, {signal})
const authorizationServer = await oauth.processDiscoveryResponse(issuer, discoveryResponse)

const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, {redirect_uris: [this.#callbackUri]}, {signal})
// Opt in to refresh tokens where the server supports them: register for the
// refresh_token grant and ask for the offline_access scope (OIDC Core §11).
// Servers that support neither see the exact requests they saw before.
const useRefreshTokens = authorizationServer.grant_types_supported?.includes("refresh_token") ?? false
const useOfflineAccess = authorizationServer.scopes_supported?.includes("offline_access") ?? false

const registrationMetadata: Parameters<typeof oauth.dynamicClientRegistrationRequest>[1] = {
redirect_uris: [this.#callbackUri],
...useRefreshTokens ? {grant_types: ["authorization_code", "refresh_token"]} : {},
}

const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, registrationMetadata, {signal})
const clientRegistration = await oauth.processDynamicClientRegistrationResponse(registrationResponse)
const [registeredRedirectUri] = clientRegistration.redirect_uris as string[]
const [registeredResponseType] = clientRegistration.response_types as string[]
Expand All @@ -125,7 +183,7 @@ export class DPoPTokenProvider implements TokenProvider {
authorizationUrl.searchParams.set("client_id", clientRegistration.client_id)
authorizationUrl.searchParams.set("redirect_uri", registeredRedirectUri!)
authorizationUrl.searchParams.set("response_type", registeredResponseType!)
authorizationUrl.searchParams.set("scope", "openid webid")
authorizationUrl.searchParams.set("scope", useOfflineAccess ? "openid webid offline_access" : "openid webid")
authorizationUrl.searchParams.set("prompt", "none")
authorizationUrl.searchParams.set("state", state)
authorizationUrl.searchParams.set("nonce", nonce)
Expand Down Expand Up @@ -153,9 +211,17 @@ export class DPoPTokenProvider implements TokenProvider {
// Workaround ESS not returning `iss` in error response
isEssMissingIssInteractionNeeded(e)
) {
console.debug("Authorization server requires user interaction, retrying without prompt")

authorizationUrl.searchParams.delete("prompt")
console.debug("Authorization server requires user interaction, retrying interactively")

// The interactive attempt must carry `prompt=consent` for the server
// to honour `offline_access`: OIDC Core §11 says the AS MUST ignore
// the scope otherwise, and oidc-provider (Community Solid Server and
// brokers built on it) enforces that strictly.
if (useOfflineAccess) {
authorizationUrl.searchParams.set("prompt", "consent")
} else {
authorizationUrl.searchParams.delete("prompt")
}
const authorizationCodeResponse = await this.#getCode(authorizationUrl, signal)
authorizationCodeParams = oauth.validateAuthResponse(authorizationServer, clientRegistration, new URL(authorizationCodeResponse), state)
} else {
Expand All @@ -171,7 +237,9 @@ export class DPoPTokenProvider implements TokenProvider {
authorizationServer,
clientRegistration,
dpopKey,
dpopHandle: dpop,
accessToken: tokenResult.access_token,
refreshToken: tokenResult.refresh_token,
expiresAt: expiresAt(tokenResult),
}
}
Expand Down
131 changes: 131 additions & 0 deletions test/DPoPTokenProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,134 @@ describe("DPoPTokenProvider session cache", () => {
expect(getCode).toHaveBeenCalledTimes(2)
})
})

describe("DPoPTokenProvider refresh tokens", () => {
beforeEach(async () => {
as = await createFakeAuthorizationServer({
issueRefreshTokens: true,
scopesSupported: ["openid", "webid", "offline_access"],
grantTypesSupported: ["authorization_code", "refresh_token"],
})
vi.stubGlobal("fetch", as.fetch)
})

it("opts in where supported: registers the refresh_token grant and requests offline_access", async () => {
const {provider} = makeProvider()

await provider.upgrade(new Request("https://pod.test/a"))

expect(as.registrations[0]?.grant_types).toEqual(["authorization_code", "refresh_token"])
expect(as.authorizationRequests[0]?.scope).toBe("openid webid offline_access")
})

it("does not change the requests for servers without refresh support", async () => {
as = await createFakeAuthorizationServer()
vi.stubGlobal("fetch", as.fetch)
const {provider} = makeProvider()

await provider.upgrade(new Request("https://pod.test/a"))

expect(as.registrations[0]?.grant_types).toBeUndefined()
expect(as.authorizationRequests[0]?.scope).toBe("openid webid")
})

it("refreshes an expired access token without user interaction", async () => {
const {provider, getCode} = makeProvider()

const first = await provider.upgrade(new Request("https://pod.test/a"))

vi.useFakeTimers()
vi.setSystemTime(Date.now() + 3601 * 1000)

const second = await provider.upgrade(new Request("https://pod.test/b"))

expect(getCode).toHaveBeenCalledTimes(1) // no new popup
expect(second.headers.get("Authorization")).not.toBe(first.headers.get("Authorization"))
expect(as.tokenRequests.at(-1)?.get("grant_type")).toBe("refresh_token")
})

it("adopts the rotated refresh token (a second expiry refreshes with the new one)", async () => {
const {provider, getCode} = makeProvider()

await provider.upgrade(new Request("https://pod.test/a"))

vi.useFakeTimers()
vi.setSystemTime(Date.now() + 3601 * 1000)
await provider.upgrade(new Request("https://pod.test/b"))

vi.setSystemTime(Date.now() + 3601 * 1000)
const third = await provider.upgrade(new Request("https://pod.test/c"))

expect(getCode).toHaveBeenCalledTimes(1)
expect(third.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/)

const refreshRequests = as.tokenRequests.filter(r => r.get("grant_type") === "refresh_token")
expect(refreshRequests).toHaveLength(2)
// The second refresh presented a different (rotated) token than the first.
expect(refreshRequests[1]?.get("refresh_token")).not.toBe(refreshRequests[0]?.get("refresh_token"))
})

it("sends prompt=consent on the interactive attempt so strict servers honour offline_access (OIDC Core §11)", async () => {
as = await createFakeAuthorizationServer({
issueRefreshTokens: true,
scopesSupported: ["openid", "webid", "offline_access"],
enforceOfflineAccessConsent: true,
})
vi.stubGlobal("fetch", as.fetch)
const {provider, getCode} = makeProvider()

const first = await provider.upgrade(new Request("https://pod.test/a"))

expect(first.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/)
expect(getCode).toHaveBeenCalledTimes(2) // silent attempt → login_required → interactive retry
expect(as.authorizationRequests.at(-1)?.prompt).toBe("consent")

// The strict server issued a refresh token, so expiry renews silently.
vi.useFakeTimers()
vi.setSystemTime(Date.now() + 3601 * 1000)
await provider.upgrade(new Request("https://pod.test/b"))

expect(getCode).toHaveBeenCalledTimes(2) // no further interaction
expect(as.tokenRequests.at(-1)?.get("grant_type")).toBe("refresh_token")
})

it("retries the refresh grant once when the server demands a DPoP nonce", async () => {
as = await createFakeAuthorizationServer({
issueRefreshTokens: true,
scopesSupported: ["openid", "webid", "offline_access"],
refreshRequiresDPoPNonce: true,
})
vi.stubGlobal("fetch", as.fetch)
const {provider, getCode} = makeProvider()

await provider.upgrade(new Request("https://pod.test/a"))

vi.useFakeTimers()
vi.setSystemTime(Date.now() + 3601 * 1000)

const second = await provider.upgrade(new Request("https://pod.test/b"))

expect(getCode).toHaveBeenCalledTimes(1) // refreshed silently despite the nonce challenge
expect(second.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/)

const refreshRequests = as.tokenRequests.filter(r => r.get("grant_type") === "refresh_token")
expect(refreshRequests).toHaveLength(2) // challenged once, then accepted with the nonce
})

it("falls back to a new authorization-code flow when the refresh grant fails", async () => {
const {provider, getCode} = makeProvider()

await provider.upgrade(new Request("https://pod.test/a"))

// Revoke server-side: the next refresh attempt gets invalid_grant.
as.activeRefreshTokens.clear()

vi.useFakeTimers()
vi.setSystemTime(Date.now() + 3601 * 1000)

const second = await provider.upgrade(new Request("https://pod.test/b"))

expect(getCode).toHaveBeenCalledTimes(2) // re-authorized
expect(second.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/)
})
})
Loading
Loading