Alexander Shabarshov 2a7a24c9e7 Initial contribution
2025-11-03 14:43:26 +00:00

284 lines
13 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* dbMango
*
* Copyright 2025 Deutsche Bank AG
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.Reflection;
using System.Security.Claims;
namespace Rms.Service.Bootstrap.Security;
/// <summary>
/// OpenIDConnect helpers
/// </summary>
internal static class OidcHelper
{
private static readonly ILogger _log = Logging.Logging.GetLogger(MethodBase.GetCurrentMethod()?.DeclaringType ?? typeof(int));
private const string ReturnUrlTag = "returnurl";
/// <summary>
/// Configure JWTBearer authorization for Oidc for use with API servers.
/// </summary>
public static void ConfigureOidcJwtBearer(IOptions<SecuritySettings> settings, JwtBearerOptions x)
{
var oidcConfiguration = OidcConfiguration.Load(settings.Value);
x.Configuration = oidcConfiguration;
x.TokenValidationParameters = new()
{
ValidAudience = settings.Value.Oidc.ClientId,
ValidAudiences = new [] {settings.Value.Oidc.ClientId}.Concat(settings.Value.Oidc.ValidClientIds)
};
x.Events = CreateJwtEventsHandler();
}
/// <summary>
/// Configure OAuth2 to use Oidc. Use within web applications.
/// </summary>
public static void ConfigureOpenIdConnect(IOptions<SecuritySettings> settings, OpenIdConnectOptions options)
{
if ( string.IsNullOrWhiteSpace(settings.Value.Oidc.Secret) )
throw new ApplicationException("ClientSecret is not provided. It required for Oidc");
var oidcConfiguration = OidcConfiguration.Load(settings.Value);
options.Configuration = oidcConfiguration;
options.ClientId = settings.Value.Oidc.ClientId;
options.ClientSecret = settings.Value.Oidc.Secret;
options.Authority = oidcConfiguration.Issuer;
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = false;
options.GetClaimsFromUserInfoEndpoint = false;
options.UseTokenLifetime = false; // sync cookie lifetime with token lifetime
// https://stackoverflow.com/questions/66545492/the-cookie-aspnetcore-identity-application-has-set-samesite-none-and-must-a
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
options.NonceCookie.SameSite = SameSiteMode.None;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
options.CorrelationCookie.SameSite = SameSiteMode.None;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("email");
options.TokenValidationParameters = new()
{
NameClaimType = ClaimTypes.Email
};
options.Events = new()
{
OnRedirectToIdentityProvider = (context) =>
{
// see https://stackoverflow.com/questions/56755406/how-to-redirect-from-signin-oidc-back-to-my-controller-action
context.ProtocolMessage.State = context.Request.Path; // note that path must be in base65. see LoginControl.razor / Login.cshtml.cs
var builder = new UriBuilder( context.ProtocolMessage.RedirectUri );
if (!string.IsNullOrWhiteSpace(settings.Value.Oidc.ForceRedirectUrlProtocol))
builder.Scheme = settings.Value.Oidc.ForceRedirectUrlProtocol;
if ( settings.Value.Oidc.ForceRedirectUrlPort != 0 )
builder.Port = settings.Value.Oidc.ForceRedirectUrlPort;
context.ProtocolMessage.RedirectUri = builder.ToString();
_log.LogDebug($"Redirecting OIDC login to RedirectUri={context.ProtocolMessage.RedirectUri}");
return Task.CompletedTask;
},
OnAccessDenied = context =>
{
context.HandleResponse();
context.Response.Redirect("/");
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
context.HandleResponse();
_log.LogError( context.Exception, "Authentication failed" );
context.Response.Redirect($"/login-failed?m={Uri.EscapeDataString(context.Exception.Message)}");
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var email = context.SecurityToken.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Email || x.Type == "email")?.Value;
var url = context.ProtocolMessage.GetParameter("state");
if ( !string.IsNullOrWhiteSpace( url ) )
{
// see https://stackoverflow.com/questions/56755406/how-to-redirect-from-signin-oidc-back-to-my-controller-action
var claims = new[] { new Claim( ReturnUrlTag, url ) };
var appIdentity = new ClaimsIdentity( claims );
//add url to claims
context.Principal?.AddIdentity( appIdentity );
}
try
{
var token = new UserTokens();
token.UpdateTokens(
context.TokenEndpointResponse?.AccessToken ?? throw new ApplicationException( "Access token is NULL" ),
context.TokenEndpointResponse?.RefreshToken ?? throw new ApplicationException( "Refresh token is NULL" ),
context.TokenEndpointResponse?.IdToken
);
var svc = context.HttpContext.RequestServices.GetRequiredService<IServerSideTokenStore>();
await svc.StoreTokensAsync( context.Principal!, token );
_log.LogDebug($"Received tokens for Email=\"{email}\" ExpireAt=\"{token.AccessTokenExpiresAt}\" AccessToken={token.AccessToken != null} RefreshToken={token.RefreshToken != null} IdToken={token.IdToken != null} State=\"{url}\"");
}
catch( Exception e )
{
context.HandleResponse();
var m = $"Authentication SUCCEEDED for {email}, but later this happened: {e.Message}";
_log.LogError( m, e);
context.Response.Redirect($"/login-failed?m={Uri.EscapeDataString(m)}");
return;
}
},
OnTicketReceived = context =>
{
// see https://stackoverflow.com/questions/56755406/how-to-redirect-from-signin-oidc-back-to-my-controller-action
var url = context.Principal?.FindFirst(ReturnUrlTag)?.Value;
if ( url?.StartsWith("/login/", StringComparison.OrdinalIgnoreCase) ?? false)
{
var plainUrl = Base64Decode(Uri.UnescapeDataString(url["/login/".Length ..]));
if ( !string.IsNullOrWhiteSpace(plainUrl))
url = plainUrl;
}
if (string.IsNullOrWhiteSpace(url)
|| url.Equals("/login", StringComparison.OrdinalIgnoreCase)
|| url.Equals("/signin-oidc", StringComparison.OrdinalIgnoreCase)
)
{
url = "/";
}
context.ReturnUri = url;
return Task.CompletedTask;
},
};
_log.LogInformation($"Configured Oidc on {settings.Value.Oidc.ConfigUrl} ClientId=\"{options.ClientId}\"");
}
private static JwtBearerEvents CreateJwtEventsHandler()
{
return new()
{
OnMessageReceived = context =>
{
_log.LogDebug( $"{context.Request.Method} {context.Request.Path} Request received" );
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
context.Request.Headers.TryGetValue( "ReqId", out var reqId );
_log.LogDebug( $"{context.Request.Method} ReqId={reqId} {context.Request.Path} Token validated {context.SecurityToken.Id} ValidTo=\"{context.SecurityToken.ValidTo}\"" );
return Task.CompletedTask;
},
OnAuthenticationFailed = authenticationFailedContext =>
{
if (authenticationFailedContext.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
authenticationFailedContext.Response.Headers["Token-Expired"] = "true";
}
authenticationFailedContext.Request.Headers.TryGetValue("ReqId", out var reqId);
_log.LogError( $"JWT Authentication failed Method={authenticationFailedContext.Request.Method} ReqId={reqId} Path=\"{authenticationFailedContext.Request.Path}\"", authenticationFailedContext.Exception);
return Task.CompletedTask;
},
OnForbidden = forbiddenContext =>
{
forbiddenContext.Request.Headers.TryGetValue("ReqId", out var reqId);
forbiddenContext.Request.Headers.TryGetValue("Authorization", out var token);
// do not log the token!!!
var s = (string?)token;
if ( s == null )
return Task.CompletedTask;
var claims = TokenHelper.ParseClaimsFromJwt(s);
var claimsStr =string.Join(", ", claims.Select(claim => $"{claim.Subject?.Name}:{claim.Value}"));
_log.LogError($"JWT Authentication forbidden Method={forbiddenContext.Request.Method}, ReqId={reqId}, Path=\"{forbiddenContext.Request.Path}\" Claims=\"{claimsStr}\"");
return Task.CompletedTask;
}
};
}
/// <summary>
/// https://stackoverflow.com/questions/72868249/how-to-handle-user-oidc-tokens-in-blazor-server-when-the-browser-is-refreshed-an
/// </summary>
public static void ConfigureCookieForOpenIdConnect(CookieAuthenticationOptions options) =>
options.Events.OnValidatePrincipal = async context =>
{
var user = context.Principal;
if ( user?.Identity == null || !user.Identity.IsAuthenticated )
return;
// get user's tokens from server side token store
var tokenStore = context.HttpContext.RequestServices.GetRequiredService<IServerSideTokenStore>();
var tokens = await tokenStore.GetTokensAsync(user);
if ( tokens?.AccessToken == null
|| tokens.IdToken == null
|| tokens.RefreshToken == null )
{
// if we lack either an access or refresh token,
// then reject the Principal (forcing a round trip to the id server)
context.RejectPrincipal();
return;
}
// we have a custom API client that takes care of refreshing our tokens
// and storing them in ServerSideTokenStore, we call that here
if ( await TokenRefreshHelper.UpdateAccessTokenIfNeeded(context.HttpContext.RequestServices, tokens, user) ?? false )
{
await tokenStore.StoreTokensAsync(user, tokens);
}
// check the tokens have been updated
var newTokens = await tokenStore.GetTokensAsync(user);
if ( newTokens?.IsAccessTokenExpired ?? true )
{
// if we lack an access token or it was not successfully renewed,
// then reject the Principal (forcing a round trip to the id server)
context.RejectPrincipal();
}
};
public static string Base64Decode(string base64EncodedData)
{
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
return System.Text.Encoding.UTF8.GetString(base64EncodedBytes);
}
}