Driving StubID from a browser test
The reason this project exists is that you cannot automate a MitID login: the widget is a cross-origin iframe that detects and blocks browser automation, and every login in the broker's pre-production environment has to be approved by hand. StubID is where that stops being true. A Playwright test walks the whole front channel — the authorize redirect, the response, the callback — and a real browser does the walking.
tests/interop-browser/matrix.sh
That script is the whole recipe, and it runs in CI on every change against the image the same job built: three engines, each refused first and then trusted by its own mechanism, each signing in over TLS with nothing relaxed. A copied example that has quietly stopped working is worse than no example, so what follows is that script explained rather than a second version of it.
Clicking through works; queueing is usually still better
A parked login redirects the browser to /op/Login, and driving it does what it looks like it
does: pick a citizen, click Approve, and the browser is carried back to the client with a code,
exactly as an automatically approved login is. Aborting returns
error=access_denied&error_description=mitid_user_aborted. A decision made through the control
API while the browser sits on that page is collected the same way, on its next navigation.
That was not always true — a parked login could not be resumed at all, and every guide here told you to queue the outcome instead. It is true now, and a browser test that wants to exercise the page should.
For everything else, queue the outcome anyway. It is one request rather than three, it needs no page to drive and no second navigation, and it is the only way to get an outcome a person cannot produce by clicking. So a browser test either leaves automatic approval on, or queues first:
POST /_stubid/v1/behaviors/enqueue
{ "approve": false, "clientId": "…", "errorCode": "mitid_user_aborted" }
and then navigates. The next matching login consumes it, and the browser is redirected back to
the client carrying error=access_denied&error_description=mitid_user_aborted — which is the
outcome a suite most wants to test and the one no broker add-on will produce on demand.
Queue it immediately before the navigation that consumes it. A queued decision outliving its own test is spent by the next one, and that failure surfaces somewhere else entirely. Deciding what a login does has the rest of the ladder.
Two trusts, not one
This is the step everybody gets half right.
A browser test has two halves and they read different trust stores. The browser walks the front channel and makes its own TLS decision in its own process. The client library — running in Node, beside the test rather than inside the browser — discovers the metadata and redeems the code, and it uses Node's trust.
NODE_EXTRA_CA_CERTS=/path/to/stubid.crt # the library's half
certutil … / update-ca-certificates # the browser's half, and it differs per engine
Setting only the first is the usual outcome, because it is the answer Playwright's own
documentation gives for certificates — and it is the right answer for Playwright's request
fixture and for its browser downloads, neither of which is the browser's TLS stack. The
sign-in then fails at the navigation with a certificate error while discovery has already
succeeded, which reads like a StubID problem and is not one.
Trusting the certificate, per engine
Fetch it first. This needs no trust, which is the point of it — the control API answers on plain HTTP even on a secured instance:
curl -fsS http://localhost:18081/_stubid/v1/runtime/tls-certificate.pem -o stubid.crt
Then, per engine. All three are measured, each against a control that must be refused first:
| Engine | Reads | Flag |
|---|---|---|
| Chromium | an NSS database at ~/.pki/nssdb |
P,, |
| Firefox | its own profile database | C,, |
| WebKit | the operating system's bundle | — |
# Chromium
certutil -d sql:$HOME/.pki/nssdb -A -n stubid -t "P,," -i stubid.crt
# Firefox, into a profile directory you then launch with
certutil -d sql:/path/to/profile -A -n stubid -t "C,," -i stubid.crt
# WebKit
sudo cp stubid.crt /usr/local/share/ca-certificates/stubid.crt && sudo update-ca-certificates
Chromium and Firefox want opposite flags, which is the detail that costs an afternoon. P is
a trusted peer for server authentication and C is a certificate authority; StubID's certificate
is a self-signed leaf with CA:FALSE, so P is what it is. Firefox takes it as a trust anchor
under C anyway and refuses it under P. Chromium refuses C — with
net::ERR_CERT_INVALID rather than net::ERR_CERT_AUTHORITY_INVALID, so the wrong flag and no
flag at all are distinguishable, and worth reading carefully before you conclude the certificate
never arrived.
Chromium also accepts --ignore-certificate-errors-spki-list=<base64 sha256 of the DER SPKI>,
which installs nothing and is bounded to one key. It is honestly an ignore-errors flag, though:
it also waves through an expired certificate and a name that does not match, for that key.
Firefox's policies.json Certificates.Install does not work here. It is the documented
enterprise mechanism and it takes no effect on a Playwright-launched Firefox at all.
Firefox forces launchPersistentContext
Not a line of setup — a constraint on the suite. A seeded profile can only be given to
launchPersistentContext, so under Firefox there is one context per launch and no
browser.newContext() isolation between tests:
const context = await firefox.launchPersistentContext(profile, {})
Chromium and WebKit are unconstrained, because their trust is outside the profile. If your suite is written around per-test contexts, Firefox is the engine that will make you restructure it, and it is better to know that before you write the other two.
The callback needs no relying party
Point the redirect URI at an address nothing serves, and read the request as the browser issues it:
const callback = page.waitForRequest(r => r.url().startsWith(redirectUri))
// Nothing answers there, so this navigation is expected to fail. The assertion is on the
// request, never on the navigation.
await page.goto(authorizationUrl, { waitUntil: 'domcontentloaded' }).catch(() => {})
const request = await callback
Do not reach for page.route here. It looks like the right tool and it is quietly the wrong
one: a route handler never fires for a redirect hop. It catches the form_post submission, which
is a fresh navigation the page's own script starts, and misses the ordinary redirect — so a suite
built on it passes one scenario, fails the other, and gives no hint why. Measured in all three
engines.
Use an https redirect URI even though nothing is listening. A form submitted from an https page
to an http action is a browser policy that has changed more than once and differs between engines,
and there is no reason to depend on it. StubID validates only that a redirect URI is present, and
that the token request repeats the same one.
form_post needs a JavaScript engine
StubID answers a response_mode=form_post request the way the broker does: a page whose body
carries onload="document.forms[0].submit()". An HTTP client cannot run that — every other test
in this repository reads the hidden inputs out of the HTML and posts them by hand. A browser is
the only thing that executes the page as written, which makes this the scenario a browser test is
uniquely worth writing.
The callback then arrives as a POST, so read request.postData() rather than the URL.
So does the admin page
StubID's own admin page keeps its table current with a small script that asks the instance for the rows every couple of seconds. Everything else about that page is asserted in memory against a test server, in milliseconds and on both operating systems; that the script runs at all, reaches the instance and puts what comes back on the page is not assertable anywhere without a browser.
The suite opens the page, leaves a mark on the window object, drives an ordinary sign-in in another tab, and waits for a row to appear. Then it checks the mark is still there. A page that had reloaded would show the new row too, and would prove nothing about the script — which is the same reason the certificate checks refuse first and trust afterwards.
What trusting it costs you locally
~/.pki/nssdb is your own Chrome and Edge trust store, not a test fixture. What you add there,
you added to your browser:
certutil -d sql:$HOME/.pki/nssdb -L # what is in there
certutil -d sql:$HOME/.pki/nssdb -D -n stubid # take it out again
The same is true of the system bundle for WebKit. The private key for that certificate sits in
the key directory under a password that is a constant in this project's source, so anyone who can
read that directory can present a certificate your machine now accepts for localhost. Not on a
shared machine, and not with a key directory you did not create. Trusting the certificate StubID
serves has the rest of that argument.
Running the browsers in a container avoids the question entirely, which is what CI does: the trust installs land in the container's own writable layer and go when it does, and the host's stores are never touched.
Or trust nothing
Plain HTTP is the default and the control API uses it even on a secured instance. Leave
StubId__Tls unset and there is no certificate, nothing to install, and none of this guide
applies — the front channel, the form_post auto-submit and the queued outcomes all work exactly
the same. TLS is worth turning on when the application under test insists on it, and not before.
What this does not cover
The matrix runs on Linux, in Playwright's own image. WebKit on Linux approximates Safari; it is not Safari. It shares the engine and not the platform's TLS stack or its trust store, so a WebKit pass is evidence about the engine rather than about a Mac.
Windows and macOS trust stores are in the certificate guide and are documented rather than run — there is no macOS runner here. The mechanisms above are the engines' own rather than Playwright's, so Selenium, Cypress and WebdriverIO need the same three trust steps; what changes is only how you launch and how you read the callback.