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.
Showing the changes to packages (3 of 5 files). Show all
Unified · Split
| @@ -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); |