## 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 | "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"], |
| @@ -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 | }, |
| @@ -1,6 +1,8 @@ | |||
| 1 | import { Effect, Layer, Option } from "effect"; | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; | 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; |
| 3 | 3 | ||
| 4 | import { shouldRefreshToken } from "@executor/plugin-oauth2"; | ||
| 5 | |||
| 4 | import { | 6 | import { |
| 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"; |
| 20 | import { refreshAccessToken } from "./oauth"; | 22 | import { refreshAccessToken } from "./oauth"; |
| 21 | 23 | ||
| 22 | const OAUTH_REFRESH_SKEW_MS = 60_000; | ||
| 23 | |||
| 24 | const SAFE_METHODS = new Set(["get", "head", "options"]); | 24 | const SAFE_METHODS = new Set(["get", "head", "options"]); |
| 25 | 25 | ||
| 26 | const stringValuesFromParameter = (value: unknown, repeated: boolean): string[] => { | 26 | const 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( |
| @@ -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 | import { Effect } from "effect"; | 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 | import { GoogleDiscoveryOAuthError } from "./errors"; | 22 | import { GoogleDiscoveryOAuthError } from "./errors"; |
| 6 | 23 | ||
| 7 | export type OAuth2TokenResponse = { | 24 | const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; |
| 8 | readonly access_token: string; | 25 | const 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 | ||
| 15 | const encodeBase64Url = (input: Buffer): string => | 27 | const 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 | ||
| 18 | export const createPkceCodeVerifier = (): string => encodeBase64Url(randomBytes(48)); | 33 | export type { OAuth2TokenResponse as OAuth2TokenResponse }; |
| 19 | 34 | ||
| 20 | const createPkceCodeChallenge = (verifier: string): string => | 35 | export const createPkceCodeVerifier = sharedCreatePkceCodeVerifier; |
| 21 | encodeBase64Url(createHash("sha256").update(verifier).digest()); | ||
| 22 | 36 | ||
| 23 | export const buildGoogleAuthorizationUrl = (input: { | 37 | export 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 | |||
| 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 | }), | ||
| 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 | export const exchangeAuthorizationCode = (input: { | 59 | export 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 | ||
| 132 | export const refreshAccessToken = (input: { | 77 | export 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 | }); | ||
| @@ -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 | }); | ||