Mandatory claims are not enforced for OID4VCI

closes #44796

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda 2025-12-15 09:55:57 +01:00 committed by Marek Posolda
parent 6a437521a9
commit ff1274c07a
7 changed files with 296 additions and 79 deletions

View File

@ -25,6 +25,7 @@ import java.security.PublicKey;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -1369,8 +1370,13 @@ public class OID4VCIssuerEndpoint {
Map<String, Object> subjectClaims = new HashMap<>();
protocolMappers.forEach(mapper -> mapper.setClaim(subjectClaims, authResult.session()));
Map<String, Object> subjectClaimsWithMetadataPrefix = new HashMap<>();
protocolMappers
.forEach(mapper -> mapper.setClaimWithMetadataPrefix(subjectClaims, subjectClaimsWithMetadataPrefix));
// Validate that requested claims from authorization_details are present
validateRequestedClaimsArePresent(subjectClaims, authResult.session(), credentialConfig.getScope());
String credentialConfigId = credentialConfig.getId();
validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId);
// Include all available claims
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
@ -1446,68 +1452,77 @@ public class OID4VCIssuerEndpoint {
/**
* Validates that all requested claims from authorization_details are present in the available claims.
*
* @param allClaims all available claims
* @param allClaims all available claims. These are the claims including metadata prefix with the resolved path
* @param credentialConfig Credential configuration
* @param userSession the user session
* @param scope the credential scope
* @throws BadRequestException if mandatory requested claims are missing
*/
private void validateRequestedClaimsArePresent(Map<String, Object> allClaims, UserSessionModel userSession, String scope) {
try {
// Look for stored claims in user session notes
String claimsKey = AUTHORIZATION_DETAILS_CLAIMS_PREFIX + scope;
String storedClaimsJson = userSession.getNote(claimsKey);
private void validateRequestedClaimsArePresent(Map<String, Object> allClaims, SupportedCredentialConfiguration credentialConfig,
UserSessionModel userSession, String scope) {
// Protocol mappers from configuration
Map<List<Object>, ClaimsDescription> claimsConfig = credentialConfig.getCredentialMetadata().getClaims()
.stream()
.map(claim -> {
List<Object> pathObj = new ArrayList<>(claim.getPath());
return new ClaimsDescription(pathObj, claim.isMandatory());
})
.collect(Collectors.toMap(ClaimsDescription::getPath, claimsDescription -> claimsDescription));
if (storedClaimsJson != null && !storedClaimsJson.isEmpty()) {
try {
// Parse the stored claims from JSON
List<ClaimsDescription> storedClaims =
JsonSerialization.readValue(storedClaimsJson,
new TypeReference<List<ClaimsDescription>>() {
});
List<ClaimsDescription> claimsFromAuthzDetails = getClaimsFromAuthzDetails(scope, userSession);
if (storedClaims != null && !storedClaims.isEmpty()) {
// Validate that all requested claims are present in the available claims
// We use filterClaimsByAuthorizationDetails to check if claims can be found
// but we don't actually filter - we just validate presence
try {
ClaimsPathPointer.filterClaimsByAuthorizationDetails(allClaims, storedClaims);
LOGGER.debugf("All requested claims are present for scope %s", scope);
} catch (IllegalArgumentException e) {
// If filtering fails, it means some requested claims are missing
LOGGER.errorf("Requested claims validation failed for scope %s: %s", scope, e.getMessage());
throw new BadRequestException("Credential issuance failed: " + e.getMessage() +
". The requested claims are not available in the user profile.");
}
} else {
LOGGER.debug("Stored claims list is null or empty");
}
} catch (Exception e) {
LOGGER.errorf(e, "Failed to parse stored claims for scope %s", scope);
// Merge claims from both protocolMappers and authorizationDetails. If either source specifies "mandatory" as true, claim is considered mandatory
for (ClaimsDescription claimDescription : claimsFromAuthzDetails) {
List<Object> path = claimDescription.getPath();
ClaimsDescription existing = claimsConfig.get(path);
if (existing == null) {
claimsConfig.put(path, claimDescription);
} else {
if (claimDescription.isMandatory()) {
existing.setMandatory(true);
}
} else {
LOGGER.debugf("No stored claims found for scope %s", scope);
}
// No claims filtering requested, all claims are valid
}
List<ClaimsDescription> claimsDescriptions = new ArrayList<>(claimsConfig.values());
// Validate that all requested claims are present in the available claims
// We use filterClaimsByAuthorizationDetails to check if claims can be found
// but we don't actually filter - we just validate presence
try {
ClaimsPathPointer.filterClaimsByAuthorizationDetails(allClaims, claimsDescriptions);
LOGGER.debugf("All requested claims are present for scope %s", scope);
} catch (IllegalArgumentException e) {
// Mandatory claim missing - this should fail credential issuance
String errorMessage = e.getMessage();
if (errorMessage.contains("Mandatory claim not found:")) {
LOGGER.errorf("Mandatory claim missing during claims filtering for scope %s: %s", scope, errorMessage);
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST,
"Credential issuance failed: " + errorMessage +
". The requested mandatory claim is not available in the user profile."));
} else {
LOGGER.errorf("Claims filtering error for scope %s: %s", scope, errorMessage);
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST,
"Credential issuance failed: " + errorMessage));
}
} catch (BadRequestException e) {
// Re-throw BadRequestException to ensure client receives proper error response
throw e;
} catch (Exception e) {
// Log error but continue with all claims to avoid breaking existing functionality
LOGGER.errorf(e, "Unexpected error during claims validation for scope %s, continuing with all claims", scope);
// If filtering fails, it means some requested claims are missing
LOGGER.warnf("Requested claims validation failed for scope '%s', user '%s', client '%s': %s"
, scope, userSession.getUser().getUsername(), session.getContext().getClient().getClientId(), e.getMessage());
throw new BadRequestException("Credential issuance failed: " + e.getMessage() +
". The requested claims are not available in the user profile.");
}
}
private List<ClaimsDescription> getClaimsFromAuthzDetails(String scope, UserSessionModel userSession) {
String username = userSession.getUser().getUsername();
String clientId = session.getContext().getClient().getClientId();
// Look for stored claims in user session notes
String claimsKey = AUTHORIZATION_DETAILS_CLAIMS_PREFIX + scope;
String storedClaimsJson = userSession.getNote(claimsKey);
if (storedClaimsJson != null && !storedClaimsJson.isEmpty()) {
try {
// Parse the stored claims from JSON
return JsonSerialization.readValue(storedClaimsJson,
new TypeReference<>() {
});
} catch (Exception e) {
LOGGER.warnf(e, "Failed to parse stored claims for scope '%s', user '%s', client '%s'", scope, username, clientId);
}
} else {
LOGGER.debugf("No stored claims found for scope '%s', user '%s', client '%s'", scope, username, clientId);
}
return Collections.emptyList();
}
}

View File

@ -19,6 +19,7 @@ package org.keycloak.protocol.oid4vc.issuance.mappers;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -158,4 +159,34 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
public abstract void setClaim(Map<String, Object> claims,
UserSessionModel userSessionModel);
/**
* Creates new map "claimsWithPrefix" with the resolved claims including path prefix
*
* @param claimsOrig Map with the original claims, which were returned by {@link #setClaim(Map, UserSessionModel)} . This method usually just reads from this map
* @param claimsWithPrefix Map with the claims including path prefix. This method might write to this map
*/
public void setClaimWithMetadataPrefix(Map<String, Object> claimsOrig, Map<String, Object> claimsWithPrefix) {
List<String> attributePath = getMetadataAttributePath();
String propertyName = attributePath.get(attributePath.size() - 1);
if (claimsOrig.get(propertyName) != null) {
Object claimValue = claimsOrig.get(propertyName);
Map<String, Object> current = claimsWithPrefix;
for (int i = 0; i < attributePath.size() ; i++) {
String currentSnippetName = attributePath.get(i);
if (i < attributePath.size() - 1) {
Map<String, Object> obj = (Map<String, Object>) current.get(currentSnippetName);
if (obj == null) {
obj = new HashMap<>();
current.put(currentSnippetName, obj);
}
current = obj;
} else {
// Last element
current.put(currentSnippetName, claimValue);
}
}
}
}
}

View File

@ -220,13 +220,13 @@ public class ClaimsPathPointer {
// Optional claims that don't exist are simply not included
} catch (IllegalArgumentException e) {
if (Boolean.TRUE.equals(claim.getMandatory())) {
// Log error for mandatory claims before re-throwing
logger.errorf("Failed to process mandatory claim path %s: %s", path, e.getMessage());
// Log warning for mandatory claims before re-throwing
logger.warnf("Failed to process mandatory claim path %s: %s", path, e.getMessage());
// Re-throw for mandatory claims
throw e;
}
// For optional claims, log warning and continue
logger.warnf("Failed to process optional claim path %s: %s", path, e.getMessage());
// For optional claims, log debug and continue
logger.debugf("Failed to process optional claim path %s: %s", path, e.getMessage());
}
}

View File

@ -28,7 +28,10 @@ import java.util.function.BiFunction;
import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientScopeResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
@ -38,6 +41,10 @@ import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
@ -88,6 +95,11 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
*/
protected abstract String getExpectedClaimPath();
/**
* Get the name of the protocol mapper for firstName
*/
protected abstract String getFirstNameProtocolMapperName();
/**
* Prepare OID4VC test context by fetching issuer metadata and credential offer
*/
@ -137,10 +149,145 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
testCompleteFlowWithClaimsValidationAuthorizationCode(credRequestSupplier);
}
// Test for the authorization_code flow with "mandatory" claim specified in the "authorization_details" parameter
@Test
public void testCompleteFlow_mandatoryClaimsInAuthzDetailsParameter() throws Exception {
Oid4vcTestContext ctx = prepareOid4vcTestContext();
BiFunction<String, String, CredentialRequest> credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> {
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
return credentialRequest;
};
// 1 - Update user to have missing "lastName" (mandatory attribute)
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "john");
UserRepresentation userRep = user.toRepresentation();
// NOTE: Need to call both "setLastName" and set attributes to be able to set last name as null
userRep.setAttributes(Collections.emptyMap());
userRep.setLastName(null);
user.update(userRep);
// 2 - Test the flow. Credential request should fail due the missing "lastName"
// Perform authorization code flow to get authorization code
AccessTokenResponse tokenResponse = authzCodeFlow(ctx);
String credentialIdentifier = assertTokenResponse(tokenResponse);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Request the actual credential using the identifier
HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse);
}
// 3 - Update user to add "lastName"
userRep.setLastName("Doe");
user.update(userRep);
// 4 - Test the credential-request again. Should be OK now
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertSuccessfulCredentialResponse(credentialResponse);
}
}
// Test for the authorization_code flow with "mandatory" claim specified in the "authorization_details" parameter as well as
// mandatory claims in the protocol mappers configuration
@Test
public void testCompleteFlow_mandatoryClaimsInAuthzDetailsParameterAndProtocolMappersConfig() throws Exception {
Oid4vcTestContext ctx = prepareOid4vcTestContext();
BiFunction<String, String, CredentialRequest> credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> {
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
return credentialRequest;
};
// 1 - Update "firstName" protocol mapper to be mandatory
ClientScopeResource clientScopeResource = ApiUtil.findClientScopeByName(testRealm(), getCredentialClientScope().getName());
assertNotNull(clientScopeResource);
ProtocolMapperRepresentation protocolMapper = clientScopeResource.getProtocolMappers().getMappers()
.stream()
.filter(protMapper -> getFirstNameProtocolMapperName().equals(protMapper.getName()))
.findFirst()
.orElseThrow((() -> new RuntimeException("Not found protocol mapper with name 'firstName-mapper'.")));
protocolMapper.getConfig().put(Oid4vcProtocolMapperModel.MANDATORY, "true");
clientScopeResource.getProtocolMappers().update(protocolMapper.getId(), protocolMapper);
try {
// 2 - Update user to have missing "lastName" (mandatory attribute by authorization_details parameter) and "firstName" (mandatory attribute by protocol mapper)
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "john");
UserRepresentation userRep = user.toRepresentation();
// NOTE: Need to call both "setLastName" and set attributes to be able to set last name as null
userRep.setAttributes(Collections.emptyMap());
userRep.setFirstName(null);
userRep.setLastName(null);
user.update(userRep);
// 2 - Test the flow. Credential request should fail due the missing "lastName"
// Perform authorization code flow to get authorization code
AccessTokenResponse tokenResponse = authzCodeFlow(ctx);
String credentialIdentifier = assertTokenResponse(tokenResponse);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Request the actual credential using the identifier
HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse);
}
// 3 - Update user to add "lastName", but keep "firstName" missing. Credential request should still fail
userRep.setLastName("Doe");
userRep.setFirstName(null);
user.update(userRep);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse);
}
// 4 - Update user to add "firstName", but missing "lastName"
userRep.setLastName(null);
userRep.setFirstName("John");
user.update(userRep);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse);
}
// 5 - Update user to both "firstName" and "lastName". Credential request should be successful
userRep.setLastName("Doe");
userRep.setFirstName("John");
user.update(userRep);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertSuccessfulCredentialResponse(credentialResponse);
}
} finally {
// 6 - Revert protocol mapper config
protocolMapper.getConfig().put(Oid4vcProtocolMapperModel.MANDATORY, "false");
clientScopeResource.getProtocolMappers().update(protocolMapper.getId(), protocolMapper);
}
}
private void testCompleteFlowWithClaimsValidationAuthorizationCode(BiFunction<String, String, CredentialRequest> credentialRequestSupplier) throws Exception {
Oid4vcTestContext ctx = prepareOid4vcTestContext();
// Perform authorization code flow to get authorization code
AccessTokenResponse tokenResponse = authzCodeFlow(ctx);
String credentialIdentifier = assertTokenResponse(tokenResponse);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Request the actual credential using the identifier
HttpPost postCredential = getCredentialRequest(ctx, credentialRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertSuccessfulCredentialResponse(credentialResponse);
}
}
// Successful authorization_code flow
private AccessTokenResponse authzCodeFlow(Oid4vcTestContext ctx) throws Exception {
// Perform authorization code flow to get authorization code
oauth.client(client.getClientId());
oauth.scope(getCredentialClientScope().getName()); // Add the credential scope
@ -183,13 +330,15 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
UrlEncodedFormEntity tokenFormEntity = new UrlEncodedFormEntity(tokenParameters, StandardCharsets.UTF_8);
postToken.setEntity(tokenFormEntity);
AccessTokenResponse tokenResponse;
try (CloseableHttpResponse tokenHttpResponse = httpClient.execute(postToken)) {
assertEquals(HttpStatus.SC_OK, tokenHttpResponse.getStatusLine().getStatusCode());
String tokenResponseBody = IOUtils.toString(tokenHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8);
tokenResponse = JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class);
return JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class);
}
}
// Test successful token response. Returns "Credential identifier" of the VC credential
private String assertTokenResponse(AccessTokenResponse tokenResponse) throws Exception {
// Extract authorization_details from token response
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
@ -206,8 +355,11 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
assertNotNull("Credential identifiers should not be null", credentialIdentifiers);
assertEquals("Credential identifiers expected to have 1 item. It had " + credentialIdentifiers.size() + " with value " + credentialIdentifiers,
1, credentialIdentifiers.size());
String credentialIdentifier = credentialIdentifiers.get(0);
return credentialIdentifiers.get(0);
}
private HttpPost getCredentialRequest(Oid4vcTestContext ctx, BiFunction<String, String, CredentialRequest> credentialRequestSupplier, AccessTokenResponse tokenResponse,
String credentialConfigurationId, String credentialIdentifier) throws Exception {
// Request the actual credential using the identifier
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.getToken());
@ -218,27 +370,36 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
return postCredential;
}
// Parse the credential response
CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class);
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
private void assertSuccessfulCredentialResponse(CloseableHttpResponse credentialResponse) throws Exception {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Verify that the issued credential contains the requested claims AND may contain additional claims
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
// Parse the credential response
CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class);
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// Verify that the issued credential contains the requested claims AND may contain additional claims
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
// Verify the credential structure based on formatfix-authorization_details-processing
verifyCredentialStructure(credentialObj);
}
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// Verify the credential structure based on formatfix-authorization_details-processing
verifyCredentialStructure(credentialObj);
}
private void assertErrorCredentialResponse(CloseableHttpResponse credentialResponse) throws Exception {
assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
OAuth2ErrorRepresentation error = JsonSerialization.readValue(responseBody, OAuth2ErrorRepresentation.class);
assertEquals("Credential issuance failed: No elements selected after processing claims path pointer. The requested claims are not available in the user profile.", error.getError());
}
/**

View File

@ -43,7 +43,12 @@ public class OID4VCJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCodeF
@Override
protected String getExpectedClaimPath() {
return "given_name";
return "family_name";
}
@Override
protected String getFirstNameProtocolMapperName() {
return "givenName";
}
@Override

View File

@ -48,6 +48,11 @@ public class OID4VCSdJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCod
return "lastName";
}
@Override
protected String getFirstNameProtocolMapperName() {
return "firstName-mapper";
}
@Override
protected void verifyCredentialStructure(Object credentialObj) {
assertNotNull("Credential object should not be null", credentialObj);

View File

@ -25,7 +25,7 @@
"protocolMapper": "oid4vc-user-attribute-mapper",
"config": {
"claim.name": "family_name",
"userProperty": "lastName",
"userAttribute": "lastName",
"vc.mandatory": "false",
"vc.display": "[{\"name\": \"اسم العائلة\", \"locale\": \"ar-SA\"}, {\"name\": \"Nachname\", \"locale\": \"de-DE\"}, {\"name\": \"Family Name\", \"locale\": \"en-US\"}, {\"name\": \"Apellido\", \"locale\": \"es-ES\"}, {\"name\": \"نام خانوادگی\", \"locale\": \"fa-IR\"}, {\"name\": \"Sukunimi\", \"locale\": \"fi-FI\"}, {\"name\": \"Nom de famille\", \"locale\": \"fr-FR\"}, {\"name\": \"परिवार का नाम\", \"locale\": \"hi-IN\"}, {\"name\": \"Cognome\", \"locale\": \"it-IT\"}, {\"name\": \"姓\", \"locale\": \"ja-JP\"}, {\"name\": \"өөрийн нэр\", \"locale\": \"mn-MN\"}, {\"name\": \"Achternaam\", \"locale\": \"nl-NL\"}, {\"name\": \"Sobrenome\", \"locale\": \"pt-PT\"}, {\"name\": \"Efternamn\", \"locale\": \"sv-SE\"}, {\"name\": \"خاندانی نام\", \"locale\": \"ur-PK\"}]"
}