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

Add User Profile Menu for OpenID Connect login #3443

Merged
merged 10 commits into from
Apr 10, 2024
Merged
33 changes: 33 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/UserProfile.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<AuthorizeView>
<Authorized>
@if (_showUserProfileMenu)
{
<div class="profile-menu-container">
<FluentProfileMenu Initials="@_initials"
EMail="@_username"
FullName="@_name"
ButtonSize="@ButtonSize"
FooterLink="">
<HeaderTemplate>
<FluentStack VerticalAlignment="VerticalAlignment.Center">
<FluentLabel>@Loc[nameof(Resources.Login.LoggedInAs)]</FluentLabel>
<FluentSpacer />
<form @formname="sign-out-form" class="sign-out-form" action="authentication/logout" method="post">
<AntiforgeryToken />
<FluentButton Appearance="Appearance.Stealth" Type="ButtonType.Submit">Sign out</FluentButton>
</form>
</FluentStack>
</HeaderTemplate>
<ChildContent>
<FluentStack class="inner-persona-container">
<FluentPersona Initials="@_initials" ImageSize="@ImageSize" Class="inner-persona">
<div class="full-name">@_name</div>
<div class="user-id">@_username</div>
</FluentPersona>
</FluentStack>
</ChildContent>
</FluentProfileMenu>
</div>
}
</Authorized>
</AuthorizeView>
72 changes: 72 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/UserProfile.razor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Security.Claims;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Resources;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;

namespace Aspire.Dashboard.Components.Controls;

public partial class UserProfile : ComponentBase
{
[Inject]
public required IOptionsMonitor<DashboardOptions> DashboardOptions { get; set; }

[CascadingParameter]
public required Task<AuthenticationState> AuthenticationState { get; set; }

[Inject]
public required IStringLocalizer<Login> Loc { get; set; }

[Inject]
public required ILogger<UserProfile> Logger { get; set; }

[Parameter]
public string ButtonSize { get; set; } = "24px";

[Parameter]
public string ImageSize { get; set; } = "52px";

private bool _showUserProfileMenu;
private string? _name;
private string? _username;
private string? _initials;

protected override async Task OnParametersSetAsync()
{
if (DashboardOptions.CurrentValue.Frontend.AuthMode == FrontendAuthMode.OpenIdConnect)
{
var authState = await AuthenticationState;

var claimsIdentity = authState.User.Identity as ClaimsIdentity;

if (claimsIdentity?.IsAuthenticated == true)
{
_showUserProfileMenu = true;
_name = claimsIdentity.FindFirst(DashboardOptions.CurrentValue.Frontend.OpenIdConnect.GetNameClaimTypes());
if (string.IsNullOrWhiteSpace(_name))
{
// Make sure there's always a name, even if that name is a placeholder
_name = Loc[nameof(Login.AuthorizedUser)];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to suggestions on what, if anything, we should display in this case. I've seen this type of placeholder before so I went with it for now.

}

_username = claimsIdentity.FindFirst(DashboardOptions.CurrentValue.Frontend.OpenIdConnect.GetUsernameClaimTypes());
_initials = _name.GetInitials();
}
else
{
// If we don't have an authenticated user, don't show the user profile menu. This shouldn't happen.
_showUserProfileMenu = false;
_name = null;
_username = null;
_initials = null;
Logger.LogError("Dashboard:Frontend:AuthMode is configured for OpenIDConnect, but there is no authenticated user.");
}
}
}
}
60 changes: 60 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@

/*
Workaround for issue in fluent-anchored-region when the content
is near the top-right corner of the screen. Addressed in
https://github.com/microsoft/fluentui-blazor/pull/1795 which
we can use instead when it is available
*/
.profile-menu-container ::deep .fluent-profile-menu fluent-anchored-region {
transform: unset !important;
left: unset;
right: -4px;
}

::deep fluent-button[appearance=stealth]:not(:hover):not(:active)::part(control) {
background-color: var(--neutral-layer-floating);
}

::deep fluent-button[appearance=stealth]:hover::part(control) {
background-color: var(--neutral-fill-secondary-hover);
}

::deep .fluent-profile-menu > .fluent-persona {
margin: 0 4px;
}

::deep .fluent-profile-menu > .fluent-persona > .initials {
font-size: var(--type-ramp-minus-1-font-size);
}

::deep .full-name,
::deep .user-id {
color: var(--neutral-foreground-rest);
max-width: 200px;
overflow-x: hidden;
text-overflow: ellipsis;
}

::deep .full-name {
font-size: var(--type-ramp-base-font-size);
}

::deep .user-id {
font-size: var(--type-ramp-minus-1-font-size);
font-weight: normal;
max-width: 200px;
}

::deep .inner-persona-container {
height: 100%;
}

::deep .fluent-persona.inner-persona {
margin-right: 40px;
align-items: normal;
}

/* The form takes up space and throws off the alignment if we don't change its display */
::deep .sign-out-form {
display: flex;
}
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
Title="@Loc[nameof(Layout.MainLayoutLaunchSettings)]" aria-label="@Loc[nameof(Layout.MainLayoutLaunchSettings)]">
<FluentIcon Value="@(new Icons.Regular.Size24.Settings())" Color="Color.Neutral" />
</FluentButton>
<UserProfile />
</FluentHeader>
<NavMenu />
<div class="messagebar-container">
Expand Down
8 changes: 4 additions & 4 deletions src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@
margin-left: auto;
}

::deep.layout > header fluent-button[appearance=stealth]:not(:hover)::part(control),
::deep.layout > header fluent-anchor[appearance=stealth]:not(:hover)::part(control),
::deep.layout > header fluent-anchor[appearance=stealth].logo::part(control),
::deep.layout > header > .header-gutters > fluent-button[appearance=stealth]:not(:hover)::part(control),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made a bunch of these header styles more specific to the elements we were trying to impact so they wouldn't change the style of things within the FluentProfileMenu

::deep.layout > header > .header-gutters > fluent-anchor[appearance=stealth]:not(:hover)::part(control),
::deep.layout > header > .header-gutters > fluent-anchor[appearance=stealth].logo::part(control),
::deep.layout > .aspire-icon fluent-anchor[appearance=stealth].logo::part(control) {
background-color: var(--neutral-layer-4);
}
Expand All @@ -86,7 +86,7 @@
margin-bottom: 0;
}

::deep.layout > header fluent-anchor {
::deep.layout > header > .header-gutters > fluent-anchor {
font-size: var(--type-ramp-plus-2-font-size);
}

Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Components/_Imports.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
@using Aspire.Dashboard.Authentication
@using Microsoft.AspNetCore.Authentication.OpenIdConnect
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
Expand Down
51 changes: 51 additions & 0 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ public sealed class FrontendOptions
public string? EndpointUrls { get; set; }
public FrontendAuthMode? AuthMode { get; set; }
public string? BrowserToken { get; set; }
public OpenIdConnectOptions OpenIdConnect { get; set; } = new OpenIdConnectOptions();

public byte[]? GetBrowserTokenBytes() => _browserTokenBytes;

Expand Down Expand Up @@ -163,3 +164,53 @@ public sealed class TelemetryLimitOptions
public int MaxAttributeLength { get; set; } = int.MaxValue;
public int MaxSpanEventCount { get; set; } = int.MaxValue;
}

// Don't set values after validating/parsing options.
public sealed class OpenIdConnectOptions
tlmii marked this conversation as resolved.
Show resolved Hide resolved
{
private string[]? _nameClaimTypes;
private string[]? _usernameClaimTypes;

public string NameClaimType { get; set; } = "name";
public string UsernameClaimType { get; set; } = "preferred_username";

public string[] GetNameClaimTypes()
{
Debug.Assert(_nameClaimTypes is not null, "Should have been parsed during validation.");
return _nameClaimTypes;
}

public string[] GetUsernameClaimTypes()
{
Debug.Assert(_usernameClaimTypes is not null, "Should have been parsed during validation.");
return _usernameClaimTypes;
}

internal bool TryParseOptions([NotNullWhen(false)] out IEnumerable<string>? errorMessages)
{
List<string>? messages = null;
if (string.IsNullOrWhiteSpace(NameClaimType))
{
messages ??= [];
messages.Add("OpenID Connect claim type for name not configured. Specify a Dashboard:OpenIdConnect:NameClaimType value.");
}
else
{
_nameClaimTypes = NameClaimType.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}

if (string.IsNullOrWhiteSpace(UsernameClaimType))
{
messages ??= [];
messages.Add("OpenID Connect claim type for username not configured. Specify a Dashboard:OpenIdConnect:UsernameClaimType value.");
}
else
{
_usernameClaimTypes = UsernameClaimType.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}

errorMessages = messages;

return messages is null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options)
case FrontendAuthMode.Unsecured:
break;
case FrontendAuthMode.OpenIdConnect:
if (!options.Frontend.OpenIdConnect.TryParseOptions(out var messages))
{
errorMessages.AddRange(messages);
}
break;
case FrontendAuthMode.BrowserToken:
if (string.IsNullOrEmpty(options.Frontend.BrowserToken))
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions.Si
});
#endif
}
else if (dashboardOptions.Frontend.AuthMode == FrontendAuthMode.OpenIdConnect)
{
_app.MapPost("/authentication/logout", () => TypedResults.SignOut(authenticationSchemes: [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));
}
}

private ILogger<DashboardWebApplication> GetLogger()
Expand Down
27 changes: 27 additions & 0 deletions src/Aspire.Dashboard/Extensions/ClaimsIdentityExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Security.Claims;

namespace Aspire.Dashboard.Extensions;

internal static class ClaimsIdentityExtensions
{
/// <summary>
/// Searches the claims in the <see cref="ClaimsIdentity.Claims"/> for each of the claim types in <paramref name="claimTypes" />
/// in the order presented and returns the first one that it finds.
/// </summary>
public static string? FindFirst(this ClaimsIdentity identity, string[] claimTypes)
{
foreach (var claimType in claimTypes)
{
var claim = identity.FindFirst(claimType);
if (claim is not null)
{
return claim.Value;
}
}

return null;
}
}
45 changes: 27 additions & 18 deletions src/Aspire.Dashboard/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,30 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using System.Text;

namespace Aspire.Dashboard.Extensions;

internal static class StringExtensions
{
/// <summary>
/// Shortens a string by replacing the middle with an ellipsis.
/// </summary>
/// <param name="text">The string to shorten</param>
/// <param name="maxLength">The max length of the result</param>
public static string TrimMiddle(this string text, int maxLength)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hasn't been used for a while, so I deleted it and the unit test cases.

{
if (text.Length <= maxLength)
{
return text;
}

var firstPart = (maxLength - 1) / 2;
var lastPart = firstPart + ((maxLength - 1) % 2);

return $"{text[..firstPart]}…{text[^lastPart..]}";
}

public static string SanitizeHtmlId(this string input)
{
var sanitizedBuilder = new StringBuilder(capacity: input.Length);
Expand All @@ -49,4 +32,30 @@ static bool IsValidHtmlIdCharacter(char c)
return char.IsLetterOrDigit(c) || c == '_' || c == '-';
}
}

/// <summary>
/// Returns the two initial letters of the first and last words in the specified <paramref name="name"/>.
/// If only one word is present, a single initial is returned. If <paramref name="name"/> is null, empty or
/// white space only, <paramref name="defaultValue"/> is returned.
/// </summary>
[return: NotNullIfNotNull(nameof(defaultValue))]
public static string? GetInitials(this string name, string? defaultValue = default)
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved
{
var s = name.AsSpan().Trim();

if (s.Length == 0)
{
return defaultValue;
}

var lastSpaceIndex = s.LastIndexOf(' ');

if (lastSpaceIndex == -1)
{
return s[0].ToString().ToUpperInvariant();
}

// The name contained two or more words. Return the initials from the first and last.
return $"{char.ToUpperInvariant(s[0])}{char.ToUpperInvariant(s[lastSpaceIndex + 1])}";
}
}
8 changes: 5 additions & 3 deletions src/Aspire.Dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ Set `Dashboard:Frontend:AuthMode` to `BrowserToken`. Browser token authenticatio

Set `Dashboard:Frontend:AuthMode` to `OpenIdConnect`, then add the following configuration:

- `Authentication:Schemes:OpenIdConnect:Authority` &mdash; URL to the identity provider (IdP)
- `Authentication:Schemes:OpenIdConnect:ClientId` &mdash; Identity of the relying party (RP)
- `Authentication:Schemes:OpenIdConnect:ClientSecret`&mdash; A secret that only the real RP would know
- `Authentication:Schemes:OpenIdConnect:Authority` URL to the identity provider (IdP)
- `Authentication:Schemes:OpenIdConnect:ClientId` Identity of the relying party (RP)
- `Authentication:Schemes:OpenIdConnect:ClientSecret` A secret that only the real RP would know
- Other properties of [`OpenIdConnectOptions`](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.builder.openidconnectoptions) specified in configuration container `Authentication:Schemes:OpenIdConnect:*`
- `Dashboard:Frontend:OpenIdConnect:NameClaimType` specifies the claim type(s) that should be used to display the authenticated user's full name. Can be a single claim type or a comma-delimited list of claim types. Defaults to `name`.
- `Dashboard:Frontend:OpenIdConnect:UsernameClaimType` specifies the claim type(s) that should be used to display the authenticated user's username. Can be a single claim type or a comma-delimited list of claim types. Defaults to `preferred_username`.
tlmii marked this conversation as resolved.
Show resolved Hide resolved

### OTLP authentication

Expand Down
Loading