Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions projects/hutch/src/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,7 @@ export function createApp(dependencies: AppDependencies): Express {
const viewRouter = initViewRoutes({
validateSaveableUrl: deps.validateSaveableUrl,
appOrigin,
secureCookies,
findArticleByUrl: deps.findArticleByUrl,
findArticleFreshness: deps.findArticleFreshness,
readArticleContent: deps.readArticleContent,
Expand Down
16 changes: 14 additions & 2 deletions projects/hutch/src/runtime/web/auth/apple-auth.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { LoginPage } from "./auth.component";
import { initFetchUserCount } from "./fetch-user-count";
import { ClickAttributionSchema, readClickAttribution } from "@packages/web-analytics";
import { PENDING_SAVE_COOKIE_NAME, readPendingSaveId } from "../pending-save";
import { LAST_VIEW_COOKIE_NAME, readLastViewUrl } from "../last-view";
import { resolvePostSignupRedirect } from "./post-signup-redirect";
import type { ConversionEvent } from "../../conversions";
import { emitUserCreated } from "../../conversions";
import { signState, verifyState } from "./oauth-state";
Expand All @@ -59,6 +61,7 @@ const StatePayloadSchema = z.object({
attribution: ClickAttributionSchema.optional(),
visitorId: z.string().optional(),
pendingSaveId: z.string().optional(),
lastViewUrl: z.string().optional(),
});

const STATE_COOKIE = "hutch_astate";
Expand Down Expand Up @@ -117,13 +120,15 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => {
const attribution = readClickAttribution(req);
const visitorId = req.visitorId;
const pendingSaveId = readPendingSaveId(req);
const lastViewUrl = readLastViewUrl(req);
const statePayload = JSON.stringify({
nonce,
returnUrl,
createdAt,
...(attribution ? { attribution } : {}),
...(visitorId ? { visitorId } : {}),
...(pendingSaveId ? { pendingSaveId } : {}),
...(lastViewUrl ? { lastViewUrl } : {}),
});
const signedState = signState({ payload: statePayload, secret: deps.stateSigningSecret });

Expand Down Expand Up @@ -237,6 +242,11 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => {
* the response Set-Cookie still applies to readplace.com — clear it so the
* consumed save cannot re-attach to a later signup. */
const clearPendingSave = () => res.clearCookie(PENDING_SAVE_COOKIE_NAME, { path: "/" });
/* Same rationale as clearPendingSave: hutch_lastview is same-site Lax so it
* is not sent on this cross-site POST, but the response Set-Cookie still
* applies to readplace.com — clear it so the tunneled URL cannot auto-save
* onto a later signup. */
const clearLastView = () => res.clearCookie(LAST_VIEW_COOKIE_NAME, { path: "/" });

const userCount = await fetchUserCount();
if (!deps.foundingAllocation.isFoundingAllocationExhausted(userCount)) {
Expand Down Expand Up @@ -268,6 +278,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => {
const sessionId = await deps.createSession({ userId: created.userId, emailVerified: true });
res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions);
clearPendingSave();
clearLastView();
sendWelcomeEmail(tokenResult.email);
emitUserCreated(
{ logger: deps.conversionLogger, now: deps.now },
Expand All @@ -279,7 +290,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => {
...conversionContext,
},
);
res.redirect(303, parseReturnUrl({ return: safeReturnUrl }));
res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl }));
return;
}

Expand Down Expand Up @@ -338,6 +349,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => {
const sessionId = await deps.createSession({ userId: created.userId, emailVerified: true });
res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions);
clearPendingSave();
clearLastView();
sendWelcomeEmail(tokenResult.email);
emitUserCreated(
{ logger: deps.conversionLogger, now: deps.now },
Expand All @@ -349,7 +361,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => {
...conversionContext,
},
);
res.redirect(303, parseReturnUrl({ return: safeReturnUrl }));
res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl }));
});

return router;
Expand Down
68 changes: 68 additions & 0 deletions projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function freshState(overrides?: {
attribution?: Record<string, unknown>;
visitorId?: string;
pendingSaveId?: string;
lastViewUrl?: string;
}) {
return {
nonce: "test-nonce",
Expand All @@ -67,6 +68,7 @@ function freshState(overrides?: {
...(overrides?.attribution ? { attribution: overrides.attribution } : {}),
...(overrides?.visitorId ? { visitorId: overrides.visitorId } : {}),
...(overrides?.pendingSaveId ? { pendingSaveId: overrides.pendingSaveId } : {}),
...(overrides?.lastViewUrl ? { lastViewUrl: overrides.lastViewUrl } : {}),
};
}

Expand Down Expand Up @@ -527,6 +529,72 @@ describe("Apple auth routes", () => {
expect(passwordCheck.ok).toBe(true);
});

describe("first-article autosave", () => {
const ARTICLE_URL = "https://example.com/post";
const AUTOSAVE_LOCATION = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`;

it("tunnels the last-viewed url into the signed state at GET so it survives the cross-site callback", async () => {
const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN);
const harness = useApp({ ...fixture, apple: appleWith() });

const getResponse = await request(harness.server)
.get("/auth/apple")
.set("Cookie", `hutch_lastview=${encodeURIComponent(ARTICLE_URL)}`);

expect(getResponse.status).toBe(303);
const state = readSetCookie(getResponse, "hutch_astate");
assert(state, "GET must set a state cookie carrying the tunneled last-view url");
const payload = JSON.parse(state.slice(0, state.lastIndexOf(".")));
expect(payload.lastViewUrl).toBe(ARTICLE_URL);
});

it("auto-saves the tunneled article for a new Apple user with no explicit return, and clears the cookie", async () => {
const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN);
const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-apple@example.com" })) });
await harness.auth.createUser({ email: "seed1@test.com", password: "password123" });
const state = signState(freshState({ lastViewUrl: ARTICLE_URL }));

const response = await postCallback(harness.server, {
state,
cookie: `hutch_astate=${encodeURIComponent(state)}`,
});

expect(response.status).toBe(303);
expect(response.headers.location).toBe(AUTOSAVE_LOCATION);
expect(cookiesFrom(response).join(";")).toContain("hutch_lastview=;");
}, 30000);

it("lets an explicit return URL win over the tunneled autosave", async () => {
const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN);
const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-return-apple@example.com" })) });
await harness.auth.createUser({ email: "seed1@test.com", password: "password123" });
const state = signState(freshState({ lastViewUrl: ARTICLE_URL, returnUrl: "/oauth/authorize?client_id=test" }));

const response = await postCallback(harness.server, {
state,
cookie: `hutch_astate=${encodeURIComponent(state)}`,
});

expect(response.status).toBe(303);
expect(response.headers.location).toBe("/oauth/authorize?client_id=test");
}, 30000);

it("redirects to a plain /queue when the state carries no last-view url", async () => {
const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN);
const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-plain-apple@example.com" })) });
await harness.auth.createUser({ email: "seed1@test.com", password: "password123" });
const state = signState(freshState());

const response = await postCallback(harness.server, {
state,
cookie: `hutch_astate=${encodeURIComponent(state)}`,
});

expect(response.status).toBe(303);
expect(response.headers.location).toBe("/queue");
}, 30000);
});

it("tunnels attribution, visitor id, and pending-save id captured at GET through the cross-site callback", async () => {
const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN);
const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "tunnel@example.com" })) });
Expand Down
9 changes: 7 additions & 2 deletions projects/hutch/src/runtime/web/auth/auth.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ import { initValidateSignup } from "./validate-signup";
import type { FoundingAllocation } from "../shared/founding-progress/founding-allocation";
import { readClickAttribution } from "@packages/web-analytics";
import { consumePendingSaveId } from "../pending-save";
import { consumeLastViewUrl, LAST_VIEW_COOKIE_NAME } from "../last-view";
import { resolvePostSignupRedirect } from "./post-signup-redirect";
import type { ConversionEvent } from "../../conversions";
import { emitUserCreated } from "../../conversions";

Expand Down Expand Up @@ -334,7 +336,8 @@ export function initAuthRoutes(deps: AuthDependencies): Router {
pendingSaveId: consumePendingSaveId({ req, res }),
},
);
res.redirect(303, parseReturnUrl({ return: returnUrl }));
const lastViewUrl = consumeLastViewUrl({ req, res });
res.redirect(303, resolvePostSignupRedirect({ returnUrl, lastViewUrl }));
return;
}

Expand Down Expand Up @@ -389,7 +392,8 @@ export function initAuthRoutes(deps: AuthDependencies): Router {
pendingSaveId: consumePendingSaveId({ req, res }),
},
);
res.redirect(303, parseReturnUrl({ return: returnUrl }));
const lastViewUrl = consumeLastViewUrl({ req, res });
res.redirect(303, resolvePostSignupRedirect({ returnUrl, lastViewUrl }));
});

router.get("/auth/checkout/success", async (req: Request, res: Response) => {
Expand Down Expand Up @@ -524,6 +528,7 @@ export function initAuthRoutes(deps: AuthDependencies): Router {
await deps.destroySession(sessionId);
}
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.clearCookie(LAST_VIEW_COOKIE_NAME, { path: "/" });
res.redirect(303, "/");
});

Expand Down
104 changes: 104 additions & 0 deletions projects/hutch/src/runtime/web/auth/auth.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,104 @@ describe("Auth routes", () => {

});

describe("POST /signup — first-article autosave", () => {
const ARTICLE_URL = "https://example.com/post";
const VIEW_PATH = "/view/example.com/post";
const AUTOSAVE_LOCATION = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`;

function lastViewSetCookie(response: request.Response): string | undefined {
const setCookie = response.headers["set-cookie"];
const cookies = Array.isArray(setCookie) ? setCookie : [];
return cookies.find((c) => c.startsWith("hutch_lastview="));
}

it("redirects a free signup to auto-save the article the visitor just viewed, and clears the cookie", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const agent = request.agent(harness.server);
await agent.get(VIEW_PATH);

const response = await agent.post("/signup").type("form").send({
email: "autosave-free@example.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(response.status).toBe(303);
expect(response.headers.location).toBe(AUTOSAVE_LOCATION);
const cleared = lastViewSetCookie(response);
assert(cleared, "signup must clear hutch_lastview after consuming it");
expect(cleared.startsWith("hutch_lastview=;")).toBe(true);
}, 30000);

it("lets an explicit ?return= win over the autosave even when the cookie is present", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const agent = request.agent(harness.server);
await agent.get(VIEW_PATH);

const response = await agent.post("/signup?return=%2Foauth%2Fauthorize").type("form").send({
email: "autosave-return@example.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(response.status).toBe(303);
expect(response.headers.location).toBe("/oauth/authorize");
}, 30000);

it("redirects a free signup to a plain /queue when no hutch_lastview cookie is present", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));

const response = await request(harness.server).post("/signup").type("form").send({
email: "autosave-none@example.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(response.status).toBe(303);
expect(response.headers.location).toBe("/queue");
}, 30000);

it("ignores a tampered hutch_lastview cookie and redirects to a plain /queue", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));

const response = await request(harness.server)
.post("/signup")
.type("form")
.set("Cookie", "hutch_lastview=not-a-url")
.send({
email: "autosave-tampered@example.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(response.status).toBe(303);
expect(response.headers.location).toBe("/queue");
}, 30000);

it("auto-saves through the trial signup branch when the founding allocation is exhausted", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
for (let i = 0; i < TEST_FOUNDING_MEMBER_LIMIT; i++) {
await harness.auth.createUser({ email: `seed${i}@test.com`, password: "password123" });
}
const agent = request.agent(harness.server);
await agent.get(VIEW_PATH);

const response = await agent.post("/signup").type("form").send({
email: "autosave-trial@example.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(response.status).toBe(303);
expect(response.headers.location).toBe(AUTOSAVE_LOCATION);
}, 30000);
});

describe("POST /signup — bot defense", () => {
it("returns a fake-success 303 to /?signup=pending and logs a 'honeypot' rejection when the hidden website field is filled", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
Expand Down Expand Up @@ -1184,6 +1282,12 @@ describe("Auth routes", () => {

expect(response.status).toBe(303);
expect(response.headers.location).toBe("/");
const setCookie = response.headers["set-cookie"];
const cookies = Array.isArray(setCookie) ? setCookie : [];
assert(
cookies.some((c) => c.startsWith("hutch_lastview=;")),
"logout must clear hutch_lastview so it cannot auto-save onto a later signup",
);
});

it("should handle logout when no session cookie exists", async () => {
Expand Down
8 changes: 6 additions & 2 deletions projects/hutch/src/runtime/web/auth/google-auth.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { LoginPage } from "./auth.component";
import { initFetchUserCount } from "./fetch-user-count";
import { readClickAttribution } from "@packages/web-analytics";
import { consumePendingSaveId } from "../pending-save";
import { consumeLastViewUrl } from "../last-view";
import { resolvePostSignupRedirect } from "./post-signup-redirect";
import type { ConversionEvent } from "../../conversions";
import { emitUserCreated } from "../../conversions";
import { signState, verifyState } from "./oauth-state";
Expand Down Expand Up @@ -218,7 +220,8 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => {
pendingSaveId: consumePendingSaveId({ req, res }),
},
);
res.redirect(303, parseReturnUrl({ return: safeReturnUrl }));
const lastViewUrl = consumeLastViewUrl({ req, res });
res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl }));
return;
}

Expand Down Expand Up @@ -284,7 +287,8 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => {
pendingSaveId: consumePendingSaveId({ req, res }),
},
);
res.redirect(303, parseReturnUrl({ return: safeReturnUrl }));
const lastViewUrl = consumeLastViewUrl({ req, res });
res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl }));
});

return router;
Expand Down
Loading
Loading