Skip to content

Commit

Permalink
feat: support client certificates
Browse files Browse the repository at this point in the history
  • Loading branch information
mxschmitt committed Jul 4, 2024
1 parent 2b974f2 commit 0a3d5dd
Show file tree
Hide file tree
Showing 30 changed files with 1,326 additions and 8 deletions.
3 changes: 3 additions & 0 deletions docs/src/api/class-apirequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ see [APIRequestContext].

Creates new instances of [APIRequestContext].

### option: APIRequest.newContext.clientCertificates = %%-context-option-clientCertificates-%%
* since: 1.46

### option: APIRequest.newContext.useragent = %%-context-option-useragent-%%
* since: v1.16

Expand Down
6 changes: 6 additions & 0 deletions docs/src/api/class-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ await browser.CloseAsync();
### option: Browser.newContext.proxy = %%-context-option-proxy-%%
* since: v1.8

### option: Browser.newContext.clientCertificates = %%-context-option-clientCertificates-%%
* since: 1.46

### option: Browser.newContext.storageState = %%-js-python-context-option-storage-state-%%
* since: v1.8

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

### option: Browser.newPage.clientCertificates = %%-context-option-clientCertificates-%%
* since: 1.46

### option: Browser.newPage.storageState = %%-js-python-context-option-storage-state-%%
* since: v1.8

Expand Down
11 changes: 11 additions & 0 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,17 @@ Sets a consistent viewport for each page. Defaults to an 1280x720 viewport. `no_

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

## context-option-clientCertificates
- `clientCertificates` <[Array]<[Object]>>
- `url` <[string]> Glob pattern to match the URLs that the certificate is valid for.
- `certs` <[Array]<[Object]>>
- `cert` ?<[string]> Path to the file with the certificate in PEM format.
- `key` ?<[string]> Path to the file with the private key in PEM format.
- `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX).
- `pfx` ?<[string]> PFX or PKCS12 encoded private key and certificate chain.

An array of client certificates to be used with the [APIRequestContext]. Each certificate object must have `cert` and `key` or `pfx` to load the client certificate. Optionally, `passphrase` property should be provided if the private key is encrypted. If the certificate is issued by a custom certificate authority, the `ca` property should be provided with the path to the file with the certificate authority's certificate. 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.

## context-option-useragent
- `userAgent` <[string]>

Expand Down
29 changes: 29 additions & 0 deletions docs/src/test-api/class-testoptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,35 @@ export default defineConfig({
]
});
```

## property: TestOptions.clientCertificates = %%-context-option-clientCertificates-%%
* since: 1.46

**Usage**

```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';

export default defineConfig({
projects: [
{
name: 'Microsoft Edge',
use: {
...devices['Desktop Edge'],
clientCertificates: [{
url: 'https://example.com',
certs: [{
cert: 'client/alice_cert.pem',
key: 'client/alice_key.pem',
passphase: 'mysecretpassword',
}],
}],
},
},
]
});
```

## property: TestOptions.colorScheme = %%-context-option-colorscheme-%%
* since: v1.10

Expand Down
19 changes: 19 additions & 0 deletions packages/playwright-core/bin/socks-certs/cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUTcrzEueVL/OuLHr4LBIPWeS4UL0wDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MDcwNDA4NDAzNFoXDTM0MDcw
MjA4NDAzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEApof+SZVN4UGma4xJDVHhMSpmEJoCdMPr+HFadJJK/brF
BNOhA1C5wNk8oD/XYo7enAHQH/EsBnq4MMxv79rXTGnIdXMF+43GdMDh5kh81FQy
Esw8Vt4eif9eZkjUxI2GHhR2ovJewmQa7E+SeUB2RzJTqz8QPLhd74JFfgaci+S2
8L37ScVjcw55T1PcNflzB4vwsQHBT3yND0MLDhm+8MLzmTl4Mw5PgIOaBl5Jh8Tr
wQF4eeeB3FPJoMQhTP8aGBjW1mo+NmSSRAPIAZyhmCAnDeC33yRjAaiHjaL5Pr9f
wt5zoF5+U1xWhGXWzGOE6p/VTj62F9a2fOXNHclYJQIDAQABo1MwUTAdBgNVHQ4E
FgQU9BoVzGtb5x70KqGO/89N1hyqi5kwHwYDVR0jBBgwFoAU9BoVzGtb5x70KqGO
/89N1hyqi5kwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYcbI
wvcfx2p8z0RNN3EA+epKX1SagZyJX4ORIO8kln1sDU+ceHde3n3xnp1dg6HG2qh1
a7CZub/fNUaP9R8+6iiV0wPT7Ybkb2NIJcH1yq+/bfSS5OC5DO0yv9SUADdBoDwa
zOuBAqdcYW1BHYcbAzsQnniRcejHu06ioaS6SwwJ8150rQnLT4Lh9LAl40W6v4nZ
NdTGQETTrbjcgH1ER4IhWTKtVyPOxGF9A/OOawMEdfS8BhUO7YRS4QNFFaQMrJAb
MDhDtjSyDogLr8P43xjjWvQWG9a7zTF0kKEsdJ0cEG5HATpg8bPHmrouxbs2HGeH
kJXzMykrsYyXsInN3w==
-----END CERTIFICATE-----
28 changes: 28 additions & 0 deletions packages/playwright-core/bin/socks-certs/key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmh/5JlU3hQaZr
jEkNUeExKmYQmgJ0w+v4cVp0kkr9usUE06EDULnA2TygP9dijt6cAdAf8SwGergw
zG/v2tdMach1cwX7jcZ0wOHmSHzUVDISzDxW3h6J/15mSNTEjYYeFHai8l7CZBrs
T5J5QHZHMlOrPxA8uF3vgkV+BpyL5LbwvftJxWNzDnlPU9w1+XMHi/CxAcFPfI0P
QwsOGb7wwvOZOXgzDk+Ag5oGXkmHxOvBAXh554HcU8mgxCFM/xoYGNbWaj42ZJJE
A8gBnKGYICcN4LffJGMBqIeNovk+v1/C3nOgXn5TXFaEZdbMY4Tqn9VOPrYX1rZ8
5c0dyVglAgMBAAECggEAB6zX4vNPKhUZAvbtvP/rlZUDLDu05kXLX+F1jk7ZxvTv
NKg+UQVM8l7wxN/8YM3944nP2lEGuuu4BoO9mvvmlV6Avy0EdxITNflX0AHCQxT4
U9Z253gIR0ruQl+T8tUk+8jsqNjr1iC//ukx8oWujdx7b7aR3IKQzcOeyU6rs2TN
lyrVVsEaFVi9+wCw0xyiCmPlobrn+egdigw7Zhp2BRinC6W9eMxuPS2hlhQUhBm/
eiD96YWp0RAv/L5qO93reoXIAzrrLdcUgPEnnq1zN7y2xihU2+B2sTph1m/A26+J
yPcXd7vQrXlRXQU6PaCa+0oJULlpiAzy3HPbnr4BkQKBgQDdmekTX8dQqiEZPX1C
017QRFbx0/x/TDFDSeJbDeauMzzCaGqCO2WVmYmTvFtby2G4/6BYowVtJVHm4uJl
XsYk8dWIQGLPIj1Cw7ZieJvb2EVRxgnY2oMaOTOazHzPHFzZV718zwEeZrryT82J
881E8wgM8V3DjkS4ye3TbwvimQKBgQDAYa/IdnpAg5z1TREi9Tt8fnoGpmSscAak
USgeXVsvoNzXXkE94MiiCOOrX1r68TWYDAzq6MKGDewkWOfLwXWR6D5C2LyE1q9P
1pxstgs/nC3ZUTz0yEH47ahSmhywhGlvXXOQEXUSLiVTOdeMCubMqwQW80F1868n
aBHcj5/lbQKBgQDIojjsWaNT3TTqbUmj30vQtI8jlBLgDlPr4FEYr5VT0wAH5BHK
p4xpzgFJyRfOHG312TuMBM087LUinfjsXsp3WJ1EJ0dO0mk0sY3HyfsTKNRaHTt9
Ixnf/DpExS+bNMq73Tyqa6FPrSNFkAtAA4SuEHwRe9aw33ZI+EpjS/8uwQKBgQCi
9NwqSLlLVnColEw0uVdXH+cLJPzX19i4bQo3lkp8MJ2ATJWk7XflUPRQoGf3ckQ8
c9CpVtoXJUnmi+xkeo21Nu0uQFqHhzZewWIk75rdmdR4ZUjl649+ZQkUVviASNjq
fVU7Lp5k9POm6LL9K+rOaPoA2rKTUAQItC2VD4+YjQKBgB6kgvgN6Mz/u0RE3kkV
2GOoP5sso71Hxwh7o6JEzUMhR+e/T/LLcBwEjLYcf1FYRySHsXLn2Ar/Uw1J7pAZ
ud54/at+7mTDliaT8Ar7S9vcso7ZfmuDX9qB9+c77idPskVBPo2tjJbwvFcB6sww
5Elcfmj6tEP4YLJ6Kv3qTPhT
-----END PRIVATE KEY-----
31 changes: 30 additions & 1 deletion packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { Worker } from './worker';
import { Events } from './events';
import { TimeoutSettings } from '../common/timeoutSettings';
import { Waiter } from './waiter';
import type { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions } from './types';
import type { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions, ClientCertificate } from './types';
import { headersObjectToArray, isRegExp, isString, urlMatchesEqual } from '../utils';
import { mkdirIfNeeded } from '../utils/fileUtils';
import type * as api from '../../types/types';
Expand Down Expand Up @@ -529,6 +529,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions
reducedMotion: options.reducedMotion === null ? 'no-override' : options.reducedMotion,
forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors,
acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads),
clientCertificates: await toClientCertificatesProtocol(options.clientCertificates),
};
if (!contextParams.recordVideo && options.videosPath) {
contextParams.recordVideo = {
Expand All @@ -548,3 +549,31 @@ function toAcceptDownloadsProtocol(acceptDownloads?: boolean) {
return 'accept';
return 'deny';
}

export async function toClientCertificatesProtocol(clientCertificates?: ClientCertificate[]): Promise<channels.PlaywrightNewRequestParams['clientCertificates']> {
if (!clientCertificates)
return undefined;
return await Promise.all(clientCertificates.map(async clientCertificate => {
if (clientCertificate.certs.length === 0)
throw new Error('No certs specified for url: ' + clientCertificate.url);
return {
url: clientCertificate.url,
certs: await Promise.all(clientCertificate.certs.map(async cert => {
if (!cert.cert && !cert.key && !cert.passphrase && !cert.pfx)
throw new Error('None of cert, key, passphrase or pfx is specified');
if (cert.cert && !cert.key)
throw new Error('cert is specified without key');
if (!cert.cert && cert.key)
throw new Error('key is specified without cert');
if (cert.pfx && (cert.cert || cert.key || cert.passphrase))
throw new Error('pfx is specified together with cert, key or passphrase');
return {
cert: cert.cert ? await fs.promises.readFile(cert.cert) : undefined,
key: cert.key ? await fs.promises.readFile(cert.key) : undefined,
passphrase: cert.passphrase,
pfx: cert.pfx ? await fs.promises.readFile(cert.pfx) : undefined,
};
}))
};
}));
}
11 changes: 7 additions & 4 deletions packages/playwright-core/src/client/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ import { assert, headersObjectToArray, isString } from '../utils';
import { mkdirIfNeeded } from '../utils/fileUtils';
import { ChannelOwner } from './channelOwner';
import { RawHeaders } from './network';
import type { FilePayload, Headers, StorageState } from './types';
import type { ClientCertificate, FilePayload, Headers, StorageState } from './types';
import type { Playwright } from './playwright';
import { Tracing } from './tracing';
import { TargetClosedError, isTargetClosedError } from './errors';
import { toClientCertificatesProtocol } from './browserContext';

export type FetchOptions = {
params?: { [key: string]: string; },
Expand All @@ -44,9 +45,10 @@ export type FetchOptions = {
maxRetries?: number,
};

type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'storageState' | 'tracesDir'> & {
type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'tracesDir'> & {
extraHTTPHeaders?: Headers,
storageState?: string | StorageState,
clientCertificates?: ClientCertificate[];
};

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

function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined {
function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined {
if (!map)
return undefined;
const result = [];
Expand Down
14 changes: 13 additions & 1 deletion packages/playwright-core/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@ export type SetStorageState = {
export type LifecycleEvent = channels.LifecycleEvent;
export const kLifecycleEvents: Set<LifecycleEvent> = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']);

export type BrowserContextOptions = Omit<channels.BrowserNewContextOptions, 'viewport' | 'noDefaultViewport' | 'extraHTTPHeaders' | 'storageState' | 'recordHar' | 'colorScheme' | 'reducedMotion' | 'forcedColors' | 'acceptDownloads'> & {
export type ClientCertificate = {
url: string;
certs: {
cert?: string;
key?: string;
passphrase?: string;
pfx?: string;
}[];
};

export type BrowserContextOptions = Omit<channels.BrowserNewContextOptions, 'viewport' | 'noDefaultViewport' | 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'recordHar' | 'colorScheme' | 'reducedMotion' | 'forcedColors' | 'acceptDownloads'> & {
viewport?: Size | null;
extraHTTPHeaders?: Headers;
logger?: Logger;
Expand All @@ -70,6 +80,8 @@ export type BrowserContextOptions = Omit<channels.BrowserNewContextOptions, 'vie
reducedMotion?: 'reduce' | 'no-preference' | null;
forcedColors?: 'active' | 'none' | null;
acceptDownloads?: boolean;
ca?: string[];
clientCertificates?: ClientCertificate[];
};

type LaunchOverrides = {
Expand Down
45 changes: 45 additions & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,15 @@ scheme.PlaywrightNewRequestParams = tObject({
userAgent: tOptional(tString),
ignoreHTTPSErrors: tOptional(tBoolean),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
clientCertificates: tOptional(tArray(tObject({
url: tString,
certs: tArray(tObject({
cert: tOptional(tBinary),
key: tOptional(tBinary),
passphrase: tOptional(tString),
pfx: tOptional(tBinary),
})),
}))),
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
Expand Down Expand Up @@ -532,6 +541,15 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({
height: tNumber,
})),
ignoreHTTPSErrors: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({
url: tString,
certs: tArray(tObject({
cert: tOptional(tBinary),
key: tOptional(tBinary),
passphrase: tOptional(tString),
pfx: tOptional(tBinary),
})),
}))),
javaScriptEnabled: tOptional(tBoolean),
bypassCSP: tOptional(tBoolean),
userAgent: tOptional(tString),
Expand Down Expand Up @@ -611,6 +629,15 @@ scheme.BrowserNewContextParams = tObject({
height: tNumber,
})),
ignoreHTTPSErrors: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({
url: tString,
certs: tArray(tObject({
cert: tOptional(tBinary),
key: tOptional(tBinary),
passphrase: tOptional(tString),
pfx: tOptional(tBinary),
})),
}))),
javaScriptEnabled: tOptional(tBoolean),
bypassCSP: tOptional(tBoolean),
userAgent: tOptional(tString),
Expand Down Expand Up @@ -673,6 +700,15 @@ scheme.BrowserNewContextForReuseParams = tObject({
height: tNumber,
})),
ignoreHTTPSErrors: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({
url: tString,
certs: tArray(tObject({
cert: tOptional(tBinary),
key: tOptional(tBinary),
passphrase: tOptional(tString),
pfx: tOptional(tBinary),
})),
}))),
javaScriptEnabled: tOptional(tBoolean),
bypassCSP: tOptional(tBoolean),
userAgent: tOptional(tString),
Expand Down Expand Up @@ -2510,6 +2546,15 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({
height: tNumber,
})),
ignoreHTTPSErrors: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({
url: tString,
certs: tArray(tObject({
cert: tOptional(tBinary),
key: tOptional(tBinary),
passphrase: tOptional(tString),
pfx: tOptional(tBinary),
})),
}))),
javaScriptEnabled: tOptional(tBoolean),
bypassCSP: tOptional(tBoolean),
userAgent: tOptional(tString),
Expand Down
7 changes: 7 additions & 0 deletions packages/playwright-core/src/server/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { RecentLogsCollector } from '../utils/debugLogger';
import type { CallMetadata } from './instrumentation';
import { SdkObject } from './instrumentation';
import { Artifact } from './artifact';
import { ClientCertificatesProxy, shouldUseMitmSocksProxy } from './socksClientCertificatesInterceptor';

export interface BrowserProcess {
onclose?: ((exitCode: number | null, signal: string | null) => void);
Expand Down Expand Up @@ -82,7 +83,13 @@ export abstract class Browser extends SdkObject {

async newContext(metadata: CallMetadata, options: channels.BrowserNewContextParams): Promise<BrowserContext> {
validateBrowserContextOptions(options, this.options);
let clientCertificateProxy: ClientCertificatesProxy | undefined;
if (shouldUseMitmSocksProxy(options)) {
clientCertificateProxy = new ClientCertificatesProxy(options.ignoreHTTPSErrors, options.clientCertificates);
options.proxy = { server: await clientCertificateProxy.listen() };
}
const context = await this.doCreateNewContext(options);
context._socksServer = clientCertificateProxy;
if (options.storageState)
await context.setStorageState(metadata, options.storageState);
return context;
Expand Down
6 changes: 6 additions & 0 deletions packages/playwright-core/src/server/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import * as consoleApiSource from '../generated/consoleApiSource';
import { BrowserContextAPIRequestContext } from './fetch';
import type { Artifact } from './artifact';
import { Clock } from './clock';
import { shouldUseMitmSocksProxy, type ClientCertificatesProxy } from './socksClientCertificatesInterceptor';

export abstract class BrowserContext extends SdkObject {
static Events = {
Expand Down Expand Up @@ -90,6 +91,7 @@ export abstract class BrowserContext extends SdkObject {
private _debugger!: Debugger;
_closeReason: string | undefined;
readonly clock: Clock;
_socksServer: ClientCertificatesProxy | undefined;

constructor(browser: Browser, options: channels.BrowserNewContextParams, browserContextId: string | undefined) {
super(browser, 'browser-context');
Expand Down Expand Up @@ -447,6 +449,8 @@ export abstract class BrowserContext extends SdkObject {
await harRecorder.flush();
await this.tracing.flush();

await this._socksServer?.close();

// Cleanup.
const promises: Promise<void>[] = [];
for (const { context, artifact } of this._browser._idToVideo.values()) {
Expand Down Expand Up @@ -687,6 +691,8 @@ export function validateBrowserContextOptions(options: channels.BrowserNewContex
throw new Error(`Browser needs to be launched with the global proxy. If all contexts override the proxy, global proxy will be never used and can be any string, for example "launch({ proxy: { server: 'http://per-context' } })"`);
options.proxy = normalizeProxySettings(options.proxy);
}
if (shouldUseMitmSocksProxy(options))
options.ignoreHTTPSErrors = true;
verifyGeolocation(options.geolocation);
}

Expand Down
Loading

0 comments on commit 0a3d5dd

Please sign in to comment.