Add OAuth2 on Blazor Client side

Blazor is an implementation of WASM made by Microsoft, it allows you to create web application in C# using the Razor engine to render pages.
Two approches exist:
Server side: the pages are rendered server side and sent to the client using a SignalR connection.
Client side: in this approch, pages are rendered on browser using WebAssembly. You can either choose to have an ASP.NET Core API that will host your app or not.

In this article we will focus on how to add authentication on a Client side application NOT hosted using Identity Server 4.

Create your project

What do you need:

  • The last version of the NET Core SDK
  • Run the following command:
    dotnet new -i Microsoft.AspNetCore.Blazor.Templates::3.1.0-preview4.19579.2
  • Install the last preview of Visual Studio

When all those steps are done you can now create your project

blazor project
blazor project client side

As you can see, the checkbox ASP.NET Core hosted is not selected.

Create a component to interact with OAuth2 server

In the way I will do, all the pages of the site will be protected, as my component will content all the site.

Add Razor component and C# class

First of all let’s add a Razor Component to your project, and let’s split the code from the front. To achieve this, add a new C# class and inside your razor component you have to inherit from this class.
Moreover you C# class must inherit from ComponentBase.

Your C# class will look like this:

using Microsoft.AspNetCore.Components;
namespace ImageGallery.BlazorClientSide.Component
{
public class OpenIdBase : ComponentBase
{
}
}

And your Razor component:

@inherits ImageGallery.BlazorClientSide.Component.OpenIdBase
@ChildContent

Modify App.razor

We can now change the root component of our application by the OAuth2 component:

Adapt the App.razor as follow:

<ImageGallery.BlazorClientSide.Component.OpenIdBase>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</ImageGallery.BlazorClientSide.Component.OpenIdBase>

Add method to the C# class

Now that you have the first part, some injections are needed. Add in the C# class the following:

[Parameter] public RenderFragment ChildContent { get; set; }
[Inject] private IOptions OpenIdConnectOptions { get; set; }
[Inject] private NavigationManager UriHelper { get; set; }
[Inject] private IJSRuntime _jsRuntime { get; set; }
[Inject] private IConfiguration _iConfiguration { get; set; }
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
}

Add methods

First piece of code needed is to retrieve the Discovery Document from the OAuth2 server

private async Task GetDiscoveryDocumentAsync()
{
var h = new HttpClient { BaseAddress = new Uri(OpenIdConnectOptions.Value.Authority) };
return await h.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
{
Policy = new DiscoveryPolicy
{
ValidateEndpoints = false,
//in case you don't need https uncomment the following line
//RequireHttps = false
}
});
}

Another method needed is the one that will call the authorize endpoint:

private async Task CallAuthorizeUrlAsync()
{
var redirectUri = new Uri(new Uri(UriHelper.Uri), "openid");
var doc = await GetDiscoveryDocumentAsync();
var authorizeUrl = new RequestUrl(doc.AuthorizeEndpoint)
.CreateAuthorizeUrl(OpenIdConnectOptions.Value.ClientId,
OidcConstants.ResponseTypes.IdTokenToken,
scope: string.Join(" ", OpenIdConnectOptions.Value.Scope),
redirectUri: redirectUri.ToString(),
nonce: Guid.NewGuid().ToString("N"),
responseMode: OidcConstants.ResponseModes.Fragment);
UriHelper.NavigateTo(authorizeUrl, true);
}

Another method needed is the one that will handle the return from OAuth2 server

private async Task HandleCallbackAsync()
{
var uri = new Uri(UriHelper.Uri);
var parameters = uri.Fragment.Split('&')
.Select(kvp => kvp.Split('='))
.ToLookup(t => t[0], t => t[1]);
var accessToken = parameters["access_token"].FirstOrDefault();
var idToken = parameters["#id_token"].First()?.Replace("\0", string.Empty);
var expiresIn = parameters["expires"].FirstOrDefault();
var doc = await GetDiscoveryDocumentAsync();
var issuerSigningKeys = doc.KeySet.Keys
.Select(k =>
{
switch (k.Kty)
{
case IdentityModel.Jwk.JsonWebAlgorithmsKeyTypes.Octet:
return new SymmetricSecurityKey(Base64Url.Decode(k.K)) { KeyId = k.Kid } as SecurityKey;
case IdentityModel.Jwk.JsonWebAlgorithmsKeyTypes.RSA:
var modulus = Base64UrlEncoder.DecodeBytes(k.N);
var modulusByte = Base64Url.Decode(k.N);
if (modulus.Length == 257 && modulus[0] == 0)
{
_logger.LogDebug("257 too long");
var newModulus = new byte[256];
Array.Copy(modulus, 1, newModulus, 0, 256);
modulusByte = newModulus;
}
return new RsaSecurityKey(new RSAParameters
{
//Private key
D = k.D != null ? Base64Url.Decode(k.D) : null,
P = k.P != null ? Base64Url.Decode(k.P) : null,
Q = k.Q != null ? Base64Url.Decode(k.Q) : null,
DP = k.DP != null ? Base64Url.Decode(k.DP) : null,
DQ = k.DQ != null ? Base64Url.Decode(k.DQ) : null,
InverseQ = k.QI != null ? Base64Url.Decode(k.QI) : null,
//Public key
Exponent = k.E != null ? Base64Url.Decode(k.E) : null,
Modulus = modulusByte
})
{ KeyId = k.Kid };
default:
throw new NotSupportedException();
}
})
.ToList();
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(idToken, new TokenValidationParameters
{
ValidateIssuer = false,
ValidateIssuerSigningKey = false,
ValidateAudience = false,
SignatureValidator = delegate (string token, TokenValidationParameters p)
{
var jwt = new JwtSecurityToken(token);
return jwt;
},
IssuerSigningKeys = issuerSigningKeys,
ValidAudience = OpenIdConnectOptions.Value.ClientId,
ValidIssuer = OpenIdConnectOptions.Value.Authority,
RequireSignedTokens = false,
ClockSkew = TimeSpan.Zero
}, out _);
_isAuthenticated = true;
}

Finally the code to display the child content:

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (_isAuthenticated)
{
builder.AddContent(0, ChildContent);
}
}

Glue it together

Now that most of the code is done, we can modify the OnInitializedAsync method.


protected override async Task OnInitializedAsync()
{
var relativePath = UriHelper.ToBaseRelativePath(UriHelper.Uri);
if (relativePath.StartsWith("openid#", StringComparison.OrdinalIgnoreCase))
{
await HandleCallbackAsync();
UriHelper.NavigateTo("");
}
else
{
await CallAuthorizeUrlAsync();
}
await base.OnInitializedAsync();
}

Add missing parts

Now that we have most of the code for the component, an important missing part is the injection of the OpenIdConnectOptions.
To inject this, the startup.cs must be modified, and more precisely the ConfigureService method:

services.AddSingleton(service =>
{
var conf = service.GetRequiredService();
var authority = conf.GetValue("authority");
var openIdConnectOptions = new Action(oidc =>
{
oidc.Authority = authority;
oidc.ClientId = "identityserveradmin";
//in case you dont need https uncomment the following line
//oidc.RequireHttpsMetadata = false;
oidc.GetClaimsFromUserInfoEndpoint = true;
oidc.Scope.Add("openid");
oidc.Scope.Add("profile");
});
return new ConfigureNamedOptions(Options.DefaultName, openIdConnectOptions);
});

You should have now a working solution, but some parts are still missing, like read the UserEndPoint or the silent renew for the Access Token.

Read User Info

We need to get the information from this end point once we are connected, this means the call needs to be done after we handle the callback from the OAuth2 server (after HandleCallBackAsync)

private async Task GetUserInfo(DiscoveryResponse doc, string accessToken)
{
var h = new HttpClient { BaseAddress = new Uri(OpenIdConnectOptions.Value.Authority) };
var res = await h.GetUserInfoAsync(new UserInfoRequest
{
Address = doc.UserInfoEndpoint,
Token = accessToken
});
foreach (var c in res.Claims)
{
//here are the informations linked to your user
}
}

Silent Renew

Your Access Token has a limited time life and you don’t have a refresh token, so a way of doing it is to call again the authorized end point but this time the redirect url will be different, and the call has to be made from an invisible iFrame!

Call the Authorize EndPoint for silent renew

The code is really similar to the previous one, only the redirect url has changed:

private async Task BuildAuthorizeUrlForSilentRenewAsync()
{
var doc = await GetDiscoveryDocumentAsync();
//build redirect URI
var uri = new Uri(new Uri(UriHelper.Uri), "silent_renew");
//build authorize URL
var authorizeUrl = new RequestUrl(doc.AuthorizeEndpoint)
.CreateAuthorizeUrl(OpenIdConnectOptions.Value.ClientId,
responseType: OidcConstants.ResponseTypes.IdTokenToken,
scope: string.Join(" ", OpenIdConnectOptions.Value.Scope),
redirectUri: uri.ToString(),
prompt: "none",
nonce: Guid.NewGuid().ToString("N"),
responseMode: OidcConstants.ResponseModes.Fragment);
return authorizeUrl;
}

Build an iFrame

In order to achieve this, you will have to write some JS code. First add a JS file in your « wwwroot » folder, and reference it in your index.html
Then the JS file is simple:

window.blazorExtensions = {
CreateIFrame: function (id, src, hidden) {
var iframe = document.createElement("iframe");
iframe.id = id;
iframe.src = src;
iframe.hidden = hidden;
document.body.appendChild(iframe);
},
RemoveIFrame: function (id) {
var iframe = document.getElementById(id);
if (iframe != null && typeof(iframe) != 'undefined') {
document.body.removeChild(iframe);
}
}
}

This piece of code will allow you to add and remove an iFrame to your page.

Call the Authorize EndPoint from the iFrame

Now if you remember, we injected an IJSRuntime to our component, we are going to need it to call the JS code.

public async Task CallSilentRenew()
{
var authorizeUrl = await BuildAuthorizeUrlForSilentRenewAsync();
//creation d'une iframe dynamique
await _jsRuntime.InvokeVoidAsync("blazorExtensions.CreateIFrame", "frame_silent_renew", authorizeUrl, "true");
var silentRenewWaitTimeMs = 5000;
await Task.Run(() => Task.Delay(Convert.ToInt32(silentRenewWaitTimeMs)));
//destruction de l'iframe dynamique
await _jsRuntime.InvokeVoidAsync("blazorExtensions.RemoveIFrame", "frame_silent_renew");
}
}

As you can see, there is a timer to destroy the iFrame after we read the new Access Token.

So now if you expect everything to be done, you will be disappointed, you still need to handle the call back from the OAuth2 server.

Handle the call back for Silent Renew

You will have to add an « if » to your OnInitializedAsync, an « if » to check if the url starts with « silent_renew », remember it’s the value we set for callback url when we built the authorized url for silent renew.

if (relativePath.StartsWith("silent_renew", StringComparison.OrdinalIgnoreCase))
{
await HandleCallbackAsync();
}

So we are going to do the same as if it was a normal call to authorize endpoint.

We are almost done, but if you looked with attention, when the iFrame makes the call, it also create a new instance of our blazor app! This means you will need some cookies to store the value of Access Token.

Add some cookies

Like for the iframe, we are going to deal with the cookie through JS code.

JS cookies!

3 methods will be added to the JS file in order to interact with cookies

  • add
  • delete
  • read

WriteCookie: function (name, value, exp) {
var expires;
if (days) {
var date = new Date();
date.setTime(date.getTime() + exp);
expires = "; expires=" + date.toGMTString();
}
else {
expires = "";
}
document.cookie = name + "=" + value + expires + "; path=/";
},
DeleteCookie: function (name) {
value = "";
expires = "; expires=0";
document.cookie = name + "=" + value + expires + "; path=/";
},
ReadCookie: function (name) {
var cookie = document.cookie;
var nameValues = cookie.split(';');
for (var i = 0; i < nameValues.length; i++) {
var nameValue = nameValues[i].split('=');
if (nameValue[0].trim() === name) {
return nameValue[1].trim();
}
}
},

Write the cookie

In order to write the cookie, we need to calculate its expiration date: we are going to set the same date on the cookie that the Access Token has.

Calculate expiration date

In the HandleCallBackAsync we can access easily the ClaimsPrincipal, and the expiration date:

var exp = long.Parse(principal.Claims.First(c => c.Type == "exp").Value)

Now that we have the expiration date we can call the method in to write the cookie with value the AccessToken:


await _jsRuntime.InvokeVoidAsync("blazorExtensions.WriteCookie", "blazor_access_token", value, exp);

Anytime you need to make an HTTP call you will need to read the cookie to get the AccessToken.

Now using cookies, even if the iframe starts a new instance of your app, it will share the same cookie, and update the cookie as well!

Trigger the silent renew

Due to the fact that you know the expiration date of your AccessToken, you can keep a timer that will call the method to do a silent renew.

Conclusion

Now that you have everything in hand, you can do some OAuth2, in my case using Identity Server 4, with your Blazor App client side.

Have fun adding this to your Blazor App!

I also have to add a big thank to Nathanael for his help, if you want to read his articles here his blog.

 

Répondre

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion /  Changer )

Photo Google

Vous commentez à l'aide de votre compte Google. Déconnexion /  Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion /  Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion /  Changer )

Connexion à %s