Follow-up to #10937: MemoryTokenStorageService + refresh token in memory causes two regressions the previous issue's link is : https://abp.io/support/questions/10937/Angular-MemoryTokenStorageService-still-persists-refreshtoken-in-localStorage-and-concurrent-tabs-break-refresh-token-rotation
Setup: We applied the workaround from this thread. RefreshTokenInMemoryStorageService extends MemoryTokenStorageService, pushes refresh_token into keysShouldStoreInMemory, posts a get for it to the SharedWorker, and removes the old refresh_token from localStorage. It's provided as { provide: MemoryTokenStorageService, useExisting: RefreshTokenInMemoryStorageService } after provideAbpOAuth().
Tokens are no longer in localStorage, as intended, but two problems appeared.
Issue 1: refreshing a page sends the user to the root (dashboard) instead of the page they were on
Steps:
Sign in and open a deep route, e.g. /my-tasks, in a single tab.
Press F5.
Expected: the page reloads on /my-tasks, still signed in.
Actual: the app signs in again through the auth server and lands on / (dashboard). Before the redirect, API calls made during startup return 401.
Our analysis: when the only tab unloads, the SharedWorker has no ports left and the browser ends it, so all in-memory tokens are lost, including the refresh token. The reloaded tab starts anonymous and has to sign in again. When that sign-in starts from a 401 rather than from the guard, no state/returnUrl is sent, so the auth server returns to the redirect URI (root).
Issue 2: duplicating a tab makes all open tabs reload repeatedly
Steps:
Sign in and open /my-tasks. Duplicate the tab (or open the app in a second tab). Expected: the new tab picks up the tokens from the SharedWorker, and the other tabs are not affected. Actual: all open tabs reload several times before they settle.
Network log of the duplicated tab (Fetch/XHR):
Boot 1: openid-configuration, jwks, token, application-configuration (9.2 kB, authenticated), API calls. Boot 2: openid-configuration, jwks, application-configuration (3.4 kB, anonymous). Then the page reloads. Boot 3: openid-configuration, jwks, application-configuration (authenticated), API calls. Our analysis, from the @abp/ng.oauth 10.1.1 sources:
MemoryTokenStorageService asks the SharedWorker for the tokens asynchronously (port.postMessage({ action: 'get' })). Nothing waits for the answers before AuthService.init() and configState.refreshAppState() in getInitialData(). A new tab can therefore load application-configuration without a token and get an anonymous currentUser. The tokens arrive right after that. checkAccessToken (CHECK_AUTHENTICATION_STATE_FN_KEY) then sees hasValidAccessToken() === true with no currentUser.id and calls clearOAuthStorage(). clearOAuthStorage() removes the in-memory keys through the worker. The worker broadcasts remove to every other port, and each tab runs refreshDocument(), which reloads it. The reloaded tabs start with no tokens and sign in again. Each new access_token is broadcast as set, and checkAuthStateChanges reloads any tab whose cache has no access_token yet. This produces the chain of reloads. Questions / requests
Can startup wait for the SharedWorker's answers (e.g. a ready promise in MemoryTokenStorageService awaited before AuthService.init()), so that a new tab never loads the configuration anonymously? Should checkAccessToken avoid clearOAuthStorage(), which signs out every tab, when the mismatch is only caused by the token arriving late in this tab? For example, it could reload the app configuration once with the token instead. Is official support for keeping refresh_token in memory planned? Our workaround relies on private members (keysShouldStoreInMemory, port). What is the recommended way to keep the user on the current page after F5 when the tokens exist only in memory (Issue 1)? Thanks.
3 Answer(s)
-
0
Hi,
- The new tab can load the application configuration before the SharedWorker returns the tokens. When the tokens arrive,
checkAccessTokensees a token without a current user and clears the tokens, which reloads the other tabs. Here is an updated workaround. It replaces the storage class from my previous answer.
The storage class waits for the worker for up to 3 seconds. If the tokens arrive later and the tab hasn't signed in on its own, it reloads that tab once, or loads the configuration again if it was already reloaded. It also keeps
nonceandPKCE_verifierper tab (in 10.1.1 they are in localStorage, so tabs that sign in at the same time overwrite each other's), and it doesn't reload a tab that is still loading the tokens or is in the middle of its own sign-in when another tab gets a token.import { Injectable, Injector, inject } from '@angular/core'; import { AbpLocalStorageService, ConfigStateService } from '@abp/ng.core'; import { MemoryTokenStorageService } from '@abp/ng.oauth'; // Per-tab sign-in state, so tabs that sign in at the same time don't overwrite each other's nonce. const sessionKeys = ['nonce', 'PKCE_verifier', 'session_state']; const reloadKey = 'abp-late-token-reload'; // Reloads this tab once. If it was already reloaded, loads the configuration again instead. export function reloadOrRefreshConfiguration(injector: Injector) { if (!sessionStorage.getItem(reloadKey)) { sessionStorage.setItem(reloadKey, 'true'); location.reload(); return; } setTimeout(() => injector.get(ConfigStateService).refreshAppState().subscribe()); } const hasUser = (injector: Injector) => !!injector.get(ConfigStateService).getDeep('currentUser.id'); export function clearReloadMark() { sessionStorage.removeItem(reloadKey); } @Injectable({ providedIn: 'root' }) export class RefreshTokenInMemoryStorageService extends MemoryTokenStorageService { readonly ready: Promise<void>; // True when the tab stopped waiting before it got the tokens (timeout or worker error). timedOut = false; // True from the start of a sign-in in this tab until it stores its own token. The library // removes the code from the URL before it exchanges it, so the URL can't be checked later. signingIn = new URLSearchParams(location.search).has('code'); private hasOwnToken = false; private waitingForTokens = true; constructor() { super(); const storage = this as any; const injector = inject(Injector); storage.keysShouldStoreInMemory.push('refresh_token'); inject(AbpLocalStorageService).removeItem('refresh_token'); // When another tab gets a token, don't reload a tab that is still loading the tokens // (the broadcast fills its cache) or that is finishing its own sign-in. const checkAuthStateChanges = storage.checkAuthStateChanges; storage.checkAuthStateChanges = (key: string) => { if (!this.waitingForTokens && !this.signingIn) { checkAuthStateChanges(key); } }; const port: MessagePort | undefined = storage.port; if (!storage.useSharedWorker || !port) { this.waitingForTokens = false; this.ready = Promise.resolve(); return; } let done = false; let lateTokens: 'apply' | 'ignore' | undefined; this.ready = new Promise<void>(resolve => { const finish = () => { done = true; this.waitingForTokens = false; resolve(); }; const stopWaiting = () => { if (!done) { this.timedOut = true; finish(); } }; const handler = port.onmessage!; port.onmessage = event => { const isLate = done && this.timedOut && event.data?.action === 'get'; if (isLate) { // A tab that signs in on its own keeps its own tokens. lateTokens ??= this.hasOwnToken || this.signingIn ? 'ignore' : 'apply'; if (lateTokens === 'ignore') { return; } } // A get answer is a snapshot from when it was asked. Don't let it overwrite // a newer value that this tab already stored or got from another tab. if (event.data?.action !== 'get' || !storage.cache.has(event.data.key)) { handler.call(port, event); } // The worker answers in order, so this is the last reply. if (event.data?.action === 'get' && event.data.key === 'refresh_token') { if (isLate && this.getItem('access_token') && !hasUser(injector)) { // The tab started without the tokens of the other tabs. reloadOrRefreshConfiguration(injector); } finish(); } }; storage.worker?.addEventListener('error', stopWaiting); setTimeout(stopWaiting, 3000); }); port.postMessage({ action: 'get', key: 'refresh_token' }); } override getItem(key: string): string | null { return sessionKeys.includes(key) ? sessionStorage.getItem(key) : super.getItem(key); } override setItem(key: string, value: string): void { if (key === 'access_token') { this.signingIn = false; this.hasOwnToken = true; } if (sessionKeys.includes(key)) { sessionStorage.setItem(key, value); } else { super.setItem(key, value); } } override removeItem(key: string): void { if (sessionKeys.includes(key)) { sessionStorage.removeItem(key); } else { super.removeItem(key); } } }AuthService.init()waits for it. The route guard and the 401 handler below both callnavigateToLogin(), so it keeps the first call, runs it afterinit(), and skips it if the tab got a token in the meantime:import { Injectable, inject } from '@angular/core'; import { Params } from '@angular/router'; import { AbpOAuthService } from '@abp/ng.oauth'; import { RefreshTokenInMemoryStorageService } from './refresh-token-in-memory-storage.service'; @Injectable({ providedIn: 'root' }) export class WaitForTokensOAuthService extends AbpOAuthService { private tokenStorage = inject(RefreshTokenInMemoryStorageService); private initDone!: () => void; // Completes when init() finishes, also when a 401 arrives before init() is called. private initialized = new Promise<void>(resolve => (this.initDone = resolve)); private signingIn = false; override async init() { try { await this.tokenStorage.ready; return await super.init(); } finally { this.initDone(); } } // The route guard and the 401 handler can both call this, keep the first call // and run it after init(). override navigateToLogin(queryParams?: Params) { if (this.signingIn) { return; } this.signingIn = true; this.tokenStorage.signingIn = true; this.initialized.then(() => { // The sign-in callback can finish while this call waits. if (this.isAuthenticated) { this.signingIn = false; this.tokenStorage.signingIn = false; return; } try { super.navigateToLogin(queryParams); } catch (error) { this.signingIn = false; this.tokenStorage.signingIn = false; throw error; } }); } }This check keeps the default behavior when the worker answered in time. When the token arrived during the configuration request, it reloads only that tab instead of clearing the tokens of every tab:
import { Injector } from '@angular/core'; import { CheckAuthenticationStateFn, ConfigStateService } from '@abp/ng.core'; import { checkAccessToken } from '@abp/ng.oauth'; import { OAuthService } from 'angular-oauth2-oidc'; import { RefreshTokenInMemoryStorageService, clearReloadMark, reloadOrRefreshConfiguration, } from './refresh-token-in-memory-storage.service'; export const checkAccessTokenOrReload: CheckAuthenticationStateFn = (injector: Injector) => { if (!injector.get(RefreshTokenInMemoryStorageService).timedOut) { // The tokens were loaded before the configuration request, keep the default check. clearReloadMark(); checkAccessToken(injector); return; } const hasValidToken = injector.get(OAuthService).hasValidAccessToken(); const hasUser = !!injector.get(ConfigStateService).getDeep('currentUser.id'); if (hasValidToken && !hasUser) { // The token arrived during the configuration request. reloadOrRefreshConfiguration(injector); } };When the tab has no token, for example after a reload of the only tab, this 401 handler starts the sign-in with the current URL instead of signing out and going to
/:import { Injectable } from '@angular/core'; import { AbpAuthenticationErrorHandler } from '@abp/ng.theme.shared'; @Injectable({ providedIn: 'root' }) export class SignInAgainErrorHandler extends AbpAuthenticationErrorHandler { override execute() { if (this.authService.getAccessToken()) { super.execute(); return; } // No token in this tab (for example after reloading the only tab): sign in and come back here. this.authService.navigateToLogin({ returnUrl: location.pathname + location.search + location.hash, }); } }Register them after
provideAbpOAuth():import { AuthService, CHECK_AUTHENTICATION_STATE_FN_KEY } from '@abp/ng.core'; import { MemoryTokenStorageService, provideAbpOAuth } from '@abp/ng.oauth'; import { AbpAuthenticationErrorHandler } from '@abp/ng.theme.shared'; import { RefreshTokenInMemoryStorageService } from './refresh-token-in-memory-storage.service'; import { WaitForTokensOAuthService } from './wait-for-tokens-oauth.service'; import { checkAccessTokenOrReload } from './check-access-token-or-reload'; import { SignInAgainErrorHandler } from './sign-in-again-error-handler'; providers: [ // ... provideAbpOAuth(), { provide: MemoryTokenStorageService, useExisting: RefreshTokenInMemoryStorageService }, { provide: AuthService, useExisting: WaitForTokensOAuthService }, { provide: CHECK_AUTHENTICATION_STATE_FN_KEY, useValue: checkAccessTokenOrReload }, { provide: AbpAuthenticationErrorHandler, useExisting: SignInAgainErrorHandler }, // ... ]A few notes:
- The notes from my previous answer still apply: this is for a non-SSR app, don't register your own
OAuthStorage, and allowdata:inworker-src. If the CSP blocks the worker, each tab signs in and signs out on its own. - With the 401 handler, a page that calls a protected API through
RestService(or the generated proxies) without a token goes to the login page. - If Remember me is not checked and the access token has already expired when a tab starts (for example after the computer was asleep), ABP signs out at startup, so the other tabs reload and sign in again.
- This uses private members of
MemoryTokenStorageService, so it's a temporary workaround.
This doesn't change the refresh timers. Each tab still refreshes on its own, so with a zero reuse leeway two tabs can send the same refresh token. When the second one fails, the tokens are cleared in every tab and the tabs sign in again when a guard or a protected call needs it, so a tab can lose its current page. A reuse leeway accepts the same refresh token again only within that window (for example 10 seconds), which covers tabs that refresh at the same time.
When the only tab is reloaded, the browser can end the SharedWorker and then the tokens are lost. If the route uses
authGuard, or the page calls a protected API throughRestService, the app signs in again with the current URL and the user comes back to the same page. No password is needed while the AuthServer cookie is valid. Public pages without either stay anonymous:
import { authGuard, permissionGuard } from '@abp/ng.core'; { path: 'my-tasks', canActivate: [authGuard, permissionGuard], // ... }- We'll fix and improve this in
@abp/ng.oauth, so you can remove this workaround later.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - The new tab can load the application configuration before the SharedWorker returns the tokens. When the tokens arrive,
-
0
Hi,
The fix is in https://github.com/abpframework/abp/pull/26349. It will ship in the next 10.7.x patch release. I don't have an exact date yet, and I'll update this thread when it's out.
After you upgrade:
- Remove the classes from my previous answer (
WaitForTokensOAuthService,checkAccessTokenOrReload,SignInAgainErrorHandlerand the waiting code in the storage class). If you still want the refresh token in memory, keep only the shortRefreshTokenInMemoryStorageServicefrom https://abp.io/support/questions/10937. - A new tab waits for the tokens of the other tabs before it starts, so duplicating a tab doesn't sign out the other tabs anymore.
- To start the sign-in and come back to the current page when a 401 arrives without a token (for example after reloading the only tab), enable it like this:
provideAbpThemeShared( withHttpErrorConfig({ signInWithoutToken: true, }), ),You mentioned you can't apply the workaround. What's blocking it? If it's something we can change, I'll try to make it simpler until the release.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - Remove the classes from my previous answer (