We have a high priority, elusive, intermittent issue on our prod system where users are getting logged out at some point while using the system while using our "public site". The public site is our own angular front end using ABP backend project + separated ABP Auth Server. The "admin site" is the normal ABP angular front end that we've built on.
There are two noted instances of the issue that I will mention:
- A customer was logged out the first time after they logged in that they attempted to get a new access token from their refresh token. We have access tokens that expire in 5 mins, so it makes
/connect/token
requests every 3 mins 45 seconds or so. What I note about this time is that two /connect/token requests were made at exactly the same time (shown in ourAbpAuditLogs
at the time that it failed. - I performed an experiment where I had 45 Chrome tabs open, all logged in our prod system as the same user (normal shared local storage etc). I didn't have any issues for 6 hours. I happened to turn a VPN for something else and at that point I got
(failed)net::ERR_NETWORK_CHANGED
on a/connect/token
request on two tabs. It navigated me to the login page. On the remaining 43 tabs, calls tohttps://<redacted>/connect/logout?post_logout_redirect_uri=https%3A%2F%2F<redacted>
were made and they all navigated me to the login page over the next ~5 mins.
In both instances of this issue, I note that there were errors in the Auth server logs, HOWEVER, it's worth noting that when I was performing the experiment, I was seeing this error on prod 14 times per minute, which equates to this happening nearly every time token requests were made (generally volume on prod is not extreme) from my 45 tabs, without me experiencing the logout issue.
An exception occurred in the database while saving changes for context type '"Volo.Abp.OpenIddict.EntityFrameworkCore.OpenIddictProDbContext"'."""Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions. at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetWithRowsAffectedOnlyAsync(Int32 commandIndex, RelationalDataReader reader, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeAsync(RelationalDataReader reader, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.SqlServer.Update.Internal.SqlServerModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)"
I can see some related questions:
- https://abp.io/support/questions/9561/Randomly-AbpDbConcurrencyException
- https://abp.io/support/questions/8933/Intermittent-Login-Failure-After-Upgrading-ABP-Framework-to-v9#answer-3a189b11-6314-b51d-08de-b775c7bdf450
- https://abp.io/support/questions/9604/AbpDbConcurrencyException
However, AbpEfCoreNavigationHelper
is not available in ABP v7.3.3. It was added in March 2024 which is after the v7.3 release I believe. Are there alternatives for v7.3.3?
This is our angular auth class that wraps over the abp auth service.
import {AuthService, ConfigStateService, LoginParams} from "@abp/ng.core";
import {Injectable} from '@angular/core';
import {ActivatedRoute, Params} from "@angular/router";
import {firstValueFrom} from "rxjs";
import {<redacted>LocalStorageService} from "../<redacted>-local-storage.service";
import {AuthConfigService} from "./auth-config.service";
import {LoginType} from "./login-type";
const socialLoginCallbackQueryParams = "social-login-callback-query-params";
const impersonationCallbackQueryParams = "impersonation-callback-query-params";
/**
* Wrapper around AuthService
* DO NOT USE AuthService directly or it may logout
* using a login flow that was NOT the one used to log in
*/
@Injectable({
providedIn: 'root',
})
export class <redacted>AuthService {
constructor(
private authService: AuthService,
private route: ActivatedRoute,
private localstorage: <redacted>LocalStorageService,
private authConfigService: AuthConfigService,
private configStateService: ConfigStateService
) {
}
get cachedSocialLoginQueryParams(): Params {
return this.localstorage.getItem(socialLoginCallbackQueryParams);
}
get localStorageImpersonationQueryParams(): Params {
return this.localstorage.getItem(impersonationCallbackQueryParams);
}
public async init(loginType?: LoginType): Promise<any> {
await this.configureAndInitAsync(loginType);
}
public async isAuthenticated(): Promise<boolean> {
await this.configureAndInitAsync();
return this.authService.isAuthenticated;
}
public async logout(): Promise<void> {
// if we're impersonating, we don't want to log out of the auth server (code) otherwise we can't initiate new
// impersonation sessions without needing to log in again, but rather just logout just for the public site
// - thus force password response type
await this.configureAndInitAsync(this.authConfigService.currentLoginType === LoginType.Impersonation
? LoginType.Password : undefined);
await firstValueFrom(this.authService.logout());
}
public async passwordLogin(params: LoginParams): Promise<any> {
await this.configureAndInitAsync(LoginType.Password);
return await firstValueFrom(this.authService.login(params));
}
public async socialLogin(provider: string): Promise<void> {
await this.configureAndInitAsync(LoginType.Social);
let queryParams: Params = await firstValueFrom(this.route.queryParams);
this.localstorage.setItem(socialLoginCallbackQueryParams, queryParams);
this.authService.navigateToLogin({
"IsSocialLogin": true,
"Provider": provider ?? undefined,
});
}
public async navigateToLoginForImpersonation(queryParams?: Params): Promise<void> {
await this.configureAndInitAsync(LoginType.Impersonation);
this.authService.navigateToLogin(queryParams);
}
public deleteCachedSocialLoginQueryParams(): void {
this.localstorage.removeItem(socialLoginCallbackQueryParams);
}
public deleteLocalStorageImpersonationQueryParams(): void {
this.localstorage.removeItem(impersonationCallbackQueryParams);
}
public async setLocalStorageImpersonationQueryParams(): Promise<void> {
let queryParams: Params = await firstValueFrom(this.route.queryParams);
this.localstorage.setItem(impersonationCallbackQueryParams, queryParams);
}
private async configureAndInitAsync(loginType?: LoginType): Promise<void> {
let hasChanges: boolean = this.authConfigService.configureAuth(loginType);
if (hasChanges) {
// Only call 'init' if the auth environment config has changed, since we can't cancel the refresh token
// subscription. Re-initializing without changes results in extra 'connect/token' requests, leading to errors on
// the auth server due to concurrency
await this.authService.init();
// Refresh app state to reflect any session changes (e.g. newly acquired token)
this.configStateService.refreshAppState();
}
}
}
and a relevant HTTP_INTERCEPTORS, but according to the AuditLogs, neither the customer nor me had a 400 error on connect/token (in my case, it never reached the Auth server and in the customer's case, they were both 200s):
import {Injectable} from '@angular/core';
import {
HttpEvent,
HttpInterceptor,
HttpHandler,
HttpRequest,
HttpErrorResponse,
} from '@angular/common/http';
import {Observable, tap} from 'rxjs';
import {EnvironmentService} from "@abp/ng.core";
import {Router} from "@angular/router";
import {<redacted>AuthService} from "./shared/auth/<redacted>-auth.service";
@Injectable()
export class ErrorHandlingInterceptor implements HttpInterceptor {
private readonly tokenUrl: string;
constructor(
private <redacted>AuthService: <redacted>AuthService,
private router: Router,
environmentService: EnvironmentService
) {
let issuerUrl = environmentService.getEnvironment()['oAuthConfig']['issuer'];
this.tokenUrl = this.ensureEndsWithSlash(issuerUrl) + 'connect/token';
}
private ensureEndsWithSlash(str: string): string {
let slash = '/';
if (str.endsWith(slash)) {
return str;
}
return str + slash;
}
intercept(
request: HttpRequest<any>,
next: HttpHandler
): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
tap({
next: _ => {
},
error: async (err: HttpErrorResponse) => {
// Account lockout
if (err.url == this.tokenUrl
&& err.status == 400
&& await this.<redacted>AuthService.isAuthenticated()) {
await this.<redacted>AuthService.logout()
await this.router.navigate([''], { queryParamsHandling: 'preserve' });
}
}
}));
}
}
Do you have any ideas about solving this issue please?
Thanks, Matt
7 Answer(s)
-
0
Your issue is related to concurrency exceptions in OpenIddict token management, which can cause unexpected logouts or failed refreshes, especially when multiple tabs or clients attempt to refresh tokens simultaneously. This is a known challenge in ABP/OpenIddict-based systems, particularly before the introduction of improved concurrency handling in later ABP versions.
Solution Steps
1. Understand the Root Cause
- The error
DbUpdateConcurrencyException
occurs when two or more requests try to update or revoke the same refresh token at the same time. OpenIddict uses optimistic concurrency for token updates, so only one request will succeed; others will fail, potentially invalidating the session for the user. - This is especially likely when multiple browser tabs or clients share the same refresh token and attempt to refresh at nearly the same time.
2. Mitigation Strategies for ABP v7.3.3
Since
AbpEfCoreNavigationHelper
and related improvements are not available in v7.3.3, you need to implement workarounds:A. Prevent Parallel Refresh Token Requests
- Ensure that your Angular client does not send multiple
/connect/token
requests in parallel for the same user/session. - Implement a mutex/lock mechanism in your Angular service to serialize refresh token requests. Only allow one refresh at a time, and queue or deduplicate others.
Example (pseudo-code for Angular service):
private refreshInProgress = false; private refreshPromise: Promise<any> | null = null; refreshToken(): Promise<any> { if (this.refreshInProgress) { return this.refreshPromise!; } this.refreshInProgress = true; this.refreshPromise = this.authService.refreshToken().finally(() => { this.refreshInProgress = false; this.refreshPromise = null; }); return this.refreshPromise; }
- Use this method in your HTTP interceptor or wherever you handle token refreshes.
B. Handle Concurrency Exceptions Gracefully
- If a concurrency exception occurs, catch it and prompt the user to re-authenticate, rather than logging them out everywhere.
- Optionally, implement a retry mechanism with a short delay, as sometimes the token state will resolve after the first failed attempt.
C. Reduce Token Lifetime or Use Reference Tokens
- Consider increasing the access token lifetime if possible, to reduce refresh frequency.
- Alternatively, use reference tokens (if supported by your OpenIddict setup) to minimize the impact of concurrency.
3. Upgrade Recommendation
- The most robust solution is to upgrade to a newer ABP version (>=8.0) where concurrency handling for OpenIddict tokens has been improved. If possible, plan for an upgrade to benefit from these fixes.
4. Additional Resources
- Review these related discussions and solutions for more context and code samples:
By serializing refresh token requests on the client and handling concurrency exceptions gracefully, you can significantly reduce or eliminate these intermittent logout issues in ABP v7.3.3. For a long-term fix, consider upgrading to a newer ABP version with improved OpenIddict concurrency support.
Sources:
- Randomly AbpDbConcurrencyException
- Intermittent Login Failure After Upgrading ABP Framework to v9
- AbpDbConcurrencyException
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
- The error
-
0
The AI response doesn't really provide anything new for me.
-
0
hi
Can you share the full logs.txt of the authserver?
liming.ma@volosoft.com
Thanks
-
0
I've sent you the full logs. Cheers.
-
0
Thanks. I will check your logs.
-
0
hi
Your logs miss something. For example, the exception entity and the call stack.
Can you share the
Logs.txt
file with the Debug level?https://abp.io/support/questions/8622/How-to-enable-Debug-logs-for-troubleshoot-problems
Thanks.
-
0
Additionally, you need to check if your Angular application is making two refresh token requests at the same time.
Override the
SaveChangesAsync
method of your app DbContext to output the exception entity and Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing.public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = new CancellationToken()) { try { return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); } catch (AbpDbConcurrencyException e) { if (e.InnerException is DbUpdateConcurrencyException dbUpdateConcurrencyException) { if (dbUpdateConcurrencyException.Entries.Count > 0) { var sb = new StringBuilder(); sb.AppendLine(dbUpdateConcurrencyException.Entries.Count > 1 ? "There are some entries which are not saved due to concurrency exception:" : "There is an entry which is not saved due to concurrency exception:"); foreach (var entry in dbUpdateConcurrencyException.Entries) { sb.AppendLine(entry.ToString()); } Logger.LogWarning(sb.ToString()); } foreach (var entry in dbUpdateConcurrencyException.Entries) { // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. entry.State = EntityState.Unchanged; } } throw; } }
Thanks.