Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@ PastureStack is an independent community effort to preserve, audit, and moderniz

## Project status

The current compatibility release is `1.6.117`. It retains the existing Node 24, Ember, Sass,
The current compatibility release is `1.6.118`. 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
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.118` fixes OIDC site-access policy editing without weakening the
provider enablement boundary. Unrestricted mode clears stale authorized
identities before saving; restricted and required entries are normalized and
deduplicated by OIDC principal type and immutable external ID. Access expansion
opens the existing MFA security-confirmation dialog with a purpose and canonical
request digest supplied by the authentication service, retries exactly once,
and never retains the one-time ticket after completion or failure. Stable backend
codes are rendered as localized, actionable errors without exposing raw response
bodies. Pair this release with Authentication Service `v0.4.37` and Engine
`0.183.303` or newer.

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
Expand Down
7 changes: 6 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Supported state

The maintained compatibility release is the pure numeric `1.6.117` line used
The maintained compatibility release is the pure numeric `1.6.118` 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
Expand Down Expand Up @@ -37,6 +37,11 @@ administrator before activation.
authentication material for that account.
- Authenticator-enrollment QR codes are generated locally in the browser; a
provisioning secret must never be sent to an external rendering service.
- OIDC site-access expansion uses a one-time MFA confirmation bound to the
authenticated operator, fixed operation purpose, and canonical request
digest. The browser retries the save at most once and clears the confirmation
ticket on success, cancellation, and failure. Unrestricted mode must submit an
explicit empty authorized-identity list.
- WebAuthn requires user verification, the exact origin, a matching
relying-party ID, and a non-public-suffix relying-party domain. HTTP is
accepted only for a loopback test origin.
Expand Down
17 changes: 13 additions & 4 deletions app/components/mfa-security-confirmation/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ export default ModalBase.extend({
},

begin() {
return this.request({
return this.request(Object.assign({
operation: 'beginSecurityConfirmation',
}).then((challenge) => {
}, this.confirmationBinding())).then((challenge) => {
this.setProperties({
challenge: challenge,
method: null,
Expand All @@ -70,17 +70,26 @@ export default ModalBase.extend({
}).then((xhr) => xhr.body);
},

confirmationBinding() {
let purpose = this.get('opts.purpose');
let requestDigest = this.get('opts.requestDigest');
if ( purpose && requestDigest ) {
return {purpose, requestDigest};
}
return {};
},

finish(webAuthnResponse) {
let challenge = this.get('challenge');
this.setProperties({waiting: true, errorMessage: null});
return this.request({
return this.request(Object.assign({
operation: 'confirmSecurityConfirmation',
challengeId: challenge.challengeId,
method: this.get('method'),
verificationCode: this.get('verificationCode'),
recoveryCode: this.get('recoveryCode'),
webAuthnResponse: webAuthnResponse,
}).then((result) => {
}, this.confirmationBinding())).then((result) => {
let onComplete = this.get('opts.onComplete');
if ( typeof onComplete === 'function' ) {
onComplete(result.securityConfirmation);
Expand Down
128 changes: 104 additions & 24 deletions app/components/site-access/component.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
import { service } from '@ember/service';
import Component from '@ember/component';
import { get } from '@ember/object';
import { Promise, reject } from 'rsvp';
import Errors from 'ui/utils/errors';

const OIDC_ACCESS_POLICY_PURPOSE = 'oidcAccessPolicyUpdate';
const CONFIG_ERROR_TRANSLATIONS = {
LocalRecoveryRequired: 'siteAccess.errors.localRecoveryRequired',
MfaConfirmationRequired: 'siteAccess.errors.mfaConfirmationRequired',
MfaConfirmationUnavailable: 'siteAccess.errors.mfaConfirmationUnavailable',
InvalidAccessMode: 'siteAccess.errors.invalidAccessMode',
InvalidAllowedIdentity: 'siteAccess.errors.invalidAllowedIdentity',
};

export function configUpdateErrorBody(err) {
let body = err && (err.body || err.responseJSON ||
(err.xhr && err.xhr.responseJSON));
if ( typeof body === 'string' ) {
try {
body = JSON.parse(body);
} catch (e) {
body = null;
}
}
return body || {};
}

export function configUpdateErrorCode(err) {
let body = configUpdateErrorBody(err);
return (err && err.code) || body.code || body.type || (err && err.type);
}

export default Component.extend({
tagName: 'section',
classNames: ['well'],
settings: service(),
access: service(),
intl: service(),
modalService: service('modal'),

model: null,
individuals: 'siteAccess.users',
Expand All @@ -19,11 +50,47 @@ export default Component.extend({
return this.get('copy.accessMode') !== 'unrestricted';
}.property('copy.accessMode'),

saveConfiguration(btnCb) {
this.send('clearError');

if ( this.get('showList') && !this.get('copy.allowedIdentities.length') )
{
this.send('gotError', this.get('intl').t('siteAccess.errors.authorizedIdentityRequired'));
btnCb();
return Promise.resolve();
}

this.set('saved', false);

let copy = this.get('copy');
if ( copy.get('accessMode') === 'unrestricted' ) {
copy.set('allowedIdentities', []);
}
return this.saveWithBoundConfirmation(copy, true).then(() => {
this.get('model').replaceWith(copy);
this.set('copy.allowedIdentities', this.get('copy.allowedIdentities').slice());
this.set('saved', true);
}).catch((err) => {
if ( !err || !err.mfaConfirmationCancelled ) {
this.send('gotError', err);
}
}).finally(() => {
btnCb();
});
},

actions: {
addAuthorized: function(data) {
this.send('clearError');
this.set('saved', false);
this.get('copy.allowedIdentities').pushObject(data);
let identities = this.get('copy.allowedIdentities');
let duplicate = identities.find((identity) => {
return get(identity, 'externalIdType') === get(data, 'externalIdType') &&
get(identity, 'externalId') === get(data, 'externalId');
});
if ( !duplicate ) {
identities.pushObject(data);
}
},

removeIdentity: function(ident) {
Expand All @@ -32,38 +99,47 @@ export default Component.extend({
},

save: function(btnCb) {
this.send('clearError');

if ( this.get('showList') && !this.get('copy.allowedIdentities.length') )
{
this.send('gotError', 'You must add at least one authorized entry');
btnCb();
return;
}

this.set('saved', false);

let copy = this.get('copy');
copy.save().then(() => {
this.get('model').replaceWith(copy);
this.set('copy.allowedIdentities', this.get('copy.allowedIdentities').slice());
this.set('saved', true);
}).catch((err) => {
this.send('gotError', err);
}).finally(() => {
btnCb();
});
return this.saveConfiguration(btnCb);
},

gotError: function(err) {
this.set('errors', [Errors.stringify(err)]);
let translation = CONFIG_ERROR_TRANSLATIONS[configUpdateErrorCode(err)];
this.set('errors', [translation ? this.get('intl').t(translation) : Errors.stringify(err)]);
},

clearError: function() {
this.set('errors', null);
},
},

saveWithBoundConfirmation(copy, mayConfirm) {
return copy.save().catch((err) => {
let body = configUpdateErrorBody(err);
let code = configUpdateErrorCode(err);
let digest = body.requestDigest;
if ( !mayConfirm || code !== 'MfaConfirmationRequired' ||
!/^[0-9a-f]{64}$/.test(digest || '') ) {
return reject(err);
}

return new Promise((resolve, rejectSave) => {
this.get('modalService').toggleModal('mfa-security-confirmation', {
closeWithOutsideClick: false,
escToClose: false,
purpose: OIDC_ACCESS_POLICY_PURPOSE,
requestDigest: digest,
onComplete: (confirmation) => {
copy.set('securityConfirmation', confirmation);
this.saveWithBoundConfirmation(copy, false)
.finally(() => copy.set('securityConfirmation', null))
.then(resolve, rejectSave);
},
onCancel: () => rejectSave({mfaConfirmationCancelled: true}),
});
});
});
},

didReceiveAttrs() {
this.set('copy', this.get('model').clone());
this.set('copy.allowedIdentities', (this.get('copy.allowedIdentities')||[]).slice());
Expand All @@ -78,7 +154,11 @@ export default Component.extend({
this.set('copy.allowedIdentities', identities);
}

if ( this.get('copy.accessMode') !== 'unrestricted' )
if ( this.get('copy.accessMode') === 'unrestricted' )
{
this.set('copy.allowedIdentities', []);
}
else
{
let me = this.get('access.identity');
let found = identities.filterBy('id', me.get('id')).length > 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "@pasturestack/web-console",
"version": "1.6.117",
"version": "1.6.118",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pasturestack/web-console",
"version": "1.6.117",
"version": "1.6.118",
"license": "Apache-2.0",
"dependencies": {
"sass": "1.103.1"
Expand Down
23 changes: 23 additions & 0 deletions docs/releases/web-console-1.6.118.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Web Console 1.6.118

- Clear `allowedIdentities` whenever OIDC site access switches to unrestricted,
preventing a stale allowlist from surviving a permission expansion.
- Normalize and deduplicate restricted or required OIDC users and groups by
principal type and immutable external ID before submission.
- Handle stable Authentication Service policy errors without exposing raw
response content. Local recovery, MFA confirmation, and invalid identity
policies now produce distinct localized guidance.
- Bind an access-expansion confirmation to the backend-supplied operation
purpose and canonical request digest, then retry the save exactly once.
Malformed challenges are rejected before opening the dialog, and the one-time
ticket is cleared after success, cancellation, or failure.
- Preserve the complete `1.6.117` cross-tab session-ownership fix, including
explicit-only token revocation, deterministic session-generation adoption,
stale callback protection, refresh recovery, and JWT confinement to cookie
and memory.
- Add focused browser-unit coverage for unrestricted clearing, principal
normalization and deduplication, stable errors, malformed challenges, and
bounded MFA retry behavior.

Use this release with Authentication Service `v0.4.37` and Orchestration Engine
`0.183.303` or newer for the complete OIDC policy-update boundary.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pasturestack/web-console",
"version": "1.6.117",
"version": "1.6.118",
"private": true,
"description": "PastureStack browser console for the compatible control platform.",
"repository": {
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-modernization-blockers
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f:
print(json.load(f).get('version', ''))
PY
)
if [[ "$version" != "1.6.117" ]]; then
echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.117"
if [[ "$version" != "1.6.118" ]]; then
echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.118"
failures=$((failures + 1))
fi

Expand Down
2 changes: 1 addition & 1 deletion scripts/check-ui-console-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -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.117 browser-session broker-broadcast
1.6.118 browser-session broker-broadcast
2 changes: 1 addition & 1 deletion scripts/check-ui-critical-high-dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -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.117":
if package.get("version") != "1.6.118":
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')}")
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-ui-test-harness-blockers
Original file line number Diff line number Diff line change
Expand Up @@ -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 != 115:
fail("DIRECT_QUNIT_IMPORT_COUNT_UNEXPECTED", actual=direct_qunit_import_count, expected=115)
if direct_qunit_import_count != 116:
fail("DIRECT_QUNIT_IMPORT_COUNT_UNEXPECTED", actual=direct_qunit_import_count, expected=116)

volatile_computed_count = 0
for path in Path("app").rglob("*.js"):
Expand Down
Loading