Skip to content

Commit a4e30ef

Browse files
Scope credentials to an origin and add clearAuthentication
1 parent fc49e8d commit a4e30ef

6 files changed

Lines changed: 360 additions & 26 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
* Add `disableJavascript` option
77
* Allow choosing the box model in `Node::getPosition()`
8-
* Add `Page::authenticate` method
8+
* Add `Page::authenticate` and `Page::clearAuthentication` methods
99

1010

1111
## 1.15.0 (2025-12-27)

‎README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -691,13 +691,17 @@ You can set credentials to answer HTTP authentication challenges, such as basic
691691
use HeadlessChromium\Exception\AuthenticationFailed;
692692

693693
try {
694-
$page->authenticate('username', 'password');
695-
$page->navigate('http://example.com/protected')->waitForNavigation();
694+
$page->authenticate('username', 'password', 'https://example.com');
695+
$page->navigate('https://example.com/protected')->waitForNavigation();
696696
} catch (AuthenticationFailed $e) {
697697
// invalid credentials
698698
}
699699
```
700700

701+
The origin argument restricts the credentials to challenges coming from that origin, compared by scheme, host and port, so that they cannot leak to other origins, e.g. through pages embedding cross-origin resources. Omit the origin to answer every challenge, e.g. when authenticating to a proxy. Use `Page::clearAuthentication` to remove the credentials again.
702+
703+
`Page::authenticate` intercepts requests using the DevTools fetch domain, so it should not be combined with custom `Fetch.enable` usage on the same page.
704+
701705
### Set user agent
702706

703707
You can set up a user-agent per page:

‎src/Page.php‎

Lines changed: 105 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use HeadlessChromium\Exception\TargetDestroyed;
3131
use HeadlessChromium\Input\Keyboard;
3232
use HeadlessChromium\Input\Mouse;
33+
use HeadlessChromium\PageUtils\AuthenticationScope;
3334
use HeadlessChromium\PageUtils\CookiesGetter;
3435
use HeadlessChromium\PageUtils\PageEvaluation;
3536
use HeadlessChromium\PageUtils\PageLayoutMetrics;
@@ -84,6 +85,27 @@ class Page
8485
*/
8586
protected $authCredentials;
8687

88+
/**
89+
* Scope the authentication credentials are restricted to.
90+
*
91+
* @var AuthenticationScope|null
92+
*/
93+
protected $authScope;
94+
95+
/**
96+
* Challenges that were already provided the credentials.
97+
*
98+
* @var array<string, true>
99+
*/
100+
protected $attemptedAuthentications = [];
101+
102+
/**
103+
* Whether the authentication challenge listeners were registered.
104+
*
105+
* @var bool
106+
*/
107+
protected $authListenersRegistered = false;
108+
87109
/**
88110
* Page constructor.
89111
*
@@ -170,48 +192,102 @@ public function getSession(): Session
170192
* Sets credentials to be used when the page encounters an HTTP authentication challenge,
171193
* such as basic authentication or a proxy requiring authentication.
172194
*
173-
* Calling this method again replaces the credentials. If the browser rejects the
174-
* credentials by challenging the same request again, the request is cancelled and an
195+
* When an origin is given, the credentials are only used to answer challenges coming from
196+
* that origin, compared by scheme, host and port, and only requests within that origin are
197+
* intercepted. Omit the origin to answer every challenge, e.g. when authenticating to a proxy.
198+
*
199+
* Calling this method again replaces the credentials and the origin. If the browser rejects
200+
* the credentials by repeating a challenge, the request is cancelled and an
175201
* AuthenticationFailed exception is thrown from the method that is reading the browser
176202
* messages at that time, usually PageNavigation::waitForNavigation.
177203
*
204+
* @throws \InvalidArgumentException
178205
* @throws CommunicationException
179206
* @throws NoResponseAvailable
180207
* @throws OperationTimedOut
181208
*/
182-
public function authenticate(string $username, string $password): void
209+
public function authenticate(string $username, string $password, ?string $origin = null): void
183210
{
184211
$this->assertNotClosed();
185212

186-
$alreadyEnabled = null !== $this->authCredentials;
213+
$scope = null !== $origin ? new AuthenticationScope($origin) : null;
187214

188215
$this->authCredentials = ['username' => $username, 'password' => $password];
216+
$this->authScope = $scope;
217+
$this->attemptedAuthentications = [];
189218

190-
if ($alreadyEnabled) {
219+
$this->registerAuthenticationListeners();
220+
221+
$this->getSession()->sendMessageSync(
222+
new Message('Fetch.enable', [
223+
'handleAuthRequests' => true,
224+
'patterns' => [
225+
['urlPattern' => null !== $scope ? $scope->getUrlPattern() : '*'],
226+
],
227+
])
228+
);
229+
}
230+
231+
/**
232+
* Removes the credentials set with Page::authenticate and stops intercepting requests.
233+
*
234+
* @throws CommunicationException
235+
* @throws NoResponseAvailable
236+
* @throws OperationTimedOut
237+
*/
238+
public function clearAuthentication(): void
239+
{
240+
$this->assertNotClosed();
241+
242+
if (null === $this->authCredentials) {
243+
return;
244+
}
245+
246+
$this->authCredentials = null;
247+
$this->authScope = null;
248+
$this->attemptedAuthentications = [];
249+
250+
$this->getSession()->sendMessageSync(new Message('Fetch.disable'));
251+
}
252+
253+
/**
254+
* Registers the fetch domain listeners used to answer authentication challenges.
255+
*/
256+
private function registerAuthenticationListeners(): void
257+
{
258+
if ($this->authListenersRegistered) {
191259
return;
192260
}
193261

194-
// requests that were already provided the credentials; a second
195-
// challenge for the same request means they were rejected
196-
$attemptedAuthentications = [];
262+
$this->authListenersRegistered = true;
197263

198-
$this->getSession()->on('method:Fetch.authRequired', function (array $params) use (&$attemptedAuthentications): void {
264+
$this->getSession()->on('method:Fetch.authRequired', function (array $params): void {
199265
$requestId = $params['requestId'];
266+
$challenge = $params['authChallenge'] ?? [];
200267

201-
if (\array_key_exists($requestId, $attemptedAuthentications)) {
202-
$this->getSession()->sendMessageSync(
203-
new Message('Fetch.continueWithAuth', [
204-
'requestId' => $requestId,
205-
'authChallengeResponse' => [
206-
'response' => 'CancelAuth',
207-
],
208-
])
209-
);
268+
$attemptKey = \implode("\0", [
269+
$requestId,
270+
$challenge['source'] ?? '',
271+
$challenge['origin'] ?? '',
272+
$challenge['scheme'] ?? '',
273+
$challenge['realm'] ?? '',
274+
]);
275+
276+
// a challenge that was already provided the credentials means they were rejected
277+
if (\array_key_exists($attemptKey, $this->attemptedAuthentications)) {
278+
$this->cancelAuthenticationChallenge($requestId);
210279

211280
throw new AuthenticationFailed('Authentication failed: invalid credentials.');
212281
}
213282

214-
$attemptedAuthentications[$requestId] = true;
283+
// cancel out of scope challenges without exposing the credentials
284+
if (null === $this->authCredentials || (null !== $this->authScope && !$this->authScope->matchesOrigin($challenge['origin'] ?? ''))) {
285+
$this->cancelAuthenticationChallenge($requestId);
286+
287+
return;
288+
}
289+
290+
$this->attemptedAuthentications[$attemptKey] = true;
215291

216292
$this->getSession()->sendMessageSync(
217293
new Message('Fetch.continueWithAuth', [
@@ -232,12 +308,18 @@ public function authenticate(string $username, string $password): void
232308
])
233309
);
234310
});
311+
}
235312

313+
/**
314+
* Cancels an authentication challenge without exposing the credentials.
315+
*/
316+
private function cancelAuthenticationChallenge(string $requestId): void
317+
{
236318
$this->getSession()->sendMessageSync(
237-
new Message('Fetch.enable', [
238-
'handleAuthRequests' => true,
239-
'patterns' => [
240-
['urlPattern' => '*'],
319+
new Message('Fetch.continueWithAuth', [
320+
'requestId' => $requestId,
321+
'authChallengeResponse' => [
322+
'response' => 'CancelAuth',
241323
],
242324
])
243325
);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Chrome PHP.
5+
*
6+
* (c) Soufiane Ghzal <sghzal@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace HeadlessChromium\PageUtils;
13+
14+
/**
15+
* Restricts authentication credentials to a single web origin.
16+
*/
17+
class AuthenticationScope
18+
{
19+
/**
20+
* @var string
21+
*/
22+
protected $scheme;
23+
24+
/**
25+
* @var string
26+
*/
27+
protected $host;
28+
29+
/**
30+
* @var int
31+
*/
32+
protected $port;
33+
34+
/**
35+
* @param string $origin an absolute http or https URI; any path, query or fragment is ignored
36+
*
37+
* @throws \InvalidArgumentException
38+
*/
39+
public function __construct(string $origin)
40+
{
41+
$parts = \parse_url($origin);
42+
43+
if (false === $parts || !isset($parts['scheme'], $parts['host'])) {
44+
throw new \InvalidArgumentException('The authentication origin must be an absolute http or https URI.');
45+
}
46+
47+
if (isset($parts['user']) || isset($parts['pass'])) {
48+
throw new \InvalidArgumentException('The authentication origin must not contain user info.');
49+
}
50+
51+
$scheme = \strtolower($parts['scheme']);
52+
53+
if (!\in_array($scheme, ['http', 'https'], true)) {
54+
throw new \InvalidArgumentException('The authentication origin must use the http or https scheme.');
55+
}
56+
57+
$host = \strtolower($parts['host']);
58+
59+
// colons are only valid within bracketed IPv6 literals
60+
if ('[' === \substr($host, 0, 1)) {
61+
$validHost = 1 === \preg_match('/^\[[0-9a-f:.]+\]$/D', $host);
62+
} else {
63+
$validHost = 1 === \preg_match('/^[a-z0-9\-._~%]+$/D', $host);
64+
}
65+
66+
if (!$validHost) {
67+
throw new \InvalidArgumentException('The authentication origin host contains invalid characters.');
68+
}
69+
70+
$this->scheme = $scheme;
71+
$this->host = $host;
72+
$this->port = $parts['port'] ?? ('https' === $scheme ? 443 : 80);
73+
}
74+
75+
/**
76+
* Check whether the given origin, e.g. of an authentication challenge, is the same web origin.
77+
*/
78+
public function matchesOrigin(string $origin): bool
79+
{
80+
try {
81+
$other = new self($origin);
82+
} catch (\InvalidArgumentException $e) {
83+
return false;
84+
}
85+
86+
return $other->scheme === $this->scheme && $other->host === $this->host && $other->port === $this->port;
87+
}
88+
89+
/**
90+
* Get the fetch request pattern matching only URLs within this origin.
91+
*/
92+
public function getUrlPattern(): string
93+
{
94+
$pattern = $this->scheme.'://'.$this->host;
95+
96+
// browsers omit default ports in request URLs
97+
if (('http' === $this->scheme && 80 !== $this->port) || ('https' === $this->scheme && 443 !== $this->port)) {
98+
$pattern .= ':'.$this->port;
99+
}
100+
101+
return $pattern.'/*';
102+
}
103+
}

‎tests/AuthenticationTest.php‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,52 @@ public function testAuthenticateUpdatesCredentialsForNewChallenges(): void
6767
self::assertStringContainsString('Authenticated', $page->getHtml());
6868
}
6969

70+
public function testAuthenticateScopedToOriginAllowsMatchingOrigin(): void
71+
{
72+
$page = self::$browser->createPage();
73+
74+
$page->authenticate(self::VALID_USER, self::VALID_PASS, 'HTTP://LOCALHOST:8083/ignored/path');
75+
$page->navigate(self::sitePath(self::AUTH_URL))->waitForNavigation();
76+
77+
self::assertStringContainsString('Authenticated', $page->getHtml());
78+
}
79+
80+
public function testAuthenticateScopedToDifferentOriginDoesNotSendCredentials(): void
81+
{
82+
$page = self::$browser->createPage();
83+
84+
$page->authenticate(self::VALID_USER, self::VALID_PASS, 'http://127.0.0.1:8083');
85+
$page->navigate(self::sitePath(self::AUTH_URL))->waitForNavigation();
86+
87+
self::assertStringNotContainsString('Authenticated', $page->getHtml());
88+
}
89+
90+
public function testAuthenticateThrowsOnInvalidOrigin(): void
91+
{
92+
$page = self::$browser->createPage();
93+
94+
$this->expectException(\InvalidArgumentException::class);
95+
96+
$page->authenticate(self::VALID_USER, self::VALID_PASS, 'not an origin');
97+
}
98+
99+
public function testClearAuthenticationStopsAnsweringChallenges(): void
100+
{
101+
$page = self::$browser->createPage();
102+
103+
$page->authenticate(self::VALID_USER, self::VALID_PASS);
104+
$page->navigate(self::sitePath(self::AUTH_URL))->waitForNavigation();
105+
106+
self::assertStringContainsString('Authenticated', $page->getHtml());
107+
108+
$page->clearAuthentication();
109+
110+
// expect different credentials so the previously cached ones no longer authenticate
111+
$page->navigate(self::sitePath(self::AUTH_URL.'?user=otheruser&pass=otherpass'))->waitForNavigation();
112+
113+
self::assertStringNotContainsString('Authenticated', $page->getHtml());
114+
}
115+
70116
public function testAuthenticateThrowsOnInvalidCredentials(): void
71117
{
72118
$page = self::$browser->createPage();

0 commit comments

Comments
 (0)