diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs index 79ce7cb71eee..a2dae15a8611 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs @@ -40,7 +40,9 @@ private static void ConfigureOpenIddict(IUmbracoBuilder builder) .SetLogoutEndpointUris( Paths.MemberApi.LogoutEndpoint.TrimStart(Constants.CharArrays.ForwardSlash)) .SetRevocationEndpointUris( - Paths.MemberApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash)); + Paths.MemberApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash)) + .SetUserinfoEndpointUris( + Paths.MemberApi.UserinfoEndpoint.TrimStart(Constants.CharArrays.ForwardSlash)); // Enable authorization code flow with PKCE options @@ -52,7 +54,8 @@ private static void ConfigureOpenIddict(IUmbracoBuilder builder) options .UseAspNetCore() .EnableAuthorizationEndpointPassthrough() - .EnableLogoutEndpointPassthrough(); + .EnableLogoutEndpointPassthrough() + .EnableUserinfoEndpointPassthrough(); // Enable reference tokens // - see https://documentation.openiddict.com/configuration/token-storage.html diff --git a/src/Umbraco.Cms.Api.Common/Security/Paths.cs b/src/Umbraco.Cms.Api.Common/Security/Paths.cs index 44b6935d12ad..7de0b0a29c2c 100644 --- a/src/Umbraco.Cms.Api.Common/Security/Paths.cs +++ b/src/Umbraco.Cms.Api.Common/Security/Paths.cs @@ -14,6 +14,8 @@ public static class MemberApi public static readonly string RevokeEndpoint = EndpointPath($"{EndpointTemplate}/revoke"); + public static readonly string UserinfoEndpoint = EndpointPath($"{EndpointTemplate}/userinfo"); + // NOTE: we're NOT using /api/v1.0/ here because it will clash with the Delivery API docs private static string EndpointPath(string relativePath) => $"/umbraco/delivery/api/v1/{relativePath}"; } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs new file mode 100644 index 000000000000..9e71636324b0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs @@ -0,0 +1,28 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Server.AspNetCore; +using Umbraco.Cms.Api.Delivery.Routing; +using Umbraco.Cms.Api.Delivery.Services; + +namespace Umbraco.Cms.Api.Delivery.Controllers.Security; + +[ApiVersion("1.0")] +[ApiController] +[VersionedDeliveryApiRoute(Common.Security.Paths.MemberApi.EndpointTemplate)] +[ApiExplorerSettings(IgnoreApi = true)] +[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)] +public class CurrentMemberController : DeliveryApiControllerBase +{ + private readonly ICurrentMemberClaimsProvider _currentMemberClaimsProvider; + + public CurrentMemberController(ICurrentMemberClaimsProvider currentMemberClaimsProvider) + => _currentMemberClaimsProvider = currentMemberClaimsProvider; + + [HttpGet("userinfo")] + public async Task Userinfo() + { + Dictionary claims = await _currentMemberClaimsProvider.GetClaimsAsync(); + return Ok(claims); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 5ecc856f3b6d..20141f210853 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -60,6 +60,7 @@ public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder) builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.ConfigureOptions(); builder.AddUmbracoApiOpenApiUI(); diff --git a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs new file mode 100644 index 000000000000..3250b24ae69b --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs @@ -0,0 +1,43 @@ +using OpenIddict.Abstractions; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Api.Delivery.Services; + +// NOTE: this is public and unsealed to allow overriding the default claims with minimal effort. +public class CurrentMemberClaimsProvider : ICurrentMemberClaimsProvider +{ + private readonly IMemberManager _memberManager; + + public CurrentMemberClaimsProvider(IMemberManager memberManager) + => _memberManager = memberManager; + + public virtual async Task> GetClaimsAsync() + { + MemberIdentityUser? memberIdentityUser = await _memberManager.GetCurrentMemberAsync(); + return memberIdentityUser is not null + ? await GetClaimsForMemberIdentityAsync(memberIdentityUser) + : throw new InvalidOperationException("Could not retrieve the current member. This method should only ever be invoked when a member has been authorized."); + } + + protected virtual async Task> GetClaimsForMemberIdentityAsync(MemberIdentityUser memberIdentityUser) + { + var claims = new Dictionary + { + [OpenIddictConstants.Claims.Subject] = memberIdentityUser.Key + }; + + if (memberIdentityUser.Name is not null) + { + claims[OpenIddictConstants.Claims.Name] = memberIdentityUser.Name; + } + + if (memberIdentityUser.Email is not null) + { + claims[OpenIddictConstants.Claims.Email] = memberIdentityUser.Email; + } + + claims[OpenIddictConstants.Claims.Role] = await _memberManager.GetRolesAsync(memberIdentityUser); + + return claims; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs new file mode 100644 index 000000000000..902129af6b1d --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Api.Delivery.Services; + +public interface ICurrentMemberClaimsProvider +{ + /// + /// Retrieves the claims for the currently logged in member. + /// + /// + /// This is used by the OIDC user info endpoint to supply "current user" info. + /// + Task> GetClaimsAsync(); +}