/* * 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; /// /// OpenIDConnect helpers /// internal static class OidcHelper { private static readonly ILogger _log = Logging.Logging.GetLogger(MethodBase.GetCurrentMethod()?.DeclaringType ?? typeof(int)); private const string ReturnUrlTag = "returnurl"; /// /// Configure JWTBearer authorization for Oidc for use with API servers. /// public static void ConfigureOidcJwtBearer(IOptions 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(); } /// /// Configure OAuth2 to use Oidc. Use within web applications. /// public static void ConfigureOpenIdConnect(IOptions 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(); 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; } }; } /// /// https://stackoverflow.com/questions/72868249/how-to-handle-user-oidc-tokens-in-blazor-server-when-the-browser-is-refreshed-an /// 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(); 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); } }