From 5132179c52169f2a3fb41dbea240e5d2d6f2c7c9 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:59:41 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20refresh-token=20support=20=E2=80=94?= =?UTF-8?q?=20renew=20expired=20sessions=20without=20user=20interaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions often outlive the access token (commonly 1 h), and until now the only way to keep going was to re-run the whole authorization flow, popup included. This makes DPoPTokenProvider renew transparently: - where the server advertises support, the provider registers the refresh_token grant (dynamic registration metadata) and requests the offline_access scope (OIDC Core §11); servers without support see the exact requests they saw before; - the refresh token is stored alongside the per-issuer session and an expired access token is renewed with the refresh-token grant (RFC 6749 §6) — no popup, no user interaction; - the grant is DPoP-bound with the session's existing key/handle, so the refreshed access token keeps the same cnf.jkt binding (RFC 9449 §4.3), with a single retry on a server-required DPoP nonce; - rotation is handled per RFC 9700 §4.14.2: when the server rotates the refresh token, the newest one always replaces the old; - when the refresh grant fails (refresh-token expiry, revocation, rotation-reuse detection), the provider falls back to a fresh authorization-code flow — silent while the IdP cookie lives. Public API is unchanged. Tokens stay in memory only, as before. Co-Authored-By: Claude Fable 5 --- src/DPoPTokenProvider.ts | 64 ++++++++++++++++++++++++-- test/DPoPTokenProvider.test.ts | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index eac3cc5..ad6ba6b 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -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 } @@ -83,12 +87,53 @@ 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 { + if (expired.refreshToken === undefined) { + return this.#authenticate(issuer) + } + + try { + return await this.#refresh(expired, expired.refreshToken) + } catch (e) { + console.debug("Refresh token grant failed, falling back to a new authorization", e) + 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 { + 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()) + } + + 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): Promise { this.#sessions.set(issuer.href, work) @@ -109,7 +154,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[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[] @@ -125,7 +181,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) @@ -171,7 +227,9 @@ export class DPoPTokenProvider implements TokenProvider { authorizationServer, clientRegistration, dpopKey, + dpopHandle: dpop, accessToken: tokenResult.access_token, + refreshToken: tokenResult.refresh_token, expiresAt: expiresAt(tokenResult), } } diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index 470a45b..cb090c0 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -91,3 +91,87 @@ 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("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+$/) + }) +}) From 8a5e8aed38ac3fcf784e34e71daf6b8fe08ebd92 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:44:25 +0100 Subject: [PATCH 2/3] test: cover the DPoP-nonce retry on the refresh grant; stop logging the raw refresh error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: the fake AS can now challenge refresh grants with use_dpop_nonce + DPoP-Nonce (RFC 9449 §8) so the one-retry handshake is exercised end to end, and the refresh-failure fallback no longer logs the raw oauth4webapi error (it can carry the token-endpoint request/response, tokens included). Co-Authored-By: Claude Fable 5 --- src/DPoPTokenProvider.ts | 6 ++++-- test/DPoPTokenProvider.test.ts | 23 +++++++++++++++++++++++ test/fakeAuthorizationServer.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index ad6ba6b..5a70ea9 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -101,8 +101,10 @@ export class DPoPTokenProvider implements TokenProvider { try { return await this.#refresh(expired, expired.refreshToken) - } catch (e) { - console.debug("Refresh token grant failed, falling back to a new authorization", e) + } 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) } } diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index cb090c0..2a1384f 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -158,6 +158,29 @@ describe("DPoPTokenProvider refresh tokens", () => { expect(refreshRequests[1]?.get("refresh_token")).not.toBe(refreshRequests[0]?.get("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() diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts index 7f651f4..f7dd9ba 100644 --- a/test/fakeAuthorizationServer.ts +++ b/test/fakeAuthorizationServer.ts @@ -16,6 +16,8 @@ export interface FakeAuthorizationServerOptions { issueRefreshTokens?: boolean /** Whether the refresh-token grant rotates the refresh token. Default true. */ rotateRefreshTokens?: boolean + /** Whether the refresh-token grant demands a server-provided DPoP nonce (RFC 9449 §8), challenging proofs without one via `use_dpop_nonce`. Default false. */ + refreshRequiresDPoPNonce?: boolean /** `scopes_supported` advertised by discovery. Default ["openid", "webid"]. */ scopesSupported?: string[] /** `grant_types_supported` advertised by discovery. Default ["authorization_code"]. */ @@ -61,6 +63,23 @@ function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), {status, headers: {"content-type": "application/json"}}) } +/** The server-provided nonce demanded when `refreshRequiresDPoPNonce` is on. */ +const dpopNonce = "fake-as-dpop-nonce" + +/** The `nonce` claim of the request's DPoP proof, if any (signature deliberately not verified — this is a test double). */ +function dpopProofNonce(request: Request): string | undefined { + const payload = request.headers.get("DPoP")?.split(".")[1] + if (payload === undefined) { + return undefined + } + + try { + return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))).nonce + } catch { + return undefined + } +} + export async function createFakeAuthorizationServer(options: FakeAuthorizationServerOptions = {}): Promise { const issuer = "https://as.test" const expiresIn = options.expiresIn ?? 3600 @@ -151,6 +170,15 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe } if (params.get("grant_type") === "refresh_token" && issueRefreshTokens) { + // Nonce challenge first (RFC 9449 §8): the presented refresh token must + // survive the challenge so the client's retry can redeem it. + if (options.refreshRequiresDPoPNonce && dpopProofNonce(request) !== dpopNonce) { + return new Response(JSON.stringify({error: "use_dpop_nonce", error_description: "Authorization server requires nonce in DPoP proof"}), { + status: 400, + headers: {"content-type": "application/json", "DPoP-Nonce": dpopNonce}, + }) + } + const presented = params.get("refresh_token") ?? "" if (!activeRefreshTokens.has(presented)) { return json({error: "invalid_grant"}, 400) From bb8606e167a232726663358439b3fa45eb5df11d Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:00:52 +0100 Subject: [PATCH 3/3] fix: send prompt=consent on the interactive attempt when requesting offline_access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OIDC Core §11: the AS MUST ignore offline_access unless the request's prompt includes consent — oidc-provider (Community Solid Server and brokers built on it) enforces this strictly, so the previous retry (prompt removed entirely) silently came back without a refresh token. Found live against a Solid broker; the fake AS gains an opt-in enforceOfflineAccessConsent mode reproducing that behaviour, and a test drives silent-attempt → login_required → prompt=consent retry → refresh token issued → silent renewal. Co-Authored-By: Claude Fable 5 --- src/DPoPTokenProvider.ts | 14 +++++++--- test/DPoPTokenProvider.test.ts | 24 ++++++++++++++++++ test/fakeAuthorizationServer.ts | 45 +++++++++++++++++++++++++-------- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 5a70ea9..252b328 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -211,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 { diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index 2a1384f..ca64ac5 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -158,6 +158,30 @@ describe("DPoPTokenProvider refresh tokens", () => { 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, diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts index f7dd9ba..64350d6 100644 --- a/test/fakeAuthorizationServer.ts +++ b/test/fakeAuthorizationServer.ts @@ -18,6 +18,14 @@ export interface FakeAuthorizationServerOptions { rotateRefreshTokens?: boolean /** Whether the refresh-token grant demands a server-provided DPoP nonce (RFC 9449 §8), challenging proofs without one via `use_dpop_nonce`. Default false. */ refreshRequiresDPoPNonce?: boolean + /** + * Emulate a server that enforces OIDC Core §11 the way oidc-provider does + * (Community Solid Server and brokers built on it): `prompt=none` is + * answered with `error=login_required` (no session), and `offline_access` + * is silently dropped from any request whose prompt does not include + * `consent`. Default false (lenient: silent authorization succeeds). + */ + enforceOfflineAccessConsent?: boolean /** `scopes_supported` advertised by discovery. Default ["openid", "webid"]. */ scopesSupported?: string[] /** `grant_types_supported` advertised by discovery. Default ["authorization_code"]. */ @@ -90,8 +98,8 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe const publicJwk = await crypto.subtle.exportKey("jwk", keys.publicKey) let counter = 0 - /** nonce + client of each outstanding authorization code */ - const codes = new Map() + /** nonce + client + effective scope of each outstanding authorization code */ + const codes = new Map() const activeRefreshTokens = new Set() const authorizationRequests: AuthorizationRequestRecord[] = [] const registrations: Record[] = [] @@ -107,12 +115,12 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe return `${header}.${payload}.${base64url(new Uint8Array(signature))}` } - function tokenBody(refreshable: boolean, idToken?: string) { + function tokenBody(refreshable: boolean, scope: string, idToken?: string) { const body: Record = { access_token: `at-${++counter}`, token_type: "DPoP", expires_in: expiresIn, - scope: "openid webid", + scope, } if (idToken !== undefined) body.id_token = idToken if (refreshable) { @@ -166,7 +174,8 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe return json({error: "invalid_grant"}, 400) } codes.delete(params.get("code")!) - return json(tokenBody(issueRefreshTokens, await signIdToken(params.get("client_id") ?? code.clientId ?? "", code.nonce))) + const refreshable = issueRefreshTokens && code.scope.split(" ").includes("offline_access") + return json(tokenBody(refreshable, code.scope, await signIdToken(params.get("client_id") ?? code.clientId ?? "", code.nonce))) } if (params.get("grant_type") === "refresh_token" && issueRefreshTokens) { @@ -186,10 +195,10 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe if (rotate) { // Rotation (RFC 9700 §4.14.2): retire the presented token and issue a replacement. activeRefreshTokens.delete(presented) - return json(tokenBody(true)) + return json(tokenBody(true, "openid webid offline_access")) } // No rotation: the presented token stays active and the response carries no new one (RFC 6749 §6). - return json(tokenBody(false)) + return json(tokenBody(false, "openid webid offline_access")) } return json({error: "unsupported_grant_type"}, 400) @@ -202,17 +211,33 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe issuer, fetch: (input, init) => handle(new Request(input, init)), async authorize(authorizationUrl: URL): Promise { + const prompt = authorizationUrl.searchParams.get("prompt") + const scope = authorizationUrl.searchParams.get("scope") ?? "openid" authorizationRequests.push({ - scope: authorizationUrl.searchParams.get("scope"), - prompt: authorizationUrl.searchParams.get("prompt"), + scope, + prompt, clientId: authorizationUrl.searchParams.get("client_id"), }) + const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri")!) + + if (options.enforceOfflineAccessConsent && prompt === "none") { + // No session: a silent request cannot succeed. + redirect.searchParams.set("error", "login_required") + redirect.searchParams.set("state", authorizationUrl.searchParams.get("state")!) + return redirect.href + } + + // OIDC Core §11: offline_access MUST be ignored unless the prompt includes consent. + const effectiveScope = options.enforceOfflineAccessConsent && !(prompt?.split(" ").includes("consent") ?? false) + ? scope.split(" ").filter(s => s !== "offline_access").join(" ") + : scope + const code = `code-${++counter}` codes.set(code, { nonce: authorizationUrl.searchParams.get("nonce"), clientId: authorizationUrl.searchParams.get("client_id"), + scope: effectiveScope, }) - const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri")!) redirect.searchParams.set("code", code) redirect.searchParams.set("state", authorizationUrl.searchParams.get("state")!) return redirect.href