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
5Retry 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.
22import { randomBytes } from "node:crypto";
23import { createServer } from "node:http";
24
25import { expect } from "@effect/vitest";
26import { Effect } from "effect";
27import { composePluginApi } from "@executor-js/api/server";
28import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
29import {
30 AuthTemplateSlug,
31 ConnectionName,
32 IntegrationSlug,
33 OAuthClientSlug,
34} from "@executor-js/sdk/shared";
35import { serveOAuthTestServer } from "@executor-js/sdk/testing";
36
37import { scenario } from "../src/scenario";
38import { Api, Mcp, Target } from "../src/services";
39
40const api = composePluginApi([openApiHttpPlugin()] as const);
41
42const 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". */
47const 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
76const 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
110const invokeByAddressCode = (address: string, args: unknown) => `
111const segments = ${JSON.stringify(address)}.split(".").slice(1);
112let node = tools;
113for (const segment of segments) node = node[segment];
114const result = await node(${JSON.stringify(args)});
115return JSON.stringify(result);
116`;
117
118type ToolEnvelope = {
119 readonly ok: boolean;
120 readonly data?: unknown;
121 readonly error?: {
122 readonly code?: string;
123 readonly message?: string;
124 };
125};
126
127scenario(
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
45const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>45const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>
46 HttpServerResponse.jsonUnsafe(body, { status });46 HttpServerResponse.jsonUnsafe(body, { status });
47 47
48/** A JSON-format token request carried a `scope`, without narrowing `unknown`
49 * for every handler that only needs to branch on its presence. */
50const hasJsonScope = (body: unknown): boolean =>
51 typeof body === "object" && body !== null && "scope" in body;
52
48const serveTokenEndpoint = (handler: TokenHandler) =>53const serveTokenEndpoint = (handler: TokenHandler) =>
49 Effect.gen(function* () {54 Effect.gen(function* () {
50 const calls = yield* Ref.make<readonly TokenCall[]>([]);55 const calls = yield* Ref.make<readonly TokenCall[]>([]);
@@ -1461,6 +1466,138 @@ describe("refreshAccessToken", () => { · 1410 unmodified lines
1461 ),1466 ),
1462 );1467 );
1463 1468
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
1464 it.effect("includes RFC 8707 resource parameter on refresh requests when provided", () =>1601 it.effect("includes RFC 8707 resource parameter on refresh requests when provided", () =>
1465 withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>1602 withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1466 Effect.gen(function* () {1603 Effect.gen(function* () {
packages/core/sdk/src/oauth-helpers.ts+94−63
@@ -1344,72 +1344,102 @@ export type RefreshAccessTokenInput = { · 1343 unmodified lines
1344 1344
1345export const refreshAccessToken = (1345export const refreshAccessToken = (
1346 input: RefreshAccessTokenInput,1346 input: RefreshAccessTokenInput,
1347): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>1347): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => {
1348 Effect.tryPromise({1348 const requestedScopes = input.scopes && input.scopes.length > 0 ? input.scopes : undefined;
1349 try: async () => {1349 const scopeParameter = (scopes: readonly string[] | undefined): string | undefined =>
1350 const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {1350 scopes === undefined ? undefined : scopes.join(input.scopeSeparator ?? " ");
1351 idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,1351
1352 endpointUrlPolicy: input.endpointUrlPolicy,1352 const attempt = (
1353 });1353 scopes: readonly string[] | undefined,
1354 const client: oauth.Client = { client_id: input.clientId };1354 ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
1355 const clientAuth = pickClientAuth(1355 Effect.tryPromise({
1356 input.clientSecret,1356 try: async () => {
1357 input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,1357 const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
1358 );1358 idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
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,
1383 endpointUrlPolicy: input.endpointUrlPolicy,1359 endpointUrlPolicy: input.endpointUrlPolicy,
1384 fetch: input.fetch,
1385 });1360 });
1386 return await processTokenEndpointResponse(as, client, response);1361 const client: oauth.Client = { client_id: input.clientId };
1387 }1362 const clientAuth = pickClientAuth(
1388 const response = await oauth.refreshTokenGrantRequest(1363 input.clientSecret,
1389 as,1364 input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1390 client,1365 );
1391 clientAuth,1366 const scope = scopeParameter(scopes);
1392 input.refreshToken,1367 const extraParams = new URLSearchParams();
1393 {1368 if (scope !== undefined) {
1394 ...oauth4webapiRequestOptions(1369 extraParams.set("scope", scope);
1395 input.tokenUrl,1370 }
1396 input.timeoutMs,1371 if (input.resource) {
1397 input.endpointUrlPolicy,1372 extraParams.set("resource", input.resource);
1398 input.fetch,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),
1399 ),1440 ),
1400 additionalParameters,1441 )
1401 },1442 ).pipe(
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)),
1413 withTokenRequestSpan({1443 withTokenRequestSpan({
1414 grantType: "refresh_token",1444 grantType: "refresh_token",
1415 tokenUrl: input.tokenUrl,1445 tokenUrl: input.tokenUrl,
@@ -1417,6 +1447,7 @@ export const refreshAccessToken = ( · 1 unmodified line
1417 hasResource: input.resource !== undefined,1447 hasResource: input.resource !== undefined,
1418 }),1448 }),
1419 );1449 );
1450};
1420 1451
1421// ---------------------------------------------------------------------------1452// ---------------------------------------------------------------------------
1422// RFC 8693 token exchange → Identity Assertion JWT Authorization Grant1453// 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
66 readonly omitTokenResponseScopes?: readonly string[];66 readonly omitTokenResponseScopes?: readonly string[];
67 readonly supportRefresh?: boolean;67 readonly supportRefresh?: boolean;
68 readonly tokenExpiresInSeconds?: number;68 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[];
69 readonly invalidRefreshTokenDescription?: string;76 readonly invalidRefreshTokenDescription?: string;
70 /** RFC 6749 error code returned when a refresh-token grant is rejected.77 /** RFC 6749 error code returned when a refresh-token grant is rejected.
71 * Defaults to `invalid_grant`; set to e.g. `invalid_request` to mirror78 * Defaults to `invalid_grant`; set to e.g. `invalid_request` to mirror
@@ -853,6 +860,17 @@ export const serveOAuthTestServer = ( · 781 unmodified lines
853 })860 })
854 : oauthError(400, invalidRefreshTokenErrorCode, invalidRefreshTokenDescription);861 : oauthError(400, invalidRefreshTokenErrorCode, invalidRefreshTokenDescription);
855 }862 }
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 }
856 const nextAccessToken = `at_${randomUUID()}`;874 const nextAccessToken = `at_${randomUUID()}`;
857 const nextRefreshToken = `rt_${randomUUID()}`;875 const nextRefreshToken = `rt_${randomUUID()}`;
858 refreshTokens.delete(refreshToken);876 refreshTokens.delete(refreshToken);