Skill Tracer
Skills › gstack › browse › cookie-auth-verification.ts

cookie-auth-verification.ts

225 lines · browse/src/cookie-auth-verification.ts at 3f79d81

1import { errors, type Page } from 'playwright';
2import { CookieImportError } from './cookie-import-browser';
3import { withCdpSession } from './cdp-bridge';
4
5export interface CookieAuthVerificationOptions {
6 identitySelector?: string;
7 expectedIdentity?: string;
8 timeoutMs?: number;
9}
11type VerificationReason = 'verified' | 'not_configured' | 'invalid_configuration'
12 | 'invalid_target' | 'target_closed' | 'target_changed' | 'login_redirect'
13 | 'http_error' | 'no_response' | 'identity_missing' | 'identity_ambiguous'
14 | 'identity_mismatch' | 'timeout' | 'verification_failed';
16type VerificationResult = { verified: boolean; reason: VerificationReason; status?: number };
18const MAX_TIMEOUT_MS = 15_000;
19const LOGIN_PATH = /(?:^|\/)(?:log[-_]?in|sign[-_]?in|logon)(?:[\/.;]|$)|(?:^|\/)sessions?\/new(?:[\/.;]|$)/i;
21function validateOrigin(expectedOrigin: string): void {
22 try {
23 const target = new URL(expectedOrigin);
24 if ((target.protocol === 'http:' || target.protocol === 'https:') && target.origin === expectedOrigin) return;
25 } catch {}
26 throw new CookieImportError('A captured HTTP(S) target origin is required.', 'invalid_target');
29export function validateCookieAuthOptions(options: CookieAuthVerificationOptions): void {
30 if (!options || typeof options.identitySelector !== 'string' || !options.identitySelector.trim()
31 || typeof options.expectedIdentity !== 'string' || !options.expectedIdentity.replace(/\s+/g, ' ').trim()) {
32 throw new CookieImportError('Authentication verification requires an identity selector and expected identity.', 'verification_not_configured');
33 }
34 if (options.timeoutMs !== undefined && (typeof options.timeoutMs !== 'number'
35 || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
36 throw new CookieImportError('Authentication verification requires a positive finite timeout.', 'invalid_verification_config');
37 }
40export async function verifyCookieAuthentication(
41 page: Page,
42 options: CookieAuthVerificationOptions,
43 expectedOrigin: string,
44): Promise<VerificationResult> {
45 try {
46 validateCookieAuthOptions(options);
47 validateOrigin(expectedOrigin);
48 } catch (error) {
49 const reason = error instanceof CookieImportError && error.code === 'verification_not_configured'
50 ? 'not_configured' : error instanceof CookieImportError && error.code === 'invalid_target'
51 ? 'invalid_target' : 'invalid_configuration';
52 return { verified: false, reason };
53 }
55 const timeoutMs = Math.min(options.timeoutMs ?? MAX_TIMEOUT_MS, MAX_TIMEOUT_MS);
56 const deadline = performance.now() + timeoutMs;
57 const expectedIdentity = options.expectedIdentity!.replace(/\s+/g, ' ').trim();
58 let finished = false;
59 let status: number | undefined;
60 let lastFailure: VerificationReason = 'timeout';
61 let timer: ReturnType<typeof setTimeout>;
62 const timeout = new Promise<VerificationResult>(resolve => {
63 timer = setTimeout(() => {
64 finished = true;
65 resolve({ verified: false, reason: lastFailure, ...(status === undefined ? {} : { status }) });
66 }, timeoutMs);
67 });
69 try {
70 return await Promise.race([timeout, (async (): Promise<VerificationResult> => {
71 if (page.isClosed()) return { verified: false, reason: 'target_closed' };
72 if (new URL(page.url()).origin !== expectedOrigin) return { verified: false, reason: 'target_changed' };
73 const response = await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.max(1, deadline - performance.now()) });
74 if (finished || performance.now() >= deadline) return { verified: false, reason: 'timeout' };
75 if (page.isClosed()) return { verified: false, reason: 'target_closed' };
76 const loadedUrl = page.url();
77 if (new URL(loadedUrl).origin !== expectedOrigin) return { verified: false, reason: 'target_changed' };
78 if (LOGIN_PATH.test(decodeURIComponent(new URL(loadedUrl).pathname))) return { verified: false, reason: 'login_redirect' };
79 if (!response) return { verified: false, reason: 'no_response' };
80 status = response.status();
81 if (status < 200 || status >= 300) return { verified: false, reason: 'http_error', status };
82 for (let request = response.request(); request; request = request.redirectedFrom()!) {
83 const url = new URL(request.url());
84 if (url.origin !== expectedOrigin) return { verified: false, reason: 'target_changed', status };
85 if (LOGIN_PATH.test(decodeURIComponent(url.pathname))) return { verified: false, reason: 'login_redirect', status };
86 }
87 if (new URL(response.url()).origin !== expectedOrigin
88 || new URL(response.url()).href !== new URL(loadedUrl.split('#')[0]).href) {
89 return { verified: false, reason: 'target_changed', status };
90 }
92 while (!finished && performance.now() < deadline) {
93 if (page.isClosed()) return { verified: false, reason: 'target_closed', status };
94 if (page.url() !== loadedUrl) return { verified: false, reason: 'target_changed', status };
95 const reason = await page.locator(options.identitySelector!).filter({ visible: true }).evaluateAll((elements, expected) => {
96 if (location.origin !== expected.origin || location.href !== expected.url) return 'target_changed';
97 if (elements.length === 0) return 'identity_missing';
98 if (elements.length !== 1) return 'identity_ambiguous';
99 const element = elements[0];
100 const text = element instanceof HTMLElement ? element.innerText : element.textContent ?? '';
101 return text.replace(/\s+/g, ' ').trim() === expected.identity ? 'verified' : 'identity_mismatch';
102 }, { origin: expectedOrigin, url: loadedUrl, identity: expectedIdentity });
103 if (finished || performance.now() >= deadline) return { verified: false, reason: lastFailure, status };
104 if (page.isClosed()) return { verified: false, reason: 'target_closed', status };
105 if (page.url() !== loadedUrl) return { verified: false, reason: 'target_changed', status };
106 if (reason === 'target_changed' || reason === 'verified') return { verified: reason === 'verified', reason, status };
107 lastFailure = reason;
108 const remaining = deadline - performance.now();
109 if (remaining <= 0) return { verified: false, reason, status };
110 await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
111 }
112 return { verified: false, reason: lastFailure, ...(status === undefined ? {} : { status }) };
113 })()]);
114 } catch (error) {
115 const reason = error instanceof errors.TimeoutError || finished || performance.now() >= deadline ? 'timeout'
116 : page.isClosed() ? 'target_closed' : 'verification_failed';
117 return { verified: false, reason, ...(status === undefined ? {} : { status }) };
118 } finally {
119 finished = true;
120 clearTimeout(timer!);
121 }
124export function validateCookieStorageSupport(page: Page): void {
125 try {
126 if (page.context().browser()?.browserType().name() === 'chromium') return;
127 } catch {}
128 throw new CookieImportError('Storage reset requires a Chromium target. Import cookies without storage reset on other browsers.', 'storage_reset_unsupported');
131export async function clearCookieTargetStorage(page: Page, expectedOrigin: string, timeoutMs = MAX_TIMEOUT_MS): Promise<void> {
132 validateOrigin(expectedOrigin);
133 validateCookieStorageSupport(page);
134 if (page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
135 let targetUrl: string;
136 try {
137 targetUrl = page.url();
138 if (new URL(targetUrl).origin !== expectedOrigin) {
139 throw new CookieImportError('The captured target has changed.', 'target_changed');
140 }
141 } catch {
142 throw new CookieImportError('The captured target has changed.', 'target_changed');
143 }
145 const deadline = performance.now() + timeoutMs;
146 let expired = false;
147 let navigated = false;
148 const frame = page.mainFrame();
149 const navigation = Promise.withResolvers<string>();
150 const onNavigation = (changed: typeof frame) => {
151 if (changed === frame) { navigated = true; navigation.resolve('target_changed'); }
152 };
153 const onClose = () => { navigated = true; navigation.resolve('target_changed'); };
154 page.on('framenavigated', onNavigation);
155 page.on('close', onClose);
156 const cancelled = () => expired || performance.now() >= deadline ? 'storage_reset_timeout'
157 : navigated || page.isClosed() || page.url() !== targetUrl ? 'target_changed' : undefined;
158 let timer: ReturnType<typeof setTimeout>;
159 const timeout = new Promise<string>(resolve => {
160 timer = setTimeout(() => {
161 expired = true;
162 resolve('storage_reset_timeout');
163 }, Math.max(0, deadline - performance.now()));
164 });
165 const cancellation = Promise.race([timeout, navigation.promise]);
166 let result: string;
167 try {
168 result = await Promise.race([cancellation, withCdpSession(page, async session => {
169 let isolated: { id: number; uniqueId: string } | undefined;
170 let frameId: string | undefined;
171 const worldName = 'gstack-cookie-storage-reset';
172 const onContext = ({ context }: any) => {
173 if (context.name === worldName && context.auxData?.frameId === frameId && context.auxData?.isDefault === false) isolated = context;
174 };
175 session.on('Runtime.executionContextCreated', onContext);
176 try {
177 return await Promise.race([cancellation, (async () => {
178 if (cancelled()) return cancelled()!;
179 const tree = await session.send('Page.getFrameTree');
180 if (cancelled()) return cancelled()!;
181 frameId = tree.frameTree.frame.id;
182 await session.send('Runtime.enable');
183 if (cancelled()) return cancelled()!;
184 const world = await session.send('Page.createIsolatedWorld', { frameId, worldName, grantUniveralAccess: false });
185 if (cancelled()) return cancelled()!;
186 if (!isolated?.uniqueId || isolated.id !== world.executionContextId) return 'storage_reset_failed';
187 const uniqueContextId = isolated.uniqueId;
188 const clock = await session.send('Runtime.evaluate', { expression: 'performance.now()', uniqueContextId, returnByValue: true, silent: true });
189 const remaining = deadline - performance.now();
190 if (cancelled() || remaining <= 0) return cancelled() ?? 'storage_reset_timeout';
191 if (clock.exceptionDetails || !Number.isFinite(clock.result?.value)) return 'storage_reset_failed';
192 const cleared = await session.send('Runtime.callFunctionOn', {
193 uniqueContextId,
194 functionDeclaration: String(({ origin, url, deadline }: { origin: string; url: string; deadline: number }) => {
195 if (performance.now() >= deadline) return 'storage_reset_timeout';
196 if (location.origin !== origin || location.href !== url) return 'target_changed';
197 localStorage.clear();
198 if (performance.now() >= deadline) return 'storage_reset_timeout';
199 sessionStorage.clear();
200 return 'cleared';
201 }),
202 arguments: [{ value: { origin: expectedOrigin, url: targetUrl, deadline: clock.result.value + remaining } }],
203 returnByValue: true,
204 silent: true,
205 });
206 if (cancelled()) return cancelled()!;
207 if (cleared.exceptionDetails || !['cleared', 'target_changed', 'storage_reset_timeout'].includes(cleared.result?.value)) return 'storage_reset_failed';
208 return cleared.result.value;
209 })()]);
210 } finally {
211 session.off('Runtime.executionContextCreated', onContext);
212 }
213 })]);
214 } catch {
215 result = cancelled() ?? 'storage_reset_failed';
216 } finally {
217 expired = true;
218 clearTimeout(timer!);
219 page.off('framenavigated', onNavigation);
220 page.off('close', onClose);
221 }
222 if (result === 'storage_reset_timeout') throw new CookieImportError('Target storage reset timed out; storage may be partially cleared.', 'storage_reset_timeout');
223 if (result === 'target_changed') throw new CookieImportError('The captured target has changed.', 'target_changed');
224 if (result !== 'cleared') throw new CookieImportError('Target storage reset failed; storage may be partially cleared.', 'storage_reset_failed');