Base solution for your next web application
Open Closed

Token endpoint issues a new token on every call (refresh/cache not honored) #12677


User avatar
0
tdv.yazilim created

Environment ASP.NET Zero version: 15.3 Project type: [Core MVC & jQuery] .NET version: [.NET 10] Auth mechanism: TokenAuth (JWT Bearer) with refresh token Cache provider: [In-memory] Topology: Two separate applications. App A calls App B's /api/TokenAuth/Authenticate and /api/TokenAuth/RefreshToken as a service/integration account. Deployment: [multiple instances]

Hi, App A authenticates against App B as a service/integration account through POST /api/TokenAuth/Authenticate, caches the returned token in ICacheManager, and is supposed to reuse that cached token until it's close to expiry. When the access token is about to expire (or App B returns 401), App A calls POST /api/TokenAuth/RefreshToken and keeps going with the refreshed token.

The problem is that in practice a brand-new token seems to be issued on almost every call. It behaves as if the cached token is never reused and the refresh path is never taken — every request ends up going through a full Authenticate again.

What we expected:

  • The cached access token is reused until it's near expiry (based on expireInSeconds).
  • On expiry / 401, RefreshToken returns a new access token while the same refresh token stays valid until its own expiration.
  • A full Authenticate only happens once the refresh token itself has expired.

What we actually see:

  • A new token is generated on (almost) every request, as if either the cache is never hit or the previously issued token/refresh token is invalidated immediately.

What we've already done on our side:

  • The token is cached in ICacheManager with an expiry derived from expireInSeconds (minus a 60-second safety buffer).
  • The refresh path is guarded by a SemaphoreSlim with a double-check inside the lock, so concurrent requests shouldn't each trigger their own Authenticate.
  • We logged the cached token and confirmed AccessTokenExpiresAt is being set, and checked that expireInSeconds is deserialized correctly from the Authenticate response.

We'd appreciate your guidance on the following:

  1. In 15.3, does POST /api/TokenAuth/RefreshToken rotate the refresh token (issue a new one and invalidate the old), or is the same refresh token reusable until RefreshTokenExpiration? Which setting controls this behavior?
  2. What are the correct settings for our scenario under Authentication:JwtBearer:* (e.g. Expiration, RefreshTokenExpiration, SecurityTokenKey)?
  3. Are there any server-side mechanisms — TokenValidityKey, security stamp, single-use / one-time refresh tokens — that invalidate the previously issued token whenever a new Authenticate or RefreshToken call is made?
  4. Since this is app-to-app (service account) communication and the token needs to be reused across requests/instances, what is the recommended pattern? Should the token be shared through a distributed cache (Redis) instead of the default in-memory ICacheManager?

The sanitized client-side code (business names replaced, token/auth/cache logic unchanged) is below.

Thanks in advance.

`public class IntegrationTokenManager : IIntegrationTokenManager { private readonly ICacheManager _cacheManager; private static readonly SemaphoreSlim _tokenLock = new SemaphoreSlim(1, 1); private const int TokenExpiryBufferSeconds = 60;

public IntegrationTokenManager(ICacheManager cacheManager)
{
    _cacheManager = cacheManager;
}

private async Task<string> PostWithAutoRefresh(string url, object data)
{
    var token = await GetValidToken();

    using var client = new HttpClient();
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", token.Result.AccessToken);

    var content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json");
    var response = await client.PostAsync(url, content);

    if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
    {
        try
        {
            var refreshedToken = await RefreshAccessToken(token.Result.RefreshToken);
            PreserveRefreshTokenIfMissing(refreshedToken, token);
            token = refreshedToken;
        }
        catch
        {
            token = await GetAccessToken();
        }

        await SaveTokenToCache(token);
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token.Result.AccessToken);
        content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json");
        response = await client.PostAsync(url, content);
    }

    if (!response.IsSuccessStatusCode)
        throw new Exception($"Request failed: {response.StatusCode}");

    return await response.Content.ReadAsStringAsync();
}

private async Task<ExternalAuthToken> GetAccessToken()
{
    using var httpClient = new HttpClient();
    var requestData = new
    {
        userNameOrEmailAddress = ExternalApiSettings.Username,
        password = ExternalApiSettings.Password,
        isIntegrationRequest = true
    };

    var requestContent = new StringContent(
        Newtonsoft.Json.JsonConvert.SerializeObject(requestData),
        Encoding.UTF8, "application/json");

    var response = await httpClient.PostAsync(
        $"{ExternalApiSettings.BaseUrl}api/TokenAuth/Authenticate", requestContent);

    if (!response.IsSuccessStatusCode)
    {
        var errorContent = await response.Content.ReadAsStringAsync();
        throw new Exception($"Authenticate failed. StatusCode: {response.StatusCode}, Response: {errorContent}");
    }

    var responseContent = await response.Content.ReadAsStringAsync();
    var tokenResponse = Newtonsoft.Json.JsonConvert.DeserializeObject<ExternalAuthToken>(responseContent);

    if (tokenResponse == null || !tokenResponse.Success)
        throw new Exception("Token response could not be deserialized or success=false.");

    return tokenResponse;
}

private async Task<ExternalAuthToken> RefreshAccessToken(string refreshToken)
{
    using var httpClient = new HttpClient();
    var response = await httpClient.PostAsync(
        $"{ExternalApiSettings.BaseUrl}api/TokenAuth/RefreshToken?refreshToken={Uri.EscapeDataString(refreshToken)}",
        null);

    if (!response.IsSuccessStatusCode)
        throw new Exception("RefreshToken request failed.");

    var responseContent = await response.Content.ReadAsStringAsync();
    var tokenResponse = Newtonsoft.Json.JsonConvert.DeserializeObject<ExternalAuthToken>(responseContent);

    if (tokenResponse == null || !tokenResponse.Success)
        throw new Exception("RefreshToken response failed.");

    return tokenResponse;
}

private async Task<ExternalAuthToken> GetValidToken()
{
    var cache = _cacheManager.GetCache(IntegrationConsts.TokenCacheName);
    var cached = await cache.GetOrDefaultAsync(IntegrationConsts.TokenCacheKey) as ExternalAuthToken;

    if (IsStillValid(cached?.AccessTokenExpiresAt))
        return cached;

    await _tokenLock.WaitAsync();
    try
    {
        cached = await cache.GetOrDefaultAsync(IntegrationConsts.TokenCacheKey) as ExternalAuthToken;
        if (IsStillValid(cached?.AccessTokenExpiresAt))
            return cached;

        ExternalAuthToken freshToken;
        if (IsStillValid(cached?.RefreshTokenExpiresAt))
        {
            try
            {
                freshToken = await RefreshAccessToken(cached.Result.RefreshToken);
                PreserveRefreshTokenIfMissing(freshToken, cached);
            }
            catch
            {
                freshToken = await GetAccessToken();
            }
        }
        else
        {
            freshToken = await GetAccessToken();
        }

        await SaveTokenToCache(freshToken);
        return freshToken;
    }
    finally
    {
        _tokenLock.Release();
    }
}

private static bool IsStillValid(DateTime? expiresAt)
    => expiresAt.HasValue && Clock.Now < expiresAt.Value;

private static DateTime CalculateExpiry(DateTime issuedAt, int lifetimeSeconds)
{
    var safeLifetime = Math.Max(lifetimeSeconds, 0);
    var buffer = Math.Min(TokenExpiryBufferSeconds, safeLifetime);
    return issuedAt.AddSeconds(safeLifetime - buffer);
}

private static void PreserveRefreshTokenIfMissing(ExternalAuthToken freshToken, ExternalAuthToken previousToken)
{
    if (freshToken?.Result == null || previousToken?.Result == null) return;

    if (string.IsNullOrWhiteSpace(freshToken.Result.RefreshToken))
    {
        freshToken.Result.RefreshToken = previousToken.Result.RefreshToken;
        freshToken.Result.RefreshTokenExpireInSeconds = previousToken.Result.RefreshTokenExpireInSeconds;
        freshToken.RefreshTokenExpiresAt = previousToken.RefreshTokenExpiresAt;
    }
}

private async Task SaveTokenToCache(ExternalAuthToken token)
{
    var cache = _cacheManager.GetCache(IntegrationConsts.TokenCacheName);
    var now = Clock.Now;
    token.AccessTokenExpiresAt = CalculateExpiry(now, token.Result?.ExpireInSeconds ?? 0);

    if (token.RefreshTokenExpiresAt == default)
        token.RefreshTokenExpiresAt = CalculateExpiry(now, token.Result?.RefreshTokenExpireInSeconds ?? 0);

    await cache.SetAsync(IntegrationConsts.TokenCacheKey, token, null, token.RefreshTokenExpiresAt);
}

}`

Markdown is supported
Copy & paste or drag & drop images (max 30 MB per image)

1 Answer(s)
  • User Avatar
    0
    oguzhanagir created
    Support Team

    Hi @tdv.yazilim

    Thanks for the detailed description and code sample.

    In the standard ASP.NET Zero 15.3 implementation, POST /api/TokenAuth/RefreshToken does not rotate the refresh token. It validates the supplied refresh token and returns only a new access token, encrypted access token, and expireInSeconds. It does not return or revoke/consume the existing refresh token. Therefore, preserving the original refresh token and its original expiration on the client is correct.

    There is no Authentication:JwtBearer setting that enables refresh token rotation. The same refresh token is normally reusable until its JWT expiration, but it can become invalid earlier because of security stamp, token-validity-key, logout/session revocation, or user state checks.

    One important exception is the Allow one concurrent login per user setting (App.UserManagement.AllowOneConcurrentLoginPerUser). When this is enabled, ASP.NET Zero updates the user's security stamp during both Authenticate and RefreshToken. Since access and refresh tokens are security stamp validated, a new authentication invalidates tokens previously issued for that user. A refresh also invalidates the refresh token that was just used, while the standard refresh response does not issue a replacement refresh token. This setting should therefore be disabled for a shared integration account that must have reusable/concurrent tokens.

    For the standard 15.3 template, the JWT settings under Authentication:JwtBearer are:

    "JwtBearer": {
      "IsEnabled": "true",
      "SecurityKey": "a-long-random-production-secret",
      "Issuer": "your-issuer",
      "Audience": "your-audience"
    }
    

    The property name is SecurityKey, not SecurityTokenKey. SecurityKey, Issuer, and Audience must be identical on every App B instance. Changing the signing key invalidates all JWTs signed with the old key.

    In the standard template, access and refresh lifetimes are not read from Authentication:JwtBearer:Expiration or Authentication:JwtBearer:RefreshTokenExpiration. They are set in AppConsts:

    public static TimeSpan AccessTokenExpiration = TimeSpan.FromDays(1);
    public static TimeSpan RefreshTokenExpiration = TimeSpan.FromDays(365);
    

    Those are the 15.3 defaults; choose production values according to your security requirements. Adding similarly named values under Authentication:JwtBearer has no effect unless your project contains a customization that reads them.

    TokenValidityKey is not single use. ASP.NET Zero creates a distinct validity key for each token and stores it in the token validity cache and in AbpUserTokens. Issuing another token adds another key; it does not remove previous keys. A cache miss on App B also does not by itself make a token invalid because validation falls back to the database. A validity key can be removed explicitly, for example on logout. Security stamp changes and session management rules are separate invalidation mechanisms.

    If Session Management is enabled on App B, also check its absolute timeout, revocation state, and fingerprint validation settings. The default fingerprint policy uses IP address and user agent. A token created by one App A instance and used by another instance with a different source IP or user agent can then be rejected.

    The supplied client code has several points that can explain the observed behavior:

    1. The default in memory ICacheManager is process local. Each App A instance has its own token entry, so each instance authenticates on its first local cache miss. Process restarts also lose the entry.
    2. The static SemaphoreSlim is also process local. It does not coordinate different App A instances.
    3. The 401 refresh block in PostWithAutoRefresh is outside that semaphore. Concurrent requests receiving 401 can all refresh; when refresh fails, they can all call Authenticate.
    4. CalculateExpiry considers a token with a lifetime of 60 seconds or less immediately expired, because the safety buffer becomes equal to the complete lifetime. Verify the actual expireInSeconds value and use a smaller buffer for short lived tokens.
    5. The broad catch blocks hide the refresh error and immediately fall back to Authenticate. Log the refresh response status and body before falling back; otherwise a security stamp, session fingerprint, binding, or signing key error appears only as repeated authentication.

    With multiple App A instances, use a distributed cache such as Redis if all instances are intended to share one token set. The refresh/authenticate critical section must also use a distributed lock (or a single token provider service); Redis alone does not make the read check refresh write sequence atomic. After obtaining that lock, read the cache again before refreshing. The same coordination path should be used for proactive expiry and 401 recovery.

    Using Redis for App B's framework caches is also recommended in a multi instance deployment, especially when relying on immediate security stamp, logout, or token revocation propagation. Keep the same database, JWT signing key, issuer, audience, and relevant settings across all App B instances.

    For machine to machine communication, the preferred long term design is an OAuth2 client credentials flow implemented/configured for the API rather than a shared user's username/password. Client credentials clients normally request a new access token when it expires rather than using a refresh token. If you continue with TokenAuth, use a dedicated non interactive account, disable one concurrent login at the effective host/tenant scope, protect the refresh token as a credential, and coordinate cache/refresh across instances.

    We suggest checking these items first:

    1. Confirm App.UserManagement.AllowOneConcurrentLoginPerUser is false at the effective host/tenant scope.
    2. Confirm whether Session Management and fingerprint validation are enabled.
    3. Log App A's instance ID, cache hit/miss, calculated access/refresh expiry, and the complete refresh failure response.
    4. Confirm expireInSeconds is greater than the 60 second buffer.
    5. Confirm all App B instances use the same SecurityKey, Issuer, and Audience.
    6. Move App A's token entry and refresh coordination to distributed infrastructure if one token must be shared.

    The custom isIntegrationRequest field is not part of the standard 15.3 Authenticate contract, so please also review any App B customization that handles that field. Such custom logic could add another invalidation path.

    Thank you

    Markdown is supported
    Copy & paste or drag & drop images (max 30 MB per image)