/*
* 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)");
}
}