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
4545 const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>
4646 HttpServerResponse.jsonUnsafe(body, { status });
4747
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+
4853 const serveTokenEndpoint = (handler: TokenHandler) =>
4954 Effect.gen(function* () {
5055 const calls = yield* Ref.make<readonly TokenCall[]>([]);
@@ -1461,6 +1466,138 @@ describe("refreshAccessToken", () => { · 1410 unmodified lines
14611466 ),
14621467 );
14631468
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+
14641601 it.effect("includes RFC 8707 resource parameter on refresh requests when provided", () =>
14651602 withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
14661603 Effect.gen(function* () {
packages/core/sdk/src/oauth-helpers.ts+94−63
@@ -1344,72 +1344,102 @@ export type RefreshAccessTokenInput = { · 1343 unmodified lines
13441344
13451345 export const refreshAccessToken = (
13461346 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,
13831359 endpointUrlPolicy: input.endpointUrlPolicy,
1384− fetch: input.fetch,
13851360 });
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),
13991440 ),
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(
14131443 withTokenRequestSpan({
14141444 grantType: "refresh_token",
14151445 tokenUrl: input.tokenUrl,
@@ -1417,6 +1447,7 @@ export const refreshAccessToken = ( · 1 unmodified line
14171447 hasResource: input.resource !== undefined,
14181448 }),
14191449 );
1450+};
14201451
14211452 // ---------------------------------------------------------------------------
14221453 // 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
6666 readonly omitTokenResponseScopes?: readonly string[];
6767 readonly supportRefresh?: boolean;
6868 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[];
6976 readonly invalidRefreshTokenDescription?: string;
7077 /** RFC 6749 error code returned when a refresh-token grant is rejected.
7178 * Defaults to `invalid_grant`; set to e.g. `invalid_request` to mirror
@@ -853,6 +860,17 @@ export const serveOAuthTestServer = ( · 781 unmodified lines
853860 })
854861 : oauthError(400, invalidRefreshTokenErrorCode, invalidRefreshTokenDescription);
855862 }
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+ }
856874 const nextAccessToken = `at_${randomUUID()}`;
857875 const nextRefreshToken = `rt_${randomUUID()}`;
858876 refreshTokens.delete(refreshToken);