From 92f8dfd1aca712c0ffa5357d7032432f52b551aa Mon Sep 17 00:00:00 2001 From: nikiforovall Date: Sun, 12 May 2024 12:24:20 +0300 Subject: [PATCH] feat: Add RolesAccessTokenMapping for WebApp scenario --- .../configuration-authentication.md | 3 + docs/examples/web-app-mvc.md | 9 +++ .../WebApp/Controllers/AccountController.cs | 27 ++++++- samples/WebApp/Controllers/HomeController.cs | 4 ++ samples/WebApp/Program.cs | 19 +++-- samples/WebApp/README.md | 22 +++++- samples/WebApp/Views/Home/AccessDenied.cshtml | 19 +++++ samples/WebApp/Views/Home/Index.cshtml | 3 +- samples/WebApp/Views/Home/Privacy.cshtml | 4 +- samples/WebApp/Views/Shared/_Layout.cshtml | 4 +- samples/WebApp/WebApp.csproj | 3 +- .../KeycloakAuthenticationOptions.cs | 5 ++ ...akWebAppAuthenticationBuilderExtensions.cs | 72 ++++++++++++++++++- 13 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 samples/WebApp/Views/Home/AccessDenied.cshtml diff --git a/docs/configuration/configuration-authentication.md b/docs/configuration/configuration-authentication.md index 27eb4b2f..d1112cd0 100644 --- a/docs/configuration/configuration-authentication.md +++ b/docs/configuration/configuration-authentication.md @@ -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. diff --git a/docs/examples/web-app-mvc.md b/docs/examples/web-app-mvc.md index 682f0264..60dfe7f9 100644 --- a/docs/examples/web-app-mvc.md +++ b/docs/examples/web-app-mvc.md @@ -1,5 +1,14 @@ # WebApp MVC + + +### 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 diff --git a/samples/WebApp/Controllers/AccountController.cs b/samples/WebApp/Controllers/AccountController.cs index 8a9993ac..3604fac9 100644 --- a/samples/WebApp/Controllers/AccountController.cs +++ b/samples/WebApp/Controllers/AccountController.cs @@ -9,9 +9,14 @@ namespace WebApp_OpenIDConnect_DotNet.Controllers; public class AccountController : Controller { + private readonly ILogger logger; + + public AccountController(ILogger logger) => this.logger = logger; + + [AllowAnonymous] public IActionResult SignIn() { - if (!User.Identity.IsAuthenticated) + if (!this.User.Identity!.IsAuthenticated) { return this.Challenge(OpenIdConnectDefaults.AuthenticationScheme); } @@ -19,11 +24,26 @@ public IActionResult SignIn() return this.RedirectToAction("Index", "Home"); } - [Authorize] + [AllowAnonymous] public async Task 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() + ?.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 { @@ -34,4 +54,7 @@ public async Task SignOutAsync() OpenIdConnectDefaults.AuthenticationScheme ); } + + [AllowAnonymous] + public IActionResult AccessDenied() => this.RedirectToAction("AccessDenied", "Home"); } diff --git a/samples/WebApp/Controllers/HomeController.cs b/samples/WebApp/Controllers/HomeController.cs index c353d929..f77f4983 100644 --- a/samples/WebApp/Controllers/HomeController.cs +++ b/samples/WebApp/Controllers/HomeController.cs @@ -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() => diff --git a/samples/WebApp/Program.cs b/samples/WebApp/Program.cs index b0f7d474..332cf827 100644 --- a/samples/WebApp/Program.cs +++ b/samples/WebApp/Program.cs @@ -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); @@ -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 => @@ -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(options => { }); +builder + .Services.AddKeycloakAuthorization(builder.Configuration) + .AddAuthorizationBuilder() + .AddPolicy("PrivacyAccess", policy => policy.RequireRealmRoles("Admin")); builder.Services.AddControllersWithViews(); diff --git a/samples/WebApp/README.md b/samples/WebApp/README.md index 2caacda3..6907a63b 100644 --- a/samples/WebApp/README.md +++ b/samples/WebApp/README.md @@ -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. + diff --git a/samples/WebApp/Views/Home/AccessDenied.cshtml b/samples/WebApp/Views/Home/AccessDenied.cshtml new file mode 100644 index 00000000..01afa741 --- /dev/null +++ b/samples/WebApp/Views/Home/AccessDenied.cshtml @@ -0,0 +1,19 @@ +@{ + ViewData["Title"] = "Forbidden 403"; +} +

@ViewData["Title"]

+ +
+

+ _ ____ _ _ + / \ ___ ___ ___ ___ ___ | _ \ ___ _ __ (_) ___ __| | + / _ \ / __/ __/ _ \/ __/ __| | | | |/ _ \ '_ \| |/ _ \/ _` | + / ___ \ (_| (_| __/\__ \__ \ | |_| | __/ | | | | __/ (_| | + /_/ _ \_\___\___\___||___/___/ |____/ \___|_| |_|_|\___|\__,_| + | || | / _ \___ / + | || |_| | | ||_ \ + |__ _| |_| |__) | + |_| \___/____/ + +

+
diff --git a/samples/WebApp/Views/Home/Index.cshtml b/samples/WebApp/Views/Home/Index.cshtml index f63816fa..132e47cc 100644 --- a/samples/WebApp/Views/Home/Index.cshtml +++ b/samples/WebApp/Views/Home/Index.cshtml @@ -6,6 +6,5 @@ ASP.NET Core web app signing-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 in your - organization. + This sample shows how to build a .NET Core MVC Web app that uses OpenID Connect to sign in users.

diff --git a/samples/WebApp/Views/Home/Privacy.cshtml b/samples/WebApp/Views/Home/Privacy.cshtml index 7bd38619..bfe5d78a 100644 --- a/samples/WebApp/Views/Home/Privacy.cshtml +++ b/samples/WebApp/Views/Home/Privacy.cshtml @@ -1,6 +1,6 @@ @{ - ViewData["Title"] = "Privacy Policy"; + ViewData["Title"] = "Private"; }

@ViewData["Title"]

-

Use this page to detail your site's privacy policy.

+

Very private page accessible only by admins

diff --git a/samples/WebApp/Views/Shared/_Layout.cshtml b/samples/WebApp/Views/Shared/_Layout.cshtml index 577480d9..42efdc84 100644 --- a/samples/WebApp/Views/Shared/_Layout.cshtml +++ b/samples/WebApp/Views/Shared/_Layout.cshtml @@ -23,10 +23,10 @@ Home diff --git a/samples/WebApp/WebApp.csproj b/samples/WebApp/WebApp.csproj index 483c0b4f..8a9fa84d 100644 --- a/samples/WebApp/WebApp.csproj +++ b/samples/WebApp/WebApp.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 @@ -12,6 +12,7 @@ + diff --git a/src/Keycloak.AuthServices.Authentication/KeycloakAuthenticationOptions.cs b/src/Keycloak.AuthServices.Authentication/KeycloakAuthenticationOptions.cs index 4a8cf2d1..313cfb68 100644 --- a/src/Keycloak.AuthServices.Authentication/KeycloakAuthenticationOptions.cs +++ b/src/Keycloak.AuthServices.Authentication/KeycloakAuthenticationOptions.cs @@ -34,4 +34,9 @@ public class KeycloakAuthenticationOptions : KeycloakInstallationOptions string.IsNullOrWhiteSpace(this.KeycloakUrlRealm) ? default : $"{this.KeycloakUrlRealm}{KeycloakConstants.OpenIdConnectConfigurationPath}"; + + /// + /// Gets or sets the roles mapping from access_token mapping + /// + public bool DisableRolesAccessTokenMapping { get; set; } } diff --git a/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs b/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs index dee1aad6..6654466d 100644 --- a/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs +++ b/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs @@ -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; @@ -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, @@ -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("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); + } + } + }; + } }