From 95182797b46afd81b838f7dd2f94c4749a9689aa Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:11:24 +0200 Subject: [PATCH] fix(oauth): retry a refresh grant without scope when the AS refuses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Railway answers every scope-bearing refresh with `invalid_scope: refresh token missing requested scope` when its stored grant is narrower than the authorization it echoed back, so a connection whose refresh token was still live failed every call as `oauth_refresh_failed` and only a hand re-authorization recovered it. RFC 6749 §6 defines omitting `scope` as "the scope originally granted", so retry the grant once without it. Only `invalid_scope` qualifies: `invalid_grant` means the token is dead, and retrying that spends a rotating refresh token to learn nothing. --- .changeset/refresh-scope-fallback.md | 5 + .../oauth-refresh-scope-fallback.test.ts | 297 ++++++++++++++++++ packages/core/sdk/src/oauth-helpers.test.ts | 137 ++++++++ packages/core/sdk/src/oauth-helpers.ts | 157 +++++---- .../core/sdk/src/testing/oauth-test-server.ts | 18 ++ 5 files changed, 551 insertions(+), 63 deletions(-) create mode 100644 .changeset/refresh-scope-fallback.md create mode 100644 e2e/scenarios/oauth-refresh-scope-fallback.test.ts diff --git a/.changeset/refresh-scope-fallback.md b/.changeset/refresh-scope-fallback.md new file mode 100644 index 0000000000..88135d1712 --- /dev/null +++ b/.changeset/refresh-scope-fallback.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Retry a refresh-token grant without `scope` when the authorization server refuses the echoed grant with `invalid_scope`. Railway answers a scope-bearing refresh with "refresh token missing requested scope" even though echoing the granted scope is legal under RFC 6749 §6, so a connection whose refresh token was still live failed every call as `oauth_refresh_failed` and only a hand re-authorization recovered it. diff --git a/e2e/scenarios/oauth-refresh-scope-fallback.test.ts b/e2e/scenarios/oauth-refresh-scope-fallback.test.ts new file mode 100644 index 0000000000..9cc510f09c --- /dev/null +++ b/e2e/scenarios/oauth-refresh-scope-fallback.test.ts @@ -0,0 +1,297 @@ +// Cross-target: an authorization server that refuses a scope-bearing refresh +// grant must not turn a live refresh token into a dead connection. +// +// Railway answers any refresh grant carrying a `scope` parameter with +// `invalid_scope: refresh token missing requested scope` when the refresh +// token's own record is narrower than the authorization response it echoed +// back (issue #1969). Echoing the recorded grant is legal under RFC 6749 §6, +// and so is omitting `scope`, which the spec defines as "the scope originally +// granted". Before the fallback, the refusal reached the sandbox as +// `oauth_refresh_failed` with `retryable: false`, so a connection whose refresh +// token was perfectly alive stayed unusable until someone re-authorized it by +// hand — the reported symptom was a saved Railway connection failing every +// call. +// +// The journey: an OpenAPI integration completes a real authorization-code flow +// against a live test AS that mints instantly-expiring access tokens and holds +// a narrower grant on the refresh token than the authorization echoed. The +// first tool call must therefore refresh, the scope-bearing grant is refused, +// executor retries WITHOUT `scope`, and the call succeeds — proven from the +// AS's own request ledger, which records both the refused request and the +// accepted retry. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +/** Upstream on 127.0.0.1: `GET /issues` is 200 for any bearer and returns one + * issue, so "the call succeeded" is distinguishable from "the call returned + * an empty body". */ +const serveUpstream = () => + Effect.acquireRelease( + Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => { + const server = createServer((request, response) => { + if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ issues: [{ id: "issue-1", title: "Scope drift" }] })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +const spec = ( + baseUrl: string, + oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string }, +): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Issues API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/issues": { + get: { + operationId: "listIssues", + summary: "List issues", + security: [{ oauth: ["issues.read"] }], + responses: { "200": { description: "issues" } }, + }, + }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: { "issues.read": "Read issues", "issues.write": "Write issues" }, + }, + }, + }, + }, + }, + }); + +const invokeByAddressCode = (address: string, args: unknown) => ` +const segments = ${JSON.stringify(address)}.split(".").slice(1); +let node = tools; +for (const segment of segments) node = node[segment]; +const result = await node(${JSON.stringify(args)}); +return JSON.stringify(result); +`; + +type ToolEnvelope = { + readonly ok: boolean; + readonly data?: unknown; + readonly error?: { + readonly code?: string; + readonly message?: string; + }; +}; + +scenario( + "Auth failures · a refresh refused for its scope is retried without one, so a scope-drifted connection keeps working", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const upstream = yield* serveUpstream(); + // The AS grants both scopes at authorization and echoes them, but the + // refresh token it stores covers only `issues.read` — the divergence + // that makes the recorded grant look like a request for more access. + const oauth = yield* serveOAuthTestServer({ + scopes: ["issues.read", "issues.write"], + refreshGrantScopes: ["issues.read"], + tokenExpiresInSeconds: 0, + }); + const slug = unique("refreshscope"); + const clientSlug = OAuthClientSlug.make(unique("refreshscopec")); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: spec(upstream.url, oauth) }, + slug, + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "oauth", + kind: "oauth2", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["issues.read", "issues.write"], + }, + ], + }, + }); + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: clientSlug, + grant: "authorization_code", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + originIntegration: IntegrationSlug.make(slug), + }, + }); + + const started = yield* client.oauth.start({ + payload: { + client: clientSlug, + clientOwner: "org", + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(started.status, "oauth.start redirects to the authorization server").toBe( + "redirect", + ); + if (started.status !== "redirect") return yield* Effect.die("no redirect"); + + // Drive the test IdP's consent by hand (authorize → login → code). + const code = yield* Effect.promise(async () => { + const authorize = await fetch(started.authorizationUrl, { redirect: "manual" }); + const loginUrl = authorize.headers.get("location"); + if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`); + const login = await fetch(loginUrl, { + method: "POST", + headers: { + authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, + }, + redirect: "manual", + }); + const callbackUrl = login.headers.get("location"); + if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`); + const minted = new URL(callbackUrl).searchParams.get("code"); + if (!minted) throw new Error("callback carried no authorization code"); + return minted; + }); + yield* client.oauth.complete({ payload: { state: started.state, code } }); + + const tools = yield* client.tools.list({ query: {} }); + const address = tools + .filter((tool) => String(tool.integration) === slug) + .map((tool) => String(tool.address)) + .find((addr) => addr.endsWith("listIssues")); + expect(address, "the listIssues tool is in the catalog").toBeDefined(); + + // Call through the real MCP surface, the channel the reported + // failure was seen on. + const session = mcp.session(identity); + let called = yield* session.call("execute", { + code: invokeByAddressCode(address!, {}), + }); + // Approval-gated tools pause the execution once per gated call. + let guard = 0; + while (called.text.includes("executionId:") && guard < 10) { + called = yield* session.approvePaused(called.text); + guard += 1; + } + expect( + called.ok, + `the MCP execute call itself completed (got: ${called.text.slice(0, 400)})`, + ).toBe(true); + const envelope = JSON.parse(called.text) as ToolEnvelope; + + // THE guarantee: the caller gets data, not `oauth_refresh_failed`. + // The access token had already expired, so this call could only + // succeed by refreshing. + expect( + envelope.ok, + `the tool call succeeded (got: ${JSON.stringify(envelope.error ?? {}).slice(0, 400)})`, + ).toBe(true); + expect( + JSON.stringify(envelope.data ?? {}), + "the upstream's payload came back, so the retried token really worked upstream", + ).toContain("issue-1"); + + // Proven from the AS's own ledger: the scope-bearing grant was + // refused, and the scope-less retry is what succeeded. + const refreshGrants = (yield* oauth.requests).filter( + (request) => + request.path === "/token" && + request.method === "POST" && + request.body.includes("grant_type=refresh_token"), + ); + expect( + refreshGrants.length, + "the refused refresh and the retry are both on the ledger (2 requests)", + ).toBe(2); + const refused = refreshGrants[0]!; + const retried = refreshGrants[1]!; + expect( + new URLSearchParams(refused.body).get("scope"), + "the first refresh echoed the grant the connection recorded", + ).toBe("issues.read issues.write"); + expect( + new URLSearchParams(retried.body).has("scope"), + "the retry omitted scope, the form RFC 6749 §6 defines as 'the grant originally issued'", + ).toBe(false); + expect( + new URLSearchParams(retried.body).get("refresh_token"), + "the retry replayed the same still-live refresh token", + ).toBe(new URLSearchParams(refused.body).get("refresh_token")); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 3b832354c6..4dac673edc 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -45,6 +45,11 @@ type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => HttpServerResponse.jsonUnsafe(body, { status }); +/** A JSON-format token request carried a `scope`, without narrowing `unknown` + * for every handler that only needs to branch on its presence. */ +const hasJsonScope = (body: unknown): boolean => + typeof body === "object" && body !== null && "scope" in body; + const serveTokenEndpoint = (handler: TokenHandler) => Effect.gen(function* () { const calls = yield* Ref.make([]); @@ -1461,6 +1466,138 @@ describe("refreshAccessToken", () => { ), ); + // Railway (issue #1969, 2026-09-09) refuses any scope-bearing refresh with + // `invalid_scope: refresh token missing requested scope` even though echoing + // the grant's own scope is legal under RFC 6749 §6. Without the fallback a + // live refresh token reads as permanently dead and the connection can never + // recover on its own. + it.effect("retries without scope when the AS refuses the echoed grant scope", () => + withTokenEndpoint( + (call) => + call.body.has("scope") + ? json(400, { + error: "invalid_scope", + error_description: "refresh token missing requested scope", + }) + : json(200, validRefreshBody), + ({ tokenUrl, calls }) => + Effect.gen(function* () { + const result = yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + refreshToken: "old", + scopes: ["issues.read", "issues.write"], + }); + + expect(result.access_token).toBe("tok2"); + const seen = yield* calls; + expect(seen).toHaveLength(2); + expect(seen[0]!.body.get("scope")).toBe("issues.read issues.write"); + expect(seen[1]!.body.has("scope")).toBe(false); + expect(seen[1]!.body.get("grant_type")).toBe("refresh_token"); + expect(seen[1]!.body.get("refresh_token")).toBe("old"); + }), + ), + ); + + it.effect("retries a JSON-format refresh without scope as well", () => + withTokenEndpoint( + (call) => + hasJsonScope(call.jsonBody) + ? json(400, { + error: "invalid_scope", + error_description: "refresh token missing requested scope", + }) + : json(200, validRefreshBody), + ({ tokenUrl, calls }) => + Effect.gen(function* () { + const result = yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + refreshToken: "old", + scopes: ["issues.read"], + requestFormat: "json", + }); + + expect(result.access_token).toBe("tok2"); + const seen = yield* calls; + expect(seen).toHaveLength(2); + expect(seen[0]!.jsonBody).toEqual({ + grant_type: "refresh_token", + refresh_token: "old", + scope: "issues.read", + client_id: "cid", + client_secret: "csecret", + }); + expect(seen[1]!.jsonBody).toEqual({ + grant_type: "refresh_token", + refresh_token: "old", + client_id: "cid", + client_secret: "csecret", + }); + }), + ), + ); + + it.effect("does not retry a scope-less refresh the AS refuses", () => + withTokenEndpoint( + () => json(400, { error: "invalid_scope", error_description: "scope is required" }), + ({ tokenUrl, calls }) => + Effect.gen(function* () { + const error = yield* Effect.flip( + refreshAccessToken({ tokenUrl, clientId: "cid", refreshToken: "old" }), + ); + + expect((error as OAuth2Error).error).toBe("invalid_scope"); + expect(yield* calls).toHaveLength(1); + }), + ), + ); + + it.effect("does not retry invalid_grant, which no scope change can fix", () => + withTokenEndpoint( + () => json(400, { error: "invalid_grant", error_description: "refresh token expired" }), + ({ tokenUrl, calls }) => + Effect.gen(function* () { + const error = yield* Effect.flip( + refreshAccessToken({ + tokenUrl, + clientId: "cid", + refreshToken: "old", + scopes: ["issues.read"], + }), + ); + + expect((error as OAuth2Error).error).toBe("invalid_grant"); + expect(yield* calls).toHaveLength(1); + }), + ), + ); + + it.effect("surfaces the scope-less retry's verdict when the AS refuses that too", () => + withTokenEndpoint( + (call) => + call.body.has("scope") + ? json(400, { error: "invalid_scope", error_description: "refresh token missing scope" }) + : json(400, { error: "invalid_grant", error_description: "refresh token expired" }), + ({ tokenUrl, calls }) => + Effect.gen(function* () { + const error = yield* Effect.flip( + refreshAccessToken({ + tokenUrl, + clientId: "cid", + refreshToken: "old", + scopes: ["issues.read"], + }), + ); + + expect((error as OAuth2Error).error).toBe("invalid_grant"); + expect(yield* calls).toHaveLength(2); + }), + ), + ); + it.effect("includes RFC 8707 resource parameter on refresh requests when provided", () => withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 2f7c39fffc..e40bdda068 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -1344,72 +1344,102 @@ export type RefreshAccessTokenInput = { export const refreshAccessToken = ( input: RefreshAccessTokenInput, -): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { - idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, - endpointUrlPolicy: input.endpointUrlPolicy, - }); - const client: oauth.Client = { client_id: input.clientId }; - const clientAuth = pickClientAuth( - input.clientSecret, - input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, - ); - const extraParams = new URLSearchParams(); - if (input.scopes && input.scopes.length > 0) { - extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); - } - if (input.resource) { - extraParams.set("resource", input.resource); - } - const additionalParameters = - Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; - if (input.requestFormat === "json") { - const response = await jsonTokenEndpointRequest({ - tokenUrl: input.tokenUrl, - clientId: input.clientId, - clientSecret: input.clientSecret, - clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, - grantType: "refresh_token", - parameters: { - refresh_token: input.refreshToken, - ...(input.scopes && input.scopes.length > 0 - ? { scope: input.scopes.join(input.scopeSeparator ?? " ") } - : {}), - ...(input.resource ? { resource: input.resource } : {}), - }, - timeoutMs: input.timeoutMs, +): Effect.Effect => { + const requestedScopes = input.scopes && input.scopes.length > 0 ? input.scopes : undefined; + const scopeParameter = (scopes: readonly string[] | undefined): string | undefined => + scopes === undefined ? undefined : scopes.join(input.scopeSeparator ?? " "); + + const attempt = ( + scopes: readonly string[] | undefined, + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { + idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, endpointUrlPolicy: input.endpointUrlPolicy, - fetch: input.fetch, }); - return await processTokenEndpointResponse(as, client, response); - } - const response = await oauth.refreshTokenGrantRequest( - as, - client, - clientAuth, - input.refreshToken, - { - ...oauth4webapiRequestOptions( - input.tokenUrl, - input.timeoutMs, - input.endpointUrlPolicy, - input.fetch, + const client: oauth.Client = { client_id: input.clientId }; + const clientAuth = pickClientAuth( + input.clientSecret, + input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, + ); + const scope = scopeParameter(scopes); + const extraParams = new URLSearchParams(); + if (scope !== undefined) { + extraParams.set("scope", scope); + } + if (input.resource) { + extraParams.set("resource", input.resource); + } + const additionalParameters = + Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; + if (input.requestFormat === "json") { + const response = await jsonTokenEndpointRequest({ + tokenUrl: input.tokenUrl, + clientId: input.clientId, + clientSecret: input.clientSecret, + clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, + grantType: "refresh_token", + parameters: { + refresh_token: input.refreshToken, + ...(scope !== undefined ? { scope } : {}), + ...(input.resource ? { resource: input.resource } : {}), + }, + timeoutMs: input.timeoutMs, + endpointUrlPolicy: input.endpointUrlPolicy, + fetch: input.fetch, + }); + return await processTokenEndpointResponse(as, client, response); + } + const response = await oauth.refreshTokenGrantRequest( + as, + client, + clientAuth, + input.refreshToken, + { + ...oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), + additionalParameters, + }, + ); + const result = await oauth.processRefreshTokenResponse( + as, + client, + (await stripIdToken(response)).response, + ); + return tokenResponseFrom(as, result); + }, + catch: (cause) => cause, + }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret))); + + // RFC 6749 §6 makes echoing the grant's own scope legal and omission mean + // "the scope originally granted". An AS whose stored grant is narrower than + // the connection's record — Railway answers any scope-bearing refresh with + // `invalid_scope: refresh token missing requested scope` — rejects that echo, + // leaving a live refresh token unusable. Retry once WITHOUT `scope`, the form + // whose meaning does not depend on our record being right. Only + // `invalid_scope` qualifies: `invalid_grant` means the token is dead, and + // retrying that spends a rotating refresh token to learn nothing. + const retryWithoutScope = (): Effect.Effect => + attempt(undefined).pipe( + Effect.tap(() => + Effect.annotateCurrentSpan({ "executor.oauth.refresh_scope_omitted": true }), + ), + ); + + return ( + requestedScopes === undefined + ? attempt(undefined) + : attempt(requestedScopes).pipe( + Effect.catch((cause) => + cause.error === "invalid_scope" ? retryWithoutScope() : Effect.fail(cause), ), - additionalParameters, - }, - ); - const result = await oauth.processRefreshTokenResponse( - as, - client, - (await stripIdToken(response)).response, - ); - return tokenResponseFrom(as, result); - }, - catch: (cause) => cause, - }).pipe( - Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), + ) + ).pipe( withTokenRequestSpan({ grantType: "refresh_token", tokenUrl: input.tokenUrl, @@ -1417,6 +1447,7 @@ export const refreshAccessToken = ( hasResource: input.resource !== undefined, }), ); +}; // --------------------------------------------------------------------------- // RFC 8693 token exchange → Identity Assertion JWT Authorization Grant diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index f309cabd69..0380e389f0 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -66,6 +66,13 @@ export interface OAuthTestServerOptions { readonly omitTokenResponseScopes?: readonly string[]; readonly supportRefresh?: boolean; readonly tokenExpiresInSeconds?: number; + /** Refuse a refresh-token grant whose `scope` parameter names anything outside + * this list, answering the RFC 6749 §5.2 envelope Railway returns: + * `invalid_scope: refresh token missing requested scope`. Models an AS whose + * refresh token carries a narrower grant than the authorization it echoed + * back, so a client that re-sends its recorded grant is refused (issue + * #1969). Omit to accept any `scope`, the default. */ + readonly refreshGrantScopes?: readonly string[]; readonly invalidRefreshTokenDescription?: string; /** RFC 6749 error code returned when a refresh-token grant is rejected. * Defaults to `invalid_grant`; set to e.g. `invalid_request` to mirror @@ -853,6 +860,17 @@ export const serveOAuthTestServer = ( }) : oauthError(400, invalidRefreshTokenErrorCode, invalidRefreshTokenDescription); } + const grantScopes = options.refreshGrantScopes; + if (grantScopes) { + const granted = new Set(grantScopes); + const requestedScope = params.get("scope"); + const outsideGrant = (requestedScope ?? "") + .split(/[\s,]+/) + .filter((scope) => scope.length > 0 && !granted.has(scope)); + if (outsideGrant.length > 0) { + return oauthError(400, "invalid_scope", "refresh token missing requested scope"); + } + } const nextAccessToken = `at_${randomUUID()}`; const nextRefreshToken = `rt_${randomUUID()}`; refreshTokens.delete(refreshToken);