# 17 — Client Certificates (mutual TLS)

Some APIs — most of them in regulated banking — require the client to present a TLS certificate of its own
before they will answer at all. Read this only when the vendor requires one; the rest of the HTTP surface is
[04-http-requests.md](04-http-requests.md).

## Attaching one

`.cert(tlsOptions)` on any request:

```js
await Flowrunner.Request.post('https://api.vendor.com/payments')
  .cert({
    cert      : clientCertPem,   // PEM encoded client certificate (or chain)
    key       : clientKeyPem,    // PEM encoded private key
    passphrase: keyPassphrase,   // optional, for an encrypted private key
    ca        : caBundlePem,     // optional, a custom CA bundle to verify the server with
  })
  .send(body)
```

Each option accepts a string, a Buffer, or an array of them, exactly as Node's TLS options do. Options other
than those four are ignored, so a connection cannot be weakened by accident.

`.cert()` may be called more than once and the options are merged, which lets the certificate and the CA
bundle come from different places:

```js
await Flowrunner.Request.get(url)
  .cert({ cert: clientCertPem, key: clientKeyPem })
  .cert({ ca: caBundlePem })
```

## Declaring it on the service

**Put it in `apiRequest`, not in individual actions** ([04-http-requests.md](04-http-requests.md)). Servers
of this kind normally demand the certificate before authentication, so the OAuth2 token endpoint
([06-oauth2.md](06-oauth2.md)) is unreachable without it too — an action-by-action approach leaves the token
call broken.

```js
configItems: z.object({
  clientCert: z.text().label('Client certificate (PEM)'),
  clientKey : z.text().label('Private key (PEM)'),
}),

initContext: ({ config }) => ({
  apiUrl    : 'https://api.vendor.com',
  clientCert: config.clientCert,
  clientKey : config.clientKey,
}),

apiRequest: async ({ url, method = 'get', body, query, context }) =>
  new Flowrunner.Request(`${ context.apiUrl }${ url }`, method, body)
    .cert({ cert: context.clientCert, key: context.clientKey })
    .query(query),
```

See [01-service-structure.md](01-service-structure.md) for `configItems` and `initContext`.

**`z.text()`, because a PEM block is multi-line** — it draws a textarea, where `z.string()` would put a
27-line certificate through a one-line box. The cost is that the key is on screen in clear text:
`.secret()` masks a `z.string()` config field only and is a no-op on `z.text()`
([02-zod-types.md](02-zod-types.md)). If that matters more than the textarea, ship the key as a file in the
service folder instead — the whole folder is deployed, so `require('fs').readFileSync(path.join(__dirname,
'../certs/client.key'))` reads it in the pod.

## What to know

- **`https://` is required.** Sending a certificate over `http://` would silently drop it and put the request
  on the wire in plain text, so the request fails instead.
- **`ca` replaces the default trust store** for that request, the way `curl --cacert` does. Pass the whole
  chain needed to verify the server, not just the leaf.
- **The private key is credential material.** Never inline it in the source and never put it in a URL —
  declare it as a config field or read it from a file in the service folder. Whichever way, the value is
  stored and transmitted in clear text; masking a config field with `.secret()` hides it from a passer-by,
  it does not protect it. The client keeps the key out of verbose output and off the request's enumerable
  properties, so `console.log(request)` and `JSON.stringify(request)` will not leak it — but your own logging
  still can, so redact there too ([04-http-requests.md](04-http-requests.md)).

## TLS failures

A failed handshake would otherwise surface as an opaque socket error, so recognized failures reject with a
`TLSError`. `message` is the sentence below plus the original code in parentheses, `error.code` keeps that
code and `error.cause` the original error. **No part of the certificate, the key or the passphrase is ever
in the message** — the sentences below are the only text it is built from.

**Your certificate or key could not be loaded** — no connection was attempted, so nothing reached the vendor:

| Message | Usually means |
|---|---|
| The private key could not be decrypted, the passphrase is missing or incorrect. | the key is encrypted and `passphrase` is missing or wrong |
| The client certificate or the private key is not valid PEM. | a truncated paste, or a DER/PKCS#12 file — only PEM is accepted |
| The private key does not match the client certificate. | `cert` and `key` are from different pairs |

**The server refused your certificate:**

| Message | Usually means |
|---|---|
| The server requires a client certificate and none was supplied. | `.cert()` is missing, or is on some requests only — put it in `apiRequest` |
| The server rejected the client certificate because it has expired. | renew it with the vendor |
| The server rejected the client certificate because it has been revoked. | as above |
| The server does not trust the authority which issued the client certificate. | often a sandbox certificate sent to production, or the wrong chain |
| The server rejected the client certificate. | the alert carried no reason |
| The server rejected the TLS handshake, the client certificate is missing or was rejected. | as above, reported at handshake level |

**The server's own certificate could not be verified** — a `ca` bundle is usually what is missing:

| Message | Usually means |
|---|---|
| The server certificate could not be verified, a CA bundle may be required. | the issuer is not in the default trust store — pass `ca` |
| The server certificate chain is self signed, a CA bundle may be required. | as above |
| The server certificate is self signed, a CA bundle may be required. | as above |
| The server certificate has expired. | the vendor's problem, not yours |
| The server certificate is not valid yet. | as above, or a wrong clock |
| The server certificate does not match the requested host. | the hostname in the URL is not the one the certificate was issued for |

**A TLS 1.3 server usually drops the connection instead of sending an alert.** There is nothing to read, so
when a client certificate was supplied an `ECONNRESET` / `EPROTO` is reported as the best available guess:

```
The connection was closed during the TLS handshake, the client certificate was most likely missing,
rejected or expired. (ECONNRESET)
```

Failures unrelated to TLS are left untouched, so a `ResponseError` still means what it means in
[04-http-requests.md](04-http-requests.md).

## Testing it

The suite's mock intercepts below TLS ([15-unit-tests.md](15-unit-tests.md)), which has two consequences:

- **A handshake cannot be reproduced.** Treat it as something only a real deploy confirms.
- **The certificate has no wire footprint, and the recorded call does not carry one either** — a
  `mock.history` entry holds the method, URL, headers, query and body, and no TLS field. So there is nothing
  to assert about `.cert()` itself; do not write a test that tries.

**What is worth covering is your own error handling**, by making the mock fail the way TLS fails. Three
forms, matching the three ways a reason is recognized:

```js
// 1. the TLS 1.3 connection drop — needs `cert` or `key` on the request, or it stays a plain ECONNRESET
mock.onGet(url).replyWithNetworkError('ECONNRESET')

// 2. a reason matched on the OpenSSL code. `replyWithNetworkError` only sets `error.code` for `E…` codes,
//    so anything else has to carry the code itself
mock.onGet(url).replyWithError(Object.assign(new Error('cert expired'), { code: 'CERT_HAS_EXPIRED' }))

// 3. a reason matched on the message — the handshake alerts and every local-material failure
mock.onGet(url).replyWithError(new Error('sslv3 alert certificate required'))
mock.onGet(url).replyWithError(new Error('error:06065064:digital envelope routines::bad decrypt'))
```

Each rejects with the `TLSError` from the tables above, so a suite can assert that a retry, a log line or a
surfaced message behaves the way it should.

**The `https://` guard needs no mock at all** — the request is rejected before anything is sent:

```js
await expect(Flowrunner.Request.get('http://api.vendor.com/x').cert({ cert, key }))
  .rejects.toThrow('A client certificate can only be used with an https:// URL.')
```
