Skip to content

Commit

Permalink
[ui, sso] warn user about pending token expiry (#15091)
Browse files Browse the repository at this point in the history
* Handle error states generally

* Dont direct, just redirect

* no longer need explicit error on controller

* Linting on _blank

* Custom notification actions and shift the template to within an else block

* Lintfix

* Make the closeAction optional

* changelog

* Add a mirage token that will always expire in 11 minutes

* Test for token expiry with ember concurrency waiters

* concurrency handling for earlier test, and button redirect test
  • Loading branch information
philrenaud authored Nov 2, 2022
1 parent 7f7e168 commit 40c821e
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 42 deletions.
3 changes: 3 additions & 0 deletions .changelog/15091.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
ui: give users a notification if their token is going to expire within the next 10 minutes
```
67 changes: 66 additions & 1 deletion ui/app/services/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ import { computed } from '@ember/object';
import { alias, reads } from '@ember/object/computed';
import { getOwner } from '@ember/application';
import { assign } from '@ember/polyfills';
import { task } from 'ember-concurrency';
import { task, timeout } from 'ember-concurrency';
import queryString from 'query-string';
import fetch from 'nomad-ui/utils/fetch';
import classic from 'ember-classic-decorator';
import moment from 'moment';

const MINUTES_LEFT_AT_WARNING = 10;
const EXPIRY_NOTIFICATION_TITLE = 'Your access is about to expire';
@classic
export default class TokenService extends Service {
@service store;
@service system;
@service router;
@service flashMessages;

aclEnabled = true;

Expand Down Expand Up @@ -77,6 +81,7 @@ export default class TokenService extends Service {

@task(function* () {
yield this.fetchSelfToken.perform();
this.kickoffTokenTTLMonitoring();
if (this.aclEnabled) {
yield this.fetchSelfTokenPolicies.perform();
}
Expand Down Expand Up @@ -115,7 +120,67 @@ export default class TokenService extends Service {
this.fetchSelfToken.cancelAll({ resetState: true });
this.fetchSelfTokenPolicies.cancelAll({ resetState: true });
this.fetchSelfTokenAndPolicies.cancelAll({ resetState: true });
this.monitorTokenTime.cancelAll({ resetState: true });
}

kickoffTokenTTLMonitoring() {
this.monitorTokenTime.perform();
}

@task(function* () {
while (this.selfToken?.expirationTime) {
const diff = new Date(this.selfToken.expirationTime) - new Date();
// Let the user know at the 10 minute mark,
// or any time they refresh with under 10 minutes left
if (diff < 1000 * 60 * MINUTES_LEFT_AT_WARNING) {
const existingNotification = this.flashMessages.queue?.find(
(m) => m.title === EXPIRY_NOTIFICATION_TITLE
);
// For the sake of updating the "time left" message, we keep running the task down to the moment of expiration
if (diff > 0) {
if (existingNotification) {
existingNotification.set(
'message',
`Your token access expires ${moment(
this.selfToken.expirationTime
).fromNow()}`
);
} else {
if (!this.expirationNotificationDismissed) {
this.flashMessages.add({
title: EXPIRY_NOTIFICATION_TITLE,
message: `Your token access expires ${moment(
this.selfToken.expirationTime
).fromNow()}`,
type: 'error',
destroyOnClick: false,
sticky: true,
customCloseAction: () => {
this.set('expirationNotificationDismissed', true);
},
customAction: {
label: 'Re-authenticate',
action: () => {
this.router.transitionTo('settings.tokens');
},
},
});
}
}
} else {
if (existingNotification) {
existingNotification.setProperties({
title: 'Your access has expired',
message: `Your token will need to be re-authenticated`,
});
}
this.monitorTokenTime.cancelAll(); // Stop updating time left after expiration
}
}
yield timeout(1000);
}
})
monitorTokenTime;
}

function addParams(url, params) {
Expand Down
9 changes: 8 additions & 1 deletion ui/app/styles/core/notifications.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
$bonusRightPadding: 20px;

section.notifications {
position: fixed;
bottom: 10px;
Expand All @@ -11,7 +13,7 @@ section.notifications {
box-shadow: 1px 1px 4px 0px rgb(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
padding-right: 20px;
padding-right: $bonusRightPadding;

&.alert-success {
background-color: lighten($nomad-green, 50%);
Expand Down Expand Up @@ -54,5 +56,10 @@ section.notifications {
}
}
}

.custom-action-button {
width: calc(100% + $bonusRightPadding - 1rem);
margin: 1.5rem 0 0;
}
}
}
10 changes: 9 additions & 1 deletion ui/app/templates/application.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@
<section class="notifications">
{{#each this.flashMessages.queue as |flash|}}
<FlashMessage @flash={{flash}} as |component flash close|>
<span class="close-button" role="button" {{on "click" (action close)}}>&times;</span>
<span class="close-button" role="button" {{on "click"
(queue
(action close)
(action (optional flash.customCloseAction))
)
}}>&times;</span>
{{#if flash.title}}
<h3>{{flash.title}}</h3>
{{/if}}
{{#if flash.message}}
<p>{{flash.message}}</p>
{{/if}}
{{#if flash.customAction}}
<button type="button" class="button custom-action-button" {{on "click" (action flash.customAction.action)}}>{{flash.customAction.label}}</button>
{{/if}}
{{#if component.showProgressBar}}
<div class="alert-progress">
<div class="alert-progressBar" style={{component.progressDuration}}></div>
Expand Down
67 changes: 34 additions & 33 deletions ui/app/templates/settings/tokens.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -41,48 +41,49 @@
</div>
</div>
</div>
{{/if}}

{{#unless this.tokenIsValid}}
<div class="field">
<label class="label" for="token-input">Secret ID</label>
<div class="control">
<input
id="token-input"
class="input"
type="text"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
{{!-- FIXME this placeholder gets read out by VoiceOver sans dashes 😵 --}}
value={{this.token.secret}}
oninput={{action (mut this.secret) value="target.value"}}
data-test-token-secret>
{{#unless this.tokenIsValid}}
<div class="field">
<label class="label" for="token-input">Secret ID</label>
<div class="control">
<input
id="token-input"
class="input"
type="text"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
{{!-- FIXME this placeholder gets read out by VoiceOver sans dashes 😵 --}}
value={{this.token.secret}}
oninput={{action (mut this.secret) value="target.value"}}
data-test-token-secret>
</div>
<p class="help">Sent with every request to determine authorization</p>
</div>
<p class="help">Sent with every request to determine authorization</p>
</div>

<p class="content"><button data-test-token-submit class="button is-primary" {{action "verifyToken"}} type="button">Set Token</button></p>
{{/unless}}
<p class="content"><button data-test-token-submit class="button is-primary" {{action "verifyToken"}} type="button">Set Token</button></p>
{{/unless}}

{{#if this.tokenIsValid}}
<div data-test-token-success class="notification is-success">
<div class="columns">
<div class="column">
<h3 class="title is-4">Token Authenticated!</h3>
<p>Your token is valid and authorized for the following policies.</p>
{{#if this.tokenIsValid}}
<div data-test-token-success class="notification is-success">
<div class="columns">
<div class="column">
<h3 class="title is-4">Token Authenticated!</h3>
<p>Your token is valid and authorized for the following policies.</p>
</div>
</div>
</div>
</div>
{{/if}}
{{/if}}

{{#if this.tokenIsInvalid}}
<div data-test-token-error class="notification is-danger">
<div class="columns">
<div class="column">
<h3 class="title is-4">Token Failed to Authenticate</h3>
<p>The token secret you have provided does not match an existing token.</p>
{{#if this.tokenIsInvalid}}
<div data-test-token-error class="notification is-danger">
<div class="columns">
<div class="column">
<h3 class="title is-4">Token Failed to Authenticate</h3>
<p>The token secret you have provided does not match an existing token, or has expired.</p>
</div>
</div>
</div>
</div>
{{/if}}

{{/if}}

{{#if this.tokenRecord}}
Expand Down
5 changes: 5 additions & 0 deletions ui/mirage/factories/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,5 +164,10 @@ node {
server.create('policy', variableViewerPolicy);
token.policyIds.push(variableViewerPolicy.id);
}
if (token.id === '3XP1R35-1N-3L3V3N-M1NU735') {
token.update({
expirationTime: new Date(new Date().getTime() + 11 * 60 * 1000),
});
}
},
});
4 changes: 4 additions & 0 deletions ui/mirage/scenarios/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,10 @@ function createTokens(server) {
name: "Safe O'Constants",
id: 'f3w3r-53cur3-v4r14bl35',
});
server.create('token', {
name: 'Lazarus MacMarbh',
id: '3XP1R35-1N-3L3V3N-M1NU735',
});
logTokens(server);
}

Expand Down
70 changes: 64 additions & 6 deletions ui/tests/acceptance/token-test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable qunit/require-expect */
import { currentURL, find, visit } from '@ember/test-helpers';
import { currentURL, find, visit, click } from '@ember/test-helpers';
import { module, skip, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
Expand All @@ -12,6 +12,7 @@ import Layout from 'nomad-ui/tests/pages/layout';
import percySnapshot from '@percy/ember';
import faker from 'nomad-ui/mirage/faker';
import moment from 'moment';
import { run } from '@ember/runloop';

let job;
let node;
Expand Down Expand Up @@ -183,24 +184,44 @@ module('Acceptance | tokens', function (hooks) {
});

test('it handles expiring tokens', async function (assert) {
// Soon-expiring token
const expiringToken = server.create('token', {
name: "Time's a-tickin",
expirationTime: moment().add(1, 'm').toDate(),
});

// Soon-expiring token
await Tokens.visit();
await Tokens.secret(expiringToken.secretId).submit();
assert
.dom('[data-test-token-expiry]')
.exists('Expiry shown for TTL-having token');

// Token with no TTL
await Tokens.clear();
await Tokens.secret(clientToken.secretId).submit();
assert
.dom('[data-test-token-expiry]')
.doesNotExist('No expiry shown for regular token');

await Tokens.clear();

// https://ember-concurrency.com/docs/testing-debugging/
setTimeout(() => run.cancelTimers(), 500);

// Token with TTL
await Tokens.secret(expiringToken.secretId).submit();
assert
.dom('[data-test-token-expiry]')
.exists('Expiry shown for TTL-having token');

// TTL Action
await Jobs.visit();
assert
.dom('.flash-message.alert-error button')
.exists('A global alert exists and has a clickable button');

await click('.flash-message.alert-error button');
assert.equal(
currentURL(),
'/settings/tokens',
'Redirected to tokens page on notification action'
);
});

test('it handles expired tokens', async function (assert) {
Expand Down Expand Up @@ -270,6 +291,43 @@ module('Acceptance | tokens', function (hooks) {
);
});

test('it notifies you when your token has 10 minutes remaining', async function (assert) {
let notificationRendered = assert.async();
let notificationNotRendered = assert.async();
window.localStorage.clear();
assert.equal(
window.localStorage.nomadTokenSecret,
null,
'No token secret set'
);
assert.timeout(6000);
const nearlyExpiringToken = server.create('token', {
name: 'Not quite dead yet',
expirationTime: moment().add(10, 'm').add(5, 's').toDate(),
});

await Tokens.visit();
await Tokens.clear();
// Ember Concurrency makes testing iterations convoluted: https://ember-concurrency.com/docs/testing-debugging/
// Waiting for half a second to validate that there's no warning;
// then a further 5 seconds to validate that there is a warning, and to explicitly cancelAllTimers(),
// short-circuiting our Ember Concurrency loop.
setTimeout(() => {
assert
.dom('.flash-message.alert-error')
.doesNotExist('No notification yet for a token with 10m5s left');
notificationNotRendered();
setTimeout(() => {
assert
.dom('.flash-message.alert-error')
.exists('Notification is rendered at the 10m mark');
notificationRendered();
run.cancelTimers();
}, 5000);
}, 500);
await Tokens.secret(nearlyExpiringToken.secretId).submit();
});

test('when the ott query parameter is present upon application load it’s exchanged for a token', async function (assert) {
const { oneTimeSecret, secretId } = managementToken;

Expand Down

0 comments on commit 40c821e

Please sign in to comment.