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 2 months ago

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

Showing the changes to packages (3 of 5 files). Show all

Unified · Split

3 files
  1. packages/core/sdk/src/oauth-helpers.test.ts +137 −0
  2. packages/core/sdk/src/oauth-helpers.ts +94 −63
  3. packages/core/sdk/src/testing/oauth-test-server.ts +18 −0
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);