History

Add storeOAuthTokens + withRefreshedAccessToken refresh wrapper (#249)

Generic helpers that take a secrets-I/O adapter and a `persistAuth` callback so plugins can transparently refresh access tokens before a request without re-implementing the ~150-line token-rotation dance. - `storeOAuthTokens`: persist a fresh OAuth2TokenResponse as new access and (optionally) refresh-token secrets via a `createSecret` callback and return a `StoredOAuthTokens` descriptor ready to write to a source config. - `withRefreshedAccessToken`: check `shouldRefreshToken`, resolve client/refresh credentials from secrets, call `refreshAccessToken`, overwrite the access-token secret in place, optionally rotate the refresh-token secret, invoke `persistAuth` with the updated snapshot, and return the current access token to be injected into the request. Ports `google-discovery`: - `completeOAuth` drops its manual `storeSecret` dance (now a ~15 line call to `storeOAuthTokens`). - `invoke.ts`'s `resolveOAuthAccessToken` shrinks from ~150 lines of secret-resolution + refresh + persist glue to ~75 lines of adapter. - The google-discovery-local `refreshAccessToken` wrapper is removed (unused after the port); the google token URL is exported so the invoker can pass it to the shared helper. 12 new fidelity tests lock in every branch (no refresh, refresh within skew, rotated refresh token, preserved refresh token, null refresh ID, public client without client_secret, secret-resolve failure wrapping).

Rhys Sullivan committed 6 months ago

Commit 1a15f721 parent 2e1f75b7 files · +724 −170Browse files

Unified · Split

7 files
  1. packages/plugins/google-discovery/src/sdk/index.ts +0 −1
  2. packages/plugins/google-discovery/src/sdk/invoke.ts +53 −130
  3. packages/plugins/google-discovery/src/sdk/oauth.ts +1 −17
  4. packages/plugins/google-discovery/src/sdk/plugin.ts +21 −22
  5. packages/plugins/oauth2/src/index.ts +15 −0
  6. packages/plugins/oauth2/src/refresh.test.ts +396 −0
  7. packages/plugins/oauth2/src/refresh.ts +238 −0
packages/plugins/google-discovery/src/sdk/index.ts+0−1
@@ -16,7 +16,6 @@ export { · 15 unmodified lines
1616 buildGoogleAuthorizationUrl,
1717 createPkceCodeVerifier,
1818 exchangeAuthorizationCode,
19− refreshAccessToken,
2019 } from "./oauth";
2120 export {
2221 GoogleDiscoveryAuth,
packages/plugins/google-discovery/src/sdk/invoke.ts+53−130
@@ -1,7 +1,9 @@
11 import { Effect, Layer, Option } from "effect";
22 import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
33
4−import { shouldRefreshToken } from "@executor/plugin-oauth2";
4+import { withRefreshedAccessToken } from "@executor/plugin-oauth2";
5+
6+import { GOOGLE_TOKEN_URL } from "./oauth";
57
68 import {
79 type ScopeId,
@@ -19,7 +21,6 @@ import { · 11 unmodified lines
1921 GoogleDiscoveryStoredSourceData,
2022 type GoogleDiscoveryParameter,
2123 } from "./types";
22−import { refreshAccessToken } from "./oauth";
2324
2425 const SAFE_METHODS = new Set(["get", "head", "options"]);
2526
@@ -91,68 +92,57 @@ const resolveOAuthAccessToken = (input: { · 65 unmodified lines
9192 }
9293
9394 const auth = input.source.auth;
94− const needsRefresh =
95− auth.refreshTokenSecretId !== null &&
96− shouldRefreshToken({ expiresAt: auth.expiresAt });
97−
98− if (!needsRefresh) {
99− return yield* input.secrets.resolve(auth.accessTokenSecretId as SecretId, input.scopeId).pipe(
100− Effect.mapError(
101− () =>
102− new ToolInvocationError({
103− toolId: "" as ToolId,
104− message: "Failed to resolve Google OAuth access token",
105− cause: undefined,
106− }),
107− ),
108− );
109− }
95+ const scopeId = input.scopeId;
11096
111− const refreshToken = yield* input.secrets
112− .resolve(auth.refreshTokenSecretId as SecretId, input.scopeId)
113− .pipe(
114− Effect.mapError(
115− () =>
116− new ToolInvocationError({
117− toolId: "" as ToolId,
118− message: "Failed to resolve Google OAuth refresh token",
119− cause: undefined,
120− }),
121− ),
122− );
123−
124− const clientSecret =
125− auth.clientSecretSecretId === null
126− ? null
127− : yield* input.secrets.resolve(auth.clientSecretSecretId as SecretId, input.scopeId).pipe(
128− Effect.mapError(
129− () =>
130− new ToolInvocationError({
131− toolId: "" as ToolId,
132− message: "Failed to resolve Google OAuth client secret",
133− cause: undefined,
134− }),
135− ),
136− );
137−
138− const clientId = yield* input.secrets
139− .resolve(auth.clientIdSecretId as SecretId, input.scopeId)
140− .pipe(
141− Effect.mapError(
142− () =>
143− new ToolInvocationError({
144− toolId: "" as ToolId,
145− message: "Failed to resolve Google OAuth client ID",
146− cause: undefined,
147− }),
148− ),
149− );
150−
151− const refreshed = yield* refreshAccessToken({
152− clientId,
153− clientSecret,
154− refreshToken,
155− scopes: auth.scopes,
97+ return yield* withRefreshedAccessToken({
98+ auth: {
99+ clientIdSecretId: auth.clientIdSecretId,
100+ clientSecretSecretId: auth.clientSecretSecretId,
101+ accessTokenSecretId: auth.accessTokenSecretId,
102+ refreshTokenSecretId: auth.refreshTokenSecretId,
103+ tokenType: auth.tokenType,
104+ expiresAt: auth.expiresAt,
105+ scopes: auth.scopes,
106+ },
107+ tokenUrl: GOOGLE_TOKEN_URL,
108+ secrets: {
109+ resolve: (id) => input.secrets.resolve(id as SecretId, scopeId),
110+ setValue: ({ secretId, value, name, purpose }) =>
111+ input.secrets
112+ .set({
113+ id: secretId as SecretId,
114+ scopeId,
115+ name,
116+ value,
117+ purpose,
118+ })
119+ .pipe(Effect.asVoid),
120+ },
121+ displayName: input.source.name,
122+ accessTokenPurpose: "google_oauth_access_token",
123+ refreshTokenPurpose: "google_oauth_refresh_token",
124+ persistAuth: (snapshot) =>
125+ Effect.gen(function* () {
126+ const updatedSource = new GoogleDiscoveryStoredSourceData({
127+ ...input.source,
128+ auth: {
129+ kind: "oauth2",
130+ clientIdSecretId: auth.clientIdSecretId,
131+ clientSecretSecretId: auth.clientSecretSecretId,
132+ accessTokenSecretId: auth.accessTokenSecretId,
133+ refreshTokenSecretId: auth.refreshTokenSecretId,
134+ tokenType: snapshot.tokenType,
135+ expiresAt: snapshot.expiresAt,
136+ scope: snapshot.scope ?? auth.scope,
137+ scopes: auth.scopes,
138+ },
139+ });
140+ yield* input.bindingStore.putSource({
141+ namespace: input.sourceId,
142+ name: input.source.name,
143+ config: updatedSource,
144+ });
145+ }),
156146 }).pipe(
157147 Effect.mapError(
158148 (error) =>
@@ -163,73 +153,6 @@ const resolveOAuthAccessToken = (input: { · 4 unmodified lines
163153 }),
164154 ),
165155 );
166−
167− yield* input.secrets
168− .set({
169− id: auth.accessTokenSecretId as SecretId,
170− scopeId: input.scopeId,
171− name: `${input.source.name} Access Token`,
172− value: refreshed.access_token,
173− purpose: "google_oauth_access_token",
174− })
175− .pipe(
176− Effect.mapError(
177− () =>
178− new ToolInvocationError({
179− toolId: "" as ToolId,
180− message: "Failed to persist refreshed Google OAuth access token",
181− cause: undefined,
182− }),
183− ),
184− );
185−
186− let refreshTokenSecretId = auth.refreshTokenSecretId;
187− if (refreshed.refresh_token && auth.refreshTokenSecretId) {
188− yield* input.secrets
189− .set({
190− id: auth.refreshTokenSecretId as SecretId,
191− scopeId: input.scopeId,
192− name: `${input.source.name} Refresh Token`,
193− value: refreshed.refresh_token,
194− purpose: "google_oauth_refresh_token",
195− })
196− .pipe(
197− Effect.mapError(
198− () =>
199− new ToolInvocationError({
200− toolId: "" as ToolId,
201− message: "Failed to persist refreshed Google OAuth refresh token",
202− cause: undefined,
203− }),
204− ),
205− );
206− refreshTokenSecretId = auth.refreshTokenSecretId;
207− }
208−
209− const updatedSource = new GoogleDiscoveryStoredSourceData({
210− ...input.source,
211− auth: {
212− kind: "oauth2",
213− clientIdSecretId: auth.clientIdSecretId,
214− clientSecretSecretId: auth.clientSecretSecretId,
215− accessTokenSecretId: auth.accessTokenSecretId,
216− refreshTokenSecretId,
217− tokenType: refreshed.token_type ?? auth.tokenType,
218− expiresAt:
219− typeof refreshed.expires_in === "number"
220− ? Date.now() + refreshed.expires_in * 1000
221− : auth.expiresAt,
222− scope: refreshed.scope ?? auth.scope,
223− scopes: auth.scopes,
224− },
225− });
226− yield* input.bindingStore.putSource({
227− namespace: input.sourceId,
228− name: input.source.name,
229− config: updatedSource,
230− });
231−
232− return refreshed.access_token;
233156 });
234157
235158 export const annotationsForOperation = (
packages/plugins/google-discovery/src/sdk/oauth.ts+1−17
@@ -15,14 +15,13 @@ import { · 14 unmodified lines
1515 buildAuthorizationUrl,
1616 createPkceCodeVerifier as sharedCreatePkceCodeVerifier,
1717 exchangeAuthorizationCode as sharedExchangeAuthorizationCode,
18− refreshAccessToken as sharedRefreshAccessToken,
1918 type OAuth2TokenResponse,
2019 } from "@executor/plugin-oauth2";
2120
2221 import { GoogleDiscoveryOAuthError } from "./errors";
2322
2423 const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
25−const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
24+export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
2625
2726 const GOOGLE_EXTRA_AUTHORIZATION_PARAMS = {
2827 access_type: "offline",
@@ -74,18 +73,3 @@ export const exchangeAuthorizationCode = (input: { · 45 unmodified lines
7473 }),
7574 );
7675
77−export const refreshAccessToken = (input: {
78− readonly clientId: string;
79− readonly clientSecret?: string | null;
80− readonly refreshToken: string;
81− readonly scopes?: readonly string[];
82−}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>
83− wrapError(
84− sharedRefreshAccessToken({
85− tokenUrl: GOOGLE_TOKEN_URL,
86− clientId: input.clientId,
87− clientSecret: input.clientSecret,
88− refreshToken: input.refreshToken,
89− scopes: input.scopes,
90− }),
91− );
packages/plugins/google-discovery/src/sdk/plugin.ts+21−22
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; · 1 unmodified line
22
33 import { Effect, Option } from "effect";
44
5+import { storeOAuthTokens } from "@executor/plugin-oauth2";
6+
57 import {
68 Source,
79 SourceDetectionResult,
@@ -535,32 +537,29 @@ export const googleDiscoveryPlugin = (options?: { · 527 unmodified lines
535537 code: input.code,
536538 });
537539
538− const accessTokenRef = yield* storeSecret(ctx, {
539− idPrefix: `${normalizeSlug(session.name)}_google_access_token`,
540− name: `${session.name} Access Token`,
541− value: tokenResponse.access_token,
542− purpose: "google_oauth_access_token",
543− });
544− const refreshTokenRef = tokenResponse.refresh_token
545− ? yield* storeSecret(ctx, {
546− idPrefix: `${normalizeSlug(session.name)}_google_refresh_token`,
547− name: `${session.name} Refresh Token`,
548− value: tokenResponse.refresh_token,
549− purpose: "google_oauth_refresh_token",
550− })
551− : null;
540+ const stored = yield* storeOAuthTokens({
541+ tokens: tokenResponse,
542+ slug: `${normalizeSlug(session.name)}_google`,
543+ displayName: session.name,
544+ accessTokenPurpose: "google_oauth_access_token",
545+ refreshTokenPurpose: "google_oauth_refresh_token",
546+ createSecret: (args) =>
547+ storeSecret(ctx, args).pipe(Effect.map((ref) => ({ id: ref.id as string }))),
548+ }).pipe(
549+ Effect.mapError(
550+ (error) => new GoogleDiscoveryOAuthError({ message: error.message }),
551+ ),
552+ );
553+
552554 return {
553555 kind: "oauth2" as const,
554556 clientIdSecretId: session.clientIdSecretId,
555557 clientSecretSecretId: session.clientSecretSecretId,
556− accessTokenSecretId: accessTokenRef.id,
557− refreshTokenSecretId: refreshTokenRef?.id ?? null,
558− tokenType: tokenResponse.token_type ?? "Bearer",
559− expiresAt:
560− typeof tokenResponse.expires_in === "number"
561− ? Date.now() + tokenResponse.expires_in * 1000
562− : null,
563− scope: tokenResponse.scope ?? null,
558+ accessTokenSecretId: stored.accessTokenSecretId,
559+ refreshTokenSecretId: stored.refreshTokenSecretId,
560+ tokenType: stored.tokenType,
561+ expiresAt: stored.expiresAt,
562+ scope: stored.scope,
564563 scopes: [...session.scopes],
565564 };
566565 }),
packages/plugins/oauth2/src/index.ts+15−0
@@ -366,3 +366,18 @@ export const shouldRefreshToken = (input: { · 365 unmodified lines
366366 const skew = input.skewMs ?? OAUTH2_REFRESH_SKEW_MS;
367367 return input.expiresAt <= now + skew;
368368 };
369+
370+// ---------------------------------------------------------------------------
371+// Re-exports from sibling modules
372+// ---------------------------------------------------------------------------
373+
374+export {
375+ type OAuth2AuthRefs,
376+ type OAuth2SecretsIO,
377+ type RefreshedAuthSnapshot,
378+ type StoreOAuthTokensInput,
379+ type StoredOAuthTokens,
380+ type WithRefreshedAccessTokenInput,
381+ storeOAuthTokens,
382+ withRefreshedAccessToken,
383+} from "./refresh";
packages/plugins/oauth2/src/refresh.test.ts+396−0
@@ -0,0 +1,396 @@
1+// ---------------------------------------------------------------------------
2+// Fidelity tests for storeOAuthTokens + withRefreshedAccessToken.
3+// Uses an in-memory secrets adapter so we can assert exactly which calls
4+// happen in which order across the "no refresh needed", "refresh needed",
5+// "refresh rotates the refresh_token", and "secret resolution fails" paths.
6+// ---------------------------------------------------------------------------
7+
8+import { afterEach, describe, expect, it, vi } from "vitest";
9+import { Effect, Exit } from "effect";
10+
11+import {
12+ OAUTH2_REFRESH_SKEW_MS,
13+ storeOAuthTokens,
14+ withRefreshedAccessToken,
15+ type OAuth2AuthRefs,
16+ type OAuth2SecretsIO,
17+ type RefreshedAuthSnapshot,
18+} from "./index";
19+
20+// ---------------------------------------------------------------------------
21+// In-memory secrets adapter
22+// ---------------------------------------------------------------------------
23+
24+const makeSecrets = (initial: Record<string, string>): {
25+ io: OAuth2SecretsIO;
26+ state: Map<string, { value: string; name: string; purpose: string }>;
27+ missing: Set<string>;
28+} => {
29+ const state = new Map<string, { value: string; name: string; purpose: string }>();
30+ for (const [id, v] of Object.entries(initial))
31+ state.set(id, { value: v, name: "", purpose: "" });
32+ const missing = new Set<string>();
33+ const io: OAuth2SecretsIO = {
34+ resolve: (secretId) =>
35+ Effect.gen(function* () {
36+ if (missing.has(secretId))
37+ return yield* Effect.fail(new Error(`secret ${secretId} not found`));
38+ const entry = state.get(secretId);
39+ if (!entry)
40+ return yield* Effect.fail(new Error(`secret ${secretId} not found`));
41+ return entry.value;
42+ }),
43+ setValue: ({ secretId, value, name, purpose }) =>
44+ Effect.sync(() => {
45+ state.set(secretId, { value, name, purpose });
46+ }),
47+ };
48+ return { io, state, missing };
49+};
50+
51+// ---------------------------------------------------------------------------
52+// fetch capture for refresh path
53+// ---------------------------------------------------------------------------
54+
55+const originalFetch = globalThis.fetch;
56+
57+const captureFetch = (
58+ response: Response | (() => Response),
59+): { calls: Array<{ url: string; init: RequestInit }> } => {
60+ const calls: Array<{ url: string; init: RequestInit }> = [];
61+ globalThis.fetch = vi
62+ .fn()
63+ .mockImplementation(async (url: string, init: RequestInit) => {
64+ calls.push({ url, init });
65+ return typeof response === "function" ? response() : response;
66+ }) as unknown as typeof fetch;
67+ return { calls };
68+};
69+
70+const jsonResponse = (status: number, body: unknown): Response =>
71+ new Response(JSON.stringify(body), {
72+ status,
73+ headers: { "content-type": "application/json" },
74+ });
75+
76+afterEach(() => {
77+ globalThis.fetch = originalFetch;
78+});
79+
80+// ---------------------------------------------------------------------------
81+// storeOAuthTokens
82+// ---------------------------------------------------------------------------
83+
84+describe("storeOAuthTokens", () => {
85+ const baseInput = {
86+ slug: "acme_api",
87+ displayName: "Acme API",
88+ accessTokenPurpose: "acme_oauth_access_token",
89+ refreshTokenPurpose: "acme_oauth_refresh_token",
90+ };
91+
92+ it("stores both access and refresh tokens as new secrets", async () => {
93+ const calls: Array<{ idPrefix: string; name: string; purpose: string; value: string }> = [];
94+ const result = await Effect.runPromise(
95+ storeOAuthTokens({
96+ ...baseInput,
97+ tokens: {
98+ access_token: "tok",
99+ refresh_token: "rtok",
100+ token_type: "Bearer",
101+ expires_in: 3600,
102+ scope: "read write",
103+ },
104+ createSecret: (input) => {
105+ calls.push(input);
106+ return Effect.succeed({ id: `id_${calls.length}` });
107+ },
108+ }),
109+ );
110+ expect(calls).toHaveLength(2);
111+ expect(calls[0]!.idPrefix).toBe("acme_api_access_token");
112+ expect(calls[0]!.name).toBe("Acme API Access Token");
113+ expect(calls[0]!.purpose).toBe("acme_oauth_access_token");
114+ expect(calls[0]!.value).toBe("tok");
115+ expect(calls[1]!.idPrefix).toBe("acme_api_refresh_token");
116+ expect(calls[1]!.value).toBe("rtok");
117+ expect(result.accessTokenSecretId).toBe("id_1");
118+ expect(result.refreshTokenSecretId).toBe("id_2");
119+ expect(result.tokenType).toBe("Bearer");
120+ expect(result.scope).toBe("read write");
121+ expect(result.expiresAt).toBeGreaterThan(Date.now());
122+ });
123+
124+ it("omits the refresh-token secret when the response did not return one", async () => {
125+ let calls = 0;
126+ const result = await Effect.runPromise(
127+ storeOAuthTokens({
128+ ...baseInput,
129+ tokens: { access_token: "tok" },
130+ createSecret: () => {
131+ calls++;
132+ return Effect.succeed({ id: `id_${calls}` });
133+ },
134+ }),
135+ );
136+ expect(calls).toBe(1);
137+ expect(result.refreshTokenSecretId).toBeNull();
138+ expect(result.tokenType).toBe("Bearer");
139+ expect(result.expiresAt).toBeNull();
140+ expect(result.scope).toBeNull();
141+ });
142+
143+ it("wraps createSecret failure in an OAuth2Error with a descriptive message", async () => {
144+ const exit = await Effect.runPromiseExit(
145+ storeOAuthTokens({
146+ ...baseInput,
147+ tokens: { access_token: "tok", refresh_token: "rtok" },
148+ createSecret: () => Effect.fail(new Error("kv write failed")),
149+ }),
150+ );
151+ expect(Exit.isFailure(exit)).toBe(true);
152+ if (Exit.isFailure(exit)) {
153+ const json = JSON.stringify(exit.cause);
154+ expect(json).toContain("OAuth2Error");
155+ expect(json).toContain("Failed to store access token");
156+ expect(json).toContain("kv write failed");
157+ }
158+ });
159+});
160+
161+// ---------------------------------------------------------------------------
162+// withRefreshedAccessToken
163+// ---------------------------------------------------------------------------
164+
165+describe("withRefreshedAccessToken", () => {
166+ const makeAuth = (overrides: Partial<OAuth2AuthRefs> = {}): OAuth2AuthRefs => ({
167+ clientIdSecretId: "cid-secret",
168+ clientSecretSecretId: "csecret-secret",
169+ accessTokenSecretId: "atok-secret",
170+ refreshTokenSecretId: "rtok-secret",
171+ tokenType: "Bearer",
172+ expiresAt: null,
173+ scopes: ["read", "write"],
174+ ...overrides,
175+ });
176+
177+ const baseInput = {
178+ tokenUrl: "https://api.example.com/oauth/token",
179+ displayName: "Acme",
180+ accessTokenPurpose: "acme_access",
181+ refreshTokenPurpose: "acme_refresh",
182+ };
183+
184+ it("returns the current access token without refreshing when expiresAt is null", async () => {
185+ const { io } = makeSecrets({
186+ "atok-secret": "current-access-token",
187+ "rtok-secret": "stored-refresh",
188+ "cid-secret": "cid",
189+ "csecret-secret": "csecret",
190+ });
191+ const fetchSpy = vi.fn();
192+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
193+ const persistAuth = vi.fn(() => Effect.void);
194+
195+ const result = await Effect.runPromise(
196+ withRefreshedAccessToken({
197+ ...baseInput,
198+ auth: makeAuth({ expiresAt: null }),
199+ secrets: io,
200+ persistAuth,
201+ }),
202+ );
203+ expect(result).toBe("current-access-token");
204+ expect(fetchSpy).not.toHaveBeenCalled();
205+ expect(persistAuth).not.toHaveBeenCalled();
206+ });
207+
208+ it("returns the current access token when expiresAt is comfortably in the future", async () => {
209+ const { io } = makeSecrets({
210+ "atok-secret": "current",
211+ "rtok-secret": "r",
212+ "cid-secret": "c",
213+ "csecret-secret": "s",
214+ });
215+ const fetchSpy = vi.fn();
216+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
217+
218+ const result = await Effect.runPromise(
219+ withRefreshedAccessToken({
220+ ...baseInput,
221+ auth: makeAuth({ expiresAt: Date.now() + 10 * 60_000 }),
222+ secrets: io,
223+ persistAuth: () => Effect.void,
224+ }),
225+ );
226+ expect(result).toBe("current");
227+ expect(fetchSpy).not.toHaveBeenCalled();
228+ });
229+
230+ it("refreshes when expiresAt is within the skew window, persists new token, calls persistAuth", async () => {
231+ const { io, state } = makeSecrets({
232+ "atok-secret": "old-access",
233+ "rtok-secret": "stored-refresh",
234+ "cid-secret": "client-1",
235+ "csecret-secret": "secret-1",
236+ });
237+ const { calls } = captureFetch(
238+ jsonResponse(200, {
239+ access_token: "new-access",
240+ token_type: "Bearer",
241+ expires_in: 3600,
242+ }),
243+ );
244+ const persistedSnapshots: RefreshedAuthSnapshot[] = [];
245+ const persistAuth = (snapshot: RefreshedAuthSnapshot) =>
246+ Effect.sync(() => {
247+ persistedSnapshots.push(snapshot);
248+ });
249+
250+ const result = await Effect.runPromise(
251+ withRefreshedAccessToken({
252+ ...baseInput,
253+ auth: makeAuth({ expiresAt: Date.now() + 30_000 }),
254+ secrets: io,
255+ persistAuth,
256+ }),
257+ );
258+
259+ expect(result).toBe("new-access");
260+ expect(calls).toHaveLength(1);
261+ const body = calls[0]!.init.body as URLSearchParams;
262+ expect(body.get("grant_type")).toBe("refresh_token");
263+ expect(body.get("refresh_token")).toBe("stored-refresh");
264+ expect(body.get("client_id")).toBe("client-1");
265+ expect(body.get("client_secret")).toBe("secret-1");
266+ expect(body.get("scope")).toBe("read write");
267+
268+ // Access-token secret was updated in place with the new value.
269+ expect(state.get("atok-secret")!.value).toBe("new-access");
270+ // persistAuth called with updated snapshot.
271+ expect(persistedSnapshots).toHaveLength(1);
272+ expect(persistedSnapshots[0]!.tokenType).toBe("Bearer");
273+ expect(persistedSnapshots[0]!.expiresAt).toBeGreaterThan(Date.now());
274+ });
275+
276+ it("rotates the refresh_token secret when the server returns a new one", async () => {
277+ const { io, state } = makeSecrets({
278+ "atok-secret": "old",
279+ "rtok-secret": "old-refresh",
280+ "cid-secret": "c",
281+ "csecret-secret": "s",
282+ });
283+ captureFetch(
284+ jsonResponse(200, {
285+ access_token: "new",
286+ refresh_token: "new-refresh",
287+ expires_in: 3600,
288+ }),
289+ );
290+ await Effect.runPromise(
291+ withRefreshedAccessToken({
292+ ...baseInput,
293+ auth: makeAuth({ expiresAt: Date.now() + 30_000 }),
294+ secrets: io,
295+ persistAuth: () => Effect.void,
296+ }),
297+ );
298+ expect(state.get("rtok-secret")!.value).toBe("new-refresh");
299+ });
300+
301+ it("does NOT touch the refresh_token secret when the server omits it", async () => {
302+ const { io, state } = makeSecrets({
303+ "atok-secret": "old",
304+ "rtok-secret": "old-refresh",
305+ "cid-secret": "c",
306+ "csecret-secret": "s",
307+ });
308+ captureFetch(jsonResponse(200, { access_token: "new", expires_in: 3600 }));
309+ await Effect.runPromise(
310+ withRefreshedAccessToken({
311+ ...baseInput,
312+ auth: makeAuth({ expiresAt: Date.now() + 30_000 }),
313+ secrets: io,
314+ persistAuth: () => Effect.void,
315+ }),
316+ );
317+ expect(state.get("rtok-secret")!.value).toBe("old-refresh");
318+ });
319+
320+ it("skips the refresh path entirely when refreshTokenSecretId is null, even if expired", async () => {
321+ const { io } = makeSecrets({
322+ "atok-secret": "only-access",
323+ "cid-secret": "c",
324+ "csecret-secret": "s",
325+ });
326+ const fetchSpy = vi.fn();
327+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
328+ const result = await Effect.runPromise(
329+ withRefreshedAccessToken({
330+ ...baseInput,
331+ auth: makeAuth({
332+ refreshTokenSecretId: null,
333+ expiresAt: Date.now() - 10_000,
334+ }),
335+ secrets: io,
336+ persistAuth: () => Effect.void,
337+ }),
338+ );
339+ expect(result).toBe("only-access");
340+ expect(fetchSpy).not.toHaveBeenCalled();
341+ });
342+
343+ it("omits client_secret from the refresh call when clientSecretSecretId is null", async () => {
344+ const { io } = makeSecrets({
345+ "atok-secret": "old",
346+ "rtok-secret": "rtok",
347+ "cid-secret": "public-client",
348+ });
349+ const { calls } = captureFetch(
350+ jsonResponse(200, { access_token: "new", expires_in: 3600 }),
351+ );
352+ await Effect.runPromise(
353+ withRefreshedAccessToken({
354+ ...baseInput,
355+ auth: makeAuth({
356+ clientSecretSecretId: null,
357+ expiresAt: Date.now() + 30_000,
358+ }),
359+ secrets: io,
360+ persistAuth: () => Effect.void,
361+ }),
362+ );
363+ const body = calls[0]!.init.body as URLSearchParams;
364+ expect(body.get("client_id")).toBe("public-client");
365+ expect(body.has("client_secret")).toBe(false);
366+ });
367+
368+ it("wraps secret-resolve failures in OAuth2Error with descriptive messages", async () => {
369+ const { io, missing } = makeSecrets({
370+ "atok-secret": "old",
371+ "rtok-secret": "rtok",
372+ "cid-secret": "c",
373+ "csecret-secret": "s",
374+ });
375+ missing.add("rtok-secret");
376+ captureFetch(jsonResponse(200, { access_token: "new" }));
377+ const exit = await Effect.runPromiseExit(
378+ withRefreshedAccessToken({
379+ ...baseInput,
380+ auth: makeAuth({ expiresAt: Date.now() + 30_000 }),
381+ secrets: io,
382+ persistAuth: () => Effect.void,
383+ }),
384+ );
385+ expect(Exit.isFailure(exit)).toBe(true);
386+ if (Exit.isFailure(exit)) {
387+ const json = JSON.stringify(exit.cause);
388+ expect(json).toContain("OAuth2Error");
389+ expect(json).toContain("Failed to resolve OAuth refresh token");
390+ }
391+ });
392+
393+ it("uses the default 60s refresh skew window", () => {
394+ expect(OAUTH2_REFRESH_SKEW_MS).toBe(60_000);
395+ });
396+});
packages/plugins/oauth2/src/refresh.ts+238−0
@@ -0,0 +1,238 @@
1+// ---------------------------------------------------------------------------
2+// Generic helpers for storing OAuth token secrets and transparently
3+// refreshing access tokens before a request.
4+//
5+// Plugins (google-discovery, openapi) supply an adapter for their secrets
6+// API and a `persistAuth` callback so this module stays decoupled from any
7+// specific plugin SDK or store shape.
8+// ---------------------------------------------------------------------------
9+
10+import { Effect } from "effect";
11+
12+import { OAuth2Error, type OAuth2TokenResponse } from "./index";
13+import {
14+ OAUTH2_DEFAULT_TIMEOUT_MS,
15+ refreshAccessToken,
16+ shouldRefreshToken,
17+ type ClientAuthMethod,
18+} from "./index";
19+
20+// ---------------------------------------------------------------------------
21+// Secrets I/O adapter — plain string IDs, no plugin types.
22+// ---------------------------------------------------------------------------
23+
24+export type OAuth2SecretsIO = {
25+ readonly resolve: (secretId: string) => Effect.Effect<string, unknown>;
26+ readonly setValue: (input: {
27+ readonly secretId: string;
28+ readonly value: string;
29+ readonly name: string;
30+ readonly purpose: string;
31+ }) => Effect.Effect<void, unknown>;
32+};
33+
34+const wrapSecretError =
35+ (operation: string) =>
36+ (cause: unknown): OAuth2Error =>
37+ new OAuth2Error({
38+ message: `${operation}: ${cause instanceof Error ? cause.message : String(cause)}`,
39+ cause,
40+ });
41+
42+// ---------------------------------------------------------------------------
43+// storeOAuthTokens — freshly store access + refresh tokens as new secrets.
44+// ---------------------------------------------------------------------------
45+
46+/** Shape of a newly persisted OAuth2 auth descriptor. */
47+export type StoredOAuthTokens = {
48+ readonly accessTokenSecretId: string;
49+ readonly refreshTokenSecretId: string | null;
50+ readonly tokenType: string;
51+ readonly expiresAt: number | null;
52+ readonly scope: string | null;
53+};
54+
55+export type StoreOAuthTokensInput = {
56+ readonly tokens: OAuth2TokenResponse;
57+ /** Slug used as prefix for new secret IDs, e.g. `acme_api`. */
58+ readonly slug: string;
59+ /** Human-readable name for the source, used in secret labels. */
60+ readonly displayName: string;
61+ /** `purpose` label stored on the access-token secret. */
62+ readonly accessTokenPurpose: string;
63+ /** `purpose` label stored on the refresh-token secret. */
64+ readonly refreshTokenPurpose: string;
65+ /** Adapter that creates a new secret and returns its freshly-minted ID. */
66+ readonly createSecret: (input: {
67+ readonly idPrefix: string;
68+ readonly name: string;
69+ readonly value: string;
70+ readonly purpose: string;
71+ }) => Effect.Effect<{ readonly id: string }, unknown>;
72+};
73+
74+/**
75+ * Persist access + refresh tokens from an OAuth2 token response as new
76+ * secrets and return a `StoredOAuthTokens` descriptor ready to write to a
77+ * source config.
78+ */
79+export const storeOAuthTokens = (
80+ input: StoreOAuthTokensInput,
81+): Effect.Effect<StoredOAuthTokens, OAuth2Error> =>
82+ Effect.gen(function* () {
83+ const accessRef = yield* input
84+ .createSecret({
85+ idPrefix: `${input.slug}_access_token`,
86+ name: `${input.displayName} Access Token`,
87+ value: input.tokens.access_token,
88+ purpose: input.accessTokenPurpose,
89+ })
90+ .pipe(Effect.mapError(wrapSecretError("Failed to store access token")));
91+
92+ const refreshRef = input.tokens.refresh_token
93+ ? yield* input
94+ .createSecret({
95+ idPrefix: `${input.slug}_refresh_token`,
96+ name: `${input.displayName} Refresh Token`,
97+ value: input.tokens.refresh_token,
98+ purpose: input.refreshTokenPurpose,
99+ })
100+ .pipe(Effect.mapError(wrapSecretError("Failed to store refresh token")))
101+ : null;
102+
103+ return {
104+ accessTokenSecretId: accessRef.id,
105+ refreshTokenSecretId: refreshRef?.id ?? null,
106+ tokenType: input.tokens.token_type ?? "Bearer",
107+ expiresAt:
108+ typeof input.tokens.expires_in === "number"
109+ ? Date.now() + input.tokens.expires_in * 1000
110+ : null,
111+ scope: input.tokens.scope ?? null,
112+ };
113+ });
114+
115+// ---------------------------------------------------------------------------
116+// withRefreshedAccessToken — return the current access token, refreshing
117+// first if it's within the skew window.
118+// ---------------------------------------------------------------------------
119+
120+/** The subset of a stored OAuth2 auth descriptor this helper needs. */
121+export type OAuth2AuthRefs = {
122+ readonly clientIdSecretId: string;
123+ readonly clientSecretSecretId: string | null;
124+ readonly accessTokenSecretId: string;
125+ readonly refreshTokenSecretId: string | null;
126+ readonly tokenType: string;
127+ readonly expiresAt: number | null;
128+ readonly scopes: readonly string[];
129+};
130+
131+/** Snapshot of auth values written back after a successful refresh. */
132+export type RefreshedAuthSnapshot = {
133+ readonly tokenType: string;
134+ readonly expiresAt: number | null;
135+ readonly scope: string | null;
136+};
137+
138+export type WithRefreshedAccessTokenInput = {
139+ readonly auth: OAuth2AuthRefs;
140+ readonly tokenUrl: string;
141+ readonly secrets: OAuth2SecretsIO;
142+ /** Display name used when re-setting the access-token secret after refresh. */
143+ readonly displayName: string;
144+ readonly accessTokenPurpose: string;
145+ readonly refreshTokenPurpose: string;
146+ readonly clientAuth?: ClientAuthMethod;
147+ readonly timeoutMs?: number;
148+ /**
149+ * Called after a successful refresh with the new expiry / tokenType /
150+ * scope so the plugin can write the updated auth back to its source
151+ * config. The accessTokenSecretId does NOT change — the value behind
152+ * it is overwritten in place.
153+ */
154+ readonly persistAuth: (
155+ snapshot: RefreshedAuthSnapshot,
156+ ) => Effect.Effect<void, unknown>;
157+};
158+
159+/**
160+ * Resolve the current access token for a source — if it's within the
161+ * refresh-skew window, first exchange the refresh token for a new access
162+ * token, persist it, and write the updated expiry back via `persistAuth`.
163+ *
164+ * Returns the access-token string to be used for the upcoming request.
165+ */
166+export const withRefreshedAccessToken = (
167+ input: WithRefreshedAccessTokenInput,
168+): Effect.Effect<string, OAuth2Error> =>
169+ Effect.gen(function* () {
170+ const { auth, secrets } = input;
171+ const needsRefresh =
172+ auth.refreshTokenSecretId !== null && shouldRefreshToken({ expiresAt: auth.expiresAt });
173+
174+ if (!needsRefresh) {
175+ return yield* secrets
176+ .resolve(auth.accessTokenSecretId)
177+ .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth access token")));
178+ }
179+
180+ // Proactive refresh path.
181+ const refreshToken = yield* secrets
182+ .resolve(auth.refreshTokenSecretId!)
183+ .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth refresh token")));
184+
185+ const clientId = yield* secrets
186+ .resolve(auth.clientIdSecretId)
187+ .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth client ID")));
188+
189+ const clientSecret =
190+ auth.clientSecretSecretId === null
191+ ? null
192+ : yield* secrets
193+ .resolve(auth.clientSecretSecretId)
194+ .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth client secret")));
195+
196+ const refreshed = yield* refreshAccessToken({
197+ tokenUrl: input.tokenUrl,
198+ clientId,
199+ clientSecret,
200+ refreshToken,
201+ scopes: auth.scopes,
202+ clientAuth: input.clientAuth,
203+ timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS,
204+ });
205+
206+ yield* secrets
207+ .setValue({
208+ secretId: auth.accessTokenSecretId,
209+ value: refreshed.access_token,
210+ name: `${input.displayName} Access Token`,
211+ purpose: input.accessTokenPurpose,
212+ })
213+ .pipe(Effect.mapError(wrapSecretError("Failed to persist refreshed access token")));
214+
215+ if (refreshed.refresh_token && auth.refreshTokenSecretId) {
216+ yield* secrets
217+ .setValue({
218+ secretId: auth.refreshTokenSecretId,
219+ value: refreshed.refresh_token,
220+ name: `${input.displayName} Refresh Token`,
221+ purpose: input.refreshTokenPurpose,
222+ })
223+ .pipe(Effect.mapError(wrapSecretError("Failed to persist rotated refresh token")));
224+ }
225+
226+ yield* input
227+ .persistAuth({
228+ tokenType: refreshed.token_type ?? auth.tokenType,
229+ expiresAt:
230+ typeof refreshed.expires_in === "number"
231+ ? Date.now() + refreshed.expires_in * 1000
232+ : auth.expiresAt,
233+ scope: refreshed.scope ?? null,
234+ })
235+ .pipe(Effect.mapError(wrapSecretError("Failed to persist updated OAuth auth")));
236+
237+ return refreshed.access_token;
238+ });