Skip to content

Two OID4VCI interop gaps in ssi-agent 1.0.0-beta.17 (issuer metadata + credential request shape) #336

Description

@timzwier

Description

While integrating ssi-agent into a real Laravel application with public-internet wallets (Sphereon Wallet, UniMe), I hit two server-side OID4VCI interop gaps. Either of them is enough on its own to break a wallet flow; together they break it twice.

I currently work around both by putting a Laravel/Caddy translation shim in front of the agent. With the shim, Sphereon Wallet completes credential issuance end-to-end. Without the shim, it doesn't reach the credential endpoint.

Filing here on the agent because both are server-side: the metadata response and the credential-request deserializer.


Environment

  • Image: impiercetechnologies/ssi-agent:1.0.0-beta.17 (org.opencontainers.image.revision=2e7763efe50be245fffd9e142127927d0251a55d)
  • Profile: UNICORE__PROFILE=development
  • Event store: MongoDB
  • Public URL: https://ssi-agent.example.com (Caddy + Let's Encrypt)
  • Test wallet that does work after the shim: Sphereon Wallet 804 (iOS)

A custom my_custom_credential credential configuration is registered at runtime via POST /v0/credential-configurations (format: jwt_vc_json).


Issue 1 — Issuer metadata is missing fields current wallets expect

Reproduction

curl -s https://your-agent/.well-known/openid-credential-issuer | jq 'keys'
# ["credential_configurations_supported", "credential_endpoint", "credential_issuer", "display"]

The response does not include nonce_endpoint, authorization_servers, or token_endpoint, even though the agent already routes:

  • POST /openid4vci/nonce
  • GET /.well-known/oauth-authorization-server (which does carry the token endpoint)

Why it matters

Per OpenID4VCI 1.0:

  • nonce_endpoint is REQUIRED in issuer metadata when the wallet is expected to retrieve a server-generated nonce before calling the credential endpoint.
  • authorization_servers is RECOMMENDED so wallets know which authorization-server-metadata document to fetch.

Concrete observed behaviour:

Wallet Behaviour against agent without the shim
Sphereon 804 (iOS) Falls back to GET /.well-known/oauth-authorization-server, recovers the token endpoint that way, eventually succeeds. Lenient parser.
UniMe 0.13.2 (iOS) Stops after fetching metadata. No further HTTP requests at all. (Cross-filed: see the companion issue on impierce/identity-wallet.)

So even Impierce's own wallet does not work against this agent without the shim — strong signal that the metadata is currently incomplete.

Suggested fix

In agent_api_http::v0::issuance::credential_issuer::well_known::openid_credential_issuer, after returning the stored credential_issuer_metadata, populate:

  • nonce_endpoint from public_url + /openid4vci/nonce
  • authorization_servers: [issuer_url]
  • (optionally) token_endpoint directly inline for older clients that don't follow the OAuth-metadata indirection

The values are already known to the agent at runtime — they just aren't serialised into the well-known response. Injecting these via reverse proxy was sufficient to make Sphereon's flow work end-to-end.

Related but distinct


Issue 2 — Credential endpoint rejects the older proof (singular) shape with misleading invalid_nonce

The oid4vci dependency pinned in Cargo.toml (rev = "2f27e41", brought in by #283 which bumped to draft 1.0) only deserialises the newer plural shape:

// agent_shared::config CredentialRequest
pub proofs: Option<Proofs>,

There is no #[serde(alias = "proof")] and no fallback parser. So when a wallet sends the older shape:

{
  "credential_configuration_id": "my_custom_credential",
  "proof": { "proof_type": "jwt", "jwt": "<jwt-with-correct-nonce>" }
}

…deserialisation produces proofs = None. NonceValidationService::extract_nonce_from_credential_request returns an empty Vec, validate() returns MissingNonce, and credential.rs maps that to:

HTTP/1.1 400 Bad Request
{ "error": "invalid_nonce" }

The same request with proofs: { "jwt": ["<same jwt>"] } succeeds — proving the nonce is fine. The error is misleading: the wallet developer sees invalid_nonce and starts debugging nonce flow, when the actual problem is field naming.

Reproduction

After getting an access_token and c_nonce:

# Fails with invalid_nonce
curl -X POST https://your-agent/openid4vci/credential \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"credential_configuration_id":"my_custom_credential",
       "proof":{"proof_type":"jwt","jwt":"<jwt with c_nonce>"}}'

# Same JWT, different wrapping shape: succeeds
curl -X POST https://your-agent/openid4vci/credential \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"credential_configuration_id":"my_custom_credential",
       "proofs":{"jwt":["<same jwt>"]}}'

Affected wallets observed

  • Sphereon Wallet 804 (iOS) — sends proof (singular)
  • UniMe 0.13.2 — never reaches this step in our test, but presumably affected too

Suggested fix (any of)

  1. Bump the oid4vci crate to a revision that supports both shapes via #[serde(alias)] or a custom deserializer.
  2. Add a one-line alias in the agent's wrapper struct so proof deserialises into proofs.jwt[0].
  3. At the very least, return a more descriptive error: invalid_request with a body like {"error":"invalid_request","error_description":"unsupported credential request shape; expected 'proofs.jwt'"}. The current invalid_nonce is a real footgun for integrators — I lost a couple of hours to it before reading the source.

Related

  • build: bump OID4VCI dependencies  #283 (closed, released on @beta) — the OID4VCI dep bump from draft 15 → 1.0 that introduced this shape change. Backward compatibility for wallets still on the older shape was apparently not in scope.

Workaround in place

For anyone hitting these in the meantime, here is the shim approach that gets Sphereon working:

  1. Front the agent with Caddy at an HTTPS hostname (mandatory for any real wallet).
  2. Path-route GET /.well-known/openid-credential-issuer and POST /openid4vci/credential to a small Laravel shim that:
    • Injects nonce_endpoint, authorization_servers, and token_endpoint into the issuer metadata response.
    • Rewrites incoming proof (singular) request bodies to proofs (plural array) before forwarding to the agent.
  3. Everything else proxies straight to the agent.

Happy to share the shim source if useful.


Are you planning to contribute this in a PR?

I can take a stab at Issue 1 (extending the well-known metadata serialiser) if it'd land cleanly. Issue 2 is more invasive (touches a pinned external crate); I'd defer to maintainers on the right approach.

Materials I can provide on request

  • Full agent log of a working Sphereon attempt (with shim) and a failing UniMe attempt.
  • The exact issuer metadata responses (raw + post-shim) and the my_custom_credential configuration.
  • Source of the Laravel shim and Caddyfile.
  • Cargo lockfile of the running image.

Metadata

Metadata

Assignees

Labels

BugSomething isn't working

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions