Skip to content

Commit 3bcfc2f

Browse files
authored
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
1 parent b3b03be commit 3bcfc2f

10 files changed

Lines changed: 1067 additions & 124 deletions

File tree

‎bun.lock‎

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/plugins/google-discovery/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
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‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
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: {
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(
Lines changed: 59 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,151 +1,91 @@
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;
2539
readonly redirectUrl: string;
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;
11462
readonly redirectUrl: string;
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;
13479
readonly clientSecret?: string | null;
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+
);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)