Implement a GRPC API with OpenIddict and the OAuth client credentials flow

This post shows how to implement a GRPC service implemented in an ASP.NET Core kestrel hosted service. The GRPC service is protected using an access token. The client application uses the OAuth2 client credentials flow with introspection and the reference token is used to get access to the GRPC service. The GRPC API uses introspection to validate and authorize the access. OpenIddict is used to implement the identity provider.

Code: https://github.com/damienbod/AspNetCoreOpeniddict

The applications are setup with a identity provider implemented using OpenIddict, an GRPC service implemented using ASP.NET Core and a simple console application to request the token and use the API.

Setup GRPC API

The GRPC API service needs to add services and middleware to support introspection and to authorize the reference token. I use the AddOpenIddict method from the OpenIddict client Nuget package, but any client package which supports introspection could be used. If you decided to use a self contained JWT bearer token, then the standard JWT bearer token middleware could be used. This can only be used if the tokens are not encrypted and are self contained JWT tokens. The aud is defined as well as the required claims. A secret is required to use introspection.

GRPC is added and the kestrel is setup to support HTTP2. For local debugging, UseHttps is added. You should always develop with HTTPS and never HTTP as the dev environment should be as close as possible to the target system and you do not deploy unsecure HTTP services even when these are hidden behind a WAF.

using GrpcApi;
using OpenIddict.Validation.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});

builder.Services.AddOpenIddict()
    .AddValidation(options =>
    {
        // Note: the validation handler uses OpenID Connect discovery
        // to retrieve the address of the introspection endpoint.
        options.SetIssuer("https://localhost:44395/");
        options.AddAudiences("rs_dataEventRecordsApi");

        // Configure the validation handler to use introspection and register the client
        // credentials used when communicating with the remote introspection endpoint.
        options.UseIntrospection()
                .SetClientId("rs_dataEventRecordsApi")
                .SetClientSecret("dataEventRecordsSecret");

        // disable access token encyption for this
        options.UseAspNetCore();

        // Register the System.Net.Http integration.
        options.UseSystemNetHttp();

        // Register the ASP.NET Core host.
        options.UseAspNetCore();
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("dataEventRecordsPolicy", policyUser =>
    {
        policyUser.RequireClaim("scope", "dataEventRecords");
    });
});

builder.Services.AddGrpc();

// Configure Kestrel to listen on a specific HTTP port 
builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(8080);
    options.ListenAnyIP(7179, listenOptions =>
    {
        listenOptions.UseHttps();
        listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
    });
});

The middleware is added like any secure API. GRPC is added instead of controllers, pages or whatever.

var app = builder.Build();

app.UseHttpsRedirection();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
    endpoints.MapGrpcService<GreeterService>();

    endpoints.MapGet("/", async context =>
    {
        await context.Response.WriteAsync("GRPC service running...");
    });
});

app.Run();

The GRPC service is secured using the authorize attribute with a policy checking the scope claim.

using Grpc.Core;
using Microsoft.AspNetCore.Authorization;

namespace GrpcApi;

[Authorize("dataEventRecordsPolicy")]
public class GreeterService : Greeter.GreeterBase
{
    public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
    {
        return Task.FromResult(new HelloReply
        {
            Message = "Hello " + request.Name
        });
    }
}

A proto3 file is used to define the API. This is just the simple example from the Microsoft ASP.NET Core GRPC documentation.

syntax = "proto3";

option csharp_namespace = "GrpcApi";

package greet;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply);
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings.
message HelloReply {
  string message = 1;
}

Setup OpenIddict client credentials flow with introspection

We use OpenIddict to implement the client credentials flow with introspection. The client uses the grant type ClientCredentials and a secret to acquire the reference token.

// API application CC
if (await manager.FindByClientIdAsync("CC") == null)
{
	await manager.CreateAsync(new OpenIddictApplicationDescriptor
	{
		ClientId = "CC",
		ClientSecret = "cc_secret",
		DisplayName = "CC for protected API",
		Permissions =
		{
			Permissions.Endpoints.Authorization,
			Permissions.Endpoints.Token,
			Permissions.GrantTypes.ClientCredentials,
			Permissions.Prefixes.Scope + "dataEventRecords"
		}
	});
}

static async Task RegisterScopesAsync(IServiceProvider provider)
{
	var manager = provider.GetRequiredService<IOpenIddictScopeManager>();

	if (await manager.FindByNameAsync("dataEventRecords") is null)
	{
		await manager.CreateAsync(new OpenIddictScopeDescriptor
		{
			DisplayName = "dataEventRecords API access",
			DisplayNames =
			{
				[CultureInfo.GetCultureInfo("fr-FR")] = "Accès à l'API de démo"
			},
			Name = "dataEventRecords",
			Resources =
			{
				"rs_dataEventRecordsApi"
			}
		});
	}
}

The AddOpenIddict method is used to define the supported features of the OpenID Connect server. Per default, encryption is used as well as introspection. The AllowClientCredentialsFlow method is used to added the support for the OAuth client credentials flow.

services.AddOpenIddict()
.AddCore(options =>
{
	options.UseEntityFrameworkCore()
	   .UseDbContext<ApplicationDbContext>();
	options.UseQuartz();
})
.AddServer(options =>
{
	options.SetAuthorizationEndpointUris("/connect/authorize")
	  .SetLogoutEndpointUris("/connect/logout")
	  .SetIntrospectionEndpointUris("/connect/introspect")
	  .SetTokenEndpointUris("/connect/token")
	  .SetUserinfoEndpointUris("/connect/userinfo")
	  .SetVerificationEndpointUris("/connect/verify");

	options.AllowAuthorizationCodeFlow()
	   .AllowHybridFlow()
	   .AllowClientCredentialsFlow()
	   .AllowRefreshTokenFlow();

	options.RegisterScopes(Scopes.Email, 
		Scopes.Profile, Scopes.Roles, "dataEventRecords");

	// Register the signing and encryption credentials.
	options.AddDevelopmentEncryptionCertificate()
	   .AddDevelopmentSigningCertificate();

	options.UseAspNetCore()
	   .EnableAuthorizationEndpointPassthrough()
	   .EnableLogoutEndpointPassthrough()
	   .EnableTokenEndpointPassthrough()
	   .EnableUserinfoEndpointPassthrough()
	   .EnableStatusCodePagesIntegration();
})

You also need to update the Account controller exchange method to support the OAuth2 client credentials (CC) flow. See the OpenIddict samples for reference.

Implementing the GRPC client

The client gets an access token and uses this to request the data from the GRPC API. The ClientCredentialAccessTokenClient class requests the access token using a secret, client Id and a scope. In a real application, you should cache and only request a new access token if it has expired, or is about to expire.

using IdentityModel.Client;
using Microsoft.Extensions.Configuration;

namespace GrpcAppClientConsole;

public class ClientCredentialAccessTokenClient
{
    private readonly HttpClient _httpClient;
    private readonly IConfiguration _configuration;

    public ClientCredentialAccessTokenClient(
        IConfiguration configuration,
        HttpClient httpClient)
    {
        _configuration = configuration;
        _httpClient = httpClient;
    }

    public async Task<string> GetAccessToken(
        string api_name, string api_scope, string secret)
    {
        try
        {
            var disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync(
                _httpClient,
                _configuration["OpenIDConnectSettings:Authority"]);

            if (disco.IsError)
            {
                Console.WriteLine($"disco error Status code: {disco.IsError}, Error: {disco.Error}");
                throw new ApplicationException($"Status code: {disco.IsError}, Error: {disco.Error}");
            }

            var tokenResponse = await HttpClientTokenRequestExtensions.RequestClientCredentialsTokenAsync(_httpClient, new ClientCredentialsTokenRequest
            {
                Scope = api_scope,
                ClientSecret = secret,
                Address = disco.TokenEndpoint,
                ClientId = api_name
            });

            if (tokenResponse.IsError)
            {
                Console.WriteLine($"tokenResponse.IsError Status code: {tokenResponse.IsError}, Error: {tokenResponse.Error}");
                throw new ApplicationException($"Status code: {tokenResponse.IsError}, Error: {tokenResponse.Error}");
            }

            return tokenResponse.AccessToken;
            
        }
        catch (Exception e)
        {
            Console.WriteLine($"Exception {e}");
            throw new ApplicationException($"Exception {e}");
        }
    }
}

The console application uses the access token to request the GRPC API data using the proto3 definition.

using Grpc.Net.Client;
using GrpcApi;
using Microsoft.Extensions.Configuration;
using Grpc.Core;
using GrpcAppClientConsole;

var builder = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json");

var configuration = builder.Build();

var clientCredentialAccessTokenClient 
    = new ClientCredentialAccessTokenClient(configuration, new HttpClient());

// 2. Get access token
var accessToken = await clientCredentialAccessTokenClient.GetAccessToken(
    "CC",
    "dataEventRecords",
    "cc_secret"
);

if (accessToken == null)
{
    Console.WriteLine("no auth result... ");
}
else
{
    Console.WriteLine(accessToken);

    var tokenValue = "Bearer " + accessToken;
    var metadata = new Metadata
    {
        { "Authorization", tokenValue }
    };

    var handler = new HttpClientHandler();

    var channel = GrpcChannel.ForAddress(
        configuration["ProtectedApiUrl"], 
        new GrpcChannelOptions
    {
        HttpClient = new HttpClient(handler)
        
    });

    CallOptions callOptions = new(metadata);

    var client = new Greeter.GreeterClient(channel);

    var reply = await client.SayHelloAsync(
        new HelloRequest { Name = "GreeterClient" }, callOptions);

    Console.WriteLine("Greeting: " + reply.Message);

    Console.WriteLine("Press any key to exit...");
    Console.ReadKey();
}

GRPC in ASP.NET Core works really well with any OAuth2, OpenID Connect server. This is my preferred way to secure GRPC services and I only use certification authentication if this is required due to the extra effort to setup the hosted environments and the deployment of the client and server certificates.

Links

https://github.com/grpc/grpc-dotnet/

https://docs.microsoft.com/en-us/aspnet/core/grpc

https://documentation.openiddict.com/

https://github.com/openiddict/openiddict-samples

https://github.com/openiddict/openiddict-core

4 comments

  1. […] Implement a GRPC API with OpenIddict and the OAuth client credentials flow – Damien Bowden […]

  2. […] Implement a gRPC API with OpenIddict and the OAuth client credentials flow (Damien Bowden) […]

  3. […] Implement a GRPC API with OpenIddict and the OAuth client credentials flow. […]

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 )

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: