This post shows how a Microsoft Entra Verified ID employee credential can be used to access user specific data. This demo shows possible paycheck data from Switzerland. A payment ID can be the printed on the pay slip or the payment document could have a QR Code to scan. The user specific data can then be viewed and possible links to further services can be opened. Access is controlled using the verification presentation. The application is implemented using ASP.NET Core.
Code: https://github.com/swiss-ssi-group/EntraEmployeePaycheck
Get your Verified Employee credential
To use this application, a Microsoft Entra employee credential from the correct tenant must be used. The following post shows how to set this up, get a credential and add this to your Microsoft wallet.
Issue Employee verifiable credentials using Entra Verified ID and ASP.NET Core
Implement the employee credential verifier
The EmployeeClaims class implements the scheme returned from the employee credential. This contains the values from the Microsoft Entra verified employee credential stored on your wallet.
public class EmployeeClaims
{
[JsonPropertyName("givenName")]
public string GivenName { get; set; } = string.Empty;
[JsonPropertyName("surname")]
public string Surname { get; set; } = string.Empty;
[JsonPropertyName("mail")]
public string Mail { get; set; } = string.Empty;
[JsonPropertyName("jobTitle")]
public string JobTitle { get; set; } = string.Empty;
[JsonPropertyName("photo")] // "type": "image/jpg;base64url",
public string Photo { get; set; } = string.Empty;
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = string.Empty;
[JsonPropertyName("preferredLanguage")]
public string PreferredLanguage { get; set; } = string.Empty;
//[JsonPropertyName("userPrincipalName")]
[JsonPropertyName("revocationId")]
public string RevocationId { get; set; } = string.Empty;
}
The GetVerifierRequestPayload is used to initialize the verification presentation request. The configuration must match the Microsoft Entra Verified ID tenant setup.
public VerifierRequestPayload GetVerifierRequestPayload(HttpRequest request)
{
var payload = new VerifierRequestPayload();
var host = GetRequestHostName(request);
payload.Callback.State = Guid.NewGuid().ToString();
payload.Callback.Url = $"{host}/api/verifier/presentationCallback";
payload.Callback.Headers.ApiKey = _credentialSettings.VcApiCallbackApiKey;
payload.Registration.ClientName = "VerifiedEmployee";
payload.Authority = _credentialSettings.VerifierAuthority;
var requestedCredentials = new RequestedCredentials
{
CrendentialsType = "VerifiedEmployee",
Purpose = "Verified Employee to authenticate your request"
};
requestedCredentials.AcceptedIssuers.Add(_credentialSettings.IssuerAuthority);
payload.RequestedCredentials.Add(requestedCredentials);
return payload;
}
The VerifierController implements the API used for the callbacks and the wallet request results. The PresentationRequest method handles the request and uses the Microsoft Entra Verified ID API to send a verification presentation request to the wallet.
[HttpGet("/api/verifier/presentation-request")]
public async Task<ActionResult> PresentationRequest()
{
try
{
var payload = _verifierService.GetVerifierRequestPayload(Request);
var (Token, Error, ErrorDescription) = await _verifierService.GetAccessToken();
if (string.IsNullOrEmpty(Token))
{
_log.LogError("failed to acquire accesstoken: {Error} : {ErrorDescription}", Error, ErrorDescription);
return BadRequest(new { error = Error, error_description = ErrorDescription });
}
var defaultRequestHeaders = _httpClient.DefaultRequestHeaders;
defaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
var res = await _httpClient.PostAsJsonAsync(
_credentialSettings.Endpoint, payload);
if (res.IsSuccessStatusCode)
{
var response = await res.Content.ReadFromJsonAsync<VerifierResponse>();
response!.Id = payload.Callback.State;
_log.LogTrace("succesfully called Request API");
if (res.StatusCode == HttpStatusCode.Created)
{
var cacheData = new CacheData
{
Status = VerifierConst.NotScanned,
Message = "Request ready, please scan with Authenticator",
Expiry = response.Expiry.ToString(CultureInfo.InvariantCulture),
};
CacheData.AddToCache(payload.Callback.State, _distributedCache, cacheData);
return Ok(response);
}
}
else
{
var message = await res.Content.ReadAsStringAsync();
_log.LogError("Unsuccesfully called Request API {message}", message);
return BadRequest(new { error = "400", error_description = message });
}
var errorResponse = await res.Content.ReadAsStringAsync();
_log.LogError("Unsuccesfully called Request API");
return BadRequest(new { error = "400", error_description = "Something went wrong calling the API: " + errorResponse });
}
catch (Exception ex)
{
return BadRequest(new { error = "400", error_description = ex.Message });
}
}
The PresentationCallback in the API controller handles the Web hook callback requests. If the presentation is successful, the data is returned and added to the cache using the state value. The UI can poll this later and use the data in the user session.
[HttpPost]
public async Task<ActionResult> PresentationCallback()
{
var content = await new StreamReader(Request.Body).ReadToEndAsync();
var verifierCallbackResponse = JsonSerializer.Deserialize<VerifierCallbackResponse>(content);
try
{
if (verifierCallbackResponse != null && verifierCallbackResponse
.RequestStatus == VerifierConst.RequestRetrieved)
{
var cacheData = new CacheData
{
Status = VerifierConst.RequestRetrieved,
Message = "QR Code is scanned. Waiting for validation...",
};
CacheData.AddToCache(verifierCallbackResponse.State, _distributedCache, cacheData);
}
if (verifierCallbackResponse != null && verifierCallbackResponse
.RequestStatus == VerifierConst.PresentationVerified)
{
var cacheData = new CacheData
{
Status = VerifierConst.PresentationVerified,
Message = "Presentation verified",
Payload = JsonSerializer.Serialize(verifierCallbackResponse.VerifiedCredentialsData),
Subject = verifierCallbackResponse.Subject
};
cacheData.Employee.Photo = verifierCallbackResponse
.VerifiedCredentialsData!.FirstOrDefault()!.Claims.Photo;
cacheData.Employee.RevocationId = verifierCallbackResponse
.VerifiedCredentialsData!.FirstOrDefault()!.Claims.RevocationId;
cacheData.Employee.PreferredLanguage = verifierCallbackResponse
.VerifiedCredentialsData!.FirstOrDefault()!.Claims.PreferredLanguage;
cacheData.Employee.Surname = verifierCallbackResponse
.VerifiedCredentialsData!.FirstOrDefault()!.Claims.Surname;
cacheData.Employee.GivenName = verifierCallbackResponse
.VerifiedCredentialsData!.FirstOrDefault()!.Claims.GivenName;
cacheData.Employee.DisplayName = verifierCallbackResponse
.VerifiedCredentialsData!.FirstOrDefault()!.Claims.DisplayName;
cacheData.Employee.Mail = verifierCallbackResponse
.VerifiedCredentialsData!.FirstOrDefault()!.Claims.Mail;
cacheData.Employee.JobTitle = verifierCallbackResponse
.VerifiedCredentialsData!.FirstOrDefault()!.Claims.JobTitle;
CacheData.AddToCache(verifierCallbackResponse.State, _distributedCache, cacheData);
}
return Ok();
}
catch (Exception ex)
{
return BadRequest(new { error = "400", error_description = ex.Message });
}
}
Authenticated session setup
The employee verifiable credential is returned and stored in a cache using a random state value. This value needs to be random with a minimum length. This is used to setup the user session. If it is possible to acquire this value in a different way, user data loss is possible. This should only be active for one time usage. This type of flow is also open to phishing attacks as it is cross device with no origin validation.
<form method="post" id="verifyEmployeePaycheck" action="" novalidate>
<input type="hidden" required id="statePresented" name="statePresented"/>
</form>
When the state is posted to the server, the user is signed in using cookies.
public async Task<IActionResult> OnPostAsync()
{
if (StatePresented == null)
{
ModelState.AddModelError("StatePresented", "no vc");
return Page();
}
var credentialData = CacheData.GetFromCache(StatePresented, _distributedCache);
var claims = new List<Claim> {
new Claim("DisplayName", credentialData!.Employee.DisplayName,
ClaimValueTypes.String, "damienbodsharepoint"),
new Claim("JobTitle", credentialData!.Employee.JobTitle,
ClaimValueTypes.String, "damienbodsharepoint"),
new Claim("PreferredLanguage", credentialData!.Employee.PreferredLanguage,
ClaimValueTypes.String, "damienbodsharepoint"),
new Claim("RevocationId", credentialData!.Employee.RevocationId,
ClaimValueTypes.String, "damienbodsharepoint"),
new Claim("GivenName", credentialData!.Employee.GivenName,
ClaimValueTypes.String, "damienbodsharepoint"),
new Claim("Mail", credentialData!.Employee.Mail,
ClaimValueTypes.String, "damienbodsharepoint"),
new Claim("Surname", credentialData!.Employee.Surname,
ClaimValueTypes.String, "damienbodsharepoint"),
new Claim("Photo", credentialData!.Employee.Photo,
ClaimValueTypes.String, "damienbodsharepoint"),
};
var userIdentity = new ClaimsIdentity(claims, "entraemployee");
var userPrincipal = new ClaimsPrincipal(userIdentity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
userPrincipal,
new AuthenticationProperties
{
ExpiresUtc = DateTime.UtcNow.AddMinutes(20),
IsPersistent = false,
AllowRefresh = false
});
CacheData.RemoveFromCache(StatePresented, _distributedCache);
return Redirect($"~/Paycheck/PaycheckDetailsS3/{PaycheckId}");
}
Run the application
A demo was deployed using one of my test tenants.
https://issueverifiableemployee.azurewebsites.net/
When the application is started, the process can be started using a paycheck ID. This all depends on how your company supports the pay data. Sometimes, only your ID is supported and the latest one could be displayed. This is always different and depends on the payment service. Some companies use a QR code on the printed pay slip.
The application verifies that you are an employee of the company using the Microsoft Entra Verified ID employee credential.
Once verified, the paycheck data can be displayed.
This demo just displays some typical data from a Swiss paycheck. This would be specific to the software used to manage the data and an API can be built to access the data on behalf of the user.
Notes
The way of verifying employees offers another way to implement access management and offers new possibilities for integrating identity checks across tenant boundaries.
Links
https://github.com/swiss-ssi-group/AzureADVerifiableCredentialsAspNetCore
https://ssi-start.adnovum.com/data
https://github.com/e-id-admin/public-sandbox-trustinfrastructure/discussions/14
https://openid.net/specs/openid-connect-self-issued-v2-1_0.html
https://identity.foundation/jwt-vc-presentation-profile/
https://github.com/Azure-Samples/active-directory-verifiable-credentials-dotnet
https://learn.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0
https://github.com/Azure-Samples/VerifiedEmployeeIssuance
https://docs.microsoft.com/azure/app-service/deploy-github-actions#configure-the-github-secret
https://issueverifiableemployee.azurewebsites.net/
Links eIDAS and EUDI standards
Draft: OAuth 2.0 Attestation-Based Client Authentication
https://datatracker.ietf.org/doc/html/draft-looker-oauth-attestation-based-client-auth-00
Draft: OpenID for Verifiable Presentations
https://openid.net/specs/openid-4-verifiable-presentations-1_0.html
Draft: OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop
Draft: OpenID for Verifiable Credential Issuance
https://openid.bitbucket.io/connect/openid-4-verifiable-credential-issuance-1_0.html
Draft: OpenID Connect for Identity Assurance 1.0
https://openid.net/specs/openid-connect-4-identity-assurance-1_0-13.html
Draft: SD-JWT-based Verifiable Credentials (SD-JWT VC)
https://vcstuff.github.io/draft-terbu-sd-jwt-vc/draft-terbu-oauth-sd-jwt-vc.html
[…] Use a Microsoft Entra Verified ID Employee credential to view paycheck data [#.NET Core #ASP.NET Core #Azure #Entra CIAM #Microsoft Entra External ID #Microsoft Entra ID #aad #aspnetcore #AzureAD #Credential #DID #dotnet #e-id #employee-id #entra #holder #iam #Identity #ion #issuer #siop #SSI #VC #verifiedid #verifier #wallet] […]