## 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
Unified · Split
| @@ -427,6 +427,7 @@ · 426 unmodified lines | ||
| 427 | 427 | "dependencies": { |
| 428 | 428 | "@effect/platform": "catalog:", |
| 429 | 429 | "@executor/api": "workspace:*", |
| 430 | + "@executor/plugin-oauth2": "workspace:*", | |
| 430 | 431 | "@executor/sdk": "workspace:*", |
| 431 | 432 | "effect": "catalog:", |
| 432 | 433 | }, |
| @@ -545,6 +546,20 @@ · 112 unmodified lines | ||
| 545 | 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 | 563 | "packages/plugins/onepassword": { |
| 549 | 564 | "name": "@executor/plugin-onepassword", |
| 550 | 565 | "version": "0.0.1", |
| @@ -1130,6 +1145,8 @@ · 579 unmodified lines | ||
| 1130 | 1145 | |
| 1131 | 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 | 1150 | "@executor/plugin-onepassword": ["@executor/plugin-onepassword@workspace:packages/plugins/onepassword"], |
| 1134 | 1151 | |
| 1135 | 1152 | "@executor/plugin-openapi": ["@executor/plugin-openapi@workspace:packages/plugins/openapi"], |
| @@ -49,6 +49,7 @@ · 48 unmodified lines | ||
| 49 | 49 | "dependencies": { |
| 50 | 50 | "@effect/platform": "catalog:", |
| 51 | 51 | "@executor/api": "workspace:*", |
| 52 | + "@executor/plugin-oauth2": "workspace:*", | |
| 52 | 53 | "@executor/sdk": "workspace:*", |
| 53 | 54 | "effect": "catalog:" |
| 54 | 55 | }, |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; |
| 3 | 3 | |
| 4 | +import { shouldRefreshToken } from "@executor/plugin-oauth2"; | |
| 5 | + | |
| 4 | 6 | import { |
| 5 | 7 | type ScopeId, |
| 6 | 8 | type SecretId, |
| @@ -19,8 +21,6 @@ import { · 12 unmodified lines | ||
| 19 | 21 | } from "./types"; |
| 20 | 22 | import { refreshAccessToken } from "./oauth"; |
| 21 | 23 | |
| 22 | −const OAUTH_REFRESH_SKEW_MS = 60_000; | |
| 23 | − | |
| 24 | 24 | const SAFE_METHODS = new Set(["get", "head", "options"]); |
| 25 | 25 | |
| 26 | 26 | const stringValuesFromParameter = (value: unknown, repeated: boolean): string[] => { |
| @@ -91,11 +91,9 @@ const resolveOAuthAccessToken = (input: { · 64 unmodified lines | ||
| 91 | 91 | } |
| 92 | 92 | |
| 93 | 93 | const auth = input.source.auth; |
| 94 | − const now = Date.now(); | |
| 95 | 94 | const needsRefresh = |
| 96 | 95 | auth.refreshTokenSecretId !== null && |
| 97 | − auth.expiresAt !== null && | |
| 98 | − auth.expiresAt <= now + OAUTH_REFRESH_SKEW_MS; | |
| 96 | + shouldRefreshToken({ expiresAt: auth.expiresAt }); | |
| 99 | 97 | |
| 100 | 98 | if (!needsRefresh) { |
| 101 | 99 | return yield* input.secrets.resolve(auth.accessTokenSecretId as SecretId, input.scopeId).pipe( |
| @@ -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 | +// --------------------------------------------------------------------------- | |
| 2 | 11 | |
| 3 | 12 | import { Effect } from "effect"; |
| 4 | 13 | |
| 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 | + | |
| 5 | 22 | import { GoogleDiscoveryOAuthError } from "./errors"; |
| 6 | 23 | |
| 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"; | |
| 14 | 26 | |
| 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; | |
| 17 | 32 | |
| 18 | −export const createPkceCodeVerifier = (): string => encodeBase64Url(randomBytes(48)); | |
| 33 | +export type { OAuth2TokenResponse as OAuth2TokenResponse }; | |
| 19 | 34 | |
| 20 | −const createPkceCodeChallenge = (verifier: string): string => | |
| 21 | − encodeBase64Url(createHash("sha256").update(verifier).digest()); | |
| 35 | +export const createPkceCodeVerifier = sharedCreatePkceCodeVerifier; | |
| 22 | 36 | |
| 23 | 37 | export const buildGoogleAuthorizationUrl = (input: { |
| 24 | 38 | readonly clientId: string; |
| @@ -26,88 +40,22 @@ export const buildGoogleAuthorizationUrl = (input: { · 1 unmodified line | ||
| 26 | 40 | readonly scopes: readonly string[]; |
| 27 | 41 | readonly state: string; |
| 28 | 42 | 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, | |
| 109 | 52 | }); |
| 110 | 53 | |
| 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 | + | |
| 111 | 59 | export const exchangeAuthorizationCode = (input: { |
| 112 | 60 | readonly clientId: string; |
| 113 | 61 | readonly clientSecret?: string | null; |
| @@ -115,19 +63,16 @@ export const exchangeAuthorizationCode = (input: { · 1 unmodified line | ||
| 115 | 63 | readonly codeVerifier: string; |
| 116 | 64 | readonly code: string; |
| 117 | 65 | }): 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, | |
| 124 | 73 | code: input.code, |
| 125 | − }); | |
| 126 | − if (input.clientSecret) { | |
| 127 | − body.set("client_secret", input.clientSecret); | |
| 128 | − } | |
| 129 | − return yield* postToTokenEndpoint(body); | |
| 130 | − }); | |
| 74 | + }), | |
| 75 | + ); | |
| 131 | 76 | |
| 132 | 77 | export const refreshAccessToken = (input: { |
| 133 | 78 | readonly clientId: string; |
| @@ -135,17 +80,12 @@ export const refreshAccessToken = (input: { · 1 unmodified line | ||
| 135 | 80 | readonly refreshToken: string; |
| 136 | 81 | readonly scopes?: readonly string[]; |
| 137 | 82 | }): 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 | + ); | |
| @@ -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,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 | +}); | |
| @@ -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 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { | |
| 5 | + include: ["src/**/*.test.ts"], | |
| 6 | + }, | |
| 7 | +}); | |