Skip to content

Commit b2fe257

Browse files
committed
feat(sdk): let a credential provider own the OAuth refresh grant
A provider that serves an indirection instead of a raw value can protect an access token: that token's only use is to be sent to a bound host, and the reply is not itself a credential. The refresh grant breaks that. The exchange needs the real refresh token, and the authorization server's reply carries a brand-new real access token, so serving an indirection here moves the exposure one step later while appearing to remove it. Providers backed by a sealed store have to refuse the refresh item outright today, which costs them refresh entirely. Add an optional `refreshGrant` to CredentialProvider so such a provider can own the exchange instead: it spends the refresh token, seals the new access token (and a rotated refresh token) under the same item ids, and returns only `{ expiresAt, scope }`. The caller then reads the access token back through `get`, the same hop every other credential already takes. Absence is not a downgrade: when the method is missing the existing host-side exchange runs unchanged. client_credentials is excluded deliberately - it has no refresh token to spend. Secrets are named by item id, never passed as values, since passing them would reintroduce the exposure this removes. The test pins the custody property directly - that the host never resolves the refresh token through the provider - rather than asserting the refresh succeeded, because a provider that quietly served the token would also go green.
1 parent f674fb8 commit b2fe257

3 files changed

Lines changed: 302 additions & 20 deletions

File tree

packages/core/sdk/src/executor.ts

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1867,6 +1867,62 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
18671867
? String(row.oauth_token_url)
18681868
: String(clientRow.token_url);
18691869

1870+
// OAuth is always single-input: the access token lives in the `token`
1871+
// item. Fall back to a deterministic id if the map is somehow empty.
1872+
const tokenItemId = ProviderItemId.make(
1873+
connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ??
1874+
`connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`,
1875+
);
1876+
1877+
// Shared by both grant paths so their bookkeeping cannot drift apart.
1878+
// `scope` is written only when the authorization server reported one —
1879+
// an unreported scope must leave the recorded scope alone rather than
1880+
// clearing it.
1881+
const recordRefreshOutcome = (expiresAt: number | null, scope: string | undefined) =>
1882+
Effect.gen(function* () {
1883+
const set: Record<string, unknown> = { expires_at: expiresAt, updated_at: new Date() };
1884+
if (scope !== undefined) set.oauth_scope = scope;
1885+
yield* core.updateMany("connection", {
1886+
where: (b: AnyCb) =>
1887+
b.and(
1888+
byOwner(owner)(b),
1889+
b("integration", "=", String(row.integration)),
1890+
b("name", "=", String(row.name)),
1891+
),
1892+
set,
1893+
});
1894+
});
1895+
1896+
// A provider that can perform the grant itself owns the whole exchange:
1897+
// it spends the refresh token, seals the newly minted tokens under the
1898+
// same item ids, and tells us only when they expire and what scope was
1899+
// granted. We then read the access token back through `get`, which is
1900+
// the same hop every other credential already takes — so the refresh
1901+
// path stops being the one place that hands a plaintext token upward.
1902+
//
1903+
// client_credentials is excluded deliberately: it has no refresh token
1904+
// to spend (the token is re-minted from the client id/secret), so it is
1905+
// a different exchange and is left on the path below.
1906+
if (provider.refreshGrant && String(clientRow.grant) !== "client_credentials") {
1907+
if (!row.refresh_item_id) {
1908+
return yield* reauth("No refresh token is stored for this connection.");
1909+
}
1910+
const granted = yield* provider.refreshGrant({
1911+
refreshItemId: ProviderItemId.make(String(row.refresh_item_id)),
1912+
accessItemId: tokenItemId,
1913+
clientSecretItemId: clientRow.client_secret_item_id
1914+
? ProviderItemId.make(String(clientRow.client_secret_item_id))
1915+
: undefined,
1916+
tokenUrl,
1917+
clientId: String(clientRow.client_id),
1918+
scopes: grantedScopes,
1919+
// RFC 8707: keep the re-minted token bound to the same resource.
1920+
resource: clientRow.resource ? String(clientRow.resource) : undefined,
1921+
});
1922+
yield* recordRefreshOutcome(granted.expiresAt, granted.scope ?? undefined);
1923+
return yield* provider.get(tokenItemId);
1924+
}
1925+
18701926
// client_credentials (machine-to-machine) has NO refresh token — the
18711927
// token is RE-MINTED from the client id/secret. The authorization_code
18721928
// path below needs a stored refresh token. Branching on grant here is
@@ -1959,33 +2015,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19592015
});
19602016

19612017
if (provider.set) {
1962-
// OAuth is always single-input: the access token lives in the `token`
1963-
// item. Fall back to a deterministic id if the map is somehow empty.
1964-
const tokenItemId =
1965-
connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ??
1966-
`connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`;
1967-
yield* provider.set(ProviderItemId.make(tokenItemId), token.access_token);
2018+
yield* provider.set(tokenItemId, token.access_token);
19682019
if (token.refresh_token && row.refresh_item_id) {
19692020
yield* provider.set(ProviderItemId.make(row.refresh_item_id), token.refresh_token);
19702021
}
19712022
}
19722023

19732024
const nextExpiresAt =
19742025
typeof token.expires_in === "number" ? Date.now() + token.expires_in * 1000 : null;
1975-
const set: Record<string, unknown> = {
1976-
expires_at: nextExpiresAt,
1977-
updated_at: new Date(),
1978-
};
1979-
if (token.scope !== undefined) set.oauth_scope = token.scope;
1980-
yield* core.updateMany("connection", {
1981-
where: (b: AnyCb) =>
1982-
b.and(
1983-
byOwner(owner)(b),
1984-
b("integration", "=", String(row.integration)),
1985-
b("name", "=", String(row.name)),
1986-
),
1987-
set,
1988-
});
2026+
yield* recordRefreshOutcome(nextExpiresAt, token.scope);
19892027

19902028
return token.access_token;
19912029
}).pipe(
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
4+
import {
5+
AuthTemplateSlug,
6+
ConnectionName,
7+
IntegrationSlug,
8+
OAuthClientSlug,
9+
ProviderItemId,
10+
ProviderKey,
11+
ToolAddress,
12+
ToolName,
13+
} from "./ids";
14+
import { definePlugin } from "./plugin";
15+
import type { CredentialProvider, RefreshGrantInput } from "./provider";
16+
import { makeTestWorkspaceHarness } from "./test-config";
17+
import { serveOAuthTestServer } from "./testing/oauth-test-server";
18+
19+
// A provider that OWNS the refresh grant never hands the refresh token out. These tests pin that
20+
// property directly rather than asserting "the refresh succeeded" — success is not the claim. The
21+
// claim is that the host never resolved the secret, and only a test watching `get` can tell a
22+
// provider that protected the token from one that quietly served it. Both would go green.
23+
24+
const INTEG = IntegrationSlug.make("acme");
25+
const TEMPLATE = AuthTemplateSlug.make("oauth");
26+
const CLIENT = OAuthClientSlug.make("acme-app");
27+
28+
const oauthPlugin = definePlugin(() => ({
29+
id: "acme" as const,
30+
storage: () => ({}),
31+
resolveTools: () =>
32+
Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }),
33+
describeAuthMethods: (record) => {
34+
const config = record.config as { readonly scopes?: readonly string[] } | null;
35+
return [
36+
{
37+
id: "oauth",
38+
label: "OAuth2",
39+
kind: "oauth" as const,
40+
template: String(TEMPLATE),
41+
oauth: { scopes: config?.scopes ?? [] },
42+
},
43+
];
44+
},
45+
invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),
46+
extension: (ctx) => ({
47+
seed: (scopes: readonly string[] = []) =>
48+
ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: { scopes } }),
49+
}),
50+
}))();
51+
52+
/** Records what the host asked the provider for, so a test can assert what it did NOT ask for. */
53+
interface Recorder {
54+
readonly reads: string[];
55+
readonly grants: RefreshGrantInput[];
56+
}
57+
58+
/** A memory provider that can also perform the refresh grant itself.
59+
*
60+
* `refreshGrant` seals a new access token under `accessItemId`, exactly as a sealed-store provider
61+
* would, and returns only expiry and scope. It never calls `get`. */
62+
const delegatingCredentialsPlugin = (recorder: Recorder, withGrant: boolean) =>
63+
definePlugin(() => {
64+
const store = new Map<string, string>();
65+
66+
const base = {
67+
key: ProviderKey.make("memory"),
68+
writable: true as const,
69+
get: (id: ProviderItemId) =>
70+
Effect.sync(() => {
71+
recorder.reads.push(String(id));
72+
return store.get(String(id)) ?? null;
73+
}),
74+
set: (id: ProviderItemId, value: string) =>
75+
Effect.sync(() => {
76+
store.set(String(id), value);
77+
}),
78+
delete: (id: ProviderItemId) =>
79+
Effect.sync(() => {
80+
store.delete(String(id));
81+
}),
82+
};
83+
84+
const provider: CredentialProvider = withGrant
85+
? {
86+
...base,
87+
refreshGrant: (input: RefreshGrantInput) =>
88+
Effect.sync(() => {
89+
recorder.grants.push(input);
90+
store.set(String(input.accessItemId), "delegated-access-token");
91+
return { expiresAt: Date.now() + 3_600_000, scope: "read" };
92+
}),
93+
}
94+
: base;
95+
96+
return {
97+
id: "memory-credentials" as const,
98+
storage: () => ({}),
99+
credentialProviders: [provider],
100+
};
101+
})();
102+
103+
describe("provider-owned OAuth refresh grant", () => {
104+
const scenario = (withGrant: boolean) =>
105+
Effect.gen(function* () {
106+
const recorder: Recorder = { reads: [], grants: [] };
107+
const server = yield* serveOAuthTestServer({ scopes: ["read"] });
108+
const plugins = [delegatingCredentialsPlugin(recorder, withGrant), oauthPlugin] as const;
109+
const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
110+
yield* executor.acme.seed();
111+
112+
yield* executor.oauth.createClient({
113+
owner: "org",
114+
slug: CLIENT,
115+
authorizationUrl: server.authorizationEndpoint,
116+
tokenUrl: server.tokenEndpoint,
117+
grant: "authorization_code",
118+
clientId: "test-client",
119+
clientSecret: "test-secret",
120+
});
121+
122+
const started = yield* executor.oauth.start({
123+
owner: "org",
124+
client: CLIENT,
125+
clientOwner: "org",
126+
name: ConnectionName.make("main"),
127+
integration: INTEG,
128+
template: TEMPLATE,
129+
});
130+
// Assert-then-return rather than throwing: this is Effect domain code, and the repo's lint
131+
// forbids constructing or throwing built-in Errors here. A failed expectation already fails
132+
// the test, so the early return only satisfies the type.
133+
expect(started.status).toBe("redirect");
134+
if (started.status !== "redirect") return { recorder, server };
135+
const callback = yield* server.completeAuthorizationCodeFlow({
136+
authorizationUrl: started.authorizationUrl,
137+
});
138+
yield* executor.oauth.complete({ state: started.state, code: callback.code });
139+
140+
// Force the next resolve down the refresh path.
141+
yield* Effect.promise(() =>
142+
config.db.updateMany("connection", {
143+
where: (b) => b("name", "=", "main"),
144+
set: { expires_at: Date.now() - 60_000 },
145+
}),
146+
);
147+
148+
recorder.reads.length = 0;
149+
recorder.grants.length = 0;
150+
yield* executor.execute(ToolAddress.make("tools.acme.org.main.whoami"), {});
151+
return { recorder, server };
152+
});
153+
154+
it.effect("delegates the grant and never resolves the refresh token through the host", () =>
155+
Effect.scoped(
156+
Effect.gen(function* () {
157+
const { recorder, server } = yield* scenario(true);
158+
159+
// The grant was delegated, and named by id rather than handed a value.
160+
expect(recorder.grants).toHaveLength(1);
161+
const grant = recorder.grants[0]!;
162+
expect(String(grant.refreshItemId)).toContain(":refresh");
163+
expect(grant.tokenUrl).toBe(server.tokenEndpoint);
164+
165+
// THE CUSTODY CLAIM. If this ever fails, the host is asking for the secret again and the
166+
// guarantee is gone — while the refresh itself still appears to work.
167+
expect(recorder.reads.some((id) => id.endsWith(":refresh"))).toBe(false);
168+
}),
169+
),
170+
);
171+
172+
it.effect("falls back to the host-side exchange when the provider cannot do the grant", () =>
173+
Effect.scoped(
174+
Effect.gen(function* () {
175+
const { recorder } = yield* scenario(false);
176+
177+
// Absence of `refreshGrant` changes nothing: the host performs the exchange, so it DOES
178+
// resolve the refresh token. Pinning that here is what makes the test above meaningful —
179+
// it shows the difference is the provider capability, not the harness.
180+
expect(recorder.grants).toHaveLength(0);
181+
expect(recorder.reads.some((id) => id.endsWith(":refresh"))).toBe(true);
182+
}),
183+
),
184+
);
185+
});

packages/core/sdk/src/provider.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,63 @@ export interface CredentialProvider {
3232
/** Browse entries for discovery (pick a 1Password item). Optional — some
3333
* backends can't enumerate. */
3434
readonly list?: () => Effect.Effect<readonly ProviderEntry[], StorageFailure>;
35+
/** Perform the OAuth refresh grant inside the provider, instead of handing the
36+
* refresh token out to be exchanged here.
37+
*
38+
* WHY THIS EXISTS. A provider that hides values behind an indirection can
39+
* protect an access token, because that token's only use is to be sent to a
40+
* bound host and the reply is not itself a credential. The refresh grant
41+
* breaks that: the exchange needs the real refresh token AND the authorization
42+
* server's reply carries a brand-new real access token, so a provider that
43+
* serves indirection here only moves the exposure one step later while
44+
* appearing to have removed it. Providers backed by a sealed store therefore
45+
* have to refuse the refresh item outright — the honest option, but it costs
46+
* them refresh entirely.
47+
*
48+
* Implementing this gives them the other option: own the exchange, seal the
49+
* new tokens under the same item ids, and return only what the caller needs to
50+
* update its bookkeeping. The caller then resolves the access token through
51+
* `get`, exactly as it resolves every other credential.
52+
*
53+
* OPTIONAL, and absence is not a downgrade: when it is missing the caller
54+
* performs the exchange itself, unchanged. Implement it only if the exchange
55+
* genuinely happens somewhere the host cannot read — returning success without
56+
* performing the grant is worse than not implementing it. */
57+
readonly refreshGrant?: (
58+
input: RefreshGrantInput,
59+
) => Effect.Effect<RefreshGrantResult, StorageFailure>;
60+
}
61+
62+
/** What the provider needs to perform the grant on the caller's behalf.
63+
*
64+
* Secrets are named by ITEM ID, never passed as values — passing the refresh
65+
* token or the client secret here would reintroduce exactly the exposure this
66+
* interface exists to remove. */
67+
export interface RefreshGrantInput {
68+
/** The stored refresh token to spend. */
69+
readonly refreshItemId: ProviderItemId;
70+
/** Where to seal the newly minted access token. The caller reads it back from
71+
* here through `get`. */
72+
readonly accessItemId: ProviderItemId;
73+
/** The OAuth app's client secret, by id. Absent for a public client. */
74+
readonly clientSecretItemId?: ProviderItemId;
75+
readonly tokenUrl: string;
76+
readonly clientId: string;
77+
readonly scopes: readonly string[];
78+
/** RFC 8707 — keeps the re-minted token bound to the same resource. */
79+
readonly resource?: string;
80+
}
81+
82+
/** Deliberately carries NO token material.
83+
*
84+
* These two fields are the whole of what the caller needs to update a
85+
* connection row after a refresh; anything more would put the host back in the
86+
* data path. A rotated refresh token is sealed by the provider under the same
87+
* `refreshItemId` and is never reported here. */
88+
export interface RefreshGrantResult {
89+
/** Epoch millis, or null when the authorization server did not say. */
90+
readonly expiresAt: number | null;
91+
/** The granted scope as reported by the authorization server, or null when it
92+
* did not report one (distinct from an empty scope). */
93+
readonly scope: string | null;
3594
}

0 commit comments

Comments
 (0)