Skip to content

Commit

Permalink
[ui, sso] Handle token expiry 500s (#15073)
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

* Redirect on token-doesnt-exist

* Forgot to import our time lib

* Linting on _blank

* Redirect tests

* changelog
  • Loading branch information
philrenaud committed Nov 3, 2022
1 parent a170dd3 commit 984ee8a
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 37 deletions.
3 changes: 3 additions & 0 deletions .changelog/15073.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
ui: redirect users to Sign In should their tokens ever come back expired or not-found
```
2 changes: 2 additions & 0 deletions ui/app/controllers/settings/tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default class Tokens extends Controller {
clearTokenProperties() {
this.token.setProperties({
secret: undefined,
tokenNotFound: false,
});
this.setProperties({
tokenIsValid: false,
Expand Down Expand Up @@ -54,6 +55,7 @@ export default class Tokens extends Controller {
tokenIsValid: true,
tokenIsInvalid: false,
});
this.token.set('tokenNotFound', false);
},
() => {
this.set('token.secret', undefined);
Expand Down
5 changes: 5 additions & 0 deletions ui/app/models/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export default class Token extends Model {
@attr('string') type;
@hasMany('policy') policies;
@attr() policyNames;
@attr('date') expirationTime;

@alias('id') accessor;

get isExpired() {
return this.expirationTime && this.expirationTime < new Date();
}
}
13 changes: 12 additions & 1 deletion ui/app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default class ApplicationRoute extends Route {
@service system;
@service store;
@service token;
@service router;

queryParams = {
region: {
Expand Down Expand Up @@ -140,7 +141,17 @@ export default class ApplicationRoute extends Route {
@action
error(error) {
if (!(error instanceof AbortError)) {
this.controllerFor('application').set('error', error);
if (
error.errors?.any(
(e) =>
e.detail === 'ACL token expired' ||
e.detail === 'ACL token not found'
)
) {
this.router.transitionTo('settings.tokens');
} else {
this.controllerFor('application').set('error', error);
}
}
}
}
6 changes: 6 additions & 0 deletions ui/app/services/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import classic from 'ember-classic-decorator';
export default class TokenService extends Service {
@service store;
@service system;
@service router;

aclEnabled = true;

tokenNotFound = false;

@computed
get secret() {
return window.localStorage.nomadTokenSecret;
Expand All @@ -39,6 +42,9 @@ export default class TokenService extends Service {
if (errors.find((error) => error === 'ACL support disabled')) {
this.set('aclEnabled', false);
}
if (errors.find((error) => error === 'ACL token not found')) {
this.set('tokenNotFound', true);
}
return null;
}
})
Expand Down
103 changes: 67 additions & 36 deletions ui/app/templates/settings/tokens.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,43 @@
<div class="column is-two-thirds">
<p class="message">Clusters that use Access Control Lists require tokens to perform certain tasks. By providing a token <strong>Secret ID</strong>, each future request will be authenticated, potentially authorizing read access to additional information. By providing a token <strong>Accessor ID</strong>, the policies and rules for the token will be listed.</p>

<div class="notification is-info">
<div class="columns">
<div class="column">
<h3 class="title is-4">Token Storage</h3>
<p>Tokens are stored client-side in <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage">local storage</a>. This will persist your token across sessions. You can manually clear your token here.</p>
{{#if this.tokenRecord.isExpired}}
<div data-test-token-expired class="notification is-danger">
<div class="columns">
<div class="column">
<h3 class="title is-4">Your token has expired</h3>
<p>Expired {{moment-from-now this.tokenRecord.expirationTime interval=1000}} ({{this.tokenRecord.expirationTime}})</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-token-clear class="button" {{action "clearTokenProperties"}} type="button">Clear Token</button>
</div>
</div>
</div>
{{else if this.token.tokenNotFound}}
<div data-test-token-not-found class="notification is-danger">
<div class="columns">
<div class="column">
<h3 class="title is-4">Your token was not found</h3>
<p>It may have expired, or been entered incorrectly.</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-token-clear class="button" {{action "clearTokenProperties"}} type="button">Clear Token</button>
</div>
</div>
<div class="column is-centered is-minimum">
<button data-test-token-clear class="button is-info" {{action "clearTokenProperties"}} type="button">Clear Token</button>
</div>
{{else}}
<div class="notification is-info">
<div class="columns">
<div class="column">
<h3 class="title is-4">Token Storage</h3>
<p>Tokens are stored client-side in <a target="_blank" rel="noopener noreferrer" href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage">local storage</a>. This will persist your token across sessions. You can manually clear your token here.</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-token-clear class="button is-info" {{action "clearTokenProperties"}} type="button">Clear Token</button>
</div>
</div>
</div>
</div>
{{/if}}

{{#unless this.tokenIsValid}}
<div class="field">
Expand Down Expand Up @@ -60,37 +86,42 @@
{{/if}}

{{#if this.tokenRecord}}
<h3 class="title is-4">Token: {{this.tokenRecord.name}}</h3>
<div class="content">
<div>AccessorID: <code>{{this.tokenRecord.accessor}}</code></div>
<div>SecretID: <code>{{this.tokenRecord.secret}}</code></div>
</div>
<h3 class="title is-4">Policies</h3>
{{#if (eq this.tokenRecord.type "management")}}
<div data-test-token-management-message class="boxed-section">
<div class="boxed-section-body has-centered-text">
The management token has all permissions
</div>
{{#unless this.tokenRecord.isExpired}}
<h3 class="title is-4">Token: {{this.tokenRecord.name}}</h3>
<div class="content">
<div>AccessorID: <code>{{this.tokenRecord.accessor}}</code></div>
<div>SecretID: <code>{{this.tokenRecord.secret}}</code></div>
{{#if this.tokenRecord.expirationTime}}
<div data-test-token-expiry>Expires: {{moment-from-now this.tokenRecord.expirationTime interval=1000}} ({{this.tokenRecord.expirationTime}})</div>
{{/if}}
</div>
{{else}}
{{#each this.tokenRecord.policies as |policy|}}
<div data-test-token-policy class="boxed-section">
<div data-test-policy-name class="boxed-section-head">
{{policy.name}}
</div>
<div class="boxed-section-body">
<p data-test-policy-description class="content">
{{#if policy.description}}
{{policy.description}}
{{else}}
<em>No description</em>
{{/if}}
</p>
<pre><code data-test-policy-rules>{{policy.rules}}</code></pre>
<h3 class="title is-4">Policies</h3>
{{#if (eq this.tokenRecord.type "management")}}
<div data-test-token-management-message class="boxed-section">
<div class="boxed-section-body has-centered-text">
The management token has all permissions
</div>
</div>
{{/each}}
{{/if}}
{{else}}
{{#each this.tokenRecord.policies as |policy|}}
<div data-test-token-policy class="boxed-section">
<div data-test-policy-name class="boxed-section-head">
{{policy.name}}
</div>
<div class="boxed-section-body">
<p data-test-policy-description class="content">
{{#if policy.description}}
{{policy.description}}
{{else}}
<em>No description</em>
{{/if}}
</p>
<pre><code data-test-policy-rules>{{policy.rules}}</code></pre>
</div>
</div>
{{/each}}
{{/if}}
{{/unless}}
{{/if}}
</div>
</div>
Expand Down
89 changes: 89 additions & 0 deletions ui/tests/acceptance/token-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
import Layout from 'nomad-ui/tests/pages/layout';
import percySnapshot from '@percy/ember';
import faker from 'nomad-ui/mirage/faker';
import moment from 'moment';

let job;
let node;
Expand Down Expand Up @@ -181,6 +182,94 @@ module('Acceptance | tokens', function (hooks) {
assert.notOk(find('[data-test-job-row]'), 'No jobs found');
});

test('it handles expiring tokens', async function (assert) {
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');
});

test('it handles expired tokens', async function (assert) {
const expiredToken = server.create('token', {
name: 'Well past due',
expirationTime: moment().add(-5, 'm').toDate(),
});

// GC'd or non-existent token, from localStorage or otherwise
window.localStorage.nomadTokenSecret = expiredToken.secretId;
await Tokens.visit();
assert
.dom('[data-test-token-expired]')
.exists('Warning banner shown for expired token');
});

test('it forces redirect on an expired token', async function (assert) {
const expiredToken = server.create('token', {
name: 'Well past due',
expirationTime: moment().add(-5, 'm').toDate(),
});

window.localStorage.nomadTokenSecret = expiredToken.secretId;
const expiredServerError = {
errors: [
{
detail: 'ACL token expired',
},
],
};
server.pretender.get('/v1/jobs', function () {
console.log('uhhhh');
return [500, {}, JSON.stringify(expiredServerError)];
});

await Jobs.visit();
assert.equal(
currentURL(),
'/settings/tokens',
'Redirected to tokens page due to an expired token'
);
});

test('it forces redirect on a not-found token', async function (assert) {
const longDeadToken = server.create('token', {
name: 'dead and gone',
expirationTime: moment().add(-5, 'h').toDate(),
});

window.localStorage.nomadTokenSecret = longDeadToken.secretId;
const notFoundServerError = {
errors: [
{
detail: 'ACL token not found',
},
],
};
server.pretender.get('/v1/jobs', function () {
return [500, {}, JSON.stringify(notFoundServerError)];
});

await Jobs.visit();
assert.equal(
currentURL(),
'/settings/tokens',
'Redirected to tokens page due to a token not being found'
);
});

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 984ee8a

Please sign in to comment.