Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Add exchange of one-time token on application load #10066

Merged
merged 15 commits into from
Apr 1, 2021
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
18 changes: 18 additions & 0 deletions ui/app/adapters/token.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { inject as service } from '@ember/service';
import { default as ApplicationAdapter, namespace } from './application';
import OTTExchangeError from '../utils/ott-exchange-error';

export default class TokenAdapter extends ApplicationAdapter {
@service store;
Expand All @@ -16,4 +17,21 @@ export default class TokenAdapter extends ApplicationAdapter {
return store.peekRecord('token', store.normalize('token', token).data.id);
});
}

exchangeOneTimeToken(oneTimeToken) {
return this.ajax(`${this.buildURL()}/token/onetime/exchange`, 'POST', {
data: {
OneTimeSecretID: oneTimeToken,
},
}).then(({ Token: token }) => {
const store = this.store;
store.pushPayload('token', {
tokens: [token],
});

return store.peekRecord('token', store.normalize('token', token).data.id);
}).catch(() => {
throw new OTTExchangeError();
});
}
}
7 changes: 7 additions & 0 deletions ui/app/controllers/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { computed } from '@ember/object';
import Ember from 'ember';
import codesForError from '../utils/codes-for-error';
import NoLeaderError from '../utils/no-leader-error';
import OTTExchangeError from '../utils/ott-exchange-error';
import classic from 'ember-classic-decorator';

@classic
Expand Down Expand Up @@ -55,6 +56,12 @@ export default class ApplicationController extends Controller {
return error instanceof NoLeaderError;
}

@computed('error')
get isOTTExchange() {
const error = this.error;
return error instanceof OTTExchangeError;
}

@observes('error')
throwError() {
if (this.get('config.isDev')) {
Expand Down
58 changes: 36 additions & 22 deletions ui/app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,21 @@ export default class ApplicationRoute extends Route {
}
}

beforeModel(transition) {
async beforeModel(transition) {
let exchangeOneTimeToken;

if (transition.to.queryParams.ott) {
exchangeOneTimeToken = this.get('token').exchangeOneTimeToken(transition.to.queryParams.ott);
} else {
exchangeOneTimeToken = Promise.resolve(true);
}

try {
await exchangeOneTimeToken;
} catch (e) {
this.controllerFor('application').set('error', e);
}

const fetchSelfTokenAndPolicies = this.get('token.fetchSelfTokenAndPolicies')
.perform()
.catch();
Expand All @@ -34,31 +48,31 @@ export default class ApplicationRoute extends Route {
.perform()
.catch();

return RSVP.all([
const promises = await RSVP.all([
this.get('system.regions'),
this.get('system.defaultRegion'),
fetchLicense,
fetchSelfTokenAndPolicies,
]).then(promises => {
if (!this.get('system.shouldShowRegions')) return promises;

const queryParam = transition.to.queryParams.region;
const defaultRegion = this.get('system.defaultRegion.region');
const currentRegion = this.get('system.activeRegion') || defaultRegion;

// Only reset the store if the region actually changed
if (
(queryParam && queryParam !== currentRegion) ||
(!queryParam && currentRegion !== defaultRegion)
) {
this.system.reset();
this.store.unloadAll();
}

this.set('system.activeRegion', queryParam || defaultRegion);

return promises;
});
]);

if (!this.get('system.shouldShowRegions')) return promises;

const queryParam = transition.to.queryParams.region;
const defaultRegion = this.get('system.defaultRegion.region');
const currentRegion = this.get('system.activeRegion') || defaultRegion;

// Only reset the store if the region actually changed
if (
(queryParam && queryParam !== currentRegion) ||
(!queryParam && currentRegion !== defaultRegion)
) {
this.system.reset();
this.store.unloadAll();
}

this.set('system.activeRegion', queryParam || defaultRegion);

return promises;
backspace marked this conversation as resolved.
Show resolved Hide resolved
}

// Model is being used as a way to transfer the provided region
Expand Down
7 changes: 7 additions & 0 deletions ui/app/services/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export default class TokenService extends Service {
})
fetchSelfToken;

async exchangeOneTimeToken(oneTimeToken) {
const TokenAdapter = getOwner(this).lookup('adapter:token');

const token = await TokenAdapter.exchangeOneTimeToken(oneTimeToken);
this.secret = token.secret;
}

@computed('secret', 'fetchSelfToken.lastSuccessful.value')
get selfToken() {
if (this.secret) return this.get('fetchSelfToken.lastSuccessful.value');
Expand Down
20 changes: 20 additions & 0 deletions ui/app/styles/components/empty-message.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,29 @@
strong {
color: $grey;
}

&:not(:last-child) {
margin-bottom: 1rem;
}
}

&.is-hollow {
background: transparent;
}

.terminal-container {
display: flex;
justify-content: center;
margin-top: 1.25rem;
}

.terminal {
background: $grey-lighter;
border-radius: $radius;
padding: 0.75rem 1rem;

.prompt {
color: $grey;
}
backspace marked this conversation as resolved.
Show resolved Hide resolved
}
}
5 changes: 5 additions & 0 deletions ui/app/templates/application.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
<p data-test-error-message class="subtitle">
The cluster has no leader. <a href="https://www.nomadproject.io/guides/outage.html"> Read about Outage Recovery.</a>
</p>
{{else if this.isOTTExchange}}
<h1 data-test-error-title class="title is-spaced">Token Exchange Error</h1>
<p data-test-error-message class="subtitle">
Failed to exchange the one-time token.
</p>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed the existing no-leader-error pattern and created the ott-exchange-error that’s checked for here to display this custom message… maybe a more generic known error type with custom title and message would be useful eventually 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Errors and notifications I think are high on the "to refactor" list.

{{else if this.is500}}
<h1 data-test-error-title class="title is-spaced">Server Error</h1>
<p data-test-error-message class="subtitle">A server error prevented data from being sent to the client.</p>
Expand Down
6 changes: 6 additions & 0 deletions ui/app/templates/components/forbidden-message.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,10 @@
permission to view this.
{{/if}}
</p>
<p class="empty-message-body">
If you have an ACL token configured for the CLI, authenticate with:
<div class='terminal-container'>
<pre class='terminal'><span class='prompt'>$</span> nomad ui -authenticate</pre>
</div>
</p>
</div>
3 changes: 3 additions & 0 deletions ui/app/utils/ott-exchange-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default class OTTExchangeError extends Error {
message = 'Failed to exchange the one-time token.';
}
18 changes: 17 additions & 1 deletion ui/mirage/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,22 @@ export default function() {
return new Response(403, {}, null);
});

this.post('/acl/token/onetime/exchange', function({ tokens }, { requestBody }) {
const { OneTimeSecretID } = JSON.parse(requestBody);

const tokenForSecret = tokens.findBy({ oneTimeSecret: OneTimeSecretID });

// Return the token if it exists
if (tokenForSecret) {
return {
Token: this.serialize(tokenForSecret),
};
}

// Forbidden error if it doesn't
return new Response(403, {}, null);
});

this.get('/acl/policy/:id', function({ policies, tokens }, req) {
const policy = policies.find(req.params.id);
const secret = req.requestHeaders['X-Nomad-Token'];
Expand Down Expand Up @@ -422,7 +438,7 @@ export default function() {
return {
License: {
Features: records.models.mapBy('name'),
}
},
};
}

Expand Down
2 changes: 2 additions & 0 deletions ui/mirage/factories/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export default Factory.extend({
global: () => faker.random.boolean(),
type: i => (i === 0 ? 'management' : 'client'),

oneTimeSecret: () => faker.random.uuid(),

afterCreate(token, server) {
const policyIds = Array(faker.random.number({ min: 1, max: 5 }))
.fill(0)
Expand Down
20 changes: 19 additions & 1 deletion ui/tests/acceptance/token-test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { find } from '@ember/test-helpers';
import { find, visit } 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 @@ -7,6 +7,7 @@ import Tokens from 'nomad-ui/tests/pages/settings/tokens';
import Jobs from 'nomad-ui/tests/pages/jobs/list';
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
import Layout from 'nomad-ui/tests/pages/layout';

let job;
let node;
Expand Down Expand Up @@ -164,6 +165,23 @@ module('Acceptance | tokens', function(hooks) {
assert.equal(requests.filter(req => req.url === '/v1/namespaces').length, 3);
});

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

await JobDetail.visit({ id: job.id, ott: oneTimeSecret });
await Tokens.visit();

assert.equal(window.localStorage.nomadTokenSecret, secretId, 'Token secret was set');
});

test('when the ott exchange fails an error is shown', async function(assert) {
await visit('/?ott=fake');

assert.ok(Layout.error.isPresent);
assert.equal(Layout.error.title, 'Token Exchange Error');
assert.equal(Layout.error.message, 'Failed to exchange the one-time token.');
});

function getHeader({ requestHeaders }, name) {
// Headers are case-insensitive, but object property look up is not
return (
Expand Down
6 changes: 6 additions & 0 deletions ui/tests/pages/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,10 @@ export default create({
breadcrumbFor(id) {
return this.breadcrumbs.toArray().find(crumb => crumb.id === id);
},

error: {
isPresent: isPresent('[data-test-error]'),
title: text('[data-test-error-title]'),
message: text('[data-test-error-message]'),
},
});