Skip to content

Commit 9569cb5

Browse files
mxschmittdgozman
andauthored
feat: support client certificates (microsoft#31529)
Signed-off-by: Max Schmitt <[email protected]> Co-authored-by: Dmitry Gozman <[email protected]>
1 parent 2290005 commit 9569cb5

File tree

33 files changed

+1456
-15
lines changed

33 files changed

+1456
-15
lines changed

docs/src/api/class-apirequest.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ see [APIRequestContext].
1212

1313
Creates new instances of [APIRequestContext].
1414

15+
### option: APIRequest.newContext.clientCertificates = %%-context-option-clientCertificates-%%
16+
* since: 1.46
17+
1518
### option: APIRequest.newContext.useragent = %%-context-option-useragent-%%
1619
* since: v1.16
1720

docs/src/api/class-browser.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ await browser.CloseAsync();
256256
### option: Browser.newContext.proxy = %%-context-option-proxy-%%
257257
* since: v1.8
258258

259+
### option: Browser.newContext.clientCertificates = %%-context-option-clientCertificates-%%
260+
* since: 1.46
261+
259262
### option: Browser.newContext.storageState = %%-js-python-context-option-storage-state-%%
260263
* since: v1.8
261264

@@ -281,6 +284,9 @@ testing frameworks should explicitly create [`method: Browser.newContext`] follo
281284
### option: Browser.newPage.proxy = %%-context-option-proxy-%%
282285
* since: v1.8
283286

287+
### option: Browser.newPage.clientCertificates = %%-context-option-clientCertificates-%%
288+
* since: 1.46
289+
284290
### option: Browser.newPage.storageState = %%-js-python-context-option-storage-state-%%
285291
* since: v1.8
286292

docs/src/api/class-browsertype.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,9 @@ use a temporary directory instead.
343343
### option: BrowserType.launchPersistentContext.firefoxUserPrefs2 = %%-csharp-java-browser-option-firefoxuserprefs-%%
344344
* since: v1.40
345345

346+
### option: BrowserType.launchPersistentContext.clientCertificates = %%-context-option-clientCertificates-%%
347+
* since: 1.46
348+
346349
## async method: BrowserType.launchServer
347350
* since: v1.8
348351
* langs: js

docs/src/api/params.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,25 @@ Sets a consistent viewport for each page. Defaults to an 1280x720 viewport. `no_
514514

515515
Does not enforce fixed viewport, allows resizing window in the headed mode.
516516

517+
## context-option-clientCertificates
518+
- `clientCertificates` <[Array]<[Object]>>
519+
- `url` <[string]> Glob pattern to match the URLs that the certificate is valid for.
520+
- `certs` <[Array]<[Object]>> List of client certificates to be used.
521+
- `certPath` ?<[string]> Path to the file with the certificate in PEM format.
522+
- `keyPath` ?<[string]> Path to the file with the private key in PEM format.
523+
- `pfxPath` ?<[string]> Path to the PFX or PKCS12 encoded private key and certificate chain.
524+
- `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX).
525+
526+
An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the private key is encrypted. If the certificate is valid only for specific URLs, the `url` property should be provided with a glob pattern to match the URLs that the certificate is valid for.
527+
528+
:::note
529+
Using Client Certificates in combination with Proxy Servers is not supported.
530+
:::
531+
532+
:::note
533+
When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`.
534+
:::
535+
517536
## context-option-useragent
518537
- `userAgent` <[string]>
519538

docs/src/test-api/class-testoptions.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,35 @@ export default defineConfig({
138138
]
139139
});
140140
```
141+
142+
## property: TestOptions.clientCertificates = %%-context-option-clientCertificates-%%
143+
* since: 1.46
144+
145+
**Usage**
146+
147+
```js title="playwright.config.ts"
148+
import { defineConfig } from '@playwright/test';
149+
150+
export default defineConfig({
151+
projects: [
152+
{
153+
name: 'Microsoft Edge',
154+
use: {
155+
...devices['Desktop Edge'],
156+
clientCertificates: [{
157+
url: 'https://example.com/**',
158+
certs: [{
159+
certPath: './cert.pem',
160+
keyPath: './key.pem',
161+
passphase: 'mysecretpassword',
162+
}],
163+
}],
164+
},
165+
},
166+
]
167+
});
168+
```
169+
141170
## property: TestOptions.colorScheme = %%-context-option-colorscheme-%%
142171
* since: v1.10
143172

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Certfificates for Socks Proxy
2+
3+
These certificates are used when client certificates are used with
4+
Playwright. Playwright then creates a Socks proxy, which sits between
5+
the browser and the actual target server. The Socks proxy uses this certificiate
6+
to talk to the browser and establishes its own secure TLS connection to the server.
7+
The certificates are generated via:
8+
9+
```bash
10+
openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 -keyout key.pem -out cert.pem -subj "/CN=localhost"
11+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDCTCCAfGgAwIBAgIUTcrzEueVL/OuLHr4LBIPWeS4UL0wDQYJKoZIhvcNAQEL
3+
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MDcwNDA4NDAzNFoXDTM0MDcw
4+
MjA4NDAzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
5+
AAOCAQ8AMIIBCgKCAQEApof+SZVN4UGma4xJDVHhMSpmEJoCdMPr+HFadJJK/brF
6+
BNOhA1C5wNk8oD/XYo7enAHQH/EsBnq4MMxv79rXTGnIdXMF+43GdMDh5kh81FQy
7+
Esw8Vt4eif9eZkjUxI2GHhR2ovJewmQa7E+SeUB2RzJTqz8QPLhd74JFfgaci+S2
8+
8L37ScVjcw55T1PcNflzB4vwsQHBT3yND0MLDhm+8MLzmTl4Mw5PgIOaBl5Jh8Tr
9+
wQF4eeeB3FPJoMQhTP8aGBjW1mo+NmSSRAPIAZyhmCAnDeC33yRjAaiHjaL5Pr9f
10+
wt5zoF5+U1xWhGXWzGOE6p/VTj62F9a2fOXNHclYJQIDAQABo1MwUTAdBgNVHQ4E
11+
FgQU9BoVzGtb5x70KqGO/89N1hyqi5kwHwYDVR0jBBgwFoAU9BoVzGtb5x70KqGO
12+
/89N1hyqi5kwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYcbI
13+
wvcfx2p8z0RNN3EA+epKX1SagZyJX4ORIO8kln1sDU+ceHde3n3xnp1dg6HG2qh1
14+
a7CZub/fNUaP9R8+6iiV0wPT7Ybkb2NIJcH1yq+/bfSS5OC5DO0yv9SUADdBoDwa
15+
zOuBAqdcYW1BHYcbAzsQnniRcejHu06ioaS6SwwJ8150rQnLT4Lh9LAl40W6v4nZ
16+
NdTGQETTrbjcgH1ER4IhWTKtVyPOxGF9A/OOawMEdfS8BhUO7YRS4QNFFaQMrJAb
17+
MDhDtjSyDogLr8P43xjjWvQWG9a7zTF0kKEsdJ0cEG5HATpg8bPHmrouxbs2HGeH
18+
kJXzMykrsYyXsInN3w==
19+
-----END CERTIFICATE-----
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmh/5JlU3hQaZr
3+
jEkNUeExKmYQmgJ0w+v4cVp0kkr9usUE06EDULnA2TygP9dijt6cAdAf8SwGergw
4+
zG/v2tdMach1cwX7jcZ0wOHmSHzUVDISzDxW3h6J/15mSNTEjYYeFHai8l7CZBrs
5+
T5J5QHZHMlOrPxA8uF3vgkV+BpyL5LbwvftJxWNzDnlPU9w1+XMHi/CxAcFPfI0P
6+
QwsOGb7wwvOZOXgzDk+Ag5oGXkmHxOvBAXh554HcU8mgxCFM/xoYGNbWaj42ZJJE
7+
A8gBnKGYICcN4LffJGMBqIeNovk+v1/C3nOgXn5TXFaEZdbMY4Tqn9VOPrYX1rZ8
8+
5c0dyVglAgMBAAECggEAB6zX4vNPKhUZAvbtvP/rlZUDLDu05kXLX+F1jk7ZxvTv
9+
NKg+UQVM8l7wxN/8YM3944nP2lEGuuu4BoO9mvvmlV6Avy0EdxITNflX0AHCQxT4
10+
U9Z253gIR0ruQl+T8tUk+8jsqNjr1iC//ukx8oWujdx7b7aR3IKQzcOeyU6rs2TN
11+
lyrVVsEaFVi9+wCw0xyiCmPlobrn+egdigw7Zhp2BRinC6W9eMxuPS2hlhQUhBm/
12+
eiD96YWp0RAv/L5qO93reoXIAzrrLdcUgPEnnq1zN7y2xihU2+B2sTph1m/A26+J
13+
yPcXd7vQrXlRXQU6PaCa+0oJULlpiAzy3HPbnr4BkQKBgQDdmekTX8dQqiEZPX1C
14+
017QRFbx0/x/TDFDSeJbDeauMzzCaGqCO2WVmYmTvFtby2G4/6BYowVtJVHm4uJl
15+
XsYk8dWIQGLPIj1Cw7ZieJvb2EVRxgnY2oMaOTOazHzPHFzZV718zwEeZrryT82J
16+
881E8wgM8V3DjkS4ye3TbwvimQKBgQDAYa/IdnpAg5z1TREi9Tt8fnoGpmSscAak
17+
USgeXVsvoNzXXkE94MiiCOOrX1r68TWYDAzq6MKGDewkWOfLwXWR6D5C2LyE1q9P
18+
1pxstgs/nC3ZUTz0yEH47ahSmhywhGlvXXOQEXUSLiVTOdeMCubMqwQW80F1868n
19+
aBHcj5/lbQKBgQDIojjsWaNT3TTqbUmj30vQtI8jlBLgDlPr4FEYr5VT0wAH5BHK
20+
p4xpzgFJyRfOHG312TuMBM087LUinfjsXsp3WJ1EJ0dO0mk0sY3HyfsTKNRaHTt9
21+
Ixnf/DpExS+bNMq73Tyqa6FPrSNFkAtAA4SuEHwRe9aw33ZI+EpjS/8uwQKBgQCi
22+
9NwqSLlLVnColEw0uVdXH+cLJPzX19i4bQo3lkp8MJ2ATJWk7XflUPRQoGf3ckQ8
23+
c9CpVtoXJUnmi+xkeo21Nu0uQFqHhzZewWIk75rdmdR4ZUjl649+ZQkUVviASNjq
24+
fVU7Lp5k9POm6LL9K+rOaPoA2rKTUAQItC2VD4+YjQKBgB6kgvgN6Mz/u0RE3kkV
25+
2GOoP5sso71Hxwh7o6JEzUMhR+e/T/LLcBwEjLYcf1FYRySHsXLn2Ar/Uw1J7pAZ
26+
ud54/at+7mTDliaT8Ar7S9vcso7ZfmuDX9qB9+c77idPskVBPo2tjJbwvFcB6sww
27+
5Elcfmj6tEP4YLJ6Kv3qTPhT
28+
-----END PRIVATE KEY-----

packages/playwright-core/src/client/browserContext.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions
529529
reducedMotion: options.reducedMotion === null ? 'no-override' : options.reducedMotion,
530530
forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors,
531531
acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads),
532+
clientCertificates: await toClientCertificatesProtocol(options.clientCertificates),
532533
};
533534
if (!contextParams.recordVideo && options.videosPath) {
534535
contextParams.recordVideo = {
@@ -548,3 +549,21 @@ function toAcceptDownloadsProtocol(acceptDownloads?: boolean) {
548549
return 'accept';
549550
return 'deny';
550551
}
552+
553+
export async function toClientCertificatesProtocol(clientCertificates?: BrowserContextOptions['clientCertificates']): Promise<channels.PlaywrightNewRequestParams['clientCertificates']> {
554+
if (!clientCertificates)
555+
return undefined;
556+
return await Promise.all(clientCertificates.map(async clientCertificate => {
557+
return {
558+
url: clientCertificate.url,
559+
certs: await Promise.all(clientCertificate.certs.map(async cert => {
560+
return {
561+
cert: cert.certPath ? await fs.promises.readFile(cert.certPath) : undefined,
562+
key: cert.keyPath ? await fs.promises.readFile(cert.keyPath) : undefined,
563+
pfx: cert.pfxPath ? await fs.promises.readFile(cert.pfxPath) : undefined,
564+
passphrase: cert.passphrase,
565+
};
566+
}))
567+
};
568+
}));
569+
}

packages/playwright-core/src/client/fetch.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ import { assert, headersObjectToArray, isString } from '../utils';
2525
import { mkdirIfNeeded } from '../utils/fileUtils';
2626
import { ChannelOwner } from './channelOwner';
2727
import { RawHeaders } from './network';
28-
import type { FilePayload, Headers, StorageState } from './types';
28+
import type { ClientCertificate, FilePayload, Headers, StorageState } from './types';
2929
import type { Playwright } from './playwright';
3030
import { Tracing } from './tracing';
3131
import { TargetClosedError, isTargetClosedError } from './errors';
32+
import { toClientCertificatesProtocol } from './browserContext';
3233

3334
export type FetchOptions = {
3435
params?: { [key: string]: string; },
@@ -44,9 +45,10 @@ export type FetchOptions = {
4445
maxRetries?: number,
4546
};
4647

47-
type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'storageState' | 'tracesDir'> & {
48+
type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'tracesDir'> & {
4849
extraHTTPHeaders?: Headers,
4950
storageState?: string | StorageState,
51+
clientCertificates?: ClientCertificate[];
5052
};
5153

5254
type RequestWithBodyOptions = Omit<FetchOptions, 'method'>;
@@ -74,6 +76,7 @@ export class APIRequest implements api.APIRequest {
7476
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
7577
storageState,
7678
tracesDir,
79+
clientCertificates: await toClientCertificatesProtocol(options.clientCertificates),
7780
})).request);
7881
this._contexts.add(context);
7982
context._request = this;
@@ -175,7 +178,7 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
175178
const params = objectToArray(options.params);
176179
const method = options.method || options.request?.method();
177180
// Cannot call allHeaders() here as the request may be paused inside route handler.
178-
const headersObj = options.headers || options.request?.headers() ;
181+
const headersObj = options.headers || options.request?.headers();
179182
const headers = headersObj ? headersObjectToArray(headersObj) : undefined;
180183
let jsonData: any;
181184
let formData: channels.NameValue[] | undefined;
@@ -395,7 +398,7 @@ function isJsonContentType(headers?: HeadersArray): boolean {
395398
return false;
396399
}
397400

398-
function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined {
401+
function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined {
399402
if (!map)
400403
return undefined;
401404
const result = [];

0 commit comments

Comments
 (0)