Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Onward credentials

How the gateway authenticates to each node it sends a request to (§13.1, N25). Each endpoint’s [credentials."<id>"] section names one scheme: a bearer token or a user and a password (Configuration), or one of the grants on this page, which obtain a token at the node’s authorization server before the request:

  • oauth2: the client-credentials grant or token exchange, authenticated by an ES256 or ES384 assertion signed with the [signing] key;
  • nuts: the Nuts track of the Dutch Generic Functions (Annex B §B.4);
  • fapi2: an authorization server under the FAPI 2.0 Security Profile, such as the BgZ/eOverdracht track (Annex B §B.4a).

A section names one of them; two in one section refuse the configuration. A node’s section may also name the TLS client certificate the gateway presents to that node and to its authorization server (Mutual TLS to a node). No log line, error or rendering carries a credential, an assertion, a key, a proof or a token.

OAuth 2.0 to a node

An oauth2 section makes the gateway authenticate to that node as itself (§13.1, N25). Before a request, it asks the node’s token endpoint for an access token with the client-credentials grant (RFC 6749 §4.4). It authenticates there with a JWT client assertion (RFC 7523 §2.2), signed with the [signing] key, ES256 or ES384 as its curve says. The assertion names client_id as its iss and sub and the token endpoint as its aud, lives assertion_lifetime_s seconds, and carries a fresh jti. The token request carries scope and, when set, resource and audience. A node’s section refuses client_secret_basic, client_secret_post and client_secret, naming the key: a client secret is for an identity service’s grant (Identity resolution). Every key of the section is required except those two and the assertion audience below:

  • scope is space-separated SMART on openEHR scopes, each a resource scope of the system compartment (system/aql-*.s, system/composition-*.cru); anything else is refused at load. The token request carries each scope in the canonical form of the grammar, its permissions in c, r, u, d, s order and one space between scopes: system/aql-*.sr is requested as system/aql-*.rs.
  • token_endpoint is an https URL with no user name, password, query or fragment; http is accepted only under profile = "development" (What must travel encrypted).
  • assertion_audience is token_endpoint, the default, or issuer. RFC 7523 §3 admits either as the assertion’s aud. An authorization server under the FAPI 2.0 Security Profile accepts only its issuer identifier, as one string (§5.3.2.1). With issuer, set issuer to that identifier, an http or https URL with no query or fragment in its canonical form (RFC 8414 §2); issuer beside the default refuses the configuration.
[credentials."cdr-c".oauth2]
# ...
assertion_audience = "issuer"
issuer = "https://auth.cdr-c.example.org"

The gateway caches a token until 30 seconds before the end of the lifetime its expires_in states, with one token request per endpoint at a time. A token with no stated lifetime serves one request. A node that answers 401 drops the cached token, and the next request obtains a new one. When no token can be obtained, that node is reported node-error and is sent nothing: the gateway never dispatches unauthenticated. The answer says only no onward credential could be obtained, so nothing was sent, followed by the token endpoint’s RFC 6749 §5.2 error code when it refused with a registered one. The full account, the token endpoint’s description included, goes to the log at warn with the endpoint and the request id. The admission check authenticates the same way.

The caller’s own Authorization header never reaches a node.

A token per caller: token exchange

grant = "token_exchange" gives each verified caller a token of its own at that node, where the node’s authorization server supports RFC 8693:

[credentials."cdr-c".oauth2]
grant = "token_exchange"
client_auth = "private_key_jwt"
token_endpoint = "https://auth.cdr-c.example.org/oauth2/token"
client_id = "ferrofed-gateway"
scope = "system/aql-*.s"                         # the gateway's own requests
resource = "https://cdr-c.example.org/openehr"   # required: the node (RFC 8707)

For a request on behalf of a caller, the gateway sends the token endpoint the caller’s verified access token as subject_token, an assertion of its own as actor_token, with its own jti, and the client assertion as above. It asks for the caller’s granted scopes that cover the operation, and never the rest (N26), each in the canonical form, and names the node with resource. The answer must issue an access token (issued_token_type, RFC 8693 §2.2.1). The token is cached per caller’s token and scope, at most 1024 per endpoint, until 30 seconds before it expires, and dropped when the node answers 401. The cache keys on a SHA-256 of the caller’s token, never the token.

  • Only a caller verified by its token’s signature or by introspection has a token to exchange. A caller the edge mode asserted has none, so that node is node-error and is sent nothing.
  • A request that no granted scope of the caller covers, such as one with no SMART on openEHR family or a demographic request, is never exchanged: the node is node-error and is sent nothing. Without scope the authorization server would choose the scope itself (RFC 8693 §2.1).
  • The gateway keeps the caller’s token only while some node’s grant is token_exchange, and never writes it to a log, a span, a metric or the openEHR-federation-client token.
  • A caller’s token whose text or claims carry the patient identifier the query was resolved on is not sent to the token endpoint, and that node is node-error (§5.4.1, N33).
  • The gateway’s own requests, the admission check and the redistribution of a held stored query, have no caller: they use the client-credentials grant at the same token endpoint, with scope.

The caller’s token reaches the node’s authorization server, which must trust the caller’s issuer, and never the node itself.

Tokens bound to a key: DPoP

dpop_key_file in an oauth2 section binds that node’s tokens to a key of the gateway’s (RFC 9449), for a deployment that requires sender-constrained tokens. The file holds a P-256 key, which signs ES256, or a P-384 key, which signs ES384, in PKCS#8 PEM:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out cdr-c-dpop.pem

Every request to that node’s URL, and to its token endpoint, then carries a DPoP proof signed with the key: it names the request’s method and URL without query and fragment, a fresh jti and the time, and, on a request that carries the token, the token’s SHA-256 in ath. The token is sent under the DPoP scheme. The token endpoint must answer token_type DPoP; a bearer token is refused and the node is node-error. A token endpoint that answers 400 use_dpop_nonce, or a node that answers 401 with a DPoP challenge naming use_dpop_nonce, is sent the request once more with the nonce it gave, within the request’s budget, and every later proof to that server carries the latest nonce it sent (RFC 9449 §8, §9). The token endpoint’s nonce and the node’s are kept apart even when both live on one host. A token request sent once more carries a newly signed client assertion, and under token exchange a new actor token, so the token endpoint never sees a jti twice. A node whose deadline passes before that second send is time-out, and it counts as a node that was asked. The key is read at start and on each reload; a key that is no P-256 or P-384 key refuses the configuration, naming dpop_key_file.

Mutual TLS to a node (RFC 8705)

A node’s [credentials."<id>"] section can name the TLS material the gateway reaches that node with, by the keys the identity services take:

KeyWhat it is
client_identity_fileThe gateway’s client certificate chain and its private key, PEM, the end-entity certificate first. client_identity takes the same inline, as a secret.
trust_roots_fileA PEM bundle of trust roots, beside the platform’s, the node and its authorization server are trusted by.

The node and its authorization server are reached over one transport built with that material, so every request to either presents the same certificate. A node presented a certificate is reached over https alone, under every profile: an http node URL refuses to start, and an http token endpoint or issuer refuses the configuration. A section may name the TLS material alone, with no scheme, when the node authenticates the gateway by the certificate and asks for no Authorization header.

With that material, an oauth2 section can authenticate by the certificate in place of an assertion, and bind its tokens to it:

[credentials."cdr-f"]
client_identity_file = "/run/secrets/cdr-f-client.pem"
trust_roots_file = "/etc/ferrofed/cdr-f-roots.pem"

[credentials."cdr-f".oauth2]
grant = "client_credentials"                 # or token_exchange
client_auth = "tls_client_auth"              # or self_signed_tls_client_auth
token_endpoint = "https://auth.cdr-f.example.org/oauth2/token"
client_id = "ferrofed-gateway"
scope = "system/aql-*.s"
tls_client_certificate_bound_access_tokens = true
  • client_auth = "tls_client_auth" authenticates the gateway by the certificate, which the authorization server validates against its PKI and matches to the subject it registered for client_id (RFC 8705 §2.1); "self_signed_tls_client_auth" has it matched to the certificate it registered instead (§2.2). The token request then carries client_id and no client assertion. A token exchange still names the gateway as the actor with an assertion the [signing] key signs (RFC 8693 §2.1).
  • tls_client_certificate_bound_access_tokens = true takes only tokens bound to the certificate (§3). The token is sent under Bearer (RFC 6750), and only over the endpoint’s transport, which presents the certificate it is bound to. Where the token states its binding, as a cnf member of the token response or the cnf claim of a JWT access token, its x5t#S256 must be the SHA-256 thumbprint of the configured certificate (§3.1, §3.2); a token bound to another certificate is never cached or sent, and the node is node-error with nothing sent. An opaque token states no binding, and the node’s authorization server and the node check it.
  • Either key without client_identity_file refuses the configuration, and so does an identity file with no certificate in it. A section with both tls_client_certificate_bound_access_tokens and dpop_key_file refuses the configuration: a token is bound one way.

The client identity is a secret no rendering shows; the settings and the start-up log name the endpoints that present a certificate and the thumbprint a grant binds to, never the certificate or its key. A service’s own credentials section, under [[pixm.manager]] or [pdqm] for example, takes no TLS material: name it on the service’s table.

The Nuts grant (Annex B §B.4)

A nuts section makes the gateway authenticate to that node on the Nuts track of the Dutch Generic Functions, the regional realisation of §13.3 that Annex B §B.4 describes. The gateway is the holder: it presents its own Verifiable Credentials, signed as a presentation with its did:web key, and the node’s authorization server answers with a token bound to the gateway’s DPoP key. The wire is Nuts RFC021, the VP Token Grant Type:

  1. The gateway reads the authorization server’s metadata at the RFC 8414 well-known URL of authorization_server. The metadata must name that issuer exactly, a token_endpoint, a presentation_definition_endpoint (RFC021 §5) and vp_formats admitting jwt_vp with the holder key’s algorithm (RFC021 §3.1); when it lists dpop_signing_alg_values_supported, the DPoP key’s algorithm must be among them. The token and definition endpoints must be on the issuer’s origin (its scheme, host and port), so the credentials go nowhere else, and an answer whose objects repeat a name is refused. Write authorization_server in its canonical form, a lower-case host and no default port, since the metadata must name it as the same text.
  2. It reads the Presentation Definition for scope and maps each [[credential]] to the input descriptor it names. A credential for a descriptor the definition lacks, a descriptor left unanswered when the definition has no submission requirements, or a submission requirement the credentials do not meet stops the request before any credential is sent. The authorization server evaluates each descriptor’s constraints against the credentials it receives (RFC021 §4.1); the gateway does not.
  3. It signs a JWT Verifiable Presentation of every credential (VC Data Model 1.1 §6.3.1): iss and sub the gateway’s did, kid the DID URL kid, aud the issuer, nbf now and exp five seconds later, and a fresh nonce and jti (RFC021 §4.2). A credential whose exp has passed is never sent.
  4. It posts grant_type=vp_token-bearer with the presentation as assertion, the Presentation Submission, the scope and, when set, client_id, with a DPoP proof of dpop_key_file’s key. A demanded nonce is answered once, with a new presentation, since RFC021 §4.4 refuses a presentation nonce seen before.
  5. It takes the token only when its token_type is DPoP. The token is kept until 30 seconds before it expires and dropped when the node answers 401, as an oauth2 token is, and every request to the node carries it under the DPoP scheme with a proof of the same key (Tokens bound to a key).
KeyWhat it is
authorization_serverThe issuer identifier of the node’s authorization server (RFC 8414 §2). https outside the development profile.
scopeThe scope the authorization server maps to its Presentation Definition, space-delimited RFC 6749 §3.3 scope tokens.
client_idOptional; sent when the authorization server identifies its clients by one (RFC 6749 §3.2.1).
didThe gateway’s did:web identifier, the holder of the credentials.
kidThe DID URL of the holder’s key, <did>#<fragment>.
key_fileThe holder’s key, a P-256 (ES256) or P-384 (ES384) private key in PKCS#8 PEM.
dpop_key_fileThe key the tokens are bound to, as in an oauth2 section. Required: GF-Authentication sender-constrains every token (GFI-005).
[[credential]]One per credential: input_descriptor, the descriptor it answers, and file, the JWT-encoded credential, issued to did.

The gateway serves its DID document, so the authorization server can resolve the presentation’s kid and verify it (RFC021 §4.2; GFI-001). The document sits where the did:web method resolves the DID: the path is /.well-known/did.json for a DID with no path, and /<segment>/…/did.json for one with path segments, so did:web:gateway.example.org:nuts is served at /nuts/did.json. It is served at that path whatever the base URL, with no client authentication, like the JWK Set, as application/did+ld+json. Route https://<host><path> of the DID’s host to the gateway. The document is built from the holder keys the nuts sections of that did name and nothing else: one JsonWebKey2020 verification method per key, its id the kid, its publicKeyJwk the public half of key_file’s key, referenced from authentication and assertionMethod. A key change in key_file changes the served document on the next reload or restart, with no file to edit.

Two nuts sections may share a did. If they name one kid, they must hold one key. A DID whose document path would sit under {base}/v1/, or two DIDs whose documents share one path, refuse the configuration, since one gateway serves one document per path. A deployment whose DID names another host still has to serve that host’s path from the gateway, through its reverse proxy. The credentials are issued to the gateway by their authoritative sources ahead of time (GFI-002); the gateway reads them from their files at start and on each reload, and checks only that each is a JWT credential, with a vc claim, whose sub is did.

No log line, error or rendering carries a credential, the presentation, a key, a proof or the token: a refusal names the authorization server’s error code and at most 256 characters of its description. An oauth2 section and a nuts section for the same endpoint refuse the configuration.

The NVI’s Nuts grant

The same table under [nl_gf.nvi.credentials.nuts] authenticates the gateway to the NVI, the Localization Service of Dutch localization, as a data user on GF-Authentication (the IG’s Localization page, GFI-004, GFI-005):

[nl_gf.nvi.credentials.nuts]
authorization_server = "https://nuts.example.org/oauth2/nvi"
scope = "nl-gf-localization"
did = "did:web:gateway.example.org"
kid = "did:web:gateway.example.org#key-1"
key_file = "/run/secrets/nuts-holder.pem"
dpop_key_file = "/run/secrets/nvi-dpop.pem"

[[nl_gf.nvi.credentials.nuts.credential]]
input_descriptor = "organization_credential"
file = "/run/secrets/organization.jwt"

The token is obtained and cached as a node’s is, each token request bounded by the localization’s timeout_ms. Every search carries it under the DPoP scheme with a proof of dpop_key_file’s key over the search URL without its query, so the pseudonym never reaches a proof or the authorization server (RFC 9449 §4.2, §7.1). A 401 from the NVI drops the token, and a nonce it demands is answered once (RFC 9449 §9). A grant the authorization server refuses leaves the localization unavailable, so the query fails closed (§14.1). The token goes to the NVI alone, never to a node, a log line or an error. authorization_server must be https outside development, or boot is refused, naming its key. The NVI’s credentials take no oauth2 or fapi2 grant: the IG defines none for the Localization Service.

The FAPI 2.0 grant (Annex B §B.4a)

A fapi2 section makes the gateway authenticate to a node whose authorization server follows the FAPI 2.0 Security Profile, as the BgZ/eOverdracht track of the Dutch binding does (Annex B §B.4a, a VWS memo at concept v0.9). The track uses OAuth 2.0 client credentials with private_key_jwt client authentication, sender-constrained tokens, and the healthcare attributes in an RFC 9396 authorization_details object of type nl-gis-v1 (§B.4a.2, §B.4a.3):

[credentials."cdr-e".fapi2]
issuer = "https://as.cdr-e.example.org"
grant = "client_credentials"            # or token_exchange
client_id = "urn:oid:2.16.528.1.1007.3.3.<URA>"
client_key_file = "/run/secrets/fapi2-client.pem"
dpop_key_file = "/run/secrets/cdr-e-dpop.pem"
scope = "system/aql-*.s"                # optional when authorization_details is set
authorization_details = '''[{"type": "nl-gis-v1",
  "purpose_of_use": "http://terminology.hl7.org/CodeSystem/v3-ActReason|TREAT",
  "locations": ["https://cdr-e.example.org/openehr"],
  "locations_organization_id": "urn:oid:2.16.528.1.1007.3.3.<URA of the node>"}]'''
# resource = "https://cdr-e.example.org/openehr"   # required by token_exchange
# audience = "cdr-e"                               # optional
  1. At the first request to the node, the gateway reads the authorization server’s metadata at the RFC 8414 well-known URL of issuer, once, and keeps it; a read that fails is tried again on the next request. The metadata must name that issuer exactly, with a token_endpoint on the issuer’s origin, and an answer whose objects repeat a name is refused, as for the Nuts grant. It must also list:
    • private_key_jwt in token_endpoint_auth_methods_supported, and ES256 in token_endpoint_auth_signing_alg_values_supported (RFC 8414 §2: an omitted method list means client_secret_basic alone);
    • client_credentials in grant_types_supported, and token exchange under grant = "token_exchange" (an omitted list means authorization_code and implicit alone);
    • every type of authorization_details in authorization_details_types_supported (RFC 9396 §10);
    • ES256 in dpop_signing_alg_values_supported, when the list is there (RFC 9449 §5.1).
  2. It asks the token endpoint for a token with the client-credentials grant, or exchanges each verified caller’s token as an oauth2 grant does (A token per caller). The client assertion is signed ES256 with client_key_file’s key, and names the issuer, as one string, as its aud (FAPI 2.0 §5.3.3.1); the actor token of an exchange is signed the same way. The request carries scope when set and authorization_details as written.
  3. It takes the token only when its token_type is DPoP and, under authorization_details, when the answer states the details it granted (RFC 9396 §7). A refusal with invalid_authorization_details is reported with that code. Every request to the node carries the token under the DPoP scheme with a proof of dpop_key_file’s key (Tokens bound to a key).

The profile admits mutual TLS for both choices (§5.3.2.1), with the node section’s client_identity_file (Mutual TLS to a node). With client_auth = "tls_client_auth" or "self_signed_tls_client_auth" the metadata must list that method in place of private_key_jwt, and the token request carries client_id and no assertion. With tls_client_certificate_bound_access_tokens = true the metadata must state tls_client_certificate_bound_access_tokens as true (RFC 8705 §3.3), the token is taken as Bearer and held to the certificate as an oauth2 grant’s is, and no DPoP key is used. A grant that uses mutual TLS sends its token requests to the token_endpoint of mtls_endpoint_aliases where the metadata names one (RFC 8705 §5), held to the issuer’s origin like the other endpoint, and its issuer must be https.

RFC 8705 §5 lets a server put that alias on another host, as its own example does with mtls.example.com. The gateway sends there only when you name the host in the node’s [credentials."<id>"] section:

[credentials."cdr-e"]
client_identity_file = "/run/secrets/cdr-e-client.pem"
mtls_alias_hosts = ["mtls.cdr-e.example.org"]   # or "mtls.cdr-e.example.org:8443"

[credentials."cdr-e".fapi2]
issuer = "https://as.cdr-e.example.org"
client_auth = "tls_client_auth"
tls_client_certificate_bound_access_tokens = true
# ... as above

Each entry is a host name, or a host name and a port, in lower case and without a scheme or a path; port 443 is left out. An alias on a named host is taken over https alone, without userinfo or a fragment. An alias on any other host is refused before a token request is sent, as the top-level token_endpoint on another origin always is. The key is refused for a section without a fapi2 grant that uses mutual TLS, since no other grant reads mtls_endpoint_aliases. Which hosts to trust is FerroFED’s own design: no specification names them.

KeyWhat it is
issuerThe issuer identifier of the node’s authorization server (RFC 8414 §2), in its canonical form. https outside the development profile.
grantclient_credentials or token_exchange. authorization_code refuses the configuration.
client_idThe client the server registered the gateway as, the assertion’s iss and sub. On the §B.4a track, the organisation’s URA-based identifier.
client_authprivate_key_jwt, when unset, or tls_client_auth or self_signed_tls_client_auth, the node section’s certificate (Mutual TLS to a node).
client_key_fileThe key every assertion is signed with: a P-256 private key in PKCS#8 PEM. The profile admits PS256, ES256 and EdDSA for a JWT (§5.4.1), so a P-384 key cannot sign here. Required with private_key_jwt, and with token_exchange for the actor token.
previous_client_key_fileOptional, while the client key is rotated: the previous client key, a P-256 private key in PKCS#8 PEM. It is published beside the current key until you remove it, and it never signs.
dpop_key_fileA P-256 private key in PKCS#8 PEM. Required unless the tokens are bound to the certificate: the profile issues only sender-constrained tokens (§5.3.2.1).
tls_client_certificate_bound_access_tokenstrue binds the tokens to the node section’s certificate in place of DPoP (§5.3.2.1; RFC 8705 §3). Never beside dpop_key_file.
scopeOptional SMART on openEHR system scopes, as in an oauth2 section.
authorization_detailsOptional JSON text: an array of RFC 9396 §2 objects, each with a type. It is sent as written.
resource, audienceAs in an oauth2 section; resource is required by token_exchange.

scope and authorization_details are each optional, and one of them is required, so the server never chooses what a token may do (FAPI 2.0 §5.3.3.1: least privilege). A fapi2 section needs [signing]: the gateway publishes the public half of client_key_file’s key in its JWK Set, beside the [signing] keys, and the authorization server verifies the assertion against it (§5.4.2; Annex B §B.4a.2), and assertion_lifetime_s sets how long each assertion lives. To make the two keys:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out fapi2-client.pem
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out cdr-e-dpop.pem

To rotate the client key, make a new P-256 key, set it as client_key_file, move the old one to previous_client_key_file, and reload or restart. The new key signs every assertion from then on, and the JWK Set publishes both keys, the new one first. An authorization server that still holds the JWK Set from before the rotation does not know the new key, so it refuses the new assertions until it fetches the set again; the set it fetches then still holds the old key, so an assertion the old key signed before the rotation verifies too. Once every authorization server that verifies the grant has fetched the new set, remove previous_client_key_file. A previous key that is no P-256 key, or that is the current key, refuses the configuration, naming the key.

client_key_file = "/run/secrets/fapi2-client-2026-10.pem"
previous_client_key_file = "/run/secrets/fapi2-client.pem"

What the gateway does not do on this track:

  • An authorization-endpoint flow. The authorization code grant needs a user agent to redirect, with pushed authorization requests (RFC 9126) and PKCE, which FAPI 2.0 requires of those flows (§5.3.2.2, §5.3.3.2). A server-to-server gateway has no user agent, so grant = "authorization_code" refuses the configuration. The client-credentials grant is admitted under the profile’s general requirements (§5.3.2.1 Note 2).
  • Purpose of use per caller. purpose_of_use and subject_organisation_type travel as the configured authorization_details of the endpoint, the same for every request, as a declaration of the gateway’s organisation (§B.4a.3). Record that answer to §13.4 for the deployment (The §13.4 deployment decisions).
  • Verifying the access token. The issuing organisation signs the access token (§B.4a.2); the node verifies it, not the gateway.

Signing keys and the JWK Set

[signing] holds the gateway’s signing keys: P-256 or P-384 private keys in PKCS#8 PEM, each read from a file. The curve decides the algorithm: a P-256 key signs ES256 and a P-384 key ES384 (RFC 7518 §3.4), and the JWK Set publishes each key with its alg. It is required whenever a registry is configured, by registry.document or by [registry.mcsd]: every request to a node carries the caller’s identity in an openEHR-federation-client token signed with the current key (Client authentication; §13.1, N24), and a federating gateway without the key refuses to start, naming it. The current key signs every client assertion of an oauth2 grant too. A key on another curve refuses the configuration, naming the key. To make a key:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out ferrofed-signing-key.pem

Choose P-256 when a node holds to the FAPI 2.0 Security Profile, which admits PS256, ES256 and EdDSA for a JWT and not ES384 (§5.4.1), so such a node may refuse an ES384 token. The one key then signs ES256 for every node:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ferrofed-signing-key.pem

The gateway serves its public keys as a JWK Set (RFC 7517) at GET {base}/.well-known/jwks.json, with no client authentication, and declares jwks_uri as federation.auth.jwks_uri in OPTIONS {base}/ (§13.1, N30). Point jwks_uri at that route on the gateway’s public address, or at wherever your deployment publishes the keys. Each key’s kid is its RFC 7638 thumbprint, so the same key always has the same kid. The set also publishes the ES256 client key of every fapi2 section, and its previous client key while it is rotated, after the [signing] keys; a reload that changes a fapi2 key publishes the new one.

Rotating the signing key

[signing] holds up to three keys, and the JWK Set publishes each with its own kid:

KeySignsPublished
key_fileevery tokenalways
previous_key_fileneverfor rotation_overlap_s from the start of the process
next_key_fileneveralways

A node verifies a token by its kid (RFC 7515 §4.1.4) against the JWK Set it fetched (RFC 7517 §5), and may hold that set for its cache time. A key must therefore be in the set a node holds before any gateway signs with it, and stay there until every token it signed has expired. The tokens the gateway signs live at most assertion_lifetime_s (the client assertion, 300 s at most) or 60 s (the openEHR-federation-client token), whichever is longer.

The procedure assumes that no node caches the JWK Set longer than node_jwks_cache_s (3600 s by default). Set it to the longest cache time of your nodes and their authorization servers; config check refuses a rotation_overlap_s shorter than assertion_lifetime_s plus node_jwks_cache_s. The three steps hold for one gateway and for any number of replicas behind one address:

  1. Publish the new key. Make the new key, copy it to every replica, set it as next_key_file, and restart the replicas one by one. Each restarted replica publishes the current key and the new one, and still signs with the current key. When the last replica runs the new configuration, wait node_jwks_cache_s more, so every node’s cached set came from a replica that publishes the new key.
  2. Sign with it. Set the new key as key_file, move the old one to previous_key_file, remove next_key_file, and restart the replicas one by one. A restarted replica signs with the new key. During the restart every replica publishes both keys: a restarted one as current and previous, one not yet restarted as current and next. A replica publishes the previous key for rotation_overlap_s from its own start, so finish this rolling restart within rotation_overlap_s minus the longest token lifetime: 3600 s with the defaults. Raise rotation_overlap_s before this step for a slower rollout.
  3. Retire the old key. Once rotation_overlap_s has passed since the last replica restarted, no replica publishes the old key and no token it signed is still valid. Remove previous_key_file at your next restart.

The new key signs with its own algorithm, so a rotation can also move the gateway from ES384 to ES256 or back. A next key on another curve than the current key refuses the configuration, naming signing.next_key_file, unless next_key_algorithm names the algorithm you mean it for:

[signing]
key_file = "/run/secrets/ferrofed-signing-key.pem"            # P-384, signs ES384
next_key_file = "/run/secrets/ferrofed-signing-key-next.pem"  # P-256
next_key_algorithm = "ES256"                                  # the move to ES256 is intended

A next key that is already the current or the previous key is refused as well. A change to [signing] takes effect only on a restart; a reload reports it.