531 lines · 18.3 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Fidelity suite — locks in every edge case the prior hand-rolled// google-discovery oauth.ts handled, so future "simplifications" of the// shared helpers fail loudly instead of silently breaking refresh / parsing /// provider-specific quirks.// --------------------------------------------------------------------------- import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";import { Cause, Chunk, Effect, Exit } from "effect"; import {  OAUTH2_DEFAULT_TIMEOUT_MS,  OAUTH2_REFRESH_SKEW_MS,  OAuth2Error,  buildAuthorizationUrl,  createPkceCodeChallenge,  createPkceCodeVerifier,  decodeTokenResponse,  exchangeAuthorizationCode,  refreshAccessToken,  shouldRefreshToken,} from "./index"; const jsonResponse = (status: number, body: unknown): Response =>  new Response(JSON.stringify(body), {    status,    headers: { "content-type": "application/json" },  }); const textResponse = (status: number, body: string): Response =>  new Response(body, { status, headers: { "content-type": "text/html" } }); // ---------------------------------------------------------------------------// PKCE// --------------------------------------------------------------------------- describe("PKCE", () => {  it("createPkceCodeVerifier returns a base64url string in the RFC 7636 length range", () => {    for (let i = 0; i < 25; i++) {      const verifier = createPkceCodeVerifier();      expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);      expect(verifier.length).toBeGreaterThanOrEqual(43);      expect(verifier.length).toBeLessThanOrEqual(128);    }  });   it("createPkceCodeChallenge matches the RFC 7636 Appendix A test vector", () => {    // RFC 7636 §4.2 test vector    const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";    const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";    expect(createPkceCodeChallenge(verifier)).toBe(expected);  });   it("createPkceCodeVerifier produces unique values", () => {    const seen = new Set<string>();    for (let i = 0; i < 50; i++) seen.add(createPkceCodeVerifier());    expect(seen.size).toBe(50);  });}); // ---------------------------------------------------------------------------// buildAuthorizationUrl// --------------------------------------------------------------------------- describe("buildAuthorizationUrl", () => {  const baseInput = {    authorizationUrl: "https://example.com/authorize",    clientId: "client-123",    redirectUrl: "https://app.example.com/callback",    scopes: ["read", "write"] as const,    state: "state-abc",    codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",  };   it("emits all RFC 6749 + PKCE params", () => {    const url = new URL(buildAuthorizationUrl(baseInput));    expect(url.origin + url.pathname).toBe("https://example.com/authorize");    expect(url.searchParams.get("client_id")).toBe("client-123");    expect(url.searchParams.get("redirect_uri")).toBe("https://app.example.com/callback");    expect(url.searchParams.get("response_type")).toBe("code");    expect(url.searchParams.get("scope")).toBe("read write");    expect(url.searchParams.get("state")).toBe("state-abc");    expect(url.searchParams.get("code_challenge_method")).toBe("S256");    expect(url.searchParams.get("code_challenge")).toBe(      "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",    );  });   it("supports a custom scope separator (e.g. comma for legacy providers)", () => {    const url = new URL(buildAuthorizationUrl({ ...baseInput, scopeSeparator: "," }));    expect(url.searchParams.get("scope")).toBe("read,write");  });   it("merges Google-style extra params without dropping them", () => {    const url = new URL(      buildAuthorizationUrl({        ...baseInput,        extraParams: {          access_type: "offline",          prompt: "consent",          include_granted_scopes: "true",        },      }),    );    expect(url.searchParams.get("access_type")).toBe("offline");    expect(url.searchParams.get("prompt")).toBe("consent");    expect(url.searchParams.get("include_granted_scopes")).toBe("true");    // Standard params are still present.    expect(url.searchParams.get("code_challenge_method")).toBe("S256");  });   it("preserves pre-existing query params on the authorization URL", () => {    const url = new URL(      buildAuthorizationUrl({ ...baseInput, authorizationUrl: "https://example.com/auth?tenant=acme" }),    );    expect(url.searchParams.get("tenant")).toBe("acme");    expect(url.searchParams.get("client_id")).toBe("client-123");  });}); // ---------------------------------------------------------------------------// decodeTokenResponse// --------------------------------------------------------------------------- describe("decodeTokenResponse", () => {  /** Assert the decode effect fails with an OAuth2Error whose message matches `pattern`. */  const expectOAuth2ErrorMatching = (    exit: Exit.Exit<unknown, OAuth2Error>,    pattern: RegExp | string,  ) => {    expect(Exit.isFailure(exit)).toBe(true);    if (!Exit.isFailure(exit)) return;    const failures = Chunk.toReadonlyArray(Cause.failures(exit.cause));    expect(failures.length).toBeGreaterThan(0);    const error = failures[0]!;    expect(error).toBeInstanceOf(OAuth2Error);    if (typeof pattern === "string") {      expect(error.message).toContain(pattern);    } else {      expect(error.message).toMatch(pattern);    }  };   it.effect("parses a minimal successful response", () =>    Effect.gen(function* () {      const result = yield* decodeTokenResponse(        jsonResponse(200, { access_token: "tok" }),      );      expect(result.access_token).toBe("tok");      expect(result.token_type).toBeUndefined();      expect(result.expires_in).toBeUndefined();    }),  );   it.effect("parses a fully populated successful response", () =>    Effect.gen(function* () {      const result = yield* decodeTokenResponse(        jsonResponse(200, {          access_token: "tok",          token_type: "Bearer",          refresh_token: "rtok",          expires_in: 3600,          scope: "read write",        }),      );      expect(result).toEqual({        access_token: "tok",        token_type: "Bearer",        refresh_token: "rtok",        expires_in: 3600,        scope: "read write",      });    }),  );   it.effect("accepts expires_in as a string (Azure / older providers)", () =>    Effect.gen(function* () {      const result = yield* decodeTokenResponse(        jsonResponse(200, { access_token: "tok", expires_in: "3600" }),      );      expect(result.expires_in).toBe(3600);    }),  );   it.effect("fails with OAuth2Error on non-JSON bodies (HTML error pages from proxies / 5xx)", () =>    Effect.gen(function* () {      const exit = yield* Effect.exit(        decodeTokenResponse(textResponse(502, "<html>bad gateway</html>")),      );      expectOAuth2ErrorMatching(exit, /non-JSON response \(502\)/);    }),  );   it.effect("fails with OAuth2Error on JSON arrays / non-object payloads", () =>    Effect.gen(function* () {      const exit = yield* Effect.exit(        decodeTokenResponse(jsonResponse(200, ["not", "an", "object"])),      );      expectOAuth2ErrorMatching(exit, /invalid JSON payload \(200\)/);    }),  );   it.effect("uses error_description from RFC 6749 error responses", () =>    Effect.gen(function* () {      const exit = yield* Effect.exit(        decodeTokenResponse(          jsonResponse(400, {            error: "invalid_grant",            error_description: "The provided authorization grant is invalid",          }),        ),      );      expectOAuth2ErrorMatching(        exit,        "OAuth token exchange failed: The provided authorization grant is invalid",      );    }),  );   it.effect("falls back to error code when error_description is absent", () =>    Effect.gen(function* () {      const exit = yield* Effect.exit(        decodeTokenResponse(jsonResponse(400, { error: "invalid_grant" })),      );      expectOAuth2ErrorMatching(exit, "OAuth token exchange failed: invalid_grant");    }),  );   it.effect("falls back to status N when no error fields are present", () =>    Effect.gen(function* () {      const exit = yield* Effect.exit(decodeTokenResponse(jsonResponse(500, {})));      expectOAuth2ErrorMatching(exit, "OAuth token exchange failed: status 500");    }),  );   it.effect("fails with OAuth2Error on 200 responses with an empty access_token", () =>    Effect.gen(function* () {      const exit = yield* Effect.exit(        decodeTokenResponse(jsonResponse(200, { access_token: "" })),      );      expectOAuth2ErrorMatching(exit, /did not return an access_token/);    }),  );   it.effect("fails with OAuth2Error on 200 responses with no access_token field", () =>    Effect.gen(function* () {      const exit = yield* Effect.exit(        decodeTokenResponse(jsonResponse(200, { token_type: "Bearer" })),      );      expectOAuth2ErrorMatching(exit, /did not return an access_token/);    }),  );}); // ---------------------------------------------------------------------------// exchangeAuthorizationCode / refreshAccessToken — request shape// --------------------------------------------------------------------------- type FetchArgs = { url: string; init: RequestInit }; const captureFetch = (response: Response): { calls: FetchArgs[] } => {  const calls: FetchArgs[] = [];  globalThis.fetch = vi    .fn()    .mockImplementation(async (url: string, init: RequestInit) => {      calls.push({ url, init });      return response;    }) as unknown as typeof fetch;  return { calls };}; const originalFetch = globalThis.fetch; describe("exchangeAuthorizationCode", () => {  afterEach(() => {    globalThis.fetch = originalFetch;  });   const validBody = {    access_token: "tok",    token_type: "Bearer",    refresh_token: "rtok",    expires_in: 3600,    scope: "read",  };   it("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", async () => {    const { calls } = captureFetch(jsonResponse(200, validBody));    const result = await Effect.runPromise(      exchangeAuthorizationCode({        tokenUrl: "https://example.com/token",        clientId: "cid",        clientSecret: "csecret",        redirectUrl: "https://app.example.com/cb",        codeVerifier: "verifier",        code: "abc",      }),    );    expect(result.access_token).toBe("tok");    expect(calls).toHaveLength(1);    const call = calls[0]!;    expect(call.url).toBe("https://example.com/token");    expect(call.init.method).toBe("POST");    const headers = call.init.headers as Record<string, string>;    expect(headers["content-type"]).toBe("application/x-www-form-urlencoded");    expect(headers["accept"]).toBe("application/json");    const body = call.init.body as URLSearchParams;    expect(body.get("grant_type")).toBe("authorization_code");    expect(body.get("client_id")).toBe("cid");    expect(body.get("client_secret")).toBe("csecret");    expect(body.get("redirect_uri")).toBe("https://app.example.com/cb");    expect(body.get("code_verifier")).toBe("verifier");    expect(body.get("code")).toBe("abc");  });   it("omits client_secret when none is provided (public clients with PKCE)", async () => {    const { calls } = captureFetch(jsonResponse(200, validBody));    await Effect.runPromise(      exchangeAuthorizationCode({        tokenUrl: "https://example.com/token",        clientId: "cid",        redirectUrl: "https://app.example.com/cb",        codeVerifier: "verifier",        code: "abc",      }),    );    const body = calls[0]!.init.body as URLSearchParams;    expect(body.get("client_id")).toBe("cid");    expect(body.has("client_secret")).toBe(false);  });   it("uses HTTP Basic auth when clientAuth=basic (Stripe-style)", async () => {    const { calls } = captureFetch(jsonResponse(200, validBody));    await Effect.runPromise(      exchangeAuthorizationCode({        tokenUrl: "https://example.com/token",        clientId: "cid",        clientSecret: "csecret",        redirectUrl: "https://app.example.com/cb",        codeVerifier: "verifier",        code: "abc",        clientAuth: "basic",      }),    );    const headers = calls[0]!.init.headers as Record<string, string>;    const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`;    expect(headers["authorization"]).toBe(expected);    const body = calls[0]!.init.body as URLSearchParams;    expect(body.has("client_id")).toBe(false);    expect(body.has("client_secret")).toBe(false);  });   it("sets a 20-second AbortSignal timeout by default", async () => {    const { calls } = captureFetch(jsonResponse(200, validBody));    await Effect.runPromise(      exchangeAuthorizationCode({        tokenUrl: "https://example.com/token",        clientId: "cid",        redirectUrl: "https://cb",        codeVerifier: "v",        code: "c",      }),    );    expect(OAUTH2_DEFAULT_TIMEOUT_MS).toBe(20_000);    expect(calls[0]!.init.signal).toBeInstanceOf(AbortSignal);  });   it("returns a typed OAuth2Error on transport failure", async () => {    globalThis.fetch = vi.fn().mockRejectedValue(new Error("boom")) as unknown as typeof fetch;    const exit = await Effect.runPromiseExit(      exchangeAuthorizationCode({        tokenUrl: "https://example.com/token",        clientId: "cid",        redirectUrl: "https://cb",        codeVerifier: "v",        code: "c",      }),    );    expect(Exit.isFailure(exit)).toBe(true);    if (Exit.isFailure(exit)) {      const err = exit.cause;      const failure = JSON.stringify(err);      expect(failure).toContain("OAuth2Error");      expect(failure).toContain("boom");    }  });   it("propagates RFC 6749 error_description text in the OAuth2Error", async () => {    captureFetch(      jsonResponse(400, {        error: "invalid_grant",        error_description: "Code expired",      }),    );    const exit = await Effect.runPromiseExit(      exchangeAuthorizationCode({        tokenUrl: "https://example.com/token",        clientId: "cid",        redirectUrl: "https://cb",        codeVerifier: "v",        code: "c",      }),    );    expect(Exit.isFailure(exit)).toBe(true);    if (Exit.isFailure(exit)) {      expect(JSON.stringify(exit.cause)).toContain("Code expired");    }  });}); describe("refreshAccessToken", () => {  afterEach(() => {    globalThis.fetch = originalFetch;  });   const validBody = { access_token: "tok2", token_type: "Bearer", expires_in: 3600 };   it("posts grant_type=refresh_token with the refresh token", async () => {    const { calls } = captureFetch(jsonResponse(200, validBody));    await Effect.runPromise(      refreshAccessToken({        tokenUrl: "https://example.com/token",        clientId: "cid",        clientSecret: "csecret",        refreshToken: "old",      }),    );    const body = calls[0]!.init.body as URLSearchParams;    expect(body.get("grant_type")).toBe("refresh_token");    expect(body.get("refresh_token")).toBe("old");    expect(body.get("client_id")).toBe("cid");    expect(body.get("client_secret")).toBe("csecret");  });   it("includes scope when scopes are provided", async () => {    const { calls } = captureFetch(jsonResponse(200, validBody));    await Effect.runPromise(      refreshAccessToken({        tokenUrl: "https://example.com/token",        clientId: "cid",        refreshToken: "old",        scopes: ["a", "b"],      }),    );    const body = calls[0]!.init.body as URLSearchParams;    expect(body.get("scope")).toBe("a b");  });   it("omits scope when scopes is empty", async () => {    const { calls } = captureFetch(jsonResponse(200, validBody));    await Effect.runPromise(      refreshAccessToken({        tokenUrl: "https://example.com/token",        clientId: "cid",        refreshToken: "old",        scopes: [],      }),    );    const body = calls[0]!.init.body as URLSearchParams;    expect(body.has("scope")).toBe(false);  });}); // ---------------------------------------------------------------------------// shouldRefreshToken// --------------------------------------------------------------------------- describe("shouldRefreshToken", () => {  it("never refreshes when expiresAt is null", () => {    expect(shouldRefreshToken({ expiresAt: null })).toBe(false);  });   it("returns true when within the skew window", () => {    const now = 1_000_000;    expect(shouldRefreshToken({ expiresAt: now + 30_000, now })).toBe(true);  });   it("returns false when comfortably in the future", () => {    const now = 1_000_000;    expect(shouldRefreshToken({ expiresAt: now + 5 * 60_000, now })).toBe(false);  });   it("uses the documented 60s default skew", () => {    expect(OAUTH2_REFRESH_SKEW_MS).toBe(60_000);    const now = 1_000_000;    expect(shouldRefreshToken({ expiresAt: now + 59_000, now })).toBe(true);    expect(shouldRefreshToken({ expiresAt: now + 61_000, now })).toBe(false);  });   it("respects a custom skew", () => {    const now = 1_000_000;    expect(shouldRefreshToken({ expiresAt: now + 30_000, now, skewMs: 10_000 })).toBe(false);    expect(shouldRefreshToken({ expiresAt: now + 5_000, now, skewMs: 10_000 })).toBe(true);  });}); // ---------------------------------------------------------------------------// Error type plumbing — make sure Effect propagates the tagged error// --------------------------------------------------------------------------- 

Lines 1–500 of 531.