A small, dependency-light Java SDK for machine-to-machine (M2M) authentication against
OID4VP-style Verifiers, using the client_credentials grant with private_key_jwt
(RFC 7523) and a self-signed Verifiable Presentation JWT as proof of possession.
It is not tied to any single Verifier implementation — any Verifier that accepts this
wire protocol (self-signed VP-JWT + client_assertion carrying vp_token, X-Tenant
header, standard OAuth2 token response) can be used as the target.
No Spring, no reactive stack, no DI framework. Just Nimbus JOSE+JWT
for JWS/JWK handling and the JDK's own java.net.http.HttpClient.
Any service that holds:
- An EC (P-256) private key, and
- A machine credential (e.g. a
LEARCredentialMachine) that binds that key as its confirmation key (cnf),
and needs to obtain an OAuth2 access token from a Verifier that trusts that credential.
Gradle
dependencies {
implementation 'net.eudistack:eudistack-verifier-client-java:<version>'
}Maven
<dependency>
<groupId>net.eudistack</groupId>
<artifactId>eudistack-verifier-client-java</artifactId>
<version><version></version>
</dependency>You configure 3 values — your private key, your machine credential, and the Verifier's
base URL. The tenant and client_id are derived automatically from the credential, so
there's nothing else to keep in sync.
VerifierM2MClient client = VerifierM2MClient.builder()
.verifierUrl("https://verifier.example.org")
.privateKey(privateKey) // JWK JSON or a raw hex scalar — see below
.credentialJwt(machineCredentialJwt) // or .credentialJwtFile(Path.of("credential.jwt"))
.build();
AccessToken token = client.authenticate();
System.out.println(token.accessToken());build() validates that your private key matches the credential's cnf immediately,
so a misconfigured key/credential pair fails at startup — not on the first request that
happens to need a token.
VerifierM2MClient client = VerifierM2MClient.fromConfig("verifier-client.yaml");
AccessToken token = client.authenticate();verifier.url is the remote service you're connecting to. Everything under
verifier.client.* is yours — the private key and credential you authenticate with, not
anything belonging to the Verifier.
| Key | Required | Meaning |
|---|---|---|
verifier.url |
yes | The Verifier's base URL, e.g. https://verifier.example.org |
verifier.client.private-key |
one of these two | Your private key, inline — see accepted formats below |
verifier.client.private-key-path |
Path to a file containing the private key (relative to the config file) | |
verifier.client.credential-jwt |
one of these two | Your machine credential, inline compact JWT |
verifier.client.credential-jwt-path |
Path to a file containing the credential JWT (relative to the config file) |
Set exactly one of the inline/path variants for the key, and exactly one for the credential.
Both the inline value and the file content accept either:
-
JWK JSON (RFC 7517):
{"kty":"EC","crv":"P-256","x":"...","y":"...","d":"..."} -
Raw hex scalar:
0xb8c069add118093e5c0d192a8edd64b426a898c933089703e63b595f74b3edd6or the same without the0xprefix — just the private valued, with or without leading-zero padding to the full 32 bytes. Some issuers hand out the key this way instead of as a JWK; the public(x, y)coordinates are derived from it automatically, with no extra step on your side.If you set this inline in a
.yamlfile, quote it (verifier.client.private-key: '0xb8c0...'). An unquoted value that looks numeric is parsed by YAML as a number, not a string — this SDK rejects that with a clear error rather than silently loading the wrong key, but quoting avoids the error entirely..propertiesfiles and the programmatic builder are unaffected.
verifier.url=https://verifier.example.org
verifier.client.private-key-path=key.jwk.json
verifier.client.credential-jwt-path=credential.jwtverifier:
url: https://verifier.example.org
client:
private-key-path: key.jwk.json
credential-jwt-path: credential.jwtYAML support requires
org.yaml:snakeyamlon your classpath — it's an optional dependency of this SDK, only needed if you load a.yaml/.ymlfile. If you only use.propertiesfiles or the programmatic builder, you don't need it.
- The private key is generated when you (or your identity provider) create the key pair the mandate is bound to. It must be a P-256 EC key.
- The machine credential is issued to you by an EUDIStack-compatible Issuer (or any
compliant OID4VCI issuer) as a
LEARCredentialMachine-shaped Verifiable Credential, with acnfclaim binding your public key.
This SDK does not issue credentials or generate keys — it only consumes them to authenticate.
Before making any network call, VerifierM2MClient verifies that the public counterpart of
your configured private key matches the credential's confirmation key. Two cnf encodings
are supported, both per RFC 7800 (cnf is
always a JSON object — never a bare string):
"cnf": { "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } }"cnf": { "kid": "did:key:zDnaek9hf761hzFqLFZvTntvhEKfYKiCPaQbV4uXifYV8eHw6#zDnaek9hf761hzFqLFZvTntvhEKfYKiCPaQbV4uXifYV8eHw6" }The second form (RFC 7800 §3.4) identifies the key by reference: a DID URL — the did:key
followed by a #-fragment naming a verification method in its DID document, which for
did:key is always identical to the DID's own method-specific identifier.
If they don't match, construction throws CredentialKeyMismatchException — this is a
client-side safety check, independent of whatever the Verifier itself validates
server-side, meant to catch a misconfigured deployment immediately instead of via an opaque
rejection on first use.
| Exception | Thrown when |
|---|---|
InvalidConfigurationException |
A required value is missing/blank, a JWK/JWT is malformed, or a config file is invalid |
CredentialKeyMismatchException |
The private key does not match the credential's cnf |
TokenRequestFailedException |
The Verifier returned a non-2xx response, or was unreachable — carries httpStatus() and responseBody() |
All extend VerifierClientException.
- "Verifier response is missing the 'access_token' field" / tenant mismatch errors —
the Verifier resolves the tenant from the credential's
mandate.power[].domainand compares it against theX-Tenantheader this SDK sends automatically. If your credential's mandate doesn't declare the domain you expect, the grant is rejected. CredentialKeyMismatchExceptionon startup — you're pointing at the wrong private key file, or the wrong credential. Confirm the key pair used to request the credential is the one configured here.- Encoding gotchas this SDK handles for you — the
vp_tokenclaim insideclient_assertionmust be standard Base64 (not URL-safe), and alliat/exp/nbfclaims must be seconds since epoch, not milliseconds. If you're implementing your own client against the same Verifier from another language, these are the two most common interop mistakes.
Apache License 2.0 — see LICENSE.