OpenID Connect Identity Assurance / eKYC

eKYC / Identity Assurance is a new OpenID Connect extension for letting providers of verified identities, such as national eID schemes, banks and eIDAS providers, release UserInfo and ID tokens with special metadata describing which claims are strongly verified and how the actual verification took place.

The examples below are based on version 9.21 of this SDK and the eKYC / Identity Assurance specification from September 2021 (draft 12).

How to request verified claims with OpenID Connect

This is a simple example OpenID authentication request for an ID token and release of given_name, family_name and address as verified user claims at the UserInfo endpoint. No trust framework is specified, which will pick the default one handled by the identity provider.

import java.net.*;
import java.util.*;
import com.nimbusds.oauth2.sdk.*;
import com.nimbusds.oauth2.sdk.id.*;
import com.nimbusds.oauth2.sdk.pkce.*;
import com.nimbusds.openid.connect.sdk.*;
import com.nimbusds.openid.connect.sdk.assurance.request.*;

// The requested verified claims
OIDCClaimsRequest claimsRequest = new OIDCClaimsRequest()
    .withUserInfoVerifiedClaimsRequest(
        new VerifiedClaimsSetRequest()
            .add("given_name")
            .add("family_name")
            .add("address")
    );

// The above translates to the "claims" JSON object:
// {
//   "verification" : {
//     "trust_framework" : null
//   },
//   "claims" : {
//     "given_name"  : null,
//     "family_name" : null,
//     "address"     : null
//   },
// }

// Extra PKCE security
CodeVerifier pkceVerifier = new CodeVerifier();

// Purpose to display
String purpose = "Account holder identification";

// Compose the OpenID authentication request
AuthenticationRequest authRequest = new AuthenticationRequest.Builder(
    new ResponseType(ResponseType.Value.CODE),
    new Scope(OIDCScopeValue.OPENID),
    new ClientID("123"),
    URI.create("https://example.com/cb"))
    .state(new State("kum8aiy0Ilai6ohD"))
    .codeChallenge(pkceVerifier, CodeChallengeMethod.S256)
    .claims(claimsRequest)
    .purpose(purpose)
    .endpointURI(URI.create("https://c2id.com/login"))
    .build();

URI request = authRequest.toURI();

// Resulting URI with OpenID authentication request
// https://c2id.com/login?
// response_type=code
// &scope=openid
// &client_id=123
// &redirect_uri=https%3A%2F%2Fexample.com%2Fcb
// &state=kum8aiy0Ilai6ohD
// &code_challenge_method=S256
// &code_challenge=LnJxED7AfXdPbzbRhvijwUDTvweK4gfDYbN9um_1dis
// &claims=%7B%22userinfo%22%3A%7B%22verified_claims%22%3A%7B%22claims%22%3A%7B%22address%22%3Anull%2C%22given_name%22%3Anull%2C%22family_name%22%3Anull%7D%7D%7D%7D
// &purpose=Account+holder+identification

The application can specify a preferred trust framework like this:

// Attest the user identity according to eIDAS
VerificationSpec verification = new MinimalVerificationSpec(
    IdentityTrustFramework.EIDAS
);

// Include the verification spec in the claims request
OIDCClaimsRequest claimsRequest = new OIDCClaimsRequest()
    .withUserInfoVerifiedClaimsRequest(
        new VerifiedClaimsSetRequest()
            .withVerification(verification)
            .add("given_name")
            .add("family_name")
            .add("address")
    );

The resulting claims JSON object:

{
  "verification" : {
    "trust_framework" : {
      "value" : "eidas"
    }
  },
  "claims" : {
    "given_name"  : null,
    "family_name" : null,
    "address"     : null
  },
}

Custom verification elements can be created by implementing the VerificationSpec interface or by extending the MinimalVerificationSpec class.

When using the interface implement the toJSONObject method:

import net.minidev.json.JSONObject;
import com.nimbusds.openid.connect.sdk.assurance.request.*;

public class MyVerificationSpec implements VerificationSpec {

    @Override
    public JSONObject toJSONObject() {

        JSONObject o = new JSONObject();
        // compose my custom verification element here
        return o;
    }
}

Example extension of the MinimalVerificationSpec class which adds a method for specifying the request of evidence attachments:

import net.minidev.json.JSONObject;
import com.nimbusds.openid.connect.sdk.assurance.*;
import com.nimbusds.openid.connect.sdk.assurance.request.*;

public class VerificationWithOptionalAttachments extends MinimalVerificationSpec {

    public VerificationWithOptionalAttachments(IdentityTrustFramework framework) {
        super(framework);
    }

    public void includeAttachments(boolean includeAttachments) {
        // Modify the protected jsonObject member;
        // the JSON object output is then handled by the parent class
        if (includeAttachments) {
            jsonObject.put("attachments", null);
        } else {
            jsonObject.remove("attachments");
        }
    }
}

On the server side the OpenID authentication request can be parsed to retrieve the elements of the request JSON object parameter in a type-safe manner. The request parameter is represented by the OIDCClaimsRequest class. For each supported verified claim the identity provider can use the methods of ClaimsSetRequest.Entry to retrieve the optional claim requirements (voluntary vs essential), preferred value(s), language tag(s) and purpose to display. Custom parameters in the spec for a given claim are also supported.

Example parsing of an OpenID authentication request for verified UserInfo:

import com.nimbusds.openid.connect.sdk.*;
import com.nimbusds.openid.connect.sdk.assurance.claims.*;
import com.nimbusds.openid.connect.sdk.assurance.request.*;

// IdP server parsing the OpenID request
AuthenticationRequest authRequest = AuthenticationRequest.parse(request);

// The spec allows for multiple verified claims sets
for (VerifiedClaimsSetRequest vcsr: claimsRequest.getUserInfoVerifiedClaimsRequests()) {
    // Print the raw verification object
    System.out.println(vcsr.getVerification().toJSONObject());

    // Print the name of each requested claim and its options
    for (OIDCClaimsSetRequest.Entry en: vcsr.getEntries()) {
        System.out.println(en.getClaimName());
        System.out.println(en.getClaimRequirement());
        System.out.println(en.getValue());
        System.out.println(en.getPurpose());
    }
}

// Print the preferred UI locales (if any)
System.out.println(authRequest.getUILocales());

// Print the purpose to display (if any)
System.out.println(authRequest.getPurpose());

Claims parameter with options

How to create a claims request parameter which specifies regular as well as verified claims for return in the ID token, with use of the essential and purpose options:

import com.nimbusds.openid.connect.sdk.*;
import com.nimbusds.openid.connect.sdk.assurance.*;
import com.nimbusds.openid.connect.sdk.assurance.request.*;

// Attest the user identity according to eIDAS
VerificationSpec verification = new MinimalVerificationSpec(
    IdentityTrustFramework.EIDAS
);

OIDCClaimsRequest claimsRequest = new OIDCClaimsRequest()
    .withIDTokenClaimsRequest(
        new ClaimsSetRequest()
            // Request for regular "email" claim, default settings
            .add("email")
    )
    .withIDTokenVerifiedClaimsRequest(
        new VerifiedClaimsSetRequest()
            // Requested verification details
            .withVerification(verification)
            // Request verified "name" claim,
            // marked as essential, with purpose message
            .add(new ClaimsSetRequest.Entry("name")
                .withClaimRequirement(ClaimRequirement.ESSENTIAL)
                .withPurpose("Name required for contract"))
            // Request for verified "address" claim,
            // marked as essential, with purpose message
            .add(new ClaimsSetRequest.Entry("address")
                .withClaimRequirement(ClaimRequirement.ESSENTIAL)
                .withPurpose("Address required for contract")));

// The claims parameter as JSON object
System.out.println(claimsRequest.toJSONString());

The resulting claims JSON object:

{
  "id_token" : {
    "email" : null,
    "verified_claims" : {
      "verification" : {
        "trust_framework" : {
          "value" : "eidas"
        }
      },
      "claims" : {
        "name" : {
          "essential" : true,
          "purpose"   : "Name required for contract"
        },
        "address" : {
          "essential" : true,
          "purpose"   : "Address required for contract"
        }
      }
    }
  }
}

How can an OpenID provider parse and process the claims parameter

An identity provider for verified claims will typically examine the claims parameter of the OpenID authentication request with the help of logic like this:

import com.nimbusds.openid.connect.sdk.*;
import com.nimbusds.openid.connect.sdk.assurance.request.*;

private static void print(OIDCClaimsRequest claimsRequest) {

    // Get the requested verified claims set for UserInfo delivery
    int num = 1;
    for (VerifiedClaimsSetRequest claimsSetRequest: claimsRequest.getUserInfoVerifiedClaimsRequests()) {
        System.out.println("UserInfo set #" + num++ + ":");
        print(claimsSetRequest);
    }

    // Get the requested verified claims set for ID token delivery
    num = 1;
    for (VerifiedClaimsSetRequest claimsSetRequest: claimsRequest.getIDTokenVerifiedClaimsRequests()) {
        System.out.println("UserInfo set #" + num++ + ":");
        print(claimsSetRequest);
    }
}

private static void print(VerifiedClaimsSetRequest verifiedClaimsSetRequest) {

    VerificationSpec verification = verifiedClaimsSetRequest.getVerification();
    System.out.println("\tVerification: " + verification.toJSONObject());

    System.out.println("\tRequested claims: ");
    for (ClaimsSetRequest.Entry en: verifiedClaimsSetRequest.getEntries()) {
        System.out.println("\t\tname: " + en.getClaimName());
        System.out.println("\t\t\trequirement: " + en.getClaimRequirement());
        if (en.getRawValue() != null) {
            // Use claim specific typed value getter
            System.out.println("\t\t\tvalue: " + en.getValueAsString());
        }
        if (en.getLangTag() != null) {
            System.out.println("\t\t\tlanguage tag: " + en.getLangTag());
        }
        if (en.getPurpose() != null) {
            System.out.println("\t\t\tpurpose message: " + en.getPurpose());
        }
    }
}

How to use the above logic with a received OpenID authentication request:

import com.nimbusds.openid.connect.sdk.*;

// Parse the OpenID authentication request
AuthenticationRequest request = AuthenticationRequest.parse(...);

// Check if the claims parameter is set
OIDCClaims claims = request.getOIDCClaims();

if (claims != null) {
    // Inspect the claims parameter
    print(claims);
}

Applying this logic to the above example produces the following output:

UserInfo set #1:
    Verification: {"trust_framework":{"value":"eidas"}}
    Requested claims:
        name: name
            requirement: ESSENTIAL
            purpose message: Name required for contract
        name: address
            requirement: ESSENTIAL
            purpose message: Address required for contract

How to compose a UserInfo response with verified claims

A UserInfo response that includes verified claims is constructed by creating a VerifiedClaimsSet container to hold the verification data and the claims. Every aspect of this is made type-safe to prevent developer mistakes and ensure the resulting JSON object will comply with the JSON schema for the verified_claims.

Example UserInfo with verified given_name, family_name and email claims, using the eidas trust framework:

import java.util.*;
import com.nimbusds.oauth2.sdk.id.*;
import com.nimbusds.oauth2.sdk.util.date.*;
import com.nimbusds.openid.connect.sdk.assurance.*;
import com.nimbusds.openid.connect.sdk.assurance.claims.*;
import com.nimbusds.openid.connect.sdk.assurance.evidences.*;

// The verification data
Date now = new Date();
DateWithTimeZoneOffset timestamp = new DateWithTimeZoneOffset(
    now,
    TimeZone.getDefault());
IdentityVerification verification = new IdentityVerification(
    IdentityTrustFramework.EIDAS,
    IdentityAssuranceLevel.SUBSTANTIAL,
    null,
    timestamp,
    new VerificationProcess(UUID.randomUUID().toString()),
    new ElectronicSignatureEvidence(
        new SignatureType("qes_eidas"),
        new Issuer("https://qes-provider.org"),
        new SerialNumber("cc58176d-6cd4-4d9d-bad9-50981ad3ee1f"),
        timestamp,
        null));

// The verified claims
PersonClaims claims = new PersonClaims();
claims.setGivenName("Alice");
claims.setFamilyName("Adams");
claims.setEmailAddress("[email protected]");

VerifiedClaimsSet verifiedClaims = new VerifiedClaimsSet(
    verification,
    claims);

UserInfo userInfo = new UserInfo(new Subject("alice"));
userInfo.setVerifiedClaims(verifiedClaims);

// Print the UserInfo JSON object
System.out.println(userInfo.toJSONObject());

Example output for the UserInfo JSON object:

{
  "sub"             : "alice",
  "verified_claims" : {
    "verification" : {
      "trust_framework"     : "eidas",
      "assurance_level"     : "substantial",
      "time"                : "2022-01-19T13:45:13+02:00",
      "verification_process": "1629ba78-2935-4603-a4fe-173f8608d282",
      "evidence"            : [ {
                                  "type"           : "electronic_signature",
                                  "signature_type" : "qes_eidas",
                                  "issuer"         : "https://qes-provider.org",
                                  "serial_number"  : "cc58176d-6cd4-4d9d-bad9-50981ad3ee1f",
                                  "created_at"     : "2022-01-19T13:45:13+02:00"
                               } ]
    },
    "claims" : {
      "given_name"  : "Alice",
      "family_name" : "Adams",
      "email"       : "[email protected]"
    }
  }
}

Note that following the principles of privacy and data minimisation an OpenID provider must not return claims and verification data that isn't explicitly requested by the relying party.

In regard to the verification data, one implementation strategy for OpenID providers is to construct a complete IdentityVerification, serialise it to a JSON object, and then use the verification element from the request as template to filter those details that are explicitly requested by the relying party and can therefore remain to be merged into the final UserInfo response (or ID token claims).

How to parse a UserInfo response with verified claims

Parsing and processing of the response can proceed in a similar type-safe manner:

import java.net.*;
import java.util.*;
import com.nimbusds.oauth2.sdk.id.*;
import com.nimbusds.oauth2.sdk.http.*;
import com.nimbusds.oauth2.sdk.token.*;
import com.nimbusds.oauth2.sdk.util.date.*;
import com.nimbusds.openid.connect.sdk.*;
import com.nimbusds.openid.connect.sdk.assurance.*;
import com.nimbusds.openid.connect.sdk.assurance.claims.*;
import com.nimbusds.openid.connect.sdk.assurance.evidences.*;
import com.nimbusds.openid.connect.sdk.claims.*;

HTTPResponse httpResponse = new UserInfoRequest(
    URI.create("https://c2id.com/userinfo"),
    new BearerAccessToken("..."))
    .toHTTPRequest()
    .send();

UserInfoResponse response = UserInfoResponse.parse(httpResponse);

if (! response.indicatesSuccess()) {
    System.err.println(response.toErrorResponse().getErrorObject());
    return;
}

UserInfo userInfo = response.toSuccessResponse().getUserInfo();

System.out.println("Subject: " + userInfo.getSubject());

if (userInfo.getVerifiedClaims() == null) {
    System.out.println("No verified claims found");
    return;
}

for (VerifiedClaimsSet verifiedClaims: userInfo.getVerifiedClaims()) {

    IdentityVerification verification = verifiedClaims.getVerification();
    System.out.println("Trust framework: " + verification.getTrustFramework());
    System.out.println("Assurance level: " + verification.getAssuranceLevel());
    System.out.println("Time: " + verification.getVerificationTime());
    System.out.println("Verification process: " + verification.getVerificationProcess());

    if (verification.getEvidence() != null) {
        for (IdentityEvidence ev : verification.getEvidence()) {
            System.out.println("Evidence type: " + ev.getEvidenceType());
            // Proceed further if necessary...
        }
    }

    System.out.println("Verified claims: ");
    PersonClaims claims = verifiedClaims.getClaimsSet();
    System.out.println("Given name: " + claims.getGivenName());
    System.out.println("Family name: " + claims.getFamilyName());
    System.out.println("Email: " + claims.getEmailAddress());
}

Example output:

Trust framework: eidas
Assurance level: substantial
Time: 2022-01-19T14:25:40+02:00
Verification process: 94bccc14-cf2b-416e-af62-88120ecab702
Evidence type: electronic_signature
Verified claims:
Given name: Alice
Family name: Adams
Email: [email protected]

How to deal with attachments

An IdentityEvidence used in the verification can come with one or more attachments (if requested by the relying party and supplied by the OpenID provider).

Those are represented by the abstract Attachment class which can be:

  • EmbeddedAttachment -- for attachments of type embedded and delivered inline; will typically only work for UserInfo responses and less so for ID tokens.
  • ExternalAttachment -- for attachments of type external to be retrieved at a secured URL.

The content of an embedded attachment can be retrieved immediately, that of an external attachment with help of the retrieveContent method (handles the optional token and the required digest validation internally):

// Some evidence in the verification data
IdentityEvidence evidence = ...;

// Set appropriate HTTP timeouts (in milliseconds)
// for the external attachments
int httpConnectTimeout = 4_000;
int httpReadTimeout = 5_000;

if (evidence.getAttachments() != null) {

    for (Attachment attachment: evidence.getAttachments()) {

        // Get the attachment content
        Content content = null;
        if (AttachmentType.EMBEDDED.equals(attachment.getType()) {
            // Embedded attachment
            content = attachment.toEmbeddedAttachment().getContent();
        } else {
            // External attachment
            try {
                content = attachment
                    .toExternalAttachment()
                    .retrieveContent(httpConnentTimeout, httpReadTimeout);
            } catch (IOException | NoSuchAlgorithmException | DigestMismatchException e) {
                System.err.println(e.getMessage());
                continue;
            }
        }

        // Save / process the attachment

        // The MIME / Content-Type
        System.out.println(content.getType());

        // The BASE64-encoded content
        System.out.println(content.getBase64().toString());

        // The optional description
        System.out.println(content.getDescription());
    }
}

When processing attachments relying parties should have a list of accepted content types and ignore or reject ones that are not understood. The ContentType class includes constants for all popular image formats, such as PNG, JPEG and PDF.

ISO 3166-1 and 3166-3 country codes

When dealing with nationalities, birthplaces and addresses the eKYC / Identity Assurance claims and verification data make use of standard ISO country codes. The codes ensure countries are represented unambiguously in passports and database records.

In the com.nimbusds.openid.connect.sdk.assurance.claims package the SDK provides concrete classes for dealing with the ISO country codes in a robust and type-safe manner:

  • ISO3166_1Alpha2CountryCode -- for 2-letter country codes, e.g. "AD" for Andorra.

  • ISO3166_1Alpha3CountryCode -- for 3-letter country codes, e.g. "AND" for Andorra.

  • ISO3166_3CountryCode -- for special 4-letter codes for former countries and territories, required in cases such a country of birth that no longer officially exists, e.g. "CSHH" for Czechoslovakia.

  • CountryCode -- abstract class for dealing with country codes.

Example parsing of an ISO-3166 alpha-2 (two letter) country code:

import com.nimbusds.openid.connect.sdk.assurance.claims.*;

// Parse a country code and check its length
CountryCode code = CountryCode.parse("AD");
assertEquals(2, code.length());

// Cast to alpha-2 country code
ISO3166_1Alpha2CountryCode alpha2Code = countryCode.toISO3166_1Alpha2CountryCode();

The ISO3166_1Alpha2CountryCode and ISO3166_1Alpha3CountryCode classes include constants for all official country codes. Those can also be queried to obtain the country name as spelled out in English in the ISO country code registry.

ISO3166_1Alpha3CountryCode alpha3Code = ISO3166_1Alpha3CountryCode.AND;
assertEquals("Andorra", alpha3Code.getCountryName());

The two classes also provide useful methods for mapping between alpha-2 and alpha-3 codes.

// Get the matching two letter code for AND
ISO3166_1Alpha3CountryCode alpha3Code = ISO3166_1Alpha3CountryCode.AND;
ISO3166_1Alpha2CountryCode alpha2Code = alpha3Code.toAlpha2CountryCode();

assertEquals(ISO3166_1Alpha2CountryCode.AD, alpha2Code);

How to query OpenID provider support for verified claims

The support for verified claims can be queried at the well-known endpoint where OpenID providers publish their metadata. The eKYC / Identity Assurance spec defines several metadata parameters to advertise the available trust frameworks, the names of the suppoted verified claims and other details.

Example request to obtain OpenID provider metadata and display its eKYC / Identity Assurance support (based on this example):

import com.nimbusds.oauth2.sdk.id.*;
import com.nimbusds.openid.connect.sdk.assurance.evidences.*;
import com.nimbusds.openid.connect.sdk.assurance.evidences.attachment.*;
import com.nimbusds.openid.connect.sdk.op.*;

// The OpenID provider issuer URL
Issuer issuer = new Issuer("https://demo.c2id.com");

// The OpenID provider issuer URL
Issuer issuer = new Issuer("https://demo.c2id.com");

// Will resolve the OpenID provider metadata automatically
OIDCProviderMetadata opMetadata = OIDCProviderMetadata.resolve(issuer);

// Show the available eKYC / Identity Assurance support
if (! opMetadata.supportsVerifiedClaims()) {
    // eKYC / IdA not supported
    return;
}
System.out.println("Trust frameworks: " + opMetadata.getIdentityTrustFrameworks());
System.out.println("Verified claims: " + opMetadata.getVerifiedClaims());
System.out.println("Evidence types: " + opMetadata.getIdentityEvidenceTypes());
if (opMetadata.getIdentityEvidenceTypes().contains(IdentityEvidenceType.DOCUMENT)) {
    System.out.println("Document types: " + opMetadata.getDocumentTypes());
    System.out.println("Document methods: " + opMetadata.getDocumentMethods());
    System.out.println("Document validation methods: " + opMetadata.getDocumentValidationMethods());
    System.out.println("Document verification methods: " + opMetadata.getDocumentVerificationMethods());
}
if (opMetadata.getIdentityEvidenceTypes().contains(IdentityEvidenceType.ELECTRONIC_RECORD)) {
    System.out.println("Electronic record types: " + opMetadata.getElectronicRecordTypes());
}
if (opMetadata.getAttachmentTypes() != null) {
    System.out.println("Attachment types: " + opMetadata.getAttachmentTypes());
    if (opMetadata.getAttachmentTypes().contains(AttachmentType.EXTERNAL)) {
        System.out.println("Hash algorithms: " + opMetadata.getAttachmentDigestAlgs());
    }
}