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
427 "dependencies": {427 "dependencies": {
428 "@effect/platform": "catalog:",428 "@effect/platform": "catalog:",
429 "@executor/api": "workspace:*",429 "@executor/api": "workspace:*",
430 "@executor/plugin-oauth2": "workspace:*",
430 "@executor/sdk": "workspace:*",431 "@executor/sdk": "workspace:*",
431 "effect": "catalog:",432 "effect": "catalog:",
432 },433 },
@@ -545,6 +546,20 @@ · 112 unmodified lines
545 "react",546 "react",
546 ],547 ],
547 },548 },
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 },
548 "packages/plugins/onepassword": {563 "packages/plugins/onepassword": {
549 "name": "@executor/plugin-onepassword",564 "name": "@executor/plugin-onepassword",
550 "version": "0.0.1",565 "version": "0.0.1",
@@ -1130,6 +1145,8 @@ · 579 unmodified lines
1130 1145
1131 "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"],1146 "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"],
1132 1147
1148 "@executor/plugin-oauth2": ["@executor/plugin-oauth2@workspace:packages/plugins/oauth2"],
1149
1133 "@executor/plugin-onepassword": ["@executor/plugin-onepassword@workspace:packages/plugins/onepassword"],1150 "@executor/plugin-onepassword": ["@executor/plugin-onepassword@workspace:packages/plugins/onepassword"],
1134 1151
1135 "@executor/plugin-openapi": ["@executor/plugin-openapi@workspace:packages/plugins/openapi"],1152 "@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
49 "dependencies": {49 "dependencies": {
50 "@effect/platform": "catalog:",50 "@effect/platform": "catalog:",
51 "@executor/api": "workspace:*",51 "@executor/api": "workspace:*",
52 "@executor/plugin-oauth2": "workspace:*",
52 "@executor/sdk": "workspace:*",53 "@executor/sdk": "workspace:*",
53 "effect": "catalog:"54 "effect": "catalog:"
54 },55 },
packages/plugins/google-discovery/src/sdk/invoke.ts+3−5
@@ -1,6 +1,8 @@
1import { Effect, Layer, Option } from "effect";1import { Effect, Layer, Option } from "effect";
2import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";2import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
3 3
4import { shouldRefreshToken } from "@executor/plugin-oauth2";
5
4import {6import {
5 type ScopeId,7 type ScopeId,
6 type SecretId,8 type SecretId,
@@ -19,8 +21,6 @@ import { · 12 unmodified lines
19} from "./types";21} from "./types";
20import { refreshAccessToken } from "./oauth";22import { refreshAccessToken } from "./oauth";
21 23
22const OAUTH_REFRESH_SKEW_MS = 60_000;
23
24const SAFE_METHODS = new Set(["get", "head", "options"]);24const SAFE_METHODS = new Set(["get", "head", "options"]);
25 25
26const stringValuesFromParameter = (value: unknown, repeated: boolean): string[] => {26const stringValuesFromParameter = (value: unknown, repeated: boolean): string[] => {
@@ -91,11 +91,9 @@ const resolveOAuthAccessToken = (input: { · 64 unmodified lines
91 }91 }
92 92
93 const auth = input.source.auth;93 const auth = input.source.auth;
94 const now = Date.now();
95 const needsRefresh =94 const needsRefresh =
96 auth.refreshTokenSecretId !== null &&95 auth.refreshTokenSecretId !== null &&
97 auth.expiresAt !== null &&96 shouldRefreshToken({ expiresAt: auth.expiresAt });
98 auth.expiresAt <= now + OAUTH_REFRESH_SKEW_MS;
99 97
100 if (!needsRefresh) {98 if (!needsRefresh) {
101 return yield* input.secrets.resolve(auth.accessTokenSecretId as SecretId, input.scopeId).pipe(99 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 @@
1import { 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// ---------------------------------------------------------------------------
2 11
3import { Effect } from "effect";12import { Effect } from "effect";
4 13
14import {
15 buildAuthorizationUrl,
16 createPkceCodeVerifier as sharedCreatePkceCodeVerifier,
17 exchangeAuthorizationCode as sharedExchangeAuthorizationCode,
18 refreshAccessToken as sharedRefreshAccessToken,
19 type OAuth2TokenResponse,
20} from "@executor/plugin-oauth2";
21
5import { GoogleDiscoveryOAuthError } from "./errors";22import { GoogleDiscoveryOAuthError } from "./errors";
6 23
7export type OAuth2TokenResponse = {24const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
8 readonly access_token: string;25const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
9 readonly token_type?: string;
10 readonly refresh_token?: string;
11 readonly expires_in?: number;
12 readonly scope?: string;
13};
14 26
15const encodeBase64Url = (input: Buffer): string =>27const GOOGLE_EXTRA_AUTHORIZATION_PARAMS = {
16 input.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");28 access_type: "offline",
29 include_granted_scopes: "true",
30 prompt: "consent",
31} as const;
17 32
18export const createPkceCodeVerifier = (): string => encodeBase64Url(randomBytes(48));33export type { OAuth2TokenResponse as OAuth2TokenResponse };
19 34
20const createPkceCodeChallenge = (verifier: string): string =>35export const createPkceCodeVerifier = sharedCreatePkceCodeVerifier;
21 encodeBase64Url(createHash("sha256").update(verifier).digest());
22 36
23export const buildGoogleAuthorizationUrl = (input: {37export const buildGoogleAuthorizationUrl = (input: {
24 readonly clientId: string;38 readonly clientId: string;
@@ -26,88 +40,22 @@ export const buildGoogleAuthorizationUrl = (input: { · 1 unmodified line
26 readonly scopes: readonly string[];40 readonly scopes: readonly string[];
27 readonly state: string;41 readonly state: string;
28 readonly codeVerifier: string;42 readonly codeVerifier: string;
29}): string => {43}): string =>
30 const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");44 buildAuthorizationUrl({
31 url.searchParams.set("client_id", input.clientId);45 authorizationUrl: GOOGLE_AUTHORIZATION_URL,
32 url.searchParams.set("redirect_uri", input.redirectUrl);46 clientId: input.clientId,
33 url.searchParams.set("response_type", "code");47 redirectUrl: input.redirectUrl,
34 url.searchParams.set("scope", input.scopes.join(" "));48 scopes: input.scopes,
35 url.searchParams.set("state", input.state);49 state: input.state,
36 url.searchParams.set("code_challenge_method", "S256");50 codeVerifier: input.codeVerifier,
37 url.searchParams.set("code_challenge", createPkceCodeChallenge(input.codeVerifier));51 extraParams: GOOGLE_EXTRA_AUTHORIZATION_PARAMS,
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
44const 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
91const 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 }),
109 });52 });
110 53
54const 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
111export const exchangeAuthorizationCode = (input: {59export const exchangeAuthorizationCode = (input: {
112 readonly clientId: string;60 readonly clientId: string;
113 readonly clientSecret?: string | null;61 readonly clientSecret?: string | null;
@@ -115,19 +63,16 @@ export const exchangeAuthorizationCode = (input: { · 1 unmodified line
115 readonly codeVerifier: string;63 readonly codeVerifier: string;
116 readonly code: string;64 readonly code: string;
117}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>65}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>
118 Effect.gen(function* () {66 wrapError(
119 const body = new URLSearchParams({67 sharedExchangeAuthorizationCode({
120 grant_type: "authorization_code",68 tokenUrl: GOOGLE_TOKEN_URL,
121 client_id: input.clientId,69 clientId: input.clientId,
122 redirect_uri: input.redirectUrl,70 clientSecret: input.clientSecret,
123 code_verifier: input.codeVerifier,71 redirectUrl: input.redirectUrl,
72 codeVerifier: input.codeVerifier,
124 code: input.code,73 code: input.code,
125 });74 }),
126 if (input.clientSecret) {75 );
127 body.set("client_secret", input.clientSecret);
128 }
129 return yield* postToTokenEndpoint(body);
130 });
131 76
132export const refreshAccessToken = (input: {77export const refreshAccessToken = (input: {
133 readonly clientId: string;78 readonly clientId: string;
@@ -135,17 +80,12 @@ export const refreshAccessToken = (input: { · 1 unmodified line
135 readonly refreshToken: string;80 readonly refreshToken: string;
136 readonly scopes?: readonly string[];81 readonly scopes?: readonly string[];
137}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>82}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>
138 Effect.gen(function* () {83 wrapError(
139 const body = new URLSearchParams({84 sharedRefreshAccessToken({
140 grant_type: "refresh_token",85 tokenUrl: GOOGLE_TOKEN_URL,
141 client_id: input.clientId,86 clientId: input.clientId,
142 refresh_token: input.refreshToken,87 clientSecret: input.clientSecret,
143 });88 refreshToken: input.refreshToken,
144 if (input.clientSecret) {89 scopes: input.scopes,
145 body.set("client_secret", input.clientSecret);90 }),
146 }91 );
147 if (input.scopes && input.scopes.length > 0) {
148 body.set("scope", input.scopes.join(" "));
149 }
150 return yield* postToTokenEndpoint(body);
151 });
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
8import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
9import { Cause, Chunk, Effect, Exit } from "effect";
10
11import {
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
24const jsonResponse = (status: number, body: unknown): Response =>
25 new Response(JSON.stringify(body), {
26 status,
27 headers: { "content-type": "application/json" },
28 });
29
30const textResponse = (status: number, body: string): Response =>
31 new Response(body, { status, headers: { "content-type": "text/html" } });
32
33// ---------------------------------------------------------------------------
34// PKCE
35// ---------------------------------------------------------------------------
36
37describe("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
65describe("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
125describe("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
259type FetchArgs = { url: string; init: RequestInit };
260
261const 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
272const originalFetch = globalThis.fetch;
273
274describe("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
411describe("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
468describe("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
501describe("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
15import { createHash, randomBytes } from "node:crypto";
16
17import { Data, Effect, ParseResult, Schema } from "effect";
18
19// ---------------------------------------------------------------------------
20// Errors
21// ---------------------------------------------------------------------------
22
23export 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
32export 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. */
45export const OAUTH2_REFRESH_SKEW_MS = 60_000;
46
47/** Default token-endpoint timeout. */
48export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;
49
50// ---------------------------------------------------------------------------
51// PKCE (RFC 7636)
52// ---------------------------------------------------------------------------
53
54const encodeBase64Url = (input: Buffer): string =>
55 input.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
56
57/** Generate a 48-byte (64-char base64url) PKCE code verifier. */
58export const createPkceCodeVerifier = (): string => encodeBase64Url(randomBytes(48));
59
60/** Compute the S256 code challenge for a given verifier. */
61export const createPkceCodeChallenge = (verifier: string): string =>
62 encodeBase64Url(createHash("sha256").update(verifier).digest());
63
64// ---------------------------------------------------------------------------
65// Authorization URL builder
66// ---------------------------------------------------------------------------
67
68export 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
85export 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
107const 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 */
119const 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
137const 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. */
147const OAuth2ErrorEnvelopeSchema = Schema.Struct({
148 error: Schema.optional(Schema.String),
149 error_description: Schema.optional(Schema.String),
150});
151
152const decodeSuccessBody = Schema.decodeUnknown(OAuth2TokenSuccessSchema);
153const 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 */
162const 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 */
191export 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
235export type ClientAuthMethod = "body" | "basic";
236
237const 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
247const 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
258const 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
283export 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
295export 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
318export 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
329export 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 */
359export 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 @@
1import { defineConfig } from "tsup";
2
3export 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 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 },
7});