Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add RolesAccessTokenMapping for WebApp scenario #105

Merged
merged 1 commit into from
May 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
@@ -1,4 +1,4 @@
namespace WebApp_OpenIDConnect_DotNet.Controllers;

Check warning on line 1 in samples/WebApp/Controllers/AccountController.cs

View workflow job for this annotation

GitHub Actions / Build-windows-latest

Remove the underscores from namespace name 'WebApp_OpenIDConnect_DotNet' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 1 in samples/WebApp/Controllers/AccountController.cs

View workflow job for this annotation

GitHub Actions / Build-windows-latest

Remove the underscores from namespace name 'WebApp_OpenIDConnect_DotNet' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 1 in samples/WebApp/Controllers/AccountController.cs

View workflow job for this annotation

GitHub Actions / Build-windows-latest

Remove the underscores from namespace name 'WebApp_OpenIDConnect_DotNet' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 1 in samples/WebApp/Controllers/AccountController.cs

View workflow job for this annotation

GitHub Actions / Build-windows-latest

Remove the underscores from namespace name 'WebApp_OpenIDConnect_DotNet' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 1 in samples/WebApp/Controllers/AccountController.cs

View workflow job for this annotation

GitHub Actions / Build-ubuntu-latest

Remove the underscores from namespace name 'WebApp_OpenIDConnect_DotNet' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 1 in samples/WebApp/Controllers/AccountController.cs

View workflow job for this annotation

GitHub Actions / Build-ubuntu-latest

Remove the underscores from namespace name 'WebApp_OpenIDConnect_DotNet' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 1 in samples/WebApp/Controllers/AccountController.cs

View workflow job for this annotation

GitHub Actions / Build-ubuntu-latest

Remove the underscores from namespace name 'WebApp_OpenIDConnect_DotNet' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 1 in samples/WebApp/Controllers/AccountController.cs

View workflow job for this annotation

GitHub Actions / Build-ubuntu-latest

Remove the underscores from namespace name 'WebApp_OpenIDConnect_DotNet' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
Expand All @@ -9,21 +9,41 @@

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 @@
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);
}
}
};
}
}
Loading