diff --git a/README.md b/README.md index 8e0874e433..2173238f82 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,13 @@ history, authorship, licenses, and dependency notices. ## Current component -Web Console [1.6.196](https://github.com/PastureStack/web-console/releases/tag/1.6.196) -is published with optional AI collaboration: seven typed tools, human upgrade -approval, conditional description edits and a dedicated-Key stdio bridge. -Browser tools start paused and require an explicit user choice. The -[release note](docs/releases/web-console-1.6.196.md) records the producer, -same-tree release tag, passing CI and verified artifact checksums. +Web Console [1.6.197](https://github.com/PastureStack/web-console/releases/tag/1.6.197) +restores the verified browser actor when an existing cookie is opened in a +fresh browser session. This allows the human approval inbox to work without +requiring a new login. Optional AI collaboration still provides seven typed +tools, conditional description edits and a dedicated-Key stdio bridge. +Browser tools start paused and require an explicit user choice. See the +[release note](docs/releases/web-console-1.6.197.md) for the session boundary. The release producer uses Node.js `24.22.1` / npm `12.0.2` in a digest-pinned container and produces an immutable static archive with separate runtime and diff --git a/app/services/access.js b/app/services/access.js index e9f1fec8f9..7e0ca492b1 100644 --- a/app/services/access.js +++ b/app/services/access.js @@ -251,6 +251,7 @@ export default Service.extend({ if ( !cookie ) { return reject({status: 401, message: 'No session cookie'}); } + let beforeValidation = this.get('authSession').readShared(); return this._readCurrentToken().then((token) => { return this.get('authSession').runExclusive(() => { @@ -260,10 +261,21 @@ export default Service.extend({ let current = this.get('authSession').readShared(); if ( !current ) { + if ( beforeValidation ) { + return reject({status: 409, message: 'Session changed during validation'}); + } current = this.get('authSession').commit( this.get('authSession').createGeneration(), token.accountId, cookie ); } else { + if ( current.accountId === null ) { + if ( !beforeValidation || current.generation !== beforeValidation.generation ) { + return reject({status: 409, message: 'Session changed during validation'}); + } + current = this.get('authSession').commit(current.generation, token.accountId, cookie); + } else if ( current.accountId !== token.accountId ) { + return reject({status: 409, message: 'Session account changed during validation'}); + } this.get('authSession').adopt(current, cookie); } this._applyTokenMetadata(token); @@ -462,6 +474,7 @@ export default Service.extend({ }, _readCurrentToken() { + let cookie = this.get('cookies').get(C.COOKIE.TOKEN); return this.get('userStore').rawRequest({ url: 'token', }).then((xhr) => { @@ -482,7 +495,28 @@ export default Service.extend({ if ( !token || (!hasAccountId && !hasUser && !hasUserIdentity) ) { return reject({status: 401, message: 'No authenticated session'}); } - return token; + const accountIdPattern = /^[a-zA-Z0-9_-]{1,256}$/; + if ( hasAccountId && !accountIdPattern.test(accountId) ) { + return reject({status: 401, message: 'Invalid authenticated account'}); + } + if ( hasAccountId ) { return token; } + if ( this.get('cookies').get(C.COOKIE.TOKEN) !== cookie ) { + return reject({status: 409, message: 'Session changed during validation'}); + } + // /token uses a virtual policy account in its response headers. After + // validating its identity, read the real actor from the authenticated + // API root; project account headers identify environments, not users. + return this.get('userStore').rawRequest({url: ''}).then((actorResponse) => { + if ( this.get('cookies').get(C.COOKIE.TOKEN) !== cookie ) { + return reject({status: 409, message: 'Session changed during validation'}); + } + let header = actorResponse.headers && typeof actorResponse.headers.get === 'function' ? actorResponse.headers.get(C.HEADER.USER_ID) : null; + let verifiedAccountId = typeof header === 'string' ? header.trim() : ''; + if ( !accountIdPattern.test(verifiedAccountId) ) { + return reject({status: 401, message: 'No authenticated account'}); + } + return Object.assign({}, token, {accountId: verifiedAccountId}); + }); }); }, @@ -515,17 +549,29 @@ export default Service.extend({ if ( !current || current.generation !== shared.generation || currentCookie !== cookie ) { return {status: 'stale', generation: current && current.generation}; } + if ( current.accountId === null ) { + current = this.get('authSession').commit(current.generation, token.accountId, currentCookie); + } else if ( current.accountId !== token.accountId ) { + return reject({status: 409, message: 'Session account changed during validation'}); + } this.get('authSession').adopt(current, currentCookie); this._applyTokenMetadata(token); return {status: 'adopted', generation: current.generation}; }); }, (error) => { - if ( this._errorStatus(error) !== 401 ) { + let status = this._errorStatus(error); + if ( status !== 401 && status !== 409 ) { return reject(error); } return this.get('authSession').runExclusive(() => { let current = this.get('authSession').readShared(); let currentCookie = this.get('cookies').get(C.COOKIE.TOKEN); + if ( status === 409 ) { + if ( !current || current.generation !== shared.generation || currentCookie !== cookie ) { + return {status: 'stale', generation: current && current.generation}; + } + return reject(error); + } if ( current && current.generation === shared.generation && currentCookie === cookie ) { this._clearOwnedLocalState(shared.generation); return {status: 'invalid'}; diff --git a/app/utils/constants.js b/app/utils/constants.js index 61762e1748..78bda5b77c 100644 --- a/app/utils/constants.js +++ b/app/utils/constants.js @@ -117,6 +117,7 @@ var C = { HEADER: { ACCOUNT_ID: 'X-Api-Account-Id', + USER_ID: 'X-Api-User-Id', ACTIONS: 'X-Api-Action-Links', ACTIONS_VALUE: 'actionLinks', CSRF: 'X-Api-Csrf', 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 e4b204450a..ecccb4dfed 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.196", + "version": "1.6.197", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.196", + "version": "1.6.197", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.197.md b/docs/releases/web-console-1.6.197.md new file mode 100644 index 0000000000..fa5f09cc72 --- /dev/null +++ b/docs/releases/web-console-1.6.197.md @@ -0,0 +1,24 @@ +# Web Console 1.6.197 + +This maintenance release repairs actor restoration when a valid cookie opens +a fresh browser session. The current-token response can contain a verified +identity while omitting `accountId`; its policy headers identify a virtual +token account. After identity validation, the console reads the authenticated +API root's `X-Api-User-Id` to recover the real actor. It does not decode the JWT +or use a client-supplied identity as an account ID. + +A legacy shared session with a null actor is repaired only while its cookie +and generation still match under the existing session mutex. Delayed replies +cannot replace a newer login. Login-options responses remain unauthenticated, +and permission failures keep their existing handling. Ordinary credential and +MFA login flows are unchanged. + +The AI collaboration build ceiling remains an explicit producer option, and +each browser page still starts with tools paused. Human approval remains a +normal authenticated UI action. The standalone bridge 0.1.0 and its shared +command-engine sources are unchanged; this release does not republish it. + +The [release assets](https://github.com/PastureStack/web-console/releases/tag/1.6.197) +include the static archive, source/profile manifest, checksums and separate +runtime/build SBOMs. Server assembly and deployed-runtime acceptance remain +separate from the browser component release. diff --git a/package-lock.json b/package-lock.json index e4b204450a..ecccb4dfed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.196", + "version": "1.6.197", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.196", + "version": "1.6.197", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 866e948bb7..574b9c8d8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.196", + "version": "1.6.197", "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 c01f96c165..92e0d372d5 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.196" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.196" +if [[ "$version" != "1.6.197" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.197" failures=$((failures + 1)) fi diff --git a/tests/unit/services/access-session-race-test.js b/tests/unit/services/access-session-race-test.js index b867f4ce9b..928491625d 100644 --- a/tests/unit/services/access-session-race-test.js +++ b/tests/unit/services/access-session-race-test.js @@ -82,6 +82,12 @@ function createAccess(browser, authSession, handler) { }); } +function currentTokenResponse(token, accountId) { + return {body: {data: [token]}, headers: {get(name) { + return name === C.HEADER.USER_ID ? accountId : null; + }}}; +} + module('Unit | Service | access session race', function(hooks) { hooks.beforeEach(function() { window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); @@ -327,11 +333,12 @@ module('Unit | Service | access session race', function(hooks) { for (let index = 0; index < responses.length; index++) { let browser = {cookie: `valid-token-${index}`}; let auth = AuthSessionService.create({lockManager: serialLockManager()}); - let access = createAccess(browser, auth, () => resolve({body: {data: [responses[index]]}})); + let access = createAccess(browser, auth, () => resolve(currentTokenResponse(responses[index], '1a1'))); let result = await access.ensureSession(); assert.strictEqual(result.status, 'active', 'the authenticated token is accepted'); assert.ok(auth.capture(), 'a generation is committed for the authenticated response'); + assert.strictEqual(auth.get('accountId'), '1a1', 'the verified response supplies the actor'); assert.notOk(responses[index].jwt, 'acceptance does not depend on an exposed jwt field'); run(() => { access.destroy(); @@ -341,6 +348,250 @@ module('Unit | Service | access session race', function(hooks) { } }); + test('a fresh cookie uses the verified response actor when the masked token body omits accountId', async function(assert) { + let browser = {cookie: 'fresh-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let token = {jwt: '********', user: 'administrator', userIdentity: {externalId: 'admin'}}; + let access = createAccess(browser, auth, () => resolve(currentTokenResponse(token, '1a1'))); + try { + let result = await access.ensureSession(); + assert.strictEqual(result.status, 'active'); + assert.strictEqual(auth.get('accountId'), '1a1'); + assert.strictEqual(auth.readShared().accountId, '1a1'); + assert.notOk(Object.hasOwn(token, 'accountId'), 'the response body is not mutated'); + assert.notOk(window.localStorage.getItem(C.AUTH_SESSION.STORAGE_KEY).includes(browser.cookie), 'the cookie stays out of storage'); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + }); + + test('invalid or absent actor metadata cannot establish a session', async function(assert) { + for (const [token, header] of [ + [{user: 'admin'}, '../1a1'], + [{user: 'admin'}, ' '], + [{user: 'admin'}, null], + [{accountId: null, user: null, userIdentity: null, jwt: null}, '1a1'], + ]) { + let browser = {cookie: 'untrusted-metadata-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let access = createAccess(browser, auth, () => resolve(currentTokenResponse(token, header))); + try { + await assert.rejects(access.ensureSession(), error => error.status === 401, 'identity and actor must both be verified'); + assert.strictEqual(auth.readShared(), null, 'no actor or generation is fabricated'); + assert.strictEqual(browser.cookie, 'untrusted-metadata-cookie', 'caller owns generation-aware cleanup'); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + } + }); + + test('the root user header identifies the actor and token virtual headers are ignored', async function(assert) { + let browser = {cookie: 'root-actor-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let paths = []; + let access = createAccess(browser, auth, options => { + paths.push(options.url); + if (options.url === 'token') { + return resolve({body: {data: [{userIdentity: {externalId: 'not-an-account-id'}, jwt: 'masked'}]}, + headers: {get() { return '1a4'; }}}); + } + return resolve({headers: {get(name) { + return name === C.HEADER.USER_ID ? '1a1' : '1a5'; + }}}); + }); + try { + await access.ensureSession(); + assert.deepEqual(paths, ['token', ''], 'actor lookup uses the unscoped API root after identity validation'); + assert.strictEqual(auth.get('accountId'), '1a1', 'neither virtual token nor environment account becomes the actor'); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + }); + + test('an authenticated body actor keeps the existing one-request path', async function(assert) { + let browser = {cookie: 'body-actor-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let reads = 0; + let access = createAccess(browser, auth, options => { + reads++; + assert.strictEqual(options.url, 'token'); + return resolve({body: {data: [{accountId: '1a1', user: 'admin'}]}, headers: {get() { return '1a4'; }}}); + }); + try { + await access.ensureSession(); + assert.strictEqual(reads, 1); + assert.strictEqual(auth.get('accountId'), '1a1', 'the virtual token header does not override the authenticated body'); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + }); + + test('login options never trigger an actor probe even when virtual headers exist', async function(assert) { + let browser = {cookie: 'expired-options-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let paths = []; + let access = createAccess(browser, auth, options => { + paths.push(options.url); + return resolve(currentTokenResponse({accountId: null, user: null, userIdentity: null, jwt: 'masked'}, '1a4')); + }); + try { + await assert.rejects(access.ensureSession(), error => error.status === 401); + assert.deepEqual(paths, ['token']); + assert.strictEqual(auth.readShared(), null); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + }); + + test('actor lookup failures preserve their authentication or permission result', async function(assert) { + for (const status of [401, 403, 502]) { + let browser = {cookie: 'actor-lookup-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let access = createAccess(browser, auth, options => options.url === 'token' ? + resolve(currentTokenResponse({user: 'admin'}, '1a4')) : reject({status})); + try { + await assert.rejects(access.ensureSession(), error => error.status === status); + assert.strictEqual(auth.readShared(), null); + assert.strictEqual(browser.cookie, 'actor-lookup-cookie'); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + } + }); + + test('a cookie replacement during the root actor request cannot create a session', async function(assert) { + let browser = {cookie: 'actor-request-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let requested = deferred(), response = deferred(); + let access = createAccess(browser, auth, options => { + if (options.url === 'token') { return resolve(currentTokenResponse({user: 'old-user'}, '1a4')); } + requested.resolve(); + return response.promise; + }); + try { + let pending = access.ensureSession(); + await requested.promise; + browser.cookie = 'replacement-cookie'; + response.resolve(currentTokenResponse({}, '1a1')); + await assert.rejects(pending, error => error.status === 409); + assert.strictEqual(auth.readShared(), null); + assert.strictEqual(browser.cookie, 'replacement-cookie'); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + }); + + test('ensureSession and peer adoption repair only the matching legacy null actor', async function(assert) { + for (const method of ['ensureSession', 'adoptSharedSession']) { + let browser = {cookie: 'legacy-actor-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let record = auth.commit(generation(370), null, browser.cookie); + let access = createAccess(browser, auth, () => resolve(currentTokenResponse({user: 'administrator'}, '1a1'))); + try { + let result = await access[method](); + assert.ok(['active', 'adopted'].includes(result.status), method); + assert.strictEqual(auth.capture(), record.generation, 'repair preserves the validated generation'); + assert.strictEqual(auth.get('accountId'), '1a1'); + assert.strictEqual(auth.readShared().accountId, '1a1'); + } finally { + run(() => { access.destroy(); auth.destroy(); }); + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + } + } + }); + + test('two tabs converge when restoring the same legacy null actor', async function(assert) { + let browser = {cookie: 'legacy-shared-cookie'}; + let locks = serialLockManager(); + let authA = AuthSessionService.create({lockManager: locks}); + let authB = AuthSessionService.create({lockManager: locks}); + let record = authA.commit(generation(371), null, browser.cookie); + let responses = [deferred(), deferred()]; + let accessA = createAccess(browser, authA, () => responses[0].promise); + let accessB = createAccess(browser, authB, () => responses[1].promise); + try { + let a = accessA.ensureSession(), b = accessB.ensureSession(); + responses.forEach(response => response.resolve(currentTokenResponse({user: 'admin'}, '1a1'))); + await Promise.all([a, b]); + assert.strictEqual(authA.get('accountId'), '1a1'); + assert.strictEqual(authB.get('accountId'), '1a1'); + assert.strictEqual(authA.capture(), record.generation); + assert.strictEqual(authB.capture(), record.generation); + } finally { run(() => { accessA.destroy(); accessB.destroy(); authA.destroy(); authB.destroy(); }); } + }); + + test('late actor repair cannot overwrite a newer login or newer null generation', async function(assert) { + for (const method of ['ensureSession', 'adoptSharedSession']) { + let browser = {cookie: 'old-actor-cookie'}; + let locks = serialLockManager(); + let authA = AuthSessionService.create({lockManager: locks}); + let authB = AuthSessionService.create({lockManager: locks}); + authA.commit(generation(372), null, browser.cookie); + let response = deferred(), requested = deferred(); + let access = createAccess(browser, authA, () => { requested.resolve(); return response.promise; }); + try { + let pending = access[method](); + await requested.promise; + browser.cookie = 'new-actor-cookie'; + let newer = authB.commit(generation(373), '1a2', browser.cookie); + response.resolve(currentTokenResponse({user: 'old-user'}, '1a1')); + if (method === 'ensureSession') { + await assert.rejects(pending, error => error.status === 409); + } else { + assert.strictEqual((await pending).status, 'stale', 'passive peer recovery ignores an obsolete actor response'); + } + assert.strictEqual(authB.readShared().generation, newer.generation); + assert.strictEqual(authB.readShared().accountId, '1a2'); + assert.strictEqual(browser.cookie, 'new-actor-cookie'); + } finally { + run(() => { access.destroy(); authA.destroy(); authB.destroy(); }); + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + } + } + + let browser = {cookie: 'unchanged-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + auth.commit(generation(374), null, browser.cookie); + let response = deferred(); + let access = createAccess(browser, auth, () => response.promise); + try { + let pending = access.ensureSession(); + auth.commit(generation(375), null, browser.cookie); + response.resolve(currentTokenResponse({user: 'old-user'}, '1a1')); + await assert.rejects(pending, error => error.status === 409); + assert.strictEqual(auth.readShared().accountId, null, 'even an unbound newer generation is not repaired by an old response'); + assert.strictEqual(auth.readShared().generation, generation(375)); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + }); + + test('a forbidden actor lookup preserves the legacy session for ordinary permission handling', async function(assert) { + let browser = {cookie: 'forbidden-actor-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let record = auth.commit(generation(376), null, browser.cookie); + let access = createAccess(browser, auth, options => options.url === 'token' ? + resolve(currentTokenResponse({user: 'admin'}, '1a4')) : reject({status: 403})); + try { + await assert.rejects(access.adoptSharedSession(), error => error.status === 403); + assert.deepEqual(auth.readShared(), record); + assert.strictEqual(browser.cookie, 'forbidden-actor-cookie'); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + }); + + test('a verified actor mismatch never replaces an existing shared actor', async function(assert) { + for (const method of ['ensureSession', 'adoptSharedSession']) { + let browser = {cookie: 'same-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let record = auth.commit(generation(377), '1a1', browser.cookie); + let access = createAccess(browser, auth, () => resolve(currentTokenResponse({user: 'other-user'}, '1a2'))); + try { + await assert.rejects(access[method](), error => error.status === 409); + assert.deepEqual(auth.readShared(), record, 'the existing generation and actor are unchanged'); + assert.strictEqual(browser.cookie, 'same-cookie'); + } finally { + run(() => { access.destroy(); auth.destroy(); }); + window.localStorage.removeItem(C.AUTH_SESSION.STORAGE_KEY); + } + } + }); + + test('a root conflict without a session change remains a conflict', async function(assert) { + let browser = {cookie: 'unchanged-conflict-cookie'}; + let auth = AuthSessionService.create({lockManager: serialLockManager()}); + let record = auth.commit(generation(378), null, browser.cookie); + let access = createAccess(browser, auth, options => options.url === 'token' ? + resolve(currentTokenResponse({user: 'admin'}, '1a4')) : reject({status: 409})); + try { + await assert.rejects(access.adoptSharedSession(), error => error.status === 409); + assert.deepEqual(auth.readShared(), record, 'an unrelated conflict cannot repair or clear the session'); + } finally { run(() => { access.destroy(); auth.destroy(); }); } + }); + 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'];