From c3cc9d5151db0aef20116ac38782ab0f21f5a0fa Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:10:21 +0800 Subject: [PATCH 1/3] Fix cross-tab session ownership race --- README.md | 16 +- .../auth/activedirectory/controller.js | 4 +- app/admin-tab/auth/azuread/controller.js | 4 +- app/admin-tab/auth/github/controller.js | 4 +- app/admin-tab/auth/localauth/controller.js | 4 +- app/admin-tab/auth/shibboleth/controller.js | 4 +- app/application/route.js | 163 +++++-- app/authenticated/project/route.js | 5 +- app/authenticated/route.js | 60 ++- app/login/index/controller.js | 12 +- app/mixins/mfa-account-manager.js | 10 +- app/mixins/subscribe.js | 101 +++-- app/services/access.js | 401 +++++++++++++++-- app/services/auth-session.js | 422 ++++++++++++++++++ app/services/cookies.js | 9 +- app/services/oidc.js | 29 +- app/services/session.js | 13 +- app/utils/auth-navigation.js | 39 ++ app/utils/constants.js | 7 + ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.117.md | 28 ++ package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 2 +- tests/unit/application/route-test.js | 96 ++++ tests/unit/authenticated/route-test.js | 47 +- .../mixins/subscribe-auth-session-test.js | 61 +++ .../unit/services/access-session-race-test.js | 404 +++++++++++++++++ tests/unit/services/access-test.js | 93 +++- tests/unit/services/auth-session-test.js | 194 ++++++++ tests/unit/services/oidc-test.js | 60 +++ tests/unit/utils/auth-navigation-test.js | 21 + 34 files changed, 2140 insertions(+), 189 deletions(-) create mode 100644 app/services/auth-session.js create mode 100644 app/utils/auth-navigation.js create mode 100644 docs/releases/web-console-1.6.117.md create mode 100644 tests/unit/mixins/subscribe-auth-session-test.js create mode 100644 tests/unit/services/access-session-race-test.js create mode 100644 tests/unit/services/auth-session-test.js create mode 100644 tests/unit/utils/auth-navigation-test.js diff --git a/README.md b/README.md index b0b885a2ca..68ce3686a9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status -The current compatibility release is `1.6.116`. It retains the existing Node 24, Ember, Sass, +The current compatibility release is `1.6.117`. It retains the existing Node 24, Ember, Sass, dependency, browser-smoke, terminal, console, and test-harness modernization. It adds a provider-neutral OpenID Connect administration and sign-in flow with PKCE S256, staged configuration validation, a real test login before @@ -16,6 +16,20 @@ activation, and local-authentication recovery. Product-owned names, logos, icons, package metadata, and visible text use PastureStack branding. API models and protocol fields remain compatible. +Release `1.6.117` prevents an older same-origin browser tab from revoking or +clearing a session that a newer tab has just established. Explicit user logout +is now the only browser path that requests server-side token revocation. Passive +401, storage, WebSocket, timer, and route failures reconcile against a +non-sensitive session generation; ordinary 403 permission failures remain local +to the failed request. Login, cookie readback, generation commit, session +adoption, and explicit logout share one cross-tab mutex, with a tested +IndexedDB lease fallback when Web Locks is unavailable. OIDC transactions retain +the generation captured before leaving the origin, stale callbacks cannot +overwrite a newer login, and waiting tabs validate the shared cookie before +adopting it. JWTs remain cookie- and memory-only and are never persisted in Web +Storage. Pair this release with Engine `0.183.302` or newer for session-bound, +idempotent server logout protection. + Release `1.6.116` recognizes the MFA API's structured error code even when the transport wraps it in a generic error. Sensitive settings updates open the security-confirmation dialog and retry only after successful confirmation; diff --git a/app/admin-tab/auth/activedirectory/controller.js b/app/admin-tab/auth/activedirectory/controller.js index 7f6c329e73..7fea50a4b9 100644 --- a/app/admin-tab/auth/activedirectory/controller.js +++ b/app/admin-tab/auth/activedirectory/controller.js @@ -212,6 +212,7 @@ export default Controller.extend({ disable: function() { this.send('clearError'); + let generation = this.get('access').captureGeneration(); var model = this.get('model'); model.setProperties({ @@ -220,7 +221,8 @@ export default Controller.extend({ model.save().then(() => { - this.get('access').clearSessionKeys(); + return this.get('access').clearLocalSession(generation); + }).then(() => { this.set('access.enabled',false); this.send('waitAndRefresh'); }).catch((err) => { diff --git a/app/admin-tab/auth/azuread/controller.js b/app/admin-tab/auth/azuread/controller.js index 56de79b5d2..88f67e12f6 100644 --- a/app/admin-tab/auth/azuread/controller.js +++ b/app/admin-tab/auth/azuread/controller.js @@ -109,6 +109,7 @@ export default Controller.extend({ disable: function() { this.send('clearError'); + let generation = this.get('access').captureGeneration(); var model = this.get('model'); model.setProperties({ @@ -118,7 +119,8 @@ export default Controller.extend({ }); model.save().then(() => { - this.get('access').clearSessionKeys(); + return this.get('access').clearLocalSession(generation); + }).then(() => { this.set('access.enabled',false); this.send('waitAndRefresh'); }).catch((err) => { diff --git a/app/admin-tab/auth/github/controller.js b/app/admin-tab/auth/github/controller.js index b2a237ba24..3cfa68324c 100644 --- a/app/admin-tab/auth/github/controller.js +++ b/app/admin-tab/auth/github/controller.js @@ -252,6 +252,7 @@ export default Controller.extend({ disable: function() { this.send('clearError'); + let generation = this.get('access').captureGeneration(); let model = this.get('model').clone(); model.setProperties({ @@ -265,7 +266,8 @@ export default Controller.extend({ }); model.save().then(() => { - this.get('access').clearSessionKeys(); + return this.get('access').clearLocalSession(generation); + }).then(() => { this.set('access.enabled',false); this.send('waitAndRefresh'); }).catch((err) => { diff --git a/app/admin-tab/auth/localauth/controller.js b/app/admin-tab/auth/localauth/controller.js index 74615a3b93..1fd8134249 100644 --- a/app/admin-tab/auth/localauth/controller.js +++ b/app/admin-tab/auth/localauth/controller.js @@ -201,6 +201,7 @@ export default Controller.extend({ disable: function() { this.send('clearError'); + let generation = this.get('access').captureGeneration(); var model = this.get('model'); model.setProperties({ @@ -210,7 +211,8 @@ export default Controller.extend({ }); model.save().then(() => { - this.get('access').clearSessionKeys(); + return this.get('access').clearLocalSession(generation); + }).then(() => { this.set('access.enabled',false); this.send('waitAndRefresh'); }).catch((err) => { diff --git a/app/admin-tab/auth/shibboleth/controller.js b/app/admin-tab/auth/shibboleth/controller.js index 65697507b3..620e9eea2a 100644 --- a/app/admin-tab/auth/shibboleth/controller.js +++ b/app/admin-tab/auth/shibboleth/controller.js @@ -29,6 +29,7 @@ export default Controller.extend({ }.property('model.allowedIdentities.@each.externalIdType','wasRestricted'), actions: { disable: function() { + let generation = this.get('access').captureGeneration(); let model = this.get('model').clone(); model.setProperties({ @@ -38,7 +39,8 @@ export default Controller.extend({ }); model.save().then(() => { - this.get('access').clearSessionKeys(); + return this.get('access').clearLocalSession(generation); + }).then(() => { this.set('access.enabled',false); this.get('shibbolethAuth').waitAndRefresh(); }).catch((err) => { diff --git a/app/application/route.js b/app/application/route.js index e8aa09eb27..613e79e661 100644 --- a/app/application/route.js +++ b/app/application/route.js @@ -5,6 +5,7 @@ import { service } from '@ember/service'; import Route from '@ember/routing/route'; import C from 'ui/utils/constants'; import Errors from 'ui/utils/errors'; +import { isAuthenticationPath, safeInternalTarget } from 'ui/utils/auth-navigation'; export default Route.extend({ access : service(), @@ -24,6 +25,10 @@ export default Route.extend({ loadingWatchdog: null, loadingTimeout : 30000, previousLang : null, + sessionSyncPromise: null, + syncingGeneration: null, + lastSyncedGeneration: null, + pendingSyncGeneration: null, init() { this._super(...arguments); @@ -62,6 +67,9 @@ export default Route.extend({ }, loading(transition) { + if ( transition && !transition.authGeneration ) { + transition.authGeneration = this.get('access').captureGeneration(); + } this.incrementProperty('loadingId'); let id = this.get('loadingId'); this.showLoadingOverlay(id); @@ -85,9 +93,10 @@ export default Route.extend({ /*if we dont abort the transition we'll call the model calls again and fail transition correctly*/ transition.abort(); - if ( [401,403].indexOf(Errors.status(err)) >= 0 ) + if ( Errors.status(err) === 401 ) { - this.send('logout',transition,true); + this.send('sessionInvalid', transition, true, null, + transition && transition.authGeneration, 401); return; } @@ -106,36 +115,79 @@ export default Route.extend({ }, logout(transition, timedOut, errorMsg) { - let session = this.get('session'); - let access = this.get('access'); - - access.clearToken().finally(() => { - session.set(C.SESSION.ACCOUNT_ID,null); - - this.get('tab-session').clear(); - - access.clearSessionKeys(); - - if ( transition && !session.get(C.SESSION.BACK_TO) ) { - session.set(C.SESSION.BACK_TO, window.location.href); + return this.get('access').explicitLogout().then((outcome) => { + if ( outcome && outcome.status === 'stale' ) { + this.reloadForSession(); + return; } + this.transitionToLogin(transition, timedOut, errorMsg); + }).catch((error) => { + this.controllerFor('application').set('error', error); + this.get('router').transitionTo('failWhale'); + }); + }, - if ( this.get('modal.modalVisible') ) { - this.get('modal').toggleModal(); + sessionInvalid(transition, timedOut, errorMsg, generation, status=401) { + generation = generation || (transition && transition.authGeneration) || + this.get('access').captureGeneration(); + return this.get('access').handlePassiveFailure(generation, status).then((outcome) => { + if ( outcome.status === 'adopted' || outcome.status === 'stale' ) { + this.reloadForSession(); + } else if ( outcome.status === 'active' && transition ) { + // A pre-fix tab can still remove the shared JavaScript cookie after + // its protected DELETE is rejected by the server. If this tab + // restored its own in-memory token snapshot, the failed transition + // was already aborted and must be resumed without another login. + this.reloadForSession(); + } else if ( outcome.status === 'invalid' ) { + this.transitionToLogin(transition, timedOut, errorMsg); + } else if ( outcome.status === 'forbidden' ) { + this.get('router').replaceWith('authenticated'); } + }).catch((error) => { + this.controllerFor('application').set('error', error); + this.get('router').transitionTo('failWhale'); + }); + }, - let params = {queryParams: {}}; - - if ( timedOut ) { - params.queryParams.timedOut = true; + authSessionChanged(change) { + let generation = change && change.newRecord && change.newRecord.generation; + if ( generation && generation === this.get('lastSyncedGeneration') ) { + return this.get('sessionSyncPromise'); + } + if ( this.get('sessionSyncPromise') ) { + if ( generation && generation === this.get('syncingGeneration') ) { + return this.get('sessionSyncPromise'); } + this.set('pendingSyncGeneration', generation || 'removed'); + return this.get('sessionSyncPromise'); + } - if ( errorMsg ) { - params.queryParams.errorMsg = errorMsg; + this.set('syncingGeneration', generation || 'removed'); + let promise = this.get('access').adoptSharedSession().then((outcome) => { + if ( outcome.status === 'adopted' ) { + this.set('lastSyncedGeneration', outcome.generation); + this.reloadForSession(); + } else if ( outcome.status === 'invalid' ) { + this.transitionToLogin(null, true); + } + return outcome; + }).finally(() => { + this.setProperties({ + sessionSyncPromise: null, + syncingGeneration : null, + }); + if ( this.get('pendingSyncGeneration') ) { + this.set('pendingSyncGeneration', null); + scheduleOnce('actions', this, function() { + this.send('authSessionChanged', { + newRecord: this.get('access.authSession').readShared(), + }); + }); } - - this.get('router').transitionTo('login', params); }); + this.set('sessionSyncPromise', promise); + return promise; }, langToggle() { @@ -207,14 +259,51 @@ export default Route.extend({ let backTo = session.get(C.SESSION.BACK_TO); session.set(C.SESSION.BACK_TO, undefined); - if ( backTo ) { - console.log('Going back to', backTo); - window.location.href = backTo; + let target = safeInternalTarget(backTo); + if ( target ) { + window.location.replace(target); } else { this.get('router').replaceWith('authenticated'); } }, + transitionToLogin(transition, timedOut, errorMsg) { + let session = this.get('session'); + session.set(C.SESSION.ACCOUNT_ID, null); + this.get('tab-session').clear(); + + if ( transition && !session.get(C.SESSION.BACK_TO) ) { + let returnTo = safeInternalTarget(window.location.href); + if ( returnTo && !isAuthenticationPath(returnTo) ) { + session.set(C.SESSION.BACK_TO, returnTo); + } + } + + if ( this.get('modal.modalVisible') ) { + this.get('modal').toggleModal(); + } + + let params = {queryParams: {}}; + if ( timedOut ) { + params.queryParams.timedOut = true; + } + if ( errorMsg ) { + params.queryParams.errorMsg = errorMsg; + } + this.get('router').transitionTo('login', params); + }, + + reloadForSession() { + let current = safeInternalTarget(window.location.href); + let backTo = safeInternalTarget(this.get(`session.${C.SESSION.BACK_TO}`)); + let target = current && !isAuthenticationPath(current) ? current : backTo; + if ( !target || isAuthenticationPath(target) ) { + this.get('router').replaceWith('authenticated'); + return; + } + window.location.replace(target); + }, + model(params, transition) { let github = this.get('github'); let stateMsg = 'Authorization state did not match, please try again.'; @@ -233,8 +322,8 @@ export default Route.extend({ }); if ( params.redirectTo ) { - let path = params.redirectTo; - if ( path.substr(0,1) === '/' ) { + let path = safeInternalTarget(params.redirectTo); + if ( path ) { this.get('session').set(C.SESSION.BACK_TO, path); } } @@ -253,9 +342,9 @@ export default Route.extend({ return reject('oidcTest'); } - let oidcCode; + let oidcLogin; try { - oidcCode = this.get('oidc').consumeAuthorization({ + oidcLogin = this.get('oidc').consumeLoginAuthorization({ code: params.code, error: params.oidcError, errorDescription: params.error_description, @@ -269,9 +358,13 @@ export default Route.extend({ return reject(err); } - return languagePromise.then(() => this.get('access').login(oidcCode)).then((xhr) => { + return languagePromise.then(() => this.get('access').login( + oidcLogin.code, undefined, undefined, oidcLogin.authSessionAttempt + )).then((xhr) => { transition.abort(); - if ( xhr.body && xhr.body.mfaRequired ) { + if ( xhr.authSessionSuperseded ) { + this.reloadForSession(); + } else if ( xhr.body && xhr.body.mfaRequired ) { this.get('router').transitionTo('login'); } else { this.finishLogin(); @@ -302,7 +395,9 @@ export default Route.extend({ // if we dont then model hook runs twice to finish the transition itself transition.abort(); // Can't call this.send() here because the initial transition isn't done yet - if ( xhr.body && xhr.body.mfaRequired ) { + if ( xhr.authSessionSuperseded ) { + this.reloadForSession(); + } else if ( xhr.body && xhr.body.mfaRequired ) { this.get('router').transitionTo('login'); } else { this.finishLogin(); diff --git a/app/authenticated/project/route.js b/app/authenticated/project/route.js index 1a6c8718f5..bf52de165c 100644 --- a/app/authenticated/project/route.js +++ b/app/authenticated/project/route.js @@ -28,9 +28,10 @@ export default Route.extend({ }, loadingError(err, transition, ret) { - if ( err && err.status && [401,403].indexOf(err.status) >= 0 ) + if ( err && err.status === 401 ) { - this.send('logout',transition,true); + this.send('sessionInvalid', transition, true, null, + transition && transition.authGeneration, 401); return; } diff --git a/app/authenticated/route.js b/app/authenticated/route.js index eb37223b55..1bf64368a7 100644 --- a/app/authenticated/route.js +++ b/app/authenticated/route.js @@ -30,27 +30,60 @@ export default Route.extend(Subscribe, PromiseToCb, { if ( this.get('access.enabled') ) { if ( this.get('access.isLoggedIn') ) { - this.testAuthToken(); + return this.get('access').ensureSession().then(() => { + this.testAuthToken(); + }, (error) => { + let generation = this.get('access').captureGeneration(); + transition.send('sessionInvalid', transition, true, null, generation, + Errors.status(error) || 401); + return reject(error); + }); } else { - transition.send('logout', transition, false); + transition.send('sessionInvalid', transition, false, null, + this.get('access').captureGeneration(), 401); return reject('Not logged in'); } } }, testAuthToken: function() { + let generation = this.get('access').captureGeneration(); let timer = later(() => { - this.get('access').testAuth().then((/* res */) => { - this.testAuthToken(); - }, (/* err */) => { - this.send('logout',null,true); - }); + this.checkAuthToken(generation); }, CHECK_AUTH_TIMER); this.set('testTimer', timer); }, + checkAuthToken(generation) { + if ( generation !== this.get('access').captureGeneration() ) { + this.testAuthToken(); + return resolve({status: 'stale'}); + } + return this.get('access').testAuth(generation).then((result) => { + if ( result && result.status === 'stale' ) { + this.send('sessionInvalid', null, false, null, generation, 401); + } else { + this.testAuthToken(); + } + return result; + }, (err) => { + let status = Errors.status(err); + if ( status === 401 ) { + this.send('sessionInvalid', null, true, null, generation, status); + } else { + // A permission denial or transient network failure is not proof that + // the browser session expired. Keep the session and try later. + this.testAuthToken(); + } + return {status: status === 403 ? 'forbidden' : 'error'}; + }); + }, + model(params, transition) { + transition.authGeneration = transition.authGeneration || + this.get('access').captureGeneration(); + let requestGeneration = transition.authGeneration; // Save whether the user is admin let type = this.get(`session.${C.SESSION.USER_TYPE}`); let isAdmin = (type === C.USER.TYPE_ADMIN) || !this.get('access.enabled'); @@ -93,7 +126,7 @@ export default Route.extend(Subscribe, PromiseToCb, { return promise.then((hash) => { return EmberObject.create(hash); }).catch((err) => { - return this.loadingError(err, transition); + return this.loadingError(err, transition, undefined, requestGeneration); }); }, @@ -128,11 +161,13 @@ export default Route.extend(Subscribe, PromiseToCb, { this.get('storeReset').reset(); }, - loadingError(err, transition) { + loadingError(err, transition, ret, generation) { console.log('Loading Error:', err); - if ( [401,403].indexOf(Errors.status(err)) >= 0 ) { + if ( Errors.status(err) === 401 ) { this.set('access.enabled', true); - this.send('logout',transition, (transition.targetName !== 'authenticated.index')); + this.send('sessionInvalid', transition, + (transition.targetName !== 'authenticated.index'), null, + generation || transition.authGeneration, 401); return; } @@ -244,7 +279,8 @@ export default Route.extend(Subscribe, PromiseToCb, { // Unauthorized error, send back to login screen if ( Errors.status(err) === 401 ) { - this.send('logout',transition,true); + this.send('sessionInvalid', transition, true, null, + transition && transition.authGeneration, 401); return false; } else diff --git a/app/login/index/controller.js b/app/login/index/controller.js index 50890abf18..d5bc95ac3a 100644 --- a/app/login/index/controller.js +++ b/app/login/index/controller.js @@ -122,7 +122,7 @@ export default Controller.extend({ later(() => { let provider = this.get('useLocalRecovery') ? 'localAuthConfig' : undefined; this.get('access').login(code, provider).then((xhr) => { - this.handleLoginResponse(xhr.body, true); + this.handleLoginResponse(xhr, true); }).catch((err) => { this.set('waiting', false); @@ -185,7 +185,7 @@ export default Controller.extend({ recoveryCode: this.get('recoveryCode'), emailCode: this.get('emailCode'), }).then((xhr) => { - this.handleLoginResponse(xhr.body); + this.handleLoginResponse(xhr); }).catch((err) => { this.setProperties({ errorMsg: localizedMfaError(err, this.get('intl')), @@ -208,7 +208,7 @@ export default Controller.extend({ webAuthnResponse: response, }); }).then((xhr) => { - this.handleLoginResponse(xhr.body); + this.handleLoginResponse(xhr); }).catch((err) => { this.setProperties({ errorMsg: localizedMfaError(err, this.get('intl')), @@ -260,8 +260,12 @@ export default Controller.extend({ }, }, - handleLoginResponse(body, resetMfaSelection) { + handleLoginResponse(xhr, resetMfaSelection) { + let body = xhr && xhr.body ? xhr.body : xhr; this.set('waiting', false); + if ( xhr && xhr.authSessionSuperseded ) { + return; + } if ( body && body.mfaRequired ) { let methods = this._availableMfaMethods(body.mfaMethods || []); let primary = methods.filter((method) => { diff --git a/app/mixins/mfa-account-manager.js b/app/mixins/mfa-account-manager.js index c358affea6..e36186446f 100644 --- a/app/mixins/mfa-account-manager.js +++ b/app/mixins/mfa-account-manager.js @@ -179,8 +179,14 @@ export default Mixin.create({ }, signInAgain() { - this.get('access').clearSessionKeys(); - this.get('router').transitionTo('login'); + let generation = this.get('access').captureGeneration(); + return this.get('access').clearLocalSession(generation).then((outcome) => { + if ( outcome.status === 'stale' ) { + window.location.reload(); + return; + } + this.get('router').transitionTo('login'); + }); }, }, diff --git a/app/mixins/subscribe.js b/app/mixins/subscribe.js index e387bd083c..4eb37341fd 100644 --- a/app/mixins/subscribe.js +++ b/app/mixins/subscribe.js @@ -14,6 +14,7 @@ const ORCHESTRATION_STACKS = [ export default Mixin.create({ k8s : service(), projects : service(), + access : service(), 'tab-session' : service(), subscribeSocket : null, @@ -31,50 +32,7 @@ export default Mixin.create({ socket.on('message', (event) => { schedule('actions', this, function() { - // Fail-safe: make sure the message is for this project - var currentProject = this.get(`tab-session.${C.TABSESSION.PROJECT}`); - var metadata = socket.getMetadata(); - var socketProject = metadata.projectId; - if ( currentProject !== socketProject ) { - console.error(`Subscribe ignoring message, current=${currentProject} socket=${socketProject} ` + this.forStr()); - this.connectSubscribe(); - return; - } - - var d = JSON.parse(event.data); - let resource; - if ( d.data && d.data.resource ) { - resource = store._typeify(d.data.resource); - d.data.resource = resource; - } - - //this._trySend('subscribeMessage',d); - - switch ( d.name) { - case 'resource.change': - let key = d.resourceType+'Changed'; - if ( this[key] ) { - this[key](d); - } - - if ( resource && C.REMOVEDISH_STATES.includes(resource.state) ) { - let type = get(resource,'type'); - let baseType = get(resource,'baseType'); - - store._remove(type, resource); - - if ( baseType && type !== baseType ) { - store._remove(baseType, resource); - } - } - break; - case 'logout': - this.send('logout', false); - break; - case 'ping': - this.subscribePing(d); - break; - } + this.handleSubscribeMessage(event, socket, store); }); }); @@ -89,9 +47,62 @@ export default Mixin.create({ this.set('subscribeSocket', socket); }, + handleSubscribeMessage(event, socket, store) { + // Fail-safe: make sure the message belongs to both this project and the + // session generation captured when the WebSocket was opened. + var currentProject = this.get(`tab-session.${C.TABSESSION.PROJECT}`); + var metadata = socket.getMetadata(); + var socketProject = metadata.projectId; + var socketGeneration = metadata.authGeneration; + if ( socketGeneration !== this.get('access').captureGeneration() ) { + this.disconnectSubscribe(); + this.send('sessionInvalid', null, false, null, socketGeneration, 401); + return; + } + if ( currentProject !== socketProject ) { + console.error(`Subscribe ignoring message, current=${currentProject} socket=${socketProject} ` + this.forStr()); + this.connectSubscribe(); + return; + } + + var d = JSON.parse(event.data); + let resource; + if ( d.data && d.data.resource ) { + resource = store._typeify(d.data.resource); + d.data.resource = resource; + } + + switch ( d.name) { + case 'resource.change': + let key = d.resourceType+'Changed'; + if ( this[key] ) { + this[key](d); + } + + if ( resource && C.REMOVEDISH_STATES.includes(resource.state) ) { + let type = get(resource,'type'); + let baseType = get(resource,'baseType'); + + store._remove(type, resource); + + if ( baseType && type !== baseType ) { + store._remove(baseType, resource); + } + } + break; + case 'logout': + this.send('sessionInvalid', null, true, null, socketGeneration, 401); + break; + case 'ping': + this.subscribePing(d); + break; + } + }, + connectSubscribe() { var socket = this.get('subscribeSocket'); var projectId = this.get(`tab-session.${C.TABSESSION.PROJECT}`); + var authGeneration = this.get('access').captureGeneration(); var url = ("ws://"+window.location.host + this.get('app.wsEndpoint')).replace(this.get('app.projectToken'), projectId); this.set('reconnect', true); @@ -100,7 +111,7 @@ export default Mixin.create({ url: url, autoReconnect: true, }); - socket.reconnect({projectId: projectId}); + socket.reconnect({projectId: projectId, authGeneration: authGeneration}); }, disconnectSubscribe(cb) { diff --git a/app/services/access.js b/app/services/access.js index 446225f00b..a802af34da 100644 --- a/app/services/access.js +++ b/app/services/access.js @@ -1,9 +1,11 @@ import { resolve, reject } from 'rsvp'; import Service, { service } from '@ember/service'; import C from 'ui/utils/constants'; +import { parseAttempt } from 'ui/services/auth-session'; export default Service.extend({ cookies: service(), + authSession: service('auth-session'), session: service(), github: service(), shibbolethAuth: service(), @@ -13,6 +15,7 @@ export default Service.extend({ token: null, mfaChallenge: null, loadedVersion: null, + explicitLogoutPromise: null, // These are set by authenticated/route // Is access control enabled @@ -31,8 +34,12 @@ export default Service.extend({ return this.get('userStore').createRecord(obj); }.property('session.'+C.SESSION.IDENTITY), - testAuth() { - // make a call to api base because it is authenticated + testAuth(generation) { + generation = generation || this.captureGeneration(); + // Do not hold the authentication mutex while a network request is in + // flight. A deliberately delayed response from an old session must be + // allowed to overlap a newer login; the captured generation is checked + // after the response settles. return this.get('userStore').rawRequest({ url: '', }).then((xhr) => { @@ -45,11 +52,12 @@ export default Service.extend({ return; } - // Auth token still good - return resolve('Auth Succeeded'); - }, (/* err */) => { - // Auth token expired - return reject('Auth Failed'); + let shared = this.get('authSession').readShared(); + if ( generation && shared && shared.generation !== generation ) { + return {status: 'stale', generation: shared.generation}; + } + + return {status: 'active', generation}; }); }, @@ -109,28 +117,47 @@ export default Service.extend({ return rv; }, - login(code, providerOverride, options) { + login(code, providerOverride, options, suppliedAttempt) { + let authSession = this.get('authSession'); + let attempt = suppliedAttempt ? authSession.resumeLogin(suppliedAttempt) : authSession.beginLogin(); let request = Object.assign({ code: code, authProvider: providerOverride || this.get('provider'), + clientSessionId: attempt.generation, }, options || {}); - return this.get('userStore').rawRequest({ + return authSession.runExclusive(() => { + if ( authSession.isAttemptSuperseded(attempt) ) { + authSession.completeLogin(attempt.generation); + return {superseded: true}; + } + return {superseded: false}; + }).then((preflight) => { + if ( preflight.superseded ) { + return {body: null, authSessionAccepted: false, authSessionSuperseded: true}; + } + return this.get('userStore').rawRequest({ url: 'token', method: 'POST', data: request, + }); }).then((xhr) => { + if ( xhr.authSessionSuperseded ) { + return xhr; + } if ( xhr.body && xhr.body.mfaRequired ) { - this.set('mfaChallenge', xhr.body); + return this._acceptMfaChallenge(xhr, attempt); } else { this.set('mfaChallenge', null); - this.acceptLogin(xhr.body); + return this.acceptLogin(xhr.body, attempt).then((result) => { + xhr.authSessionAccepted = result.accepted; + xhr.authSessionSuperseded = result.superseded; + return xhr; + }); } return xhr; }).catch((res) => { - let err; - try { - err = res.body; - } catch(e) { + let err = res && res.body ? res.body : res; + if ( !err ) { err = {type: 'error', message: 'Error logging in'}; } return reject(err); @@ -138,29 +165,29 @@ export default Service.extend({ }, completeMfa(data) { + let attempt = this.get('authSession').currentLogin(); return this.get('userStore').rawRequest({ url: 'token', method: 'POST', data: Object.assign({ authProvider: 'mfa', + clientSessionId: attempt.generation, }, data || {}), }).then((xhr) => { if ( xhr.body && xhr.body.mfaRequired ) { - let current = this.get('mfaChallenge'); - let sameChallenge = current && current.mfaChallengeId && - current.mfaChallengeId === xhr.body.mfaChallengeId; - this.set('mfaChallenge', sameChallenge ? - Object.assign({}, current, xhr.body) : xhr.body); + return this._acceptMfaChallenge(xhr, attempt); } else { this.set('mfaChallenge', null); - this.acceptLogin(xhr.body); + return this.acceptLogin(xhr.body, attempt).then((result) => { + xhr.authSessionAccepted = result.accepted; + xhr.authSessionSuperseded = result.superseded; + return xhr; + }); } return xhr; }).catch((res) => { - let err; - try { - err = res.body; - } catch(e) { + let err = res && res.body ? res.body : res; + if ( !err ) { err = {type: 'error', message: 'Error verifying the security factor'}; } return reject(err); @@ -169,31 +196,205 @@ export default Service.extend({ cancelMfa() { this.set('mfaChallenge', null); + let attempt = this.get('authSession').get('pendingLogin'); + this.get('authSession').completeLogin(attempt && attempt.generation); }, - acceptLogin(auth) { - var session = this.get('session'); - var interesting = {}; - C.TOKEN_TO_SESSION_KEYS.forEach((key) => { - if ( typeof auth[key] !== 'undefined' ) - { - interesting[key] = auth[key]; + acceptLogin(auth, attempt) { + if ( !auth || typeof auth.jwt !== 'string' || auth.jwt.trim().length === 0 ) { + return reject(new Error('The login response did not contain a valid session token')); + } + + attempt = parseAttempt(attempt || this.get('authSession').currentLogin()); + if ( !attempt ) { + return reject(new Error('The login response was not associated with a valid authentication attempt')); + } + return this.get('authSession').runExclusive(() => { + let authSession = this.get('authSession'); + let shared = authSession.readShared(); + + // An older OIDC/MFA callback is never allowed to overwrite a login that + // began later in another tab. + if ( authSession.isAttemptSuperseded(attempt) ) { + authSession.completeLogin(attempt.generation); + return {accepted: false, superseded: true}; } + + let previousCookie = this.get('cookies').get(C.COOKIE.TOKEN); + let previousValues = this._sessionValues(); + let previousRecord = shared; + let written = this._writeTokenCookie(auth.jwt); + + if ( !written || this.get('cookies').get(C.COOKIE.TOKEN) !== auth.jwt ) { + this._restoreLocalSnapshot(previousCookie, previousValues); + throw new Error('The browser refused the session cookie'); + } + + try { + this._applyTokenMetadata(auth); + authSession.commit(attempt.generation, auth.accountId, auth.jwt); + } catch (e) { + this._restoreLocalSnapshot(previousCookie, previousValues); + if ( previousRecord ) { + window.localStorage.setItem(C.AUTH_SESSION.STORAGE_KEY, JSON.stringify(previousRecord)); + authSession.adopt(previousRecord, previousCookie); + } else { + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + authSession.forget(); + } + throw e; + } + + authSession.completeLogin(attempt.generation); + return {accepted: true, superseded: false}; }); + }, - this.get('cookies').setWithOptions(C.COOKIE.TOKEN, auth['jwt'], { - path: '/', - secure: window.location.protocol === 'https:' + captureGeneration() { + return this.get('authSession').capture(); + }, + + ensureSession() { + let cookie = this.get('cookies').get(C.COOKIE.TOKEN); + if ( !cookie ) { + return reject({status: 401, message: 'No session cookie'}); + } + + return this._readCurrentToken().then((token) => { + return this.get('authSession').runExclusive(() => { + if ( this.get('cookies').get(C.COOKIE.TOKEN) !== cookie ) { + return reject({status: 409, message: 'Session changed during validation'}); + } + + let current = this.get('authSession').readShared(); + if ( !current ) { + current = this.get('authSession').commit( + this.get('authSession').createGeneration(), token.accountId, cookie + ); + } else { + this.get('authSession').adopt(current, cookie); + } + this._applyTokenMetadata(token); + return {status: 'active', generation: current.generation}; + }); + }); + }, + + adoptSharedSession() { + let shared = this.get('authSession').readShared(); + let cookie = this.get('cookies').get(C.COOKIE.TOKEN); + if ( !shared || !cookie ) { + return this.get('authSession').runExclusive(() => { + this._clearOwnedLocalState(this.captureGeneration()); + return {status: 'invalid'}; + }); + } + + return this._validateAndAdopt(shared, cookie); + }, + + handlePassiveFailure(generation, status) { + if ( status === 403 ) { + return resolve({status: 'forbidden'}); + } + + let shared = this.get('authSession').readShared(); + let cookie = this.get('cookies').get(C.COOKIE.TOKEN); + return this.get('authSession').runExclusive(() => { + shared = this.get('authSession').readShared(); + cookie = this.get('cookies').get(C.COOKIE.TOKEN); + if ( shared && !cookie && this.get('authSession').owns(shared.generation) ) { + let snapshot = this.get('authSession.tokenSnapshot'); + if ( snapshot && this._writeTokenCookie(snapshot) && + this.get('cookies').get(C.COOKIE.TOKEN) === snapshot ) { + cookie = snapshot; + // A page running pre-fix JavaScript can remove this shared cookie + // after the bound server DELETE was safely rejected. Recommit the + // same generation so other tabs receive a storage event only after + // the owning tab has restored and read back the cookie. + shared = this.get('authSession').commit( + shared.generation, shared.accountId, snapshot + ); + } + } + + if ( !shared || !cookie ) { + this._clearOwnedLocalState(generation); + return {status: 'invalid'}; + } + return {status: 'validate'}; + }).then((outcome) => { + if ( outcome.status !== 'validate' ) { + return outcome; + } + return this._validateAndAdopt(shared, cookie).then((validated) => { + return validated.status === 'adopted' && generation === shared.generation ? + {status: 'active', generation: validated.generation} : validated; + }, (error) => { + return this._errorStatus(error) === 403 ? {status: 'forbidden'} : reject(error); + }); + }); + }, + + explicitLogout() { + if ( this.get('explicitLogoutPromise') ) { + return this.get('explicitLogoutPromise'); + } + + let promise = this.get('authSession').runExclusive((lockGuard) => { + let authSession = this.get('authSession'); + let generation = authSession.capture(); + let shared = authSession.readShared(); + let cookie = this.get('cookies').get(C.COOKIE.TOKEN); + let snapshot = authSession.get('tokenSnapshot'); + + if ( !generation || !shared || shared.generation !== generation || + !snapshot || cookie !== snapshot ) { + if ( shared && cookie ) { + return {status: 'stale'}; + } + this._clearOwnedLocalState(generation); + return {status: 'complete'}; + } + + // Keep the mutex until the response settles. The server does not emit + // an expiry cookie for bound sessions; this tab clears it only after it + // has rechecked ownership below. + return this.get('userStore').rawRequest({ + url: 'token/current', + method: 'DELETE', + headers: { + [C.AUTH_SESSION.LOGOUT_HEADER]: generation, + }, + }).then(() => lockGuard.assertOwned()).then(() => { + let current = authSession.readShared(); + let currentCookie = this.get('cookies').get(C.COOKIE.TOKEN); + if ( current && current.generation === generation && currentCookie === snapshot ) { + this._clearOwnedLocalState(generation); + } + return {status: 'complete'}; + }); + }); + + this.set('explicitLogoutPromise', promise); + return promise.finally(() => { + this.set('explicitLogoutPromise', null); }); - session.setProperties(interesting); }, clearToken() { - return this.get('userStore').rawRequest({ - url: 'token/current', - method: 'DELETE', - }).then(() => { - return true; + return this.explicitLogout(); + }, + + clearLocalSession(generation) { + generation = generation || this.captureGeneration(); + return this.get('authSession').runExclusive(() => { + let shared = this.get('authSession').readShared(); + if ( shared && (!generation || shared.generation !== generation) ) { + return {status: 'stale', generation: shared.generation}; + } + this._clearOwnedLocalState(generation); + return {status: 'complete'}; }); }, @@ -240,7 +441,8 @@ export default Service.extend({ if ( snapshot.token ) { this.get('cookies').setWithOptions(C.COOKIE.TOKEN, snapshot.token, { path: '/', - secure: window.location.protocol === 'https:' + secure: window.location.protocol === 'https:', + sameSite: 'Lax', }); } }, @@ -256,5 +458,118 @@ export default Service.extend({ } return false; - } + }, + + _readCurrentToken() { + return this.get('userStore').rawRequest({ + url: 'token', + }).then((xhr) => { + let data = xhr && xhr.body && xhr.body.data; + let token = data && data[0]; + if ( !token ) { + return reject({status: 401, message: 'No authenticated session'}); + } + return token; + }); + }, + + _acceptMfaChallenge(xhr, attempt) { + return this.get('authSession').runExclusive(() => { + let authSession = this.get('authSession'); + if ( authSession.isAttemptSuperseded(attempt) ) { + authSession.completeLogin(attempt.generation); + xhr.authSessionAccepted = false; + xhr.authSessionSuperseded = true; + return xhr; + } + + let current = this.get('mfaChallenge'); + let sameChallenge = current && current.mfaChallengeId && + current.mfaChallengeId === xhr.body.mfaChallengeId; + this.set('mfaChallenge', sameChallenge ? + Object.assign({}, current, xhr.body) : xhr.body); + xhr.authSessionAccepted = false; + xhr.authSessionSuperseded = false; + return xhr; + }); + }, + + _validateAndAdopt(shared, cookie) { + return this._readCurrentToken().then((token) => { + return this.get('authSession').runExclusive(() => { + let current = this.get('authSession').readShared(); + let currentCookie = this.get('cookies').get(C.COOKIE.TOKEN); + if ( !current || current.generation !== shared.generation || currentCookie !== cookie ) { + return {status: 'stale', generation: current && current.generation}; + } + this.get('authSession').adopt(current, currentCookie); + this._applyTokenMetadata(token); + return {status: 'adopted', generation: current.generation}; + }); + }, (error) => { + if ( this._errorStatus(error) !== 401 ) { + return reject(error); + } + return this.get('authSession').runExclusive(() => { + let current = this.get('authSession').readShared(); + let currentCookie = this.get('cookies').get(C.COOKIE.TOKEN); + if ( current && current.generation === shared.generation && currentCookie === cookie ) { + this._clearOwnedLocalState(shared.generation); + return {status: 'invalid'}; + } + return {status: 'stale', generation: current && current.generation}; + }); + }); + }, + + _applyTokenMetadata(auth) { + let interesting = {}; + C.TOKEN_TO_SESSION_KEYS.forEach((key) => { + if ( typeof auth[key] !== 'undefined' ) { + interesting[key] = auth[key]; + } + }); + this.get('session').setProperties(interesting); + }, + + _sessionValues() { + let values = {}; + C.TOKEN_TO_SESSION_KEYS.forEach((key) => { + values[key] = this.get('session').get(key); + }); + return values; + }, + + _writeTokenCookie(token) { + return this.get('cookies').setWithOptions(C.COOKIE.TOKEN, token, { + path: '/', + secure: window.location.protocol === 'https:', + sameSite: 'Lax', + }); + }, + + _restoreLocalSnapshot(cookie, values) { + this.get('session').setProperties(values || {}); + if ( cookie ) { + this._writeTokenCookie(cookie); + } else { + this.get('cookies').remove(C.COOKIE.TOKEN, {path: '/'}); + } + }, + + _clearOwnedLocalState(generation) { + let authSession = this.get('authSession'); + let shared = authSession.readShared(); + if ( shared && (!generation || shared.generation !== generation) ) { + return false; + } + this.clearSessionKeys(); + authSession.removeShared(generation); + authSession.forget(generation); + return true; + }, + + _errorStatus(error) { + return error && error.xhr ? error.xhr.status : (error && error.status); + }, }); diff --git a/app/services/auth-session.js b/app/services/auth-session.js new file mode 100644 index 0000000000..07d4115223 --- /dev/null +++ b/app/services/auth-session.js @@ -0,0 +1,422 @@ +import Service from '@ember/service'; +import { Promise, reject, resolve } from 'rsvp'; +import C from 'ui/utils/constants'; + +const GENERATION_RE = /^\d{13}\.[0-9a-f]{64}$/; +const LOCK_STORE = 'locks'; +const LOCK_LEASE_MS = 30000; +const LOCK_RETRY_MS = 25; + +function randomHex(bytes) { + let crypto = window.crypto; + if ( !crypto || typeof crypto.getRandomValues !== 'function' ) { + throw new Error('Secure random generation is unavailable'); + } + + let values = new Uint8Array(bytes); + crypto.getRandomValues(values); + return Array.prototype.map.call(values, (value) => { + return value.toString(16).padStart(2, '0'); + }).join(''); +} + +function parseRecord(value) { + if ( !value ) { + return null; + } + + let parsed; + try { + parsed = typeof value === 'string' ? JSON.parse(value) : value; + } catch (e) { + return null; + } + + if ( !parsed || !GENERATION_RE.test(parsed.generation || '') || + !Number.isFinite(parsed.committedAt) || + (parsed.accountId !== null && typeof parsed.accountId !== 'string') ) { + return null; + } + + return { + generation: parsed.generation, + accountId: parsed.accountId, + committedAt: parsed.committedAt, + }; +} + +function generationStartedAt(generation) { + if ( !GENERATION_RE.test(generation || '') ) { + return 0; + } + return Number(generation.slice(0, 13)); +} + +function parseAttempt(value) { + if ( !value || !GENERATION_RE.test(value.generation || '') || + (value.baseGeneration !== null && value.baseGeneration !== undefined && + !GENERATION_RE.test(value.baseGeneration)) || + !Number.isFinite(value.startedAt) ) { + return null; + } + + return { + baseGeneration: value.baseGeneration || null, + generation: value.generation, + startedAt: value.startedAt, + }; +} + +export { GENERATION_RE, parseRecord, parseAttempt, generationStartedAt }; + +export default Service.extend({ + tabGeneration: null, + tokenSnapshot: null, + accountId: null, + pendingLogin: null, + lockManager: undefined, + + init() { + this._super(...arguments); + this._storageHandler = (event) => { + if ( event.key !== C.AUTH_SESSION.STORAGE_KEY || event.oldValue === event.newValue ) { + return; + } + + let change = { + oldRecord: parseRecord(event.oldValue), + newRecord: parseRecord(event.newValue), + }; + + try { + window.lc('application').send('authSessionChanged', change); + } catch (e) { + // The application route may not exist yet during initial boot. The + // authenticated route will reconcile the shared cookie before use. + } + }; + window.addEventListener('storage', this._storageHandler); + }, + + willDestroy() { + window.removeEventListener('storage', this._storageHandler); + this._storageHandler = null; + this._super(...arguments); + }, + + createGeneration() { + return String(Date.now()).padStart(13, '0') + '.' + randomHex(32); + }, + + beginLogin() { + let shared = this.readShared(); + let attempt = { + baseGeneration: shared ? shared.generation : null, + generation: this.createGeneration(), + startedAt: Date.now(), + }; + this.set('pendingLogin', attempt); + return attempt; + }, + + currentLogin() { + return this.get('pendingLogin') || this.beginLogin(); + }, + + resumeLogin(attempt) { + attempt = parseAttempt(attempt); + if ( !attempt ) { + throw new Error('Invalid authentication attempt'); + } + + let current = this.get('pendingLogin'); + if ( !current || current.generation === attempt.generation || + this.isNewer(attempt.generation, current.generation) ) { + this.set('pendingLogin', attempt); + } + + // The caller must continue to carry the generation that was captured by + // its own OIDC/MFA transaction. Returning a newer in-memory attempt here + // would relabel a late callback and allow it to pass the stale check. + return attempt; + }, + + completeLogin(generation) { + let current = this.get('pendingLogin'); + if ( generation && current && current.generation !== generation ) { + return false; + } + this.set('pendingLogin', null); + return true; + }, + + isAttemptSuperseded(attempt) { + attempt = parseAttempt(attempt); + if ( !attempt ) { + return true; + } + + let shared = this.readShared(); + return !!(shared && shared.generation !== attempt.baseGeneration && + shared.generation !== attempt.generation && + this.isNewer(shared.generation, attempt.generation)); + }, + + capture() { + return this.get('tabGeneration'); + }, + + owns(generation) { + return !!generation && this.get('tabGeneration') === generation; + }, + + readShared() { + return parseRecord(window.localStorage.getItem(C.AUTH_SESSION.STORAGE_KEY)); + }, + + commit(generation, accountId, tokenSnapshot) { + let previous = this.readShared(); + let record = { + generation, + accountId: accountId === undefined || accountId === null ? null : String(accountId), + // Keep recommits observable even when two writes land in the same + // millisecond; storage events are suppressed when the serialized value + // is identical. + committedAt: Math.max(Date.now(), previous ? previous.committedAt + 1 : 0), + }; + window.localStorage.setItem(C.AUTH_SESSION.STORAGE_KEY, JSON.stringify(record)); + this.adopt(record, tokenSnapshot); + return record; + }, + + adopt(record, tokenSnapshot) { + record = parseRecord(record); + if ( !record ) { + throw new Error('Invalid shared authentication session'); + } + let pending = this.get('pendingLogin'); + this.setProperties({ + tabGeneration: record.generation, + tokenSnapshot, + accountId: record.accountId, + pendingLogin: pending && this.isNewer(pending.generation, record.generation) ? pending : null, + }); + return record; + }, + + forget(generation) { + if ( generation && !this.owns(generation) ) { + return false; + } + this.setProperties({ + tabGeneration: null, + tokenSnapshot: null, + accountId: null, + pendingLogin: null, + }); + return true; + }, + + removeShared(generation) { + let shared = this.readShared(); + if ( shared && (!generation || shared.generation === generation) ) { + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + return true; + } + return false; + }, + + isNewer(left, right) { + let leftStarted = generationStartedAt(left); + let rightStarted = generationStartedAt(right); + + if ( leftStarted !== rightStarted ) { + return leftStarted > rightStarted; + } + + // Date.now() can be identical in two tabs. The random suffix gives us a + // deterministic total order so that two callbacks can never both decide + // they are the newest session. + return String(left || '') > String(right || ''); + }, + + runExclusive(callback) { + let manager = this.get('lockManager'); + if ( manager === undefined ) { + manager = window.navigator && window.navigator.locks; + } + + if ( manager && typeof manager.request === 'function' ) { + return manager.request(C.AUTH_SESSION.LOCK_NAME, {mode: 'exclusive'}, () => { + return callback({assertOwned: () => resolve(true)}); + }); + } + + return this._runIndexedDbExclusive(callback); + }, + + _openLockDatabase() { + if ( !window.indexedDB ) { + return reject(new Error('Cross-tab authentication mutex is unavailable')); + } + + if ( this._lockDatabasePromise ) { + return this._lockDatabasePromise; + } + + this._lockDatabasePromise = new Promise((resolveDb, rejectDb) => { + let request = window.indexedDB.open(C.AUTH_SESSION.LOCK_DATABASE, 1); + request.onupgradeneeded = () => { + let db = request.result; + if ( !db.objectStoreNames.contains(LOCK_STORE) ) { + db.createObjectStore(LOCK_STORE, {keyPath: 'name'}); + } + }; + request.onsuccess = () => resolveDb(request.result); + request.onerror = () => rejectDb(request.error || new Error('Unable to open authentication mutex database')); + request.onblocked = () => rejectDb(new Error('Authentication mutex database is blocked')); + }); + return this._lockDatabasePromise; + }, + + _claimIndexedDbLock(db, owner) { + return new Promise((resolveClaim, rejectClaim) => { + let transaction = db.transaction(LOCK_STORE, 'readwrite'); + let store = transaction.objectStore(LOCK_STORE); + let request = store.get(C.AUTH_SESSION.LOCK_NAME); + let claimed = false; + + request.onsuccess = () => { + let current = request.result; + let now = Date.now(); + if ( !current || !Number.isFinite(current.expiresAt) || current.expiresAt <= now ) { + store.put({ + name: C.AUTH_SESSION.LOCK_NAME, + owner, + expiresAt: now + LOCK_LEASE_MS, + }); + claimed = true; + } + }; + transaction.oncomplete = () => resolveClaim(claimed); + transaction.onerror = () => rejectClaim(transaction.error || new Error('Unable to claim authentication mutex')); + transaction.onabort = () => rejectClaim(transaction.error || new Error('Authentication mutex transaction aborted')); + }); + }, + + _renewIndexedDbLock(db, owner) { + return new Promise((resolveRenew) => { + let transaction = db.transaction(LOCK_STORE, 'readwrite'); + let store = transaction.objectStore(LOCK_STORE); + let request = store.get(C.AUTH_SESSION.LOCK_NAME); + let renewed = false; + request.onsuccess = () => { + let current = request.result; + if ( current && current.owner === owner ) { + current.expiresAt = Date.now() + LOCK_LEASE_MS; + store.put(current); + renewed = true; + } + }; + transaction.oncomplete = () => resolveRenew(renewed); + transaction.onerror = () => resolveRenew(false); + transaction.onabort = () => resolveRenew(false); + }); + }, + + _ownsIndexedDbLock(db, owner) { + return new Promise((resolveOwnership) => { + let transaction = db.transaction(LOCK_STORE, 'readonly'); + let request = transaction.objectStore(LOCK_STORE).get(C.AUTH_SESSION.LOCK_NAME); + request.onsuccess = () => { + let current = request.result; + resolveOwnership(!!(current && current.owner === owner && + Number.isFinite(current.expiresAt) && current.expiresAt > Date.now())); + }; + request.onerror = () => resolveOwnership(false); + }); + }, + + _releaseIndexedDbLock(db, owner) { + return new Promise((resolveRelease) => { + let transaction = db.transaction(LOCK_STORE, 'readwrite'); + let store = transaction.objectStore(LOCK_STORE); + let request = store.get(C.AUTH_SESSION.LOCK_NAME); + request.onsuccess = () => { + let current = request.result; + if ( current && current.owner === owner ) { + store.delete(C.AUTH_SESSION.LOCK_NAME); + } + }; + transaction.oncomplete = () => resolveRelease(); + transaction.onerror = () => resolveRelease(); + transaction.onabort = () => resolveRelease(); + }); + }, + + _waitForLock() { + return new Promise((resolveWait) => { + window.setTimeout(resolveWait, LOCK_RETRY_MS); + }); + }, + + _acquireIndexedDbLock(db, owner) { + return this._claimIndexedDbLock(db, owner).then((claimed) => { + if ( claimed ) { + return true; + } + return this._waitForLock().then(() => this._acquireIndexedDbLock(db, owner)); + }); + }, + + _runIndexedDbExclusive(callback) { + let owner; + try { + owner = this.createGeneration() + '.' + randomHex(8); + } catch (e) { + return reject(e); + } + + return this._openLockDatabase().then((db) => { + return this._acquireIndexedDbLock(db, owner).then(() => { + let leaseLost = false; + let renewTimer = window.setInterval(() => { + this._renewIndexedDbLock(db, owner).then((renewed) => { + leaseLost = leaseLost || !renewed; + }); + }, Math.floor(LOCK_LEASE_MS / 3)); + + let guard = { + assertOwned: () => { + if ( leaseLost ) { + return reject(new Error('Cross-tab authentication mutex ownership was lost')); + } + return this._ownsIndexedDbLock(db, owner).then((owned) => { + if ( !owned ) { + throw new Error('Cross-tab authentication mutex ownership was lost'); + } + return true; + }); + }, + }; + + let result; + try { + result = callback(guard); + } catch (e) { + result = reject(e); + } + + return resolve(result).then((value) => { + return guard.assertOwned().then(() => value); + }).then((value) => { + window.clearInterval(renewTimer); + return this._releaseIndexedDbLock(db, owner).then(() => value); + }, (error) => { + window.clearInterval(renewTimer); + return this._releaseIndexedDbLock(db, owner).then(() => reject(error)); + }); + }); + }); + }, +}); diff --git a/app/services/cookies.js b/app/services/cookies.js index 0712181c58..8202800c66 100644 --- a/app/services/cookies.js +++ b/app/services/cookies.js @@ -45,11 +45,11 @@ export default Service.extend({ this.setWithOptions(key, value); }, - // Opt: expire: date or number of days, path, domain, secure + // Opt: expire: date or number of days, path, domain, secure, sameSite setWithOptions: function(name, value, opt) { opt = opt || {}; opt.path = (typeof opt.path === 'undefined' ? '/' : opt.path); - opt.secure = (typeof opt.path === 'undefined' ? false : !!opt.secure); + opt.secure = (typeof opt.secure === 'undefined' ? false : !!opt.secure); let str = encodeURIComponent(name) + '=' + encodeURIComponent(value); @@ -83,6 +83,11 @@ export default Service.extend({ str += ';secure'; } + if ( opt.sameSite ) + { + str += ';samesite=' + opt.sameSite; + } + try { document.cookie = str; diff --git a/app/services/oidc.js b/app/services/oidc.js index 918808ce05..4c8ef24e24 100644 --- a/app/services/oidc.js +++ b/app/services/oidc.js @@ -107,7 +107,7 @@ export default Service.extend({ }); }, - getAuthorizeUrl: function(preparedToken) { + getAuthorizeUrl: function(preparedToken, trackLogin) { let token = preparedToken || this.get('access.token'); let tokenPromise = token && token.redirectUrl ? resolve(token) : this.getToken(); @@ -120,6 +120,14 @@ export default Service.extend({ let pkceEnabled = currentToken.pkceEnabled !== false && currentToken.pkceEnabled !== 'false'; return this.createTransaction(pkceEnabled).then((transaction) => { + if ( trackLogin ) { + let attempt = this.get('access.authSession').beginLogin(); + transaction.authSessionAttempt = { + baseGeneration: attempt.baseGeneration, + generation: attempt.generation, + startedAt: attempt.startedAt, + }; + } this.get('tab-session').set(C.TABSESSION.OIDC_TRANSACTION, transaction); let params = { @@ -143,6 +151,10 @@ export default Service.extend({ }, consumeAuthorization: function(params) { + return this.consumeLoginAuthorization(params).code; + }, + + consumeLoginAuthorization: function(params) { let transaction = this.get('tab-session').get(C.TABSESSION.OIDC_TRANSACTION); this.get('tab-session').set(C.TABSESSION.OIDC_TRANSACTION, undefined); @@ -166,15 +178,18 @@ export default Service.extend({ throw new Error(this.get('intl').t('loginOidc.error.missingCode')); } - return JSON.stringify({ - authorizationCode: params.code, - codeVerifier: transaction.codeVerifier || '', - nonce: transaction.nonce, - }); + return { + authSessionAttempt: transaction.authSessionAttempt || null, + code: JSON.stringify({ + authorizationCode: params.code, + codeVerifier: transaction.codeVerifier || '', + nonce: transaction.nonce, + }), + }; }, authorizeRedirect: function() { - return this.getAuthorizeUrl(null).then((url) => { + return this.getAuthorizeUrl(null, true).then((url) => { window.location.assign(url); }); }, diff --git a/app/services/session.js b/app/services/session.js index 2fe2466251..8dbc219680 100644 --- a/app/services/session.js +++ b/app/services/session.js @@ -1,6 +1,5 @@ import Service from '@ember/service'; import BrowserStore from 'ui/utils/browser-storage'; -import C from 'ui/utils/constants'; export default Service.extend(BrowserStore, { backing: window.localStorage, @@ -18,15 +17,9 @@ export default Service.extend(BrowserStore, { { this.notifyPropertyChange(key); - if ( key === C.SESSION.ACCOUNT_ID && old && neu && old !== neu ) - { - // If the active user changes, flee - try { - window.lc('application').send('logout'); - } - catch (e) { - } - } + // Authentication ownership is coordinated by auth-session. A + // localStorage notification is only evidence that another tab wrote + // something; it is never authority to revoke the current cookie. } }); }, diff --git a/app/utils/auth-navigation.js b/app/utils/auth-navigation.js new file mode 100644 index 0000000000..aa1f55aba1 --- /dev/null +++ b/app/utils/auth-navigation.js @@ -0,0 +1,39 @@ +const SENSITIVE_QUERY_KEYS = [ + 'access_token', + 'code', + 'id_token', + 'mfaCode', + 'otp', + 'state', + 'token', +]; + +export function safeInternalTarget(candidate, origin=window.location.origin) { + if ( !candidate || typeof candidate !== 'string' ) { + return null; + } + + let parsed; + try { + parsed = new URL(candidate, origin); + } catch (e) { + return null; + } + + if ( parsed.origin !== origin ) { + return null; + } + + let hasSensitiveValue = SENSITIVE_QUERY_KEYS.some((key) => { + return parsed.searchParams.has(key); + }); + if ( hasSensitiveValue ) { + return null; + } + + return parsed.pathname + parsed.search + parsed.hash; +} + +export function isAuthenticationPath(path) { + return /^\/(?:login|logout)(?:\/|$)/.test(path || ''); +} diff --git a/app/utils/constants.js b/app/utils/constants.js index 7071f9942e..fbcbc98c36 100644 --- a/app/utils/constants.js +++ b/app/utils/constants.js @@ -61,6 +61,13 @@ var C = { LANG: 'LANG', }, + AUTH_SESSION: { + STORAGE_KEY: 'pasturestack.authSession.v1', + LOCK_NAME: 'pasturestack.authSession', + LOCK_DATABASE: 'pasturestack-auth-session', + LOGOUT_HEADER: 'X-PastureStack-Client-Session-Id', + }, + EXTERNAL_ID: { KIND_SEPARATOR: '://', GROUP_SEPARATOR: ':', diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 9313b96cb2..3999357b15 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.116", + "version": "1.6.117", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.116", + "version": "1.6.117", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.117.md b/docs/releases/web-console-1.6.117.md new file mode 100644 index 0000000000..48851f2e69 --- /dev/null +++ b/docs/releases/web-console-1.6.117.md @@ -0,0 +1,28 @@ +# Web Console 1.6.117 + +- Separate explicit user logout from passive authentication failures. Only the + explicit path sends `DELETE /token/current`; 401, storage, WebSocket, timer, + and route events never revoke a server token, while 403 remains a permission + error rather than a global logout. +- Add a high-entropy, non-sensitive browser session generation. Web Storage + contains only the generation, account ID, and commit time; JWT values remain + in the secure cookie and per-tab memory. +- Serialize login acceptance, cookie readback, generation commit, adoption, and + explicit logout with Web Locks or a verified IndexedDB lease fallback. The + fallback fails closed if mutual exclusion cannot be established. +- Capture the generation before an OIDC redirect and carry it in the tab-scoped + transaction. A delayed callback, request, timer, or socket event cannot clear + or replace a newer committed session. +- Let waiting tabs validate `GET /v2-beta/token`, adopt the newer session, and + safely replace login/MFA/callback routes or reload their existing protected + route without loops. Manual refresh rebuilds the same session from the cookie + and committed metadata. +- Validate non-empty JWT responses and cookie readback before committing login; + failed writes never create `token=undefined` or a half-session. +- Deterministic browser-unit coverage includes 100 alternating TOTP/Passkey + delayed-response races, three-tab adoption, same- and different-account + replacement, duplicate passive failures, refresh, storage/socket/timer/route + events, stale OIDC callbacks, cookie repair, and coalesced explicit logout. +- Pair with Orchestration Engine `0.183.302` or newer. New tokens carry the + client session generation so older JavaScript cannot revoke a newer bound + token. diff --git a/package-lock.json b/package-lock.json index 9313b96cb2..3999357b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.116", + "version": "1.6.117", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.116", + "version": "1.6.117", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 475a392a05..0a98c58406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.116", + "version": "1.6.117", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 9077399bb3..fc72b855f1 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.116" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.116" +if [[ "$version" != "1.6.117" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.117" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 4959c23132..36708690a3 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.116 browser-session broker-broadcast + 1.6.117 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index cfa551ded5..0699e37b60 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.116": +if package.get("version") != "1.6.117": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/tests/unit/application/route-test.js b/tests/unit/application/route-test.js index e6477902a9..d6326f326e 100644 --- a/tests/unit/application/route-test.js +++ b/tests/unit/application/route-test.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import { run } from '@ember/runloop'; +import { Promise } from 'rsvp'; import { module, test } from 'qunit'; import ApplicationRoute from 'ui/application/route'; @@ -64,6 +65,7 @@ test('the latest overlapping transition owns the loading overlay', function(asse var second = fakeTransition(); var route = ApplicationRoute.create({ loadingTimeout: 60000, + access: {captureGeneration() { return 'generation-a'; }}, }); run(() => { @@ -96,6 +98,7 @@ test('a rejected transition clears the loading overlay', function(assert) { }; var route = ApplicationRoute.create({ loadingTimeout: 60000, + access: {captureGeneration() { return 'generation-a'; }}, }); run(() => route.get('actions').loading.call(route, transition)); @@ -107,3 +110,96 @@ test('a rejected transition clears the loading overlay', function(assert) { $('#loading-overlay, #loading-underlay').remove(); run(() => route.destroy()); }); + +test('a route 401 uses passive generation-aware reconciliation', function(assert) { + assert.expect(5); + let transition = { + authGeneration: 'request-generation', + abort() { + assert.ok(true, 'the failed transition is stopped'); + }, + }; + let route = ApplicationRoute.create(); + route.hideLoadingOverlay = function() {}; + route.send = function(action, sentTransition, timedOut, error, generation) { + assert.strictEqual(action, 'sessionInvalid', 'route failures cannot invoke explicit logout'); + assert.strictEqual(sentTransition, transition, 'the failed transition is retained'); + assert.strictEqual(timedOut, true, 'the user sees the expired-session reason if ownership is confirmed'); + assert.strictEqual(generation, 'request-generation', 'the request starting generation is retained'); + }; + + route.get('actions').error.call(route, {xhr: {status: 401}}, transition); + run(() => route.destroy()); +}); + +test('a restored current session resumes an aborted route without another login', async function(assert) { + let reloads = 0; + let loginTransitions = 0; + let transition = {authGeneration: 'current-generation'}; + let route = ApplicationRoute.create({ + access: { + handlePassiveFailure(generation, status) { + assert.strictEqual(generation, 'current-generation', 'the failed request keeps its owner'); + assert.strictEqual(status, 401, 'only authentication failure enters recovery'); + return Promise.resolve({status: 'active', generation}); + }, + }, + }); + route.reloadForSession = function() { + reloads++; + }; + route.transitionToLogin = function() { + loginTransitions++; + }; + + await route.get('actions').sessionInvalid.call( + route, transition, true, null, transition.authGeneration, 401 + ); + + assert.strictEqual(reloads, 1, 'the aborted route is retried after cookie recovery'); + assert.strictEqual(loginTransitions, 0, 'a valid recovered session never shows login'); + run(() => route.destroy()); +}); + +test('duplicate cross-tab generation events validate and reload exactly once', async function(assert) { + let resolveAdoption; + let adoption = new Promise((resolve) => { + resolveAdoption = resolve; + }); + let validations = 0; + let reloads = 0; + let generation = '1726358400000.' + 'a'.repeat(64); + let route = ApplicationRoute.create({ + access: { + adoptSharedSession() { + validations++; + return adoption; + }, + authSession: { + readShared() { + return {generation, accountId: '1a1', committedAt: 1726358400000}; + }, + }, + }, + }); + route.reloadForSession = function() { + reloads++; + }; + + let first = route.get('actions').authSessionChanged.call(route, { + newRecord: {generation}, + }); + let duplicate = route.get('actions').authSessionChanged.call(route, { + newRecord: {generation}, + }); + assert.strictEqual(first, duplicate, 'overlapping events share the same reconciliation promise'); + + resolveAdoption({status: 'adopted', generation}); + await first; + run(() => {}); + + assert.strictEqual(validations, 1, 'the shared cookie is validated once'); + assert.strictEqual(reloads, 1, 'the route reloads once without a generation loop'); + assert.strictEqual(route.get('lastSyncedGeneration'), generation, 'the applied generation is remembered'); + run(() => route.destroy()); +}); diff --git a/tests/unit/authenticated/route-test.js b/tests/unit/authenticated/route-test.js index d83cf36707..8c4d8bdbf7 100644 --- a/tests/unit/authenticated/route-test.js +++ b/tests/unit/authenticated/route-test.js @@ -6,23 +6,60 @@ import AuthenticatedRoute from 'ui/authenticated/route'; module('Unit | Route | authenticated'); test('logs out only for an actual authentication failure', function(assert) { - assert.expect(4); + assert.expect(5); - let transition = {targetName: 'authenticated.project.index'}; + let transition = {targetName: 'authenticated.project.index', authGeneration: 'generation-a'}; let route = AuthenticatedRoute.create({ access: EmberObject.create({enabled: true}), }); - route.send = function(action, sentTransition, timedOut) { - assert.strictEqual(action, 'logout', 'the logout action is used'); + route.send = function(action, sentTransition, timedOut, error, generation) { + assert.strictEqual(action, 'sessionInvalid', 'the passive session action is used'); assert.strictEqual(sentTransition, transition, 'the failed transition is retained'); assert.strictEqual(timedOut, true, 'the login page explains that authentication expired'); + assert.strictEqual(generation, 'generation-a', 'the request generation is retained'); }; - route.loadingError({xhr: {status: 403}}, transition, EmberObject.create()); + route.loadingError({xhr: {status: 401}}, transition, EmberObject.create(), 'generation-a'); assert.strictEqual(route.get('access.enabled'), true, 'access control remains enabled'); run(() => route.destroy()); }); +test('a 403 remains a permission failure and does not invalidate the session', function(assert) { + assert.expect(1); + let failure = {xhr: {status: 403}}; + let route = AuthenticatedRoute.create({access: EmberObject.create({enabled: true})}); + route.send = function() { + assert.ok(false, '403 must not become logout or session invalidation'); + }; + + return route.loadingError(failure, {targetName: 'authenticated.project.index'}).catch((actual) => { + assert.strictEqual(actual, failure, 'the permission failure is surfaced unchanged'); + run(() => route.destroy()); + }); +}); + +test('a timer response keeps its starting generation and never calls explicit logout', function(assert) { + assert.expect(5); + let access = EmberObject.create({ + captureGeneration() { + return 'generation-a'; + }, + testAuth(generation) { + assert.strictEqual(generation, 'generation-a', 'the timer uses the captured generation'); + return Promise.reject({xhr: {status: 401}}); + }, + }); + let route = AuthenticatedRoute.create({access}); + route.send = function(action, transition, timedOut, error, generation) { + assert.strictEqual(action, 'sessionInvalid', 'the timer uses passive invalidation'); + assert.strictEqual(timedOut, true, 'the expired-session explanation is retained'); + assert.strictEqual(generation, 'generation-a', 'the delayed timer cannot change ownership'); + assert.strictEqual(arguments[5], 401, 'the authentication status is preserved'); + }; + + return route.checkAuthToken('generation-a').then(() => run(() => route.destroy())); +}); + test('preserves the session and surfaces non-authentication initialization failures', function(assert) { assert.expect(2); diff --git a/tests/unit/mixins/subscribe-auth-session-test.js b/tests/unit/mixins/subscribe-auth-session-test.js new file mode 100644 index 0000000000..64c5d10995 --- /dev/null +++ b/tests/unit/mixins/subscribe-auth-session-test.js @@ -0,0 +1,61 @@ +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { module, test } from 'qunit'; +import Subscribe from 'ui/mixins/subscribe'; +import C from 'ui/utils/constants'; + +const Subject = EmberObject.extend(Subscribe, { + init() { + // Unit tests exercise the extracted message boundary directly without + // opening a real WebSocket. + }, +}); + +module('Unit | Mixin | subscribe auth session'); + +test('a WebSocket logout is passive and carries the socket generation', function(assert) { + assert.expect(5); + let subject = Subject.create({ + access: {captureGeneration() { return 'new-generation'; }}, + 'tab-session': EmberObject.create({[C.TABSESSION.PROJECT]: '1a5'}), + }); + let disconnected = 0; + subject.disconnectSubscribe = function() { + disconnected++; + }; + subject.send = function(action, transition, timedOut, error, generation, status) { + assert.strictEqual(action, 'sessionInvalid', 'the socket cannot request explicit logout'); + assert.strictEqual(timedOut, false, 'a stale socket does not show an expiry message'); + assert.strictEqual(generation, 'old-generation', 'the socket opening generation is preserved'); + assert.strictEqual(status, 401, 'the passive authentication signal is preserved'); + }; + + subject.handleSubscribeMessage({data: JSON.stringify({name: 'logout'})}, { + getMetadata() { + return {projectId: '1a5', authGeneration: 'old-generation'}; + }, + }, EmberObject.create()); + + assert.strictEqual(disconnected, 1, 'stale socket work is stopped immediately'); + run(() => subject.destroy()); +}); + +test('a current WebSocket logout still uses passive invalidation', function(assert) { + assert.expect(3); + let subject = Subject.create({ + access: {captureGeneration() { return 'current-generation'; }}, + 'tab-session': EmberObject.create({[C.TABSESSION.PROJECT]: '1a5'}), + }); + subject.send = function(action, transition, timedOut, error, generation) { + assert.strictEqual(action, 'sessionInvalid', 'server logout is revalidated instead of revoked by the browser'); + assert.strictEqual(timedOut, true, 'a current invalid session can show the expiry message'); + assert.strictEqual(generation, 'current-generation', 'the socket generation is supplied for ownership checks'); + }; + + subject.handleSubscribeMessage({data: JSON.stringify({name: 'logout'})}, { + getMetadata() { + return {projectId: '1a5', authGeneration: 'current-generation'}; + }, + }, EmberObject.create()); + run(() => subject.destroy()); +}); diff --git a/tests/unit/services/access-session-race-test.js b/tests/unit/services/access-session-race-test.js new file mode 100644 index 0000000000..4d3cc46e14 --- /dev/null +++ b/tests/unit/services/access-session-race-test.js @@ -0,0 +1,404 @@ +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { Promise, reject, resolve } from 'rsvp'; +import { module, test } from 'qunit'; +import AccessService from 'ui/services/access'; +import AuthSessionService from 'ui/services/auth-session'; +import C from 'ui/utils/constants'; + +function generation(index) { + let timestamp = String(1726358400000 + index).padStart(13, '0'); + let suffix = index.toString(16).padStart(64, '0').slice(-64); + return `${timestamp}.${suffix}`; +} + +function deferred() { + let resolveValue; + let rejectValue; + let promise = new Promise((resolvePromise, rejectPromise) => { + resolveValue = resolvePromise; + rejectValue = rejectPromise; + }); + return {promise, resolve: resolveValue, reject: rejectValue}; +} + +function serialLockManager() { + let tail = resolve(); + return { + request(name, options, callback) { + let next = tail.then(callback); + tail = next.catch(() => undefined); + return next; + }, + }; +} + +function cookieService(browser) { + return EmberObject.create({ + get() { + return browser.cookie; + }, + setWithOptions(name, value) { + if ( browser.refuseCookie ) { + return false; + } + browser.cookie = value; + return true; + }, + remove() { + browser.cookie = undefined; + return true; + }, + }); +} + +function sessionService() { + let values = {}; + return EmberObject.create({ + get(key) { + return values[key]; + }, + set(key, value) { + values[key] = value; + return value; + }, + setProperties(next) { + Object.assign(values, next || {}); + return this; + }, + clear() { + values = {}; + }, + }); +} + +function createAccess(browser, authSession, handler) { + return AccessService.create({ + authSession, + cookies: cookieService(browser), + session: sessionService(), + userStore: EmberObject.create({rawRequest: handler}), + }); +} + +module('Unit | Service | access session race', function(hooks) { + hooks.beforeEach(function() { + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + }); + + hooks.afterEach(function() { + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + }); + + test('a delayed 401 cannot revoke or clear a newer TOTP or Passkey session in 100 deterministic runs', async function(assert) { + assert.expect(504); + let methods = ['totp', 'webauthn']; + + for (let iteration = 0; iteration < 100; iteration++) { + let browser = {cookie: `old-jwt-${iteration}`}; + let lockManager = serialLockManager(); + let authA = AuthSessionService.create({lockManager}); + let authB = AuthSessionService.create({lockManager}); + let oldGeneration = generation(iteration * 2 + 1); + let newGeneration = generation(iteration * 2 + 2); + let oldRecord = authA.commit(oldGeneration, '1a1', browser.cookie); + authB.adopt(oldRecord, browser.cookie); + let oldResponse = deferred(); + let deleteCount = 0; + let method = methods[iteration % methods.length]; + let newJwt = `new-jwt-${iteration}`; + let currentToken = {accountId: '1a1', user: 'administrator'}; + + let accessA = createAccess(browser, authA, (options) => { + if ( options.method === 'DELETE' ) { + deleteCount++; + return resolve({status: 204}); + } + if ( options.url === '' ) { + return oldResponse.promise; + } + if ( options.url === 'token' ) { + return resolve({body: {data: [currentToken]}}); + } + return reject({status: 500}); + }); + let mfaRequest; + let accessB = createAccess(browser, authB, (options) => { + mfaRequest = options; + return resolve({body: Object.assign({jwt: newJwt}, currentToken)}); + }); + authB.set('pendingLogin', { + baseGeneration: oldGeneration, + generation: newGeneration, + startedAt: 1726358400000 + iteration * 2 + 2, + }); + + let staleResult = accessA.testAuth(oldGeneration).catch((error) => { + return accessA.handlePassiveFailure(oldGeneration, error.status); + }); + let mfaPayload = method === 'webauthn' ? + {mfaMethod: method, webAuthnResponse: {id: 'virtual-credential'}} : + {mfaMethod: method, mfaCode: 'synthetic-code'}; + await accessB.completeMfa(mfaPayload); + oldResponse.reject({status: 401}); + let outcome = await staleResult; + + assert.strictEqual(mfaRequest.data.clientSessionId, newGeneration, + 'the MFA completion is bound to the login generation'); + assert.strictEqual(mfaRequest.data.mfaMethod, method, 'both factor completion paths use the same contract'); + assert.strictEqual(outcome.status, 'adopted', 'the old tab adopts the newer authenticated session'); + assert.strictEqual(browser.cookie, newJwt, 'the newer cookie survives the old response'); + assert.strictEqual(deleteCount, 0, 'a passive failure never sends DELETE'); + + run(() => { + accessA.destroy(); + accessB.destroy(); + authA.destroy(); + authB.destroy(); + }); + } + + let stored = JSON.parse(window.localStorage.getItem(C.AUTH_SESSION.STORAGE_KEY)); + assert.deepEqual(Object.keys(stored).sort(), ['accountId', 'committedAt', 'generation'], + 'the final shared record contains no JWT or factor response'); + assert.notOk(JSON.stringify(stored).includes('jwt'), 'no JWT is persisted in Web Storage'); + assert.notOk(JSON.stringify(stored).includes('synthetic-code'), 'no OTP is persisted in Web Storage'); + assert.notOk(JSON.stringify(stored).includes('virtual-credential'), 'no passkey response is persisted in Web Storage'); + }); + + test('simultaneous passive errors adopt a new account without a DELETE storm', async function(assert) { + let browser = {cookie: 'old-account-token'}; + let lockManager = serialLockManager(); + let auth = AuthSessionService.create({lockManager}); + let oldGeneration = generation(300); + let newGeneration = generation(301); + auth.adopt({generation: oldGeneration, accountId: '1a1', committedAt: Date.now()}, browser.cookie); + window.localStorage.setItem(C.AUTH_SESSION.STORAGE_KEY, JSON.stringify({ + generation: newGeneration, + accountId: '1a2', + committedAt: Date.now(), + })); + browser.cookie = 'new-account-token'; + let deletes = 0; + let reads = 0; + let access = createAccess(browser, auth, (options) => { + if ( options.method === 'DELETE' ) { + deletes++; + } + reads++; + return resolve({body: {data: [{accountId: '1a2', user: 'second-user'}]}}); + }); + + let results = await Promise.all([ + access.handlePassiveFailure(oldGeneration, 401), + access.handlePassiveFailure(oldGeneration, 401), + access.handlePassiveFailure(oldGeneration, 401), + access.handlePassiveFailure(oldGeneration, 403), + ]); + + assert.strictEqual(deletes, 0, 'passive errors never revoke any server token'); + assert.strictEqual(results[3].status, 'forbidden', 'a 403 remains a permission denial'); + assert.ok(results.slice(0, 3).every((result) => ['adopted', 'active'].includes(result.status)), + 'all delayed 401 handlers converge on the newer account'); + assert.strictEqual(auth.capture(), newGeneration, 'the tab now owns the newer generation'); + assert.strictEqual(auth.get('accountId'), '1a2', 'the account switch is adopted'); + assert.ok(reads >= 3, 'each delayed error revalidates ownership without destructive side effects'); + run(() => { + access.destroy(); + auth.destroy(); + }); + }); + + test('three waiting tabs adopt one committed login without another authentication ceremony', async function(assert) { + let browser = {cookie: 'shared-login-token'}; + let lockManager = serialLockManager(); + let owner = AuthSessionService.create({lockManager}); + let waiting = [0, 1, 2].map(() => AuthSessionService.create({lockManager})); + let committed = generation(350); + owner.commit(committed, '1a1', browser.cookie); + let tokenReads = 0; + let accesses = waiting.map((auth) => createAccess(browser, auth, (options) => { + assert.strictEqual(options.url, 'token', 'a waiting tab verifies the shared cookie'); + tokenReads++; + return resolve({body: {data: [{accountId: '1a1', user: 'administrator'}]}}); + })); + + let results = await Promise.all(accesses.map((access) => access.adoptSharedSession())); + + assert.strictEqual(tokenReads, 3, 'each of the three tabs performs one server validation'); + assert.ok(results.every((result) => result.status === 'adopted'), + 'all waiting tabs adopt the committed login'); + assert.ok(waiting.every((auth) => auth.capture() === committed), + 'all tabs converge on the same generation'); + assert.strictEqual(browser.cookie, 'shared-login-token', 'no tab rewrites or clears the shared cookie'); + run(() => { + accesses.forEach((access) => access.destroy()); + waiting.forEach((auth) => auth.destroy()); + owner.destroy(); + }); + }); + + test('a manual refresh rebuilds the in-memory session from the cookie and committed generation', async function(assert) { + let browser = {cookie: 'refresh-token'}; + let generationBeforeRefresh = generation(375); + window.localStorage.setItem(C.AUTH_SESSION.STORAGE_KEY, JSON.stringify({ + generation: generationBeforeRefresh, + accountId: '1a1', + committedAt: 1726358400375, + })); + let refreshedAuth = AuthSessionService.create({lockManager: serialLockManager()}); + let reads = 0; + let access = createAccess(browser, refreshedAuth, (options) => { + reads++; + return resolve({body: {data: [{accountId: '1a1', user: 'administrator'}]}}); + }); + + let result = await access.ensureSession(); + + assert.strictEqual(result.status, 'active', 'the refreshed page validates an existing cookie'); + assert.strictEqual(reads, 1, 'refresh performs one token validation'); + assert.strictEqual(refreshedAuth.capture(), generationBeforeRefresh, + 'refresh adopts the previously committed generation'); + assert.strictEqual(refreshedAuth.get('tokenSnapshot'), 'refresh-token', + 'the token snapshot is rebuilt only in memory'); + assert.notOk(window.localStorage.getItem(C.AUTH_SESSION.STORAGE_KEY).includes('refresh-token'), + 'the JWT remains absent from Web Storage'); + run(() => { + access.destroy(); + refreshedAuth.destroy(); + }); + }); + + test('explicit logout is coalesced, carries ownership, and waits for the response before clearing', async function(assert) { + let browser = {cookie: 'owned-token'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let ownedGeneration = generation(400); + auth.commit(ownedGeneration, '1a1', browser.cookie); + let response = deferred(); + let deletes = 0; + let header; + let access = createAccess(browser, auth, (options) => { + deletes++; + header = options.headers[C.AUTH_SESSION.LOGOUT_HEADER]; + return response.promise; + }); + + let first = access.explicitLogout(); + let second = access.explicitLogout(); + await resolve(); + assert.strictEqual(deletes, 1, 'concurrent clicks issue at most one DELETE'); + assert.strictEqual(header, ownedGeneration, 'the DELETE is bound to the owned generation'); + assert.strictEqual(browser.cookie, 'owned-token', 'the cookie remains until DELETE settles'); + response.resolve({status: 204}); + await Promise.all([first, second]); + assert.strictEqual(browser.cookie, undefined, 'the owned cookie is cleared after the response'); + assert.strictEqual(window.localStorage.getItem(C.AUTH_SESSION.STORAGE_KEY), null, + 'the owned shared record is cleared once'); + run(() => { + access.destroy(); + auth.destroy(); + }); + }); + + test('an owning tab repairs a cookie removed by pre-fix JavaScript without revoking the session', async function(assert) { + let browser = {cookie: 'current-token'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let currentGeneration = generation(450); + let initialRecord = auth.commit(currentGeneration, '1a1', browser.cookie); + let deletes = 0; + let reads = 0; + let access = createAccess(browser, auth, (options) => { + if ( options.method === 'DELETE' ) { + deletes++; + return resolve({status: 204}); + } + reads++; + return resolve({body: {data: [{accountId: '1a1', user: 'administrator'}]}}); + }); + + browser.cookie = undefined; + let outcome = await access.handlePassiveFailure(currentGeneration, 401); + let recoveredRecord = JSON.parse(window.localStorage.getItem(C.AUTH_SESSION.STORAGE_KEY)); + + assert.strictEqual(outcome.status, 'active', 'the same owned session remains active'); + assert.strictEqual(browser.cookie, 'current-token', 'the in-memory snapshot restores the cookie'); + assert.strictEqual(reads, 1, 'the restored cookie is revalidated with the server'); + assert.strictEqual(deletes, 0, 'cookie recovery never revokes a token'); + assert.strictEqual(recoveredRecord.generation, currentGeneration, 'ownership does not change'); + assert.ok(recoveredRecord.committedAt > initialRecord.committedAt, + 'the recovered commit always notifies other tabs after cookie readback'); + assert.notOk(JSON.stringify(recoveredRecord).includes('current-token'), + 'the repaired JWT still never enters Web Storage'); + run(() => { + access.destroy(); + auth.destroy(); + }); + }); + + test('invalid login responses and failed cookie readback never commit a half-session', async function(assert) { + let browser = {cookie: undefined}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let access = createAccess(browser, auth, () => resolve()); + + let missingError; + let emptyError; + let cookieError; + try { + await access.acceptLogin(null); + } catch (error) { + missingError = error; + } + try { + await access.acceptLogin({jwt: ''}); + } catch (error) { + emptyError = error; + } + assert.ok(/valid session token/.test(missingError && missingError.message), 'a missing JWT is rejected'); + assert.ok(/valid session token/.test(emptyError && emptyError.message), 'an empty JWT is rejected'); + browser.refuseCookie = true; + try { + await access.acceptLogin({jwt: 'uncommitted'}, { + generation: generation(500), baseGeneration: null, startedAt: 1726358400500, + }); + } catch (error) { + cookieError = error; + } + assert.ok(/refused the session cookie/.test(cookieError && cookieError.message), + 'a refused cookie is rejected'); + assert.strictEqual(window.localStorage.getItem(C.AUTH_SESSION.STORAGE_KEY), null, + 'no generation is committed after a cookie failure'); + assert.strictEqual(auth.capture(), null, 'the tab has no partial ownership'); + run(() => { + access.destroy(); + auth.destroy(); + }); + }); + + test('an old OIDC callback is rejected before creating a token after a newer login commits', async function(assert) { + let browser = {cookie: 'new-token'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let oldAttempt = { + baseGeneration: null, + generation: generation(600), + startedAt: 1726358400600, + }; + let newGeneration = generation(601); + auth.commit(newGeneration, '1a1', browser.cookie); + let requests = 0; + let access = createAccess(browser, auth, () => { + requests++; + return reject({status: 500}); + }); + + let result = await access.login('old-authorization-code', 'oidcconfig', undefined, oldAttempt); + + assert.true(result.authSessionSuperseded, 'the old callback is identified as superseded'); + assert.strictEqual(requests, 0, 'the stale callback never reaches POST /token'); + assert.strictEqual(browser.cookie, 'new-token', 'the committed session remains untouched'); + assert.strictEqual(auth.capture(), newGeneration, 'ownership remains with the newer session'); + run(() => { + access.destroy(); + auth.destroy(); + }); + }); +}); diff --git a/tests/unit/services/access-test.js b/tests/unit/services/access-test.js index 18b2289f82..a34683e90a 100644 --- a/tests/unit/services/access-test.js +++ b/tests/unit/services/access-test.js @@ -7,6 +7,82 @@ import AccessService from 'ui/services/access'; module('Unit | Service | access'); +function authSessionStub() { + let generation = '1726358400000.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let pending; + let shared; + + return EmberObject.create({ + beginLogin() { + pending = {baseGeneration: shared && shared.generation, generation, startedAt: 1726358400000}; + return pending; + }, + resumeLogin(attempt) { + pending = attempt; + return attempt; + }, + currentLogin() { + return pending || this.beginLogin(); + }, + completeLogin() { + pending = null; + }, + runExclusive(callback) { + return resolve().then(callback); + }, + readShared() { + return shared; + }, + commit(committedGeneration, accountId, tokenSnapshot) { + shared = {generation: committedGeneration, accountId: accountId || null, committedAt: Date.now()}; + this.setProperties({tabGeneration: committedGeneration, tokenSnapshot}); + return shared; + }, + adopt(record, tokenSnapshot) { + shared = record; + this.setProperties({tabGeneration: record.generation, tokenSnapshot}); + }, + isNewer() { + return false; + }, + isAttemptSuperseded() { + return false; + }, + capture() { + return this.get('tabGeneration'); + }, + owns(value) { + return value === this.get('tabGeneration'); + }, + removeShared() { + shared = null; + }, + forget() { + this.setProperties({tabGeneration: null, tokenSnapshot: null}); + }, + }); +} + +function cookieJar(onWrite) { + let value; + return EmberObject.create({ + get() { + return value; + }, + setWithOptions(name, next, options) { + value = next; + if ( onWrite ) { + onWrite(name, next, options); + } + return true; + }, + remove() { + value = undefined; + return true; + }, + }); +} + test('uses an explicit provider only for the activation token exchange', function(assert) { assert.expect(4); @@ -14,10 +90,9 @@ test('uses an explicit provider only for the activation token exchange', functio let sessionValues; let request; let service = AccessService.create({ - cookies: EmberObject.create({ - setWithOptions(name, value) { - cookie = {name, value}; - }, + authSession: authSessionStub(), + cookies: cookieJar((name, value) => { + cookie = {name, value}; }), provider: 'localauthconfig', session: EmberObject.create({ @@ -57,6 +132,7 @@ test('suspends and restores the current provider session around activation', fun [C.SESSION.USER_ID]: '1u1', }; let service = AccessService.create({ + authSession: authSessionStub(), cookies: EmberObject.create({ get() { return cookieValue; @@ -106,6 +182,7 @@ test('holds an MFA challenge without creating a browser session', function(asser mfaMethods: ['totp'], }; let service = AccessService.create({ + authSession: authSessionStub(), cookies: EmberObject.create({ setWithOptions() { cookieWritten = true; @@ -140,10 +217,9 @@ test('creates the browser session only after MFA succeeds', function(assert) { let cookie; let sessionValues; let service = AccessService.create({ - cookies: EmberObject.create({ - setWithOptions(name, value) { - cookie = {name, value}; - }, + authSession: authSessionStub(), + cookies: cookieJar((name, value) => { + cookie = {name, value}; }), mfaChallenge: {mfaRequired: true}, session: EmberObject.create({ @@ -185,6 +261,7 @@ test('preserves other factor options while an email recovery code is requested', webAuthnOptions: {challenge: 'browser-challenge'}, }; let service = AccessService.create({ + authSession: authSessionStub(), mfaChallenge: original, userStore: EmberObject.create({ rawRequest(options) { diff --git a/tests/unit/services/auth-session-test.js b/tests/unit/services/auth-session-test.js new file mode 100644 index 0000000000..e44b6a9209 --- /dev/null +++ b/tests/unit/services/auth-session-test.js @@ -0,0 +1,194 @@ +import { run } from '@ember/runloop'; +import { Promise, resolve } from 'rsvp'; +import { module, test } from 'qunit'; +import AuthSessionService, { GENERATION_RE, parseRecord } from 'ui/services/auth-session'; +import C from 'ui/utils/constants'; + +function deferred() { + let resolveValue; + let promise = new Promise((resolvePromise) => { + resolveValue = resolvePromise; + }); + return {promise, resolve: resolveValue}; +} + +module('Unit | Service | auth-session', function(hooks) { + hooks.beforeEach(function() { + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + }); + + hooks.afterEach(function() { + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + }); + + test('stores only non-sensitive ownership metadata and keeps the token in memory', function(assert) { + let service = AuthSessionService.create({ + lockManager: {request(name, options, callback) { + return resolve().then(callback); + }}, + }); + let generation = service.createGeneration(); + let record = service.commit(generation, '1a1', 'memory-only-jwt'); + let stored = JSON.parse(window.localStorage.getItem(C.AUTH_SESSION.STORAGE_KEY)); + + assert.ok(GENERATION_RE.test(generation), 'the generation includes a timestamp and 256 bits of randomness'); + assert.deepEqual(Object.keys(stored).sort(), ['accountId', 'committedAt', 'generation'], + 'the shared record has only the documented non-sensitive fields'); + assert.notOk(JSON.stringify(stored).includes('memory-only-jwt'), 'the JWT never reaches localStorage'); + assert.strictEqual(service.get('tokenSnapshot'), 'memory-only-jwt', 'the tab snapshot remains in memory'); + assert.deepEqual(parseRecord(record), record, 'the committed record passes strict parsing'); + run(() => service.destroy()); + }); + + test('the Web Locks mutex serializes deferred authentication work without an unlocked fallback', function(assert) { + assert.expect(6); + let entered = []; + let releaseFirst; + let firstBarrier = new Promise((resolveBarrier) => { + releaseFirst = resolveBarrier; + }); + let tail = resolve(); + let lockManager = { + request(name, options, callback) { + assert.strictEqual(name, C.AUTH_SESSION.LOCK_NAME, 'all auth mutations share one lock name'); + assert.strictEqual(options.mode, 'exclusive', 'the lock is exclusive'); + let next = tail.then(callback); + tail = next.catch(() => undefined); + return next; + }, + }; + let service = AuthSessionService.create({lockManager}); + let first = service.runExclusive(() => { + entered.push('first'); + return firstBarrier; + }); + let second = service.runExclusive(() => { + entered.push('second'); + }); + + return resolve().then(() => { + assert.deepEqual(entered, ['first'], 'the second callback cannot enter while the first is deferred'); + releaseFirst(); + return Promise.all([first, second]); + }).then(() => { + assert.deepEqual(entered, ['first', 'second'], 'the second callback enters only after release'); + run(() => service.destroy()); + }); + }); + + test('the IndexedDB fallback serializes deferred work and verifies lease ownership', async function(assert) { + let firstEntered = deferred(); + let releaseFirst = deferred(); + let entered = []; + let first = AuthSessionService.create({lockManager: null}); + let second = AuthSessionService.create({lockManager: null}); + let db = await first._openLockDatabase(); + await first._releaseIndexedDbLock(db, '__test_cleanup__'); + + // Delete any record left by an interrupted previous test without bypassing + // IndexedDB transaction isolation. + await new Promise((resolveDelete, rejectDelete) => { + let transaction = db.transaction('locks', 'readwrite'); + transaction.objectStore('locks').delete(C.AUTH_SESSION.LOCK_NAME); + transaction.oncomplete = resolveDelete; + transaction.onerror = () => rejectDelete(transaction.error); + }); + + let firstWork = first.runExclusive((guard) => { + entered.push('first'); + firstEntered.resolve(); + return releaseFirst.promise.then(() => guard.assertOwned()); + }); + await firstEntered.promise; + let secondWork = second.runExclusive(() => { + entered.push('second'); + }); + await resolve(); + assert.deepEqual(entered, ['first'], 'the fallback never enters a second critical section concurrently'); + + releaseFirst.resolve(); + await Promise.all([firstWork, secondWork]); + assert.deepEqual(entered, ['first', 'second'], 'the waiting tab enters after the verified lease is released'); + run(() => { + first.destroy(); + second.destroy(); + }); + }); + + test('malformed shared records and equal-millisecond generations are handled deterministically', function(assert) { + let service = AuthSessionService.create({lockManager: {request(name, options, callback) { + return resolve().then(callback); + }}}); + let lower = '1726358400000.0' + '0'.repeat(63); + let higher = '1726358400000.f' + 'f'.repeat(63); + + assert.strictEqual(parseRecord('{not-json'), null, 'invalid JSON is rejected'); + assert.strictEqual(parseRecord({generation: 'short', accountId: null, committedAt: 1}), null, + 'invalid generations are rejected'); + assert.ok(service.isNewer(higher, lower), 'the random suffix breaks timestamp ties consistently'); + assert.notOk(service.isNewer(lower, higher), 'the ordering cannot report both generations as newer'); + run(() => service.destroy()); + }); + + test('resuming an older callback never relabels it as a newer in-memory login', function(assert) { + let service = AuthSessionService.create({lockManager: {request(name, options, callback) { + return resolve().then(callback); + }}}); + let older = { + baseGeneration: null, + generation: '1726358400000.' + '1'.repeat(64), + startedAt: 1726358400000, + }; + let newer = { + baseGeneration: null, + generation: '1726358400001.' + '2'.repeat(64), + startedAt: 1726358400001, + }; + service.set('pendingLogin', newer); + + assert.strictEqual(service.resumeLogin(older).generation, older.generation, + 'the asynchronous callback keeps its original ownership generation'); + assert.strictEqual(service.get('pendingLogin').generation, newer.generation, + 'the newer local login remains the active pending transaction'); + run(() => service.destroy()); + }); + + test('storage events report ownership changes without invoking logout', function(assert) { + assert.expect(4); + let originalLookup = window.lc; + let generation = '1726358400000.' + 'a'.repeat(64); + let record = {generation, accountId: '1a1', committedAt: 1726358400100}; + let service; + window.lc = function(name) { + assert.strictEqual(name, 'application', 'the existing application route receives the change'); + return { + send(action, change) { + assert.strictEqual(action, 'authSessionChanged', 'the event is reconciliation, never logout'); + assert.strictEqual(change.newRecord.generation, generation, 'the committed generation is forwarded'); + assert.deepEqual(Object.keys(change.newRecord).sort(), ['accountId', 'committedAt', 'generation'], + 'only non-sensitive metadata crosses tabs'); + }, + }; + }; + + try { + service = AuthSessionService.create({lockManager: {request(name, options, callback) { + return resolve().then(callback); + }}}); + // Invoke the service-owned listener directly. The full Ember test app + // can also have its singleton service alive; dispatching globally would + // test both instances and double the assertions without adding coverage. + service._storageHandler(new StorageEvent('storage', { + key: C.AUTH_SESSION.STORAGE_KEY, + oldValue: null, + newValue: JSON.stringify(record), + storageArea: window.localStorage, + })); + } finally { + window.lc = originalLookup; + if ( service ) { + run(() => service.destroy()); + } + } + }); +}); diff --git a/tests/unit/services/oidc-test.js b/tests/unit/services/oidc-test.js index 38228b8770..df40e07503 100644 --- a/tests/unit/services/oidc-test.js +++ b/tests/unit/services/oidc-test.js @@ -21,6 +21,14 @@ function createService(transaction) { }); } +function authAttempt(index) { + return { + baseGeneration: null, + generation: `172635840000${index}.` + String(index).repeat(64), + startedAt: 1726358400000 + index, + }; +} + test('exchanges a single-use state for an opaque authorization payload', function(assert) { let service = createService({ codeVerifier: 'verifier', @@ -62,6 +70,58 @@ test('rejects a mismatched state and still consumes the transaction', function(a run(() => service.destroy()); }); +test('returns the tab-scoped login generation captured before the OIDC redirect', function(assert) { + let attempt = authAttempt(1); + let service = createService({ + authSessionAttempt: attempt, + codeVerifier: 'verifier', + createdAt: Date.now(), + nonce: 'nonce', + state: 'expected-state', + }); + + let result = service.consumeLoginAuthorization({ + code: 'authorization-code', + state: 'expected-state', + }); + + assert.deepEqual(result.authSessionAttempt, attempt, + 'the callback keeps the generation from before leaving this origin'); + assert.strictEqual(JSON.parse(result.code).authorizationCode, 'authorization-code'); + run(() => service.destroy()); +}); + +test('captures the login generation before constructing the external OIDC redirect', async function(assert) { + let attempt = authAttempt(2); + let service = createService(); + service.set('access', EmberObject.create({ + authSession: EmberObject.create({ + beginLogin() { + return attempt; + }, + }), + })); + service.createTransaction = function() { + return resolve({ + createdAt: Date.now(), + nonce: 'nonce', + state: 'state', + }); + }; + + let url = await service.getAuthorizeUrl({ + callbackUrl: 'https://stack.example.test/login/oidc-auth', + pkceEnabled: false, + redirectUrl: 'https://identity.example.test/authorize', + }, true); + let transaction = service.get('tab-session').get(C.TABSESSION.OIDC_TRANSACTION); + + assert.deepEqual(transaction.authSessionAttempt, attempt, + 'the tab persists the exact attempt before navigation leaves the origin'); + assert.ok(url.includes('state=state'), 'the authorization URL uses the same persisted transaction'); + run(() => service.destroy()); +}); + test('prepares a provider without saving the active authentication configuration', function(assert) { let service = createService(); let config = EmberObject.create({provider: 'oidcconfig'}); diff --git a/tests/unit/utils/auth-navigation-test.js b/tests/unit/utils/auth-navigation-test.js new file mode 100644 index 0000000000..8dac28edc2 --- /dev/null +++ b/tests/unit/utils/auth-navigation-test.js @@ -0,0 +1,21 @@ +import { module, test } from 'qunit'; +import { isAuthenticationPath, safeInternalTarget } from 'ui/utils/auth-navigation'; + +module('Unit | Utility | auth-navigation'); + +test('accepts same-origin application paths and rejects external or credential-bearing returns', function(assert) { + let origin = 'https://stack.example.test'; + assert.strictEqual(safeInternalTarget('/env/1a5/apps?which=infra', origin), + '/env/1a5/apps?which=infra', 'an internal system path is retained'); + assert.strictEqual(safeInternalTarget('https://stack.example.test/admin/accounts#active', origin), + '/admin/accounts#active', 'an absolute same-origin path is normalized'); + assert.strictEqual(safeInternalTarget('https://attacker.example/collect', origin), null, + 'an external origin is rejected'); + assert.strictEqual(safeInternalTarget('/login/oidc-auth?code=secret&state=opaque', origin), null, + 'OIDC material is never copied into a return URL'); + assert.strictEqual(safeInternalTarget('/login?token=secret', origin), null, + 'tokens are never copied into a return URL'); + assert.ok(isAuthenticationPath('/login/oidc-auth'), 'OIDC callback paths are authentication paths'); + assert.ok(isAuthenticationPath('/logout'), 'logout is an authentication path'); + assert.notOk(isAuthenticationPath('/admin/accounts'), 'normal authenticated paths remain eligible'); +}); From c773d6d87bbdbede5d9071d0adde347195f982af Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:16:19 +0800 Subject: [PATCH 2/3] Update QUnit test inventory --- scripts/check-ui-test-harness-blockers | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-ui-test-harness-blockers b/scripts/check-ui-test-harness-blockers index f574b4dd1b..d81c2658f4 100755 --- a/scripts/check-ui-test-harness-blockers +++ b/scripts/check-ui-test-harness-blockers @@ -51,8 +51,8 @@ if module_for_count != 0: fail("MODULE_FOR_USAGE_COUNT_UNEXPECTED", actual=module_for_count, expected=0) if module_for_component_count != 0: fail("MODULE_FOR_COMPONENT_USAGE_UNEXPECTED", actual=module_for_component_count, expected=0) -if direct_qunit_import_count != 111: - fail("DIRECT_QUNIT_IMPORT_COUNT_UNEXPECTED", actual=direct_qunit_import_count, expected=111) +if direct_qunit_import_count != 115: + fail("DIRECT_QUNIT_IMPORT_COUNT_UNEXPECTED", actual=direct_qunit_import_count, expected=115) volatile_computed_count = 0 for path in Path("app").rglob("*.js"): From e4bd332cb4d45228d64f460dbdb904bc358e6d4a Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:49:53 +0800 Subject: [PATCH 3/3] Harden superseded session login handling --- README.md | 7 +- SECURITY.md | 6 +- app/services/access.js | 55 ++++++++---- docs/releases/web-console-1.6.117.md | 7 +- .../unit/services/access-session-race-test.js | 86 +++++++++++++++++++ 5 files changed, 139 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 68ce3686a9..b78df71178 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,11 @@ IndexedDB lease fallback when Web Locks is unavailable. OIDC transactions retain the generation captured before leaving the origin, stale callbacks cannot overwrite a newer login, and waiting tabs validate the shared cookie before adopting it. JWTs remain cookie- and memory-only and are never persisted in Web -Storage. Pair this release with Engine `0.183.302` or newer for session-bound, -idempotent server logout protection. +Storage. A precise `409 ClientSessionSuperseded` response from the Engine is +treated as a stale completion rather than a failed active login, and request +options cannot override the provider, authorization value, or captured +generation. Pair this release with Engine `0.183.302` or newer for +session-bound, ordered, idempotent server logout protection. Release `1.6.116` recognizes the MFA API's structured error code even when the transport wraps it in a generic error. Sensitive settings updates open diff --git a/SECURITY.md b/SECURITY.md index c15b6c2e24..262cb6748b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported state -The maintained compatibility release is the pure numeric `1.6.115` line used +The maintained compatibility release is the pure numeric `1.6.117` line used by the current PastureStack Server release. Earlier branded coordinates are historical records and are not current release or deployment targets. Authentication-provider combinations must still be validated by an @@ -27,6 +27,10 @@ administrator before activation. - MFA login must complete before a browser session is stored. TOTP and email challenges are rate-limited, single-use, and short-lived; email is an account-recovery channel, not an authentication factor. +- Browser tabs share one origin-level authentication mutex. Only explicit user + logout may revoke a server token; passive failures reconcile session + generation ownership without clearing or revoking a newer session. JWTs must + never be written to Web Storage, URLs, or diagnostics. - MFA enrollment, recovery-code generation, and recovery-address verification require the account holder's own authenticated session. Administrators may inspect and revoke another account's factors, but cannot create or retrieve diff --git a/app/services/access.js b/app/services/access.js index a802af34da..3db85780cd 100644 --- a/app/services/access.js +++ b/app/services/access.js @@ -120,11 +120,11 @@ export default Service.extend({ login(code, providerOverride, options, suppliedAttempt) { let authSession = this.get('authSession'); let attempt = suppliedAttempt ? authSession.resumeLogin(suppliedAttempt) : authSession.beginLogin(); - let request = Object.assign({ + let request = Object.assign({}, options || {}, { code: code, authProvider: providerOverride || this.get('provider'), clientSessionId: attempt.generation, - }, options || {}); + }); return authSession.runExclusive(() => { if ( authSession.isAttemptSuperseded(attempt) ) { authSession.completeLogin(attempt.generation); @@ -155,13 +155,9 @@ export default Service.extend({ }); } return xhr; - }).catch((res) => { - let err = res && res.body ? res.body : res; - if ( !err ) { - err = {type: 'error', message: 'Error logging in'}; - } - return reject(err); - }); + }).catch((res) => this._handleLoginFailure( + res, attempt, 'Error logging in' + )); }, completeMfa(data) { @@ -169,10 +165,10 @@ export default Service.extend({ return this.get('userStore').rawRequest({ url: 'token', method: 'POST', - data: Object.assign({ + data: Object.assign({}, data || {}, { authProvider: 'mfa', clientSessionId: attempt.generation, - }, data || {}), + }), }).then((xhr) => { if ( xhr.body && xhr.body.mfaRequired ) { return this._acceptMfaChallenge(xhr, attempt); @@ -185,13 +181,9 @@ export default Service.extend({ }); } return xhr; - }).catch((res) => { - let err = res && res.body ? res.body : res; - if ( !err ) { - err = {type: 'error', message: 'Error verifying the security factor'}; - } - return reject(err); - }); + }).catch((res) => this._handleLoginFailure( + res, attempt, 'Error verifying the security factor' + )); }, cancelMfa() { @@ -569,6 +561,33 @@ export default Service.extend({ return true; }, + _handleLoginFailure(response, attempt, fallbackMessage) { + if ( this._isSupersededLoginError(response) ) { + this.get('authSession').completeLogin(attempt && attempt.generation); + return resolve({ + body: null, + authSessionAccepted: false, + authSessionSuperseded: true, + }); + } + + let error = response && response.body ? response.body : response; + if ( !error ) { + error = {type: 'error', message: fallbackMessage}; + } + return reject(error); + }, + + _isSupersededLoginError(error) { + let status = this._errorStatus(error); + let code = error && error.body && error.body.code; + code = code || (error && error.responseJSON && error.responseJSON.code); + code = code || (error && error.xhr && error.xhr.responseJSON && + error.xhr.responseJSON.code); + code = code || (error && error.code); + return code === 'ClientSessionSuperseded' && (!status || status === 409); + }, + _errorStatus(error) { return error && error.xhr ? error.xhr.status : (error && error.status); }, diff --git a/docs/releases/web-console-1.6.117.md b/docs/releases/web-console-1.6.117.md index 48851f2e69..885efad4ca 100644 --- a/docs/releases/web-console-1.6.117.md +++ b/docs/releases/web-console-1.6.117.md @@ -13,6 +13,10 @@ - Capture the generation before an OIDC redirect and carry it in the tab-scoped transaction. A delayed callback, request, timer, or socket event cannot clear or replace a newer committed session. +- Treat the Engine's precise `409 ClientSessionSuperseded` response as a stale + login completion, leaving the newer cookie and generation untouched. Token + request options cannot override the route-selected provider, authorization + value, or captured generation. - Let waiting tabs validate `GET /v2-beta/token`, adopt the newer session, and safely replace login/MFA/callback routes or reload their existing protected route without loops. Manual refresh rebuilds the same session from the cookie @@ -22,7 +26,8 @@ - Deterministic browser-unit coverage includes 100 alternating TOTP/Passkey delayed-response races, three-tab adoption, same- and different-account replacement, duplicate passive failures, refresh, storage/socket/timer/route - events, stale OIDC callbacks, cookie repair, and coalesced explicit logout. + events, preflight and in-flight stale OIDC callbacks, cookie repair, + protected request fields, and coalesced explicit logout. - Pair with Orchestration Engine `0.183.302` or newer. New tokens carry the client session generation so older JavaScript cannot revoke a newer bound token. diff --git a/tests/unit/services/access-session-race-test.js b/tests/unit/services/access-session-race-test.js index 4d3cc46e14..56362729e3 100644 --- a/tests/unit/services/access-session-race-test.js +++ b/tests/unit/services/access-session-race-test.js @@ -401,4 +401,90 @@ module('Unit | Service | access session race', function(hooks) { auth.destroy(); }); }); + + test('an in-flight older callback accepts the server superseded result without touching the newer session', async function(assert) { + let browser = {cookie: undefined}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let oldAttempt = { + baseGeneration: null, + generation: generation(625), + startedAt: 1726358400625, + }; + let response = deferred(); + let postedGeneration; + let access = createAccess(browser, auth, (options) => { + postedGeneration = options.data.clientSessionId; + return response.promise; + }); + + let pending = access.login('old-authorization-code', 'oidcconfig', undefined, oldAttempt); + await resolve(); + let newerGeneration = generation(626); + browser.cookie = 'newer-token'; + auth.commit(newerGeneration, '1a1', browser.cookie); + response.reject({ + status: 409, + body: {code: 'ClientSessionSuperseded', message: 'newer session completed'}, + }); + let result = await pending; + + assert.strictEqual(postedGeneration, oldAttempt.generation, + 'the request remains bound to the generation captured before the deferred response'); + assert.true(result.authSessionSuperseded, 'the precise server conflict is treated as a stale callback'); + assert.false(result.authSessionAccepted, 'the stale response never completes login'); + assert.strictEqual(browser.cookie, 'newer-token', 'the newer cookie is untouched'); + assert.strictEqual(auth.capture(), newerGeneration, 'the newer generation remains owned'); + run(() => { + access.destroy(); + auth.destroy(); + }); + }); + + test('callers cannot override the provider, authorization value, or bound generation', async function(assert) { + let browser = {cookie: undefined}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let loginAttempt = { + baseGeneration: null, + generation: generation(650), + startedAt: 1726358400650, + }; + let requests = []; + let access = createAccess(browser, auth, (options) => { + requests.push(options.data); + if ( requests.length === 1 ) { + return resolve({body: { + mfaRequired: true, + mfaChallengeId: 'opaque-challenge', + mfaMethods: ['totp'], + }}); + } + return resolve({body: {jwt: 'bound-token', accountId: '1a1'}}); + }); + + await access.login('trusted-code', 'oidcconfig', { + code: 'overridden-code', + authProvider: 'untrusted-provider', + clientSessionId: generation(999), + }, loginAttempt); + await access.completeMfa({ + code: 'opaque-challenge', + mfaMethod: 'totp', + mfaCode: '123456', + authProvider: 'untrusted-provider', + clientSessionId: generation(999), + }); + + assert.strictEqual(requests[0].code, 'trusted-code', 'the callback code comes from the route transaction'); + assert.strictEqual(requests[0].authProvider, 'oidcconfig', 'the route-selected provider cannot be overwritten'); + assert.strictEqual(requests[0].clientSessionId, loginAttempt.generation, + 'the primary request uses its captured generation'); + assert.strictEqual(requests[1].authProvider, 'mfa', 'the continuation provider is fixed'); + assert.strictEqual(requests[1].clientSessionId, loginAttempt.generation, + 'the MFA request retains the same bound generation'); + assert.strictEqual(browser.cookie, 'bound-token', 'the protected response completes normally'); + run(() => { + access.destroy(); + auth.destroy(); + }); + }); });