History

fix(oauth): retry a refresh grant without scope when the AS refuses it (#1982)

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.

mmarabel committed 1 month ago

Commit cc0fd8f1 parent e9055c15 files · +551 −63Browse files

Unified · Split

5 files
  1. .changeset/refresh-scope-fallback.md +5 −0
  2. e2e/scenarios/oauth-refresh-scope-fallback.test.ts +297 −0
  3. packages/core/sdk/src/oauth-helpers.test.ts +137 −0
  4. packages/core/sdk/src/oauth-helpers.ts +94 −63
  5. packages/core/sdk/src/testing/oauth-test-server.ts +18 −0
.changeset/refresh-scope-fallback.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/sdk": patch
3+---
4+
5+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.
e2e/scenarios/oauth-refresh-scope-fallback.test.ts+297−0
@@ -0,0 +1,297 @@
1+// Cross-target: an authorization server that refuses a scope-bearing refresh
2+// grant must not turn a live refresh token into a dead connection.
3+//
4+// Railway answers any refresh grant carrying a `scope` parameter with
5+// `invalid_scope: refresh token missing requested scope` when the refresh
6+// token's own record is narrower than the authorization response it echoed
7+// back (issue #1969). Echoing the recorded grant is legal under RFC 6749 §6,
8+// and so is omitting `scope`, which the spec defines as "the scope originally
9+// granted". Before the fallback, the refusal reached the sandbox as
10+// `oauth_refresh_failed` with `retryable: false`, so a connection whose refresh
11+// token was perfectly alive stayed unusable until someone re-authorized it by
12+// hand — the reported symptom was a saved Railway connection failing every
13+// call.
14+//
15+// The journey: an OpenAPI integration completes a real authorization-code flow
16+// against a live test AS that mints instantly-expiring access tokens and holds
17+// a narrower grant on the refresh token than the authorization echoed. The
18+// first tool call must therefore refresh, the scope-bearing grant is refused,
19+// executor retries WITHOUT `scope`, and the call succeeds — proven from the
20+// AS's own request ledger, which records both the refused request and the
21+// accepted retry.
22+import { randomBytes } from "node:crypto";
23+import { createServer } from "node:http";
24+
25+import { expect } from "@effect/vitest";
26+import { Effect } from "effect";
27+import { composePluginApi } from "@executor-js/api/server";
28+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
29+import {
30+ AuthTemplateSlug,
31+ ConnectionName,
32+ IntegrationSlug,
33+ OAuthClientSlug,
34+} from "@executor-js/sdk/shared";
35+import { serveOAuthTestServer } from "@executor-js/sdk/testing";
36+
37+import { scenario } from "../src/scenario";
38+import { Api, Mcp, Target } from "../src/services";
39+
40+const api = composePluginApi([openApiHttpPlugin()] as const);
41+
42+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
43+
44+/** Upstream on 127.0.0.1: `GET /issues` is 200 for any bearer and returns one
45+ * issue, so "the call succeeded" is distinguishable from "the call returned
46+ * an empty body". */
47+const serveUpstream = () =>
48+ Effect.acquireRelease(
49+ Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
50+ const server = createServer((request, response) => {
51+ if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) {
52+ response.writeHead(200, { "content-type": "application/json" });
53+ response.end(JSON.stringify({ issues: [{ id: "issue-1", title: "Scope drift" }] }));
54+ return;
55+ }
56+ response.writeHead(404, { "content-type": "application/json" });
57+ response.end(JSON.stringify({ error: "not_found" }));
58+ });
59+ server.listen(0, "127.0.0.1", () => {
60+ const address = server.address();
61+ const port = typeof address === "object" && address ? address.port : 0;
62+ resume(
63+ Effect.succeed({
64+ url: `http://127.0.0.1:${port}`,
65+ close: () => {
66+ server.close();
67+ server.closeAllConnections();
68+ },
69+ }),
70+ );
71+ });
72+ }),
73+ (server) => Effect.sync(server.close),
74+ );
75+
76+const spec = (
77+ baseUrl: string,
78+ oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string },
79+): string =>
80+ JSON.stringify({
81+ openapi: "3.0.3",
82+ info: { title: "Issues API", version: "1.0.0" },
83+ servers: [{ url: baseUrl }],
84+ paths: {
85+ "/issues": {
86+ get: {
87+ operationId: "listIssues",
88+ summary: "List issues",
89+ security: [{ oauth: ["issues.read"] }],
90+ responses: { "200": { description: "issues" } },
91+ },
92+ },
93+ },
94+ components: {
95+ securitySchemes: {
96+ oauth: {
97+ type: "oauth2",
98+ flows: {
99+ authorizationCode: {
100+ authorizationUrl: oauth.authorizationEndpoint,
101+ tokenUrl: oauth.tokenEndpoint,
102+ scopes: { "issues.read": "Read issues", "issues.write": "Write issues" },
103+ },
104+ },
105+ },
106+ },
107+ },
108+ });
109+
110+const invokeByAddressCode = (address: string, args: unknown) => `
111+const segments = ${JSON.stringify(address)}.split(".").slice(1);
112+let node = tools;
113+for (const segment of segments) node = node[segment];
114+const result = await node(${JSON.stringify(args)});
115+return JSON.stringify(result);
116+`;
117+
118+type ToolEnvelope = {
119+ readonly ok: boolean;
120+ readonly data?: unknown;
121+ readonly error?: {
122+ readonly code?: string;
123+ readonly message?: string;
124+ };
125+};
126+
127+scenario(
128+ "Auth failures · a refresh refused for its scope is retried without one, so a scope-drifted connection keeps working",
129+ {},
130+ Effect.scoped(
131+ Effect.gen(function* () {
132+ const target = yield* Target;
133+ const { client: makeClient } = yield* Api;
134+ const mcp = yield* Mcp;
135+ const identity = yield* target.newIdentity();
136+ const client = yield* makeClient(api, identity);
137+ const upstream = yield* serveUpstream();
138+ // The AS grants both scopes at authorization and echoes them, but the
139+ // refresh token it stores covers only `issues.read` — the divergence
140+ // that makes the recorded grant look like a request for more access.
141+ const oauth = yield* serveOAuthTestServer({
142+ scopes: ["issues.read", "issues.write"],
143+ refreshGrantScopes: ["issues.read"],
144+ tokenExpiresInSeconds: 0,
145+ });
146+ const slug = unique("refreshscope");
147+ const clientSlug = OAuthClientSlug.make(unique("refreshscopec"));
148+
149+ yield* Effect.ensuring(
150+ Effect.gen(function* () {
151+ yield* client.openapi.addSpec({
152+ payload: {
153+ spec: { kind: "blob", value: spec(upstream.url, oauth) },
154+ slug,
155+ baseUrl: upstream.url,
156+ authenticationTemplate: [
157+ {
158+ slug: "oauth",
159+ kind: "oauth2",
160+ authorizationUrl: oauth.authorizationEndpoint,
161+ tokenUrl: oauth.tokenEndpoint,
162+ scopes: ["issues.read", "issues.write"],
163+ },
164+ ],
165+ },
166+ });
167+ yield* client.oauth.createClient({
168+ payload: {
169+ owner: "org",
170+ slug: clientSlug,
171+ grant: "authorization_code",
172+ authorizationUrl: oauth.authorizationEndpoint,
173+ tokenUrl: oauth.tokenEndpoint,
174+ clientId: "test-client",
175+ clientSecret: "test-secret",
176+ originIntegration: IntegrationSlug.make(slug),
177+ },
178+ });
179+
180+ const started = yield* client.oauth.start({
181+ payload: {
182+ client: clientSlug,
183+ clientOwner: "org",
184+ owner: "org",
185+ name: ConnectionName.make("main"),
186+ integration: IntegrationSlug.make(slug),
187+ template: AuthTemplateSlug.make("oauth"),
188+ },
189+ });
190+ expect(started.status, "oauth.start redirects to the authorization server").toBe(
191+ "redirect",
192+ );
193+ if (started.status !== "redirect") return yield* Effect.die("no redirect");
194+
195+ // Drive the test IdP's consent by hand (authorize → login → code).
196+ const code = yield* Effect.promise(async () => {
197+ const authorize = await fetch(started.authorizationUrl, { redirect: "manual" });
198+ const loginUrl = authorize.headers.get("location");
199+ if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`);
200+ const login = await fetch(loginUrl, {
201+ method: "POST",
202+ headers: {
203+ authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,
204+ },
205+ redirect: "manual",
206+ });
207+ const callbackUrl = login.headers.get("location");
208+ if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`);
209+ const minted = new URL(callbackUrl).searchParams.get("code");
210+ if (!minted) throw new Error("callback carried no authorization code");
211+ return minted;
212+ });
213+ yield* client.oauth.complete({ payload: { state: started.state, code } });
214+
215+ const tools = yield* client.tools.list({ query: {} });
216+ const address = tools
217+ .filter((tool) => String(tool.integration) === slug)
218+ .map((tool) => String(tool.address))
219+ .find((addr) => addr.endsWith("listIssues"));
220+ expect(address, "the listIssues tool is in the catalog").toBeDefined();
221+
222+ // Call through the real MCP surface, the channel the reported
223+ // failure was seen on.
224+ const session = mcp.session(identity);
225+ let called = yield* session.call("execute", {
226+ code: invokeByAddressCode(address!, {}),
227+ });
228+ // Approval-gated tools pause the execution once per gated call.
229+ let guard = 0;
230+ while (called.text.includes("executionId:") && guard < 10) {
231+ called = yield* session.approvePaused(called.text);
232+ guard += 1;
233+ }
234+ expect(
235+ called.ok,
236+ `the MCP execute call itself completed (got: ${called.text.slice(0, 400)})`,
237+ ).toBe(true);
238+ const envelope = JSON.parse(called.text) as ToolEnvelope;
239+
240+ // THE guarantee: the caller gets data, not `oauth_refresh_failed`.
241+ // The access token had already expired, so this call could only
242+ // succeed by refreshing.
243+ expect(
244+ envelope.ok,
245+ `the tool call succeeded (got: ${JSON.stringify(envelope.error ?? {}).slice(0, 400)})`,
246+ ).toBe(true);
247+ expect(
248+ JSON.stringify(envelope.data ?? {}),
249+ "the upstream's payload came back, so the retried token really worked upstream",
250+ ).toContain("issue-1");
251+
252+ // Proven from the AS's own ledger: the scope-bearing grant was
253+ // refused, and the scope-less retry is what succeeded.
254+ const refreshGrants = (yield* oauth.requests).filter(
255+ (request) =>
256+ request.path === "/token" &&
257+ request.method === "POST" &&
258+ request.body.includes("grant_type=refresh_token"),
259+ );
260+ expect(
261+ refreshGrants.length,
262+ "the refused refresh and the retry are both on the ledger (2 requests)",
263+ ).toBe(2);
264+ const refused = refreshGrants[0]!;
265+ const retried = refreshGrants[1]!;
266+ expect(
267+ new URLSearchParams(refused.body).get("scope"),
268+ "the first refresh echoed the grant the connection recorded",
269+ ).toBe("issues.read issues.write");
270+ expect(
271+ new URLSearchParams(retried.body).has("scope"),
272+ "the retry omitted scope, the form RFC 6749 §6 defines as 'the grant originally issued'",
273+ ).toBe(false);
274+ expect(
275+ new URLSearchParams(retried.body).get("refresh_token"),
276+ "the retry replayed the same still-live refresh token",
277+ ).toBe(new URLSearchParams(refused.body).get("refresh_token"));
278+ }),
279+ Effect.gen(function* () {
280+ yield* client.connections
281+ .remove({
282+ params: {
283+ owner: "org",
284+ integration: IntegrationSlug.make(slug),
285+ name: ConnectionName.make("main"),
286+ },
287+ })
288+ .pipe(Effect.ignore);
289+ yield* client.oauth
290+ .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } })
291+ .pipe(Effect.ignore);
292+ yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
293+ }),
294+ );
295+ }),
296+ ),
297+);
packages/core/sdk/src/oauth-helpers.test.ts+137−0
@@ -45,6 +45,11 @@ type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; · 44 unmodified lines
4545 const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>
4646 HttpServerResponse.jsonUnsafe(body, { status });
4747
48+/** A JSON-format token request carried a `scope`, without narrowing `unknown`
49+ * for every handler that only needs to branch on its presence. */
50+const hasJsonScope = (body: unknown): boolean =>
51+ typeof body === "object" && body !== null && "scope" in body;
52+
4853 const serveTokenEndpoint = (handler: TokenHandler) =>
4954 Effect.gen(function* () {
5055 const calls = yield* Ref.make<readonly TokenCall[]>([]);
@@ -1461,6 +1466,138 @@ describe("refreshAccessToken", () => { · 1410 unmodified lines
14611466 ),
14621467 );
14631468
1469+ // Railway (issue #1969, 2026-09-09) refuses any scope-bearing refresh with
1470+ // `invalid_scope: refresh token missing requested scope` even though echoing
1471+ // the grant's own scope is legal under RFC 6749 §6. Without the fallback a
1472+ // live refresh token reads as permanently dead and the connection can never
1473+ // recover on its own.
1474+ it.effect("retries without scope when the AS refuses the echoed grant scope", () =>
1475+ withTokenEndpoint(
1476+ (call) =>
1477+ call.body.has("scope")
1478+ ? json(400, {
1479+ error: "invalid_scope",
1480+ error_description: "refresh token missing requested scope",
1481+ })
1482+ : json(200, validRefreshBody),
1483+ ({ tokenUrl, calls }) =>
1484+ Effect.gen(function* () {
1485+ const result = yield* refreshAccessToken({
1486+ tokenUrl,
1487+ clientId: "cid",
1488+ refreshToken: "old",
1489+ scopes: ["issues.read", "issues.write"],
1490+ });
1491+
1492+ expect(result.access_token).toBe("tok2");
1493+ const seen = yield* calls;
1494+ expect(seen).toHaveLength(2);
1495+ expect(seen[0]!.body.get("scope")).toBe("issues.read issues.write");
1496+ expect(seen[1]!.body.has("scope")).toBe(false);
1497+ expect(seen[1]!.body.get("grant_type")).toBe("refresh_token");
1498+ expect(seen[1]!.body.get("refresh_token")).toBe("old");
1499+ }),
1500+ ),
1501+ );
1502+
1503+ it.effect("retries a JSON-format refresh without scope as well", () =>
1504+ withTokenEndpoint(
1505+ (call) =>
1506+ hasJsonScope(call.jsonBody)
1507+ ? json(400, {
1508+ error: "invalid_scope",
1509+ error_description: "refresh token missing requested scope",
1510+ })
1511+ : json(200, validRefreshBody),
1512+ ({ tokenUrl, calls }) =>
1513+ Effect.gen(function* () {
1514+ const result = yield* refreshAccessToken({
1515+ tokenUrl,
1516+ clientId: "cid",
1517+ clientSecret: "csecret",
1518+ refreshToken: "old",
1519+ scopes: ["issues.read"],
1520+ requestFormat: "json",
1521+ });
1522+
1523+ expect(result.access_token).toBe("tok2");
1524+ const seen = yield* calls;
1525+ expect(seen).toHaveLength(2);
1526+ expect(seen[0]!.jsonBody).toEqual({
1527+ grant_type: "refresh_token",
1528+ refresh_token: "old",
1529+ scope: "issues.read",
1530+ client_id: "cid",
1531+ client_secret: "csecret",
1532+ });
1533+ expect(seen[1]!.jsonBody).toEqual({
1534+ grant_type: "refresh_token",
1535+ refresh_token: "old",
1536+ client_id: "cid",
1537+ client_secret: "csecret",
1538+ });
1539+ }),
1540+ ),
1541+ );
1542+
1543+ it.effect("does not retry a scope-less refresh the AS refuses", () =>
1544+ withTokenEndpoint(
1545+ () => json(400, { error: "invalid_scope", error_description: "scope is required" }),
1546+ ({ tokenUrl, calls }) =>
1547+ Effect.gen(function* () {
1548+ const error = yield* Effect.flip(
1549+ refreshAccessToken({ tokenUrl, clientId: "cid", refreshToken: "old" }),
1550+ );
1551+
1552+ expect((error as OAuth2Error).error).toBe("invalid_scope");
1553+ expect(yield* calls).toHaveLength(1);
1554+ }),
1555+ ),
1556+ );
1557+
1558+ it.effect("does not retry invalid_grant, which no scope change can fix", () =>
1559+ withTokenEndpoint(
1560+ () => json(400, { error: "invalid_grant", error_description: "refresh token expired" }),
1561+ ({ tokenUrl, calls }) =>
1562+ Effect.gen(function* () {
1563+ const error = yield* Effect.flip(
1564+ refreshAccessToken({
1565+ tokenUrl,
1566+ clientId: "cid",
1567+ refreshToken: "old",
1568+ scopes: ["issues.read"],
1569+ }),
1570+ );
1571+
1572+ expect((error as OAuth2Error).error).toBe("invalid_grant");
1573+ expect(yield* calls).toHaveLength(1);
1574+ }),
1575+ ),
1576+ );
1577+
1578+ it.effect("surfaces the scope-less retry's verdict when the AS refuses that too", () =>
1579+ withTokenEndpoint(
1580+ (call) =>
1581+ call.body.has("scope")
1582+ ? json(400, { error: "invalid_scope", error_description: "refresh token missing scope" })
1583+ : json(400, { error: "invalid_grant", error_description: "refresh token expired" }),
1584+ ({ tokenUrl, calls }) =>
1585+ Effect.gen(function* () {
1586+ const error = yield* Effect.flip(
1587+ refreshAccessToken({
1588+ tokenUrl,
1589+ clientId: "cid",
1590+ refreshToken: "old",
1591+ scopes: ["issues.read"],
1592+ }),
1593+ );
1594+
1595+ expect((error as OAuth2Error).error).toBe("invalid_grant");
1596+ expect(yield* calls).toHaveLength(2);
1597+ }),
1598+ ),
1599+ );
1600+
14641601 it.effect("includes RFC 8707 resource parameter on refresh requests when provided", () =>
14651602 withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
14661603 Effect.gen(function* () {
packages/core/sdk/src/oauth-helpers.ts+94−63
@@ -1344,72 +1344,102 @@ export type RefreshAccessTokenInput = { · 1343 unmodified lines
13441344
13451345 export const refreshAccessToken = (
13461346 input: RefreshAccessTokenInput,
1347−): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
1348− Effect.tryPromise({
1349− try: async () => {
1350− const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
1351− idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
1352− endpointUrlPolicy: input.endpointUrlPolicy,
1353− });
1354− const client: oauth.Client = { client_id: input.clientId };
1355− const clientAuth = pickClientAuth(
1356− input.clientSecret,
1357− input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1358− );
1359− const extraParams = new URLSearchParams();
1360− if (input.scopes && input.scopes.length > 0) {
1361− extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
1362− }
1363− if (input.resource) {
1364− extraParams.set("resource", input.resource);
1365− }
1366− const additionalParameters =
1367− Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
1368− if (input.requestFormat === "json") {
1369− const response = await jsonTokenEndpointRequest({
1370− tokenUrl: input.tokenUrl,
1371− clientId: input.clientId,
1372− clientSecret: input.clientSecret,
1373− clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1374− grantType: "refresh_token",
1375− parameters: {
1376− refresh_token: input.refreshToken,
1377− ...(input.scopes && input.scopes.length > 0
1378− ? { scope: input.scopes.join(input.scopeSeparator ?? " ") }
1379− : {}),
1380− ...(input.resource ? { resource: input.resource } : {}),
1381− },
1382− timeoutMs: input.timeoutMs,
1347+): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => {
1348+ const requestedScopes = input.scopes && input.scopes.length > 0 ? input.scopes : undefined;
1349+ const scopeParameter = (scopes: readonly string[] | undefined): string | undefined =>
1350+ scopes === undefined ? undefined : scopes.join(input.scopeSeparator ?? " ");
1351+
1352+ const attempt = (
1353+ scopes: readonly string[] | undefined,
1354+ ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
1355+ Effect.tryPromise({
1356+ try: async () => {
1357+ const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
1358+ idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
13831359 endpointUrlPolicy: input.endpointUrlPolicy,
1384− fetch: input.fetch,
13851360 });
1386− return await processTokenEndpointResponse(as, client, response);
1387− }
1388− const response = await oauth.refreshTokenGrantRequest(
1389− as,
1390− client,
1391− clientAuth,
1392− input.refreshToken,
1393− {
1394− ...oauth4webapiRequestOptions(
1395− input.tokenUrl,
1396− input.timeoutMs,
1397− input.endpointUrlPolicy,
1398− input.fetch,
1361+ const client: oauth.Client = { client_id: input.clientId };
1362+ const clientAuth = pickClientAuth(
1363+ input.clientSecret,
1364+ input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1365+ );
1366+ const scope = scopeParameter(scopes);
1367+ const extraParams = new URLSearchParams();
1368+ if (scope !== undefined) {
1369+ extraParams.set("scope", scope);
1370+ }
1371+ if (input.resource) {
1372+ extraParams.set("resource", input.resource);
1373+ }
1374+ const additionalParameters =
1375+ Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
1376+ if (input.requestFormat === "json") {
1377+ const response = await jsonTokenEndpointRequest({
1378+ tokenUrl: input.tokenUrl,
1379+ clientId: input.clientId,
1380+ clientSecret: input.clientSecret,
1381+ clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1382+ grantType: "refresh_token",
1383+ parameters: {
1384+ refresh_token: input.refreshToken,
1385+ ...(scope !== undefined ? { scope } : {}),
1386+ ...(input.resource ? { resource: input.resource } : {}),
1387+ },
1388+ timeoutMs: input.timeoutMs,
1389+ endpointUrlPolicy: input.endpointUrlPolicy,
1390+ fetch: input.fetch,
1391+ });
1392+ return await processTokenEndpointResponse(as, client, response);
1393+ }
1394+ const response = await oauth.refreshTokenGrantRequest(
1395+ as,
1396+ client,
1397+ clientAuth,
1398+ input.refreshToken,
1399+ {
1400+ ...oauth4webapiRequestOptions(
1401+ input.tokenUrl,
1402+ input.timeoutMs,
1403+ input.endpointUrlPolicy,
1404+ input.fetch,
1405+ ),
1406+ additionalParameters,
1407+ },
1408+ );
1409+ const result = await oauth.processRefreshTokenResponse(
1410+ as,
1411+ client,
1412+ (await stripIdToken(response)).response,
1413+ );
1414+ return tokenResponseFrom(as, result);
1415+ },
1416+ catch: (cause) => cause,
1417+ }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)));
1418+
1419+ // RFC 6749 §6 makes echoing the grant's own scope legal and omission mean
1420+ // "the scope originally granted". An AS whose stored grant is narrower than
1421+ // the connection's record — Railway answers any scope-bearing refresh with
1422+ // `invalid_scope: refresh token missing requested scope` — rejects that echo,
1423+ // leaving a live refresh token unusable. Retry once WITHOUT `scope`, the form
1424+ // whose meaning does not depend on our record being right. Only
1425+ // `invalid_scope` qualifies: `invalid_grant` means the token is dead, and
1426+ // retrying that spends a rotating refresh token to learn nothing.
1427+ const retryWithoutScope = (): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
1428+ attempt(undefined).pipe(
1429+ Effect.tap(() =>
1430+ Effect.annotateCurrentSpan({ "executor.oauth.refresh_scope_omitted": true }),
1431+ ),
1432+ );
1433+
1434+ return (
1435+ requestedScopes === undefined
1436+ ? attempt(undefined)
1437+ : attempt(requestedScopes).pipe(
1438+ Effect.catch((cause) =>
1439+ cause.error === "invalid_scope" ? retryWithoutScope() : Effect.fail(cause),
13991440 ),
1400− additionalParameters,
1401− },
1402− );
1403− const result = await oauth.processRefreshTokenResponse(
1404− as,
1405− client,
1406− (await stripIdToken(response)).response,
1407− );
1408− return tokenResponseFrom(as, result);
1409− },
1410− catch: (cause) => cause,
1411− }).pipe(
1412− Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1441+ )
1442+ ).pipe(
14131443 withTokenRequestSpan({
14141444 grantType: "refresh_token",
14151445 tokenUrl: input.tokenUrl,
@@ -1417,6 +1447,7 @@ export const refreshAccessToken = ( · 1 unmodified line
14171447 hasResource: input.resource !== undefined,
14181448 }),
14191449 );
1450+};
14201451
14211452 // ---------------------------------------------------------------------------
14221453 // RFC 8693 token exchange → Identity Assertion JWT Authorization Grant
packages/core/sdk/src/testing/oauth-test-server.ts+18−0
@@ -66,6 +66,13 @@ export interface OAuthTestServerOptions { · 65 unmodified lines
6666 readonly omitTokenResponseScopes?: readonly string[];
6767 readonly supportRefresh?: boolean;
6868 readonly tokenExpiresInSeconds?: number;
69+ /** Refuse a refresh-token grant whose `scope` parameter names anything outside
70+ * this list, answering the RFC 6749 §5.2 envelope Railway returns:
71+ * `invalid_scope: refresh token missing requested scope`. Models an AS whose
72+ * refresh token carries a narrower grant than the authorization it echoed
73+ * back, so a client that re-sends its recorded grant is refused (issue
74+ * #1969). Omit to accept any `scope`, the default. */
75+ readonly refreshGrantScopes?: readonly string[];
6976 readonly invalidRefreshTokenDescription?: string;
7077 /** RFC 6749 error code returned when a refresh-token grant is rejected.
7178 * Defaults to `invalid_grant`; set to e.g. `invalid_request` to mirror
@@ -853,6 +860,17 @@ export const serveOAuthTestServer = ( · 781 unmodified lines
853860 })
854861 : oauthError(400, invalidRefreshTokenErrorCode, invalidRefreshTokenDescription);
855862 }
863+ const grantScopes = options.refreshGrantScopes;
864+ if (grantScopes) {
865+ const granted = new Set(grantScopes);
866+ const requestedScope = params.get("scope");
867+ const outsideGrant = (requestedScope ?? "")
868+ .split(/[\s,]+/)
869+ .filter((scope) => scope.length > 0 && !granted.has(scope));
870+ if (outsideGrant.length > 0) {
871+ return oauthError(400, "invalid_scope", "refresh token missing requested scope");
872+ }
873+ }
856874 const nextAccessToken = `at_${randomUUID()}`;
857875 const nextRefreshToken = `rt_${randomUUID()}`;
858876 refreshTokens.delete(refreshToken);