853 lines · 28.0 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Fidelity suite — locks in every edge case the prior hand-rolled// Google OAuth integrations handled, so future "simplifications" of the// shared helpers fail loudly instead of silently breaking refresh / parsing /// provider-specific quirks.// --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest";import { Effect, Exit, Ref } from "effect";import { HttpServerResponse } from "effect/unstable/http"; import {  OAUTH2_DEFAULT_TIMEOUT_MS,  OAUTH2_REFRESH_SKEW_MS,  OAuth2Error,  buildAuthorizationUrl,  providerAuthorizeExtras,  createPkceCodeChallenge,  createPkceCodeVerifier,  exchangeAuthorizationCode,  exchangeClientCredentials,  refreshAccessToken,  shouldRefreshToken,} from "./oauth-helpers";import { serveTestHttpApp } from "./testing"; interface TokenCall {  readonly method: string;  readonly url: string;  readonly headers: Readonly<Record<string, string>>;  readonly body: URLSearchParams;} type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>  HttpServerResponse.jsonUnsafe(body, { status }); const serveTokenEndpoint = (handler: TokenHandler) =>  Effect.gen(function* () {    const calls = yield* Ref.make<readonly TokenCall[]>([]);    const server = yield* serveTestHttpApp((request) =>      Effect.gen(function* () {        const bodyText = yield* request.text;        const call = {          method: request.method,          url: request.url ?? "/",          headers: request.headers,          body: new URLSearchParams(bodyText),        };        yield* Ref.update(calls, (all) => [...all, call]);        return handler(call);      }).pipe(        Effect.catch(() =>          Effect.succeed(HttpServerResponse.text("token fixture failed", { status: 500 })),        ),      ),    );     return {      tokenUrl: server.url("/token"),      calls: Ref.get(calls),    } as const;  }); const withTokenEndpoint = <A, E>(  handler: TokenHandler,  use: (fixture: {    readonly tokenUrl: string;    readonly calls: Effect.Effect<readonly TokenCall[]>;  }) => Effect.Effect<A, E>,) =>  Effect.scoped(    Effect.gen(function* () {      const fixture = yield* serveTokenEndpoint(handler);      return yield* use(fixture);    }),  ); const validCodeBody = {  access_token: "tok",  token_type: "Bearer",  refresh_token: "rtok",  expires_in: 3600,  scope: "read",}; const validRefreshBody = { access_token: "tok2", token_type: "Bearer", expires_in: 3600 }; const jwtPart = (value: unknown): string =>  Buffer.from(JSON.stringify(value)).toString("base64url"); const unsignedJwt = (claims: Record<string, unknown>, alg = "RS256"): string =>  `${jwtPart({ alg, typ: "JWT" })}.${jwtPart(claims)}.sig`; const tokenResponse =  (body: unknown): TokenHandler =>  () =>    json(200, body); // ---------------------------------------------------------------------------// 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", async () => {    const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";    const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";    expect(await 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("providerAuthorizeExtras (Google offline/consent quirk)", () => {  it("adds access_type=offline + prompt=consent for the Google authorize host", () => {    expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({      access_type: "offline",      prompt: "consent",    });  });  it("adds nothing for non-Google hosts or an unparseable URL (token host ≠ authorize host)", () => {    expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({});    expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({});    expect(providerAuthorizeExtras("not a url")).toEqual({});  });}); 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",    codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",  };   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("omits scope when no scopes are requested", () => {    const url = new URL(buildAuthorizationUrl({ ...baseInput, scopes: [] }));    expect(url.searchParams.has("scope")).toBe(false);  });   it("merges provider extra params without dropping them", () => {    const url = new URL(      buildAuthorizationUrl({        ...baseInput,        extraParams: {          access_type: "offline",          prompt: "consent",        },      }),    );    expect(url.searchParams.get("access_type")).toBe("offline");    expect(url.searchParams.get("prompt")).toBe("consent");    expect(url.searchParams.has("include_granted_scopes")).toBe(false);    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");  });   it("includes RFC 8707 resource indicator when provided", () => {    const url = new URL(      buildAuthorizationUrl({        ...baseInput,        resource: "https://api.example.com/v1/mcp",      }),    );    expect(url.searchParams.get("resource")).toBe("https://api.example.com/v1/mcp");  });   it("omits resource parameter when not provided", () => {    const url = new URL(buildAuthorizationUrl(baseInput));    expect(url.searchParams.has("resource")).toBe(false);  });   it("rejects unsupported authorization URL schemes", () => {    expect(() =>      buildAuthorizationUrl({        ...baseInput,        authorizationUrl: "javascript:alert(1)",      }),    ).toThrow(/Authorization URL must use https: or loopback http:/);    expect(() =>      buildAuthorizationUrl({        ...baseInput,        authorizationUrl: "http://example.com/authorize",      }),    ).toThrow(/Authorization URL must use https: or loopback http:/);  });   it("allows HTTP authorization URLs when the caller opts into local HTTP", () => {    const url = new URL(      buildAuthorizationUrl({        ...baseInput,        authorizationUrl: "http://example.com/authorize",        endpointUrlPolicy: { allowHttp: true },      }),    );    expect(url.origin + url.pathname).toBe("http://example.com/authorize");  });}); describe("exchangeAuthorizationCode", () => {  it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () =>    withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>      Effect.gen(function* () {        const result = yield* exchangeAuthorizationCode({          tokenUrl,          clientId: "cid",          clientSecret: "csecret",          redirectUrl: "https://app.example.com/cb",          codeVerifier: "verifier",          code: "abc",        });        expect(result.access_token).toBe("tok");        const call = (yield* calls)[0]!;        expect(call.method).toBe("POST");        expect(call.headers["content-type"]).toMatch(/^application\/x-www-form-urlencoded/);        expect(call.headers["accept"]).toContain("application/json");        expect(call.body.get("grant_type")).toBe("authorization_code");        expect(call.body.get("client_id")).toBe("cid");        expect(call.body.get("client_secret")).toBe("csecret");        expect(call.body.get("redirect_uri")).toBe("https://app.example.com/cb");        expect(call.body.get("code_verifier")).toBe("verifier");        expect(call.body.get("code")).toBe("abc");      }),    ),  );   it.effect("rejects unsupported token URL schemes before exchange", () =>    Effect.gen(function* () {      const exit = yield* Effect.exit(        exchangeAuthorizationCode({          tokenUrl: "http://example.com/token",          clientId: "cid",          redirectUrl: "https://app.example.com/cb",          codeVerifier: "verifier",          code: "abc",        }),      );      expect(Exit.isFailure(exit)).toBe(true);      if (!Exit.isFailure(exit)) return;      expect(JSON.stringify(exit.cause)).toContain("Token URL must use https: or loopback http:");    }),  );   it.effect("omits client_secret when none is provided (public clients with PKCE)", () =>    withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>      Effect.gen(function* () {        yield* exchangeAuthorizationCode({          tokenUrl,          clientId: "cid",          redirectUrl: "https://app.example.com/cb",          codeVerifier: "verifier",          code: "abc",        });        const body = (yield* calls)[0]!.body;        expect(body.get("client_id")).toBe("cid");        expect(body.has("client_secret")).toBe(false);      }),    ),  );   it.effect("includes RFC 8707 resource parameter on the token request when provided", () =>    withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>      Effect.gen(function* () {        yield* exchangeAuthorizationCode({          tokenUrl,          clientId: "cid",          redirectUrl: "https://app.example.com/cb",          codeVerifier: "verifier",          code: "abc",          resource: "https://api.example.com/v1/mcp",        });        expect((yield* calls)[0]!.body.get("resource")).toBe("https://api.example.com/v1/mcp");      }),    ),  );   it.effect("omits resource parameter when not provided", () =>    withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>      Effect.gen(function* () {        yield* exchangeAuthorizationCode({          tokenUrl,          clientId: "cid",          redirectUrl: "https://app.example.com/cb",          codeVerifier: "verifier",          code: "abc",        });        expect((yield* calls)[0]!.body.has("resource")).toBe(false);      }),    ),  );   it.effect("strips id_tokens whose iss does not match AS metadata", () =>    withTokenEndpoint(      tokenResponse({        ...validCodeBody,        id_token: unsignedJwt({          iss: "https://us.posthog.com",          aud: "cid",          sub: "user-1",          exp: Math.floor(Date.now() / 1000) + 3600,          iat: Math.floor(Date.now() / 1000),        }),      }),      ({ tokenUrl }) =>        Effect.gen(function* () {          const result = yield* exchangeAuthorizationCode({            tokenUrl,            issuerUrl: new URL(tokenUrl).origin,            clientId: "cid",            redirectUrl: "https://app.example.com/cb",            codeVerifier: "verifier",            code: "abc",          });          expect(result.access_token).toBe("tok");          expect(result.refresh_token).toBe("rtok");        }),    ),  );   it.effect("strips id_tokens whose aud does not match the client_id", () =>    withTokenEndpoint(      tokenResponse({        ...validCodeBody,        id_token: unsignedJwt({          iss: "http://127.0.0.1",          aud: "another-client",          sub: "user-1",          exp: Math.floor(Date.now() / 1000) + 3600,          iat: Math.floor(Date.now() / 1000),        }),      }),      ({ tokenUrl }) =>        Effect.gen(function* () {          const result = yield* exchangeAuthorizationCode({            tokenUrl,            issuerUrl: new URL(tokenUrl).origin,            clientId: "cid",            redirectUrl: "https://app.example.com/cb",            codeVerifier: "verifier",            code: "abc",          });          expect(result.access_token).toBe("tok");        }),    ),  );   it.effect("happy path: token endpoint with no id_token still parses normally", () =>    withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl }) =>      Effect.gen(function* () {        const result = yield* exchangeAuthorizationCode({          tokenUrl,          clientId: "cid",          redirectUrl: "https://app.example.com/cb",          codeVerifier: "verifier",          code: "abc",        });        expect(result.access_token).toBe("tok");        expect(result.refresh_token).toBe("rtok");        expect(result.expires_in).toBe(3600);      }),    ),  );   it.effect("still surfaces RFC 6749 §5.2 error envelopes after the id_token strip", () =>    withTokenEndpoint(      () =>        json(400, {          error: "invalid_grant",          error_description: "authorization code expired",        }),      ({ tokenUrl }) =>        Effect.gen(function* () {          const exit = yield* Effect.exit(            exchangeAuthorizationCode({              tokenUrl,              clientId: "cid",              redirectUrl: "https://app.example.com/cb",              codeVerifier: "verifier",              code: "abc",            }),          );          expect(Exit.isFailure(exit)).toBe(true);          if (!Exit.isFailure(exit)) return;          const failure = JSON.stringify(exit.cause);          expect(failure).toContain("OAuth2Error");          expect(failure).toContain("invalid_grant");          expect(failure).toContain("authorization code expired");        }),    ),  );   it.effect("strips id_tokens with algorithms not advertised in AS metadata", () =>    withTokenEndpoint(      tokenResponse({        ...validCodeBody,        id_token: unsignedJwt(          {            iss: "http://127.0.0.1",            aud: "cid",            sub: "user-1",            exp: Math.floor(Date.now() / 1000) + 3600,            iat: Math.floor(Date.now() / 1000),          },          "ES256",        ),      }),      ({ tokenUrl }) =>        Effect.gen(function* () {          const result = yield* exchangeAuthorizationCode({            tokenUrl,            issuerUrl: new URL(tokenUrl).origin,            clientId: "cid",            redirectUrl: "https://app.example.com/cb",            codeVerifier: "verifier",            code: "abc",          });          expect(result.access_token).toBe("tok");          expect(result.refresh_token).toBe("rtok");        }),    ),  );   it.effect("uses HTTP Basic auth when clientAuth=basic (Stripe-style)", () =>    withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>      Effect.gen(function* () {        yield* exchangeAuthorizationCode({          tokenUrl,          clientId: "cid",          clientSecret: "csecret",          redirectUrl: "https://app.example.com/cb",          codeVerifier: "verifier",          code: "abc",          clientAuth: "basic",        });        const call = (yield* calls)[0]!;        const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`;        expect(call.headers["authorization"]).toBe(expected);        expect(call.body.has("client_id")).toBe(false);        expect(call.body.has("client_secret")).toBe(false);      }),    ),  );   it.effect("uses the documented 20-second timeout default", () =>    withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl }) =>      Effect.gen(function* () {        yield* exchangeAuthorizationCode({          tokenUrl,          clientId: "cid",          redirectUrl: "https://cb",          codeVerifier: "v",

Lines 1–500 of 853.