Skip to content

Commit dd36390

Browse files
Fix challenge handling, add origin scoping and clearAuthentication
1 parent 45ade6b commit dd36390

8 files changed

Lines changed: 442 additions & 35 deletions

File tree

‎CHANGELOG.md‎

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

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

910

1011
## 1.15.1 (UPCOMING)

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -685,19 +685,23 @@ if ($cookieBar) {
685685

686686
### HTTP Authentication
687687

688-
You can set credentials for HTTP Basic Authentication using `Page::authenticate` before navigating to a protected page or when using a proxy. If the credentials are invalid, an `AuthenticationFailed` exception is thrown.
688+
You can set credentials to answer HTTP authentication challenges, such as basic authentication or a proxy requiring authentication, using `Page::authenticate` before navigating. If the credentials are rejected, an `AuthenticationFailed` exception is thrown while waiting for the navigation.
689689

690690
```php
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/Exception/AuthenticationFailed.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace HeadlessChromium\Exception;
1313

14-
class AuthenticationFailed extends \Exception
14+
use Exception;
15+
16+
class AuthenticationFailed extends Exception
1517
{
1618
}

‎src/Page.php‎

Lines changed: 131 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use HeadlessChromium\Exception\TargetDestroyed;
3232
use HeadlessChromium\Input\Keyboard;
3333
use HeadlessChromium\Input\Mouse;
34+
use HeadlessChromium\PageUtils\AuthenticationScope;
3435
use HeadlessChromium\PageUtils\CookiesGetter;
3536
use HeadlessChromium\PageUtils\PageEvaluation;
3637
use HeadlessChromium\PageUtils\PageLayoutMetrics;
@@ -79,6 +80,34 @@ class Page
7980
*/
8081
protected $dom;
8182

83+
/**
84+
* Credentials to be used to answer HTTP authentication challenges.
85+
*
86+
* @var array{username: string, password: string}|null
87+
*/
88+
protected $authCredentials;
89+
90+
/**
91+
* Scope the authentication credentials are restricted to.
92+
*
93+
* @var AuthenticationScope|null
94+
*/
95+
protected $authScope;
96+
97+
/**
98+
* Challenges that were already provided the credentials.
99+
*
100+
* @var array<string, true>
101+
*/
102+
protected $attemptedAuthentications = [];
103+
104+
/**
105+
* Whether the authentication challenge listeners were registered.
106+
*
107+
* @var bool
108+
*/
109+
protected $authListenersRegistered = false;
110+
82111
/**
83112
* Page constructor.
84113
*
@@ -162,58 +191,137 @@ public function getSession(): Session
162191
}
163192

164193
/**
165-
* Sets credentials to be used when the page encounters an HTTP authentication challenge.
194+
* Sets credentials to be used when the page encounters an HTTP authentication challenge,
195+
* such as basic authentication or a proxy requiring authentication.
196+
*
197+
* When an origin is given, the credentials are only used to answer challenges coming from
198+
* that origin, compared by scheme, host and port, and only requests within that origin are
199+
* intercepted. Omit the origin to answer every challenge, e.g. when authenticating to a proxy.
166200
*
167-
* @throws AuthenticationFailed
201+
* Calling this method again replaces the credentials and the origin. If the browser rejects
202+
* the credentials by repeating a challenge, the request is cancelled and an
203+
* AuthenticationFailed exception is thrown from the method that is reading the browser
204+
* messages at that time, usually PageNavigation::waitForNavigation.
205+
*
206+
* @throws InvalidArgumentException
168207
* @throws CommunicationException
208+
* @throws NoResponseAvailable
209+
* @throws OperationTimedOut
169210
*/
170-
public function authenticate(string $username, string $password): void
211+
public function authenticate(string $username, string $password, ?string $origin = null): void
171212
{
172213
$this->assertNotClosed();
173214

174-
$credentialsAttempted = false;
215+
$scope = null !== $origin ? new AuthenticationScope($origin) : null;
175216

176-
$this->getSession()->on('method:Fetch.authRequired', function (array $params) use ($username, $password, &$credentialsAttempted) {
177-
if ($credentialsAttempted) {
178-
$this->getSession()->sendMessageSync(
179-
new Message('Fetch.continueWithAuth', [
180-
'requestId' => $params['requestId'],
181-
'authChallengeResponse' => [
182-
'response' => 'CancelAuth',
183-
],
184-
])
185-
);
217+
$this->authCredentials = ['username' => $username, 'password' => $password];
218+
$this->authScope = $scope;
219+
$this->attemptedAuthentications = [];
220+
221+
$this->registerAuthenticationListeners();
222+
223+
$this->getSession()->sendMessageSync(
224+
new Message('Fetch.enable', [
225+
'handleAuthRequests' => true,
226+
'patterns' => [
227+
['urlPattern' => null !== $scope ? $scope->getUrlPattern() : '*'],
228+
],
229+
])
230+
);
231+
}
232+
233+
/**
234+
* Removes the credentials set with Page::authenticate and stops intercepting requests.
235+
*
236+
* @throws CommunicationException
237+
* @throws NoResponseAvailable
238+
* @throws OperationTimedOut
239+
*/
240+
public function clearAuthentication(): void
241+
{
242+
$this->assertNotClosed();
243+
244+
if (null === $this->authCredentials) {
245+
return;
246+
}
247+
248+
$this->authCredentials = null;
249+
$this->authScope = null;
250+
$this->attemptedAuthentications = [];
251+
252+
$this->getSession()->sendMessageSync(new Message('Fetch.disable'));
253+
}
254+
255+
/**
256+
* Registers the fetch domain listeners used to answer authentication challenges.
257+
*/
258+
private function registerAuthenticationListeners(): void
259+
{
260+
if ($this->authListenersRegistered) {
261+
return;
262+
}
263+
264+
$this->authListenersRegistered = true;
265+
266+
$this->getSession()->on('method:Fetch.authRequired', function (array $params): void {
267+
$requestId = $params['requestId'];
268+
$challenge = $params['authChallenge'] ?? [];
269+
270+
$attemptKey = \implode("\0", [
271+
$requestId,
272+
$challenge['source'] ?? '',
273+
$challenge['origin'] ?? '',
274+
$challenge['scheme'] ?? '',
275+
$challenge['realm'] ?? '',
276+
]);
277+
278+
// a challenge that was already provided the credentials means they were rejected
279+
if (\array_key_exists($attemptKey, $this->attemptedAuthentications)) {
280+
$this->cancelAuthenticationChallenge($requestId);
186281

187282
throw new AuthenticationFailed('Authentication failed: invalid credentials.');
188283
}
189284

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

192294
$this->getSession()->sendMessageSync(
193295
new Message('Fetch.continueWithAuth', [
194-
'requestId' => $params['requestId'],
296+
'requestId' => $requestId,
195297
'authChallengeResponse' => [
196298
'response' => 'ProvideCredentials',
197-
'username' => $username,
198-
'password' => $password,
299+
'username' => $this->authCredentials['username'],
300+
'password' => $this->authCredentials['password'],
199301
],
200302
])
201303
);
202304
});
203305

204-
$this->getSession()->on('method:Fetch.requestPaused', function (array $params) {
306+
$this->getSession()->on('method:Fetch.requestPaused', function (array $params): void {
205307
$this->getSession()->sendMessageSync(
206308
new Message('Fetch.continueRequest', [
207309
'requestId' => $params['requestId'],
208310
])
209311
);
210312
});
313+
}
211314

315+
/**
316+
* Cancels an authentication challenge without exposing the credentials.
317+
*/
318+
private function cancelAuthenticationChallenge(string $requestId): void
319+
{
212320
$this->getSession()->sendMessageSync(
213-
new Message('Fetch.enable', [
214-
'handleAuthRequests' => true,
215-
'patterns' => [
216-
['urlPattern' => '*'],
321+
new Message('Fetch.continueWithAuth', [
322+
'requestId' => $requestId,
323+
'authChallengeResponse' => [
324+
'response' => 'CancelAuth',
217325
],
218326
])
219327
);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
use InvalidArgumentException;
15+
16+
/**
17+
* Restricts authentication credentials to a single web origin.
18+
*/
19+
class AuthenticationScope
20+
{
21+
/**
22+
* @var string
23+
*/
24+
protected $scheme;
25+
26+
/**
27+
* @var string
28+
*/
29+
protected $host;
30+
31+
/**
32+
* @var int
33+
*/
34+
protected $port;
35+
36+
/**
37+
* @param string $origin an absolute http or https URI; any path, query or fragment is ignored
38+
*
39+
* @throws InvalidArgumentException
40+
*/
41+
public function __construct(string $origin)
42+
{
43+
$parts = \parse_url($origin);
44+
45+
if (false === $parts || !isset($parts['scheme'], $parts['host'])) {
46+
throw new InvalidArgumentException('The authentication origin must be an absolute http or https URI.');
47+
}
48+
49+
if (isset($parts['user']) || isset($parts['pass'])) {
50+
throw new InvalidArgumentException('The authentication origin must not contain user info.');
51+
}
52+
53+
$scheme = \strtolower($parts['scheme']);
54+
55+
if (!\in_array($scheme, ['http', 'https'], true)) {
56+
throw new InvalidArgumentException('The authentication origin must use the http or https scheme.');
57+
}
58+
59+
$host = \strtolower($parts['host']);
60+
61+
// colons are only valid within bracketed IPv6 literals
62+
if ('[' === \substr($host, 0, 1)) {
63+
$validHost = 1 === \preg_match('/^\[[0-9a-f:.]+\]$/D', $host);
64+
} else {
65+
$validHost = 1 === \preg_match('/^[a-z0-9\-._~%]+$/D', $host);
66+
}
67+
68+
if (!$validHost) {
69+
throw new InvalidArgumentException('The authentication origin host contains invalid characters.');
70+
}
71+
72+
$this->scheme = $scheme;
73+
$this->host = $host;
74+
$this->port = $parts['port'] ?? ('https' === $scheme ? 443 : 80);
75+
}
76+
77+
/**
78+
* Check whether the given origin, e.g. of an authentication challenge, is the same web origin.
79+
*/
80+
public function matchesOrigin(string $origin): bool
81+
{
82+
try {
83+
$other = new self($origin);
84+
} catch (InvalidArgumentException $e) {
85+
return false;
86+
}
87+
88+
return $other->scheme === $this->scheme && $other->host === $this->host && $other->port === $this->port;
89+
}
90+
91+
/**
92+
* Get the fetch request pattern matching only URLs within this origin.
93+
*/
94+
public function getUrlPattern(): string
95+
{
96+
$pattern = $this->scheme.'://'.$this->host;
97+
98+
// browsers omit default ports in request URLs
99+
if (('http' === $this->scheme && 80 !== $this->port) || ('https' === $this->scheme && 443 !== $this->port)) {
100+
$pattern .= ':'.$this->port;
101+
}
102+
103+
return $pattern.'/*';
104+
}
105+
}

0 commit comments

Comments
 (0)