diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts index 87a8c8e2b756..815483224a26 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts @@ -205,6 +205,12 @@ describe('UmbAuthContext', () => { it('times the user out on a definitive invalid_grant failure', async () => { fetchResponder = invalidGrantResponse; + + // A peer tab establishes the session that is about to be rejected + const now = Math.floor(Date.now() / 1000); + channel.postMessage({ type: 'sessionUpdate', accessTokenExpiresAt: now + 60, expiresAt: now + 240 }); + await aTimeout(50); + let timeOutCalls = 0; context.timeOut = () => { timeOutCalls++; @@ -215,6 +221,19 @@ describe('UmbAuthContext', () => { expect(timeOutCalls).to.equal(1); }); + it('does not time the user out when there was no session to lose', async () => { + fetchResponder = invalidGrantResponse; + let timeOutCalls = 0; + context.timeOut = () => { + timeOutCalls++; + }; + + await context.setInitialState(); + + expect(timeOutCalls).to.equal(0); + expect(context.getIsAuthorized()).to.be.false; + }); + it('retries /token after a transient network failure', async () => { fetchResponder = () => { throw new TypeError('Failed to fetch'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index a173ec603eb5..9c4eb67d2e94 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -518,13 +518,18 @@ export class UmbAuthContext extends UmbContextBase { /** * Performs the actual refresh request and applies the result. - * A definitive rejection (e.g. `invalid_grant`) marks the session as dead and times the - * user out, so the re-authentication flow starts instead of every subsequent API request - * firing its own doomed refresh attempt. Transient failures (network errors, 5xx) leave - * the session state untouched so a later attempt can retry. + * A definitive rejection (e.g. `invalid_grant`) marks the session as dead, so every + * subsequent API request does not fire its own doomed refresh attempt. When the rejected + * refresh belonged to an established session, the user is also timed out so the + * re-authentication flow starts. Transient failures (network errors, 5xx) leave the + * session state untouched so a later attempt can retry. * @returns {Promise} True if the refresh succeeded, otherwise false. */ async #performRefresh(): Promise { + // A rejection with no session in hand means nobody is signed in — not that a session + // expired. Timing out there re-authenticates in a popup and leaves the caller to + // navigate, where a cold boot needs the ordinary login redirect. + const hadSession = !!this.#session.getValue(); const result = await this.#client.refreshToken(); if (result.response) { this.#updateSession(result.response.expiresIn, result.response.issuedAt); @@ -532,7 +537,9 @@ export class UmbAuthContext extends UmbContextBase { } if (result.fatal) { this.#sessionDead = true; - this.timeOut(); + if (hadSession) { + this.timeOut(); + } } return false; }