# FAPI 2.0 & mutual TLS

!!! info "New in v1.1.0-rc.15"
    The FAPI 2.0 security profile, FAPI 2.0 Message Signing and mutual TLS arrived with `v1.1.0-rc.15`. Upgrading:
    [from 1.1.0-rc.14 to 1.1.0-rc.15](../operations/upgrade-1.1.0-rc14-rc15.md). Conformance self-run:
    [FAPI 2.0 conformance-suite run](../compliance/fapi2-conformance.md).

A realm has a **security profile**: *Standard* (the default) or *FAPI 2.0*. With FAPI 2.0, every client of the realm
follows the [FAPI 2.0 Security Profile (Final)](https://openid.net/specs/fapi-security-profile-2_0-final.html) at the
same time. The rules are checked whenever a client is used, whatever the client stored: a client that does not meet
them stops working, and nothing falls back to a weaker setting.

FAPI 2.0 mode is not a certification. HelixIAM has not been certified by the OpenID Foundation for FAPI 2.0; the
OpenID Foundation's FAPI 2.0 test plans pass in a [self-run](../compliance/fapi2-conformance.md).

## Turning it on

- **Console:** *Realm settings → Security*. Choose *FAPI 2.0* and save. The page lists the clients of the realm that
  would stop working, with the reason, and asks for confirmation if there are any.
- **Admin API:** `PUT /admin/realms/{realm}/settings/security-profile` with `{"securityProfile": "FAPI2"}` (or
  `"STANDARD"`). Needs realm-admin. `GET` on the same path returns the profile and the clients that would stop
  working (`nonCompliantClients`, each with `reasons`: `publicClient`, `clientSecret`, `noKeys`, `implicit`).
- **Realm export/import:** the export's `keyCustody.securityProfile` carries the profile (and
  `keyCustody.requireSignedRequestObject`, below); an import applies it before the clients, so they are checked
  against it.
- **Signed requests (FAPI 2.0 Message Signing):** in a FAPI 2.0 realm, *Require signed request objects* (console) or
  `"requireSignedRequestObject": true` in the same `PUT` makes every client of the realm send its authorization
  requests as signed request objects at PAR (see [Message Signing](#message-signing-jar-and-jarm)). A single client can
  require it too (client setting *Require signed request objects*). In a Standard realm the switch is refused
  (`fieldErrorCodes.requireSignedRequestObject: onlyFapi2`); switching the realm back to Standard turns it off.

A refused write answers `400` with `fieldErrors.securityProfile` and a code in `fieldErrorCodes`: `required`,
`unknown`, or `masterRealm`. The **master realm cannot use FAPI 2.0**: the admin console signs in to it as a public
client.

The setting is stored with the realm's key-custody settings (`realm_custody_settings.security_profile`, migration
V197) and every replica picks up a change at commit.

## What a FAPI 2.0 realm enforces

| Rule | Behaviour | Spec |
|---|---|---|
| Confidential clients, `private_key_jwt` or mutual TLS | `private_key_jwt`, `tls_client_auth` or `self_signed_tls_client_auth` (RFC 8705 §2). Saving a public client or one with a client secret is refused (`400`, `fieldErrorCodes.publicClient: fapi2PublicClient` or `fieldErrorCodes.tokenEndpointAuthMethod: fapi2AuthMethod`; also in Dynamic Client Registration and the realm import). A client stored before the switch cannot authenticate (`invalid_client`). | §5.3.2.1 |
| Client assertion `aud` | The issuer identifier only, as a single string. A token, PAR or other endpoint URL, or an array, is refused. | §5.3.2.1-8 |
| Pushed authorization requests (PAR) | A plain `/oauth2/authorize` request is refused with `invalid_request` (redirected to a registered redirect URI only). A pushed request needs `redirect_uri`. The `request_uri` lives 300 s (`expires_in`); after that it is removed and answered `invalid_request_uri` at the pushed request's redirect URI. | §5.3.2.2, RFC 9126 |
| PKCE | `S256` required, for confidential clients too. A missing or wrong `code_verifier` is refused. | §5.3.2.2 |
| Response type | `code` only. The implicit and hybrid flows are refused, at use and when a client is saved (`implicitFapi2`). | §5.3.2.2 |
| Authorization code | Lives at most 60 s, single use, bound to its client. | §5.3.2.1 |
| Sender-constrained tokens | Every access token is sender-constrained, by DPoP or by mutual TLS, chosen per client. **DPoP** (default): bound to the client's DPoP key (`cnf.jkt`, `token_type: DPoP`); a token request without a proof is refused with `invalid_dpop_proof`; userinfo takes the token with its proof (`Authorization: DPoP`) and refuses it as Bearer. **Mutual TLS** (client setting *certificate-bound access tokens*): bound to the client certificate (`cnf.x5t#S256`); a token request without a certificate is refused with `invalid_request`; userinfo takes the token only with that same certificate. Never an unbound Bearer token. | §5.3.2.1, §5.3.3, RFC 9449, RFC 8705 §3 |
| Other token endpoints | The agent delegation endpoint (`/agent/delegation/token`) issues a FAPI 2.0 client sender-constrained tokens only: without a DPoP proof (or the certificate of a certificate-bound client) it is refused with `invalid_dpop_proof` (`invalid_request` for the certificate). Workload identity federation (`/workload-identity/token`) is refused with `unsupported_grant_type`: the workload authenticates with an external JWT, neither `private_key_jwt` nor mutual TLS, and its tokens are not sender-constrained. | §5.3.2.1-6, §5.3.3 |
| `dpop_jkt` | A DPoP proof sent with the pushed request binds the code to its key; a `dpop_jkt` that names another key is refused at PAR; the code is redeemed only with a proof of that key. | RFC 9449 §10 |
| Refresh tokens | Not rotated (the same refresh token keeps working). With mutual TLS the refresh needs the client certificate of the access token (another certificate: `invalid_grant`). With DPoP the refresh needs a DPoP proof, and the new access token is bound to that proof's key: the refresh token of a confidential client is not bound to a DPoP key, since the client authenticates (RFC 9449 §5). | §5.3.2.1, RFC 9449 §5 |
| Algorithms | The realm signs ID tokens, access tokens and other JWTs with PS256, ES256 or EdDSA: RS256 is signed as PS256 with the same RSA key, and there is no RS256 fallback. Client assertions and request objects are accepted with PS256, ES256 or EdDSA (RSA keys of 2048 bits or more); DPoP proofs with PS256 or ES256. `none` is never accepted. | §5.4.1 |
| Clock skew | A JWT whose `iat` or `nbf` is up to 10 s in the future is accepted; more than 60 s is refused (client assertions, DPoP proofs, request objects). | §5.3.2.1 |
| Redirects | Redirects to the client's redirect URIs (responses and errors) are `303 See Other`, never 307. | §5.3.2.2 |
| Discovery | Advertises exactly the above: `require_pushed_authorization_requests: true`, `private_key_jwt`, and when a client certificate can reach the server (a forwarded-certificate header or the server's own TLS client authentication) `self_signed_tls_client_auth` (plus `tls_client_auth` when trust anchors are configured) and `tls_client_certificate_bound_access_tokens: true`, PS256/ES256/EdDSA (also for `request_object_signing_alg_values_supported` and `authorization_signing_alg_values_supported`), `dpop_signing_alg_values_supported` PS256/ES256, `code_challenge_methods_supported` S256, `response_types_supported` code, and `require_signed_request_object: true` when the realm requires signed requests. | RFC 8414, RFC 9126 §5, RFC 8705 §3.3, RFC 9101 §10.5 |

## Message Signing (JAR and JARM)

FAPI 2.0 Message Signing (§5.3.1, §5.4.1) adds signed authorization requests and signed authorization responses. Both
work in every realm; in a FAPI 2.0 realm the rules below are strict.

**Signed request objects at PAR (JAR, RFC 9101 with RFC 9126 §3).** The client sends `client_id`, its client
authentication and `request` (the signed request object) to the PAR endpoint. The pushed request is then exactly the
request object's parameters plus the client authentication: `redirect_uri`, `state`, `nonce`, `scope`,
`response_mode` and every other parameter come from inside the object, and other form parameters are ignored. The
`client_id` and `iss` inside must be the client's, and a request object that carries `request_uri` or `request` is
refused. In a FAPI 2.0 realm the request object must:

- be signed with PS256, ES256 or EdDSA (RS256 and `none` are refused) by a key of the client;
- have `aud` equal to, or containing, the issuer;
- have `exp` and `nbf`, with `nbf` at most 60 minutes in the past and `exp` at most 60 minutes after `nbf`; an `nbf`
  or `iat` up to 10 s in the future is accepted (clock skew), an expired object is refused;
- have `typ` `oauth-authz-req+jwt` or `JWT` (or none).

A request object that fails a check is answered `400 invalid_request_object`. When the client or its realm requires
signed request objects, a pushed request without one is `400 invalid_request`. The `state` and `nonce` of the response
and the ID token are the ones inside the object; a `state` sent next to the `request_uri` at the authorization
endpoint is ignored.

**Signed authorization responses (JARM).** A client asks for one with `response_mode` `jwt`, `query.jwt`,
`fragment.jwt` or `form_post.jwt` (in its request object, its pushed request or a plain request), or registers a JARM
response mode (client setting *JARM response mode*), in which case it always gets JARM. The response, success or error
(also a cancelled sign-in, an expired `request_uri` or a refused plain request), is one JWT in the `response`
parameter, with `iss` (the issuer), `aud` (the client), `exp` (2 minutes), `iat` and the response parameters (`code`,
`state` or `error`, `error_description`). It is signed with the realm's ID-token key, with the client's
`authorization_signed_response_alg` when it registered one (client setting *Response signing algorithm*; PS256, ES256
or EdDSA, and RS256 outside a FAPI 2.0 realm), else the realm's default (PS256, ES256 or EdDSA in a FAPI 2.0 realm).
The registered algorithm must be one the realm has an active key for (in key custody: an active ID-token key that
signs it); the admin API, Dynamic Client Registration and the realm import refuse it otherwise (admin API: 400,
`fieldErrorCodes.authorizationSignedResponseAlg = noActiveKey`; registration: `invalid_client_metadata`). A response
that cannot be signed as asked (the key was deactivated later) is a server error, never an unsigned response or one
signed with another algorithm. Discovery lists the four JARM
modes in `response_modes_supported` and the algorithms in `authorization_signing_alg_values_supported`. Encrypted
responses (`authorization_encrypted_response_alg`) are not supported.

## In every realm

Some of the work behind FAPI 2.0 applies to all realms because the specs require it:

- Every authorization response carries `iss` (RFC 9207), in the query, the fragment or the `form_post` form; a JARM
  response carries it inside its JWT. Both discovery documents say `authorization_response_iss_parameter_supported: true`.
- Client assertions and request objects are accepted with RS256, PS256, ES256 or EdDSA (before: RS256 only).
  Outside a FAPI 2.0 realm the token endpoint URL stays an accepted assertion audience (OpenID Connect Core §9).
- An access token requested with a DPoP proof is bound to its key and typed `DPoP`, and userinfo accepts DPoP.
- A `request_uri` inside a pushed authorization request is refused (RFC 9126 §2.1), and an expired `request_uri` is
  answered `invalid_request_uri` (RFC 9126 §2.2).
- A request to the token, PAR, revocation or introspection endpoint without any client authentication (no assertion,
  a missing or unknown `client_assertion_type`) is `401` with the JSON error `invalid_client` (RFC 6749 §5.2), never a
  redirect to the sign-in page or an empty body.
- The sign-in page has a *Cancel* while an authorization request is pending: the client gets `error=access_denied`
  with its `state` at its redirect URI (OpenID Connect Core §3.1.2.6). A pushed request is then used up.
- A DPoP proof's `htu` is compared without query and fragment, after the RFC 3986 normalization (case of scheme and
  host, default port, dot segments, percent-encoding), at userinfo and wherever else a proof is checked (RFC 9449 §4.3).
- A signed request object at the PAR endpoint and JARM by `response_mode`: see
  [Message Signing](#message-signing-jar-and-jarm).
- A client with certificate-bound access tokens (RFC 8705) gets a token only with its certificate; without one the
  request is refused (`invalid_request`). Before, an unbound token was issued.
- A certificate-bound token is accepted at userinfo and at the admin API only with the certificate it is bound to; a
  DPoP-bound token is never accepted as a Bearer token. Access tokens are never taken from the query string.
- `tls_client_auth` and `self_signed_tls_client_auth` are available to every client (see below).

## Mutual-TLS client authentication (RFC 8705 §2)

A client's token endpoint auth method can be:

- `tls_client_auth`: a certificate issued by a CA in the server's trust anchors, with the subject the client
  registered: exactly one of the subject DN (`subject_dn`, compared as an X.500 name) or one subject alternative name,
  `san_dns`, `san_uri`, `san_ip` or `san_email`. Admin API and console: `tlsClientAuthAttribute` and
  `tlsClientAuthValue`; Dynamic Client Registration: `tls_client_auth_subject_dn`, `tls_client_auth_san_dns`,
  `tls_client_auth_san_uri`, `tls_client_auth_san_ip` or `tls_client_auth_san_email` (migration V198). The chain is
  checked with PKIX, validity dates included; revocation (CRL/OCSP) is not checked. Without trust anchors
  (`helix.fapi.mtls.trust-anchors`) the method is not advertised and refuses every certificate.
- `self_signed_tls_client_auth`: the certificate is the first `x5c` certificate of a key in the client's JWK Set by
  value (`jwks`), and within its validity dates. A JWKS URL is not used for this method.

The client sends `client_id` and presents its certificate; nothing else. Certificate-bound access tokens are a
separate client setting (`x509CertificateBoundAccessTokens`, RFC 8705 `tls_client_certificate_bound_access_tokens`),
so an mTLS client can still use DPoP as its sender constraint. A wrong, untrusted or missing certificate is
`invalid_client`.

## TLS and mutual TLS (FAPI 2.0)

HelixIAM serves plain HTTP behind a TLS-terminating proxy or ingress. The transport rules of FAPI 2.0 (§5.2.2) are
therefore set at that proxy, and HelixIAM relies on it for the parts below.

**TLS policy.** The proxy must offer TLS 1.2 and TLS 1.3 only (no TLS 1.0 or 1.1), and for TLS 1.2 only the cipher
suites BCP 195 recommends (RFC 9325 §4.2):

- `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256`, `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256`
- `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384`, `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`

No DHE, CBC or static-RSA suites. TLS 1.3 with its standard suites (AES-GCM, CHACHA20-POLY1305) is fine, and a client
that offers TLS 1.3 must get it (OpenSSL-based proxies do this by default). Every response carries
`Strict-Transport-Security: max-age=31536000; includeSubDomains`. HelixIAM also sends that header on requests that
arrived over HTTPS; a proxy that adds its own should drop the upstream copy so there is only one.

**Client certificates.** For `tls_client_auth`, `self_signed_tls_client_auth` and certificate-bound access tokens
(RFC 8705), the proxy asks for a client certificate but does not require it and does not verify it: HelixIAM does.
The proxy forwards the certificate as URL-encoded PEM in the header `helix.fapi.client-cert-header`
(`HELIX_FAPI_CLIENT_CERT_HEADER`; empty by default, which reads no forwarded certificate at all; for example
`X-Client-Cert`), and it must overwrite that header on every request, so a
client can never send its own copy. A certificate is public: a header a client could set proves nothing.

HelixIAM honours the header only when the TCP peer is listed in `helix.fapi.client-cert-trusted-proxies`
(`HELIX_FAPI_CLIENT_CERT_TRUSTED_PROXIES`): the proxies that request and forward client certificates. This list is
separate from `helix.security.trusted-proxies` (`HELIX_TRUSTED_PROXIES`, for `X-Forwarded-*`) on purpose: a hop that is
trusted for the forwarded host and scheme but does not terminate client TLS, such as the admin console's nginx, must
never be able to present a certificate. Do not list such hops. From any other peer, or with the list empty, the header
is ignored and an mTLS client fails closed: it cannot authenticate with its certificate, and no certificate-bound token is issued or accepted for it.
Without the header and that list (and without `server.ssl.client-auth` `want` or `need` on the server itself) no certificate can
reach HelixIAM, so discovery advertises no mutual-TLS method and no `tls_client_certificate_bound_access_tokens`, in
every realm (RFC 8414 §2: only what the endpoint supports).

HelixIAM checks the certificate as follows:

- `tls_client_auth`: the certificate must chain to a trust anchor in `helix.fapi.mtls.trust-anchors`
  (`HELIX_FAPI_MTLS_TRUST_ANCHORS`, the path of a PEM file of CA certificates; a path that cannot be read stops the
  server at startup), be meant for TLS client authentication (a key usage, when present, includes
  `digitalSignature`; an extended key usage, when present, includes `clientAuth` or `anyExtendedKeyUsage`, so a TLS
  server certificate of the same CA is refused), and match the subject the client registered. The proxy forwards the
  leaf certificate only, so put the issuing CA (an intermediate too, if it issues client certificates) in that file.
  The trust anchors are server-wide: every realm's `tls_client_auth` clients trust the same CAs, so a client of one
  realm can register a subject that a certificate issued for another realm's client carries. Give each tenant's
  clients subjects that only their CA issues, or use `self_signed_tls_client_auth` (exact certificate match) where
  tenants share a CA.
- `self_signed_tls_client_auth`: the certificate must be the `x5c` certificate of a key in the client's JWK Set by
  value (exact match).
- Certificate-bound tokens: the SHA-256 thumbprint of the certificate is put in the token (`cnf.x5t#S256`), and the
  same certificate must be presented with the token.

**nginx example.** TLS 1.2/1.3 with the BCP 195 suites, HSTS, an optional unverified client certificate, and the
certificate forwarded in `X-Client-Cert`:

```nginx
server {
  listen 443 ssl;
  server_name idp.example.com;
  ssl_certificate     /etc/nginx/tls/fullchain.pem;
  ssl_certificate_key /etc/nginx/tls/privkey.pem;

  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
  ssl_prefer_server_ciphers off;
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

  # Ask for a client certificate; HelixIAM verifies it.
  ssl_verify_client optional_no_ca;

  location / {
    proxy_pass http://helixiam:8080;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-Host $http_host;
    proxy_set_header X-Forwarded-Port 443;
    # Replaces any X-Client-Cert the client sent; without a client certificate the header is not sent at all.
    proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
    proxy_hide_header Strict-Transport-Security;
  }
}
```

`proxy_set_header` with a header name replaces the incoming header of that name, and nginx does not pass a header
whose value is empty, so a client-sent `X-Client-Cert` never reaches HelixIAM. Keep all `proxy_set_header` lines in
the same block: a block that sets any of its own does not inherit those of the enclosing block. Then list the proxy's
address in `HELIX_TRUSTED_PROXIES` and in `HELIX_FAPI_CLIENT_CERT_TRUSTED_PROXIES`.

Every other proxy that can reach HelixIAM must remove a client-sent copy of the header too. The admin console's nginx
(the `helix-dashboard` image) clears the well-known client-certificate headers and `HELIX_FAPI_CLIENT_CERT_HEADER` in
every location it proxies; the Helm chart passes it that name.

For Kubernetes with ingress-nginx, the Helm chart sets the mTLS annotations (`ingress.mtls.enabled`, forwarded header
`ssl-client-cert`), and the TLS policy goes in the controller's ConfigMap: see the chart README (`helm show readme`), "TLS policy and
mutual TLS (FAPI 2.0)".

**A separate mTLS host.** Some deployments ask for client certificates on a separate host only, so browsers on the
main host never see a certificate prompt. Set `helix.fapi.mtls-base-url` (`HELIX_FAPI_MTLS_BASE_URL`) to that origin,
for example `https://mtls.idp.example.com` (scheme and host only, no path). Discovery then publishes
`mtls_endpoint_aliases` (RFC 8705 §5): the token, PAR, userinfo, introspection, revocation, device authorization and
registration endpoints on that origin, with the same paths.

The issuer is derived from the host of the request. So the proxy of the mTLS host must send `X-Forwarded-Host` with
the main (issuer) host, `X-Forwarded-Port` with its port and `X-Forwarded-Proto: https`; tokens issued through the
aliases then carry the normal issuer. In the example above, for a second `server` block with
`server_name mtls.idp.example.com`, that means `proxy_set_header X-Forwarded-Host idp.example.com;` instead of
`$http_host`, and the main host's block can then leave out `ssl_verify_client`. The mTLS host's proxy must also be
in `HELIX_TRUSTED_PROXIES` and `HELIX_FAPI_CLIENT_CERT_TRUSTED_PROXIES`.

## Not supported yet

- **Encrypted request objects and responses** (JWE request objects, `authorization_encrypted_response_*`).
- **Signed introspection responses** (RFC 9701).
- **TLS policy inside HelixIAM.** HelixIAM serves plain HTTP; TLS 1.2+ with the BCP 195 suites is set at the proxy or
  ingress (above). HelixIAM does not check the TLS version the proxy negotiated.
- **Certificate revocation** (CRL, OCSP) for `tls_client_auth` and certificate-bound tokens. A revoked client
  certificate keeps authenticating until it expires: keep client certificates short-lived, and remove a compromised
  client's registration (or change its registered subject) instead. HelixIAM checks revocation for the EUDI wallet's issuer
  certificates only, not for client certificates yet.
- **Per-realm trust anchors** for `tls_client_auth`: `helix.fapi.mtls.trust-anchors` is one set for the whole server
  (see above).
- Dynamic Client Registration takes the RFC 8705 metadata, `require_signed_request_object` (RFC 9101) and
  `authorization_signed_response_alg` (JARM), but not a JARM response mode: that stays an administrator's setting (a
  registered client asks for JARM with `response_mode`).
  An RFC 7592 update may raise certificate-bound tokens, signed request objects and the client authentication method,
  never lower them (`invalid_client_metadata`): only an administrator does.
