Ctrl K
ComunidadGuías…

src/lib/google-oauth.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { memoryStorage, useStorage } from "@/test/memory-storage";
import {
  beginGoogleSignIn,
  consumeGoogleAttempt,
  googleRedirectUri,
  GOOGLE_CALLBACK_PATH,
} from "./google-oauth";

const KEY = "opendiving:google-sign-in-attempts";
const CLIENT_ID = "test-client.apps.googleusercontent.com";

// jsdom serves `http://localhost:3000` by default, which is also the origin the
// local API derives its expected `redirect_uri` from - so the assertions below
// about the redirect URI are the real string, not a stand-in.
function stateOf(url: string): string {
  return new URL(url).searchParams.get("state")!;
}

function storedAttempts(): Record<string, { expiresAt: number }> {
  return JSON.parse(window.localStorage.getItem(KEY) ?? "{}");
}

beforeEach(() => {
  useStorage(memoryStorage());
});

afterEach(() => {
  vi.useRealTimers();
});

describe("the authorization URL", () => {
  it("is Google's authorization endpoint carrying exactly the parameters the flow needs", async () => {
    const url = new URL(await beginGoogleSignIn({ clientId: CLIENT_ID }));

    expect(`${url.origin}${url.pathname}`).toBe(
      "https://accounts.google.com/o/oauth2/v2/auth",
    );
    expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
    expect(url.searchParams.get("response_type")).toBe("code");
    expect(url.searchParams.get("scope")).toBe("openid email profile");
    expect(url.searchParams.get("prompt")).toBe("select_account");
    expect(url.searchParams.get("code_challenge_method")).toBe("S256");
    expect(url.searchParams.get("code_challenge")).toBeTruthy();
    expect(url.searchParams.get("state")).toBeTruthy();
  });

  // Invariant, not a detail: `access_type=offline` is what asks Google for a
  // refresh token, and `/privacy` §4.12 tells the reader this app is never handed
  // a Google credential it could store. That has to be true by construction.
  it("never asks for offline access, so Google issues no refresh token", async () => {
    const url = new URL(await beginGoogleSignIn({ clientId: CLIENT_ID }));

    expect(url.searchParams.get("access_type")).toBeNull();
  });

  // A nonce is required by Google for `response_type=id_token` and optional for
  // the code flow. This app deliberately sends none - the ID token never touches
  // the browser - and a future edit that adds one should have to say why.
  it("sends no nonce", async () => {
    const url = new URL(await beginGoogleSignIn({ clientId: CLIENT_ID }));

    expect(url.searchParams.get("nonce")).toBeNull();
  });

  it("sends the browser's own origin as the redirect URI", async () => {
    const url = new URL(await beginGoogleSignIn({ clientId: CLIENT_ID }));

    expect(url.searchParams.get("redirect_uri")).toBe(
      `${window.location.origin}${GOOGLE_CALLBACK_PATH}`,
    );
    expect(googleRedirectUri()).toBe(url.searchParams.get("redirect_uri"));
  });

  it("mints a fresh state and challenge per attempt", async () => {
    const first = new URL(await beginGoogleSignIn({ clientId: CLIENT_ID }));
    const second = new URL(await beginGoogleSignIn({ clientId: CLIENT_ID }));

    expect(first.searchParams.get("state")).not.toBe(
      second.searchParams.get("state"),
    );
    expect(first.searchParams.get("code_challenge")).not.toBe(
      second.searchParams.get("code_challenge"),
    );
  });
});

describe("the PKCE verifier", () => {
  // The API validates this field to RFC 7636 §4.1 and answers 422 to anything
  // else, so a verifier built with standard base64 - `+` and `/` are outside the
  // unreserved set - would fail at the schema rather than at Google. This is the
  // guard on that.
  it("is 43-128 characters drawn only from the unreserved set", async () => {
    const url = await beginGoogleSignIn({ clientId: CLIENT_ID });
    const attempt = consumeGoogleAttempt(stateOf(url))!;

    expect(attempt.codeVerifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
    expect(attempt.codeVerifier.length).toBeGreaterThanOrEqual(43);
    expect(attempt.codeVerifier.length).toBeLessThanOrEqual(128);
  });

  // Same alphabet rule, applied to the value that actually travels in the URL:
  // a challenge containing `+` or `/` would be mangled by percent-decoding on
  // Google's side and rejected as a mismatch at the exchange.
  it("is committed to as a base64url S256 challenge", async () => {
    const url = new URL(await beginGoogleSignIn({ clientId: CLIENT_ID }));

    expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9\-_]+$/);
  });
});

describe("consuming an attempt", () => {
  it("hands back the verifier and destination the attempt was minted with", async () => {
    const url = await beginGoogleSignIn({
      clientId: CLIENT_ID,
      redirectTo: "/dives",
    });

    const attempt = consumeGoogleAttempt(stateOf(url));

    expect(attempt?.redirectTo).toBe("/dives");
    expect(attempt?.codeVerifier).toBeTruthy();
    expect(attempt?.via).toBeNull();
  });

  // A join page's button: the slug has to survive the trip to Google, and `state`
  // - the one value Google echoes back - stays a nonce that carries none of it.
  it("hands back the join link the attempt was minted on", async () => {
    const url = await beginGoogleSignIn({ clientId: CLIENT_ID, via: "reddit" });

    expect(stateOf(url)).not.toContain("reddit");
    expect(consumeGoogleAttempt(stateOf(url))?.via).toBe("reddit");
  });

  // A member's invite-link token rides the same field, and fits the slug's shape.
  it("hands back an invite-link token the same way", async () => {
    const token = "0123456789abcdef0123456789abcdef";
    const url = await beginGoogleSignIn({ clientId: CLIENT_ID, via: token });

    expect(stateOf(url)).not.toContain(token);
    expect(consumeGoogleAttempt(stateOf(url))?.via).toBe(token);
  });

  it("is single-use: a second read of the same state finds nothing", async () => {
    const url = await beginGoogleSignIn({ clientId: CLIENT_ID });
    const state = stateOf(url);

    expect(consumeGoogleAttempt(state)).not.toBeNull();
    expect(consumeGoogleAttempt(state)).toBeNull();
  });

  it("refuses a state this browser never issued", async () => {
    await beginGoogleSignIn({ clientId: CLIENT_ID });

    expect(consumeGoogleAttempt("not-a-state-we-minted")).toBeNull();
  });

  // Names every object has, own or not - read off the prototype, the first one is
  // `Object.prototype` itself, which has no `expiresAt` to fail the expiry check on.
  it.each(["__proto__", "constructor", "toString", "hasOwnProperty"])(
    "refuses %s, which the attempt map inherits rather than holds",
    async (state) => {
      await beginGoogleSignIn({ clientId: CLIENT_ID });

      expect(consumeGoogleAttempt(state)).toBeNull();
    },
  );

  it("refuses a missing state without touching what is stored", async () => {
    const url = await beginGoogleSignIn({ clientId: CLIENT_ID });

    expect(consumeGoogleAttempt(null)).toBeNull();
    expect(consumeGoogleAttempt(undefined)).toBeNull();
    expect(consumeGoogleAttempt("")).toBeNull();
    // The real attempt is still there to be used.
    expect(consumeGoogleAttempt(stateOf(url))).not.toBeNull();
  });

  // The whole reason the destination moved inside the per-attempt record. With a
  // single current-attempt slot, tab B's sign-in would overwrite tab A's, and tab
  // A would then either fail its state check or land on tab B's page.
  it("keeps two concurrent attempts from invalidating each other", async () => {
    const tabA = await beginGoogleSignIn({
      clientId: CLIENT_ID,
      redirectTo: "/dives",
    });
    const tabB = await beginGoogleSignIn({ clientId: CLIENT_ID });

    const b = consumeGoogleAttempt(stateOf(tabB));
    const a = consumeGoogleAttempt(stateOf(tabA));

    expect(b?.redirectTo).toBeNull();
    expect(a?.redirectTo).toBe("/dives");
    expect(a?.codeVerifier).not.toBe(b?.codeVerifier);
  });

  it("refuses an attempt that has expired", async () => {
    vi.useFakeTimers();
    const url = await beginGoogleSignIn({ clientId: CLIENT_ID });

    vi.advanceTimersByTime(31 * 60 * 1000);

    expect(consumeGoogleAttempt(stateOf(url))).toBeNull();
  });

  it("holds an attempt across a slow but plausible sign-in", async () => {
    vi.useFakeTimers();
    const url = await beginGoogleSignIn({ clientId: CLIENT_ID });

    // An account chooser, a password, a second factor and a consent screen.
    vi.advanceTimersByTime(20 * 60 * 1000);

    expect(consumeGoogleAttempt(stateOf(url))).not.toBeNull();
  });
});

describe("what is stored", () => {
  it("removes the key entirely once the last attempt is consumed", async () => {
    const url = await beginGoogleSignIn({ clientId: CLIENT_ID });

    consumeGoogleAttempt(stateOf(url));

    expect(window.localStorage.getItem(KEY)).toBeNull();
  });

  // Every write is also a sweep, so a visitor who abandons attempt after attempt
  // never accumulates more than the last half hour's worth.
  it("drops expired attempts as new ones are written", async () => {
    vi.useFakeTimers();
    await beginGoogleSignIn({ clientId: CLIENT_ID });
    await beginGoogleSignIn({ clientId: CLIENT_ID });
    expect(Object.keys(storedAttempts())).toHaveLength(2);

    vi.advanceTimersByTime(31 * 60 * 1000);
    await beginGoogleSignIn({ clientId: CLIENT_ID });

    expect(Object.keys(storedAttempts())).toHaveLength(1);
  });

  it("sanitizes the destination on the way in", async () => {
    const url = await beginGoogleSignIn({
      clientId: CLIENT_ID,
      redirectTo: "//evil.example/steal",
    });

    expect(consumeGoogleAttempt(stateOf(url))?.redirectTo).toBeNull();
  });

  // The stored value has sat somewhere anything else at this browser could have
  // rewritten, so the open-redirect guard runs again on the way out rather than
  // trusting what was written earlier.
  it("sanitizes the destination on the way out", () => {
    window.localStorage.setItem(
      KEY,
      JSON.stringify({
        tampered: {
          codeVerifier: "x".repeat(43),
          redirectTo: "https://evil.example",
          expiresAt: Date.now() + 60_000,
        },
      }),
    );

    expect(consumeGoogleAttempt("tampered")?.redirectTo).toBeNull();
  });

  // The API refuses the whole exchange over a `via` that is not a slug, so a
  // rewritten one is dropped rather than sent - and an entry an older build wrote,
  // with no `via` at all, still completes its sign-in.
  it.each([
    ["rewritten into something that is not a slug", "../../admin"],
    ["a number", 7],
    ["absent", undefined],
  ])("reads a join link that is %s as none", (_label, via) => {
    window.localStorage.setItem(
      KEY,
      JSON.stringify({
        s: {
          codeVerifier: "x".repeat(43),
          redirectTo: null,
          via,
          expiresAt: Date.now() + 60_000,
        },
      }),
    );

    const attempt = consumeGoogleAttempt("s");
    expect(attempt).not.toBeNull();
    expect(attempt?.via).toBeNull();
  });

  it.each([
    ["unparseable", "not json at all"],
    ["an array", "[]"],
    ["a bare string", '"nope"'],
    ["an entry missing its verifier", '{"s":{"expiresAt":99999999999}}'],
    [
      "an entry with a non-numeric expiry",
      '{"s":{"codeVerifier":"x","expiresAt":"soon"}}',
    ],
  ])("treats %s as no attempt at all", (_label, raw) => {
    window.localStorage.setItem(KEY, raw);

    expect(consumeGoogleAttempt("s")).toBeNull();
  });

  // Safari private mode, storage disabled, quota exceeded. Losing the attempt
  // means the callback asks the visitor to try again, which is the right outcome
  // for a browser that cannot remember what it started - but it must not throw
  // out of the click handler.
  it("survives a browser that hands out no storage", async () => {
    useStorage(undefined);

    const url = await beginGoogleSignIn({ clientId: CLIENT_ID });

    expect(url).toContain("code_challenge");
    expect(consumeGoogleAttempt(stateOf(url))).toBeNull();
  });
});