Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] allow client certificate selection and settings from Javascript #1799

Closed
frzme opened this issue Apr 15, 2020 · 106 comments
Closed

[Feature] allow client certificate selection and settings from Javascript #1799

frzme opened this issue Apr 15, 2020 · 106 comments
Assignees
Labels

Comments

@frzme
Copy link

frzme commented Apr 15, 2020

Similarly to puppeteer/puppeteer#540

Currently when navigating to a page that requires client certificates and client certificates are available a popup is shown in Firefox and Chrome which asks to select which certificate to use. It would be beneficial to provide an API to select the correct certificate to use (or use none).


Edit by maintainers

There is experimental support for it here: #1799 (comment)

@yyvess
Copy link

yyvess commented Nov 22, 2020

You can apply same workaround as for puppeteer.
First client certificate popup should only open if you have more than one client certificate installed.
You can remove client certificate to avoid the popup be open.

Secondly you can inject dynamically an certificate as is =>

const fs = require('fs');
const request = require('request');
const browser = await chromium.launch();
const ctx = await browser.newContext();
await ctx.route('**/*', (route, req) => {
    const options = {
      uri: req.url(),
      method: req.method(),
      headers: req.headers(),
      body: req.postDataBuffer(),
      timeout: 10000,
      followRedirect: false,
      agentOptions: {
        ca: fs.readFileSync('./certs/ca.pem'),
        pfx: fs.readFileSync('./certs/user_cert.p12'),
        passphrase: fs.readFileSync('./certs/user_cert.p12.pwd'),
      },
    };
    let firstTry = true;
    const handler = function handler(err, resp, data) {
      if (err) {
        /* Strange random connection error on first request, do one re-try */
        if (firstTry) {
          firstTry = false;
          return request(options, handler);
        }
        console.error(`Unable to call ${options.uri}`, err.code, err);
        return route.abort();
      } else {
        return route.fulfill({
          status: resp.statusCode,
          headers: resp.headers,
          body: data,
        });
      }
    };
    return request(options, handler);
  });

@gepd
Copy link

gepd commented Jul 29, 2021

You can apply same workaround as for puppeteer.
First client certificate popup should only open if you have more than one client certificate installed.
You can remove client certificate to avoid the popup be open.

Hey @yyvess do you have an example of how remove client certificate programmatically?

@yyvess
Copy link

yyvess commented Aug 2, 2021

Hi @gepd, as I know you cannot remove it programmatically. But if you have only one certificate installed, the browser should not ask you to select one. Note that the popup is not present when you run your test in background.

@mxschmitt mxschmitt self-assigned this Aug 2, 2021
@mxschmitt
Copy link
Member

mxschmitt commented Sep 8, 2021

Hey folks!

We are currently evaluating and digging into this feature and have a few questions so it will fit your needs:

  • Is selecting a certificate out of the given ones from the operating system programatically enough? (like e.g. a clientCertificate event where you can select out of the loaded ones or a mapping from hosts to client certificates)
  • Do you want to load a custom certificate manually or is it already in your operating system certificate storage?
  • If you add them manually, are you using PEM or PFX certificate file format?
  • Is it enough if its on browser.launch level? (context level with multiple certificates on each context would be the alternative)
  • Do you want to validate against a CA?
  • (your use-case would also help a lot)

Thank you! ❤️ And sorry for the ping.

cc @osmenia, @SMN947, @delsoft, @dcr007, @inikulin, @BredSt, @matthias-ccri, @nlack, @sdeprez, @bramvanhoutte, @yyvess, @gepd

@sdeprez
Copy link

sdeprez commented Sep 8, 2021

👋   great news!
So to talk about our own usecase:

  • programmatical selection is enough yes
  • on the fly loading would be very nice, because for us the certificate is ultimately provided by our customers and as such is dynamic, so we would like to avoid tooling to constantly add and remove certificates from the OS storage
  • only PEM
  • browser.launch is enough, we have one browser per session (and only one certificate per session)
  • no need to validate, probably some certificates will be self-signed I can imagine

@yyvess
Copy link

yyvess commented Sep 8, 2021

Hi,

Thanks for your interest on this features !

On my case applications use client certificate to do the authentication of users.

Today as work around we inject certificate as I described before on this thread.
Only small issue that still is that when we run test in no headless, the browser requests a certificate selection on a popup.
We can select manually any certificate proposed by the browser as after an other is inject by the proxy .
But it's force to add a wait to gave time to select manually one other tests faild.

  • Is selecting a certificate out of the given ones from the operating system programatically enough?

Will more easy to pass a list (or map<key,file>) of certificate files to Playwright during the initialization.
On CI our test are executed on a dedicated docker image.
But when a developer run test manually, it's run directly on his Os.

  • If you add them manually, are you using PEM or PFX certificate file format?

Converting certificate is not an issue.

  • Is it enough if its on browser.launch level?

We have some test that require different users with different privilege.
Then we should be able to select or switch certificate between two different tests.

  • Do you want to validate against a CA?

Not really needed

@gepd
Copy link

gepd commented Sep 11, 2021

Hey!

Thanks for considerate this!

in my use case:

  • programmatical selection is enough
  • like @yyvess on the fly loading would be very nice
  • We use PFX
  • we have one browser and certificate per session
  • If we can validate would be cool, but not mandatory

Our use case; we need to download a file from a gov website, this website only works creating a session with a PFX certificate. Each user doesn't need to download that file very often, but we have many users, that is why on the fly loading is good for our use case

Thanks again!

@callmemagnus
Copy link

The solution @yyvess proposed works for most use cases.

I have an issue with multipart POST request, like file uploads.
The new request does not contain the multipart file content.
We need the formData content available, if we would like this solution to be sustainable.

@arekzelechowski
Copy link

arekzelechowski commented Sep 23, 2021

I'd like to add my thoughts to this discussion:

Is selecting a certificate out of the given ones from the operating system programatically enough? (like e.g. a clientCertificate event where you can select out of the loaded ones or a mapping from hosts to client certificates)
For our use case OS certs are not enough. The event idea seems awesome.

Do you want to load a custom certificate manually or is it already in your operating system certificate storage?
We'd like to do that manually.

If you add them manually, are you using PEM or PFX certificate file format?
Actually it does not matter. Converting between PEMs and PFX/P12 is quite easy. However, take a look at https.Agent and tls.connect() implementations. Using the same interface could be beneficial - ca, cert and key fields in PEM format

Is it enough if its on browser.launch level? (context level with multiple certificates on each context would be the alternative)
For our use case it is enough

Do you want to validate against a CA?
Yes, we do. We have our own CA that issues both client and server certificates.

(your use-case would also help a lot)

We use Playwright in two ways: E2E tests and automated scripts. Current solution for E2E is ok, but automated scripts runtime is somewhat problematic. Our intention is to write scripts with APIs as much as possible, however, some of our legacy apps do not have these. In that case, we use playwright as an workaround. All of these legacy apps are behind HTTPS (sometimes with certs issued by local CA) and client certs auth.

One more important factor is Docker. We run E2E tests with Gitlab CI runner that uses docker containers. We would also like to run these scripts in docker containers as you do not need to install node, playwright, no updates required. This is why asking OS for cert is not sufficient.

PS. Using custom TLS certs for APIs is not a problem at all.

@Xen0byte
Copy link

Hi @mxschmitt, I've raised on the dotnet repo the issue of not being able to import certificates or to use already-existing certificates in headless mode (microsoft/playwright-dotnet#1601). That issue got merged into this one, which is when I started having some concerns that this somewhat unrelated issue would be bundled with the actual problem that I've raised on the dotnet repo. For my organisation, certificate selection is a nice-to-have, and this can be easily worked around via the registry in Windows and via config on UNIX-based systems, in lieu of programmatic means, however not being able to import certificates in dotnet or to use already-existing certificates in headless mode is currently a blocker on one of our products. Hope this information helps towards development on this; for me and my organisation, this would be the most important feature since the first release on the stable channel. Cheers.

@fargraph
Copy link

fargraph commented Nov 2, 2021

This is also our use case, including docker:

Is selecting a certificate out of the given ones from the operating system programatically enough? (like e.g. a clientCertificate event where you can select out of the loaded ones or a mapping from hosts to client certificates) For our use case OS certs are not enough. The event idea seems awesome.

Do you want to load a custom certificate manually or is it already in your operating system certificate storage? We'd like to do that manually.

If you add them manually, are you using PEM or PFX certificate file format? Actually it does not matter. Converting between PEMs and PFX/P12 is quite easy. However, take a look at https.Agent and tls.connect() implementations. Using the same interface could be beneficial - ca, cert and key fields in PEM format

Is it enough if its on browser.launch level? (context level with multiple certificates on each context would be the alternative) For our use case it is enough

Do you want to validate against a CA? Yes, we do. We have our own CA that issues both client and server certificates.

(your use-case would also help a lot)

We use Playwright in two ways: E2E tests and automated scripts. Current solution for E2E is ok, but automated scripts runtime is somewhat problematic. Our intention is to write scripts with APIs as much as possible, however, some of our legacy apps do not have these. In that case, we use playwright as an workaround. All of these legacy apps are behind HTTPS (sometimes with certs issued by local CA) and client certs auth.

One more important factor is Docker. We run E2E tests with Gitlab CI runner that uses docker containers. We would also like to run these scripts in docker containers as you do not need to install node, playwright, no updates required. This is why asking OS for cert is not sufficient.

PS. Using custom TLS certs for APIs is not a problem at all.

@fargraph
Copy link

fargraph commented Nov 3, 2021

If you came here looking for a solution and you saw the solution from @yyvess, you may also be looking for where to implement that solution. It took me a while trying to integrate it in the global config, or even a setup/teardown file, but I just couldn't find the correct apis that would let me do that. So I finally landed on creating a custom fixture that provides a context and then also re-exports all of @playwright/test. The pattern is similar to testing-library in which they recommend creating fixtures as needed to reduce code.

I have a global config that I'll include for completeness, but it really doesn't do anything special. The real magic is in the fixture which is used by the test for all of the Playwright imports.

Note, if you need to do MFA Authentication and want to use the documented persistent context, but can't figure out how to tell playwright to launch a persistent context, you can use this same solution, instead of using the provided context in the fixture, you would just create one in the fixture and it will be provided to any tests that use the fixture.

// playwright.config.ts

import { PlaywrightTestConfig } from '@playwright/test'

const config: PlaywrightTestConfig = {
    testDir: 'tests',
    use: {
        channel: 'chrome',
        ignoreHTTPSErrors: true,
    },
}
export default config
// caAuthenticationFixture.ts

import { test as base, chromium, BrowserContext } from '@playwright/test'
import fs from 'fs'
import request, { CoreOptions } from 'request'

export const test = base.extend({
    context: async ({ context }, use) => {

       // I use the context that is created using my base config here, just adding the route, but you could also create
       // a context first if you needed even more customizability.

        await context.route('**/*', (route, req) => {
            const options = {
                uri: req.url(),
                method: req.method(),
                headers: req.headers(),
                body: req.postDataBuffer(),
                timeout: 10000,
                followRedirect: false,
                agentOptions: {
                    ca: fs.readFileSync('./certs/ca.pem'),
                    pfx: fs.readFileSync('./certs/user.p12'),
                    passphrase: fs.readFileSync('./certs/user.p12.passwd', 'utf8'),
                },
            }
            let firstTry = true
            const handler = function handler(err: any, resp: any, data: any) {
                if (err) {
                    /* Strange random connection error on first request, do one re-try */
                    if (firstTry) {
                        firstTry = false
                        return request(options, handler)
                    }
                    console.error(`Unable to call ${options.uri}`, err.code, err)
                    return route.abort()
                } else {
                    return route.fulfill({
                        status: resp.statusCode,
                        headers: resp.headers,
                        body: data,
                    })
                }
            }
            return request(options, handler)
        })
        use(context)
    },
})

export * from '@playwright/test'
// login.spec.ts

import { test, expect } from './certAuthenticationFixture'  // <-- note the import of everything from our fixture.

test('login test', async ({ page, context }) => {
  
    await page.goto('https://my.page.net')

    const title = page.locator('title')

    await expect(title).toHaveText('My Title')
})

@philga7
Copy link

philga7 commented Nov 23, 2021

Hi, @fargraph:

In using Node v14.18.0, were you (or anyone else) able to get beyond the Node-level (but not Node error) message SELF_SIGNED_CERT_IN_CHAIN Error: self signed certificate in certificate chain?

In other automation frameworks that I've used, they typically had cert auth baked in, so having to rely upon Node wasn't necessarily an issue.

Thanks!

@fargraph
Copy link

I believe that error is bypassed by this line in the playwright config:

// playwright.config.ts

import { PlaywrightTestConfig } from '@playwright/test'

const config: PlaywrightTestConfig = {
    testDir: 'tests',
    use: {
        channel: 'chrome',
        ignoreHTTPSErrors: true, // <-- bypass the SELF_SIGNED_CERT_IN_CHAIN Error
    },
}
export default config

See this issue for reference: #2814

@philga7
Copy link

philga7 commented Nov 29, 2021

@fargraph: Unfortunately, no, ignoreHTTPSErrors: true does not deal with the SELF_SIGNED_CERT_IN_CHAIN error.

This is something likely very simple that I'm simply overlooking.

Appreciate the help.

@randomactions
Copy link

While the request is being implemented, is there a way to simply click "Cancel" in the "Select a certificate" dialog?

@radu-nicoara-bayer
Copy link

We would also need just a way to cancel the popup. I am currently implementing a login tests, and it always gets stuck when the browser is asking for a certificate, that we are trying to cancel ether way. Just that playwright does not offer the possibility when using page.on('dialog', dialog => dialog.dismiss());. Nothing happens.

@mxschmitt mxschmitt removed their assignment Feb 18, 2022
@enrialonso
Copy link

Hi guys, few days ago I involved on the same problem and want to share here my workaround for this.

Important, this workaround is really weak and need improve to attach the real goal: select the certificate programmatically

  • Only work on headless=false
  • Only tested on chromium browser

In this repository explain better the workaround >> playwright-auto-select-certificates-for-url

Basically manage the policies of the chromium browser in the path /etc/chromium/policies/managed and filter the certificated used for a URL inside of the json policy.

{
  "AutoSelectCertificateForUrls": ["{\"pattern\":\"*\",\"filter\":{}}"]
}

The browser launch read this policy and automatic select the cert for the url.

Any mistake pls sorry and sorry for my english.

@standbyoneself
Copy link

@standbyoneself

Does it work when there are multiple certificates in pop-up? -> yes

I have tried something similar but the main problem that my route doesn't intercepted because my website has OIDC authentication so many redirects occure. Any workarounds? -> I'm not quite sure about OIDC, sorry. However, could it be possible by capturing cookies for each connection? If that's the case, it seems doable.

Thanks for your reply, I'll take a look.

I'll publish my Java solution if I'll make it work.

@ilorwork
Copy link

ilorwork commented Apr 1, 2024

I'll add my solution again since it works perfectly and even better than Cypress built-in solution:

A great workaround for Windows & Chromium users - using Windows Registry AutoSelectCertificateForUrls Chromium Policy

I’m demonstrating on Chromium/Chrome, but it’s the same for every other Chromium-based browser. (you just need to figure the exact key path)

For the Playwright Chromium browser you need to create this registry key:
HKEY_CURRENT_USER\\SOFTWARE\\Policies\\Chromium\\AutoSelectCertificateForUrls

Note: According to google-docs If you use the PC Chrome (i.e.
channel: "chrome")
you should use this path instead:
HKEY_CURRENT_USER\\SOFTWARE\\Policies\\Google\\Chrome\\AutoSelectCertificateForUrls

with this value pattern:

"1"="{\"pattern\":\"<https://www.example.com\>",\"filter\":{\"ISSUER\":{\"CN\":\"certificate issuer name\", \"L\": \"certificate issuer location\", \"O\": \"certificate issuer org\", \"OU\": \"certificate issuer org unit\"}, \"SUBJECT\":{\"CN\":\"certificate subject name\", \"L\": \"certificate subject location\", \"O\": \"certificate subject org\", \"OU\": \"certificate subject org unit\"}}}"

In this example I'm executing the CMD reg add command using node-js exec function:

const key = "HKEY_CURRENT_USER\\SOFTWARE\\Policies\\Chromium\\AutoSelectCertificateForUrls";
const value = "1";
const data = `{\\"pattern\\":\\"${url}\\",\\"filter\\":{\\"SUBJECT\\":{\\"CN\\":\\"${certName}\\"}}}`;

exec(`reg add "${key}" /v "${value}" /d "${data}"`);

Now, navigate to your site and the certificate pop-up shouldn't pop-up.

@standbyoneself
Copy link

standbyoneself commented Apr 1, 2024

I'll add my solution again since it works perfectly and even better than Cypress built-in solution:

A great workaround for Windows & Chromium users - using Windows Registry AutoSelectCertificateForUrls Chromium Policy

I’m demonstrating on Chromium/Chrome, but it’s the same for every other Chromium-based browser. (you just need to figure the exact key path)

For the Playwright Chromium browser you need to create this registry key: HKEY_CURRENT_USER\\SOFTWARE\\Policies\\Chromium\\AutoSelectCertificateForUrls

Note: According to google-docs If you use the PC Chrome (i.e.
channel: "chrome")
you should use this path instead:
HKEY_CURRENT_USER\\SOFTWARE\\Policies\\Google\\Chrome\\AutoSelectCertificateForUrls

with this value pattern:

"1"="{\"pattern\":\"<https://www.example.com\>",\"filter\":{\"ISSUER\":{\"CN\":\"certificate issuer name\", \"L\": \"certificate issuer location\", \"O\": \"certificate issuer org\", \"OU\": \"certificate issuer org unit\"}, \"SUBJECT\":{\"CN\":\"certificate subject name\", \"L\": \"certificate subject location\", \"O\": \"certificate subject org\", \"OU\": \"certificate subject org unit\"}}}"

In this example I'm executing the CMD reg add command using node-js exec function:

const key = "HKEY_CURRENT_USER\\SOFTWARE\\Policies\\Chromium\\AutoSelectCertificateForUrls";
const value = "1";
const data = `{\\"pattern\\":\\"${url}\\",\\"filter\\":{\\"SUBJECT\\":{\\"CN\\":\\"${certName}\\"}}}`;

exec(`reg add "${key}" /v "${value}" /d "${data}"`);

Now, navigate to your site and the certificate pop-up shouldn't pop-up.

Thanks. It works not for Windows only, but it is a not flexy solution.

I build a platform with RBAC and I have about 10-20 certificates for QA stand (multiple roles).

Suppose, if I use Java, I wanna create the annotation @Cert(acc-1, acc-2, acc-3). It would run this test with 3 certificates, may be in parallel.

So it's about selecting certificates programmatically, wonder that it is not properly implemented by any framework.

P.S. Also this policy requires browser to be run headed which is also more expensive and more difficult especially in CI.

@callmekohei
Copy link

I think the solution using the registry is also very valuable. In fact, I was doing that too.However, not everyone can manipulate the registry.The method using F# prevents the pop-up from appearing in the first place, so I believe it has its own value.

@callmekohei
Copy link

I'm Sorry...

I mentioned in my previous post that I managed to get certificate authentication working with F# without touching the registry settings. Turns out, it was actually working because of some pre-existing registry settings I hadn't noticed. Sorry for the mix-up! Here's the code I was talking about:

  let private httpRequestAsync (clientCert: X509Certificate2) (request: IRequest) =
    Http.AsyncRequest(
        url = request.Url
      , httpMethod = request.Method
      , headers = (request.Headers |> Seq.map(fun x -> x.Key,x.Value))
      , customizeHttpRequest =
          fun req ->
            req.ClientCertificates.Add(clientCert) |> ignore
            req
    )

  let private routeResponseWithCertificateAsync (clientCert: X509Certificate2) (route:IRoute) =
    task {
      try
        let! httpResponse = httpRequestAsync clientCert (route.Request)
        let opt = new RouteFulfillOptions()
        opt.Headers <- httpResponse.Headers |> Map.toSeq |> Seq.map(fun (k,v) -> KeyValuePair(k,v))
        opt.BodyBytes <-
          match httpResponse.Body with
          | Text text    -> System.Text.Encoding.UTF8.GetBytes(text)
          | Binary bytes -> bytes
        opt.ContentType <- httpResponse.Headers.Item("Content-Type")
        opt.Status <- httpResponse.StatusCode |> Nullable
        do! route.FulfillAsync(opt)
      with _ ->
        do! route.AbortAsync()
    }
    :> Task

  [<EntryPointAttribute>]
  let main _ =
    task {

      let url = "https://foo..."
      let urlPattern = "**/bar/"
      let clientCert =
        let cn = "baz"
        Certfs.GetCertificateByCommonName cn Certfs.myStoreName.My

      let! browser = Playwright.CreateAsync()
      let! edge = browser.Chromium.LaunchAsync(BrowserTypeLaunchOptions(Channel="msedge",Headless=false))
      let! context = edge.NewContextAsync()
      do! context.RouteAsync(urlPattern,(routeResponseWithCertificateAsync clientCert))
      let! page = context.NewPageAsync()
      do! sample url page
    }
    |> Task.WaitAll
    0

Really sorry for sharing incorrect information earlier. This has been a good reminder of how important it is to fully understand all the elements behind a solution. Looking forward to learning more and contributing to the community. Thanks for your understanding!

@standbyoneself
@ilorwork

@ilorwork
Copy link

ilorwork commented Apr 3, 2024

Thanks for your hard work and honesty. any kind of solution being found its a great progress.
I have to say that this registry solution I've posted saved my life...
After months of searching and kinda wasting my and team's time, I've almost quit Playwright and already started migrating to Cypress, I was very disappointed! and in my few last desperate attempts - I found this solution.

@Az8th
Copy link
Contributor

Az8th commented Jun 14, 2024

Unfortunately, all those workarounds are not compatible with the new MPT service.
It seems almost everything that is related to certificates is folded in this issue, but I have the feeling that it doesn't help with comprehension nor solving.

Meanwhile, this issue is mentioned at almost every community event, and as explained by Debbie during one of the last Playwright Happy Hour, there are several issues that should be adressed independently, so maybe it could help to split them here too.

Here are, if I remember correctly, the different ones (feel free to correct me if there is any mistake/miscomprehension) :

  • Add possibility to interact with the certificate choosing dialog
    image
  • Add possibility to provide the certificate given its file to the context object (and ability to skip the choosing dialog if possible)
  • Add a certificate manager that could permit to switch between multiple ones

@standbyoneself
Copy link

Hi there. Here is my solution. Based on fetching session cookies, so there is no popup shown, works in headless=true.

Steps:

  1. Create SSLContext using sslcontext-kickstart-for-pem library
  2. Make GET request to the website in order to fetch cookies
  3. Add cookies to Playwright Context

PageTestFixtures.java

import java.lang.reflect.Method;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import org.junit.jupiter.api.*;

import com.microsoft.playwright.*;
import com.microsoft.playwright.options.Cookie;

import io.qameta.allure.Allure;
import ru.sbrf.rmc.AuthClient;
import ru.sbrf.rmc.AuthClientImpl;
import ru.sbrf.rmc.BrowserFactory;
import ru.sbrf.rmc.BrowserFactoryImpl;
import ru.sbrf.rmc.annotations.Certificate;
import ru.sbrf.rmc.mappers.CookieMapperImpl;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class PageTestFixtures {
    private Playwright playwright;
    private BrowserType browserType;
    private Browser browser;
    private BrowserContext context;
    protected Page page;
    private AuthClient authClient = new AuthClientImpl(new CookieMapperImpl());

    private String getCertificateName(TestInfo testInfo) throws NoSuchMethodException {
        Optional<Method> optionalMethod = testInfo.getTestMethod();

        if (optionalMethod.isEmpty()) {
            throw new NoSuchMethodException();
        }

        Method method = optionalMethod.get();
        Certificate annotation = method.getAnnotation(Certificate.class);
        return annotation.value();
    }

    private void updateAllureTestNameAndHistoryId(String baseName) {
        Allure.getLifecycle().updateTestCase(testResult -> {
            String name = String.format("%s: %s", baseName, testResult.getName());
            String historyId = UUID.nameUUIDFromBytes(name.getBytes()).toString();
            testResult.setName(name);
            testResult.setHistoryId(historyId);
        });
    }

    @BeforeAll
    public void launchBrowser() {
        playwright = Playwright.create();
        BrowserFactory browserFactory = new BrowserFactoryImpl(playwright);
        String browserName = System.getenv().getOrDefault("BROWSER", "chromium");
        browserType = browserFactory.createBrowserType(browserName);
        BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions();
        Optional<String> browserPath = Optional.ofNullable(System.getenv("BROWSER_PATH"));
        if (browserPath.isPresent()) {
            launchOptions.setExecutablePath(Paths.get(browserPath.get()));
        }
        browser = browserType.launch(launchOptions);
    }

    @AfterAll
    public void closeBrowser() {
        playwright.close();
    }

    @BeforeEach
    public void createContextAndPage(TestInfo testInfo) {
        updateAllureTestNameAndHistoryId(String.format("%s %s", browserType.name(), browser.version()));
        context = browser.newContext(new Browser.NewContextOptions()
            .setBaseURL(System.getenv().getOrDefault("BASE_URL", "https://rmp-ift.sberbank.ru/lenta/"))
            .setIgnoreHTTPSErrors(true));
        page = context.newPage();

        try {
            String certificateName = getCertificateName(testInfo);
            List<Cookie> cookies = authClient.getSessionCookies(certificateName);
            context.addCookies(cookies);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @AfterEach
    public void closeContext() {
        context.close();
    }
}

AuthClientImpl.java

package ru.sbrf.rmc;

import java.io.IOException;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.CookieStore;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;

import javax.net.ssl.X509ExtendedKeyManager;
import javax.net.ssl.X509ExtendedTrustManager;

import com.microsoft.playwright.options.Cookie;

import nl.altindag.ssl.SSLFactory;
import nl.altindag.ssl.util.PemUtils;
import ru.sbrf.rmc.mappers.CookieMapper;

public class AuthClientImpl implements AuthClient {
    private CookieMapper cookieMapper;

    public AuthClientImpl(CookieMapper cookieMapper) {
        this.cookieMapper = cookieMapper;
    }

    private SSLFactory getSSLFactory(String certificateName) {
        Path currentDir = Paths.get("ssl");
        Path fullPath = currentDir.toAbsolutePath();
        X509ExtendedKeyManager keyManager = PemUtils.loadIdentityMaterial(fullPath.resolve(certificateName + ".crt"), fullPath.resolve(certificateName + ".key"));
        X509ExtendedTrustManager trustManager = PemUtils.loadTrustMaterial(fullPath.resolve("ca.pem"));
        return SSLFactory.builder().withIdentityMaterial(keyManager).withTrustMaterial(trustManager).build();
    }

    public void sendRequest(String url, String certificateName, CookieManager cookieManager) throws IOException, InterruptedException, URISyntaxException {
        SSLFactory sslFactory = getSSLFactory(certificateName);
        HttpRequest request = HttpRequest.newBuilder().uri(new URI(url)).method("GET", BodyPublishers.noBody()).setHeader("Accept", "*/*").build();
        HttpClient client = HttpClient.newBuilder().cookieHandler(cookieManager).followRedirects(HttpClient.Redirect.NORMAL).sslParameters(sslFactory.getSslParameters()).sslContext(sslFactory.getSslContext()).build();
        client.send(request, BodyHandlers.ofString());
    }

    public List<Cookie> getSessionCookies(String certificateName) throws IOException, InterruptedException, URISyntaxException {
        CookieManager cookieManager = new CookieManager(null, CookiePolicy.ACCEPT_ORIGINAL_SERVER);
        CookieStore cookieStore = cookieManager.getCookieStore();
        sendRequest("https://rmp-ift.sberbank.ru", certificateName, cookieManager);
        return cookieMapper.httpCookiesToPlaywrightCookies(cookieStore.getCookies());
    }
}

Certificates are stored under ssl folder (don't forget to add it to .gitignore).

CookieMapperImpl.java

package ru.sbrf.rmc.mappers;

import java.net.HttpCookie;
import java.util.List;

import com.microsoft.playwright.options.Cookie;

public class CookieMapperImpl implements CookieMapper {
    public Cookie httpCookieToPlaywrightCookie(HttpCookie httpCookie) {
        return new Cookie(httpCookie.getName(), httpCookie.getValue())
            .setDomain(httpCookie.getDomain())
            .setPath(httpCookie.getPath())
            .setHttpOnly(httpCookie.isHttpOnly())
            .setSecure(httpCookie.getSecure());
    }

    public List<Cookie> httpCookiesToPlaywrightCookies(List<HttpCookie> httpCookies) {
        return httpCookies.stream().map(this::httpCookieToPlaywrightCookie).toList();
    }
}

Certificate.java

package ru.sbrf.rmc.annotations;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Certificate {
    String value();
}

Using in tests:

AdminPageTests.java

import java.util.Date;

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import io.qameta.allure.Issue;
import ru.sbrf.rmc.annotations.Certificate;
import ru.sbrf.rmc.annotations.TestCase;
import ru.sbrf.rmc.annotations.TestCases;
import ru.sbrf.rmc.models.AdminPagePath;
import ru.sbrf.rmc.pages.AdminPage;

@DisplayName("Админка")
public class AdminPageTests extends PageTestFixtures {
    private AdminPage adminPage;

    @BeforeEach
    public void createPage() {
        adminPage = new AdminPage(page);
    }

    @Nested
    @DisplayName("Объекты")
    class Subjects {
        private void shouldCreateAndDeleteSubject(String prefix, String searchQuery) {
            long timestamp = new Date().getTime();
            String name = prefix + "-" + timestamp;
            adminPage.goTo(AdminPagePath.SUBJECTS);
            adminPage.goToSubjectForm();
            adminPage.fillSubjectName(name);
            adminPage.fillSubjectSearchQuery(searchQuery);
            adminPage.submitSubjectForm();
            adminPage.waitFor(AdminPagePath.SUBJECTS);
            adminPage.checkIfSubjectIsVisible(name);
            adminPage.deleteSubject(name);
        }

        @ParameterizedTest(name = "{displayName}")
        @Certificate("curuser-6")
        @Issue("RMC-6335")
        @TestCases({@TestCase("RMC-T6354"), @TestCase("RMC-T6346")})
        @CsvSource({"Subject,playwright OR selenium"})
        @DisplayName("Создание и удаление объекта с ролью \"Бизнес-администратор\"")
        public void shouldCreateAndDeleteSubjectByFeedAdmin(String prefix, String searchQuery) {
            shouldCreateAndDeleteSubject(prefix, searchQuery);
        }
    
        @ParameterizedTest(name = "{displayName}")
        @Certificate("curuser-10")
        @Issue("RMC-6335")
        @TestCases({@TestCase("RMC-T6355"), @TestCase("RMC-T6347")})
        @CsvSource({"Subject,playwright OR selenium"})
        @DisplayName("Создание и удаление объекта с ролью \"Куратор процесса\"")
        public void shouldCreateAndDeleteSubjectByProcessManager(String prefix, String searchQuery) {
            shouldCreateAndDeleteSubject(prefix, searchQuery);
        }
    }
}

@Az8th
Copy link
Contributor

Az8th commented Jul 15, 2024

@mxschmitt Deep thanks for adding this to the next release ! Sorry for every bit of impatience and frustation you have faced from users concerning this feature request.

Is there any ETA for 1.46 ? Also by providing multiple certificates to the context, how can we handle the certificate choice dialog and will providing only one will skip it ?

@mxschmitt
Copy link
Member

@Az8th we unfortunately can't commit on an eta, but if you want help us Beta test, here are the docs and you can test on Canary. Appreciate!

@Az8th
Copy link
Contributor

Az8th commented Jul 23, 2024

@Az8th we unfortunately can't commit on an eta, but if you want help us Beta test, here are the docs and you can test on Canary. Appreciate!

I did ! I only tested with one .pfx cert at a time, but it works well so far, thanks :D
The only issue I face is that I also work with PKCS#11/Cryptoki certificates (smartcards), which I thought were a previous version of the standard, but its a completely different one. Would it be plausible to add support for it too ?

@mxschmitt
Copy link
Member

Hello everyone! This feature is implemented in our Beta/Canary and will be part of v1.46. We would appreciate, if you could help us beta test it:

Thank you!

@mxschmitt
Copy link
Member

@Az8th we unfortunately can't commit on an eta, but if you want help us Beta test, here are the docs and you can test on Canary. Appreciate!

I did ! I only tested with one .pfx cert at a time, but it works well so far, thanks :D The only issue I face is that I also work with PKCS#11/Cryptoki certificates (smartcards), which I thought were a previous version of the standard, but its a completely different one. Would it be plausible to add support for it too ?

I recommend to file a separate issue for that. Thanks!

@okraus-ari
Copy link

Hello @mxschmitt,

I struggle to make it work. I have tried both use cases - web page authentication and an API call. For both cases I have tried to add certificates via global and per-page test options, for API testing I have also tried to set the certificate in newContext options. The outcome is always the same.

The web page authentication testing scenario is relatively simple. It just does not send the certificate no matter what I do. Yet if I import the same certificate into the browser manually (the one opened by Playwright during the testing) after the failed Playwright test step and repeat the attempt, it works fine (tested with firefox).

The API call scenario is more interesting. In the end I get similar error from all browsers (chromium/firefox/webkit):

Error: apiRequestContext.post: unsupported
Call log:
  - → POST https://... (redacted)
  -   user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36
  -   accept: */*

But if I try to introduce some mistakes into clientCertificates structure it ends with a different error:

  • with wrong 'origin' it ends with "400 No required SSL certificate was sent" - as expected
  • with wrong 'pfxPath' it ends with "Error: ENOENT: no such file or directory" - it seems ok
  • with wrong 'passphrase' it ends with "Error: apiRequestContext.post: mac verify failure" - it seems correct as well

Therefore I assume that the 'unsupported' is really related to some problem with the certificate or feature implementation.

Some additional information:

  • There is apparently no communication to the backend during the 'unsupported' API call, or at least there is no request logged on the server side. It is still possible that the call fails during TLS handshake, I have not checked it with Wireshark yet.
  • The certificate is in PKCS12 format and is issued by CA which is not imported into Playwright browsers. Despite this the certificate works fine in manual scenarios in all browsers tested.
  • Playwright: Version 1.47.0-alpha-2024-07-29, OS Windows 11 Pro
  • Playwright Test for VSCode v1.1.7
  • VS Code 1.91.1
  • Chromium 128.0.6613.7 (playwright build v1128)
  • Firefox 128.0 (playwright build v1458)
  • Webkit 18.0 (playwright build v2053)
  • All Playwright browsers warn about "invalid" self-signed site certificate with CN=localhost for all sites. I assume that this is because of some Playwright internals, but I do not recall seeing this in the stable Playwright branch. Testing target site has a valid trusted certificate (checked multiple times).

I am relatively new to the Playwright (few days) so there should be no "residue" from workarounds or tests mentioned in this thread.

@mxschmitt
Copy link
Member

@okraus-ari thank you for testing it out! It sounds like the pfx certificate uses an outdated format which is not supported by Node.js / openssl anymore. Looking at this issue shows how to modernise it. Could you try that? If that works for you, we might should wrap this error, so its easier to understand for future users or add a note about it in the docs. Thanks!

@okraus-ari
Copy link

@mxschmitt Thank you for quick reply and a great suggestion. After PFX conversion it now works in the API call case.

Unfortunately I still have no luck in the web page authentication scenario. It seems like it just ignores the certificate. Here is the code:

import { test, expect } from '@playwright/test';

const originUrl = "https://example.com";

test.use({
  clientCertificates: [
    {
      origin: originUrl,
      pfxPath: 'certificate.p12',
      passphrase: 'password',
    },
],
});

test('test', async ({ page }) => {
  await page.goto(originUrl);
  await expect(page.getByText('No required SSL certificate')).toBeFalsy();
});

where example.com is in my code our testing domain without URL path (no ending slash).

The page in browser always ends up with "No required SSL certificate" error. Only typo in pfxPath fails earlier with "Error: ENOENT: no such file or directory".

@mxschmitt
Copy link
Member

Update: This issue should be fixed in the latest @canary. See #31906 for further discussions.

@amitAutoS
Copy link

amitAutoS commented Aug 5, 2024

Update: This issue should be fixed in the latest @canary. See #31906 for further discussions.

Hi @mxschmitt I have tried this with the beta release and testOptions but it doesn't works as expected, I am still getting the pop-up asking to select the certificate.

image

here's my config:
image

package.json:
image

test file:
image

**Edit from maintainers: ** This should be solved in v1.46.1.

@ilorwork
Copy link

ilorwork commented Sep 3, 2024

Why this cannot be used along side proxy?

@mxschmitt
Copy link
Member

@ilorwork we have a dedicated feature request for it: #32370 - I recommend subscribing there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests