Create an OIDC credential Issuer with MATTR and ASP.NET Core

This article shows how to create and issue verifiable credentials using MATTR and an ASP.NET Core. The ASP.NET Core application allows an admin user to create an OIDC credential issuer using the MATTR service. The credentials are displayed in an ASP.NET Core Razor Page web UI as a QR code for the users of the application. The user can use a digital wallet form MATTR to scan the QR code, authenticate against an Auth0 identity provider configured for this flow and use the claims from the id token to add the verified credential to the digital wallet. In a follow up post, a second application will then use the verified credentials to allow access to a second business process.

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

Blogs in the series

Setup

The solutions involves an MATTR API which handles all the blockchain identity logic. An ASP.NET Core application is used to create the digital identity and the OIDC credential issuer using the MATTR APIs and also present this as a QR code which can be scanned. An identity provider is required to add the credential properties to the id token. The properties in a verified credential are issued using the claims values from the id token so a specific identity provider is required with every credential issuer using this technic. Part of the business of this solution is adding business claims to the identity provider. A MATTR digital wallet is required to scan the QR code, authenticate against the OIDC provider which in our case is Auth0 and then store the verified credentials to the wallet for later use.

MATTR Setup

You need to register with MATTR and create a new account. MATTR will issue you access to your sandbox domain and you will get access data from them plus a link to support.

Once setup, use the OIDC Bridge tutorial to implement the flow used in this demo. The docs are really good but you need to follow the docs exactly.

https://learn.mattr.global/tutorials/issue/oidc-bridge/issue-oidc

Auth0 Setup

A standard trusted web application which supports the code flow is required so that the MATTR digital wallet can authenticate using the identity provider and use the id token values from the claims which are required in the credential. It is important to create a new application which is only used for this because the client secret is required when creating the OIDC credential issuer and is shared with the MATTR platform. It would probably be better to use certificates instead of a shared secret which is persisted in different databases. We also use a second Auth0 application configuration to sign into the web application but this is not required to issue credentials.

In Auth0, rules are used to extend the id token claims. You need to add your claims as required by the MATTR API and your business logic for the credentials you wish to issue.

function (user, context, callback) {
    const namespace = 'https://--your-tenant--.vii.mattr.global/';
    context.idToken[namespace + 'license_issued_at'] = user.user_metadata.license_issued_at;
    context.idToken[namespace + 'license_type'] = user.user_metadata.license_type;
    context.idToken[namespace + 'name'] = user.user_metadata.name;
    context.idToken[namespace + 'first_name'] = user.user_metadata.first_name;
    context.idToken[namespace + 'date_of_birth'] = user.user_metadata.date_of_birth;
    callback(null, user, context);
}

For every user (holder) who should be able to create verifiable credentials, you must add the credential data to the user profile. This is part of the business process with this flow. If you were to implement this for a real application with lots of users, it would probably be better to integrate the identity provider into the solution issuing the credentials and add a UI for editing the user profile data which is used in the credentials. This would be really easy using ASP.NET Core Identity and for example OpenIddict or IdentityServer4. It is important that the user cannot edit this data. This logic is part of the credential issuer logic and not part of the user profile.

After creating a new MATTR OIDC credential issuer, the callback URL needs to be added to the Open ID connect code flow client used for the digital wallet sign in.

Add the URL to the Allowed Callback URLs in the settings of your Auth0 application configuration for the digital wallet.

Implementing the OpenID Connect credentials Issuer application

The ASP.NET Core application is used to create new OIDC credential issuers and also display the QR code for these so that the verifiable credential can be loaded to the digital wallet. The application requires secrets. The data is stored to a database, so that any credential can be added to a wallet at a later date and also so that you can find the credentials you created. The MattrConfiguration is the data and the secrets you received from MATTR for you account access to the API. The Auth0 configuration is the data required to sign in to the application. The Auth0Wallet configuration is the data required to create the OIDC credential issuer so that the digital wallet can authenticate the identity using the Auth0 application. This data is stored in the user secrets during development.

{
  // use user secrets
  "ConnectionStrings": {
    "DefaultConnection": "--your-connection-string--"
  },
  "MattrConfiguration": {
    "Audience": "https://vii.mattr.global",
    "ClientId": "--your-client-id--",
    "ClientSecret": "--your-client-secret--",
    "TenantId": "--your-tenant--",
    "TenantSubdomain": "--your-tenant-sub-domain--",
    "Url": "http://mattr-prod.au.auth0.com/oauth/token"
  },
  "Auth0": {
    "Domain": "--your-auth0-domain",
    "ClientId": "--your--auth0-client-id--",
    "ClientSecret": "--your-auth0-client-secret--",
  }
  "Auth0Wallet": {
    "Domain": "--your-auth0-wallet-domain",
    "ClientId": "--your--auth0-wallet-client-id--",
    "ClientSecret": "--your-auth0-wallet-client-secret--",
  }
}

Accessing the MATTR APIs

The MattrConfiguration DTO is used to fetch the MATTR account data for the API access and to use in the application.

public class MattrConfiguration
{
	public string Audience { get; set; }
	public string ClientId { get; set; }
	public string ClientSecret { get; set; }
	public string TenantId { get; set; }
	public string TenantSubdomain { get; set; }
	public string Url { get; set; }
}

The MattrTokenApiService is used to acquire an access token and used for the MATTR API access. The token is stored to a cache and only fetched if the old one has expired or is not available.

public class MattrTokenApiService
{
	private readonly ILogger<MattrTokenApiService> _logger;
	private readonly MattrConfiguration _mattrConfiguration;

	private static readonly Object _lock = new Object();
	private IDistributedCache _cache;

	private const int cacheExpirationInDays = 1;

	private class AccessTokenResult
	{
		public string AcessToken { get; set; } = string.Empty;
		public DateTime ExpiresIn { get; set; }
	}

	private class AccessTokenItem
	{
		public string access_token { get; set; } = string.Empty;
		public int expires_in { get; set; }
		public string token_type { get; set; }
		public string scope { get; set; }
	}

	private class MattrCrendentials
	{
		public string audience { get; set; }
		public string client_id { get; set; }
		public string client_secret { get; set; }
		public string grant_type { get; set; } = "client_credentials";
	}

	public MattrTokenApiService(
			IOptions<MattrConfiguration> mattrConfiguration,
			IHttpClientFactory httpClientFactory,
			ILoggerFactory loggerFactory,
			IDistributedCache cache)
	{
		_mattrConfiguration = mattrConfiguration.Value;
		_logger = loggerFactory.CreateLogger<MattrTokenApiService>();
		_cache = cache;
	}

	public async Task<string> GetApiToken(HttpClient client, string api_name)
	{
		var accessToken = GetFromCache(api_name);

		if (accessToken != null)
		{
			if (accessToken.ExpiresIn > DateTime.UtcNow)
			{
				return accessToken.AcessToken;
			}
			else
			{
				// remove  => NOT Needed for this cache type
			}
		}

		_logger.LogDebug($"GetApiToken new from oauth server for {api_name}");

		// add
		var newAccessToken = await GetApiTokenClient(client);
		AddToCache(api_name, newAccessToken);

		return newAccessToken.AcessToken;
	}

	private async Task<AccessTokenResult> GetApiTokenClient(HttpClient client)
	{
		try
		{
			var payload = new MattrCrendentials
			{
				client_id = _mattrConfiguration.ClientId,
				client_secret = _mattrConfiguration.ClientSecret,
				audience = _mattrConfiguration.Audience
			};

			var authUrl = "https://auth.mattr.global/oauth/token";
			var tokenResponse = await client.PostAsJsonAsync(authUrl, payload);

			if (tokenResponse.StatusCode == System.Net.HttpStatusCode.OK)
			{
				var result = await tokenResponse.Content.ReadFromJsonAsync<AccessTokenItem>();
				DateTime expirationTime = DateTimeOffset.FromUnixTimeSeconds(result.expires_in).DateTime;
				return new AccessTokenResult
				{
					AcessToken = result.access_token,
					ExpiresIn = expirationTime
				};
			}

			_logger.LogError($"tokenResponse.IsError Status code: {tokenResponse.StatusCode}, Error: {tokenResponse.ReasonPhrase}");
			throw new ApplicationException($"Status code: {tokenResponse.StatusCode}, Error: {tokenResponse.ReasonPhrase}");
		}
		catch (Exception e)
		{
			_logger.LogError($"Exception {e}");
			throw new ApplicationException($"Exception {e}");
		}
	}

	private void AddToCache(string key, AccessTokenResult accessTokenItem)
	{
		var options = new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromDays(cacheExpirationInDays));

		lock (_lock)
		{
			_cache.SetString(key, JsonConvert.SerializeObject(accessTokenItem), options);
		}
	}

	private AccessTokenResult GetFromCache(string key)
	{
		var item = _cache.GetString(key);
		if (item != null)
		{
			return JsonConvert.DeserializeObject<AccessTokenResult>(item);
		}

		return null;
	}
}

Generating the API DTOs using Nswag

The MattrOpenApiClientSevice file was generated using Nswag and the Open API file provided by MATTR here. We only generated the DTOs using this and access the client then using a HttpClient instance. The Open API file used in this solution is deployed in the git repo.

Creating the OIDC credential issuer

The MattrCredentialsService is used to create an OIDC credentials issuer using the MATTR APIs. This is implemented using the CreateCredentialsAndCallback method. The created callback is returned so that it can be displayed in the UI and copied to the specific Auth0 application configuration.

private readonly IConfiguration _configuration;
private readonly DriverLicenseCredentialsService _driverLicenseService;
private readonly IHttpClientFactory _clientFactory;
private readonly MattrTokenApiService _mattrTokenApiService;
private readonly MattrConfiguration _mattrConfiguration;

public MattrCredentialsService(IConfiguration configuration,
	DriverLicenseCredentialsService driverLicenseService,
	IHttpClientFactory clientFactory,
	IOptions<MattrConfiguration> mattrConfiguration,
	MattrTokenApiService mattrTokenApiService)
{
	_configuration = configuration;
	_driverLicenseService = driverLicenseService;
	_clientFactory = clientFactory;
	_mattrTokenApiService = mattrTokenApiService;
	_mattrConfiguration = mattrConfiguration.Value;
}

public async Task<string> CreateCredentialsAndCallback(string name)
{
	// create a new one
	var driverLicenseCredentials = await CreateMattrDidAndCredentialIssuer();
	driverLicenseCredentials.Name = name;
	await _driverLicenseService.CreateDriverLicense(driverLicenseCredentials);

	var callback = $"https://{_mattrConfiguration.TenantSubdomain}/ext/oidc/v1/issuers/{driverLicenseCredentials.OidcIssuerId}/federated/callback";
	return callback;
}

The CreateMattrDidAndCredentialIssuer method implements the different steps described in the MATTR API documentation for this. An access token for the MATTR API is created or retrieved from the cache and DID is created and the id from the DID post response is used to create the OIDC credential issuer. The DriverLicenseCredentials is returned which is persisted to a database and the callback is created using this object.

private async Task<DriverLicenseCredentials> CreateMattrDidAndCredentialIssuer()
{
	HttpClient client = _clientFactory.CreateClient();
	var accessToken = await _mattrTokenApiService
		.GetApiToken(client, "mattrAccessToken");
	client.DefaultRequestHeaders.Authorization =
		new AuthenticationHeaderValue("Bearer", accessToken);
	client.DefaultRequestHeaders
		.TryAddWithoutValidation("Content-Type", "application/json");

	var did = await CreateMattrDid(client);
	var oidcIssuer = await CreateMattrCredentialIssuer(client, did);

	return new DriverLicenseCredentials
	{
		Name = "not_named",
		Did = JsonConvert.SerializeObject(did),
		OidcIssuer = JsonConvert.SerializeObject(oidcIssuer),
		OidcIssuerId = oidcIssuer.Id
	};
}

The CreateMattrDid method creates a new DID as specified by the API. The MattrOptions class is used to create the request object. This is serialized using the StringContentWithoutCharset class due to a bug in the MATTR API validation. I created this class using the blog from Gunnar Peipman.

private async Task<V1_CreateDidResponse> CreateMattrDid(HttpClient client)
{
	// create did , post to dids 
	// https://learn.mattr.global/api-ref/#operation/createDid
	// https://learn.mattr.global/tutorials/dids/use-did/

	var createDidUrl = $"https://{_mattrConfiguration.TenantSubdomain}/core/v1/dids";

	var payload = new MattrOpenApiClient.V1_CreateDidDocument
	{
		Method = MattrOpenApiClient.V1_CreateDidDocumentMethod.Key,
		Options = new MattrOptions()
	};
	var payloadJson = JsonConvert.SerializeObject(payload);
	var uri = new Uri(createDidUrl);

	using (var content = new StringContentWithoutCharset(payloadJson, "application/json"))
	{
		var createDidResponse = await client.PostAsync(uri, content);

		if (createDidResponse.StatusCode == System.Net.HttpStatusCode.Created)
		{
			var v1CreateDidResponse = JsonConvert.DeserializeObject<V1_CreateDidResponse>(
					await createDidResponse.Content.ReadAsStringAsync());

			return v1CreateDidResponse;
		}

		var error = await createDidResponse.Content.ReadAsStringAsync();
	}

	return null;
}

The MattrOptions DTO is used to create a default DID using the key type “ed25519”. See the MATTR API docs for further details.

public class MattrOptions
{
  /// <summary>
  /// The supported key types for the DIDs are ed25519 and bls12381g2. 
  /// If the keyType is omitted, the default key type that will be used is ed25519.
  /// 
  /// If the keyType in options is set to bls12381g2 a DID will be created with 
  /// a BLS key type which supports BBS+ signatures for issuing ZKP-enabled credentials.
  /// </summary>
  public string keyType { get; set; } = "ed25519";
}

The CreateMattrCredentialIssuer implements the OIDC credential issuer to create the post request. The request properties need to be setup for your credential properties and must match claims from the id token of the Auth0 user profile. This is where the OIDC client for the digital wallet is setup and also where the credential claims are specified. If this is setup up incorrectly, loading the data into your wallet will fail. The HTTP request and the response DTOs are implemented using the Nswag generated classes.

private async Task<V1_CreateOidcIssuerResponse> CreateMattrCredentialIssuer(HttpClient client, V1_CreateDidResponse did)
        {
            // create vc, post to credentials api
            // https://learn.mattr.global/tutorials/issue/oidc-bridge/setup-issuer

            var createCredentialsUrl = $"https://{_mattrConfiguration.TenantSubdomain}/ext/oidc/v1/issuers";

            var payload = new MattrOpenApiClient.V1_CreateOidcIssuerRequest
            {
                Credential = new Credential
                {
                    IssuerDid = did.Did,
                    Name = "NationalDrivingLicense",
                    Context = new List<Uri> {
                         new Uri( "https://schema.org") // Only this is supported
                    },
                    Type = new List<string> { "nationaldrivinglicense" }
                },
                ClaimMappings = new List<ClaimMappings>
                {
                    new ClaimMappings{ JsonLdTerm="name", OidcClaim=$"https://{_mattrConfiguration.TenantSubdomain}/name"},
                    new ClaimMappings{ JsonLdTerm="firstName", OidcClaim=$"https://{_mattrConfiguration.TenantSubdomain}/first_name"},
                    new ClaimMappings{ JsonLdTerm="licenseType", OidcClaim=$"https://{_mattrConfiguration.TenantSubdomain}/license_type"},
                    new ClaimMappings{ JsonLdTerm="dateOfBirth", OidcClaim=$"https://{_mattrConfiguration.TenantSubdomain}/date_of_birth"},
                    new ClaimMappings{ JsonLdTerm="licenseIssuedAt", OidcClaim=$"https://{_mattrConfiguration.TenantSubdomain}/license_issued_at"}
                },
                FederatedProvider = new FederatedProvider
                {
                    ClientId = _configuration["Auth0Wallet:ClientId"],
                    ClientSecret = _configuration["Auth0Wallet:ClientSecret"],
                    Url = new Uri($"https://{_configuration["Auth0Wallet:Domain"]}"),
                    Scope = new List<string> { "openid", "profile", "email" }
                }
            };

            var payloadJson = JsonConvert.SerializeObject(payload);

            var uri = new Uri(createCredentialsUrl);

            using (var content = new StringContentWithoutCharset(payloadJson, "application/json"))
            {
                var createOidcIssuerResponse = await client.PostAsync(uri, content);

                if (createOidcIssuerResponse.StatusCode == System.Net.HttpStatusCode.Created)
                {
                    var v1CreateOidcIssuerResponse = JsonConvert.DeserializeObject<V1_CreateOidcIssuerResponse>(
                            await createOidcIssuerResponse.Content.ReadAsStringAsync());

                    return v1CreateOidcIssuerResponse;
                }

                var error = await createOidcIssuerResponse.Content.ReadAsStringAsync();
            }

            throw new Exception("whoops something went wrong");
        }

Now the service is completely ready to generate credentials. This can be used in any Blazor UI, Razor page or MVC view in ASP.NET Core. The services are added to the DI in the startup class. The callback method is displayed in the UI if the application successfully creates a new OIDC credential issuer.

private readonly MattrCredentialsService _mattrCredentialsService;
public bool CreatingDriverLicense { get; set; } = true;
public string Callback { get; set; }

[BindProperty]
public IssuerCredential IssuerCredential { get; set; }
public AdminModel(MattrCredentialsService mattrCredentialsService)
{
	_mattrCredentialsService = mattrCredentialsService;
}
public void OnGet()
{
	IssuerCredential = new IssuerCredential();
}

public async Task<IActionResult> OnPostAsync()
{
	if (!ModelState.IsValid)
	{
		return Page();
	}

	Callback = await _mattrCredentialsService
		.CreateCredentialsAndCallback(IssuerCredential.CredentialName);
	CreatingDriverLicense = false;
	return Page();
}
}

public class IssuerCredential
{
	[Required]
	public string CredentialName { get; set; }
}

Adding credentials you wallet

After the callback method has been added to the Auth0 callback URLs, the credentials can be used to add verifiable credentials to your wallet. This is fairly simple. The Razor Page uses the data from the database and generates an URL using the MATTR specification and the id from the created OIDC credential issuer. The claims from the id token or the profile data is just used to display the data for the user signed into the web application. This is not the same data which is used be the digital wallet. If the same person logs into the digital wallet, then the data is the same. The wallet authenticates the identity separately.

public class DriverLicenseCredentialsModel : PageModel
{
	private readonly DriverLicenseCredentialsService _driverLicenseCredentialsService;
	private readonly MattrConfiguration _mattrConfiguration;

	public string DriverLicenseMessage { get; set; } = "Loading credentials";
	public bool HasDriverLicense { get; set; } = false;
	public DriverLicense DriverLicense { get; set; }
	public string CredentialOfferUrl { get; set; }
	public DriverLicenseCredentialsModel(DriverLicenseCredentialsService driverLicenseCredentialsService,
		IOptions<MattrConfiguration> mattrConfiguration)
	{
		_driverLicenseCredentialsService = driverLicenseCredentialsService;
		_mattrConfiguration = mattrConfiguration.Value;
	}
	public async Task OnGetAsync()
	{
		//"license_issued_at": "2021-03-02",
		//"license_type": "B1",
		//"name": "Bob",
		//"first_name": "Lammy",
		//"date_of_birth": "1953-07-21"

		var identityHasDriverLicenseClaims = true;
		var nameClaim = User.Claims.FirstOrDefault(t => t.Type == $"https://{_mattrConfiguration.TenantSubdomain}/name");
		var firstNameClaim = User.Claims.FirstOrDefault(t => t.Type == $"https://{_mattrConfiguration.TenantSubdomain}/first_name");
		var licenseTypeClaim = User.Claims.FirstOrDefault(t => t.Type == $"https://{_mattrConfiguration.TenantSubdomain}/license_type");
		var dateOfBirthClaim = User.Claims.FirstOrDefault(t => t.Type == $"https://{_mattrConfiguration.TenantSubdomain}/date_of_birth");
		var licenseIssuedAtClaim = User.Claims.FirstOrDefault(t => t.Type == $"https://{_mattrConfiguration.TenantSubdomain}/license_issued_at");

		if (nameClaim == null
			|| firstNameClaim == null
			|| licenseTypeClaim == null
			|| dateOfBirthClaim == null
			|| licenseIssuedAtClaim == null)
		{
			identityHasDriverLicenseClaims = false;
		}

		if (identityHasDriverLicenseClaims)
		{
			DriverLicense = new DriverLicense
			{
				Name = nameClaim.Value,
				FirstName = firstNameClaim.Value,
				LicenseType = licenseTypeClaim.Value,
				DateOfBirth = dateOfBirthClaim.Value,
				IssuedAt = licenseIssuedAtClaim.Value,
				UserName = User.Identity.Name

			};
			// get per name
			//var offerUrl = await _driverLicenseCredentialsService.GetDriverLicenseCredentialIssuerUrl("ndlseven");

			// get the last one
			var offerUrl = await _driverLicenseCredentialsService.GetLastDriverLicenseCredentialIssuerUrl();

			DriverLicenseMessage = "Add your driver license credentials to your wallet";
			CredentialOfferUrl = offerUrl;
			HasDriverLicense = true;
		}
		else
		{
			DriverLicenseMessage = "You have no valid driver license";
		}
	}
}

The data is displayed using Bootstrap. If you use a MATTR wallet to scan the QR Code shown underneath, you will be redirected to authenticate against the specified Auth0 application. If you have the claims, you can add verifiable claims to you digital wallet.

Notes

MATTR API has a some problems with its API and a stricter validation would help a lot. But MATTR support is awesome and the team are really helpful and you will end up with a working solution. It would be also awesome if the Open API file could be used without changes to generate a client and the DTOs. It would makes sense, if you could issue credentials data from the data in the credential issuer application and not from the id token of the user profile. I understand that in some use cases, you would like to protect against any wallet taking credentials for other identities, but I as a credential issuer cannot always add my business data to user profiles from the IDP. The security of this solution all depends on the user profile data. If a non authorized person can change this data (in this case, this could be the same user), then incorrect verifiable credentials can be created.

Next step is to create an application to verify and use the verifiable credentials created here.

Links

https://mattr.global/

https://mattr.global/get-started/

https://learn.mattr.global/

https://keybase.io/

https://learn.mattr.global/tutorials/dids/did-key

https://gunnarpeipman.com/httpclient-remove-charset/

https://auth0.com/

2 comments

  1. […] Create an OIDC credential Issuer with Mattr and ASP.NET Core (Damien Bowden) […]

  2. […] Create an OIDC credential Issuer with Mattr and ASP.NET Core – Damien Bowden […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

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

%d bloggers like this: