Issue Employee verifiable credentials using Entra Verified ID and ASP.NET Core

This article shows how to implement verifiable credentials using Microsoft Entra Verified ID and ASP.NET Core to issue the employee credentials. This solution uses a self sovereign identity (SSI) based technical stack built using open standards and some of the SSI concepts. The credential can be loaded into a wallet belonging to a holder and used at a later stage. In the follow up blogs, examples of using the verifiable credential will be explored.

The Microsoft Entra verified ID services can only be used, if you have an Azure AD subscription attached to your tenant.

Code: https://github.com/swiss-ssi-group/EntraVerifiedEmployee

History

2023-12-03 Updated to .NET 8

Setup verified ID employee

The standard Entra Verified employee credential is used and this scheme cannot be changed. This is a fixed credential created by Microsoft and will hopefully be supported by other solution creators.

All vendors produce their own schemes, wallets and ledgers and it is hoped that some of these will work together in the future. Entra verified ID will hopefully work with the future Swiss E-ID and the EU E-ID. The Verified employee credential will work good because most companies have M365 licenses and can use this credential based on the Azure AD accounts. A company could create this credential in the HR employee onboarding process based on a government E-ID or a manual onboarding process.

The Verified employee credential has eight fixed claim types. The UPN is used for the revocationId. Most of these claims can be set without problem, although the photo and the preferredLanguage requires some extra logic to set for the different Azure AD user accounts depending on what Microsoft licenses the company has. You can also use any user account data source to produce the verifiable credential, it does not need to be an Azure AD user account.

The manifest contains the scheme definition. The claims and the types can be viewed.

"claims": {
      "vc.credentialSubject.givenName": {
        "type": "String",
        "label": "Name"
      },
      "vc.credentialSubject.surname": {
        "type": "String",
        "label": "Surname"
      },
      "vc.credentialSubject.mail": {
        "type": "String",
        "label": "Email"
      },
      "vc.credentialSubject.jobTitle": {
        "type": "String",
        "label": "Job title"
      },
      "vc.credentialSubject.photo": {
        "type": "image/jpg;base64url",
        "label": "User picture"
      },
      "vc.credentialSubject.displayName": {
        "type": "String",
        "label": "Display name"
      },
      "vc.credentialSubject.preferredLanguage": {
        "type": "String",
        "label": "Preferred language"
      },
      "vc.credentialSubject.revocationId": {
        "type": "String",
        "label": "Revocation id"
      }
    },

Issue employee verifiable credentials using ASP.NET Core

Issuing an verifiable credential is a more complex flow to implement compared with other authentication and identity validation systems. The following steps are required to implement this:

  • Initialize the flow using a HTTP request in the Issuer web application
  • Get and validate the data required to issue the VC to the wallet
  • Create the payload and send a HTTP post to the Microsoft Entra Verified ID API
  • Persist this state to cache
  • Present the Entra Verified ID API response in the UI for the wallet to complete (usually a QR code)
  • Scan the QR code using the wallet and add the credential to the wallet
  • Use a web hook to receive the Entra Verified ID update requests
  • Process HTTP requests and persist this if required, update the cache (Public endpoint required for this)

Note: the following example was built based on this example from Azure (and other Azure samples):

https://github.com/Azure-Samples/VerifiedEmployeeIssuance

Initialize the flow using a HTTP request in the Issuer web application, get and validate the data required to issue the VC to the wallet

The Get method handles the request and gets the data from the database or whatever source you use. This is returned as the employee data to the UI. The view can be used to start the verifiable credential request.

public async Task OnGetAsync()
{
	var oid = User.Claims
		.FirstOrDefault(t => t.Type == Consts.OID_TYPE);
	
	var employeeData = await _microsoftGraphDelegatedClient
		.GetEmployee(oid!.Value);

	if (employeeData.Employee != null)
	{
		Employee = new Employee
		{
			DisplayName = employeeData.Employee.DisplayName!,
			GivenName = employeeData.Employee.GivenName!,
			JobTitle = employeeData.Employee.JobTitle!,
			Surname = employeeData.Employee.Surname!,
			PreferredLanguage = employeeData.Employee.PreferredLanguage!,
			Mail = employeeData.Employee.Mail!,
			RevocationId = employeeData.Employee.RevocationId!,
			Photo = employeeData.Employee.Photo,
			AccountEnabled = employeeData.Employee.AccountEnabled
		};
		EmployeeMessage = "Add your employee credentials to your wallet";
		HasEmployee = true;
		Photo = Base64UrlEncoder.DecodeBytes(Employee.Photo);
	}
	else
	{
		EmployeeMessage = 
			$"You have no valid employee, Error: {employeeData.Error}";
		if(employeeData.Error!.Contains("Preferred Language"))
		{
			PreferredLanguageMissing = true;
		}
	}
}

The data used for creating the verifiable credential can be requested from anywhere. I used Microsoft Graph and the user account data. The photo needs to be an URL encoded base64 string. I used the Base64UrlEncoder class to implement this. You can use your own database or use any system for this.

public async Task<(Employee? Employee, string? Error)> 
	GetEmployee(string? oid)
{
	if (oid == null) return (null, "OID not defined");

	var photo = string.Empty;
	try
	{
		photo = await GetGraphApiProfilePhoto(oid);
	}
	catch (Exception)
	{
		return (null, "User MUST have a photo...");
	}

	var user =  await _graphServiceClient.Users[oid]
		.GetAsync((requestConfiguration) =>
		{
			requestConfiguration.QueryParameters.Select = new string[] { 
				"id", "givenName", "surname", "jobTitle", "displayName",
				"mail",  "employeeId", "employeeType", "otherMails",
				"mobilePhone", "accountEnabled", "photo", "preferredLanguage",
				"userPrincipalName", "identities"};
			requestConfiguration.Headers.Add("ConsistencyLevel", "eventual");
		});

	if (user!.PreferredLanguage == null)
	{
		return (null, "No Preferred Language defined...");
	}

	if (user!.JobTitle == null)
	{
		return (null, "No JobTitle defined...");
	}

	if (user!.Surname == null)
	{
		return (null, "No Surname defined for the user, add this please");
	}

	if (user!.GivenName == null)
	{
		return (null, "No GivenName defined for the user, add this please");
	}

	if (user!.DisplayName == null)
	{
		return (null, "No DisplayName defined...");
	}

	if (user!.UserPrincipalName == null)
	{
		return (null, "No UserPrincipalName defined...");
	}

	var employee = new Employee
	{
		DisplayName = user.DisplayName,
		GivenName = user.GivenName,
		JobTitle = user.JobTitle,
		Surname = user.Surname,
		PreferredLanguage = user.PreferredLanguage,
		RevocationId = user.UserPrincipalName,
		Photo = photo,
		AccountEnabled = user.AccountEnabled.GetValueOrDefault()
	};

	if (user.Mail != null)
	{
		employee.Mail = user.Mail;
	}
	else
	{
		var otherMail = user.OtherMails!.FirstOrDefault();
		if (otherMail != null)
		{
			employee.Mail = otherMail;
		}
		else
		{
			var validEmail = IsEmailValid(user.UserPrincipalName);
			if (validEmail)
			{
				employee.Mail = user.UserPrincipalName;
			}
			else
			{
				return (null, "No Mail defined...");
			}
		}
	}

	return (employee, null);
}

public async Task<string> GetGraphApiProfilePhoto(string oid)
{
    var photo = string.Empty;
    byte[] photoByte;

    var streamPhoto = new MemoryStream();
    using (var photoStream = await _graphServiceClient.Users[oid].Photo
        .Content.GetAsync())
    {
        photoStream!.CopyTo(streamPhoto);
        photoByte = streamPhoto!.ToArray();
    }

    using var imageFromFile = new MagickImage(photoByte);
    // Sets the output format to jpeg
    imageFromFile.Format = MagickFormat.Jpeg;
    var size = new MagickGeometry(400, 400);

    // This will resize the image to a fixed size without maintaining the aspect ratio.
    // Normally an image will be resized to fit inside the specified size.
    //size.IgnoreAspectRatio = true;

    imageFromFile.Resize(size);

    // Create byte array that contains a jpeg file
    var data = imageFromFile.ToByteArray();
    photo = Base64UrlEncoder.Encode(data);

    return photo;
}

Create the payload and send a HTTP post to the Entra Verified ID API, persist this state to cache

The GetIssuanceRequestPayloadAsync sends the HTTP Post request to the Microsoft Entra Verified ID API.

var payload = await _issuerService.GetIssuanceRequestPayloadAsync(Request);

var (Token, Error, ErrorDescription) = await _issuerService.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);
var response = await res.Content.ReadFromJsonAsync<IssuanceResponse>();

if (response == null)
	return BadRequest(new { error = "400", error_description = "no response from VC API" });

if (res.StatusCode == HttpStatusCode.Created)
{
	_log.LogTrace("succesfully called Request API");

	if (payload.Pin.Value != null)
	{
		response.Pin = payload.Pin.Value;
	}

	response.Id = payload.Callback.State;

	var cacheData = new CacheData
	{
		Status = IssuanceConst.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 = "Something went wrong calling the API: " + response });
}

The GetIssuanceRequestPayloadAsync method creates the payload body for the Entra Verified ID API request. The API is well documented on Azure.

public async Task<IssuanceRequestPayload> 
	GetIssuanceRequestPayloadAsync(HttpRequest request)
{
	var payload = new IssuanceRequestPayload();

	var length = 4;
	var pinMaxValue = (int)Math.Pow(10, length) - 1;
	var randomNumber = RandomNumberGenerator.GetInt32(1, pinMaxValue);
	var newpin = string.Format(CultureInfo.InvariantCulture,
		"{0:D" + length.ToString(CultureInfo.InvariantCulture) + "}", 
		randomNumber);

	payload.Pin.Length = length;
	payload.Pin.Value = newpin;

	payload.CredentialsType = "VerifiedEmployee";

	payload.Manifest = $"{_credentialSettings
		.CredentialManifest}{"?manifestType=claimInjection"}";

	var host = GetRequestHostName(request);
	payload.Callback.State = Guid.NewGuid().ToString();
	payload.Callback.Url = $"{host}/api/issuer/issuanceCallback";
	payload.Callback.Headers.ApiKey = _credentialSettings.VcApiCallbackApiKey;

	payload.Registration.ClientName = "Verifiable Credential Employee";
	payload.Authority = _credentialSettings.IssuerAuthority;

	var oid = request.HttpContext.User
		.Claims.FirstOrDefault(t => t.Type == Consts.OID_TYPE);
	
	var (Employee, Error) = await _microsoftGraphDelegatedClient
		.GetEmployee(oid!.Value);

	if (Employee != null)
	{
		payload.Claims.GivenName = Employee.GivenName;
		payload.Claims.Surname = Employee.Surname;
		payload.Claims.Mail = Employee.Mail;
		payload.Claims.JobTitle = Employee.JobTitle;
		payload.Claims.Photo = Employee.Photo;
		payload.Claims.DisplayName = Employee.DisplayName;
		payload.Claims.PreferredLanguage = Employee.PreferredLanguage;
		payload.Claims.RevocationId = Employee.RevocationId;

		return payload;
	}

	throw new ArgumentNullException(nameof(Employee));
}

Present the Entra Verified ID API response in the UI for the wallet to complete (usually a QR code)

Once the flow is started, the response is handled and displayed in the UI to complete the process on the wallet.

The IssuanceResponse is used to update the UI with the state of the flow.

[HttpGet("/api/issuer/issuance-response")]
public ActionResult IssuanceResponse()
{
	try
	{
		string? state = Request.Query["id"];
		if (state == null)
		{
			return BadRequest(new { error = "400", 
				error_description = "Missing argument 'id'" });
		}

		var data = CacheData.GetFromCache(state, _distributedCache);
		if (data != null)
		{
			Debug.WriteLine("check if there was a response yet: " + data);
			return new ContentResult
			{
				ContentType = "application/json",
				Content = JsonSerializer.Serialize(data)
			};
		}

		return Ok();
	}
	catch (Exception ex)
	{
		return BadRequest(new { error = "400",
			error_description = ex.Message });
	}
}

Scan the QR code using the wallet and add the credential to the wallet

The user scans the code and completes the process in the wallet on the cross device.

Use a web hook to receive the Entra Verified ID update requests, process HTTP requests and persist this if required, update the cache (Public endpoint required for this)

The IssuanceCallback method is used to handle the callback requests from the Microsoft Entra Verified ID API. This persists the data and updates the cache for the flow.

[AllowAnonymous]
[HttpPost("/api/issuer/issuanceCallback")]
public async Task<ActionResult> IssuanceCallback()
{
	var content = await new StreamReader(Request.Body)
		.ReadToEndAsync();
	var issuanceResponse = JsonSerializer
		.Deserialize<IssuanceCallbackResponse>(content);

	if (issuanceResponse?.RequestStatus 
		== IssuanceConst.RequestRetrieved)
	{
		var cacheData = new CacheData
		{
			Status = IssuanceConst.RequestRetrieved,
			Message = "QR Code is scanned. Waiting for issuance...",
		};
		CacheData.AddToCache(issuanceResponse.State, 
			_distributedCache, cacheData);
	}

	if (issuanceResponse?.RequestStatus 
		== IssuanceConst.IssuanceSuccessful)
	{
		var cacheData = new CacheData
		{
			Status = IssuanceConst.IssuanceSuccessful,
			Message = "Credential successfully issued",
		};
		CacheData.AddToCache(issuanceResponse.State, 
			_distributedCache, cacheData);
	}

	if (issuanceResponse?.RequestStatus 
		== IssuanceConst.IssuanceError)
	{
		var cacheData = new CacheData
		{
			Status = IssuanceConst.IssuanceError,
			Payload = issuanceResponse.Error?.Code,
			Message = issuanceResponse.Error?.Message
		};
		CacheData.AddToCache(issuanceResponse.State, 
			_distributedCache, cacheData);
	}

	return Ok();

Standards, Scheme and interoperability

Microsoft Entra Verified ID is built using open standards. This is really great. A lot of the other vendors producing SSI products also build the products based on open standards as well. The big problem is that no vendor seems to work using the same standards or have interoperability. It is very hard to see how one product can be used with another product. I hope that the EU E-ID, Swiss E-ID are supported by all vendors. This would require that the government build the products using modern standards and not out-of-date ones. It is also very hard to implement this, the ledgers are complicated and expensive to implement. Using SSI, you probably need to trust a third party service which has costs and requires a lot of trust. Do the vendors manage the certificates and secrets correctly? Some vendors use passwords to authenticate to the user accounts. The security of the solutions are also lower compared to existing solutions. SSI has no way of protecting against phishing and requires further authentication standards like FIDO2 to be safe to use, especially when handling data like a user E-ID. We teach end users to never trust or scan a QR code for authentication. This is used in most SSI implementations. A user must authenticate in some other way before or after scanning the QR Code using a phishing resistant authentication.

Testing

When the example is run using a public endpoint, the credentials can be issued and added to the wallet. I used ngrok for local development. The credential would display the data. In the image below, I display a driving license credential created the same way.

Notes, next steps

Now that the VC can be issued, this can be used implement business flows for employees.

Self sovereign identity looks like a really promising identity solution. I am not sure this tech stack will make the impact or improve the identity business flows as hoped. The main problem with this solution stack is almost no vendor solution works with any other vendor solution. It is expensive to implement your own ledger and the vendor choice for the ledger locks you down to the wallet implementation. For a tech stack which is built using standards, it is strange that no two solutions work together. We have to wait and hope that interoperability will be solved with the introduction of government E-IDs.

Links:

https://learn.microsoft.com/en-us/azure/active-directory/verifiable-credentials/how-to-use-quickstart-multiple

https://github.com/swiss-ssi-group/AzureADVerifiableCredentialsAspNetCore

https://learn.microsoft.com/en-us/azure/active-directory/verifiable-credentials/decentralized-identifier-overview

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://learn.microsoft.com/en-us/azure/active-directory/verifiable-credentials/verifiable-credentials-standards

https://github.com/Azure-Samples/active-directory-verifiable-credentials-dotnet

https://aka.ms/mysecurityinfo

https://fontawesome.com/

https://developer.microsoft.com/en-us/graph/graph-explorer?tenant=damienbodsharepoint.onmicrosoft.com

https://learn.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0

https://github.com/Azure-Samples/VerifiedEmployeeIssuance

https://github.com/AzureAD/microsoft-identity-web/blob/jmprieur/Graph5/src/Microsoft.Identity.Web.GraphServiceClient/Readme.md#replace-the-nuget-packages

https://docs.microsoft.com/azure/app-service/deploy-github-actions#configure-the-github-secret

https://issueverifiableemployee.azurewebsites.net/

https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/

Links Swiss E-ID

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

RFC: 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.github.io/OpenID4VCI/openid-4-verifiable-credential-issuance-wg-draft.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://www.ietf.org/archive/id/draft-terbu-oauth-sd-jwt-vc-00.html

5 comments

  1. […] Issue Employee verifiable credentials using Entra Verified ID and ASP.NET Core [#.NET #.NET Core #ASP.NET Core #Azure #Azure AD #aad #aspnetcore #AzureAD #Credential #DID #dotnet #e-id #employee-id #entra #holder #iam #Identity #ion #issuer #siop #SSI #VC #verifiedid #verifier #wallet] […]

  2. […] Issue Employee verifiable credentials using Entra Verified ID and ASP.NET Core (Damien Bowden) […]

  3. […] Issue Employee verifiable credentials using Entra Verified ID and ASP.NET Core – Damien Bowden […]

  4. […] Issue Employee verifiable credentials using Entra Verified ID and ASP.NET Core […]

  5. […] Issue Employee verifiable credentials using Entra Verified ID and ASP.NET Core […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.