src/lib/google-oauth.test.ts
Rama main · réplica de lectura del repositorio original.
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();
});
});