- The management token has all permissions
+ {{#if this.tokenRecord.isExpired}}
+
+
+
+
Your authentication has expired
+
Expired {{moment-from-now this.tokenRecord.expirationTime interval=1000}} ({{this.tokenRecord.expirationTime}})
+
+
+ Sign In Again
- {{else}}
- {{#each this.tokenRecord.policies as |policy|}}
-
-
- {{policy.name}}
-
-
-
- {{#if policy.description}}
- {{policy.description}}
- {{else}}
- No description
- {{/if}}
-
-
{{policy.rules}}
+
+ {{else}}
+ {{#if (eq this.signInStatus "success")}}
+
+
+
+
Token Authenticated!
+
Your token is valid and authorized for the following policies.
- {{/each}}
+
{{/if}}
{{/if}}
+
+ {{#if this.token.tokenNotFound}}
+
+
+
+
Your token was not found
+
It may have expired, or been entered incorrectly.
+
+
+
+ {{/if}}
+
+ {{#if this.SSOFailure}}
+
+
+
+
Failed to sign in with SSO
+
Your OIDC provider has failed on sign in; please try again or contact your SSO administrator.
+
+
+ Clear
+
+
+
+ {{/if}}
-
+
+
+ {{#if this.canSignIn}}
+
+ {{#if this.authMethods.length}}
+
Sign in with SSO
+
Sign in to Nomad using the configured authorization provider. After logging in, the policies and rules for the token will be listed.
+
+ {{#each this.model.authMethods as |method|}}
+ Sign in with with {{method.name}}
+
+ {{/each}}
+
+
Or
+ {{/if}}
+
+
Sign in with token
+
Clusters that use Access Control Lists require tokens to perform certain tasks. By providing a token Secret ID, each future request will be authenticated, potentially authorizing read access to additional information.
+
Secret ID
+
+
+
+
Sent with every request to determine authorization
+
Set Token
+
+ {{/if}}
+
+ {{#if this.shouldShowPolicies}}
+
+ {{#unless this.tokenRecord.isExpired}}
+
+
+
Token: {{this.tokenRecord.name}}
+
AccessorID: {{this.tokenRecord.accessor}}
+
SecretID: {{this.tokenRecord.secret}}
+ {{#if this.tokenRecord.expirationTime}}
+
Expires: {{moment-from-now this.tokenRecord.expirationTime interval=1000}} ({{this.tokenRecord.expirationTime}})
+ {{/if}}
+
+
+ Sign Out
+
+
+
Policies
+ {{#if (eq this.tokenRecord.type "management")}}
+
+
+ The management token has all permissions
+
+
+ {{else}}
+ {{#each this.tokenRecord.policies as |policy|}}
+
+
+ {{policy.name}}
+
+
+
+ {{#if policy.description}}
+ {{policy.description}}
+ {{else}}
+ No description
+ {{/if}}
+
+
{{policy.rules}}
+
+
+ {{/each}}
+ {{/if}}
+ {{/unless}}
+
+ {{/if}}
+
+
+ {{/if}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index eff6200ad..dc844835b 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -443,6 +443,11 @@ export default function () {
return JSON.stringify(findLeader(schema));
});
+ // Note: Mirage-only route, for UI testing and not part of the Nomad API
+ this.get('/acl/tokens', function ({ tokens }, req) {
+ return this.serialize(tokens.all());
+ });
+
this.get('/acl/token/self', function ({ tokens }, req) {
const secret = req.requestHeaders['X-Nomad-Token'];
const tokenForSecret = tokens.findBy({ secretId: secret });
@@ -925,6 +930,34 @@ export default function () {
this.get('/client/allocation/:id/checks', allocationServiceChecksHandler);
//#endregion Services
+
+ //#region SSO
+ this.get('/acl/auth-methods', function (schema, request) {
+ return schema.authMethods.all();
+ });
+ this.post('/acl/oidc/auth-url', (schema, req) => {
+ const {AuthMethod, ClientNonce, RedirectUri, Meta} = JSON.parse(req.requestBody);
+ return new Response(200, {}, {
+ AuthURL: `/ui/oidc-mock?auth_method=${AuthMethod}&client_nonce=${ClientNonce}&redirect_uri=${RedirectUri}&meta=${Meta}`
+ });
+ });
+
+ // Simulate an OIDC callback by assuming the code passed is the secret of an existing token, and return that token.
+ this.post('/acl/oidc/complete-auth', function (schema, req) {
+ const code = JSON.parse(req.requestBody).Code;
+ const token = schema.tokens.findBy({
+ id: code
+ });
+
+ return new Response(200, {}, {
+ ACLToken: token.secretId
+ });
+ }, {timing: 1000});
+
+
+
+
+ //#endregion SSO
}
function filterKeys(object, ...keys) {
diff --git a/ui/mirage/factories/agent.js b/ui/mirage/factories/agent.js
index ad26fc1b0..e2840f428 100644
--- a/ui/mirage/factories/agent.js
+++ b/ui/mirage/factories/agent.js
@@ -16,6 +16,9 @@ export default Factory.extend({
UI: {
Enabled: true,
},
+ ACL: {
+ Enabled: true
+ },
Version: {
Version: '1.1.0',
VersionMetadata: 'ent',
diff --git a/ui/mirage/factories/auth-method.js b/ui/mirage/factories/auth-method.js
new file mode 100644
index 000000000..35b4d712e
--- /dev/null
+++ b/ui/mirage/factories/auth-method.js
@@ -0,0 +1,15 @@
+import { Factory } from 'ember-cli-mirage';
+import faker from 'nomad-ui/mirage/faker';
+import { provide, pickOne } from '../utils';
+
+export default Factory.extend({
+ name: () => pickOne(['vault', 'auth0', 'github', 'cognito', 'okta']),
+ type: () => pickOne(['kubernetes', 'jwt', 'oidc', 'ldap', 'radius']),
+ tokenLocality: () => pickOne(['local', 'global']),
+ maxTokenTTL: () => faker.random.number({ min: 1, max: 1000 }) + 'h',
+ default: () => faker.random.boolean(),
+ createTime: () => faker.date.past(),
+ createIndex: () => faker.random.number(),
+ modifyTime: () => faker.date.past(),
+ modifyIndex: () => faker.random.number(),
+});
diff --git a/ui/mirage/factories/token.js b/ui/mirage/factories/token.js
index 575463c9b..12961a3bb 100644
--- a/ui/mirage/factories/token.js
+++ b/ui/mirage/factories/token.js
@@ -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),
+ });
+ }
},
});
diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js
index a97253769..5243bdc76 100644
--- a/ui/mirage/scenarios/default.js
+++ b/ui/mirage/scenarios/default.js
@@ -176,6 +176,11 @@ function smallCluster(server) {
volume.readAllocs.add(alloc);
volume.save();
});
+
+ server.create('auth-method', {name: 'vault'});
+ server.create('auth-method', {name: 'auth0'});
+ server.create('auth-method', {name: 'cognito'});
+
}
function mediumCluster(server) {
@@ -473,6 +478,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);
}
diff --git a/ui/tests/acceptance/global-header-test.js b/ui/tests/acceptance/global-header-test.js
index 9234324e4..17f843513 100644
--- a/ui/tests/acceptance/global-header-test.js
+++ b/ui/tests/acceptance/global-header-test.js
@@ -1,10 +1,12 @@
/* eslint-disable ember-a11y-testing/a11y-audit-called */
import { module, test } from 'qunit';
-import { visit } from '@ember/test-helpers';
+import { click, visit, currentURL } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import Layout from 'nomad-ui/tests/pages/layout';
+let managementToken;
+
module('Acceptance | global header', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
@@ -46,4 +48,37 @@ module('Acceptance | global header', function (hooks) {
assert.equal(Layout.navbar.end.vaultLink.text, 'Vault');
assert.equal(Layout.navbar.end.vaultLink.link, 'http://localhost:8200/ui');
});
+
+ test('it diplays SignIn', async function (assert) {
+ managementToken = server.create('token');
+
+ window.localStorage.clear();
+
+ await visit('/');
+ assert.true(Layout.navbar.end.signInLink.isVisible);
+ assert.false(Layout.navbar.end.profileDropdown.isVisible);
+ });
+
+ test('it diplays a Profile dropdown', async function (assert) {
+ managementToken = server.create('token');
+
+ window.localStorage.nomadTokenSecret = managementToken.secretId;
+
+ await visit('/');
+ assert.true(Layout.navbar.end.profileDropdown.isVisible);
+ assert.false(Layout.navbar.end.signInLink.isVisible);
+ await Layout.navbar.end.profileDropdown.open();
+
+ await click('.dropdown-options .ember-power-select-option:nth-child(1)');
+ assert.equal(
+ currentURL(),
+ '/settings/tokens',
+ 'Authroization link takes you to the tokens page'
+ );
+
+ await Layout.navbar.end.profileDropdown.open();
+ await click('.dropdown-options .ember-power-select-option:nth-child(2)');
+ assert.equal(window.localStorage.nomadTokenSecret, null, 'Token is wiped');
+ assert.equal(currentURL(), '/jobs', 'After signout, back on the jobs page');
+ });
});
diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js
index e701bb6b1..56344df9a 100644
--- a/ui/tests/acceptance/token-test.js
+++ b/ui/tests/acceptance/token-test.js
@@ -1,5 +1,5 @@
/* eslint-disable qunit/require-expect */
-import { currentURL, find, visit } from '@ember/test-helpers';
+import { currentURL, find, findAll, 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';
@@ -11,6 +11,8 @@ 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';
+import { run } from '@ember/runloop';
let job;
let node;
@@ -48,7 +50,7 @@ module('Acceptance | tokens', function (hooks) {
null,
'No token secret set'
);
- assert.equal(document.title, 'Tokens - Nomad');
+ assert.equal(document.title, 'Authorization - Nomad');
await Tokens.secret(secretId).submit();
assert.equal(
@@ -181,6 +183,150 @@ module('Acceptance | tokens', function (hooks) {
assert.notOk(find('[data-test-job-row]'), 'No jobs found');
});
+ 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(),
+ });
+
+ await Tokens.visit();
+
+ // Token with no TTL
+ 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) {
+ 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 () {
+ 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('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();
+
+ // 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(async () => {
+ await percySnapshot(assert);
+ 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;
@@ -200,6 +346,70 @@ module('Acceptance | tokens', function (hooks) {
);
});
+ test('SSO Sign-in flow: Manager', async function (assert) {
+ server.create('auth-method', { name: 'vault' });
+ server.create('auth-method', { name: 'cognito' });
+ server.create('token', { name: 'Thelonious' });
+
+ await Tokens.visit();
+ assert.dom('[data-test-auth-method]').exists({ count: 2 });
+ await click('button[data-test-auth-method]');
+ assert.ok(currentURL().startsWith('/oidc-mock'));
+ let managerButton = [...findAll('button')].filter((btn) =>
+ btn.textContent.includes('Sign In as Manager')
+ )[0];
+
+ assert.dom(managerButton).exists();
+ await click(managerButton);
+
+ await percySnapshot(assert);
+
+ assert.ok(currentURL().startsWith('/settings/tokens'));
+ assert.dom('[data-test-token-name]').includesText('Token: Manager');
+ });
+
+ test('SSO Sign-in flow: Regular User', async function (assert) {
+ server.create('auth-method', { name: 'vault' });
+ server.create('token', { name: 'Thelonious' });
+
+ await Tokens.visit();
+ assert.dom('[data-test-auth-method]').exists({ count: 1 });
+ await click('button[data-test-auth-method]');
+ assert.ok(currentURL().startsWith('/oidc-mock'));
+ let newTokenButton = [...findAll('button')].filter((btn) =>
+ btn.textContent.includes('Sign In as Thelonious')
+ )[0];
+ assert.dom(newTokenButton).exists();
+ await click(newTokenButton);
+
+ assert.ok(currentURL().startsWith('/settings/tokens'));
+ assert.dom('[data-test-token-name]').includesText('Token: Thelonious');
+ });
+
+ test('It shows an error on failed SSO', async function (assert) {
+ server.create('auth-method', { name: 'vault' });
+ await visit('/settings/tokens?state=failure');
+ assert.ok(Tokens.ssoErrorMessage);
+ await Tokens.clearSSOError();
+ assert.equal(currentURL(), '/settings/tokens', 'State query param cleared');
+ assert.notOk(Tokens.ssoErrorMessage);
+
+ await click('button[data-test-auth-method]');
+ assert.ok(currentURL().startsWith('/oidc-mock'));
+
+ let failureButton = find('.button.error');
+ assert.dom(failureButton).exists();
+ await click(failureButton);
+ assert.equal(
+ currentURL(),
+ '/settings/tokens?state=failure',
+ 'Redirected with failure state'
+ );
+
+ await percySnapshot(assert);
+ assert.ok(Tokens.ssoErrorMessage);
+ });
+
test('when the ott exchange fails an error is shown', async function (assert) {
await visit('/?ott=fake');
diff --git a/ui/tests/pages/layout.js b/ui/tests/pages/layout.js
index 7d5cd60f1..7f19842f7 100644
--- a/ui/tests/pages/layout.js
+++ b/ui/tests/pages/layout.js
@@ -62,6 +62,22 @@ export default create({
text: text(),
link: property('href'),
},
+
+ signInLink: {
+ scope: '[data-test-header-signin-link]',
+ text: text(),
+ link: property('href'),
+ },
+
+ profileDropdown: {
+ scope: '[data-test-header-profile-dropdown]',
+ text: text(),
+ open: clickable(),
+ options: collection('.dropdown-label', {
+ label: text(),
+ choose: clickable(),
+ }),
+ },
},
},
diff --git a/ui/tests/pages/settings/tokens.js b/ui/tests/pages/settings/tokens.js
index 998c7cd1f..03d1d30ba 100644
--- a/ui/tests/pages/settings/tokens.js
+++ b/ui/tests/pages/settings/tokens.js
@@ -18,6 +18,8 @@ export default create({
errorMessage: isVisible('[data-test-token-error]'),
successMessage: isVisible('[data-test-token-success]'),
managementMessage: isVisible('[data-test-token-management-message]'),
+ ssoErrorMessage: isVisible('[data-test-sso-error]'),
+ clearSSOError: clickable('[data-test-sso-error-clear]'),
policies: collection('[data-test-token-policy]', {
name: text('[data-test-policy-name]'),