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
16 buildGoogleAuthorizationUrl,16 buildGoogleAuthorizationUrl,
17 createPkceCodeVerifier,17 createPkceCodeVerifier,
18 exchangeAuthorizationCode,18 exchangeAuthorizationCode,
19 refreshAccessToken,
20} from "./oauth";19} from "./oauth";
21export {20export {
22 GoogleDiscoveryAuth,21 GoogleDiscoveryAuth,
packages/plugins/google-discovery/src/sdk/invoke.ts+53−130
@@ -1,7 +1,9 @@
1import { Effect, Layer, Option } from "effect";1import { Effect, Layer, Option } from "effect";
2import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";2import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
3 3
4import { shouldRefreshToken } from "@executor/plugin-oauth2";4import { withRefreshedAccessToken } from "@executor/plugin-oauth2";
5
6import { GOOGLE_TOKEN_URL } from "./oauth";
5 7
6import {8import {
7 type ScopeId,9 type ScopeId,
@@ -19,7 +21,6 @@ import { · 11 unmodified lines
19 GoogleDiscoveryStoredSourceData,21 GoogleDiscoveryStoredSourceData,
20 type GoogleDiscoveryParameter,22 type GoogleDiscoveryParameter,
21} from "./types";23} from "./types";
22import { refreshAccessToken } from "./oauth";
23 24
24const SAFE_METHODS = new Set(["get", "head", "options"]);25const SAFE_METHODS = new Set(["get", "head", "options"]);
25 26
@@ -91,68 +92,57 @@ const resolveOAuthAccessToken = (input: { · 65 unmodified lines
91 }92 }
92 93
93 const auth = input.source.auth;94 const auth = input.source.auth;
94 const needsRefresh =95 const scopeId = input.scopeId;
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 }
110 96
111 const refreshToken = yield* input.secrets97 return yield* withRefreshedAccessToken({
112 .resolve(auth.refreshTokenSecretId as SecretId, input.scopeId)98 auth: {
113 .pipe(99 clientIdSecretId: auth.clientIdSecretId,
114 Effect.mapError(100 clientSecretSecretId: auth.clientSecretSecretId,
115 () =>101 accessTokenSecretId: auth.accessTokenSecretId,
116 new ToolInvocationError({102 refreshTokenSecretId: auth.refreshTokenSecretId,
117 toolId: "" as ToolId,103 tokenType: auth.tokenType,
118 message: "Failed to resolve Google OAuth refresh token",104 expiresAt: auth.expiresAt,
119 cause: undefined,105 scopes: auth.scopes,
120 }),106 },
121 ),107 tokenUrl: GOOGLE_TOKEN_URL,
122 );108 secrets: {
123 109 resolve: (id) => input.secrets.resolve(id as SecretId, scopeId),
124 const clientSecret =110 setValue: ({ secretId, value, name, purpose }) =>
125 auth.clientSecretSecretId === null111 input.secrets
126 ? null112 .set({
127 : yield* input.secrets.resolve(auth.clientSecretSecretId as SecretId, input.scopeId).pipe(113 id: secretId as SecretId,
128 Effect.mapError(114 scopeId,
129 () =>115 name,
130 new ToolInvocationError({116 value,
131 toolId: "" as ToolId,117 purpose,
132 message: "Failed to resolve Google OAuth client secret",118 })
133 cause: undefined,119 .pipe(Effect.asVoid),
134 }),120 },
135 ),121 displayName: input.source.name,
136 );122 accessTokenPurpose: "google_oauth_access_token",
137 123 refreshTokenPurpose: "google_oauth_refresh_token",
138 const clientId = yield* input.secrets124 persistAuth: (snapshot) =>
139 .resolve(auth.clientIdSecretId as SecretId, input.scopeId)125 Effect.gen(function* () {
140 .pipe(126 const updatedSource = new GoogleDiscoveryStoredSourceData({
141 Effect.mapError(127 ...input.source,
142 () =>128 auth: {
143 new ToolInvocationError({129 kind: "oauth2",
144 toolId: "" as ToolId,130 clientIdSecretId: auth.clientIdSecretId,
145 message: "Failed to resolve Google OAuth client ID",131 clientSecretSecretId: auth.clientSecretSecretId,
146 cause: undefined,132 accessTokenSecretId: auth.accessTokenSecretId,
147 }),133 refreshTokenSecretId: auth.refreshTokenSecretId,
148 ),134 tokenType: snapshot.tokenType,
149 );135 expiresAt: snapshot.expiresAt,
150 136 scope: snapshot.scope ?? auth.scope,
151 const refreshed = yield* refreshAccessToken({137 scopes: auth.scopes,
152 clientId,138 },
153 clientSecret,139 });
154 refreshToken,140 yield* input.bindingStore.putSource({
155 scopes: auth.scopes,141 namespace: input.sourceId,
142 name: input.source.name,
143 config: updatedSource,
144 });
145 }),
156 }).pipe(146 }).pipe(
157 Effect.mapError(147 Effect.mapError(
158 (error) =>148 (error) =>
@@ -163,73 +153,6 @@ const resolveOAuthAccessToken = (input: { · 4 unmodified lines
163 }),153 }),
164 ),154 ),
165 );155 );
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;
233 });156 });
234 157
235export const annotationsForOperation = (158export const annotationsForOperation = (
packages/plugins/google-discovery/src/sdk/oauth.ts+1−17
@@ -15,14 +15,13 @@ import { · 14 unmodified lines
15 buildAuthorizationUrl,15 buildAuthorizationUrl,
16 createPkceCodeVerifier as sharedCreatePkceCodeVerifier,16 createPkceCodeVerifier as sharedCreatePkceCodeVerifier,
17 exchangeAuthorizationCode as sharedExchangeAuthorizationCode,17 exchangeAuthorizationCode as sharedExchangeAuthorizationCode,
18 refreshAccessToken as sharedRefreshAccessToken,
19 type OAuth2TokenResponse,18 type OAuth2TokenResponse,
20} from "@executor/plugin-oauth2";19} from "@executor/plugin-oauth2";
21 20
22import { GoogleDiscoveryOAuthError } from "./errors";21import { GoogleDiscoveryOAuthError } from "./errors";
23 22
24const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";23const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
25const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";24export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
26 25
27const GOOGLE_EXTRA_AUTHORIZATION_PARAMS = {26const GOOGLE_EXTRA_AUTHORIZATION_PARAMS = {
28 access_type: "offline",27 access_type: "offline",
@@ -74,18 +73,3 @@ export const exchangeAuthorizationCode = (input: { · 45 unmodified lines
74 }),73 }),
75 );74 );
76 75
77export 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
2 2
3import { Effect, Option } from "effect";3import { Effect, Option } from "effect";
4 4
5import { storeOAuthTokens } from "@executor/plugin-oauth2";
6
5import {7import {
6 Source,8 Source,
7 SourceDetectionResult,9 SourceDetectionResult,
@@ -535,32 +537,29 @@ export const googleDiscoveryPlugin = (options?: { · 527 unmodified lines
535 code: input.code,537 code: input.code,
536 });538 });
537 539
538 const accessTokenRef = yield* storeSecret(ctx, {540 const stored = yield* storeOAuthTokens({
539 idPrefix: `${normalizeSlug(session.name)}_google_access_token`,541 tokens: tokenResponse,
540 name: `${session.name} Access Token`,542 slug: `${normalizeSlug(session.name)}_google`,
541 value: tokenResponse.access_token,543 displayName: session.name,
542 purpose: "google_oauth_access_token",544 accessTokenPurpose: "google_oauth_access_token",
543 });545 refreshTokenPurpose: "google_oauth_refresh_token",
544 const refreshTokenRef = tokenResponse.refresh_token546 createSecret: (args) =>
545 ? yield* storeSecret(ctx, {547 storeSecret(ctx, args).pipe(Effect.map((ref) => ({ id: ref.id as string }))),
546 idPrefix: `${normalizeSlug(session.name)}_google_refresh_token`,548 }).pipe(
547 name: `${session.name} Refresh Token`,549 Effect.mapError(
548 value: tokenResponse.refresh_token,550 (error) => new GoogleDiscoveryOAuthError({ message: error.message }),
549 purpose: "google_oauth_refresh_token",551 ),
550 })552 );
551 : null;553
552 return {554 return {
553 kind: "oauth2" as const,555 kind: "oauth2" as const,
554 clientIdSecretId: session.clientIdSecretId,556 clientIdSecretId: session.clientIdSecretId,
555 clientSecretSecretId: session.clientSecretSecretId,557 clientSecretSecretId: session.clientSecretSecretId,
556 accessTokenSecretId: accessTokenRef.id,558 accessTokenSecretId: stored.accessTokenSecretId,
557 refreshTokenSecretId: refreshTokenRef?.id ?? null,559 refreshTokenSecretId: stored.refreshTokenSecretId,
558 tokenType: tokenResponse.token_type ?? "Bearer",560 tokenType: stored.tokenType,
559 expiresAt:561 expiresAt: stored.expiresAt,
560 typeof tokenResponse.expires_in === "number"562 scope: stored.scope,
561 ? Date.now() + tokenResponse.expires_in * 1000
562 : null,
563 scope: tokenResponse.scope ?? null,
564 scopes: [...session.scopes],563 scopes: [...session.scopes],
565 };564 };
566 }),565 }),
packages/plugins/oauth2/src/index.ts+15−0
@@ -366,3 +366,18 @@ export const shouldRefreshToken = (input: { · 365 unmodified lines
366 const skew = input.skewMs ?? OAUTH2_REFRESH_SKEW_MS;366 const skew = input.skewMs ?? OAUTH2_REFRESH_SKEW_MS;
367 return input.expiresAt <= now + skew;367 return input.expiresAt <= now + skew;
368};368};
369
370// ---------------------------------------------------------------------------
371// Re-exports from sibling modules
372// ---------------------------------------------------------------------------
373
374export {
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
8import { afterEach, describe, expect, it, vi } from "vitest";
9import { Effect, Exit } from "effect";
10
11import {
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
24const 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
55const originalFetch = globalThis.fetch;
56
57const 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
70const jsonResponse = (status: number, body: unknown): Response =>
71 new Response(JSON.stringify(body), {
72 status,
73 headers: { "content-type": "application/json" },
74 });
75
76afterEach(() => {
77 globalThis.fetch = originalFetch;
78});
79
80// ---------------------------------------------------------------------------
81// storeOAuthTokens
82// ---------------------------------------------------------------------------
83
84describe("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
165describe("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
10import { Effect } from "effect";
11
12import { OAuth2Error, type OAuth2TokenResponse } from "./index";
13import {
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
24export 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
34const 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. */
47export 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
55export 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 */
79export 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. */
121export 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. */
132export type RefreshedAuthSnapshot = {
133 readonly tokenType: string;
134 readonly expiresAt: number | null;
135 readonly scope: string | null;
136};
137
138export 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 */
166export 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 });