1662 lines · 58.1 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 { Cause, Effect, Exit, Ref, Schema } from "effect";import type * as Tracer from "effect/Tracer";import { HttpServerResponse } from "effect/unstable/http"; import {  OAUTH2_DEFAULT_TIMEOUT_MS,  OAUTH2_REFRESH_SKEW_MS,  OAuth2Error,  PREVIEWABLE_BODY_FIELDS,  PREVIEWABLE_WITHIN_ERROR_FIELDS,  buildAuthorizationUrl,  providerAuthorizeExtras,  createPkceCodeChallenge,  createPkceCodeVerifier,  exchangeAuthorizationCode,  exchangeClientCredentials,  idTokenIdentityLabel,  isPermanentTokenRejection,  isUnusableSuccessTokenResponse,  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;  readonly jsonBody: unknown;} const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); 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),          jsonBody: request.headers["content-type"]?.startsWith("application/json")            ? decodeJsonBody(bodyText)            : null,        };        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); /** Records each span's attributes and its ending exit, so a test can assert on *  exactly what telemetry would export for the token-request span. */interface RecordedSpan {  readonly attributes: Map<string, unknown>;  endExit?: unknown;} const makeRecordingTracer = (spans: Map<string, RecordedSpan>): Tracer.Tracer => ({  span: (options) => {    const record: RecordedSpan = { attributes: new Map() };    spans.set(options.name, record);    let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };    return {      _tag: "Span",      name: options.name,      spanId: "0000000000000001",      traceId: "00000000000000000000000000000001",      parent: options.parent,      annotations: options.annotations,      get status() {        return status;      },      attributes: record.attributes,      links: options.links,      sampled: options.sampled,      kind: options.kind,      end: (endTime, exit) => {        record.endExit = exit;        status = { _tag: "Ended", startTime: options.startTime, endTime, exit };      },      attribute: (key, value) => {        record.attributes.set(key, value);      },      event: () => undefined,      addLinks: () => undefined,    };  },}); const tokenResponseFetch =  (body: unknown): typeof globalThis.fetch =>  async () =>    new Response(JSON.stringify(body), {      status: 200,      headers: { "content-type": "application/json" },    }); // ---------------------------------------------------------------------------// 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("supports JSON token exchange with HTTP Basic client authentication", () =>    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",          requestFormat: "json",        });        const call = (yield* calls)[0]!;        expect(call.headers["content-type"]).toBe("application/json");        expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ=");        expect(call.jsonBody).toEqual({          grant_type: "authorization_code",          code: "abc",          redirect_uri: "https://app.example.com/cb",          code_verifier: "verifier",        });      }),    ),  );   it.effect("supports JSON token exchange with client credentials in the body", () =>    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",          requestFormat: "json",        });        expect((yield* calls)[0]!.jsonBody).toEqual({          grant_type: "authorization_code",          code: "abc",          redirect_uri: "https://app.example.com/cb",          code_verifier: "verifier",          client_id: "cid",          client_secret: "csecret",        });      }),    ),  );   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");          expect(result.idTokenIdentityLabel).toBe("user-1");        }),    ),  );   it.effect("extracts id_token email as the identity label", () =>    withTokenEndpoint(      tokenResponse({        ...validCodeBody,        id_token: unsignedJwt({          email: "alice@example.com",          preferred_username: "alice",          sub: "user-1",        }),      }),      ({ tokenUrl }) =>        Effect.gen(function* () {          const result = yield* exchangeAuthorizationCode({            tokenUrl,            clientId: "cid",            redirectUrl: "https://app.example.com/cb",            codeVerifier: "verifier",            code: "abc",          });          expect(result.idTokenIdentityLabel).toBe("alice@example.com");        }),    ),

Lines 1–500 of 1662.