Skip to content

Commit d8d4dcb

Browse files
committed
Refresh OAuth tokens on upstream 401, not just on stored expiry
The proactive expiry check is the only thing that has ever triggered a refresh, and expires_at is only the AS's advertised lifetime. Tokens die earlier than that routinely: server-side revocation, an identity provider idle-timeout policy, or an AS that omits expires_in entirely (null expiry, which shouldRefreshToken returns false for forever). Treat an upstream 401 as authoritative: re-mint once and retry. Excludes 403 (authenticated but not permitted) and connections with no refresh token, and keeps the upstream's own failure when the retry also fails. Also drop pooled MCP sessions on 401 — a session is bound to the bearer it was dialled with, so a retained one would serve the dead credential for the full idle window and the refreshed token could never reach the server. Adds tracing to the refresh path, which had no span, log, or metric.
1 parent fc1e589 commit d8d4dcb

8 files changed

Lines changed: 536 additions & 21 deletions

File tree

.changeset/olive-donkeys-smile.md

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+
Refresh OAuth tokens when the upstream rejects them with HTTP 401, not only when the stored expiry says they are due. Connections whose authorization server omits `expires_in` can now recover without a manual reconnect, and the refresh path is traced.

packages/core/sdk/src/auth-tool-failure.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ToolResult, type ToolError } from "./tool-result";
1+
import { isToolResult, ToolResult, type ToolError } from "./tool-result";
22

33
export type AuthToolFailureCode =
44
| "connection_value_missing"
@@ -84,3 +84,21 @@ export const authToolFailure = <T = never>(input: AuthToolFailureInput): ToolRes
8484
};
8585
return ToolResult.fail(error);
8686
};
87+
88+
/**
89+
* Whether a tool result is the upstream saying "this credential is not valid"
90+
* — an HTTP 401 that every protocol plugin reports as `connection_rejected`.
91+
*
92+
* This is the trigger for a reactive token refresh, so it is deliberately
93+
* exact. `oauth_scope_insufficient` and any 403 are excluded: those mean
94+
* authenticated-but-not-permitted, and re-minting the same grant returns the
95+
* identical answer (a retry loop that burns a refresh-token rotation per call
96+
* and never converges). `oauth_reauth_required` and `oauth_refresh_failed` are
97+
* excluded too — they are raised by the refresh path itself, so retrying on
98+
* them would mean refreshing in response to a failed refresh.
99+
*/
100+
export const isUnauthorizedToolFailure = (value: unknown): boolean =>
101+
isToolResult(value) &&
102+
!value.ok &&
103+
value.error.code === "connection_rejected" &&
104+
value.error.status === 401;

packages/core/sdk/src/executor.ts

Lines changed: 119 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ import {
151151
} from "./oauth-helpers";
152152
import { connectionIdentifier } from "./connection-name-identifier";
153153
import { annotateToolResultOutcome } from "./tool-result";
154+
import { isUnauthorizedToolFailure } from "./auth-tool-failure";
154155

155156
const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90;
156157
const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90;
@@ -1500,10 +1501,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
15001501
where: (b: AnyCb) => b.and(byOwner(owner)(b), b("slug", "=", slug)),
15011502
});
15021503

1504+
/** What drove a refresh: the pre-call expiry check (`proactive`), or an
1505+
* upstream 401 on a token we believed was still valid (`reactive`). */
1506+
type RefreshTrigger = "proactive" | "reactive";
1507+
15031508
// Perform the actual refresh-token grant and persist the rotated material.
15041509
const performTokenRefresh = (
15051510
row: ConnectionRow,
15061511
provider: CredentialProvider,
1512+
trigger: RefreshTrigger,
15071513
): Effect.Effect<string | null, StorageFailure | CredentialResolutionError> =>
15081514
Effect.gen(function* () {
15091515
const owner = row.owner as Owner;
@@ -1653,11 +1659,45 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
16531659
});
16541660

16551661
return token.access_token;
1656-
});
1662+
}).pipe(
1663+
// The refresh path was previously invisible to telemetry: no span, no
1664+
// log, no metric. When a customer reported "my OAuth just died", there
1665+
// was no way to answer "did a refresh even fire, and what did the AS
1666+
// say?" without a repro. Stamp the outcome and the AS's own error code
1667+
// (an enumerable RFC 6749 §5.2 identifier, never user content or token
1668+
// material) so refresh health is greppable in traces.
1669+
Effect.tap(() => Effect.annotateCurrentSpan({ "executor.oauth.refresh.outcome": "ok" })),
1670+
Effect.tapError((error: StorageFailure | CredentialResolutionError) =>
1671+
Effect.annotateCurrentSpan({
1672+
"executor.oauth.refresh.outcome": "fail",
1673+
"executor.oauth.refresh.error": Predicate.isTagged(error, "CredentialResolutionError")
1674+
? "CredentialResolutionError"
1675+
: "StorageFailure",
1676+
// Whether the AS's refusal was definitive (RFC 6749 invalid_grant →
1677+
// the refresh token itself is dead) or a transient failure the next
1678+
// invoke can retry. The split is the actionable half of the signal.
1679+
...(Predicate.isTagged(error, "CredentialResolutionError")
1680+
? { "executor.oauth.refresh.reauth_required": error.reauthRequired === true }
1681+
: {}),
1682+
}),
1683+
),
1684+
Effect.withSpan("executor.oauth.refresh", {
1685+
attributes: {
1686+
"executor.integration": String(row.integration),
1687+
"executor.connection": String(row.name),
1688+
// Which path drove this refresh: the expiry check ahead of a call,
1689+
// or an upstream 401 on a token we believed was still good. The
1690+
// ratio is the health signal — a rising `reactive` share means
1691+
// tokens are dying earlier than their advertised expiry.
1692+
"executor.oauth.refresh.trigger": trigger,
1693+
},
1694+
}),
1695+
);
16571696

16581697
const refreshConnectionToken = (
16591698
row: ConnectionRow,
16601699
provider: CredentialProvider,
1700+
trigger: RefreshTrigger = "proactive",
16611701
): Effect.Effect<string | null, StorageFailure | CredentialResolutionError> =>
16621702
// Share a single refresh per connection so concurrent resolves of the same
16631703
// connection all await one refresh-token grant (the AS rotates the refresh
@@ -1666,11 +1706,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
16661706
// expiry can refresh again.
16671707
Effect.gen(function* () {
16681708
const key = connectionKey(row);
1709+
// Joining an in-flight grant is correct for BOTH triggers: whatever
1710+
// that peer mints is newer than the token this fiber just saw rejected,
1711+
// which is exactly what a reactive retry wants. The gate is cleared on
1712+
// settle, so a 401 arriving after a refresh completed starts a fresh
1713+
// grant rather than replaying the stale memoized one.
16691714
const existing = refreshInFlight.get(key);
16701715
if (existing) return yield* existing;
16711716
// `Effect.cached` memoizes the grant onto a deferred: it runs once and
16721717
// replays to every awaiter sharing this entry.
1673-
const memoized = yield* Effect.cached(performTokenRefresh(row, provider));
1718+
const memoized = yield* Effect.cached(performTokenRefresh(row, provider, trigger));
16741719
const gated = memoized.pipe(
16751720
Effect.ensuring(Effect.sync(() => refreshInFlight.delete(key))),
16761721
);
@@ -1720,6 +1765,29 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
17201765
),
17211766
);
17221767

1768+
/** Re-mint an OAuth connection's access token unconditionally, ignoring the
1769+
* stored expiry. Drives the reactive path: the upstream just rejected the
1770+
* token we sent, which is authoritative regardless of what `expires_at`
1771+
* claims (revoked server-side, an idle-timeout policy shorter than the
1772+
* advertised lifetime, or an expiry the AS never advertised at all).
1773+
*
1774+
* Returns null when the connection can't be re-minted without a human —
1775+
* not OAuth-backed, or holding no refresh token — so the caller keeps the
1776+
* upstream's own auth failure instead of inventing one. */
1777+
const forceRefreshConnectionValues = (
1778+
row: ConnectionRow,
1779+
): Effect.Effect<
1780+
Record<string, string | null> | null,
1781+
StorageFailure | CredentialResolutionError
1782+
> =>
1783+
Effect.gen(function* () {
1784+
if (row.oauth_client == null || row.refresh_item_id == null) return null;
1785+
const provider = credentialProviders.get(row.provider);
1786+
if (!provider) return null;
1787+
const access = yield* refreshConnectionToken(row, provider, "reactive");
1788+
return { [PRIMARY_INPUT_VARIABLE]: access };
1789+
});
1790+
17231791
/** The primary (`token`) value — the public seam for OAuth + single-input
17241792
* callers that only ever need one value. */
17251793
const resolveConnectionValue = (
@@ -3798,27 +3866,58 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
37983866
const values = yield* resolveConnectionValues(connectionRow);
37993867
const integrationRow = yield* findIntegrationRow(parsed.integration);
38003868
const grantedScopes = grantedScopesFromRow(connectionRow);
3801-
const credential: ToolInvocationCredential = {
3802-
owner: parsed.owner,
3803-
integration: parsed.integration,
3804-
connection: parsed.connection,
3805-
template: AuthTemplateSlug.make(connectionRow.template),
3806-
value: values[PRIMARY_INPUT_VARIABLE] ?? null,
3807-
values,
3808-
config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined,
3809-
...(grantedScopes ? { grantedScopes } : {}),
3869+
const invokeTool = runtime.plugin.invokeTool;
3870+
const invokeWith = (
3871+
resolved: Record<string, string | null>,
3872+
): Effect.Effect<unknown, ToolInvocationError> => {
3873+
const credential: ToolInvocationCredential = {
3874+
owner: parsed.owner,
3875+
integration: parsed.integration,
3876+
connection: parsed.connection,
3877+
template: AuthTemplateSlug.make(connectionRow.template),
3878+
value: resolved[PRIMARY_INPUT_VARIABLE] ?? null,
3879+
values: resolved,
3880+
config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined,
3881+
...(grantedScopes ? { grantedScopes } : {}),
3882+
};
3883+
return wrapInvocationError(
3884+
invokeTool({
3885+
ctx: runtime.ctx,
3886+
toolRow: row,
3887+
credential,
3888+
args,
3889+
elicit: buildElicit(address, args, handler),
3890+
invokeOptions: options,
3891+
}),
3892+
);
38103893
};
38113894

3812-
return yield* wrapInvocationError(
3813-
runtime.plugin.invokeTool({
3814-
ctx: runtime.ctx,
3815-
toolRow: row,
3816-
credential,
3817-
args,
3818-
elicit: buildElicit(address, args, handler),
3819-
invokeOptions: options,
3820-
}),
3895+
const first = yield* invokeWith(values);
3896+
// Reactive refresh. `expires_at` is only ever the AS's ADVERTISED
3897+
// lifetime; the upstream rejecting the token is the authoritative word
3898+
// on whether it is still good. The two diverge routinely: server-side
3899+
// revocation, an identity provider's idle-timeout policy shorter than
3900+
// the token lifetime, and connections whose AS omitted `expires_in`
3901+
// entirely (null expiry → the proactive check never fires, so this is
3902+
// their ONLY route back to a working token short of a reconnect).
3903+
//
3904+
// Deliberately narrow: exactly one retry, only on the 401 that means
3905+
// "this credential is not valid", and only for a connection holding a
3906+
// refresh token. A 403 is excluded — it means authenticated-but-not-
3907+
// permitted, and re-minting the same grant returns the same answer.
3908+
// If the retry also fails its result stands, so a genuinely dead grant
3909+
// still surfaces the upstream's own auth failure and its reconnect
3910+
// guidance rather than a masked one.
3911+
if (!isUnauthorizedToolFailure(first)) return first;
3912+
const refreshed = yield* forceRefreshConnectionValues(connectionRow).pipe(
3913+
// A failed re-mint is not this call's failure to report: the upstream
3914+
// already produced an auth failure with recovery guidance, which is
3915+
// strictly more actionable than a refresh-plumbing error. Keep it.
3916+
Effect.catchTag("CredentialResolutionError", () => Effect.succeed(null)),
38213917
);
3918+
if (!refreshed) return first;
3919+
yield* Effect.annotateCurrentSpan({ "executor.oauth.refresh.retried": true });
3920+
return yield* invokeWith(refreshed);
38223921
}).pipe(
38233922
// Expected tool failures (`ToolResult.fail`) resolve through the
38243923
// success channel, so the tracer alone would record them as healthy

packages/core/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ export {
404404
} from "./sqlite-oauth-client-gc-migration";
405405
export {
406406
authToolFailure,
407+
isUnauthorizedToolFailure,
407408
type AuthToolFailureCode,
408409
type AuthToolFailureInput,
409410
} from "./auth-tool-failure";

0 commit comments

Comments
 (0)