/* * 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.Extensions.Options; using System.Reflection; using System.Security.Claims; using System.Text.Json.Nodes; using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Rms.Service.Bootstrap.Security; internal static class TokenRefreshHelper { private static readonly ILogger _log = Logging.Logging.GetLogger(MethodBase.GetCurrentMethod()?.DeclaringType ?? typeof(int)); public static int OidcTokenRefreshGapMinutes = 4; private static string? _clientId; private static string? _secret; /// /// Renew AccessToken using RefreshToken /// https://stackoverflow.com/questions/60858985/addopenidconnect-and-refresh-tokens-in-asp-net-core /// /// true if tokens were updated public static async Task UpdateAccessTokenIfNeeded( IServiceProvider services, UserTokens userTokens, ClaimsPrincipal? principal = null ) { if ( userTokens.AccessToken == null ) return null; InitClientId( services ); var email = GetUserEmail( services, principal ); var needToRefresh = NeedToRefresh( userTokens, email ); if ( needToRefresh != true ) return needToRefresh; return await TryRefreshTokens( userTokens, email); } /// /// Check if token needs to be updated /// /// /// true - need to refresh token now /// false - token needs to be refreshed, but it's not possible (refresh token expired) /// null - there is no need to refresh token /// private static bool? NeedToRefresh( UserTokens userTokens, string? email ) { if ( userTokens is { RefreshToken: not null, IsRefreshTokenExpired: true } ) { _log.LogDebug( $"Refresh token already expired for Email=\"{email}\" at Expiration=\"{userTokens.RefreshTokenExpiresAt:G}\", Now=\"{DateTime.UtcNow:G}\". No refresh attempt made." ); userTokens.Clear(); return false; } var expiration = userTokens.AccessTokenExpiresAt!; var gap = TimeSpan.FromMinutes( OidcTokenRefreshGapMinutes ); // no need to update yet if ( expiration > DateTime.UtcNow + gap ) { //_log.LogDebug($"Access token for Email=\"{email}\" expires at {expiration:G}, now is {DateTime.UtcNow:G}. There is plenty of time."); return null; } _log.LogDebug( userTokens.IsAccessTokenExpired ? $"Access token for Email=\"{email}\" already expired at Expiration=\"{expiration:G}\", Now=\"{DateTime.UtcNow:G}\". Refreshing." : $"Access token for Email=\"{email}\" expires at Expiration=\"{expiration:G}, Now=\"{DateTime.UtcNow:G}\". Condition={expiration > DateTime.UtcNow + gap} Refreshing." ); return true; } private static readonly SemaphoreSlim _globalLock = new(1, 1); private static async Task TryRefreshTokens( UserTokens userTokens, string? email ) { await _globalLock.WaitAsync(); try { // second check in case token was already updated in another thread var needToRefresh = NeedToRefresh( userTokens, email ); if ( needToRefresh != true ) { _log.LogDebug( $"Access token for Email=\"{email}\" renewed in different thread new Expiration=\"{userTokens.AccessTokenExpiresAt:G}\", Now=\"{DateTime.UtcNow:G}\"" ); return needToRefresh; } return await TryRefreshTokensUnsafe( userTokens, email ); } finally { _globalLock.Release(); } } private static async Task TryRefreshTokensUnsafe( UserTokens userTokens, string? email ) { if ( _clientId == null || _secret == null ) throw new ApplicationException( "Oidc is not configured" ); var tokens = await RefreshTokens( _clientId, _secret, userTokens.RefreshToken!, email ); if ( tokens != null ) { UpdateTokenProvider( userTokens, tokens, email ); return true; } _log.LogDebug( $"Access token for Email=\"{email}\" expired and revoked." ); userTokens.Clear(); return false; } private static void InitClientId( IServiceProvider services ) { if ( _clientId == null || _secret == null ) { var settings = services.GetRequiredService>(); var pm = services.GetRequiredService(); _secret = pm.DecryptPassword( settings.Value.Oidc.Secret ); _clientId = settings.Value.Oidc.ClientId; } } private static string? GetUserEmail(IServiceProvider services, ClaimsPrincipal? principal) { if (principal == null) { var user = services.GetRequiredService(); principal = user.GetUser(); } var email = Get(principal, ClaimTypes.Email) ?? Get(principal, "email"); return email; } private static string? Get(ClaimsPrincipal principal, string claimType) => principal.Identities .SelectMany(x => x.Claims) .FirstOrDefault(x => x.Type == claimType && !string.IsNullOrWhiteSpace(x.Value)) ?.Value; private static async Task RefreshTokens(string clientId, string clientSecret, string refreshToken, string? email) { if (OidcConfiguration.Configuration == null) return null; var tokenEndpoint = OidcConfiguration.Configuration.TokenEndpoint; var requestData = new[] { new KeyValuePair("client_id", clientId), new KeyValuePair("client_secret", clientSecret), new KeyValuePair("grant_type", "refresh_token"), new KeyValuePair("refresh_token", refreshToken), }; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("accept", "application/json"); var response = await client.PostAsync(tokenEndpoint, new FormUrlEncodedContent(requestData)); if (!response.IsSuccessStatusCode) { string data; try { data = await response.Content.ReadAsStringAsync(); } catch( Exception e ) { data = e.Message; } _log.LogError( $"Refresh token request unsuccessful for Email=\"{email}\": {response.StatusCode}\n\t{data}" ); return null; } var json = await response.Content.ReadAsStringAsync(); return JsonNode.Parse(json)?.AsObject(); } private static void UpdateTokenProvider( UserTokens userTokens, JsonNode tokens, string? email ) { var accessToken = tokens[OpenIdConnectParameterNames.AccessToken]?.ToString(); var refreshToken = tokens[OpenIdConnectParameterNames.RefreshToken]?.ToString(); var idToken = tokens[OpenIdConnectParameterNames.IdToken]?.ToString(); if ( accessToken == null || refreshToken == null ) { if ( userTokens.ClearExpired() ) _log.LogDebug($"Expired token(s) were revoked. Email=\"{email}\"\n"+ $"\tAccessTokenExpiresAt=\"{userTokens.AccessTokenExpiresAt:G}\" AccessTokenExpired={userTokens.IsAccessTokenExpired}\n" + $"\tRefreshTokenExpiresAt=\"{userTokens.RefreshTokenExpiresAt:G}\" RefreshTokenExpired={userTokens.IsRefreshTokenExpired}\n" + $"\tNow=\"{DateTime.UtcNow:G}\" (UTC time)"); return; } userTokens.UpdateTokens( accessToken, refreshToken, idToken ); _log.LogDebug($"Tokens updated for Email=\"{email}\" AccessTokenExpiresAt=\"{userTokens.AccessTokenExpiresAt:G}\" " + $"AccessTokenExpired={userTokens.IsAccessTokenExpired} Now=\"{DateTime.UtcNow:G}\" (UTC time)"); } }