diff --git a/src/Microsoft.Identity.Web/Resource/RolesOrScopesRequiredHttpContextExtensions.cs b/src/Microsoft.Identity.Web/Resource/RolesOrScopesRequiredHttpContextExtensions.cs new file mode 100644 index 000000000..d55e5ceaa --- /dev/null +++ b/src/Microsoft.Identity.Web/Resource/RolesOrScopesRequiredHttpContextExtensions.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using global::Microsoft.AspNetCore.Http; +using global::Microsoft.Identity.Web; +using System.Globalization; +using System.Net; +using System.Security.Claims; + +namespace Microsoft.Identity.Web.Resource; + +/// +/// Extension class providing the extension methods for that +/// can be used in web APIs to validate scopes and roles in controller actions. +/// +public static class RolesOrScopesRequiredHttpContextExtensions +{ + /// + /// When applied to an , verifies that the user authenticated in the + /// web API has any of the accepted scopes or roles. + /// If there is no authenticated user, the response is a 401 (Unauthenticated). + /// If the authenticated user does not have any of these or , the + /// method updates the HTTP response providing a status code 403 (Forbidden) + /// and writes to the response body a message telling which scopes or roles are expected in the token. + /// + /// HttpContext (from the controller). + /// Scopes accepted by this web API. + /// Roles accepted by this web API. + + public static void ValidateAppRolesOrScopes(this HttpContext context, string[] acceptedScopes, string[] acceptedRoles) + { + if (acceptedScopes?.Any() != true && acceptedRoles?.Any() != true) + { + throw new ArgumentException($"{nameof(acceptedScopes)} and {nameof(acceptedRoles)} are null or empty"); + } + ArgumentNullException.ThrowIfNull(context); + + IEnumerable userClaims; + ClaimsPrincipal user; + + // Need to lock due to https://docs.microsoft.com/en-us/aspnet/core/performance/performance-best-practices?#do-not-access-httpcontext-from-multiple-threads + lock (context) + { + user = context.User; + userClaims = user.Claims; + } + + if (user == null || userClaims == null || !userClaims.Any()) + { + lock (context) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + } + //throw new UnauthorizedAccessException(IDWebErrorMessage.UnauthenticatedUser); + throw new UnauthorizedAccessException("IDW10204: The user is unauthenticated. The HttpContext does not contain any claims. "); + } + + var hasScopeOrRole = false; + if (acceptedScopes?.Any() == true) + { + var scpClaim = user.FindFirst(ClaimConstants.Scp)?.Value?.Split(' '); + var scopeClaim = user.FindFirst(ClaimConstants.Scope)?.Value?.Split(' '); + + hasScopeOrRole = scpClaim?.Any(acceptedScopes.Contains) == true || scopeClaim?.Any(acceptedScopes.Contains) == true; + } + if (acceptedRoles?.Any() == true) + { + var rolesClaim = userClaims.Where(claims => claims.Type == ClaimConstants.Roles || claims.Type == ClaimConstants.Role) + .SelectMany(roles => roles.Value.Split(' ')); + hasScopeOrRole = rolesClaim?.Any(acceptedRoles.Contains) == true; + } + if (hasScopeOrRole) + { + return; + } + + var message = string.Format(CultureInfo.InvariantCulture, + $"The 'scope' or 'scp' claim does not contain scopes '{0}' nor " + + $"the 'roles' or 'role' claim does not contain roles '{1}'", + new string[] { string.Join(",", acceptedScopes), string.Join(",", acceptedRoles) }); + + lock (context) + { + context.Response.StatusCode = (int)HttpStatusCode.Forbidden; + context.Response.WriteAsync(message); + context.Response.CompleteAsync(); + } + throw new UnauthorizedAccessException(message); + } +}