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).
Unified · Split
| @@ -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"; |
| 21 | export { | 20 | export { |
| 22 | GoogleDiscoveryAuth, | 21 | GoogleDiscoveryAuth, |
| @@ -1,7 +1,9 @@ | |||
| 1 | import { Effect, Layer, Option } from "effect"; | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; | 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; |
| 3 | 3 | ||
| 4 | import { shouldRefreshToken } from "@executor/plugin-oauth2"; | 4 | import { withRefreshedAccessToken } from "@executor/plugin-oauth2"; |
| 5 | |||
| 6 | import { GOOGLE_TOKEN_URL } from "./oauth"; | ||
| 5 | 7 | ||
| 6 | import { | 8 | import { |
| 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"; |
| 22 | import { refreshAccessToken } from "./oauth"; | ||
| 23 | 24 | ||
| 24 | const SAFE_METHODS = new Set(["get", "head", "options"]); | 25 | const 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.secrets | 97 | 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 === null | 111 | input.secrets |
| 126 | ? null | 112 | .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.secrets | 124 | 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 | ||
| 235 | export const annotationsForOperation = ( | 158 | export const annotationsForOperation = ( |
| @@ -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 | ||
| 22 | import { GoogleDiscoveryOAuthError } from "./errors"; | 21 | import { GoogleDiscoveryOAuthError } from "./errors"; |
| 23 | 22 | ||
| 24 | const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; | 23 | 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"; |
| 26 | 25 | ||
| 27 | const GOOGLE_EXTRA_AUTHORIZATION_PARAMS = { | 26 | const 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 | ||
| 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 | ); | ||
| @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { Effect, Option } from "effect"; | 3 | import { Effect, Option } from "effect"; |
| 4 | 4 | ||
| 5 | import { storeOAuthTokens } from "@executor/plugin-oauth2"; | ||
| 6 | |||
| 5 | import { | 7 | import { |
| 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_token | 546 | 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 | }), |
| @@ -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 | |||
| 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"; | ||
| @@ -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 | }); | ||
| @@ -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 | }); | ||