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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,31 @@ 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
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. 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
the security-confirmation dialog and retry only after successful confirmation;
Expand Down
6 changes: 5 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.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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion app/admin-tab/auth/activedirectory/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion app/admin-tab/auth/azuread/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion app/admin-tab/auth/github/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion app/admin-tab/auth/localauth/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion app/admin-tab/auth/shibboleth/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) => {
Expand Down
163 changes: 129 additions & 34 deletions app/application/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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.';
Expand All @@ -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);
}
}
Expand All @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading