History

Extract @executor/plugin-oauth2 shared helpers (#247)

## Summary - New `@executor/plugin-oauth2` package: generic, provider-agnostic OAuth 2.0 helpers (PKCE, authorization URL, code exchange, refresh, refresh-skew predicate, typed `OAuth2Error`). - Ports `google-discovery` to use the shared package — its `oauth.ts` shrinks from ~150 lines to ~85, with Google-specific behavior (`access_type=offline`, `prompt=consent`, `include_granted_scopes=true`, hardcoded URLs) preserved via the `extraParams` escape hatch and a thin wrapper. - 33-test fidelity suite encodes every real-world quirk the prior implementation handled, so future "simplifications" fail loudly. ## Why We were about to copy this code a third time for OpenAPI L3 OAuth onboarding. Extracting it now keeps the call sites consistent and gives us one place to fix bugs. The fidelity suite is the contract — the goal of extraction was zero behavioral change in `google-discovery`, and the existing google-discovery tests verify that. ## Fidelity behaviors locked in by tests - **PKCE**: 48-byte verifier matches `^[A-Za-z0-9_-]{43,128}$`, RFC 7636 Appendix A test vector for `S256(verifier) == challenge`, uniqueness across 50 invocations. - **`decodeTokenResponse`**: rejects non-JSON bodies (HTML 5xx pages), rejects JSON arrays/primitives, accepts `expires_in` as **string OR number** (Azure et al.), error fallback chain (`error_description` → `error` → `status N`), rejects 200 with empty / missing `access_token`. - **Token endpoint POST**: `application/x-www-form-urlencoded` + `accept: application/json`, `AbortSignal.timeout(20_000)`, body-based client_secret by default, optional HTTP Basic auth (`clientAuth: "basic"`) for Stripe-style providers, `OAuth2Error` propagation through the Effect failure channel. - **`buildAuthorizationUrl`**: standard PKCE params, custom scope separator (comma for legacy providers), `extraParams` merge does not drop standard params, preserves pre-existing query params on the authorization URL. - **`refreshAccessToken`**: `grant_type=refresh_token`, scope omitted when empty, included when provided. - **`shouldRefreshToken`**: `null` expiry never refreshes, 60s default skew, custom skew respected. ## Test plan - [x] `bunx vitest run` in `packages/plugins/oauth2` → 33 passed - [x] `bunx vitest run` in `packages/plugins/google-discovery` → 6 passed (existing regression gate) - [x] `bunx turbo run typecheck` → 29 packages clean - [x] `bunx turbo run test` → all 23 task suites pass

Rhys Sullivan committed 7 months ago

Commit 3bcfc2f1 parent b3b03be10 files · +1067 −124Browse files

Unified · Split

10 files
  1. bun.lock +17 −0
  2. packages/plugins/google-discovery/package.json +1 −0
  3. packages/plugins/google-discovery/src/sdk/invoke.ts +3 −5
  4. packages/plugins/google-discovery/src/sdk/oauth.ts +59 −119
  5. packages/plugins/oauth2/package.json +49 −0
  6. packages/plugins/oauth2/src/index.test.ts +531 −0
  7. packages/plugins/oauth2/src/index.ts +368 −0
  8. packages/plugins/oauth2/tsconfig.json +20 −0
  9. packages/plugins/oauth2/tsup.config.ts +12 −0
  10. packages/plugins/oauth2/vitest.config.ts +7 −0
bun.lock+17−0
@@ -427,6 +427,7 @@ · 426 unmodified lines
427427 "dependencies": {
428428 "@effect/platform": "catalog:",
429429 "@executor/api": "workspace:*",
430+ "@executor/plugin-oauth2": "workspace:*",
430431 "@executor/sdk": "workspace:*",
431432 "effect": "catalog:",
432433 },
@@ -545,6 +546,20 @@ · 112 unmodified lines
545546 "react",
546547 ],
547548 },
549+ "packages/plugins/oauth2": {
550+ "name": "@executor/plugin-oauth2",
551+ "version": "0.0.1",
552+ "dependencies": {
553+ "effect": "catalog:",
554+ },
555+ "devDependencies": {
556+ "@effect/vitest": "catalog:",
557+ "@types/node": "catalog:",
558+ "bun-types": "catalog:",
559+ "tsup": "catalog:",
560+ "vitest": "catalog:",
561+ },
562+ },
548563 "packages/plugins/onepassword": {
549564 "name": "@executor/plugin-onepassword",
550565 "version": "0.0.1",
@@ -1130,6 +1145,8 @@ · 579 unmodified lines
11301145
11311146 "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"],
11321147
1148+ "@executor/plugin-oauth2": ["@executor/plugin-oauth2@workspace:packages/plugins/oauth2"],
1149+
11331150 "@executor/plugin-onepassword": ["@executor/plugin-onepassword@workspace:packages/plugins/onepassword"],
11341151
11351152 "@executor/plugin-openapi": ["@executor/plugin-openapi@workspace:packages/plugins/openapi"],
packages/plugins/google-discovery/package.json+1−0
@@ -49,6 +49,7 @@ · 48 unmodified lines
4949 "dependencies": {
5050 "@effect/platform": "catalog:",
5151 "@executor/api": "workspace:*",
52+ "@executor/plugin-oauth2": "workspace:*",
5253 "@executor/sdk": "workspace:*",
5354 "effect": "catalog:"
5455 },
packages/plugins/google-discovery/src/sdk/invoke.ts+3−5
@@ -1,6 +1,8 @@
11 import { Effect, Layer, Option } from "effect";
22 import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
33
4+import { shouldRefreshToken } from "@executor/plugin-oauth2";
5+
46 import {
57 type ScopeId,
68 type SecretId,
@@ -19,8 +21,6 @@ import { · 12 unmodified lines
1921 } from "./types";
2022 import { refreshAccessToken } from "./oauth";
2123
22−const OAUTH_REFRESH_SKEW_MS = 60_000;
23−
2424 const SAFE_METHODS = new Set(["get", "head", "options"]);
2525
2626 const stringValuesFromParameter = (value: unknown, repeated: boolean): string[] => {
@@ -91,11 +91,9 @@ const resolveOAuthAccessToken = (input: { · 64 unmodified lines
9191 }
9292
9393 const auth = input.source.auth;
94− const now = Date.now();
9594 const needsRefresh =
9695 auth.refreshTokenSecretId !== null &&
97− auth.expiresAt !== null &&
98− auth.expiresAt <= now + OAUTH_REFRESH_SKEW_MS;
96+ shouldRefreshToken({ expiresAt: auth.expiresAt });
9997
10098 if (!needsRefresh) {
10199 return yield* input.secrets.resolve(auth.accessTokenSecretId as SecretId, input.scopeId).pipe(
packages/plugins/google-discovery/src/sdk/oauth.ts+59−119
@@ -1,24 +1,38 @@
1−import { createHash, randomBytes } from "node:crypto";
1+// ---------------------------------------------------------------------------
2+// Google-specific thin wrapper over @executor/plugin-oauth2.
3+//
4+// All standards-compliant OAuth 2.0 logic lives in the shared package. This
5+// file only carries the bits Google needs that are NOT in the spec:
6+// - Hardcoded authorization URL (accounts.google.com/o/oauth2/v2/auth)
7+// - access_type=offline — required to receive a refresh_token
8+// - prompt=consent — forces re-consent so refresh_token is reissued
9+// - include_granted_scopes=true — Google's incremental authorization
10+// ---------------------------------------------------------------------------
211
312 import { Effect } from "effect";
413
14+import {
15+ buildAuthorizationUrl,
16+ createPkceCodeVerifier as sharedCreatePkceCodeVerifier,
17+ exchangeAuthorizationCode as sharedExchangeAuthorizationCode,
18+ refreshAccessToken as sharedRefreshAccessToken,
19+ type OAuth2TokenResponse,
20+} from "@executor/plugin-oauth2";
21+
522 import { GoogleDiscoveryOAuthError } from "./errors";
623
7−export type OAuth2TokenResponse = {
8− readonly access_token: string;
9− readonly token_type?: string;
10− readonly refresh_token?: string;
11− readonly expires_in?: number;
12− readonly scope?: string;
13−};
24+const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
25+const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
1426
15−const encodeBase64Url = (input: Buffer): string =>
16− input.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
27+const GOOGLE_EXTRA_AUTHORIZATION_PARAMS = {
28+ access_type: "offline",
29+ include_granted_scopes: "true",
30+ prompt: "consent",
31+} as const;
1732
18−export const createPkceCodeVerifier = (): string => encodeBase64Url(randomBytes(48));
33+export type { OAuth2TokenResponse as OAuth2TokenResponse };
1934
20−const createPkceCodeChallenge = (verifier: string): string =>
21− encodeBase64Url(createHash("sha256").update(verifier).digest());
35+export const createPkceCodeVerifier = sharedCreatePkceCodeVerifier;
2236
2337 export const buildGoogleAuthorizationUrl = (input: {
2438 readonly clientId: string;
@@ -26,88 +40,22 @@ export const buildGoogleAuthorizationUrl = (input: { · 1 unmodified line
2640 readonly scopes: readonly string[];
2741 readonly state: string;
2842 readonly codeVerifier: string;
29−}): string => {
30− const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
31− url.searchParams.set("client_id", input.clientId);
32− url.searchParams.set("redirect_uri", input.redirectUrl);
33− url.searchParams.set("response_type", "code");
34− url.searchParams.set("scope", input.scopes.join(" "));
35− url.searchParams.set("state", input.state);
36− url.searchParams.set("code_challenge_method", "S256");
37− url.searchParams.set("code_challenge", createPkceCodeChallenge(input.codeVerifier));
38− url.searchParams.set("access_type", "offline");
39− url.searchParams.set("include_granted_scopes", "true");
40− url.searchParams.set("prompt", "consent");
41− return url.toString();
42−};
43−
44−const decodeTokenResponse = async (response: Response): Promise<OAuth2TokenResponse> => {
45− const rawText = await response.text();
46− let parsed: unknown;
47− try {
48− parsed = JSON.parse(rawText);
49− } catch {
50− throw new Error(`OAuth token endpoint returned non-JSON response (${response.status})`);
51− }
52−
53− if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
54− throw new Error(`OAuth token endpoint returned invalid JSON payload (${response.status})`);
55− }
56−
57− const record = parsed as Record<string, unknown>;
58− const accessToken =
59− typeof record.access_token === "string" && record.access_token.length > 0
60− ? record.access_token
61− : null;
62−
63− if (!response.ok) {
64− const description =
65− typeof record.error_description === "string"
66− ? record.error_description
67− : typeof record.error === "string"
68− ? record.error
69− : `status ${response.status}`;
70− throw new Error(`OAuth token exchange failed: ${description}`);
71− }
72−
73− if (accessToken === null) {
74− throw new Error("OAuth token endpoint did not return an access_token");
75− }
76−
77− return {
78− access_token: accessToken,
79− token_type: typeof record.token_type === "string" ? record.token_type : undefined,
80− refresh_token: typeof record.refresh_token === "string" ? record.refresh_token : undefined,
81− expires_in:
82− typeof record.expires_in === "number"
83− ? record.expires_in
84− : typeof record.expires_in === "string"
85− ? Number(record.expires_in)
86− : undefined,
87− scope: typeof record.scope === "string" ? record.scope : undefined,
88− };
89−};
90−
91−const postToTokenEndpoint = (body: URLSearchParams) =>
92− Effect.tryPromise({
93− try: async () => {
94− const response = await fetch("https://oauth2.googleapis.com/token", {
95− method: "POST",
96− headers: {
97− "content-type": "application/x-www-form-urlencoded",
98− accept: "application/json",
99− },
100− body,
101− signal: AbortSignal.timeout(20_000),
102− });
103− return decodeTokenResponse(response);
104− },
105− catch: (cause) =>
106− new GoogleDiscoveryOAuthError({
107− message: cause instanceof Error ? cause.message : String(cause),
108− }),
43+}): string =>
44+ buildAuthorizationUrl({
45+ authorizationUrl: GOOGLE_AUTHORIZATION_URL,
46+ clientId: input.clientId,
47+ redirectUrl: input.redirectUrl,
48+ scopes: input.scopes,
49+ state: input.state,
50+ codeVerifier: input.codeVerifier,
51+ extraParams: GOOGLE_EXTRA_AUTHORIZATION_PARAMS,
10952 });
11053
54+const wrapError = <A>(
55+ effect: Effect.Effect<A, { readonly message: string }>,
56+): Effect.Effect<A, GoogleDiscoveryOAuthError> =>
57+ Effect.mapError(effect, (error) => new GoogleDiscoveryOAuthError({ message: error.message }));
58+
11159 export const exchangeAuthorizationCode = (input: {
11260 readonly clientId: string;
11361 readonly clientSecret?: string | null;
@@ -115,19 +63,16 @@ export const exchangeAuthorizationCode = (input: { · 1 unmodified line
11563 readonly codeVerifier: string;
11664 readonly code: string;
11765 }): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>
118− Effect.gen(function* () {
119− const body = new URLSearchParams({
120− grant_type: "authorization_code",
121− client_id: input.clientId,
122− redirect_uri: input.redirectUrl,
123− code_verifier: input.codeVerifier,
66+ wrapError(
67+ sharedExchangeAuthorizationCode({
68+ tokenUrl: GOOGLE_TOKEN_URL,
69+ clientId: input.clientId,
70+ clientSecret: input.clientSecret,
71+ redirectUrl: input.redirectUrl,
72+ codeVerifier: input.codeVerifier,
12473 code: input.code,
125− });
126− if (input.clientSecret) {
127− body.set("client_secret", input.clientSecret);
128− }
129− return yield* postToTokenEndpoint(body);
130− });
74+ }),
75+ );
13176
13277 export const refreshAccessToken = (input: {
13378 readonly clientId: string;
@@ -135,17 +80,12 @@ export const refreshAccessToken = (input: { · 1 unmodified line
13580 readonly refreshToken: string;
13681 readonly scopes?: readonly string[];
13782 }): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>
138− Effect.gen(function* () {
139− const body = new URLSearchParams({
140− grant_type: "refresh_token",
141− client_id: input.clientId,
142− refresh_token: input.refreshToken,
143− });
144− if (input.clientSecret) {
145− body.set("client_secret", input.clientSecret);
146− }
147− if (input.scopes && input.scopes.length > 0) {
148− body.set("scope", input.scopes.join(" "));
149− }
150− return yield* postToTokenEndpoint(body);
151− });
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/oauth2/package.json+49−0
@@ -0,0 +1,49 @@
1+{
2+ "name": "@executor/plugin-oauth2",
3+ "version": "0.0.1",
4+ "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/oauth2",
5+ "bugs": {
6+ "url": "https://github.com/RhysSullivan/executor/issues"
7+ },
8+ "license": "MIT",
9+ "repository": {
10+ "type": "git",
11+ "url": "git+https://github.com/RhysSullivan/executor.git",
12+ "directory": "packages/plugins/oauth2"
13+ },
14+ "files": [
15+ "dist"
16+ ],
17+ "type": "module",
18+ "exports": {
19+ ".": "./src/index.ts"
20+ },
21+ "publishConfig": {
22+ "access": "public",
23+ "exports": {
24+ ".": {
25+ "import": {
26+ "types": "./dist/index.d.ts",
27+ "default": "./dist/index.js"
28+ }
29+ }
30+ }
31+ },
32+ "scripts": {
33+ "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)",
34+ "typecheck": "tsgo --noEmit",
35+ "test": "vitest run",
36+ "test:watch": "vitest",
37+ "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
38+ },
39+ "dependencies": {
40+ "effect": "catalog:"
41+ },
42+ "devDependencies": {
43+ "@effect/vitest": "catalog:",
44+ "@types/node": "catalog:",
45+ "bun-types": "catalog:",
46+ "tsup": "catalog:",
47+ "vitest": "catalog:"
48+ }
49+}
packages/plugins/oauth2/src/index.test.ts+531−0
@@ -0,0 +1,531 @@
1+// ---------------------------------------------------------------------------
2+// Fidelity suite — locks in every edge case the prior hand-rolled
3+// google-discovery oauth.ts handled, so future "simplifications" of the
4+// shared helpers fail loudly instead of silently breaking refresh / parsing /
5+// provider-specific quirks.
6+// ---------------------------------------------------------------------------
7+
8+import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
9+import { Cause, Chunk, Effect, Exit } from "effect";
10+
11+import {
12+ OAUTH2_DEFAULT_TIMEOUT_MS,
13+ OAUTH2_REFRESH_SKEW_MS,
14+ OAuth2Error,
15+ buildAuthorizationUrl,
16+ createPkceCodeChallenge,
17+ createPkceCodeVerifier,
18+ decodeTokenResponse,
19+ exchangeAuthorizationCode,
20+ refreshAccessToken,
21+ shouldRefreshToken,
22+} from "./index";
23+
24+const jsonResponse = (status: number, body: unknown): Response =>
25+ new Response(JSON.stringify(body), {
26+ status,
27+ headers: { "content-type": "application/json" },
28+ });
29+
30+const textResponse = (status: number, body: string): Response =>
31+ new Response(body, { status, headers: { "content-type": "text/html" } });
32+
33+// ---------------------------------------------------------------------------
34+// PKCE
35+// ---------------------------------------------------------------------------
36+
37+describe("PKCE", () => {
38+ it("createPkceCodeVerifier returns a base64url string in the RFC 7636 length range", () => {
39+ for (let i = 0; i < 25; i++) {
40+ const verifier = createPkceCodeVerifier();
41+ expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
42+ expect(verifier.length).toBeGreaterThanOrEqual(43);
43+ expect(verifier.length).toBeLessThanOrEqual(128);
44+ }
45+ });
46+
47+ it("createPkceCodeChallenge matches the RFC 7636 Appendix A test vector", () => {
48+ // RFC 7636 §4.2 test vector
49+ const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
50+ const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
51+ expect(createPkceCodeChallenge(verifier)).toBe(expected);
52+ });
53+
54+ it("createPkceCodeVerifier produces unique values", () => {
55+ const seen = new Set<string>();
56+ for (let i = 0; i < 50; i++) seen.add(createPkceCodeVerifier());
57+ expect(seen.size).toBe(50);
58+ });
59+});
60+
61+// ---------------------------------------------------------------------------
62+// buildAuthorizationUrl
63+// ---------------------------------------------------------------------------
64+
65+describe("buildAuthorizationUrl", () => {
66+ const baseInput = {
67+ authorizationUrl: "https://example.com/authorize",
68+ clientId: "client-123",
69+ redirectUrl: "https://app.example.com/callback",
70+ scopes: ["read", "write"] as const,
71+ state: "state-abc",
72+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
73+ };
74+
75+ it("emits all RFC 6749 + PKCE params", () => {
76+ const url = new URL(buildAuthorizationUrl(baseInput));
77+ expect(url.origin + url.pathname).toBe("https://example.com/authorize");
78+ expect(url.searchParams.get("client_id")).toBe("client-123");
79+ expect(url.searchParams.get("redirect_uri")).toBe("https://app.example.com/callback");
80+ expect(url.searchParams.get("response_type")).toBe("code");
81+ expect(url.searchParams.get("scope")).toBe("read write");
82+ expect(url.searchParams.get("state")).toBe("state-abc");
83+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
84+ expect(url.searchParams.get("code_challenge")).toBe(
85+ "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
86+ );
87+ });
88+
89+ it("supports a custom scope separator (e.g. comma for legacy providers)", () => {
90+ const url = new URL(buildAuthorizationUrl({ ...baseInput, scopeSeparator: "," }));
91+ expect(url.searchParams.get("scope")).toBe("read,write");
92+ });
93+
94+ it("merges Google-style extra params without dropping them", () => {
95+ const url = new URL(
96+ buildAuthorizationUrl({
97+ ...baseInput,
98+ extraParams: {
99+ access_type: "offline",
100+ prompt: "consent",
101+ include_granted_scopes: "true",
102+ },
103+ }),
104+ );
105+ expect(url.searchParams.get("access_type")).toBe("offline");
106+ expect(url.searchParams.get("prompt")).toBe("consent");
107+ expect(url.searchParams.get("include_granted_scopes")).toBe("true");
108+ // Standard params are still present.
109+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
110+ });
111+
112+ it("preserves pre-existing query params on the authorization URL", () => {
113+ const url = new URL(
114+ buildAuthorizationUrl({ ...baseInput, authorizationUrl: "https://example.com/auth?tenant=acme" }),
115+ );
116+ expect(url.searchParams.get("tenant")).toBe("acme");
117+ expect(url.searchParams.get("client_id")).toBe("client-123");
118+ });
119+});
120+
121+// ---------------------------------------------------------------------------
122+// decodeTokenResponse
123+// ---------------------------------------------------------------------------
124+
125+describe("decodeTokenResponse", () => {
126+ /** Assert the decode effect fails with an OAuth2Error whose message matches `pattern`. */
127+ const expectOAuth2ErrorMatching = (
128+ exit: Exit.Exit<unknown, OAuth2Error>,
129+ pattern: RegExp | string,
130+ ) => {
131+ expect(Exit.isFailure(exit)).toBe(true);
132+ if (!Exit.isFailure(exit)) return;
133+ const failures = Chunk.toReadonlyArray(Cause.failures(exit.cause));
134+ expect(failures.length).toBeGreaterThan(0);
135+ const error = failures[0]!;
136+ expect(error).toBeInstanceOf(OAuth2Error);
137+ if (typeof pattern === "string") {
138+ expect(error.message).toContain(pattern);
139+ } else {
140+ expect(error.message).toMatch(pattern);
141+ }
142+ };
143+
144+ it.effect("parses a minimal successful response", () =>
145+ Effect.gen(function* () {
146+ const result = yield* decodeTokenResponse(
147+ jsonResponse(200, { access_token: "tok" }),
148+ );
149+ expect(result.access_token).toBe("tok");
150+ expect(result.token_type).toBeUndefined();
151+ expect(result.expires_in).toBeUndefined();
152+ }),
153+ );
154+
155+ it.effect("parses a fully populated successful response", () =>
156+ Effect.gen(function* () {
157+ const result = yield* decodeTokenResponse(
158+ jsonResponse(200, {
159+ access_token: "tok",
160+ token_type: "Bearer",
161+ refresh_token: "rtok",
162+ expires_in: 3600,
163+ scope: "read write",
164+ }),
165+ );
166+ expect(result).toEqual({
167+ access_token: "tok",
168+ token_type: "Bearer",
169+ refresh_token: "rtok",
170+ expires_in: 3600,
171+ scope: "read write",
172+ });
173+ }),
174+ );
175+
176+ it.effect("accepts expires_in as a string (Azure / older providers)", () =>
177+ Effect.gen(function* () {
178+ const result = yield* decodeTokenResponse(
179+ jsonResponse(200, { access_token: "tok", expires_in: "3600" }),
180+ );
181+ expect(result.expires_in).toBe(3600);
182+ }),
183+ );
184+
185+ it.effect("fails with OAuth2Error on non-JSON bodies (HTML error pages from proxies / 5xx)", () =>
186+ Effect.gen(function* () {
187+ const exit = yield* Effect.exit(
188+ decodeTokenResponse(textResponse(502, "<html>bad gateway</html>")),
189+ );
190+ expectOAuth2ErrorMatching(exit, /non-JSON response \(502\)/);
191+ }),
192+ );
193+
194+ it.effect("fails with OAuth2Error on JSON arrays / non-object payloads", () =>
195+ Effect.gen(function* () {
196+ const exit = yield* Effect.exit(
197+ decodeTokenResponse(jsonResponse(200, ["not", "an", "object"])),
198+ );
199+ expectOAuth2ErrorMatching(exit, /invalid JSON payload \(200\)/);
200+ }),
201+ );
202+
203+ it.effect("uses error_description from RFC 6749 error responses", () =>
204+ Effect.gen(function* () {
205+ const exit = yield* Effect.exit(
206+ decodeTokenResponse(
207+ jsonResponse(400, {
208+ error: "invalid_grant",
209+ error_description: "The provided authorization grant is invalid",
210+ }),
211+ ),
212+ );
213+ expectOAuth2ErrorMatching(
214+ exit,
215+ "OAuth token exchange failed: The provided authorization grant is invalid",
216+ );
217+ }),
218+ );
219+
220+ it.effect("falls back to error code when error_description is absent", () =>
221+ Effect.gen(function* () {
222+ const exit = yield* Effect.exit(
223+ decodeTokenResponse(jsonResponse(400, { error: "invalid_grant" })),
224+ );
225+ expectOAuth2ErrorMatching(exit, "OAuth token exchange failed: invalid_grant");
226+ }),
227+ );
228+
229+ it.effect("falls back to status N when no error fields are present", () =>
230+ Effect.gen(function* () {
231+ const exit = yield* Effect.exit(decodeTokenResponse(jsonResponse(500, {})));
232+ expectOAuth2ErrorMatching(exit, "OAuth token exchange failed: status 500");
233+ }),
234+ );
235+
236+ it.effect("fails with OAuth2Error on 200 responses with an empty access_token", () =>
237+ Effect.gen(function* () {
238+ const exit = yield* Effect.exit(
239+ decodeTokenResponse(jsonResponse(200, { access_token: "" })),
240+ );
241+ expectOAuth2ErrorMatching(exit, /did not return an access_token/);
242+ }),
243+ );
244+
245+ it.effect("fails with OAuth2Error on 200 responses with no access_token field", () =>
246+ Effect.gen(function* () {
247+ const exit = yield* Effect.exit(
248+ decodeTokenResponse(jsonResponse(200, { token_type: "Bearer" })),
249+ );
250+ expectOAuth2ErrorMatching(exit, /did not return an access_token/);
251+ }),
252+ );
253+});
254+
255+// ---------------------------------------------------------------------------
256+// exchangeAuthorizationCode / refreshAccessToken — request shape
257+// ---------------------------------------------------------------------------
258+
259+type FetchArgs = { url: string; init: RequestInit };
260+
261+const captureFetch = (response: Response): { calls: FetchArgs[] } => {
262+ const calls: FetchArgs[] = [];
263+ globalThis.fetch = vi
264+ .fn()
265+ .mockImplementation(async (url: string, init: RequestInit) => {
266+ calls.push({ url, init });
267+ return response;
268+ }) as unknown as typeof fetch;
269+ return { calls };
270+};
271+
272+const originalFetch = globalThis.fetch;
273+
274+describe("exchangeAuthorizationCode", () => {
275+ afterEach(() => {
276+ globalThis.fetch = originalFetch;
277+ });
278+
279+ const validBody = {
280+ access_token: "tok",
281+ token_type: "Bearer",
282+ refresh_token: "rtok",
283+ expires_in: 3600,
284+ scope: "read",
285+ };
286+
287+ it("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", async () => {
288+ const { calls } = captureFetch(jsonResponse(200, validBody));
289+ const result = await Effect.runPromise(
290+ exchangeAuthorizationCode({
291+ tokenUrl: "https://example.com/token",
292+ clientId: "cid",
293+ clientSecret: "csecret",
294+ redirectUrl: "https://app.example.com/cb",
295+ codeVerifier: "verifier",
296+ code: "abc",
297+ }),
298+ );
299+ expect(result.access_token).toBe("tok");
300+ expect(calls).toHaveLength(1);
301+ const call = calls[0]!;
302+ expect(call.url).toBe("https://example.com/token");
303+ expect(call.init.method).toBe("POST");
304+ const headers = call.init.headers as Record<string, string>;
305+ expect(headers["content-type"]).toBe("application/x-www-form-urlencoded");
306+ expect(headers["accept"]).toBe("application/json");
307+ const body = call.init.body as URLSearchParams;
308+ expect(body.get("grant_type")).toBe("authorization_code");
309+ expect(body.get("client_id")).toBe("cid");
310+ expect(body.get("client_secret")).toBe("csecret");
311+ expect(body.get("redirect_uri")).toBe("https://app.example.com/cb");
312+ expect(body.get("code_verifier")).toBe("verifier");
313+ expect(body.get("code")).toBe("abc");
314+ });
315+
316+ it("omits client_secret when none is provided (public clients with PKCE)", async () => {
317+ const { calls } = captureFetch(jsonResponse(200, validBody));
318+ await Effect.runPromise(
319+ exchangeAuthorizationCode({
320+ tokenUrl: "https://example.com/token",
321+ clientId: "cid",
322+ redirectUrl: "https://app.example.com/cb",
323+ codeVerifier: "verifier",
324+ code: "abc",
325+ }),
326+ );
327+ const body = calls[0]!.init.body as URLSearchParams;
328+ expect(body.get("client_id")).toBe("cid");
329+ expect(body.has("client_secret")).toBe(false);
330+ });
331+
332+ it("uses HTTP Basic auth when clientAuth=basic (Stripe-style)", async () => {
333+ const { calls } = captureFetch(jsonResponse(200, validBody));
334+ await Effect.runPromise(
335+ exchangeAuthorizationCode({
336+ tokenUrl: "https://example.com/token",
337+ clientId: "cid",
338+ clientSecret: "csecret",
339+ redirectUrl: "https://app.example.com/cb",
340+ codeVerifier: "verifier",
341+ code: "abc",
342+ clientAuth: "basic",
343+ }),
344+ );
345+ const headers = calls[0]!.init.headers as Record<string, string>;
346+ const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`;
347+ expect(headers["authorization"]).toBe(expected);
348+ const body = calls[0]!.init.body as URLSearchParams;
349+ expect(body.has("client_id")).toBe(false);
350+ expect(body.has("client_secret")).toBe(false);
351+ });
352+
353+ it("sets a 20-second AbortSignal timeout by default", async () => {
354+ const { calls } = captureFetch(jsonResponse(200, validBody));
355+ await Effect.runPromise(
356+ exchangeAuthorizationCode({
357+ tokenUrl: "https://example.com/token",
358+ clientId: "cid",
359+ redirectUrl: "https://cb",
360+ codeVerifier: "v",
361+ code: "c",
362+ }),
363+ );
364+ expect(OAUTH2_DEFAULT_TIMEOUT_MS).toBe(20_000);
365+ expect(calls[0]!.init.signal).toBeInstanceOf(AbortSignal);
366+ });
367+
368+ it("returns a typed OAuth2Error on transport failure", async () => {
369+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("boom")) as unknown as typeof fetch;
370+ const exit = await Effect.runPromiseExit(
371+ exchangeAuthorizationCode({
372+ tokenUrl: "https://example.com/token",
373+ clientId: "cid",
374+ redirectUrl: "https://cb",
375+ codeVerifier: "v",
376+ code: "c",
377+ }),
378+ );
379+ expect(Exit.isFailure(exit)).toBe(true);
380+ if (Exit.isFailure(exit)) {
381+ const err = exit.cause;
382+ const failure = JSON.stringify(err);
383+ expect(failure).toContain("OAuth2Error");
384+ expect(failure).toContain("boom");
385+ }
386+ });
387+
388+ it("propagates RFC 6749 error_description text in the OAuth2Error", async () => {
389+ captureFetch(
390+ jsonResponse(400, {
391+ error: "invalid_grant",
392+ error_description: "Code expired",
393+ }),
394+ );
395+ const exit = await Effect.runPromiseExit(
396+ exchangeAuthorizationCode({
397+ tokenUrl: "https://example.com/token",
398+ clientId: "cid",
399+ redirectUrl: "https://cb",
400+ codeVerifier: "v",
401+ code: "c",
402+ }),
403+ );
404+ expect(Exit.isFailure(exit)).toBe(true);
405+ if (Exit.isFailure(exit)) {
406+ expect(JSON.stringify(exit.cause)).toContain("Code expired");
407+ }
408+ });
409+});
410+
411+describe("refreshAccessToken", () => {
412+ afterEach(() => {
413+ globalThis.fetch = originalFetch;
414+ });
415+
416+ const validBody = { access_token: "tok2", token_type: "Bearer", expires_in: 3600 };
417+
418+ it("posts grant_type=refresh_token with the refresh token", async () => {
419+ const { calls } = captureFetch(jsonResponse(200, validBody));
420+ await Effect.runPromise(
421+ refreshAccessToken({
422+ tokenUrl: "https://example.com/token",
423+ clientId: "cid",
424+ clientSecret: "csecret",
425+ refreshToken: "old",
426+ }),
427+ );
428+ const body = calls[0]!.init.body as URLSearchParams;
429+ expect(body.get("grant_type")).toBe("refresh_token");
430+ expect(body.get("refresh_token")).toBe("old");
431+ expect(body.get("client_id")).toBe("cid");
432+ expect(body.get("client_secret")).toBe("csecret");
433+ });
434+
435+ it("includes scope when scopes are provided", async () => {
436+ const { calls } = captureFetch(jsonResponse(200, validBody));
437+ await Effect.runPromise(
438+ refreshAccessToken({
439+ tokenUrl: "https://example.com/token",
440+ clientId: "cid",
441+ refreshToken: "old",
442+ scopes: ["a", "b"],
443+ }),
444+ );
445+ const body = calls[0]!.init.body as URLSearchParams;
446+ expect(body.get("scope")).toBe("a b");
447+ });
448+
449+ it("omits scope when scopes is empty", async () => {
450+ const { calls } = captureFetch(jsonResponse(200, validBody));
451+ await Effect.runPromise(
452+ refreshAccessToken({
453+ tokenUrl: "https://example.com/token",
454+ clientId: "cid",
455+ refreshToken: "old",
456+ scopes: [],
457+ }),
458+ );
459+ const body = calls[0]!.init.body as URLSearchParams;
460+ expect(body.has("scope")).toBe(false);
461+ });
462+});
463+
464+// ---------------------------------------------------------------------------
465+// shouldRefreshToken
466+// ---------------------------------------------------------------------------
467+
468+describe("shouldRefreshToken", () => {
469+ it("never refreshes when expiresAt is null", () => {
470+ expect(shouldRefreshToken({ expiresAt: null })).toBe(false);
471+ });
472+
473+ it("returns true when within the skew window", () => {
474+ const now = 1_000_000;
475+ expect(shouldRefreshToken({ expiresAt: now + 30_000, now })).toBe(true);
476+ });
477+
478+ it("returns false when comfortably in the future", () => {
479+ const now = 1_000_000;
480+ expect(shouldRefreshToken({ expiresAt: now + 5 * 60_000, now })).toBe(false);
481+ });
482+
483+ it("uses the documented 60s default skew", () => {
484+ expect(OAUTH2_REFRESH_SKEW_MS).toBe(60_000);
485+ const now = 1_000_000;
486+ expect(shouldRefreshToken({ expiresAt: now + 59_000, now })).toBe(true);
487+ expect(shouldRefreshToken({ expiresAt: now + 61_000, now })).toBe(false);
488+ });
489+
490+ it("respects a custom skew", () => {
491+ const now = 1_000_000;
492+ expect(shouldRefreshToken({ expiresAt: now + 30_000, now, skewMs: 10_000 })).toBe(false);
493+ expect(shouldRefreshToken({ expiresAt: now + 5_000, now, skewMs: 10_000 })).toBe(true);
494+ });
495+});
496+
497+// ---------------------------------------------------------------------------
498+// Error type plumbing — make sure Effect propagates the tagged error
499+// ---------------------------------------------------------------------------
500+
501+describe("OAuth2Error tagging", () => {
502+ beforeEach(() => {
503+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("network down")) as unknown as typeof fetch;
504+ });
505+ afterEach(() => {
506+ globalThis.fetch = originalFetch;
507+ });
508+
509+ it("Effect failure channel carries OAuth2Error", async () => {
510+ const exit = await Effect.runPromiseExit(
511+ refreshAccessToken({
512+ tokenUrl: "https://example.com/token",
513+ clientId: "cid",
514+ refreshToken: "old",
515+ }),
516+ );
517+ expect(Exit.isFailure(exit)).toBe(true);
518+ if (Exit.isFailure(exit)) {
519+ // Walk the cause to find the failure
520+ const failures = JSON.stringify(exit.cause);
521+ expect(failures).toContain("OAuth2Error");
522+ }
523+ });
524+
525+ it("OAuth2Error is constructable directly with message and cause", () => {
526+ const err = new OAuth2Error({ message: "test", cause: { foo: 1 } });
527+ expect(err._tag).toBe("OAuth2Error");
528+ expect(err.message).toBe("test");
529+ expect(err.cause).toEqual({ foo: 1 });
530+ });
531+});
packages/plugins/oauth2/src/index.ts+368−0
@@ -0,0 +1,368 @@
1+// ---------------------------------------------------------------------------
2+// @executor/plugin-oauth2 — generic OAuth 2.0 helpers
3+//
4+// Pure helpers for building authorization URLs, exchanging codes, and
5+// refreshing tokens against a standards-compliant OAuth 2.0 token endpoint.
6+// Plugins (google-discovery, openapi, ...) wrap these with their own
7+// session storage, secret management, and onboarding UI.
8+//
9+// Every public helper is intentionally provider-agnostic. Provider-specific
10+// query parameters (Google's `access_type=offline`, `prompt=consent`, etc.)
11+// are passed via the `extraParams` escape hatch so callers don't lose
12+// fidelity when switching from a hand-rolled implementation.
13+// ---------------------------------------------------------------------------
14+
15+import { createHash, randomBytes } from "node:crypto";
16+
17+import { Data, Effect, ParseResult, Schema } from "effect";
18+
19+// ---------------------------------------------------------------------------
20+// Errors
21+// ---------------------------------------------------------------------------
22+
23+export class OAuth2Error extends Data.TaggedError("OAuth2Error")<{
24+ readonly message: string;
25+ readonly cause?: unknown;
26+}> {}
27+
28+// ---------------------------------------------------------------------------
29+// Token response shape (RFC 6749 §5.1)
30+// ---------------------------------------------------------------------------
31+
32+export type OAuth2TokenResponse = {
33+ readonly access_token: string;
34+ readonly token_type?: string;
35+ readonly refresh_token?: string;
36+ readonly expires_in?: number;
37+ readonly scope?: string;
38+};
39+
40+// ---------------------------------------------------------------------------
41+// Constants
42+// ---------------------------------------------------------------------------
43+
44+/** Refresh tokens this many ms before expiry to avoid mid-request expiration. */
45+export const OAUTH2_REFRESH_SKEW_MS = 60_000;
46+
47+/** Default token-endpoint timeout. */
48+export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;
49+
50+// ---------------------------------------------------------------------------
51+// PKCE (RFC 7636)
52+// ---------------------------------------------------------------------------
53+
54+const encodeBase64Url = (input: Buffer): string =>
55+ input.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
56+
57+/** Generate a 48-byte (64-char base64url) PKCE code verifier. */
58+export const createPkceCodeVerifier = (): string => encodeBase64Url(randomBytes(48));
59+
60+/** Compute the S256 code challenge for a given verifier. */
61+export const createPkceCodeChallenge = (verifier: string): string =>
62+ encodeBase64Url(createHash("sha256").update(verifier).digest());
63+
64+// ---------------------------------------------------------------------------
65+// Authorization URL builder
66+// ---------------------------------------------------------------------------
67+
68+export type BuildAuthorizationUrlInput = {
69+ readonly authorizationUrl: string;
70+ readonly clientId: string;
71+ readonly redirectUrl: string;
72+ readonly scopes: readonly string[];
73+ readonly state: string;
74+ readonly codeVerifier: string;
75+ /** Separator between scopes. RFC 6749 says space; some providers use comma. */
76+ readonly scopeSeparator?: string;
77+ /**
78+ * Provider-specific extra params (e.g. Google's `access_type=offline`,
79+ * `prompt=consent`, `include_granted_scopes=true`). Merged AFTER the
80+ * standard params so callers can override if absolutely necessary.
81+ */
82+ readonly extraParams?: Readonly<Record<string, string>>;
83+};
84+
85+export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string => {
86+ const url = new URL(input.authorizationUrl);
87+ const separator = input.scopeSeparator ?? " ";
88+ url.searchParams.set("client_id", input.clientId);
89+ url.searchParams.set("redirect_uri", input.redirectUrl);
90+ url.searchParams.set("response_type", "code");
91+ url.searchParams.set("scope", input.scopes.join(separator));
92+ url.searchParams.set("state", input.state);
93+ url.searchParams.set("code_challenge_method", "S256");
94+ url.searchParams.set("code_challenge", createPkceCodeChallenge(input.codeVerifier));
95+ if (input.extraParams) {
96+ for (const [k, v] of Object.entries(input.extraParams)) {
97+ url.searchParams.set(k, v);
98+ }
99+ }
100+ return url.toString();
101+};
102+
103+// ---------------------------------------------------------------------------
104+// Token endpoint response parsing
105+// ---------------------------------------------------------------------------
106+
107+const oauth2Error = (message: string, cause?: unknown): OAuth2Error =>
108+ new OAuth2Error({ message, cause });
109+
110+// ---------------------------------------------------------------------------
111+// Schemas
112+// ---------------------------------------------------------------------------
113+
114+/**
115+ * `expires_in` per RFC 6749 is a number of seconds, but some providers
116+ * (Azure, older OAuth servers) return it as a string. Accept either and
117+ * coerce to number.
118+ */
119+const TokenExpirySeconds = Schema.transformOrFail(
120+ Schema.Union(Schema.Number, Schema.String),
121+ Schema.Number,
122+ {
123+ strict: true,
124+ decode: (input, _options, ast) => {
125+ if (typeof input === "number") return ParseResult.succeed(input);
126+ const parsed = Number(input);
127+ return Number.isFinite(parsed)
128+ ? ParseResult.succeed(parsed)
129+ : ParseResult.fail(
130+ new ParseResult.Type(ast, input, `expires_in "${input}" is not a number`),
131+ );
132+ },
133+ encode: (value) => ParseResult.succeed(value),
134+ },
135+);
136+
137+const OAuth2TokenSuccessSchema = Schema.Struct({
138+ /** RFC 6749 §5.1 requires a non-empty access_token on success. */
139+ access_token: Schema.String.pipe(Schema.minLength(1)),
140+ token_type: Schema.optional(Schema.String),
141+ refresh_token: Schema.optional(Schema.String),
142+ expires_in: Schema.optional(TokenExpirySeconds),
143+ scope: Schema.optional(Schema.String),
144+});
145+
146+/** RFC 6749 §5.2 error response envelope — all fields optional; we probe them. */
147+const OAuth2ErrorEnvelopeSchema = Schema.Struct({
148+ error: Schema.optional(Schema.String),
149+ error_description: Schema.optional(Schema.String),
150+});
151+
152+const decodeSuccessBody = Schema.decodeUnknown(OAuth2TokenSuccessSchema);
153+const decodeErrorBody = Schema.decodeUnknown(OAuth2ErrorEnvelopeSchema);
154+
155+/**
156+ * Parse the body of a token endpoint Response as JSON and ensure it's a
157+ * plain object. Fails with `OAuth2Error` whose message matches the
158+ * historical wording (`non-JSON response (${status})` /
159+ * `invalid JSON payload (${status})`) so callers keep their fidelity
160+ * guarantees.
161+ */
162+const parseJsonObject = (
163+ rawText: string,
164+ status: number,
165+): Effect.Effect<Record<string, unknown>, OAuth2Error> =>
166+ Effect.try({
167+ try: () => JSON.parse(rawText) as unknown,
168+ catch: () => oauth2Error(`OAuth token endpoint returned non-JSON response (${status})`),
169+ }).pipe(
170+ Effect.flatMap((parsed) =>
171+ parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
172+ ? Effect.succeed(parsed as Record<string, unknown>)
173+ : Effect.fail(
174+ oauth2Error(`OAuth token endpoint returned invalid JSON payload (${status})`),
175+ ),
176+ ),
177+ );
178+
179+/**
180+ * Parse a `Response` from an OAuth 2.0 token endpoint into an
181+ * `OAuth2TokenResponse`. Failures surface through the Effect failure
182+ * channel as `OAuth2Error`.
183+ *
184+ * Handles, in order, the failure modes we have seen in the wild:
185+ * 1. Non-JSON bodies (HTML error pages from misconfigured proxies / 5xx)
186+ * 2. JSON arrays / primitives instead of an object
187+ * 3. RFC 6749 error responses (`error_description` → `error` → `status N`)
188+ * 4. 200 responses with empty / missing `access_token`
189+ * 5. `expires_in` returned as a string instead of a number (Azure et al.)
190+ */
191+export const decodeTokenResponse = (
192+ response: Response,
193+): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
194+ Effect.gen(function* () {
195+ const rawText = yield* Effect.tryPromise({
196+ try: () => response.text(),
197+ catch: (cause) =>
198+ oauth2Error(
199+ `Failed to read OAuth token endpoint body: ${cause instanceof Error ? cause.message : String(cause)}`,
200+ cause,
201+ ),
202+ });
203+
204+ const record = yield* parseJsonObject(rawText, response.status);
205+
206+ if (!response.ok) {
207+ // Probe the error envelope. A body that doesn't match the envelope
208+ // (e.g. completely empty) is not itself a failure — we just fall
209+ // back to `status N`. This mirrors the tolerant behavior of the
210+ // prior hand-rolled parser.
211+ const envelope = yield* decodeErrorBody(record).pipe(
212+ Effect.catchAll(() => Effect.succeed({} as { error?: string; error_description?: string })),
213+ );
214+ const description =
215+ envelope.error_description ?? envelope.error ?? `status ${response.status}`;
216+ return yield* Effect.fail(oauth2Error(`OAuth token exchange failed: ${description}`));
217+ }
218+
219+ return yield* decodeSuccessBody(record).pipe(
220+ Effect.mapError(() =>
221+ // The only schema constraint that can fail on a 2xx is "access_token
222+ // is a non-empty string". Any other shape mismatch (wrong type on an
223+ // optional field, malformed expires_in) also surfaces as the same
224+ // message — we deliberately fold them together: the user needs an
225+ // access token, and they didn't get one.
226+ oauth2Error("OAuth token endpoint did not return an access_token"),
227+ ),
228+ );
229+ });
230+
231+// ---------------------------------------------------------------------------
232+// Token endpoint POST
233+// ---------------------------------------------------------------------------
234+
235+export type ClientAuthMethod = "body" | "basic";
236+
237+const buildClientAuthHeaders = (
238+ clientId: string,
239+ clientSecret: string | null | undefined,
240+ method: ClientAuthMethod,
241+): Record<string, string> => {
242+ if (method !== "basic" || !clientSecret) return {};
243+ const encoded = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
244+ return { authorization: `Basic ${encoded}` };
245+};
246+
247+const applyClientAuthBody = (
248+ body: URLSearchParams,
249+ clientId: string,
250+ clientSecret: string | null | undefined,
251+ method: ClientAuthMethod,
252+): void => {
253+ if (method === "basic") return;
254+ body.set("client_id", clientId);
255+ if (clientSecret) body.set("client_secret", clientSecret);
256+};
257+
258+const postToTokenEndpoint = (input: {
259+ readonly tokenUrl: string;
260+ readonly body: URLSearchParams;
261+ readonly extraHeaders: Record<string, string>;
262+ readonly timeoutMs: number;
263+}): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
264+ Effect.tryPromise({
265+ try: () =>
266+ fetch(input.tokenUrl, {
267+ method: "POST",
268+ headers: {
269+ "content-type": "application/x-www-form-urlencoded",
270+ accept: "application/json",
271+ ...input.extraHeaders,
272+ },
273+ body: input.body,
274+ signal: AbortSignal.timeout(input.timeoutMs),
275+ }),
276+ catch: (cause) => oauth2Error(cause instanceof Error ? cause.message : String(cause), cause),
277+ }).pipe(Effect.flatMap(decodeTokenResponse));
278+
279+// ---------------------------------------------------------------------------
280+// Exchange authorization code → tokens
281+// ---------------------------------------------------------------------------
282+
283+export type ExchangeAuthorizationCodeInput = {
284+ readonly tokenUrl: string;
285+ readonly clientId: string;
286+ readonly clientSecret?: string | null;
287+ readonly redirectUrl: string;
288+ readonly codeVerifier: string;
289+ readonly code: string;
290+ /** "body" (default) sends client creds in the form body; "basic" uses HTTP Basic. */
291+ readonly clientAuth?: ClientAuthMethod;
292+ readonly timeoutMs?: number;
293+};
294+
295+export const exchangeAuthorizationCode = (
296+ input: ExchangeAuthorizationCodeInput,
297+): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => {
298+ const clientAuth = input.clientAuth ?? "body";
299+ const body = new URLSearchParams({
300+ grant_type: "authorization_code",
301+ redirect_uri: input.redirectUrl,
302+ code_verifier: input.codeVerifier,
303+ code: input.code,
304+ });
305+ applyClientAuthBody(body, input.clientId, input.clientSecret, clientAuth);
306+ return postToTokenEndpoint({
307+ tokenUrl: input.tokenUrl,
308+ body,
309+ extraHeaders: buildClientAuthHeaders(input.clientId, input.clientSecret, clientAuth),
310+ timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS,
311+ });
312+};
313+
314+// ---------------------------------------------------------------------------
315+// Refresh access token
316+// ---------------------------------------------------------------------------
317+
318+export type RefreshAccessTokenInput = {
319+ readonly tokenUrl: string;
320+ readonly clientId: string;
321+ readonly clientSecret?: string | null;
322+ readonly refreshToken: string;
323+ readonly scopes?: readonly string[];
324+ readonly scopeSeparator?: string;
325+ readonly clientAuth?: ClientAuthMethod;
326+ readonly timeoutMs?: number;
327+};
328+
329+export const refreshAccessToken = (
330+ input: RefreshAccessTokenInput,
331+): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => {
332+ const clientAuth = input.clientAuth ?? "body";
333+ const body = new URLSearchParams({
334+ grant_type: "refresh_token",
335+ refresh_token: input.refreshToken,
336+ });
337+ applyClientAuthBody(body, input.clientId, input.clientSecret, clientAuth);
338+ if (input.scopes && input.scopes.length > 0) {
339+ body.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
340+ }
341+ return postToTokenEndpoint({
342+ tokenUrl: input.tokenUrl,
343+ body,
344+ extraHeaders: buildClientAuthHeaders(input.clientId, input.clientSecret, clientAuth),
345+ timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS,
346+ });
347+};
348+
349+// ---------------------------------------------------------------------------
350+// Refresh-needed predicate
351+// ---------------------------------------------------------------------------
352+
353+/**
354+ * Returns true iff the current time is within `OAUTH2_REFRESH_SKEW_MS` of
355+ * `expiresAt`. A null `expiresAt` (server didn't return `expires_in`) means
356+ * we cannot proactively refresh — callers should fall back to reactive
357+ * refresh on 401 responses.
358+ */
359+export const shouldRefreshToken = (input: {
360+ readonly expiresAt: number | null;
361+ readonly now?: number;
362+ readonly skewMs?: number;
363+}): boolean => {
364+ if (input.expiresAt === null) return false;
365+ const now = input.now ?? Date.now();
366+ const skew = input.skewMs ?? OAUTH2_REFRESH_SKEW_MS;
367+ return input.expiresAt <= now + skew;
368+};
packages/plugins/oauth2/tsconfig.json+20−0
@@ -0,0 +1,20 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "lib": ["ES2022"],
9+ "types": ["bun-types", "node"],
10+ "noUnusedLocals": true,
11+ "noImplicitOverride": true,
12+ "plugins": [
13+ {
14+ "name": "@effect/language-service",
15+ "diagnosticSeverity": {}
16+ }
17+ ]
18+ },
19+ "include": ["src/**/*.ts"]
20+}
packages/plugins/oauth2/tsup.config.ts+12−0
@@ -0,0 +1,12 @@
1+import { defineConfig } from "tsup";
2+
3+export default defineConfig({
4+ entry: {
5+ index: "src/index.ts",
6+ },
7+ format: ["esm"],
8+ dts: false,
9+ sourcemap: true,
10+ clean: true,
11+ external: [/^@executor\//, /^effect/, /^@effect\//],
12+});
packages/plugins/oauth2/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ },
7+});