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:
scopeis space-separated SMART on openEHR scopes, each a resource scope of thesystemcompartment (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 inc,r,u,d,sorder and one space between scopes:system/aql-*.sris requested assystem/aql-*.rs.token_endpointis anhttpsURL with no user name, password, query or fragment;httpis accepted only underprofile = "development"(What must travel encrypted).assertion_audienceistoken_endpoint, the default, orissuer. RFC 7523 §3 admits either as the assertion’saud. An authorization server under the FAPI 2.0 Security Profile accepts only its issuer identifier, as one string (§5.3.2.1). Withissuer, setissuerto that identifier, anhttporhttpsURL with no query or fragment in its canonical form (RFC 8414 §2);issuerbeside 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-errorand 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-errorand is sent nothing. Withoutscopethe 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 theopenEHR-federation-clienttoken. - 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:
| Key | What it is |
|---|---|
client_identity_file | The 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_file | A 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 forclient_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 carriesclient_idand 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 = truetakes only tokens bound to the certificate (§3). The token is sent underBearer(RFC 6750), and only over the endpoint’s transport, which presents the certificate it is bound to. Where the token states its binding, as acnfmember of the token response or thecnfclaim of a JWT access token, itsx5t#S256must 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 isnode-errorwith nothing sent. An opaque token states no binding, and the node’s authorization server and the node check it.- Either key without
client_identity_filerefuses the configuration, and so does an identity file with no certificate in it. A section with bothtls_client_certificate_bound_access_tokensanddpop_key_filerefuses 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:
- 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, atoken_endpoint, apresentation_definition_endpoint(RFC021 §5) andvp_formatsadmittingjwt_vpwith the holder key’s algorithm (RFC021 §3.1); when it listsdpop_signing_alg_values_supported, theDPoPkey’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. Writeauthorization_serverin its canonical form, a lower-case host and no default port, since the metadata must name it as the same text. - It reads the Presentation Definition for
scopeand 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. - It signs a JWT Verifiable Presentation of every credential (VC Data
Model 1.1 §6.3.1):
issandsubthe gateway’sdid,kidthe DID URLkid,audthe issuer,nbfnow andexpfive seconds later, and a freshnonceandjti(RFC021 §4.2). A credential whoseexphas passed is never sent. - It posts
grant_type=vp_token-bearerwith the presentation asassertion, the Presentation Submission, thescopeand, when set,client_id, with aDPoPproof ofdpop_key_file’s key. A demanded nonce is answered once, with a new presentation, since RFC021 §4.4 refuses a presentation nonce seen before. - It takes the token only when its
token_typeisDPoP. The token is kept until 30 seconds before it expires and dropped when the node answers401, as anoauth2token is, and every request to the node carries it under theDPoPscheme with a proof of the same key (Tokens bound to a key).
| Key | What it is |
|---|---|
authorization_server | The issuer identifier of the node’s authorization server (RFC 8414 §2). https outside the development profile. |
scope | The scope the authorization server maps to its Presentation Definition, space-delimited RFC 6749 §3.3 scope tokens. |
client_id | Optional; sent when the authorization server identifies its clients by one (RFC 6749 §3.2.1). |
did | The gateway’s did:web identifier, the holder of the credentials. |
kid | The DID URL of the holder’s key, <did>#<fragment>. |
key_file | The holder’s key, a P-256 (ES256) or P-384 (ES384) private key in PKCS#8 PEM. |
dpop_key_file | The 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
- 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 atoken_endpointon 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_jwtintoken_endpoint_auth_methods_supported, andES256intoken_endpoint_auth_signing_alg_values_supported(RFC 8414 §2: an omitted method list meansclient_secret_basicalone);client_credentialsingrant_types_supported, and token exchange undergrant = "token_exchange"(an omitted list meansauthorization_codeandimplicitalone);- every
typeofauthorization_detailsinauthorization_details_types_supported(RFC 9396 §10); ES256indpop_signing_alg_values_supported, when the list is there (RFC 9449 §5.1).
- It asks the token endpoint for a token with the client-credentials
grant, or exchanges each verified caller’s token as an
oauth2grant does (A token per caller). The client assertion is signed ES256 withclient_key_file’s key, and names the issuer, as one string, as itsaud(FAPI 2.0 §5.3.3.1); the actor token of an exchange is signed the same way. The request carriesscopewhen set andauthorization_detailsas written. - It takes the token only when its
token_typeisDPoPand, underauthorization_details, when the answer states the details it granted (RFC 9396 §7). A refusal withinvalid_authorization_detailsis reported with that code. Every request to the node carries the token under theDPoPscheme with a proof ofdpop_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.
| Key | What it is |
|---|---|
issuer | The issuer identifier of the node’s authorization server (RFC 8414 §2), in its canonical form. https outside the development profile. |
grant | client_credentials or token_exchange. authorization_code refuses the configuration. |
client_id | The 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_auth | private_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_file | The 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_file | Optional, 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_file | A 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_tokens | true 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. |
scope | Optional SMART on openEHR system scopes, as in an oauth2 section. |
authorization_details | Optional JSON text: an array of RFC 9396 §2 objects, each with a type. It is sent as written. |
resource, audience | As 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_useandsubject_organisation_typetravel as the configuredauthorization_detailsof 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:
| Key | Signs | Published |
|---|---|---|
key_file | every token | always |
previous_key_file | never | for rotation_overlap_s from the start of the process |
next_key_file | never | always |
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:
- 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, waitnode_jwks_cache_smore, so every node’s cached set came from a replica that publishes the new key. - Sign with it. Set the new key as
key_file, move the old one toprevious_key_file, removenext_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 forrotation_overlap_sfrom its own start, so finish this rolling restart withinrotation_overlap_sminus the longest token lifetime: 3600 s with the defaults. Raiserotation_overlap_sbefore this step for a slower rollout. - Retire the old key. Once
rotation_overlap_shas passed since the last replica restarted, no replica publishes the old key and no token it signed is still valid. Removeprevious_key_fileat 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.