ABP version: 10.1.1 (@abp/ng.core, @abp/ng.oauth), Angular 21.2, angular-oauth2-oidc 20.0.2. Commercial license, microservice solution, Angular SPA, OpenIddict 7.2 auth server.
Background
Issue #23930 / PR #24050 moved the access and ID tokens to memory (MemoryTokenStorageService with a SharedWorker). A penetration test of our product found that OAuth tokens kept in browser storage can be stolen through XSS. With 10.1.1 the access token is protected, but refresh_token is not in keysShouldStoreInMemory, so it is still written to localStorage through AbpLocalStorageService. The refresh token is the long-lived credential: with it an attacker can mint new access tokens until it expires. So the original issue is only partly solved.
Problem 1: refresh token in localStorage
- Steps: default
provideAbpOAuth()with the code flow and theoffline_accessscope. Sign in, then open DevTools → Application → Local Storage. - Expected: no long-lived credential in Web Storage.
- Actual:
refresh_tokenis present in localStorage.
Problem 2: tabs refreshing at the same time break rotation
- Our OpenIddict server follows current best practice:
- single-use refresh tokens (
SetRefreshTokenReuseLeeway(0)); - reuse detection that revokes the whole token chain;
- no sliding expiration.
- single-use refresh tokens (
- The SharedWorker shares stored tokens between tabs, but each tab still runs its own refresh timer. When two tabs refresh at the same time, the second request presents an already-used refresh token. The server treats it as a replay and revokes the session, and the user is signed out in every tab.
Requests
- An option, or a supported extension point, to keep
refresh_tokenin memory as well (shared through the SharedWorker), accepting that the session ends when the last tab closes and is restored by a silent authorization-code redirect using the auth server's cookie. - Make only one tab refresh at a time (run the refresh inside the SharedWorker, or use a cross-tab lock such as the Web Locks API), so rotation with no reuse leeway works with several tabs open.
- Longer term: a supported refresh token in an HttpOnly, Secure, SameSite cookie from the auth server, or a documented backend-for-frontend template for Angular, as in the original #23930 proposal.
- Documentation:
- the token storage behaviour in 10.1;
- that apps overriding
OAuthStoragewithlocalStoragelose the protection; - the CSP needed for the
data:URL SharedWorker (worker-src data:), since a strictworker-srcsilently turns the shared storage off.
Impact This blocks closing a penetration-test finding (tokens in browser storage, CWE-922) for a government client without replacing ABP's OAuth storage and refresh code.
Want me to remove the two override lines on a front-end branch and test reload, several tabs and the two-tab refresh against dev?
2 Answer(s)
-
0
Hi,
- You can keep the
refresh_tokenin memory too. It is then shared between tabs through the same SharedWorker as the access token. ExtendMemoryTokenStorageServicelike this:
import { Injectable, inject } from '@angular/core'; import { AbpLocalStorageService } from '@abp/ng.core'; import { MemoryTokenStorageService } from '@abp/ng.oauth'; @Injectable({ providedIn: 'root' }) export class RefreshTokenInMemoryStorageService extends MemoryTokenStorageService { constructor() { super(); const storage = this as any; storage.keysShouldStoreInMemory.push('refresh_token'); storage.port?.postMessage({ action: 'get', key: 'refresh_token' }); inject(AbpLocalStorageService).removeItem('refresh_token'); } }Then register it after
provideAbpOAuth()inapp.config.ts:import { MemoryTokenStorageService, provideAbpOAuth } from '@abp/ng.oauth'; import { RefreshTokenInMemoryStorageService } from './refresh-token-in-memory-storage.service'; providers: [ // ... provideAbpOAuth(), { provide: MemoryTokenStorageService, useExisting: RefreshTokenInMemoryStorageService }, // ... ]A few notes:
- This is for a normal (non-SSR) Angular app.
- If you register
OAuthStorageyourself (for example withlocalStorage), remove that. Otherwise this class is not used. - The SharedWorker is created from a
data:URL, so your CSP needs to allowdata:inworker-src. If the worker can't start, each tab keeps its own tokens. - This class uses private members of
MemoryTokenStorageService, so treat it as a temporary workaround. - The
removeItemcall deletes the refresh token that older builds left in localStorage. - The tokens are not kept after the last tab is closed, and may be lost when the only open tab is reloaded. The user then needs to click Login again. As long as the AuthServer cookie is still valid, they won't be asked for the password.
- Each tab runs its own refresh timer, so two tabs can send the same refresh token. If the token was already redeemed when the second request arrives, that request is rejected as a replay. We don't have a workaround that keeps the zero leeway for now. If you can accept a short reuse window, change your current zero leeway to 10 seconds in the
PreConfigureServicesof your AuthServer module:
PreConfigure<OpenIddictServerBuilder>(builder => { builder.SetRefreshTokenReuseLeeway(TimeSpan.FromSeconds(10)); });A redeemed refresh token is then accepted again only within 10 seconds of its first redemption.
- We'll pass the rest to the Angular team: a supported option to keep the refresh token in memory, letting only one tab refresh at a time, the HttpOnly cookie / BFF option, and the docs for the token storage and the
worker-srcCSP.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - You can keep the
-
0
Hi maliming
Thanks for the workaround. We'll use it for now.
For our pen-test finding (CWE-922), the cleanest fix is a cookie-based session, where no tokens are held in the browser at all. Could you tell us:
- Does ABP have a supported or sample backend-for-frontend (BFF) setup for an Angular SPA in a microservice solution? In this setup, an ASP.NET Core host signs in with OpenIddict, keeps the tokens server-side, gives the browser only an HttpOnly, Secure, SameSite cookie, and proxies API calls (for example with YARP) to the gateway with the access token attached.
- If not, is it on the roadmap, and is there a rough timeline?
- In the meantime, what is the recommended way to run @abp/ng.core without @abp/ng.oauth? Which services or providers should we replace so that login, logout, the 401 redirect, and /api/abp/application-configuration work with cookie auth?
This would also solve the multi-tab refresh problem, since only the server would redeem refresh tokens, and we could keep SetRefreshTokenReuseLeeway(0).
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)