Skip to content

Commit 541549a

Browse files
authored
Classify non-conform OAuth refresh rejections and stop retrying dead grants (#1507)
1 parent 2c18801 commit 541549a

6 files changed

Lines changed: 343 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**Fix: OAuth refresh rejections with non-spec error bodies (e.g. Datadog) now surface as expired connections with a reconnect path, and definitively dead refresh tokens are no longer retried against the authorization server**

packages/core/sdk/src/executor.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,19 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[]
783783
: [];
784784
};
785785

786+
/** Epoch ms of the definitive refresh rejection recorded on `provider_state`,
787+
* or null. Set when the AS rejects the grant itself (RFC 6749 invalid_grant —
788+
* retrying cannot change the verdict); cleared by the reconnect mint, which
789+
* rewrites `provider_state` wholesale. While set, refresh attempts are
790+
* skipped: the pre-fix behavior re-sent a known-dead grant to the AS every
791+
* proactive cycle, forever, and surfaced nothing to the user. */
792+
const oauthReauthRequiredAtFromProviderState = (value: unknown): number | null => {
793+
const decoded = decodeJsonColumn(value);
794+
if (decoded == null || typeof decoded !== "object" || Array.isArray(decoded)) return null;
795+
const at = (decoded as Record<string, unknown>).oauthReauthRequiredAt;
796+
return typeof at === "number" ? at : null;
797+
};
798+
786799
const rowToConnection = (row: ConnectionRow): Connection => {
787800
const owner = row.owner as Owner;
788801
const integration = IntegrationSlug.make(row.integration);
@@ -1744,6 +1757,44 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
17441757
* upstream 401 on a token we believed was still valid (`reactive`). */
17451758
type RefreshTrigger = "proactive" | "reactive";
17461759

1760+
/** Record the AS's invalid_grant verdict on the row so later refreshes
1761+
* skip the doomed token request, and stamp `last_health` expired so the
1762+
* accounts list shows the dead connection at a glance instead of only
1763+
* after a manual probe. Merges into `provider_state` (preserving
1764+
* `missingOAuthScopes`); the reconnect mint rewrites the column wholesale,
1765+
* which is what re-arms refresh. Best-effort: a bookkeeping write failure
1766+
* must not mask the refresh failure being reported. */
1767+
const markRefreshGrantDead = (
1768+
row: ConnectionRow,
1769+
detail: string,
1770+
): Effect.Effect<void, never> => {
1771+
const existingState = decodeJsonColumn(row.provider_state);
1772+
const mergedState =
1773+
existingState != null && typeof existingState === "object" && !Array.isArray(existingState)
1774+
? (existingState as Record<string, unknown>)
1775+
: {};
1776+
const health: HealthCheckResult = {
1777+
status: "expired",
1778+
checkedAt: Date.now(),
1779+
detail,
1780+
};
1781+
return core
1782+
.updateMany("connection", {
1783+
where: (b: AnyCb) =>
1784+
b.and(
1785+
byOwner(row.owner as Owner)(b),
1786+
b("integration", "=", String(row.integration)),
1787+
b("name", "=", String(row.name)),
1788+
),
1789+
set: {
1790+
provider_state: { ...mergedState, oauthReauthRequiredAt: Date.now() },
1791+
last_health: health,
1792+
updated_at: new Date(),
1793+
},
1794+
})
1795+
.pipe(Effect.ignore);
1796+
};
1797+
17471798
// Perform the actual refresh-token grant and persist the rotated material.
17481799
const performTokenRefresh = (
17491800
row: ConnectionRow,
@@ -1761,6 +1812,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
17611812
reauthRequired: true,
17621813
});
17631814

1815+
// A recorded invalid_grant is the AS's standing verdict on this grant:
1816+
// re-sending it cannot succeed, so don't. Fail as reauth-required
1817+
// without a token request — the reconnect mint rewrites
1818+
// `provider_state` and thereby re-arms refresh. Without this gate a
1819+
// dead connection re-sent its dead grant on every proactive cycle,
1820+
// indefinitely (owner.com's Datadog connections: 100+ identical
1821+
// rejections over two days, surfacing nothing).
1822+
if (oauthReauthRequiredAtFromProviderState(row.provider_state) !== null) {
1823+
yield* Effect.annotateCurrentSpan({ "executor.oauth.refresh.skipped_known_dead": true });
1824+
return yield* reauth(
1825+
"The authorization server rejected this connection's refresh token (invalid_grant). Reconnect to continue.",
1826+
);
1827+
}
1828+
17641829
// Load the backing app by the owner STORED on the connection (a Personal
17651830
// connection may be backed by a shared Workspace app) — no derivation.
17661831
const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
@@ -1866,6 +1931,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
18661931
cause,
18671932
});
18681933
}),
1934+
// Persist the definitive verdict so the NEXT refresh skips
1935+
// the doomed grant (see the known-dead gate above) and the
1936+
// connection shows `expired` without waiting for a probe.
1937+
Effect.tapError((error) =>
1938+
Predicate.isTagged(error, "CredentialResolutionError") &&
1939+
error.reauthRequired === true
1940+
? // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field
1941+
markRefreshGrantDead(row, error.message)
1942+
: Effect.void,
1943+
),
18691944
);
18701945
});
18711946

@@ -1932,6 +2007,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19322007
),
19332008
Effect.withSpan("executor.oauth.refresh", {
19342009
attributes: {
2010+
// Tenant + subject make refresh outcomes answerable PER CUSTOMER
2011+
// ("is org X's Datadog refresh healthy?") — without them the only
2012+
// grouping dimensions were integration-wide. Opaque ids, never
2013+
// emails or org names.
2014+
"executor.tenant": tenant,
2015+
...(subject != null ? { "executor.subject": subject } : {}),
19352016
"executor.integration": String(row.integration),
19362017
"executor.connection": String(row.name),
19372018
// Which path drove this refresh: the expiry check ahead of a call,
@@ -3277,6 +3358,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
32773358
}).pipe(
32783359
Effect.withSpan("executor.connection.health.check", {
32793360
attributes: {
3361+
"executor.tenant": tenant,
3362+
...(subject != null ? { "executor.subject": subject } : {}),
32803363
"executor.integration": String(ref.integration),
32813364
"executor.connection": String(ref.name),
32823365
},
@@ -3332,7 +3415,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
33323415
return result;
33333416
}).pipe(
33343417
Effect.withSpan("executor.connection.validate", {
3335-
attributes: { "executor.integration": String(input.integration) },
3418+
attributes: {
3419+
"executor.tenant": tenant,
3420+
...(subject != null ? { "executor.subject": subject } : {}),
3421+
"executor.integration": String(input.integration),
3422+
},
33363423
}),
33373424
);
33383425

packages/core/sdk/src/oauth-flow.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,116 @@ describe("oauth token refresh in resolveConnectionValue", () => {
962962
),
963963
);
964964

965+
it.effect("a definitively rejected grant is not re-sent to the AS on later refreshes", () =>
966+
Effect.scoped(
967+
Effect.gen(function* () {
968+
const server = yield* serveOAuthTestServer({
969+
scopes: ["read"],
970+
supportRefresh: false,
971+
tokenExpiresInSeconds: 0,
972+
invalidRefreshTokenDescription: "Grant revoked",
973+
});
974+
const harness = yield* makeTestWorkspaceHarness({ plugins });
975+
const { executor, config } = harness;
976+
yield* executor.acme.seed();
977+
978+
yield* executor.oauth.createClient({
979+
owner: "org",
980+
slug: CLIENT,
981+
authorizationUrl: server.authorizationEndpoint,
982+
tokenUrl: server.tokenEndpoint,
983+
grant: "authorization_code",
984+
clientId: "test-client",
985+
clientSecret: "test-secret",
986+
resource: server.mcpResourceUrl,
987+
});
988+
989+
const started = yield* executor.oauth.start({
990+
owner: "org",
991+
client: CLIENT,
992+
clientOwner: "org",
993+
name: ConnectionName.make("main"),
994+
integration: INTEG,
995+
template: TEMPLATE,
996+
});
997+
expect(started.status).toBe("redirect");
998+
if (started.status !== "redirect") return;
999+
const callback = yield* server.completeAuthorizationCodeFlow({
1000+
authorizationUrl: started.authorizationUrl,
1001+
});
1002+
yield* executor.oauth.complete({ state: started.state, code: callback.code });
1003+
1004+
yield* Effect.promise(() =>
1005+
config.db.updateMany("connection", {
1006+
where: (b) => b("name", "=", "main"),
1007+
set: { expires_at: Date.now() - 60_000 },
1008+
}),
1009+
);
1010+
yield* server.clearRequests;
1011+
1012+
// First resolve: the refresh grant reaches the AS and is rejected
1013+
// with invalid_grant — the AS's definitive verdict.
1014+
const first = yield* Effect.flip(
1015+
executor.execute(ToolAddress.make("tools.acme.org.main.whoami"), {}),
1016+
);
1017+
expect(JSON.stringify(first)).toContain("invalid_grant");
1018+
1019+
// The verdict is persisted: the dead-grant marker plus an expired
1020+
// health record, without waiting for a probe.
1021+
const row = yield* Effect.promise(() =>
1022+
config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }),
1023+
);
1024+
expect(
1025+
(row?.provider_state as { oauthReauthRequiredAt?: number } | null)?.oauthReauthRequiredAt,
1026+
).toEqual(expect.any(Number));
1027+
expect(row?.last_health).toMatchObject({ status: "expired" });
1028+
1029+
const grantRequests = () =>
1030+
server.requests.pipe(
1031+
Effect.map(
1032+
(all) =>
1033+
all.filter((r) => r.path === "/token" && r.body.includes("refresh_token")).length,
1034+
),
1035+
);
1036+
const sentBefore = yield* grantRequests();
1037+
expect(sentBefore).toBe(1);
1038+
1039+
// Later resolves still fail reauth-required, but WITHOUT re-sending
1040+
// the dead grant: the token endpoint sees no further traffic.
1041+
const second = yield* Effect.flip(
1042+
executor.execute(ToolAddress.make("tools.acme.org.main.whoami"), {}),
1043+
);
1044+
expect(JSON.stringify(second)).toContain("Reconnect");
1045+
expect(yield* grantRequests()).toBe(sentBefore);
1046+
1047+
// Reconnecting mints a fresh grant and re-arms refresh: the marker is
1048+
// gone and resolution works again.
1049+
const restarted = yield* executor.oauth.start({
1050+
owner: "org",
1051+
client: CLIENT,
1052+
clientOwner: "org",
1053+
name: ConnectionName.make("main"),
1054+
integration: INTEG,
1055+
template: TEMPLATE,
1056+
});
1057+
expect(restarted.status).toBe("redirect");
1058+
if (restarted.status !== "redirect") return;
1059+
const reCallback = yield* server.completeAuthorizationCodeFlow({
1060+
authorizationUrl: restarted.authorizationUrl,
1061+
});
1062+
yield* executor.oauth.complete({ state: restarted.state, code: reCallback.code });
1063+
1064+
const cleared = yield* Effect.promise(() =>
1065+
config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }),
1066+
);
1067+
expect(
1068+
(cleared?.provider_state as { oauthReauthRequiredAt?: number } | null)
1069+
?.oauthReauthRequiredAt,
1070+
).toBeUndefined();
1071+
}),
1072+
),
1073+
);
1074+
9651075
it.effect(
9661076
"checkHealth reports healthy from OAuth credential resolution when no probe is configured",
9671077
() =>

packages/core/sdk/src/oauth-helpers.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,68 @@ describe("refreshAccessToken", () => {
871871
}),
872872
),
873873
);
874+
875+
// Datadog answers refresh grants with a non-conform envelope; the §5.2 code
876+
// must still be recovered so invalid_grant classifies as reauth-required
877+
// instead of a retried-forever transient (owner.com prod, 2026-07-30).
878+
it.effect("recovers invalid_grant from Datadog's non-conform errors array", () =>
879+
withTokenEndpoint(
880+
() =>
881+
json(400, {
882+
errors: ["invalid_grant - Invalid or expired refresh token or code verifier."],
883+
}),
884+
({ tokenUrl }) =>
885+
Effect.gen(function* () {
886+
const error = yield* Effect.flip(
887+
refreshAccessToken({ tokenUrl, clientId: "cid", refreshToken: "old" }),
888+
);
889+
expect(error).toBeInstanceOf(OAuth2Error);
890+
expect((error as OAuth2Error).error).toBe("invalid_grant");
891+
}),
892+
),
893+
);
894+
895+
it.effect("recovers a bare non-conform `error` string outside the spec envelope shape", () =>
896+
withTokenEndpoint(
897+
() => json(400, { error: "invalid_grant", detail: 42 }),
898+
({ tokenUrl }) =>
899+
Effect.gen(function* () {
900+
const error = yield* Effect.flip(
901+
refreshAccessToken({ tokenUrl, clientId: "cid", refreshToken: "old" }),
902+
);
903+
expect(error).toBeInstanceOf(OAuth2Error);
904+
expect((error as OAuth2Error).error).toBe("invalid_grant");
905+
}),
906+
),
907+
);
908+
909+
it.effect("does not invent a code from free-text error bodies", () =>
910+
withTokenEndpoint(
911+
() => json(400, { errors: ["something went wrong, try again later"] }),
912+
({ tokenUrl }) =>
913+
Effect.gen(function* () {
914+
const error = yield* Effect.flip(
915+
refreshAccessToken({ tokenUrl, clientId: "cid", refreshToken: "old" }),
916+
);
917+
expect(error).toBeInstanceOf(OAuth2Error);
918+
expect((error as OAuth2Error).error).toBeUndefined();
919+
}),
920+
),
921+
);
922+
923+
it.effect("does not probe non-conform bodies on 5xx responses", () =>
924+
withTokenEndpoint(
925+
() => json(502, { errors: ["invalid_grant - upstream proxy noise"] }),
926+
({ tokenUrl }) =>
927+
Effect.gen(function* () {
928+
const error = yield* Effect.flip(
929+
refreshAccessToken({ tokenUrl, clientId: "cid", refreshToken: "old" }),
930+
);
931+
expect(error).toBeInstanceOf(OAuth2Error);
932+
expect((error as OAuth2Error).error).toBeUndefined();
933+
}),
934+
),
935+
);
874936
});
875937

876938
describe("shouldRefreshToken", () => {

0 commit comments

Comments
 (0)