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 | const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => | 45 | const 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. */ | ||
| 50 | const hasJsonScope = (body: unknown): boolean => | ||
| 51 | typeof body === "object" && body !== null && "scope" in body; | ||
| 52 | |||
| 48 | const serveTokenEndpoint = (handler: TokenHandler) => | 53 | const 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* () { |
| @@ -1344,72 +1344,102 @@ export type RefreshAccessTokenInput = { · 1343 unmodified lines | |||
| 1344 | 1344 | ||
| 1345 | export const refreshAccessToken = ( | 1345 | export 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 Grant | 1453 | // RFC 8693 token exchange → Identity Assertion JWT Authorization Grant |
| @@ -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 mirror | 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 | : 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); |