OpenIddict aims at providing a versatile solution to implement OpenID Connect client, server and token validation support in any .NET application. I’ll describe in this article the steps to achieve a simple use case.
I needed to create a simple RESTFul application that would allow me to mock a webservice provided by a client but inaccessible from my environment.
Also, some endpoints would be queried through Basic Authentication and others through the OpenID OAuth2 standard.
It wasn’t easy because at the same time, I was struggling with:
Finding the application logs in my App Service to debug the deployment to Azure.
Adding the right links between resources with very limited permission in my organization.
An SQL Server Instance to create a new database, but I suppose you could use a MySQL or any other driver. You need it for the OpenIddict database where you store the application registrations and tokens. Alternatively, you can use an SQLite database, but I couldn’t create a storage account to store the file in my case and I already had an SQL Server provisioned.
Create the openiddict Database
I’ll use an SQL Server to host the data for OpenIddict. As stated above, you can choose another driver.
You’ll need to provision it before you continue.
Here are the steps:
Go to the Azure Portal.
Browse to SQL Databases
Click Create
Note: in case you don’t have an SQL Instance yet, the Azure Portal will request you to create it. Use the SQL authentication to create the credentials to connect the instance in SQL Server Management Studio (in short SSMS) and through the application.
Under Basics tab
Make sure to select the Subscription and Ressource Group:
Your target Subscription.
Your target Ressource Group.
Set the database name to openiddict.
Set the server instance (or create one).
Leave Want to use SQL elastic pool to No.
Leave the Workload environment to Development.
Configure the Compute + storage to the Basic tier with 500 MB Storage.
Choose Locally-redundant backup storage.
Under Networking tab
Leave the defaults as they are.
Under Security tab
Leave the defaults as they are.
Under Additional settings tab
Set the Collation to French_CI_AS or any value you prefer.
usingDemoWebApiWithOpenIddict.Models;usingMicrosoft.EntityFrameworkCore;usingMicrosoft.IdentityModel.Protocols.Configuration;usingvarloggerFactory=LoggerFactory.Create(loggingBuilder=>loggingBuilder.SetMinimumLevel(LogLevel.Trace).AddConsole());ILoggerlogger=loggerFactory.CreateLogger<Program>();logger.LogInformation("Program.cs logger ready :)");logger.LogInformation("Program.cs > init builder...");builder.Services.AddControllers();builder.Services.AddDbContext<ApplicationDbContext>(options=>{// Configure the context to use sql server.vardbServer=RetrieveValueFromConfig(builder,"DbServer",logger);vardbUser=RetrieveValueFromConfig(builder,"DbUser",logger);vardbPassword=RetrieveValueFromConfig(builder,"DbPassword",logger);varconnectionString=builder.Configuration.GetConnectionString("DefaultConnection")!;options.UseSqlServer(string.Format(connectionString,dbServer,dbUser,dbPassword));// Register the entity sets needed by OpenIddict.// Note: use the generic overload if you need// to replace the default OpenIddict entities.options.UseOpenIddict();});staticstringRetrieveValueFromConfig(WebApplicationBuilderbuilder,stringkey,ILoggerlogger){varkeyValue=builder.Configuration[key];returnkeyValue??thrownewConfigurationErrorsException($"Missing <{key}> environment value in App Service");}
You need to declare the ConnectionString in your appsettings.json.
On the App Service, we need to add the 3 variables above under the Environment variables blade of the App Service.
Click “+ Add” for each variable and provide its name and value as defined in the JSON file. By default, I check the “Deployment slot setting” to make sure Microsoft Azure uses the setting when creating a slot (another topic on its own…).
To test, we’ll need to complete a few more steps.
Note: you could store the ConnectionString in a KeyVault. I chose the environment variables to store the server, the user and the password for simplicity.
The Key Vault
Why a Key Vault
To protect the tokens generated, the OpenIddict client and server stacks use 2 types of credentials:
Signing credentials are used to protect against tampering. They can be either asymmetric (e.g., a RSA or ECDSA key) or symmetric.
Encryption credentials are used to ensure the content of tokens can’t be read by malicious parties. They can be either asymmetric (e.g., a RSA key) or symmetric.
To read the certificates from the application in Azure.
To generate the certificates, you need an access policy and depending on your seniority in your organization, you may not have the permission to list the users or applications to whom you need to assign the policy.
So first, under the newly created Key Vault, go to the Access Policies blade and click “+ Create”.
We’ll add the policy to the certificate creator (you), so if you end up not being able to validate the creation, ask your manager.
In the form,
Select Select all as Permissions.
Then, select the Principal or the user using your full email address.
Leave the Application blank.
Finish by clicking Create.
Next, add another policy to the App Service resource.
Before that, make sure to enable the System assigned identity under the App Service and the blade Identity. It’ll generate a Principal (Object) ID you’ll use to search the Principal to assign the policy to.
For this policy, assign the permissions “Get” and “List”.
Just in Case…
While I was doing this myself for the first time, I thought I need to add role-based permissions for myself and the App Service. I added:
“Key Vault Administrator” to myself.
“Key Vault Cerfitificates User” and “Key Vault Cerfitificates Officer“ to the App Service.
I don’t think you need. But just in case…
Generate the Certificates
Now navigate to the Certificates blade in your Key Vault and:
Click “+ Generate/Import”.
Leave the method to Generate.
Provide a name like “certificate-openiddict-encryption”.
Leave the type of certificate to Self-signed certificate unless you can provide an authority.
Set the Subject to your App Service full domain name. So you could have for example CN=your-appservice-fcg3bqdgbme3dchd.westeurope-01.azurewebsites.net. Please adjust the URI to your App Service URI.
Choose the Validity Period.
Leave the Lifetime Action Type as it is, e.g., Automatically renew at a given percentage lifetime.
Confirm with a click on Create.
Repeat the steps but name the second certificate “certificate-openiddict-signing”.
Integrate the OpenIddict Solution to Enable OpenID on the Project
Back to the Program.cs, we first add the required packages:
usingSystem.Security.Cryptography.X509Certificates;staticvoidSetupOpenIddict(WebApplicationBuilderbuilder,ILoggerlogger){builder.Services.AddOpenIddict().AddCore(options=>{// Configure OpenIddict to use the Entity Framework Core stores and models.// Note: call ReplaceDefaultEntities() to replace the default OpenIddict entities.options.UseEntityFrameworkCore().UseDbContext<ApplicationDbContext>();})// Register the OpenIddict server components..AddServer(options=>{// And enable the token endpoint.options.SetTokenEndpointUris("connect/token");// Then, enable the client credentials flow.options.AllowClientCredentialsFlow();// Then, register the signing and encryption credentials.if(builder.Environment.IsDevelopment()){// Locally, use the certificates provided through OpenIddict.// They won't work in Production.options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate();}else{// In Production, use the certificates read from the Key Vault.logger.LogInformation("SetupOpenIddict > AddServer > Not Development...");string?keyVaultUri=RetrieveValueFromConfig(builder,"OpenIddict:KeyVaultUri",logger);logger.LogInformation($"SetupOpenIddict > AddServer > OpenIddict:KeyVaultUri is <{keyVaultUri}>");// Initialize the Certificate Client to query the Key VaultvarcertClient=newCertificateClient(newUri(keyVaultUri),newDefaultAzureCredential());logger.LogInformation("SetupOpenIddict > AddServer > KeyValult client to read certificates = OK!");// Load encryption certificatevaropenIddict_EncryptionCertificateName=RetrieveValueFromConfig(builder,"OpenIddict:EncryptionCertificateName",logger);logger.LogInformation($"SetupOpenIddict > AddServer > OpenIddict:EncryptionCertificateName is <{openIddict_EncryptionCertificateName}>");try{// Download the full certificate that includes the private key,// required for OpenIddict. GetCertificate isn't enough and doesn't// contain the private key.varencryptionCert=certClient.DownloadCertificate(openIddict_EncryptionCertificateName).Value;logger.LogInformation($"SetupOpenIddict > AddServer > read encryption cert: <{encryptionCert}>");options.AddEncryptionCertificate(newX509Certificate2(encryptionCert));logger.LogInformation("SetupOpenIddict > AddServer > AddEncryptionCertificate = OK!");}catch(Exceptionex){logger.LogError(ex.Message,ex.StackTrace);throw;}// Load signing certificatevaropenIddict_SigningCertificateName=RetrieveValueFromConfig(builder,"OpenIddict:SigningCertificateName",logger);logger.LogInformation($"SetupOpenIddict > AddServer > OpenIddict:SigningCertificateName is <{openIddict_SigningCertificateName}>");try{varsigningCert=certClient.DownloadCertificate(openIddict_SigningCertificateName).Value;logger.LogInformation($"SetupOpenIddict > AddServer > read encryption cert: <{signingCert}>");options.AddSigningCertificate(newX509Certificate2(signingCert));logger.LogInformation("SetupOpenIddict > AddServer > AddSigningCertificate = OK!");}catch(Exception){throw;}}// Register the ASP.NET Core host and// configure the ASP.NET Core-specific options.options.UseAspNetCore().EnableTokenEndpointPassthrough();})// Register the OpenIddict validation components..AddValidation(options=>{// Import the configuration from the local OpenIddict// server instance.// Basically, the IIS Express or the App Service is the// OpenID server in parallel to your Web API.options.UseLocalServer();// Register the ASP.NET Core host.options.UseAspNetCore();});}
You may have noticed you need to define some configuration key in appsettings.json. Here they are:
1
2
3
4
5
6
7
8
9
{// ... the rest of your file
"OpenIddict":{"KeyVaultUri":"https://kcdemo.vault.azure.net/","EncryptionCertificateName":"certificat-openiddict-encryption","SigningCertificateName":"certificat-openiddict-signing"}// ... the rest of your file
}
The names of the certificates are important. They must match the name provided on certificate creation earlier.
Next, you could set the values in the environment variables. To do so, adjust the configuration keys and the way you read them in the code (OpenIddict:KeyVaultUri vs OpenIddict_KeyVaultUri):
1
2
3
4
5
6
7
{// ... the rest of your file
"OpenIddict_KeyVaultUri":"https://kcdemo.vault.azure.net/","OpenIddict_EncryptionCertificateName":"certificat-openiddict-encryption","OpenIddict_SigningCertificateName":"certificat-openiddict-signing"// ... the rest of your file
}
Seed the Database With a Demo Application Registration
To be able to test later, we need to tell OpenIddict who may authenticate and get a token.
To do so, let’s craft a Seeder.cs file at the root of the WebApi project:
usingSystem;usingSystem.Threading;usingSystem.Threading.Tasks;usingDemoWebApiWithOpenIddict.Models;usingMicrosoft.Extensions.DependencyInjection;usingMicrosoft.Extensions.Hosting;usingOpenIddict.Abstractions;usingstaticOpenIddict.Abstractions.OpenIddictConstants;namespaceDemoWebApiWithOpenIddict.Core;publicclassOpenIddictSeeder:IHostedService{privatereadonlyIServiceProvider_serviceProvider;publicOpenIddictSeeder(IServiceProviderserviceProvider)=>_serviceProvider=serviceProvider;publicasyncTaskStartAsync(CancellationTokencancellationToken){awaitusingvarscope=_serviceProvider.CreateAsyncScope();varcontext=scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();awaitcontext.Database.EnsureCreatedAsync();varmanager=scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();varapplication=awaitmanager.FindByClientIdAsync("console");if(application==null){awaitCreateApplication(manager);}else{awaitmanager.DeleteAsync(application);awaitCreateApplication(manager);}}privatestaticasyncTaskCreateApplication(IOpenIddictApplicationManagermanager){awaitmanager.CreateAsync(newOpenIddictApplicationDescriptor{// The ClientId and ClientSecret will be used in the client later in the article.ClientId="console",ClientSecret="388D45FA-B36B-4988-BA59-B187D329C207",DisplayName="Demo OAuth2 App For RefList",Permissions={Permissions.Endpoints.Token,Permissions.GrantTypes.ClientCredentials}});}publicTaskStopAsync(CancellationTokencancellationToken)=>Task.CompletedTask;}
Note: The ClientSecret is a random GUID.
By the way, the client secret should be:
Sufficiently random and not guessable.
Generated using a cryptographically secure method.
At least 256 bits long, typically represented as a 64-character hexadecimal string.
# Browse to your project first, if you're in a large solutioncd DemoWebApiWithOpenIddict# Then create the migrationdotnetefmigrationsaddInitOpenIddict--contextDemoWebApiWithOpenIddict.Models.ApplicationDbContext--output-dir./Migrations# And create the SQL file to run manually or through DbUp, if you use it.# The "0" means we ask to generate the first migration# The "-i" option tell EF to generate a script that you can run multiple times (e.g. reset)dotnetefmigrationsscript0InitOpenIddict--contextDemoWebApiWithOpenIddict.Models.ApplicationDbContext-o./SQL/Patch/001-init-openiddict-tables.sql-i
Just in case, edit the .csproj so you have the following if the migration commands fail:
/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/openiddict/openiddict-core for more information concerning
* the license and the contributors participating to this project.
*/usingSystem.Security.Claims;usingDemoWebApiWithOpenIddict.Helpers;usingMicrosoft.AspNetCore;usingMicrosoft.AspNetCore.Mvc;usingMicrosoft.IdentityModel.Tokens;usingOpenIddict.Abstractions;usingOpenIddict.Server.AspNetCore;usingstaticOpenIddict.Abstractions.OpenIddictConstants;namespaceDemoWebApiWithOpenIddict.Controllers;publicclassAuthorizationController:Controller{privatereadonlyIOpenIddictApplicationManager_applicationManager;privatereadonlyIOpenIddictScopeManager_scopeManager;publicAuthorizationController(IOpenIddictApplicationManagerapplicationManager,IOpenIddictScopeManagerscopeManager){_applicationManager=applicationManager;_scopeManager=scopeManager;} [HttpPost("~/connect/token"), IgnoreAntiforgeryToken, Produces("application/json")]publicasyncTask<IActionResult>Exchange(){varrequest=HttpContext.GetOpenIddictServerRequest();if(request.IsClientCredentialsGrantType()){// Note: the client credentials are automatically validated by OpenIddict:// if client_id or client_secret are invalid, this action won't be invoked.varapplication=await_applicationManager.FindByClientIdAsync(request.ClientId);if(application==null){thrownewInvalidOperationException("The application details cannot be found in the database.");}// Create the claims-based identity that will be used by OpenIddict to generate tokens.varidentity=newClaimsIdentity(authenticationType:TokenValidationParameters.DefaultAuthenticationType,nameType:Claims.Name,roleType:Claims.Role);// Add the claims that will be persisted in the tokens (use the client_id as the subject identifier).identity.SetClaim(Claims.Subject,await_applicationManager.GetClientIdAsync(application));identity.SetClaim(Claims.Name,await_applicationManager.GetDisplayNameAsync(application));// Note: In the original OAuth 2.0 specification, the client credentials grant// doesn't return an identity token, which is an OpenID Connect concept.//// As a non-standardized extension, OpenIddict allows returning an id_token// to convey information about the client application when the "openid" scope// is granted (i.e specified when calling principal.SetScopes()). When the "openid"// scope is not explicitly set, no identity token is returned to the client application.// Set the list of scopes granted to the client application in access_token.identity.SetScopes(request.GetScopes());identity.SetResources(await_scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());identity.SetDestinations(GetDestinations);returnSignIn(newClaimsPrincipal(identity),OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}thrownewNotImplementedException("The specified grant type is not implemented.");}privatestaticIEnumerable<string>GetDestinations(Claimclaim){// Note: by default, claims are NOT automatically included in the access and identity tokens.// To allow OpenIddict to serialize them, you must attach them a destination, that specifies// whether they should be included in access tokens, in identity tokens or in both.returnclaim.Typeswitch{Claims.NameorClaims.Subject=>[Destinations.AccessToken,Destinations.IdentityToken],_=>[Destinations.AccessToken],};}}
Important: the endpoint you specified in the Program.cs (options.SetTokenEndpointUris) must match the endpoint in this controller.
Modify the WeatherForecast Controller
To authorize requests from authenticated clients using a valid token, let’s add the Authorize attribute:
1
2
3
4
5
6
7
8
9
10
namespaceDemoWebApiWithOpenIddict.Controllers{ [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] [ApiController] [Route("[controller]")]
publicclassWeatherForecastController:ControllerBase{// Your controller's code}}
You can place the attribute on some methods if not all of them require authorization. Similarly, you could have controllers not requiring any OAuth2 authorization.
Now, launch your application locally: when you load the /weatherforecast endpoint, you should get an error HTTP 401. We expected that!
Test the Implementation
Test Locally
To test your Web API locally, use this simple console application code and select the first URL (adjust them to your environment 😉).
usingSystem.Net.Http.Headers;usingMicrosoft.Extensions.DependencyInjection;usingOpenIddict.Client;staticstring?PickUrl(){Console.WriteLine("Please select a URL:");Console.WriteLine("1. https://localhost:7129 (Make sure you are running it locally)");Console.WriteLine("2. https://your-app-service-efgmfncjguejeaes.westeurope-01.azurewebsites.net");while(true){Console.Write("\nEnter 1 or 2: ");stringchoice=Console.ReadLine();returnchoiceswitch{"1"=>"https://localhost:7129","2"=>"https://your-app-service-efgmfncjguejeaes.westeurope-01.azurewebsites.net",_=>null};}}varhost=PickUrl();varnoPick=host==null;varpickAttempts=0;varmaxPickAttempts=5;while(noPick){host=PickUrl();noPick=host==null;pickAttempts++;if(pickAttempts>=maxPickAttempts){Console.Write("\nFollow instructions... Restart the app :)");Console.ReadLine();}}if(host==null)return;Console.WriteLine($"\nWebApi picked: {host}");ServiceCollectionservices=ConfigureValidServices(host);awaitusingvarprovider=services.BuildServiceProvider();vartoken=awaitGetTokenAsync(provider);Console.WriteLine("Access token: {0}",token);Console.WriteLine();varresponse=awaitGetResourceAsync(provider,token,host!,"/weatherforecast");Console.WriteLine("API response: {0}",response);Console.WriteLine();Console.WriteLine("Press key to test oauth-protected endpoint");Console.ReadLine();response=awaitGetResourceAsync(provider,token,host,"/weatherforecast",false);Console.WriteLine("API response: {0}",response);Console.WriteLine();Console.WriteLine("Press key to test oauth-protected endpoint without token bearer");Console.ReadLine();staticasyncTask<string>GetTokenAsync(IServiceProviderprovider){varservice=provider.GetRequiredService<OpenIddictClientService>();varresult=awaitservice.AuthenticateWithClientCredentialsAsync(new());returnresult.AccessToken;}staticasyncTask<string>GetResourceAsync(IServiceProviderprovider,stringtoken,stringhost,stringresource,boolincludeAuthBearer=true){usingvarclient=provider.GetRequiredService<HttpClient>();usingvarrequest=newHttpRequestMessage(HttpMethod.Get,$"{host}{resource}");if(includeAuthBearer){request.Headers.Authorization=newAuthenticationHeaderValue("Bearer",token);}Console.WriteLine($"Result of calling {host}{resource}");usingvarresponse=awaitclient.SendAsync(request);try{response.EnsureSuccessStatusCode();returnawaitresponse.Content.ReadAsStringAsync();}catch(Exceptionex){Console.WriteLine(ex.Message);}finally{client.Dispose();}return"Error thrown";}staticServiceCollectionConfigureValidServices(string?host,stringscope="demo_api_scope"){varservices=newServiceCollection();services.AddOpenIddict()// Register the OpenIddict client components..AddClient(options=>{// Allow grant_type=client_credentials to be negotiated.options.AllowClientCredentialsFlow();// Disable token storage, which is not necessary for non-interactive flows like// grant_type=password, grant_type=client_credentials or grant_type=refresh_token.options.DisableTokenStorage();// Register the System.Net.Http integration and use the identity of the current// assembly as a more specific user agent, which can be useful when dealing with// providers that use the user agent as a way to throttle requests (e.g Reddit).options.UseSystemNetHttp().SetProductInformation(typeof(Program).Assembly);// Add a client registration matching the client application definition in the server project.options.AddRegistration(newOpenIddictClientRegistration{Issuer=newUri($"{host}/",UriKind.Absolute),// Should match the values in OpenIddictSeederClientId="console",ClientSecret="388D45FA-B36B-4988-BA59-B187D329C207"});});returnservices;}
Test Remotely
Finally, hit that Publish button in Visual Studio.
Make sure to check that the application loads fine and no errors occurred using the log file.
Then run the console app and choose the remote URL.
You should get the same results!
Conclusion
There you have it! It was a long one but I spent a couple of days figuring it out completely (AI doesn’t do it all, BTW, but it helps).