Upgrading from 8.x to 9.0
EUDIPLO 9.0 tightens the OAuth, OID4VCI and OID4VP flows, enforces session expiry, limits sessions, credential status changes, key export and KMS configuration to the matching roles, reports failed presentations to webhooks, and changes defaults of the outbound URL policy, schema synchronization and the bundled deployments. Each section below says what changed and what to do; skip the sections that do not apply to you.
Before you upgrade
- Upgrade from any 8.x release. On 7.x or older, upgrade to 8.x first.
- Follow the upgrade procedure: back up the database, configuration, uploads and encryption key material. The 9.0 migrations change stored sessions and presentation configurations; going back means restoring that backup.
- Use the 9.0 images for backend and client together.
| If you … | Read |
|---|---|
| run the bundled Compose files, Kubernetes manifests or the monitoring stack | Operators |
| call the management API, assign roles to API clients, receive webhooks or use the session event stream | Integrators |
| issue or verify credentials with wallets | Wallet-facing behavior |
| keep tenant configuration as files or exported bundles | Configuration files |
Operators
Container images moved to ghcr.io/eudiplo
Changed: EUDIPLO is now an LF Decentralized Trust project, and its repository moved from openwallet-foundation/eudiplo to EUDIPLO/eudiplo. The images are published as ghcr.io/eudiplo/eudiplo, ghcr.io/eudiplo/eudiplo-client and ghcr.io/eudiplo/eudiplo-demo. The ghcr.io/openwallet-foundation/* images receive no new releases.
Do: with the EUDIPLO CLI, eudiplo upgrade moves the instance to the new images. In any other deployment, change the image name together with the tag, for example EUDIPLO_IMAGE and EUDIPLO_CLIENT_IMAGE in the Compose env file or image: in the Kubernetes manifests.
Outbound URLs: HTTP and private networks are blocked by default
Changed: OUTBOUND_URL_ALLOW_HTTP and OUTBOUND_URL_ALLOW_PRIVATE_NETWORK default to false in every environment. In 8.x they were true unless NODE_ENV=production. The policy covers webhooks, attribute providers, metadata imports and, new in 9.0, the rulebook and schema downloads of schema metadata publishing, trust lists, status lists, OpenID Federation entity configurations, CRLs, and the metadata, keys, token and introspection endpoints of external authorization servers and of the chained server's upstream provider; metadata imports now also honor OUTBOUND_URL_ALLOWED_HOSTS. Trust lists, status lists, federation entities and authorization server keys on EUDIPLO's own PUBLIC_URL or INTERNAL_URL are exempt, and CRLs may use plain HTTP. The token request to an upstream provider no longer follows redirects.
Do: if EUDIPLO calls services over plain HTTP or on private or loopback addresses (typical in development and in Compose networks, for example http://webhook:8787, a Keycloak at http://keycloak:8080 as upstream provider, or a trust list, status list or CRL on an internal host), set the matching variable to true, or move the services to HTTPS on public addresses.
CRLs of your own certificates must be signed by the issuing CA
Changed: before a key chain signs, EUDIPLO checks its leaf certificate against the CRL the certificate names. 9.0 counts a CRL only if it names the leaf's issuer and is signed by the issuing CA certificate from the key chain. 8.x accepted any CRL the distribution point returned, so anyone on the network path could stop a key chain from signing with a forged CRL, or hide a revocation. A CRL that fails these checks is ignored like an unreachable one: the key chain keeps signing and the log shows a warning. Self-signed certificates are no longer checked against a CRL.
Do: nothing for key chains that EUDIPLO created; their certificates name no CRL. If an imported certificate names a CRL distribution point, put its issuing CA certificate after the leaf in crt; otherwise the log shows Cannot check CRL and revocation is not checked. See Revocation check.
Bundled object storage: MinIO replaced by RustFS
Changed: the Compose file in deployment/docker-compose/, the template of new CLI projects and the Kubernetes manifests run RustFS 1.0.0 instead of MinIO: service rustfs, endpoint http://rustfs:9000, credentials RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY, a new rustfs-data volume or PVC, and a bucket job that creates S3_BUCKET. Existing CLI projects keep their Compose file and env file; eudiplo upgrade does not switch them.
Do, if you adopt the new files and have objects in MinIO (copy through the S3 API; do not mount the MinIO data directory into RustFS):
-
Start RustFS with an empty volume next to the running MinIO.
-
Copy the bucket with a tool that preserves object metadata, for example:
mc alias set old http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"mc alias set new http://rustfs:9000 "$RUSTFS_ACCESS_KEY" "$RUSTFS_SECRET_KEY"mc mirror --preserve old/uploads new/uploads -
Compare object counts and spot-check downloads (
GET /storage/<key>). -
Replace
MINIO_ROOT_USER/MINIO_ROOT_PASSWORDwithRUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY, setS3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEYto the same values andS3_ENDPOINT=http://rustfs:9000. -
Keep MinIO and its volume until EUDIPLO works against RustFS.
The bucket job keeps the anonymous download policy (s3:GetObject) of the MinIO setup. EUDIPLO itself works with any S3-compatible storage, so you can also keep MinIO and point S3_ENDPOINT at it.
TLS fails closed
Changed: with TLS_ENABLED=true, a missing or unreadable TLS_CERT_PATH, TLS_KEY_PATH or TLS_CA_PATH stops the startup. 8.x silently served plain HTTP. TLS_CA_PATH adds intermediate certificates to the served chain; it never enabled client certificate verification (mTLS) and still does not.
Do: check the paths and file permissions before the upgrade. If you relied on TLS_CA_PATH for mTLS, terminate mTLS in a reverse proxy. See TLS.
Schema changes only through migrations
Changed: DB_SYNCHRONIZE defaults to false. In 8.x it defaulted to true, so TypeORM also aligned the schema with the entity definitions at every start. 9.0 changes the schema only through migrations (DB_MIGRATIONS_RUN=true, the default), which bring an 8.x database up to date.
Do: nothing if you used the defaults. Remove DB_SYNCHRONIZE=true from production env files: it is meant for development, and synchronization drops and re-creates columns whose type differs from the entity, losing their values. With DB_MIGRATIONS_RUN=false, run the migrations before you start 9.0. See Database.
Removed variables and scripts
Changed: KM_TYPE, VAULT_NAMESPACE and VAULT_MOUNT_PATH are gone from the templates and manifests; the backend never read them. monitor/configure-prometheus.sh and prometheus-flexible.yml are removed; they scraped a /metrics endpoint the backend does not have.
Do: delete the three variables from your env files. Vault signing keys are configured in kms.json (vaultUrl, vaultToken; see KMS). Metrics reach Prometheus through the OpenTelemetry collector of the monitor/ stack.
sessions metric is a gauge
Changed: sessions is an observable gauge read from the database (refreshed at most every 30 s) and labeled tenant_id, session_type, status. Every replica reports the same values.
Do: aggregate with max by (tenant_id, session_type, status) before summing, as the dashboards and rules in monitor/ now do. A plain sum counts every session once per replica. Presentations also report the status fetched.
Integrators
Presentation webhooks also report failures
Changed: the presentation webhook fires for completed, failed and declined presentations. Every call carries status (completed or failed) and the structured outcome; failed calls contain no credentials. A redirectUri returned by the webhook is also used after a failure. Delivery errors never change the session result. Failed mDOC verifications now carry a failureCode, as SD-JWT VC failures already did.
Do: check status before you read credentials. Payload: Webhooks.
Clients see only the sessions of their side
Changed: GET /api/session, GET /api/session/{id}, GET /api/session/{id}/logs and DELETE /api/session/{id} only cover the sessions that match the client's roles: issuance sessions need issuance:offer or issuance:manage, presentation sessions presentation:request or presentation:manage. In 8.x every client with issuance:offer or presentation:request saw all sessions of its tenant, including issued claims and presented credentials. Sessions of the other side are missing from the list (a type filter for them returns an empty page), reads and logs answer 404, and DELETE answers 204 without deleting them.
Do: give a client that must read both kinds of sessions, for example a dashboard or support tool, a role of each side. See API clients with least privilege.
The session event stream needs the Authorization header and ends
Changed:
GET /api/session/{id}/eventstakes the access token from theAuthorizationheader only and is authorized likeGET /api/session/{id}:issuance:offerorpresentation:request, scoped to the client's side; unknown and out-of-scope sessions answer 404. A token in the query string (?token=, as the 8.x documentation and SDK sent it) is rejected with 401. In 8.x the stream answered every subscription with 401, so it could not be used at all.- The stream sends the current status first and completes after a terminal status (or when the session is deleted). Status changes processed by another replica now reach the stream.
- In
@eudiplo/sdk-core9.0,subscribeToSession()andwaitForSessionWithSse()read the stream withfetchand the client's token (Node.js 18+ and browsers) and stop after three failed connection attempts. 8.x versions of the SDK usedEventSourcewith the token in the URL.
Do: read the stream from your backend and send the token in the header, for example curl -N "$EUDIPLO/api/session/$SESSION/events" -H "Authorization: Bearer $TOKEN". The browser's EventSource cannot send the header, and the management token must not reach the browser: pass the status on to your page instead. Upgrade @eudiplo/sdk-core to 9.0. Treat the end of the stream as final; do not reconnect after a terminal status. Expect fetched for presentations (the wallet fetched the request), not only for issuance. See Server-Sent Events.
Sessions expire when their time is up
Changed: expiry is checked at request time. A wallet that uses an offer or presentation request after expiresAt, or after it was completed, gets HTTP 400 or 404, or OAuth invalid_grant. Presentation requests expire after the presentation config's lifeTime (default 300 s). expiresAt is stored as a full timestamp instead of a date. Credential offers get an expiresAt only when you set the new offerLifetimeSeconds on the issuance configuration or on the offer request; a maintenance job then expires unredeemed offers. Independently, pre-authorized codes are rejected after the session TTL (SESSION_TTL, default 24 h).
Do: create the offer or request when the user is ready to scan it, and handle the expired state in your UI. Set lifeTime higher if users need more time. Read expiresAt as a timestamp.
Issued claims are validated
Changed: before signing, claims from every source (offer, attribute provider, webhook) are validated against the claim definitions of the credential configuration: missing, mistyped, unknown and invalid nested claims are rejected. POST /api/issuer/offer with invalid inline claims returns HTTP 409; claims from an attribute provider that fail validation end the wallet's credential request with credential_request_denied.
Do: compare what your attribute provider returns with the credential configuration's fields (Claims) and test one issuance per configuration.
DCQL claim values are checked
Changed: in OpenID4VP flows a claim query with values only accepts a disclosed claim that equals one of them in type and value; otherwise the presentation fails with failureCode claim_value_mismatch. 8.x passed values to the wallet but completed the session with whatever the wallet disclosed. values now also accepts integers and booleans (8.x only strings) and must not be empty.
Do: check that every values entry has the type the credential uses, for example [true] instead of ["true"] for age_equal_or_over. Handle claim_value_mismatch like a negative answer. See DCQL.
Stricter management API validation
| Change | Do |
|---|---|
activeCredentials without statusManagement: true is rejected with HTTP 400 (8.x rejected it only on file import). Stored configurations still load, with a warning. | Enable statusManagement or remove activeCredentials. |
registration_cert.body.provided_attestations in presentation configurations is rejected. A database migration removes it from stored configurations. | Use provides_attestations: an array of the credential types (SD-JWT VC vct or mdoc doctype) you provide. |
POST /api/session/revoke with status: 2 (suspended) on a status list with 1 bit per entry is rejected with HTTP 400. In 8.x it was accepted and corrupted the published list: the suspended credential stayed valid and up to seven neighbouring entries changed status. | Use lists with 2 or more bits for suspension (STATUS_BITS, the tenant's status list config, or bits when creating a list). See Lists corrupted by suspension in 8.x. |
POST /api/session/revoke rejects reinstating or suspending a revoked credential (1 → 0 or 2) with HTTP 409; revocation is final. Suspensions can still be lifted. | Issue a new credential instead of reinstating a revoked one. |
POST /api/session/revoke requires issuance:offer or issuance:manage; clients with only presentation:request get 403. | Give the client that changes credential status an issuance role. |
| Concurrent updates of the same trust list return HTTP 409. Managed lists are re-signed automatically before they expire. | Retry with the current state. |
POST /issuers/{tenant}/authorize/interactive/complete-web-auth/{authSession} requires a management token with issuance:offer of the tenant and only completes a session waiting in the redirect_to_web step. | Call it from your backend with a client token. |
federation.role other than leaf and federation.enforceSigningPolicy: false in the issuance configuration are rejected with HTTP 400; in 8.x they were stored without effect. | Remove both fields or set role: "leaf" and enforceSigningPolicy: true. |
Key export and KMS configuration need tenant:admin
| Change | Do |
|---|---|
GET /api/key-chain/{id}/export requires tenant:admin or tenants:manage instead of issuance:manage or presentation:manage, because it returns the private key of db key chains. Key chains of an external KMS are exported with the public key only. The web client shows the export only to these roles, and View as JSON no longer shows the private key. | Grant tenant:admin to the clients that export keys. |
GET, PUT and DELETE /api/key-chain/providers/config (tenant KMS provider configuration, which contains provider credentials) require tenant:admin or tenants:manage. Listing providers and their health keeps the old roles. | Grant tenant:admin to the clients and users that manage KMS providers. |
GET /api/key-chain/providers/config returns credentials as <redacted> (stored ${ENV_VAR} placeholders of the tenant file as they are); in 8.x effectiveConfig contained the resolved credentials, also of the global kms.json. A PUT keeps a stored credential sent as <redacted> and rejects <redacted> for one that is not stored. | Send credentials you change; send <redacted> back for the others. Do not save effectiveConfig as the tenant configuration (KMS). |
Authorization server settings take effect
Changed:
- The built-in authorization server applies
token.lifetimeSeconds(default 300),token.signingKeyId(also published in its JWKS) andrequireDPoP(token endpoint and PAR). Configurations saved with an 8.x web client storetoken.lifetimeSeconds: 3600, so their access tokens now live one hour. - One refresh token policy for the built-in, chained and OID4VP authorization servers: refresh tokens are issued unless
token.refreshTokenEnabledisfalse, live fortoken.refreshTokenExpiresInSeconds(default 30 days) and keep their original expiry when used. Chained and OID4VP servers therefore issue refresh tokens now; existing refresh tokens without a stored expiry expire 30 days after the session was created. The refresh grant is only advertised when enabled; otherwise refresh requests getunsupported_grant_type. - The OID4VP-backed chained server (
vpon achainedentry, served under/api/issuers/{tenant}/chained-as-vp/*) is removed. Storedchainedentries withvpand withoutupstreamare skipped with a warning; entries withupstreamkeep working withoutvp. Create, update and import rejectvp.
Do: check authorizationServers in each issuance configuration: remove token.lifetimeSeconds from built-in entries or set it to the lifetime you want; set refreshTokenEnabled: false where wallets must not refresh; replace vp entries with an oid4vp server:
{
"type": "oid4vp",
"id": "pid-login",
"presentationConfigId": "pid"
}
Wallet-facing behavior
These changes need no configuration but can break wallets or test tools that relied on the 8.x behavior. Test your wallets against 9.0 before you upgrade production.
| Area | 9.0 behavior |
|---|---|
| PKCE | Every authorization_code grant needs an S256 code_challenge and the matching verifier: built-in, chained, OID4VP and interactive authorization. plain and missing methods are rejected. |
| PAR (built-in server) | redirect_uri is required. request_uri and authorization codes are valid for 60 s. A redirect_uri sent to the token endpoint must equal the one from PAR. |
| DPoP | Proofs are verified (RFC 9449: signature, typ, alg, htm, htu, iat) and a jti is accepted only once; invalid proofs get invalid_dpop_proof. A key bound at PAR must sign the proofs at the token and refresh requests. |
| Codes | An authorization code is only accepted with the authorization_code grant, a pre-authorized code only with the pre-authorized grant. Pre-authorized codes expire with the session TTL (SESSION_TTL or the tenant's ttlSeconds). A wrong tx_code returns invalid_grant and counts towards the lockout. |
| Sessions | Offers and presentation requests are rejected after expiry or completion (above). |
| Interactive authorization | S256 PKCE only. openid4vp_response must be the encrypted OpenID4VP authorization response and is fully verified; issuer_state must name a redeemable authorization-code offer. Codes expire after 60 s and each auth session yields one code. |
| OID4VP authorization server | /authorize returns an HTML page with an Open wallet link instead of a redirect to openid4vp://, because Android Custom Tabs drop redirects without a user gesture. |
| OID4VP responses | Every vp_token entry must be a non-empty array. More than one presentation per credential query needs multiple: true in the DCQL query. Error responses from the wallet are answered with HTTP 200 and recorded on the session. |
| Trusted authorities | etsi_tl entries of DCQL trusted_authorities become aki values of the issuer certificates listed in the trust list (8.x used the list's signing certificate), so wallets stop hiding valid credentials. Lists that cannot be matched by key identifier are passed as etsi_tl URLs. |
| Notifications and deferred | The notification endpoint works with tokens of every authorization server type and answers unknown ids with invalid_notification_id. A deferred transaction_id can only be redeemed with a token of the session that created it. |
| Key attestations | keyAttestationsRequired of a credential configuration is enforced: every proof needs a trusted key attestation that covers the proven keys and states an accepted key_storage and user_authentication level; otherwise the request fails with invalid_proof. In 8.x it was only published, so jwt proofs without key_attestation were accepted. Check configurations with keyAttestationsRequired against the wallets you support (Wallet and key attestation). |
| OpenID Federation trusted authorities | A DCQL credential query whose only trusted_authorities entry is openid_federation rejects issuers that do not chain to one of its trust anchors; in 8.x the issuer was not checked. With an etsi_tl entry, the trust list still decides (OpenID Federation). |
Configuration files
Changed: two resources have a new file format version:
| Resource | 9.0 format | Change |
|---|---|---|
IssuanceConfig | https://eudiplo.dev/schemas/v2/IssuanceConfigFile.schema.json | Optional offerLifetimeSeconds |
PresentationConfig | https://eudiplo.dev/schemas/v3/PresentationConfigFile.schema.json | provides_attestations replaces provided_attestations (v2); DCQL claim values accept integers and booleans and must not be empty (v3) |
9.0 imports older files and upgrades them on the fly; the v1 → v2 step of presentation configurations drops provided_attestations with the warning PROVIDED_ATTESTATIONS_REMOVED, and the v2 → v3 step stops on an empty values array. Files and bundles exported from 9.0 use the new versions and cannot be imported into 8.x.
Do:
-
Rewrite configuration folders kept in Git once, so diffs stay clean and the warning disappears:
eudiplo config upgrade ./config --dry-run # show the changeseudiplo config upgrade ./config --output ./config-v9 # then replace ./config with it -
Add
provides_attestationswhere a presentation configuration requests a registration certificate. -
Replace
vponchainedauthorization servers with anoid4vpserver (above); the import rejectsvp. -
Remove
activeCredentialsfrom credential configurations withoutstatusManagement; the import rejects the combination. -
Remove
federation.rolevalues other thanleafandfederation.enforceSigningPolicy: falsefrom issuance configurations; the import rejects them. -
Keep
keyAttestationsRequiredonly in credential configurations whose wallets send key attestations (above). -
Keep an 8.x export from before the upgrade if you might need to restore 8.x.
Lists corrupted by suspension in 8.x
If you suspended credentials (status: 2) on 1-bit status lists in 8.x, those lists were published with wrong values for the suspended and neighbouring credentials. 9.0 does not publish such a list anymore and logs a warning at startup for every affected entry (tenant, list, index, session and credential configuration). For each logged session, call POST /api/session/revoke with status: 1 (revoke) or 0 (lift the suspension). The next published token is then correct for all entries. Alternatively, re-issue the affected credentials on a list with 2 bits per entry. Verifiers may still hold a cached corrupted token until its ttl ends.
After the upgrade
GET /healthisokand the startup log shows the 9.0 migrations, no configuration import errors and no unexpectedSKIP_*warnings.- Webhooks, attribute providers, metadata imports, trust lists, status lists and external or upstream authorization servers are reachable (watch for outbound URL policy rejections in the log).
- API clients still see the sessions they need, and your backend reads the session event stream with the
Authorizationheader. - One issuance per authorization server type and one presentation complete with the wallets you support; a declined presentation reaches your webhook with
status: "failed". - Uploaded images and logos still load (
GET /storage/<key>) if you moved to RustFS. - Managed trust lists that expired under 8.x are renewed at startup; check the trust list views or
GET /issuers/{tenant}/trust-list/{id}. - Dashboards and alerts on
sessionsusemax by (...).