automate-google-login-scraper 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +378 -0
- package/bin/cli.js +6 -0
- package/dist/auth-file.d.ts +91 -0
- package/dist/cli.d.ts +19 -0
- package/dist/google-login.d.ts +145 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +455 -0
- package/dist/puppeteer-state.d.ts +80 -0
- package/dist/secret-B4HOopFT.js +276 -0
- package/dist/secret.d.ts +31 -0
- package/dist/state-core.d.ts +50 -0
- package/dist/storage-state.d.ts +50 -0
- package/dist/types.d.ts +99 -0
- package/dist/worker.d.ts +138 -0
- package/dist/worker.js +309 -0
- package/examples/auth.setup.ts +42 -0
- package/examples/dashboard.spec.ts +20 -0
- package/examples/playwright.config.ts +38 -0
- package/examples/test-auth-endpoint.ts +58 -0
- package/examples/worker-check.ts +49 -0
- package/package.json +82 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { n as normalizeStorageState, s as summarizeStorageState, r as redactStorageState, b as applyStorageState, e as extractStorageState, i as isAuthorized, A as AUTH_HEADER } from "./secret-B4HOopFT.js";
|
|
2
|
+
const KEY_STATE = "storage-state";
|
|
3
|
+
function json(body, status = 200) {
|
|
4
|
+
return new Response(JSON.stringify(body, null, 2), {
|
|
5
|
+
status,
|
|
6
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
async function handleRequest(request, env) {
|
|
10
|
+
if (!env.TEST_AUTH_SECRET) {
|
|
11
|
+
return json(
|
|
12
|
+
{
|
|
13
|
+
error: "not_configured",
|
|
14
|
+
message: "TEST_AUTH_SECRET is not set, so this Worker refuses every request. Set it with `wrangler secret put TEST_AUTH_SECRET`."
|
|
15
|
+
},
|
|
16
|
+
503
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
if (!isAuthorized(request.headers, env.TEST_AUTH_SECRET)) {
|
|
20
|
+
return new Response("Unauthorized", { status: 401, headers: { "x-required-header": AUTH_HEADER } });
|
|
21
|
+
}
|
|
22
|
+
const url = new URL(request.url);
|
|
23
|
+
if (url.pathname === "/health") return json({ ok: true });
|
|
24
|
+
const session = url.searchParams.get("session") || "default";
|
|
25
|
+
const stub = env.BROWSER_SESSION.get(env.BROWSER_SESSION.idFromName(session));
|
|
26
|
+
return stub.fetch(request);
|
|
27
|
+
}
|
|
28
|
+
const worker = {
|
|
29
|
+
fetch: handleRequest
|
|
30
|
+
};
|
|
31
|
+
let puppeteerOverride;
|
|
32
|
+
function setPuppeteer(implementation) {
|
|
33
|
+
puppeteerOverride = implementation;
|
|
34
|
+
}
|
|
35
|
+
async function getPuppeteer() {
|
|
36
|
+
if (puppeteerOverride) return puppeteerOverride;
|
|
37
|
+
const loaded = await import("@cloudflare/puppeteer");
|
|
38
|
+
const implementation = loaded.default ?? loaded;
|
|
39
|
+
if (typeof implementation?.launch !== "function") {
|
|
40
|
+
throw new TypeError(
|
|
41
|
+
"Could not load @cloudflare/puppeteer. Install it in the Worker that deploys this, or call setPuppeteer() with your own implementation."
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return implementation;
|
|
45
|
+
}
|
|
46
|
+
class GoogleLoginBrowser {
|
|
47
|
+
storage;
|
|
48
|
+
env;
|
|
49
|
+
browser;
|
|
50
|
+
constructor(state, env) {
|
|
51
|
+
this.storage = state.storage;
|
|
52
|
+
this.env = env;
|
|
53
|
+
}
|
|
54
|
+
get idleMs() {
|
|
55
|
+
const minutes = Number.parseFloat(this.env.BROWSER_IDLE_MINUTES ?? "3");
|
|
56
|
+
return (Number.isFinite(minutes) && minutes > 0 ? minutes : 3) * 6e4;
|
|
57
|
+
}
|
|
58
|
+
async fetch(request) {
|
|
59
|
+
const url = new URL(request.url);
|
|
60
|
+
try {
|
|
61
|
+
switch (`${request.method} ${url.pathname}`) {
|
|
62
|
+
case "POST /state":
|
|
63
|
+
return await this.putState(request);
|
|
64
|
+
case "GET /state":
|
|
65
|
+
return await this.describeState();
|
|
66
|
+
case "DELETE /state":
|
|
67
|
+
return await this.clearState();
|
|
68
|
+
case "POST /check":
|
|
69
|
+
return await this.check(request);
|
|
70
|
+
case "POST /login":
|
|
71
|
+
return await this.login(request);
|
|
72
|
+
case "POST /close":
|
|
73
|
+
await this.closeBrowser();
|
|
74
|
+
return json({ closed: true });
|
|
75
|
+
default:
|
|
76
|
+
return json({ error: "not_found", message: `No route for ${request.method} ${url.pathname}` }, 404);
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return json({ error: "failed", message: error.message }, 500);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Store a storage state for this session.
|
|
84
|
+
*
|
|
85
|
+
* Validated on the way in, so a malformed upload fails here with a precise
|
|
86
|
+
* reason rather than as an opaque CDP error during the next check.
|
|
87
|
+
*/
|
|
88
|
+
async putState(request) {
|
|
89
|
+
const state = normalizeStorageState(await request.json());
|
|
90
|
+
await this.storage.put(KEY_STATE, state);
|
|
91
|
+
return json({ stored: true, summary: summarizeStorageState(state) });
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Describe the stored state — redacted, always.
|
|
95
|
+
*
|
|
96
|
+
* There is no route that returns the live values. That is the point: this Worker
|
|
97
|
+
* can be handed to CI without handing CI a way to read the session back out.
|
|
98
|
+
*/
|
|
99
|
+
async describeState() {
|
|
100
|
+
const state = await this.storage.get(KEY_STATE);
|
|
101
|
+
if (!state) return json({ present: false }, 404);
|
|
102
|
+
return json({
|
|
103
|
+
present: true,
|
|
104
|
+
summary: summarizeStorageState(state),
|
|
105
|
+
redacted: redactStorageState(state)
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async clearState() {
|
|
109
|
+
const existed = await this.storage.delete(KEY_STATE);
|
|
110
|
+
await this.closeBrowser();
|
|
111
|
+
return json({ cleared: existed });
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Replay the stored session against a URL and report whether it is signed in.
|
|
115
|
+
*
|
|
116
|
+
* "Signed in" is whatever you say it is: `expectSelector` is checked in the page
|
|
117
|
+
* and `rejectUrl` catches the redirect-to-login case, which is the failure that
|
|
118
|
+
* otherwise looks like a passing 200.
|
|
119
|
+
*/
|
|
120
|
+
async check(request) {
|
|
121
|
+
const body = await request.json();
|
|
122
|
+
if (!body.url) return json({ error: "bad_request", message: "`url` is required" }, 400);
|
|
123
|
+
const state = await this.storage.get(KEY_STATE);
|
|
124
|
+
if (!state) {
|
|
125
|
+
return json(
|
|
126
|
+
{
|
|
127
|
+
error: "no_state",
|
|
128
|
+
message: "No stored session for this name. POST /state with a Playwright storage state first."
|
|
129
|
+
},
|
|
130
|
+
409
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const browser = await this.ensureBrowser();
|
|
134
|
+
const page = await browser.newPage();
|
|
135
|
+
try {
|
|
136
|
+
const applied = await applyStorageState(page, state, { origins: body.origins });
|
|
137
|
+
const response = await page.goto(body.url, { waitUntil: "domcontentloaded" });
|
|
138
|
+
const landedUrl = page.url();
|
|
139
|
+
const title = await page.title();
|
|
140
|
+
let authenticated = true;
|
|
141
|
+
let reason = "navigated without being redirected away";
|
|
142
|
+
if (body.rejectUrl && landedUrl.includes(body.rejectUrl)) {
|
|
143
|
+
authenticated = false;
|
|
144
|
+
reason = `redirected to ${body.rejectUrl} — the session was not accepted`;
|
|
145
|
+
} else if (body.expectSelector) {
|
|
146
|
+
const found = await page.evaluate(
|
|
147
|
+
((selector) => document.querySelector(selector) !== null),
|
|
148
|
+
body.expectSelector
|
|
149
|
+
);
|
|
150
|
+
authenticated = found === true;
|
|
151
|
+
reason = found ? `found ${body.expectSelector}` : `${body.expectSelector} is not on the page — probably signed out`;
|
|
152
|
+
}
|
|
153
|
+
const result = {
|
|
154
|
+
authenticated,
|
|
155
|
+
status: typeof response?.status === "function" ? response.status() : null,
|
|
156
|
+
url: landedUrl,
|
|
157
|
+
title,
|
|
158
|
+
reason,
|
|
159
|
+
applied
|
|
160
|
+
};
|
|
161
|
+
if (body.screenshot) {
|
|
162
|
+
const shot = await page.screenshot({ type: "png" });
|
|
163
|
+
result.screenshot = toBase64(shot);
|
|
164
|
+
}
|
|
165
|
+
return json(result);
|
|
166
|
+
} finally {
|
|
167
|
+
await page.close();
|
|
168
|
+
await this.touch();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Sign in to Google for real, in the Worker, and store the resulting session.
|
|
173
|
+
*
|
|
174
|
+
* Gated twice — the flag and the credentials — and still the path you should
|
|
175
|
+
* reach for last. Read {@link signInWithGoogle}'s notes in `google-login.ts`:
|
|
176
|
+
* they apply here in full, with the extra wrinkle that a datacentre IP makes
|
|
177
|
+
* Google's risk checks more likely, not less.
|
|
178
|
+
*/
|
|
179
|
+
async login(request) {
|
|
180
|
+
if (this.env.ALLOW_REAL_GOOGLE_LOGIN !== "true") {
|
|
181
|
+
return json(
|
|
182
|
+
{
|
|
183
|
+
error: "disabled",
|
|
184
|
+
message: 'Real Google sign-in is disabled. Set ALLOW_REAL_GOOGLE_LOGIN="true" and the GOOGLE_TEST_EMAIL / GOOGLE_TEST_PASSWORD secrets to enable POST /login — and prefer capturing the session locally with `playwright codegen` instead.'
|
|
185
|
+
},
|
|
186
|
+
403
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const email = this.env.GOOGLE_TEST_EMAIL;
|
|
190
|
+
const password = this.env.GOOGLE_TEST_PASSWORD;
|
|
191
|
+
if (!email || !password) {
|
|
192
|
+
return json(
|
|
193
|
+
{ error: "disabled", message: "GOOGLE_TEST_EMAIL and GOOGLE_TEST_PASSWORD must both be set." },
|
|
194
|
+
403
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const body = await request.json();
|
|
198
|
+
if (!body.loginUrl) return json({ error: "bad_request", message: "`loginUrl` is required" }, 400);
|
|
199
|
+
if (!body.expectUrl) {
|
|
200
|
+
return json(
|
|
201
|
+
{
|
|
202
|
+
error: "bad_request",
|
|
203
|
+
message: '`expectUrl` is required — a substring of the URL you land on once signed in, e.g. "/dashboard". It is not defaulted to your origin, because the login page is on that origin too and the wait would pass before sign-in even started.'
|
|
204
|
+
},
|
|
205
|
+
400
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const browser = await this.ensureBrowser();
|
|
209
|
+
const page = await browser.newPage();
|
|
210
|
+
try {
|
|
211
|
+
await page.goto(body.loginUrl, { waitUntil: "domcontentloaded" });
|
|
212
|
+
await clickSelector(page, body.signInSelector ?? 'a[href*="google"], button[data-provider="google"]');
|
|
213
|
+
await typeInto(page, 'input[type="email"]', email);
|
|
214
|
+
await clickSelector(page, "#identifierNext button, #identifierNext");
|
|
215
|
+
await typeInto(page, 'input[type="password"]', password);
|
|
216
|
+
await clickSelector(page, "#passwordNext button, #passwordNext");
|
|
217
|
+
await waitForUrlContaining(
|
|
218
|
+
page,
|
|
219
|
+
body.expectUrl,
|
|
220
|
+
// Capped rather than trusted: an unbounded wait pins a billed browser
|
|
221
|
+
// session open for as long as the caller likes.
|
|
222
|
+
Math.min(Math.max(body.timeoutMs ?? 3e4, 250), 12e4)
|
|
223
|
+
);
|
|
224
|
+
const state = await extractStorageState(page, { origins: body.origins });
|
|
225
|
+
await this.storage.put(KEY_STATE, state);
|
|
226
|
+
return json({ stored: true, summary: summarizeStorageState(state) });
|
|
227
|
+
} finally {
|
|
228
|
+
await page.close();
|
|
229
|
+
await this.touch();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async ensureBrowser() {
|
|
233
|
+
if (this.browser?.isConnected?.() === false) this.browser = void 0;
|
|
234
|
+
if (!this.browser) {
|
|
235
|
+
const puppeteer = await getPuppeteer();
|
|
236
|
+
this.browser = await puppeteer.launch(this.env.BROWSER);
|
|
237
|
+
}
|
|
238
|
+
await this.touch();
|
|
239
|
+
return this.browser;
|
|
240
|
+
}
|
|
241
|
+
/** Push the idle alarm out. Called after every request that used the browser. */
|
|
242
|
+
async touch() {
|
|
243
|
+
await this.storage.setAlarm(Date.now() + this.idleMs);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Close the browser when the session goes quiet.
|
|
247
|
+
*
|
|
248
|
+
* Not an optimisation — a Browser Rendering session left open is billed and
|
|
249
|
+
* counts against the account's concurrency limit, so the alarm is the difference
|
|
250
|
+
* between a test tool and a surprise invoice.
|
|
251
|
+
*/
|
|
252
|
+
async alarm() {
|
|
253
|
+
await this.closeBrowser();
|
|
254
|
+
}
|
|
255
|
+
async closeBrowser() {
|
|
256
|
+
const browser = this.browser;
|
|
257
|
+
this.browser = void 0;
|
|
258
|
+
if (!browser) return;
|
|
259
|
+
try {
|
|
260
|
+
await browser.close();
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function clickSelector(page, selector) {
|
|
266
|
+
await page.evaluate(
|
|
267
|
+
((css) => {
|
|
268
|
+
const element = document.querySelector(css);
|
|
269
|
+
if (!element) throw new Error(`No element matched ${css}`);
|
|
270
|
+
element.click();
|
|
271
|
+
}),
|
|
272
|
+
selector
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
async function typeInto(page, selector, value) {
|
|
276
|
+
await page.evaluate(
|
|
277
|
+
(([css, text]) => {
|
|
278
|
+
const element = document.querySelector(css);
|
|
279
|
+
if (!element) throw new Error(`No element matched ${css}`);
|
|
280
|
+
element.focus();
|
|
281
|
+
element.value = text;
|
|
282
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
283
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
284
|
+
}),
|
|
285
|
+
[selector, value]
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
async function waitForUrlContaining(page, fragment, timeoutMs = 3e4) {
|
|
289
|
+
const deadline = Date.now() + timeoutMs;
|
|
290
|
+
while (Date.now() < deadline) {
|
|
291
|
+
if (page.url().includes(fragment)) return;
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
293
|
+
}
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Timed out waiting for a URL containing "${fragment}" — currently ${page.url()}. A consent screen, a device check or MFA looks like this, and none of them should be automated: capture the session by hand instead.`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
function toBase64(bytes) {
|
|
299
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
300
|
+
let binary = "";
|
|
301
|
+
for (const byte of view) binary += String.fromCharCode(byte);
|
|
302
|
+
return btoa(binary);
|
|
303
|
+
}
|
|
304
|
+
export {
|
|
305
|
+
GoogleLoginBrowser,
|
|
306
|
+
worker as default,
|
|
307
|
+
handleRequest,
|
|
308
|
+
setPuppeteer
|
|
309
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup project. Pick one of the two bodies below.
|
|
3
|
+
*
|
|
4
|
+
* **Verify a hand-captured session** (the default, and what a laptop should do):
|
|
5
|
+
* no credentials anywhere, no Google traffic, and a sub-millisecond check.
|
|
6
|
+
*
|
|
7
|
+
* **Mint your app's own session** (what CI should do): your app creates the same
|
|
8
|
+
* session a successful Google callback creates, so the run holds one secret of
|
|
9
|
+
* yours instead of a Google password.
|
|
10
|
+
*/
|
|
11
|
+
import { test as setup } from "@playwright/test";
|
|
12
|
+
import { bootstrapAppSession, requireStoredSession } from "test-google-login";
|
|
13
|
+
|
|
14
|
+
const SECRET = process.env.E2E_TEST_AUTH_SECRET;
|
|
15
|
+
|
|
16
|
+
setup("an authenticated session is available", async ({ page, context, request }) => {
|
|
17
|
+
if (SECRET) {
|
|
18
|
+
// CI path. Regenerates state every run, so nothing persists between jobs.
|
|
19
|
+
const file = await bootstrapAppSession({
|
|
20
|
+
request,
|
|
21
|
+
page,
|
|
22
|
+
context,
|
|
23
|
+
secret: SECRET,
|
|
24
|
+
endpoint: process.env.E2E_TEST_AUTH_ENDPOINT ?? "/api/test-auth/google-user",
|
|
25
|
+
landingPath: "/dashboard",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
console.log(`minted a fresh app session → ${file}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Local path. Throws with the exact `playwright codegen` command on the first
|
|
33
|
+
// run, which is the whole onboarding story for a new contributor.
|
|
34
|
+
const summary = requireStoredSession({ baseUrl: process.env.E2E_BASE_URL });
|
|
35
|
+
|
|
36
|
+
console.log(
|
|
37
|
+
`session ok — ${summary.cookieCount} cookies across ${summary.domains.join(", ")}` +
|
|
38
|
+
(summary.expiresInSeconds === null
|
|
39
|
+
? " (no expiry; dies with the browser)"
|
|
40
|
+
: `, earliest expiry in ${Math.round(summary.expiresInSeconds / 60)} min`),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the tests look like once the setup project has done its work: nothing
|
|
3
|
+
* about auth, because the browser is already signed in.
|
|
4
|
+
*/
|
|
5
|
+
import { expect, test } from "@playwright/test";
|
|
6
|
+
|
|
7
|
+
test("an authenticated user can open the dashboard", async ({ page }) => {
|
|
8
|
+
await page.goto("/dashboard");
|
|
9
|
+
|
|
10
|
+
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("the session survives a reload", async ({ page }) => {
|
|
14
|
+
await page.goto("/dashboard");
|
|
15
|
+
await page.reload();
|
|
16
|
+
|
|
17
|
+
// The assertion worth making explicitly: a storageState that restores cookies
|
|
18
|
+
// but not localStorage often passes the first navigation and fails this one.
|
|
19
|
+
await expect(page).not.toHaveURL(/\/login/);
|
|
20
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup-project layout: capture (or verify) the session once, then every
|
|
3
|
+
* browser project starts signed in.
|
|
4
|
+
*
|
|
5
|
+
* Copy this next to your tests. The two projects matter — `chromium` declares
|
|
6
|
+
* `dependencies: ["auth-setup"]`, so Playwright runs the setup once per run
|
|
7
|
+
* rather than once per test file, and a failure there fails fast with a message
|
|
8
|
+
* that says what to do instead of timing out on a login form.
|
|
9
|
+
*/
|
|
10
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
11
|
+
import { DEFAULT_AUTH_FILE } from "test-google-login";
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
testDir: "./tests/e2e",
|
|
15
|
+
timeout: 30_000,
|
|
16
|
+
|
|
17
|
+
use: {
|
|
18
|
+
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
|
19
|
+
trace: "on-first-retry",
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
projects: [
|
|
23
|
+
{
|
|
24
|
+
name: "auth-setup",
|
|
25
|
+
testMatch: /.*\.setup\.ts/,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "chromium",
|
|
29
|
+
dependencies: ["auth-setup"],
|
|
30
|
+
use: {
|
|
31
|
+
...devices["Desktop Chrome"],
|
|
32
|
+
// Imported rather than written out, so the config and the CLI cannot
|
|
33
|
+
// drift onto different paths — which looks exactly like an expired session.
|
|
34
|
+
storageState: DEFAULT_AUTH_FILE,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test-only endpoint `bootstrapAppSession` calls — the piece that lives in
|
|
3
|
+
* *your* app, not in this package.
|
|
4
|
+
*
|
|
5
|
+
* Written for a Next.js route handler; the shape is the same anywhere. Four
|
|
6
|
+
* things are not optional:
|
|
7
|
+
*
|
|
8
|
+
* 1. **It must not exist in production.** The 404 below is the floor, not the
|
|
9
|
+
* ceiling — prefer excluding the file from the production build entirely, and
|
|
10
|
+
* write a deployment test that asserts the route 404s in prod.
|
|
11
|
+
* 2. **It must require a strong secret**, compared in constant time.
|
|
12
|
+
* 3. **It must create the same session** a real Google callback creates: same
|
|
13
|
+
* format, same claims, same roles, same cookie attributes. A subtly different
|
|
14
|
+
* session turns your whole suite into a test of a code path that never ships.
|
|
15
|
+
* 4. **It must not accept a user id from the request** beyond what you are
|
|
16
|
+
* willing to let any caller with the secret create.
|
|
17
|
+
*/
|
|
18
|
+
import { constantTimeEqual } from "test-google-login";
|
|
19
|
+
|
|
20
|
+
// Stand-ins for your app's own modules.
|
|
21
|
+
declare const db: {
|
|
22
|
+
user: {
|
|
23
|
+
upsert(args: Record<string, unknown>): Promise<{ id: string }>;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
declare function createSessionForUser(userId: string): Promise<{ id: string }>;
|
|
27
|
+
declare function buildSessionCookie(session: { id: string }): string;
|
|
28
|
+
|
|
29
|
+
export async function POST(request: Request): Promise<Response> {
|
|
30
|
+
// Defence in depth. The real protection is this file not being in the bundle.
|
|
31
|
+
if (process.env.NODE_ENV === "production") {
|
|
32
|
+
return new Response("Not found", { status: 404 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!constantTimeEqual(request.headers.get("x-e2e-auth-secret"), process.env.E2E_TEST_AUTH_SECRET)) {
|
|
36
|
+
return new Response("Unauthorized", { status: 401 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const user = await db.user.upsert({
|
|
40
|
+
where: { email: "google-e2e@example.test" },
|
|
41
|
+
create: {
|
|
42
|
+
email: "google-e2e@example.test",
|
|
43
|
+
name: "Google E2E User",
|
|
44
|
+
googleSubject: "google-e2e-subject-001",
|
|
45
|
+
emailVerified: true,
|
|
46
|
+
},
|
|
47
|
+
update: {},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const session = await createSessionForUser(user.id);
|
|
51
|
+
|
|
52
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
53
|
+
headers: {
|
|
54
|
+
"content-type": "application/json",
|
|
55
|
+
"set-cookie": buildSessionCookie(session),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driving the Durable Object from a test, so one captured session can be checked
|
|
3
|
+
* against a deployed environment from Cloudflare's network.
|
|
4
|
+
*
|
|
5
|
+
* Worth doing when "does the session work?" depends on where the request comes
|
|
6
|
+
* from — geo-routed auth, Cloudflare Access in front of the app, a WAF rule that
|
|
7
|
+
* treats your CI runner's IP differently from a real visitor.
|
|
8
|
+
*/
|
|
9
|
+
import { AUTH_HEADER, loadStorageStateFor } from "test-google-login";
|
|
10
|
+
|
|
11
|
+
const WORKER = process.env.TGL_WORKER_URL ?? "https://test-google-login.example.workers.dev";
|
|
12
|
+
const SECRET = process.env.TGL_WORKER_SECRET;
|
|
13
|
+
|
|
14
|
+
if (!SECRET) throw new Error("TGL_WORKER_SECRET is required.");
|
|
15
|
+
|
|
16
|
+
async function worker(path: string, body?: unknown) {
|
|
17
|
+
const response = await fetch(`${WORKER}${path}`, {
|
|
18
|
+
method: body === undefined ? "GET" : "POST",
|
|
19
|
+
headers: { [AUTH_HEADER]: SECRET!, "content-type": "application/json" },
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!response.ok) throw new Error(`${path} → ${response.status}: ${await response.text()}`);
|
|
24
|
+
return response.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Upload the session captured locally. This is the one request that carries live
|
|
28
|
+
// cookies, so it goes over HTTPS to a Worker that only accepts the shared secret —
|
|
29
|
+
// and there is deliberately no route that reads them back out.
|
|
30
|
+
await worker("/state?session=signed-in", loadStorageStateFor());
|
|
31
|
+
|
|
32
|
+
const result = (await worker("/check?session=signed-in", {
|
|
33
|
+
url: "https://app.example.test/dashboard",
|
|
34
|
+
expectSelector: "[data-testid=user-menu]",
|
|
35
|
+
rejectUrl: "/login",
|
|
36
|
+
// Narrow the origins: restoring accounts.google.com storage means navigating a
|
|
37
|
+
// datacentre browser to Google, which is slow and trips its risk checks.
|
|
38
|
+
origins: ["https://app.example.test"],
|
|
39
|
+
screenshot: true,
|
|
40
|
+
})) as { authenticated: boolean; reason: string; screenshot?: string };
|
|
41
|
+
|
|
42
|
+
console.log(result.authenticated ? `signed in — ${result.reason}` : `NOT signed in — ${result.reason}`);
|
|
43
|
+
|
|
44
|
+
// GET /state returns a redacted summary, never the values. Safe to log in CI.
|
|
45
|
+
console.log(await worker("/state?session=signed-in"));
|
|
46
|
+
|
|
47
|
+
// Close the browser rather than waiting for the idle alarm: an open Browser
|
|
48
|
+
// Rendering session is billed.
|
|
49
|
+
await worker("/close?session=signed-in", {});
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "automate-google-login-scraper",
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "End-to-end test harness for Google sign-in: persist a Playwright storageState once, reuse it everywhere, and replay it from a Cloudflare Workers Browser Rendering Durable Object",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./worker": {
|
|
15
|
+
"types": "./dist/worker.d.ts",
|
|
16
|
+
"import": "./dist/worker.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"test-google-login": "./bin/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"bin",
|
|
25
|
+
"examples"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "vite build",
|
|
29
|
+
"prepublishOnly": "bun run build",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"test:ci": "vitest run --reporter=default --reporter=junit --outputFile=junit.xml --coverage",
|
|
34
|
+
"coverage": "vitest run --coverage",
|
|
35
|
+
"deploy": "wrangler deploy",
|
|
36
|
+
"dev:worker": "wrangler dev",
|
|
37
|
+
"ship": "npx standard-version --release-as patch; npm publish"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"playwright",
|
|
41
|
+
"puppeteer",
|
|
42
|
+
"google-login",
|
|
43
|
+
"oauth",
|
|
44
|
+
"storage-state",
|
|
45
|
+
"e2e",
|
|
46
|
+
"testing",
|
|
47
|
+
"cloudflare-workers",
|
|
48
|
+
"browser-rendering",
|
|
49
|
+
"durable-objects"
|
|
50
|
+
],
|
|
51
|
+
"author": "vtempest",
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@cloudflare/puppeteer": "^1.4.0",
|
|
55
|
+
"@cloudflare/workers-types": "^4.20260507.1",
|
|
56
|
+
"@types/node": "^22.10.0",
|
|
57
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
58
|
+
"typescript": "^5.9.3",
|
|
59
|
+
"vite": "^6.3.5",
|
|
60
|
+
"vite-plugin-dts": "^4.5.4",
|
|
61
|
+
"vitest": "^4.1.0",
|
|
62
|
+
"wrangler": "^4.24.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"@cloudflare/puppeteer": "*",
|
|
66
|
+
"@playwright/test": "*"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"@playwright/test": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"@cloudflare/puppeteer": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"repository": {
|
|
77
|
+
"type": "git",
|
|
78
|
+
"url": "git+https://github.com/OpenSourceAGI/dev-tools-starter-agent.git",
|
|
79
|
+
"directory": "packages/test-google-login"
|
|
80
|
+
},
|
|
81
|
+
"homepage": "https://github.com/OpenSourceAGI/dev-tools-starter-agent/tree/master/packages/test-google-login"
|
|
82
|
+
}
|