Skip to content

Commit

Permalink
feat: Add RolesAccessTokenMapping for WebApp scenario (#105)
Browse files Browse the repository at this point in the history
  • Loading branch information
NikiforovAll authored May 12, 2024
1 parent 071a1a0 commit b337ff0
Show file tree
Hide file tree
Showing 13 changed files with 171 additions and 23 deletions.
3 changes: 3 additions & 0 deletions docs/configuration/configuration-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ public static KeycloakWebAppAuthenticationBuilder AddKeycloakWebApp(

See [source code](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/blob/main/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs) for more details.

> [!TIP]
> See an example of how to use `AddKeycloakWebApp` in MVC application - [Web App MVC](/examples/web-app-mvc)
## Adapter File Configuration Provider

Using *appsettings.json* is a recommended and it is an idiomatic approach for .NET, but if you want a standalone "adapter" (installation) file - *keycloak.json*. You can use `ConfigureKeycloakConfigurationSource`. It adds dedicated configuration source.
Expand Down
9 changes: 9 additions & 0 deletions docs/examples/web-app-mvc.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# WebApp MVC

<!--@include: @/../samples/WebApp/README.md-->

### Role Mapping

> [!WARNING]
> By default Keycloak doesn't map roles to **id_token**, so we need an **access_token** in this case, **access_token** is **NOT** available in all OAuth flows. For example, "Implicit Flow" is based on id_token and **access_token** is not retrieved at all.
## Code

<<< @/../samples/WebApp/Program.cs

<<< @/../samples/WebApp/Controllers/AccountController.cs
Expand Down
27 changes: 25 additions & 2 deletions samples/WebApp/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,41 @@ namespace WebApp_OpenIDConnect_DotNet.Controllers;

public class AccountController : Controller
{
private readonly ILogger<AccountController> logger;

public AccountController(ILogger<AccountController> logger) => this.logger = logger;

[AllowAnonymous]
public IActionResult SignIn()
{
if (!User.Identity.IsAuthenticated)
if (!this.User.Identity!.IsAuthenticated)
{
return this.Challenge(OpenIdConnectDefaults.AuthenticationScheme);
}

return this.RedirectToAction("Index", "Home");
}

[Authorize]
[AllowAnonymous]
public async Task<IActionResult> SignOutAsync()
{
if (!this.User.Identity!.IsAuthenticated)
{
return this.Challenge(OpenIdConnectDefaults.AuthenticationScheme);
}

var idToken = await this.HttpContext.GetTokenAsync("id_token");

var authResult = this
.HttpContext.Features.Get<IAuthenticateResultFeature>()
?.AuthenticateResult;

var tokens = authResult!.Properties!.GetTokens();

var tokenNames = tokens.Select(token => token.Name).ToArray();

this.logger.LogInformation("Token Names: {TokenNames}", string.Join(", ", tokenNames));

return this.SignOut(
new AuthenticationProperties
{
Expand All @@ -34,4 +54,7 @@ public async Task<IActionResult> SignOutAsync()
OpenIdConnectDefaults.AuthenticationScheme
);
}

[AllowAnonymous]
public IActionResult AccessDenied() => this.RedirectToAction("AccessDenied", "Home");
}
4 changes: 4 additions & 0 deletions samples/WebApp/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ public class HomeController : Controller
{
public IActionResult Index() => this.View();

[Authorize(Policy = "PrivacyAccess")]
public IActionResult Privacy() => this.View();

[AllowAnonymous]
public IActionResult Public() => this.View();

[AllowAnonymous]
public IActionResult AccessDenied() => this.View();

[AllowAnonymous]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error() =>
Expand Down
19 changes: 8 additions & 11 deletions samples/WebApp/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Keycloak.AuthServices.Authentication;
using Keycloak.AuthServices.Authorization.AuthorizationServer;
using Microsoft.AspNetCore.Authentication;
using Keycloak.AuthServices.Authorization;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);
Expand All @@ -14,12 +13,12 @@
builder
.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddKeycloakWebApp(
builder.Configuration.GetSection(KeycloakAuthorizationServerOptions.Section),
builder.Configuration.GetSection(KeycloakAuthenticationOptions.Section),
configureOpenIdConnectOptions: options =>
{
// used for front-channel logout
// we need this for front-channel sign-out
options.SaveTokens = true;

options.ResponseType = "code";
options.Events = new OpenIdConnectEvents
{
OnSignedOutCallbackRedirect = context =>
Expand All @@ -30,15 +29,13 @@
return Task.CompletedTask;
}
};

// NOTE, the source for claims is id_token and not access_token.
// By default, id_token doesn't contain realm_roles claim
// and you will need to create a mapper for that
options.ClaimActions.MapUniqueJsonKey("realm_access", "realm_access");
}
);

builder.Services.PostConfigure<OpenIdConnectOptions>(options => { });
builder
.Services.AddKeycloakAuthorization(builder.Configuration)
.AddAuthorizationBuilder()
.AddPolicy("PrivacyAccess", policy => policy.RequireRealmRoles("Admin"));

builder.Services.AddControllersWithViews();

Expand Down
22 changes: 21 additions & 1 deletion samples/WebApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,24 @@

## Scenario

This sample shows how to build a .NET Core MVC Web app that uses OpenID Connect to sign in users.
This sample shows how to build a .NET Core MVC Web app that uses OpenID Connect to sign in users. In a typical cookie-based authentication scenario using OpenID Connect, the following components and flow are involved:

Components:

* User: The end-user who wants to authenticate.
* Client: The application that wants to authenticate the user (e.g., a web application).
* Authorization Server: The server that performs the authentication and issues tokens (e.g., Google, Facebook).
* Resource Server: The server hosting the protected resources that the client wants to access.

Authentication Flow:

* Step 1: The user accesses the client application and requests to log in.
* Step 2: The client redirects the user to the authorization server's login page, where the user enters their credentials.
* Step 3: Upon successful authentication, the authorization server redirects the user back to the client application with an authorization code.
* Step 4: The client exchanges the authorization code for an ID token and an access token at the authorization server's token endpoint.
* Step 5: The authorization server validates the authorization code, and if valid, issues the ID token and access token.
* Step 6: The client validates the ID token and retrieves the user's identity information.
* Step 7: The client creates a session for the user and stores the session identifier in a secure, HTTP-only cookie.
* Step 8: The user's subsequent requests to the client include the session cookie, which the client uses to identify the user and maintain the authenticated session.
* Step 9: If the client needs to access protected resources from a resource server, it can use the access token to authenticate the requests.

19 changes: 19 additions & 0 deletions samples/WebApp/Views/Home/AccessDenied.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@{
ViewData["Title"] = "Forbidden 403";
}
<h2>@ViewData["Title"]</h2>

<pre>
<p>
_ ____ _ _
/ \ ___ ___ ___ ___ ___ | _ \ ___ _ __ (_) ___ __| |
/ _ \ / __/ __/ _ \/ __/ __| | | | |/ _ \ '_ \| |/ _ \/ _` |
/ ___ \ (_| (_| __/\__ \__ \ | |_| | __/ | | | | __/ (_| |
/_/ _ \_\___\___\___||___/___/ |____/ \___|_| |_|_|\___|\__,_|
| || | / _ \___ /
| || |_| | | ||_ \
|__ _| |_| |__) |
|_| \___/____/

</p>
</pre>
3 changes: 1 addition & 2 deletions samples/WebApp/Views/Home/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,5 @@
ASP.NET Core web app signing-in users in your organization
</h1>
<p>
This sample shows how to build a .NET Core MVC Web app that uses OpenID Connect to sign in users in your
organization.
This sample shows how to build a .NET Core MVC Web app that uses OpenID Connect to sign in users.
</p>
4 changes: 2 additions & 2 deletions samples/WebApp/Views/Home/Privacy.cshtml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
ViewData["Title"] = "Privacy Policy";
ViewData["Title"] = "Private";
}
<h2>@ViewData["Title"]</h2>

<p>Use this page to detail your site's privacy policy.</p>
<p>Very private page accessible only by admins</p>
4 changes: 2 additions & 2 deletions samples/WebApp/Views/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Public">Public</a>
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Private</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Public">Public</a>
</li>
</ul>
</div>
Expand Down
3 changes: 2 additions & 1 deletion samples/WebApp/WebApp.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFrameworks>net6.0</TargetFrameworks>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
Expand All @@ -12,6 +12,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Keycloak.AuthServices.Authorization\Keycloak.AuthServices.Authorization.csproj" />
<ProjectReference Include="..\..\src\Keycloak.AuthServices.Authentication\Keycloak.AuthServices.Authentication.csproj" />
<ProjectReference Include="..\..\src\Keycloak.AuthServices.Common\Keycloak.AuthServices.Common.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,9 @@ public class KeycloakAuthenticationOptions : KeycloakInstallationOptions
string.IsNullOrWhiteSpace(this.KeycloakUrlRealm)
? default
: $"{this.KeycloakUrlRealm}{KeycloakConstants.OpenIdConnectConfigurationPath}";

/// <summary>
/// Gets or sets the roles mapping from access_token mapping
/// </summary>
public bool DisableRolesAccessTokenMapping { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace Keycloak.AuthServices.Authentication;

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Keycloak.AuthServices.Common;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
Expand Down Expand Up @@ -80,8 +82,7 @@ public static KeycloakWebAppAuthenticationBuilder AddKeycloakWebApp(
ArgumentNullException.ThrowIfNull(configurationSection);

return builder.AddKeycloakWebAppWithConfiguration(
configureKeycloakOptions: options =>
configurationSection.BindKeycloakOptions(options),
configureKeycloakOptions: options => configurationSection.BindKeycloakOptions(options),
configureCookieAuthenticationOptions: configureCookieAuthenticationOptions,
configureOpenIdConnectOptions: configureOpenIdConnectOptions,
openIdConnectScheme: openIdConnectScheme,
Expand Down Expand Up @@ -271,8 +272,75 @@ private static void AddKeycloakWebAppInternal(
RoleClaimType = keycloakOptions.RoleClaimType,
};

if (options.MapInboundClaims)
{
options.ClaimActions.MapUniqueJsonKey(
KeycloakConstants.RealmAccessClaimType,
KeycloakConstants.RealmAccessClaimType
);
options.ClaimActions.MapUniqueJsonKey(
KeycloakConstants.ResourceAccessClaimType,
KeycloakConstants.ResourceAccessClaimType
);
}

configureOpenIdConnectOptions?.Invoke(options);

if (!keycloakOptions.DisableRolesAccessTokenMapping)
{
MapAccessTokenRoles(options);
}
}
);
}

private static void MapAccessTokenRoles(OpenIdConnectOptions options)
{
options.Events ??= new OpenIdConnectEvents();

var baseOnTokenResponseReceived = options.Events.OnTokenResponseReceived;
var baseOnTokenValidated = options.Events.OnTokenValidated;

options.Events.OnTokenResponseReceived = async context =>
{
if (options.Events.OnTokenResponseReceived is not null)
{
await baseOnTokenResponseReceived.Invoke(context);
}

var accessToken = context.TokenEndpointResponse.AccessToken;

context.Properties?.SetParameter("access_token", accessToken);
};

options.Events.OnTokenValidated = async context =>
{
if (options.Events.OnTokenValidated is not null)
{
await baseOnTokenValidated.Invoke(context);
}

var accessToken = context.Properties?.GetParameter<string>("access_token");

if (accessToken is not null && context.Principal is not null)
{
var handler = new JwtSecurityTokenHandler();

if (handler.ReadToken(accessToken) is JwtSecurityToken jwtSecurityToken)
{
var claims = jwtSecurityToken.Claims;

var identity = new ClaimsIdentity();
identity.AddClaims(
claims.Where(c =>
c.Type
is KeycloakConstants.RealmAccessClaimType
or KeycloakConstants.ResourceAccessClaimType
)
);
context.Principal.AddIdentity(identity);
}
}
};
}
}

0 comments on commit b337ff0

Please sign in to comment.