OAuth Authorization Code using OpenIddict and .NET

Posted by : on

Category : Authorization guides

Why you may want to read this article

In this article we are going to implement OAuth Authorization Code Grant, using .NET and OpenIddict library. We will comply with official RFC during implementation.

In particular, we will implement Authorization Server and Resource Server along with Swagger Client acting as an OAuth client, performing OAuth authorization, and sending requests to Resource Server.

Putting it more simply - our implementation will act the same as big OAuth / OpenId Connect providers: Google Authorization, Github Authorization, and Microsoft Authorization.

It will be possible to add somewhere Sign in with … button, like you might have seen: “Sign in with Google”, “Sign in with Facebook”, etc, but using your Authorization Server.

You can read about the OAuth overview and how it works here, this article is a particular step-by-step guide about how to build actual implementation using particular instruments.

The source code of the sample is located here.


OpenIddict

As an authorization library, I have chosen OpenIddict. There are different reasons for that.

We can see different alternatives for OAuth / OpenId Connect implementation like: Identity Server, Keycloak, Azure AD B2C, Ory Hydra, Auth0, etc.

The thing is that the majority of them are standalone products, SaaS, or self-hosted:

alt_text

The real flexibility comes with libraries, and there are 2 main competitors: Identity Server and OpenIddict.

On top of that Identity Server is not a free solution anymore for commercial products.

I faced OpenIddict twice already in real projects.

All the time it is a little bit of a pain, because OpenIddict is not well documented.

The official documentation is here. Also, there is source code, and samples that are not usually cover your case or explain anything.

There were a lot of cases where I just looked at the sample and had no idea why it is implemented as it is implemented.

This brought me to the idea to create this guide for the future.


Flow overview

We are going to use

  • Razor Pages, which is a server-side, page-focused framework that enables building dynamic, data-driven websites. We will need it to show HTML, forms, and process data submitted from them. It simplifies such security concerns as anti-forgery tokens to prevent CSRF by default.
  • ASP NET Core API, which is a framework to create Web API. In our case, these will be stateless endpoints for authorization, token, and logout. Though some of the requests rely on cookies, they are still stateless, because we do not use sessions, conversely - cookies are the encrypted data itself.

There are 5 main use cases to implement:

1) Authorize endpoint (Backend endpoint), which is the entry point and will redirect to other endpoints and use cases. Authorize endpoint is responsible to verify Resource Owner’s identity and verify Resource Owner has granted access to the Client.

2) Authenticate endpoint (Razor Pages), which is responsible to authenticate the Resource Owner: show login page, verify login & password are correct, and sign in using cookies.

3) Consent endpoint (Razor Pages), which is responsible for prompting Resource Owner to allow or deny access to the Client.

4) Token endpoint (Backend endpoint) will exchange Authorization Code for Access Token.

5) Logout endpoint (Backend endpoint) will sign out from authentication cookies

6) Resource endpoint (Backend endpoint) will show user info from JWT token claims.

We are using Razor Pages in Authenticate and Consent cases - because we would like to have CSRF protection with anti-forgery tokens. For sure it is possible to generate HTML from the pure backend and return it as text/html. But then we would be responsible for anti-forgery token management.

Unfortunately, it is not possible to split Authenticate and Consent use cases to some fancy SPA frameworks like Angular or React and leave pure backend on .NET, because:

  • Authorization Server should be responsible for authorization and token issuing and authentication + consent according to OAuth2 RFC docs as a single server. For sure for scalability, it could be replicated, but as a single instance anyway.
  • Authentication cookies should be available in authorize endpoints, meaning the domain and port should be the same.
  • Allowing another frontend web app to gather login + password and then send it over the network to the authorization server (pure backended) adds another security risk.


Authorization Server

The Authorization Server will be responsible for authenticating Resource Owner, verifying consent from Resource Owner, and issuing the tokens to the Clients.

The authorization Server will listen to the 7000 port.

1. Create ASP NET Core Web App

Create using whatever tool is more convenient for you. I’m using Visual Studio.

2. Add the necessary Nuget packages


Npgsql

Npgsql.EntityFrameworkCore.PostgreSQL

OpenIddict.AspNetCore

OpenIddict.EntityFrameworkCore

System.Linq.Async

Microsoft.EntityFrameworkCore.Design

As you can see, we are going to use my favorite database called Postgres. You can find useful articles about how to set up and work with it here and here.

Microsoft.EntityFrameworkCore.Design is only used for Add-Migration command. It is not necessary.


3. Add Authorization Service


public class AuthorizationService
{
    public IDictionary<string, StringValues> ParseOAuthParameters(HttpContext httpContext, List<string>? excluding = null)
    {
        excluding ??= new List<string>();

        var parameters = httpContext.Request.HasFormContentType
            ? httpContext.Request.Form
                .Where(v => !excluding.Contains(v.Key))
                .ToDictionary(v => v.Key, v => v.Value)
            : httpContext.Request.Query
                .Where(v => !excluding.Contains(v.Key))
                .ToDictionary(v => v.Key, v => v.Value);

        return parameters;
    }

    public string BuildRedirectUrl(HttpRequest request, IDictionary<string, StringValues> oAuthParameters)
    {
        var url = request.PathBase + request.Path + QueryString.Create(oAuthParameters);
        return url;
    }

    public bool IsAuthenticated(AuthenticateResult authenticateResult, OpenIddictRequest request)
    {
        if (!authenticateResult.Succeeded)
        {
            return false;
        }

        if (request.MaxAge.HasValue && authenticateResult.Properties != null)
        {
            var maxAgeSeconds = TimeSpan.FromSeconds(request.MaxAge.Value);

            var expired = !authenticateResult.Properties.IssuedUtc.HasValue ||
                          DateTimeOffset.UtcNow - authenticateResult.Properties.IssuedUtc > maxAgeSeconds;
            if (expired)
            {
                return false;
            }
        }

        return true;
    }

    public static List<string> GetDestinations(ClaimsIdentity identity, Claim claim)
    {
        var destinations = new List<string>();

        if (claim.Type is OpenIddictConstants.Claims.Name or OpenIddictConstants.Claims.Email)
        {
            destinations.Add(OpenIddictConstants.Destinations.AccessToken);
        }

        return destinations;
    }
}

This service is just encapsulated behavior that I didn’t want to keep in Controller or Razor Pages behavior. It can extract parameters, check whether the user is authenticated and set Token destinations (by default OpenIddict does not put ClaimsIdentity claims into token).


4. Add global constants


public class Consts
{
   public const string Email = "email";
   public const string Password = "password";
   public const string ConsentNaming = "consent";
   public const string GrantAccessValue = "Grant";
   public const string DenyAccessValue = "Deny";
}


5. Add Authenticate page model and html page


public class AuthenticateModel : PageModel
{
   public string Email { get; set; } = Consts.Email;

   public string Password { get; set; } = Consts.Password;

   [BindProperty]
   public string? ReturnUrl { get; set; }

   public string AuthStatus { get; set; } = "";

   public IActionResult OnGet(string returnUrl)
   {
      ReturnUrl = returnUrl;
      return Page();
   }
   
   public async Task<IActionResult> OnPostAsync(string email, string password)
   {
      if (email != Consts.Email || password != Consts.Password)
      {
            AuthStatus = "Email or password is invalid";
            return Page();
      }

      var claims = new List<Claim>
      {
            new(ClaimTypes.Email, email),
      };

      var principal = new ClaimsPrincipal(
            new List<ClaimsIdentity> 
            {
               new(claims, CookieAuthenticationDefaults.AuthenticationScheme)
            });

      await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);

      if (!string.IsNullOrEmpty(ReturnUrl))
      {
            return Redirect(ReturnUrl);
      }

      AuthStatus = "Successfully authenticated";
      return Page();
   }
}

@page

@model OAuth.AuthorizationServer.Pages.AuthenticateModel

@*Please do not forget to add tag helper*@

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@{

}

Authenticate

<div>@Model.AuthStatus</div>
<div>Return url: @Model.ReturnUrl</div>

<form method="post">
    <input name="email" value="@Model.Email"/>
    <input name="password" value="@Model.Password" />
    <input type="submit" />
</form>

This page will be needed in our Authorize endpoint. In case the Resource Owner is not authenticated we will redirect him to Authenticate page. We are saving ReturnUrl to be able to return Resource Owner back to Authorize endpoint but with authentication cookies set.

In reality, in our OnPostAsyncmethod we need to check Resource Owner’s login and password against your database (usually using the Identity library), and then set cookies with await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);

To know what is going on in the HttpContext.SignInAsync you could read my article about Cookies Auth internals in .NET.

In our case let’s pretend that if the email == Consts.Email and password == Consts.Password - the user is valid and stored in our database.



[Authorize]
public class Consent : PageModel
{
    [BindProperty]
    public string? ReturnUrl { get; set; }
    
    public IActionResult OnGet(string returnUrl)
    {
        ReturnUrl = returnUrl;
        return Page();
    }

    public async Task<IActionResult> OnPostAsync(string grant)
    {
        User.SetClaim(Consts.ConsentNaming, grant);

        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, User);
        return Redirect(ReturnUrl);
    }
}


@page

@using OAuth.AuthorizationServer

@model OAuth.AuthorizationServer.Pages.Consent

@*Please do not forget to add tag helper*@

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@{
    Layout = null;
}

Consent

Redirect url: @Model.ReturnUrl

<form method="post">
    <input type="submit" name="grant" value="@Consts.GrantAccessValue">
    <input type="submit" name="grant" value="@Consts.DenyAccessValue"/>
</form>

Consent page is needed in our Authorize endpoint as well. Once Resource Owner is authenticated - according to OAuth2 RFC docs we need to ensure that Resource Owner consents particular client to access its data.

We are going to get Consent from the submitted HTML form, and if it has ‘Grant’ value - we will override authentication cookies with a new value, that will represent Resource Owner’s consent.

You can see [Authorize] attribute, meaning, that the request already contains authentication cookies. In our case, it will contain only one Claim with the email ClaimTypes.Email. In OnPostAsync we will add a new claim with Consts.ConsentNaming name representing Resource Owner’s consent.

Then we redirect the user back to Authorize endpoint with both Authentication and Consent set in cookies.

Alternative solutions:

  • Usually in OpenIddict samples, consent processing happens in the same connect/authorize Controller route that allows just continue authorization without redirection. For us it does not work, because we will not have access to OpenIddict request on our Razor page (we are not in authorize, or token route)
  • Another alternative is to pass Consent value (Grant or Deny) to Authorize Controller. But pure API Controllers do not validate the Antiforgery tokens. It is possible but will lead to security issues.


7. Add the AuthorizationController


[ApiController]
public class AuthorizationController : Controller
{
   private readonly IOpenIddictApplicationManager _applicationManager;
   private readonly IOpenIddictAuthorizationManager _authorizationManager;
   private readonly IOpenIddictScopeManager _scopeManager;
   private readonly AuthorizationService _authService;

   public AuthorizationController(
      IOpenIddictApplicationManager applicationManager,
      IOpenIddictAuthorizationManager authorizationManager,
      IOpenIddictScopeManager scopeManager,
      AuthorizationService authService)
   {
      _applicationManager = applicationManager;
      _authorizationManager = authorizationManager;
      _scopeManager = scopeManager;
      _authService = authService;
   }
}


7.1 Add Authorize endpoint as described in rfc


[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
public async Task<IActionResult> Authorize()
{
    var request = HttpContext.GetOpenIddictServerRequest() ??
                    throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

    var parameters = _authService.ParseOAuthParameters(HttpContext, new List<string> { Parameters.Prompt });

    var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

    if (!_authService.IsAuthenticated(result, request))
    {
        return Challenge(properties: new AuthenticationProperties
        {
            RedirectUri = _authService.BuildRedirectUrl(HttpContext.Request, parameters)
        }, new[] { CookieAuthenticationDefaults.AuthenticationScheme });
    }

    var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ??
                        throw new InvalidOperationException("Details concerning the calling client application cannot be found.");

    var consentType = await _applicationManager.GetConsentTypeAsync(application);

    // we just ignore other consent types, because they are not compliant with OAuth and OpenId Connect docs, that state that Resource Owner should grant the Client access
    // you might also support Implicit ConsentType - where you do not require consent screen even if `prompt=consent` provided. In that case just drop this if.
    // you might want to support External ConsentType - where you need to get created authorization first by admin to be able to log in.
    if (consentType != ConsentTypes.Explicit)
    {
        return Forbid(
            authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
            properties: new AuthenticationProperties(new Dictionary<string, string?>
            {
                [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidClient,
                [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
                    "Only explicit consent clients are supported"
            }));
    }

    var consentClaim = result.Principal.GetClaim(Consts.ConsentNaming);

    // it might be extended in a way that consent claim will contain list of allowed client ids.
    if (consentClaim != Consts.GrantAccessValue)
    {
        var returnUrl = HttpUtility.UrlEncode(_authService.BuildRedirectUrl(HttpContext.Request, parameters));
        var consentRedirectUrl = $"/Consent?returnUrl={returnUrl}";

        return Redirect(consentRedirectUrl);
    }

    var userId = result.Principal.FindFirst(ClaimTypes.Email)!.Value;

    var identity = new ClaimsIdentity(
        authenticationType: TokenValidationParameters.DefaultAuthenticationType,
        nameType: Claims.Name,
        roleType: Claims.Role);

    identity.SetClaim(Claims.Subject, userId)
        .SetClaim(Claims.Email, userId)
        .SetClaim(Claims.Name, userId)
        .SetClaims(Claims.Role, new List<string> { "user", "admin" }.ToImmutableArray());

    identity.SetScopes(request.GetScopes());
    identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());

    identity.SetDestinations(c => AuthorizationService.GetDestinations(identity, c));

    return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}

The flow here is the following:

  • We check whether the Resource Owner is Authenticated (has authentication cookies with data inside it). If not - we redirect him to Authenticate page with return Challenge
  • We check whether Resource Owner parsed from Authentication cookies contains Consent claim. If not - we redirect him to Consent page that will set Consent claim to cookies
  • We create a new identity and sign in it with OpenIddictServerAspNetCoreDefaults.AuthenticationScheme. It will redirect us to ReturnUrl with authorization code.

OpenIddict has ConsentType term. It is the way Resource Owner can consent access for the Client to access Resource Owner’s data. To follow OAuth RFC that states The authorization server authenticates the resource owner (via the user-agent) and establishes whether the resource owner grants or denies the client's access request. we ensure Resource Owner has granted access.

From the OpenIddict source code, it could do different things with the same SignIn operation. For example, for the authorize endpoint it will redirect to ReturnUrl with Authorization Code, for token endpoint it will issue a new Access Token and return it to the Client.


7.2 Token Endpoint as described in rfc


[HttpPost("~/connect/token")]
public async Task<IActionResult> Exchange()
{
   var request = HttpContext.GetOpenIddictServerRequest() ??
                  throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

   if (!request.IsAuthorizationCodeGrantType() && !request.IsRefreshTokenGrantType())
         throw new InvalidOperationException("The specified grant type is not supported.");

   var result =
         await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

   var userId = result.Principal.GetClaim(Claims.Subject);

   if (string.IsNullOrEmpty(userId))
   {
         return Forbid(
            authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
            properties: new AuthenticationProperties(new Dictionary<string, string>
            {
               [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
               [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
                     "Cannot find user from the token."
            }));
   }

   var identity = new ClaimsIdentity(result.Principal.Claims,
         authenticationType: TokenValidationParameters.DefaultAuthenticationType,
         nameType: Claims.Name,
         roleType: Claims.Role);

   identity.SetClaim(Claims.Subject, userId)
         .SetClaim(Claims.Email, userId)
         .SetClaim(Claims.Name, userId)
         .SetClaims(Claims.Role, new List<string> { "user", "admin" }.ToImmutableArray());

   identity.SetDestinations(c => AuthorizationService.GetDestinations(identity, c));

   return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}

This endpoint is used to exchange Authorization Code for Access Token (and maybe Id Token or Refresh Token).

We are retrieving the identity that we put when signing in with OpenIddictServerAspNetCoreDefaults.AuthenticationScheme. We are signing in this identity again with up-to-date token claim destinations. This time SignIn will issue Tokens and possibly set cookies.


7.3 Logout endpoint


[HttpPost("~/connect/logout")]
public async Task<IActionResult> LogoutPost()
{
   await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

   return SignOut(
         authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
         properties: new AuthenticationProperties
         {
            RedirectUri = "/"
         });
}

The Logout endpoint is straightforward - we are signing out from CookieAuthenticationDefaults.AuthenticationScheme (Authentication / Consent), and then we are signing out from OpenIddictServerAspNetCoreDefaults.AuthenticationScheme (Authorization).


8. Create Clients and Resources DbSeeder


public class ClientsSeeder
{

   private readonly IServiceProvider _serviceProvider;

   public ClientsSeeder(IServiceProvider serviceProvider)
   {
      _serviceProvider = serviceProvider;
   }

   public async Task AddScopes()
   {

      await using var scope = _serviceProvider.CreateAsyncScope();
      var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictScopeManager>();
      var apiScope = await manager.FindByNameAsync("api1");

      if (apiScope != null)
      {
            await manager.DeleteAsync(apiScope);
      }

      await manager.CreateAsync(new OpenIddictScopeDescriptor
      {
            DisplayName = "Api scope",
            Name = "api1",
            Resources =
            {
               "resource_server_1"
            }
      });
   }

   public async Task AddClients()
   {

      await using var scope = _serviceProvider.CreateAsyncScope();
      var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

      await context.Database.EnsureCreatedAsync();

      var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
      var client = await manager.FindByClientIdAsync("web-client");

      if (client != null)
      {
            await manager.DeleteAsync(client);
      }

      await manager.CreateAsync(new OpenIddictApplicationDescriptor
      {
            ClientId = "web-client",
            ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654",
            ConsentType = ConsentTypes.Explicit,
            DisplayName = "Postman client application",
            RedirectUris =
            {
               new Uri("https://localhost:7002/swagger/oauth2-redirect.html")
            },
            PostLogoutRedirectUris =
            {
               new Uri("https://localhost:7002/resources")
            },
            Permissions =
            {
               Permissions.Endpoints.Authorization,
               Permissions.Endpoints.Logout,
               Permissions.Endpoints.Token,
               Permissions.GrantTypes.AuthorizationCode,
               Permissions.ResponseTypes.Code,
               Permissions.Scopes.Email,
               Permissions.Scopes.Profile,
               Permissions.Scopes.Roles,
               $"{Permissions.Prefixes.Scope}api1"
            },
            //Requirements =
            //{
            //    Requirements.Features.ProofKeyForCodeExchange
            //}
      });
   }
}

This seeder will create our client called web-client without PKCE, setting the redirect URL to [https://localhost:7002/swagger/oauth2-redirect.html](https://localhost:7002/swagger/oauth2-redirect.html) as default Swagger redirect URL for OAuth2 / OpenId Connect.

On top of that, it will create Resource Server as a scope with name api1 and resource name resource_server_1. And to allow the client to use this Scope we added {Permissions.Prefixes.Scope}api1 to permissions.


9. Register Services in Program


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
    options.UseOpenIddict();
});

builder.Services.AddOpenIddict()
    .AddCore(options =>
    {
        options.UseEntityFrameworkCore()
                .UseDbContext<ApplicationDbContext>();
    })
    .AddServer(options =>
    {
        options.SetAuthorizationEndpointUris("connect/authorize")
                .SetLogoutEndpointUris("connect/logout")
                .SetTokenEndpointUris("connect/token");

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

        options.AllowAuthorizationCodeFlow();

        options.AddEncryptionKey(new SymmetricSecurityKey(
            Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));

        options.AddDevelopmentEncryptionCertificate()
                .AddDevelopmentSigningCertificate();

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

builder.Services.AddTransient<AuthorizationService>();

builder.Services.AddControllers();
builder.Services.AddRazorPages();

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(c =>
    {
        c.LoginPath = "/Authenticate";
    });

builder.Services.AddTransient<ClientsSeeder>();

builder.Services.AddEndpointsApiExplorer();

builder.Services.AddSwaggerGen();

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy => 
    {
        policy.WithOrigins("https://localhost:7002")
            .AllowAnyHeader(); 
    });
});

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var seeder = scope.ServiceProvider.GetRequiredService<ClientsSeeder>();
    seeder.AddClients().GetAwaiter().GetResult();
    seeder.AddScopes().GetAwaiter().GetResult();
}
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapRazorPages();
app.Run();

We added DbContext for OpenIddict and registered all OpenIddict services:

  • Allowed authorize, logout, and token endpoints
  • Added default scopes
  • Allowed Authorization Code grant only
  • Added Symmetric key for token signing (this is one of the ways to validate signed tokens on Resource Server side)

On top of that we have registered our AuthorizationService, and ClientsSeeder.

We have added Cookie authentication with our login path pointing to Authenticate Razor Page described above.

Added Cors to allow Swagger to call token endpoint.


10. Use Registered services and Middlewares in Program


using (var scope = app.Services.CreateScope())
{
    var seeder = scope.ServiceProvider.GetRequiredService<ClientsSeeder>();
    seeder.AddClients().GetAwaiter().GetResult();
    seeder.AddScopes().GetAwaiter().GetResult();
}
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseCors();

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

app.MapControllers();
app.MapRazorPages();

app.Run();

The second part of Program.cs is using all registered services and middlewares.

First of all we are seeding clients and scopes with resources, used https redirection, cors, and controllers with Razor Pages.


11. Add Migrations

Run Add-Migration Initial from your Package Manager Console in Visual Studio, or create the migration using any convenient tool for you

It will create migration files:

These migrations will be applied in Seeder on EnsureCreated step.


Resource Server

The Resource Server is going to have protected endpoints that the Client will call with Access Token issued with Resource Owner’s permission.

Resource Server also will serve Swagger UI, so it is both the Client and Resource Server at the same time. But you could use Postman as a Client as well, or create a real web/mobile app to act as a Client

Resource Server will listen to 7002 port.


1. Add Nuget packages


OpenIddict.Validation.AspNetCore

OpenIddict.Validation.SystemNetHttp


2. Add ResourceController


[ApiController]
[Route("resources")]
public class ResourceController : Controller
{
    [Authorize]
    [HttpGet]
    public async Task<IActionResult> GetSecretResources()
    {
        var user = HttpContext.User?.Identity?.Name;
        return Ok($"user: {user}");
    }
}

This endpoint is a simple one - to verify we have our user authenticated with all necessary claims - we are going to show the user id parsed from token claims.


3. Register and use services in Program


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddOpenIddict()
    .AddValidation(options =>
    {
        options.SetIssuer("https://localhost:7000/");
        options.AddAudiences("resource_server_1");

        options.AddEncryptionKey(new SymmetricSecurityKey(
            Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));

        options.UseSystemNetHttp();
        options.UseAspNetCore();
    });

builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
builder.Services.AddAuthorization();

builder.Services.AddEndpointsApiExplorer();

builder.Services.AddSwaggerGen(c =>
{
    c.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.OAuth2,
        Flows = new OpenApiOAuthFlows
        {
            AuthorizationCode = new OpenApiOAuthFlow
            {
                AuthorizationUrl = new Uri("https://localhost:7000/connect/authorize"),
                TokenUrl = new Uri("https://localhost:7000/connect/token"),
                Scopes = new Dictionary<string, string>
                {
                    { "api1", "resource server scope" }
                }
            },
        }
    });

    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" }
            },
            Array.Empty<string>()
        }
    });
});

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI(c =>
{
    c.OAuthClientId("web-client");
    c.OAuthClientSecret("901564A5-E7FE-42CB-B10D-61EF6A8F3654");
});

app.UseHttpsRedirection();

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

app.MapControllers();

app.Run();

We have added OpenIddict validation services to be able to validate signed (and potentially encrypted) Access Token:

  • Set issuer url to our Authorization Server
  • Set audience to our registered resource
  • Added the same symmetric key to be able to validate Signature of Access Token from Authorization Server.

We have registered OpenIddict Authentication which allows us to be able to validate JWT Access Tokens. We don’t add Authentication / Consent cookies authentication because it is only an Authorization Server concern according to RFC.

We have added Swagger, which will act as an OAuth2 client, so added OAuth2 SecurityDefinition and SecurityRequirement.

On the UseSwaggerUI we put our web-client credentials. They will be automatically populated in the UI.


DEMO

Let’s launch both services.

Click Authorize

alt_text

Click Authorize in the pop up window

It will go to connect/authorize the first time, see, that the request does not have Authenticate cookies, and redirect the Resource Owner to Authenticate razor page

alt_text

Click Submit

It will sign in the user and set Authenticate cookies ( .AspNetCore.Cookies) - then redirect Resource Owner to connect/authorize.

alt_text

You might also see Antiforgery cookies are in place, they are needed to prevent CSRF vulnerability.

Now we try to go to connect/authorize the second time. Now with authenticated Resource Owner (authentication cookies in place).

But Cookies don’t contain consent value, so we are redirected to the consent page.

Click Grant, and after that, we try to go to connect/authorize the third time. Now with authenticated Resource Owner that granted access to its data.

alt_text

This time we successfully authorized and got redirected to the Swagger Return URL with Authorization Code.

Swagger then exchanges Authorization Code for Access Token, and internally most probably stores it somewhere.


Conclusion

In this article we implemented OAuth 2 protocol using .NET and OpenIddict as a library. We only covered OAuth 2 according to official RFC documentation and specification. OpenId Connect implementation follow-up will be in the next article.

Thank you for attention

Please subscribe to my social media to not miss updates.: Instagram, Telegram

I’m talking about life as a Software Engineer at Microsoft.


Besides that, my projects:

Symptoms Diary: https://symptom-diary.com

Pet4Pet: https://pet-4-pet.com


About Andrii Bui

Hi, my name is Andrii. I'm Software Engineer at Microsoft with 5 years of experience.