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.
Unified · Split
| @@ -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. | |
| @@ -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 | +); | |
| @@ -45,6 +45,11 @@ type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; · 44 unmodified lines | ||
| 45 | 45 | const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => |
| 46 | 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. */ | |
| 50 | +const hasJsonScope = (body: unknown): boolean => | |
| 51 | + typeof body === "object" && body !== null && "scope" in body; | |
| 52 | + | |
| 48 | 53 | const serveTokenEndpoint = (handler: TokenHandler) => |
| 49 | 54 | Effect.gen(function* () { |
| 50 | 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 | 1601 | it.effect("includes RFC 8707 resource parameter on refresh requests when provided", () => |
| 1465 | 1602 | withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => |
| 1466 | 1603 | Effect.gen(function* () { |
| @@ -1344,72 +1344,102 @@ export type RefreshAccessTokenInput = { · 1343 unmodified lines | ||
| 1344 | 1344 | |
| 1345 | 1345 | export const refreshAccessToken = ( |
| 1346 | 1346 | 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, | |
| 1383 | 1359 | endpointUrlPolicy: input.endpointUrlPolicy, |
| 1384 | − fetch: input.fetch, | |
| 1385 | 1360 | }); |
| 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), | |
| 1399 | 1440 | ), |
| 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( | |
| 1413 | 1443 | withTokenRequestSpan({ |
| 1414 | 1444 | grantType: "refresh_token", |
| 1415 | 1445 | tokenUrl: input.tokenUrl, |
| @@ -1417,6 +1447,7 @@ export const refreshAccessToken = ( · 1 unmodified line | ||
| 1417 | 1447 | hasResource: input.resource !== undefined, |
| 1418 | 1448 | }), |
| 1419 | 1449 | ); |
| 1450 | +}; | |
| 1420 | 1451 | |
| 1421 | 1452 | // --------------------------------------------------------------------------- |
| 1422 | 1453 | // RFC 8693 token exchange → Identity Assertion JWT Authorization Grant |
| @@ -66,6 +66,13 @@ export interface OAuthTestServerOptions { · 65 unmodified lines | ||
| 66 | 66 | readonly omitTokenResponseScopes?: readonly string[]; |
| 67 | 67 | readonly supportRefresh?: boolean; |
| 68 | 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 | 76 | readonly invalidRefreshTokenDescription?: string; |
| 70 | 77 | /** RFC 6749 error code returned when a refresh-token grant is rejected. |
| 71 | 78 | * 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 | 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 | 874 | const nextAccessToken = `at_${randomUUID()}`; |
| 857 | 875 | const nextRefreshToken = `rt_${randomUUID()}`; |
| 858 | 876 | refreshTokens.delete(refreshToken); |