Skip to content

Commit 2b695b8

Browse files
committed
Bidi: Unlock cookies support
1 parent e79e75e commit 2b695b8

3 files changed

Lines changed: 339 additions & 18 deletions

File tree

lib/PuppeteerSharp.Nunit/TestExpectations/TestExpectations.local.json

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -315,21 +315,6 @@
315315
"FAIL"
316316
]
317317
},
318-
{
319-
"comment": "This is part of organizing the webdriver bidi implementation, We will remove it one by one",
320-
"testIdPattern": "[cookies.spec] *",
321-
"platforms": [
322-
"darwin",
323-
"linux",
324-
"win32"
325-
],
326-
"parameters": [
327-
"webDriverBiDi"
328-
],
329-
"expectations": [
330-
"FAIL"
331-
]
332-
},
333318
{
334319
"comment": "This is part of organizing the webdriver bidi implementation, We will remove it one by one",
335320
"testIdPattern": "[crash] *",
@@ -1769,5 +1754,20 @@
17691754
"expectations": [
17701755
"FAIL"
17711756
]
1757+
},
1758+
{
1759+
"comment": "BidiBrowserContext.CloseAsync() is not implemented - fails with NotImplementedException",
1760+
"testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should isolate cookies in browser contexts",
1761+
"platforms": [
1762+
"darwin",
1763+
"linux",
1764+
"win32"
1765+
],
1766+
"parameters": [
1767+
"webDriverBiDi"
1768+
],
1769+
"expectations": [
1770+
"SKIP"
1771+
]
17721772
}
17731773
]
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// * MIT License
2+
// *
3+
// * Copyright (c) Darío Kondratiuk
4+
// *
5+
// * Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// * of this software and associated documentation files (the "Software"), to deal
7+
// * in the Software without restriction, including without limitation the rights
8+
// * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// * copies of the Software, and to permit persons to whom the Software is
10+
// * furnished to do so, subject to the following conditions:
11+
// *
12+
// * The above copyright notice and this permission notice shall be included in all
13+
// * copies or substantial portions of the Software.
14+
// *
15+
// * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// * SOFTWARE.
22+
23+
using System;
24+
using WebDriverBiDi.Network;
25+
using WebDriverBiDi.Storage;
26+
using BidiCookie = WebDriverBiDi.Network.Cookie;
27+
28+
namespace PuppeteerSharp.Bidi;
29+
30+
internal static class BidiCookieHelper
31+
{
32+
/// <summary>
33+
/// Converts a BiDi cookie to a PuppeteerSharp cookie.
34+
/// </summary>
35+
public static CookieParam BidiToPuppeteerCookie(BidiCookie bidiCookie)
36+
=> new()
37+
{
38+
Name = bidiCookie.Name,
39+
Value = bidiCookie.Value.Value,
40+
Domain = bidiCookie.Domain,
41+
Path = bidiCookie.Path,
42+
Size = (int)bidiCookie.Size,
43+
HttpOnly = bidiCookie.HttpOnly,
44+
Secure = bidiCookie.Secure,
45+
SameSite = ConvertSameSiteBidiToPuppeteer(bidiCookie.SameSite),
46+
Expires = bidiCookie.EpochExpires.HasValue ? (double)bidiCookie.EpochExpires.Value / 1000 : -1,
47+
Session = !bidiCookie.EpochExpires.HasValue || bidiCookie.EpochExpires.Value == 0,
48+
};
49+
50+
/// <summary>
51+
/// Converts a PuppeteerSharp cookie to a BiDi partial cookie.
52+
/// </summary>
53+
public static PartialCookie PuppeteerToBidiCookie(CookieParam cookie, string domain)
54+
{
55+
var bidiCookie = new PartialCookie(
56+
cookie.Name,
57+
BytesValue.FromString(cookie.Value),
58+
domain)
59+
{
60+
Path = cookie.Path,
61+
SameSite = ConvertSameSitePuppeteerToBidi(cookie.SameSite),
62+
};
63+
64+
// Only set HttpOnly and Secure if explicitly provided
65+
if (cookie.HttpOnly.HasValue)
66+
{
67+
bidiCookie.HttpOnly = cookie.HttpOnly.Value;
68+
}
69+
70+
if (cookie.Secure.HasValue)
71+
{
72+
bidiCookie.Secure = cookie.Secure.Value;
73+
}
74+
75+
// Convert expiration
76+
if (cookie.Expires.HasValue && cookie.Expires.Value != -1)
77+
{
78+
bidiCookie.Expires = DateTimeOffset.FromUnixTimeSeconds((long)cookie.Expires.Value).DateTime;
79+
}
80+
81+
// Add CDP-specific properties if needed
82+
if (cookie.SameParty.HasValue)
83+
{
84+
bidiCookie.AdditionalData["goog:sameParty"] = cookie.SameParty.Value;
85+
}
86+
87+
if (cookie.SourceScheme.HasValue)
88+
{
89+
bidiCookie.AdditionalData["goog:sourceScheme"] = ConvertSourceSchemeEnumToString(cookie.SourceScheme.Value);
90+
}
91+
92+
if (cookie.Priority.HasValue)
93+
{
94+
bidiCookie.AdditionalData["goog:priority"] = ConvertPriorityEnumToString(cookie.Priority.Value);
95+
}
96+
97+
if (!string.IsNullOrEmpty(cookie.Url))
98+
{
99+
bidiCookie.AdditionalData["goog:url"] = cookie.Url;
100+
}
101+
102+
return bidiCookie;
103+
}
104+
105+
/// <summary>
106+
/// Checks if a cookie matches a URL according to the spec.
107+
/// </summary>
108+
public static bool TestUrlMatchCookie(CookieParam cookie, Uri url)
109+
{
110+
return TestUrlMatchCookieHostname(cookie, url) && TestUrlMatchCookiePath(cookie, url);
111+
}
112+
113+
/// <summary>
114+
/// Checks if cookie domain matches URL hostname.
115+
/// </summary>
116+
private static bool TestUrlMatchCookieHostname(CookieParam cookie, Uri url)
117+
{
118+
var cookieDomain = cookie.Domain.ToLowerInvariant();
119+
var urlHostname = url.Host.ToLowerInvariant();
120+
121+
if (cookieDomain == urlHostname)
122+
{
123+
return true;
124+
}
125+
126+
// TODO: does not consider additional restrictions w.r.t to IP
127+
// addresses which is fine as it is for representation and does not
128+
// mean that cookies actually apply that way in the browser.
129+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
130+
return cookieDomain.StartsWith(".", StringComparison.Ordinal) && urlHostname.EndsWith(cookieDomain, StringComparison.Ordinal);
131+
}
132+
133+
/// <summary>
134+
/// Checks if cookie path matches URL path.
135+
/// Spec: https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4.
136+
/// </summary>
137+
private static bool TestUrlMatchCookiePath(CookieParam cookie, Uri url)
138+
{
139+
var uriPath = url.AbsolutePath;
140+
var cookiePath = cookie.Path;
141+
142+
if (uriPath == cookiePath)
143+
{
144+
// The cookie-path and the request-path are identical.
145+
return true;
146+
}
147+
148+
if (uriPath.StartsWith(cookiePath, StringComparison.Ordinal))
149+
{
150+
// The cookie-path is a prefix of the request-path.
151+
if (cookiePath.EndsWith("/", StringComparison.Ordinal))
152+
{
153+
// The last character of the cookie-path is %x2F ("/").
154+
return true;
155+
}
156+
157+
if (uriPath.Length > cookiePath.Length && uriPath[cookiePath.Length] == '/')
158+
{
159+
// The first character of the request-path that is not included in the cookie-path
160+
// is a %x2F ("/") character.
161+
return true;
162+
}
163+
}
164+
165+
return false;
166+
}
167+
168+
private static SameSite ConvertSameSiteBidiToPuppeteer(CookieSameSiteValue sameSite)
169+
{
170+
return sameSite switch
171+
{
172+
CookieSameSiteValue.Strict => SameSite.Strict,
173+
CookieSameSiteValue.Lax => SameSite.Lax,
174+
_ => SameSite.None,
175+
};
176+
}
177+
178+
private static CookieSameSiteValue? ConvertSameSitePuppeteerToBidi(SameSite? sameSite)
179+
{
180+
return sameSite switch
181+
{
182+
SameSite.Strict => CookieSameSiteValue.Strict,
183+
SameSite.Lax => CookieSameSiteValue.Lax,
184+
SameSite.None => CookieSameSiteValue.None,
185+
_ => null,
186+
};
187+
}
188+
189+
private static string ConvertSourceSchemeEnumToString(CookieSourceScheme sourceScheme)
190+
{
191+
return sourceScheme switch
192+
{
193+
CookieSourceScheme.Unset => "Unset",
194+
CookieSourceScheme.NonSecure => "NonSecure",
195+
CookieSourceScheme.Secure => "Secure",
196+
_ => "Unset",
197+
};
198+
}
199+
200+
private static string ConvertPriorityEnumToString(CookiePriority priority)
201+
{
202+
return priority switch
203+
{
204+
CookiePriority.Low => "Low",
205+
CookiePriority.Medium => "Medium",
206+
CookiePriority.High => "High",
207+
_ => "Medium",
208+
};
209+
}
210+
}

lib/PuppeteerSharp/Bidi/BidiPage.cs

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,66 @@ await BidiMainFrame.BrowsingContext.SetViewportAsync(
205205
}
206206

207207
/// <inheritdoc />
208-
public override Task SetCookieAsync(params CookieParam[] cookies) => throw new NotImplementedException();
208+
public override async Task SetCookieAsync(params CookieParam[] cookies)
209+
{
210+
if (cookies == null)
211+
{
212+
throw new ArgumentNullException(nameof(cookies));
213+
}
214+
215+
var pageUrl = Url;
216+
var pageUrlStartsWithHttp = pageUrl.StartsWith("http", StringComparison.Ordinal);
217+
218+
foreach (var cookie in cookies)
219+
{
220+
var cookieUrl = cookie.Url ?? string.Empty;
221+
if (string.IsNullOrEmpty(cookieUrl) && pageUrlStartsWithHttp)
222+
{
223+
cookieUrl = pageUrl;
224+
}
225+
226+
if (cookieUrl == "about:blank")
227+
{
228+
throw new PuppeteerException($"Blank page can not have cookie \"{cookie.Name}\"");
229+
}
230+
231+
if (cookieUrl.StartsWith("data:", StringComparison.Ordinal))
232+
{
233+
throw new PuppeteerException($"Data URL page can not have cookie \"{cookie.Name}\"");
234+
}
235+
236+
// TODO: Support Chrome cookie partition keys
237+
if (!string.IsNullOrEmpty(cookie.PartitionKey))
238+
{
239+
throw new PuppeteerException("BiDi only allows domain partition keys");
240+
}
241+
242+
Uri normalizedUrl = null;
243+
if (Uri.TryCreate(cookieUrl, UriKind.Absolute, out var parsedUrl))
244+
{
245+
normalizedUrl = parsedUrl;
246+
}
247+
248+
var domain = cookie.Domain ?? normalizedUrl?.Host;
249+
if (string.IsNullOrEmpty(domain))
250+
{
251+
throw new MessageException("At least one of the url and domain needs to be specified");
252+
}
253+
254+
// Automatically set secure flag for HTTPS URLs if not explicitly provided
255+
if (!cookie.Secure.HasValue && normalizedUrl?.Scheme == "https")
256+
{
257+
cookie.Secure = true;
258+
}
259+
260+
var bidiCookie = BidiCookieHelper.PuppeteerToBidiCookie(cookie, domain);
261+
262+
await BidiBrowser.Driver.Storage.SetCookieAsync(new WebDriverBiDi.Storage.SetCookieCommandParameters(bidiCookie)
263+
{
264+
Partition = new WebDriverBiDi.Storage.BrowsingContextPartitionDescriptor(BidiMainFrame.BrowsingContext.Id),
265+
}).ConfigureAwait(false);
266+
}
267+
}
209268

210269
/// <inheritdoc />
211270
public override async Task CloseAsync(PageCloseOptions options = null)
@@ -221,7 +280,41 @@ public override async Task CloseAsync(PageCloseOptions options = null)
221280
}
222281

223282
/// <inheritdoc />
224-
public override Task DeleteCookieAsync(params CookieParam[] cookies) => throw new NotImplementedException();
283+
public override async Task DeleteCookieAsync(params CookieParam[] cookies)
284+
{
285+
await Task.WhenAll(cookies.Select(async cookie =>
286+
{
287+
var cookieUrl = cookie.Url ?? Url;
288+
Uri normalizedUrl = null;
289+
if (Uri.TryCreate(cookieUrl, UriKind.Absolute, out var parsedUrl))
290+
{
291+
normalizedUrl = parsedUrl;
292+
}
293+
294+
var domain = cookie.Domain ?? normalizedUrl?.Host;
295+
if (string.IsNullOrEmpty(domain))
296+
{
297+
throw new MessageException("At least one of the url and domain needs to be specified");
298+
}
299+
300+
var filter = new WebDriverBiDi.Storage.CookieFilter
301+
{
302+
Domain = domain,
303+
Name = cookie.Name,
304+
};
305+
306+
if (!string.IsNullOrEmpty(cookie.Path))
307+
{
308+
filter.Path = cookie.Path;
309+
}
310+
311+
await BidiBrowser.Driver.Storage.DeleteCookiesAsync(new WebDriverBiDi.Storage.DeleteCookiesCommandParameters
312+
{
313+
Filter = filter,
314+
Partition = new WebDriverBiDi.Storage.BrowsingContextPartitionDescriptor(BidiMainFrame.BrowsingContext.Id),
315+
}).ConfigureAwait(false);
316+
})).ConfigureAwait(false);
317+
}
225318

226319
/// <inheritdoc />
227320
public override Task SetDragInterceptionAsync(bool enabled) => throw new NotImplementedException();
@@ -257,7 +350,25 @@ public override async Task CloseAsync(PageCloseOptions options = null)
257350
public override Task EmulateNetworkConditionsAsync(NetworkConditions networkConditions) => throw new NotImplementedException();
258351

259352
/// <inheritdoc />
260-
public override Task<CookieParam[]> GetCookiesAsync(params string[] urls) => throw new NotImplementedException();
353+
public override async Task<CookieParam[]> GetCookiesAsync(params string[] urls)
354+
{
355+
if (urls == null)
356+
{
357+
throw new ArgumentNullException(nameof(urls));
358+
}
359+
360+
var normalizedUrls = (urls.Length > 0 ? urls : [Url]).Select(url => new Uri(url)).ToArray();
361+
362+
var result = await BidiBrowser.Driver.Storage.GetCookiesAsync(new WebDriverBiDi.Storage.GetCookiesCommandParameters
363+
{
364+
Partition = new WebDriverBiDi.Storage.BrowsingContextPartitionDescriptor(BidiMainFrame.BrowsingContext.Id),
365+
}).ConfigureAwait(false);
366+
367+
return result.Cookies
368+
.Select(BidiCookieHelper.BidiToPuppeteerCookie)
369+
.Where(cookie => normalizedUrls.Any(url => BidiCookieHelper.TestUrlMatchCookie(cookie, url)))
370+
.ToArray();
371+
}
261372

262373
internal static BidiPage From(BidiBrowserContext browserContext, BrowsingContext browsingContext)
263374
{

0 commit comments

Comments
 (0)