[ui, epic] SSO and Auth improvements (#15110)
* Top nav auth dropdown (#15055) * Basic dropdown styles * Some cleanup * delog * Default nomad hover state styles * Component separation-of-concerns and acceptance tests for auth dropdown * lintfix * [ui, sso] Handle token expiry 500s (#15073) * 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 * [ui, sso] warn user about pending token expiry (#15091) * 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 * [ui] if ACLs are disabled, remove the Sign In link from the top of the UI (#15114) * Remove top nav link if ACLs disabled * Change to an enabled-by-default model since you get no agent config when ACLs are disabled but you lack a token * PR feedback addressed; down with double negative conditionals * lintfix * ember getter instead of ?.prop * [SSO] Auth Methods and Mock OIDC Flow (#15155) * Big ol first pass at a redirect sign in flow * dont recursively add queryparams on redirect * Passing state and code qps * In which I go off the deep end and embed a faux provider page in the nomad ui * Buggy but self-contained flow * Flow auto-delay added and a little more polish to resetting token * secret passing turned to accessor passing * Handle SSO Failure * General cleanup and test fix * Lintfix * SSO flow acceptance tests * Percy snapshots added * Explicitly note the OIDC test route is mirage only * Handling failure case for complete-auth * Leentfeex * Tokens page styles (#15273) * styling and moving columns around * autofocus and enter press handling * Styles refined * Split up manager and regular tests * Standardizing to a binary status state * Serialize auth-methods response to use "name" as primary key (#15380) * Serializer for unique-by-name * Use @classic because of class extension
This commit is contained in:
parent
752955e876
commit
ffd16dfec6
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui: redirect users to Sign In should their tokens ever come back expired or not-found
|
||||
```
|
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui: give users a notification if their token is going to expire within the next 10 minutes
|
||||
```
|
|
@ -0,0 +1,41 @@
|
|||
// @ts-check
|
||||
import { default as ApplicationAdapter, namespace } from './application';
|
||||
import { dasherize } from '@ember/string';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
export default class AuthMethodAdapter extends ApplicationAdapter {
|
||||
namespace = `${namespace}/acl`;
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
* @returns {string}
|
||||
*/
|
||||
urlForFindAll(modelName) {
|
||||
return dasherize(this.buildURL(modelName));
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ACLOIDCAuthURLParams
|
||||
* @property {string} AuthMethod
|
||||
* @property {string} RedirectUri
|
||||
* @property {string} ClientNonce
|
||||
* @property {Object[]} Meta // NOTE: unsure if array of objects or kv pairs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {ACLOIDCAuthURLParams} params
|
||||
* @returns
|
||||
*/
|
||||
getAuthURL({ AuthMethod, RedirectUri, ClientNonce, Meta }) {
|
||||
const url = `/${this.namespace}/oidc/auth-url`;
|
||||
return this.ajax(url, 'POST', {
|
||||
data: {
|
||||
AuthMethod,
|
||||
RedirectUri,
|
||||
ClientNonce,
|
||||
Meta,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -11,4 +11,15 @@ export default class GlobalHeader extends Component {
|
|||
|
||||
'data-test-global-header' = true;
|
||||
onHamburgerClick() {}
|
||||
|
||||
// Show sign-in if:
|
||||
// - User can't load agent config (meaning ACLs are enabled but they're not signed in)
|
||||
// - User can load agent config in and ACLs are enabled (meaning ACLs are enabled and they're signed in)
|
||||
// The excluded case here is if there is both an agent config and ACLs are disabled
|
||||
get shouldShowProfileNav() {
|
||||
return (
|
||||
!this.system.agent?.get('config') ||
|
||||
this.system.agent?.get('config.ACL.Enabled') === true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
{{#if this.token.selfToken}}
|
||||
<PowerSelect
|
||||
data-test-header-profile-dropdown
|
||||
{{keyboard-shortcut menuLevel=true pattern=(array "g" "p") }}
|
||||
@options={{this.profileOptions}}
|
||||
@onChange={{action (queue
|
||||
(fn (mut this.profileSelection))
|
||||
this.profileSelection.action
|
||||
)}}
|
||||
@dropdownClass="dropdown-options"
|
||||
@matchTriggerWidth={{false}}
|
||||
@selected={{get this.profileSelection "key"}}
|
||||
class="profile-dropdown navbar-item"
|
||||
as |option|>
|
||||
<span class="ember-power-select-prefix">Profile</span>
|
||||
<span class="dropdown-label" data-test-dropdown-option={{option.key}}>{{option.label}}</span>
|
||||
</PowerSelect>
|
||||
{{else}}
|
||||
<LinkTo data-test-header-signin-link @route="settings.tokens" class="navbar-item" {{keyboard-shortcut menuLevel=true pattern=(array "g" "p") }}>
|
||||
Sign In
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
|
||||
{{yield}}
|
|
@ -0,0 +1,36 @@
|
|||
// @ts-check
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class ProfileNavbarItemComponent extends Component {
|
||||
@service token;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
profileOptions = [
|
||||
{
|
||||
label: 'Authorization',
|
||||
key: 'authorization',
|
||||
action: () => {
|
||||
this.router.transitionTo('settings.tokens');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Sign Out',
|
||||
key: 'sign-out',
|
||||
action: () => {
|
||||
this.token.setProperties({
|
||||
secret: undefined,
|
||||
});
|
||||
|
||||
// Clear out all data to ensure only data the anonymous token is privileged to see is shown
|
||||
this.store.unloadAll();
|
||||
this.token.reset();
|
||||
this.router.transitionTo('jobs.index');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
profileSelection = this.profileOptions[0];
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import Ember from 'ember';
|
||||
|
||||
export default class OidcMockController extends Controller {
|
||||
@service router;
|
||||
|
||||
queryParams = ['auth_method', 'client_nonce', 'redirect_uri', 'meta'];
|
||||
|
||||
@action
|
||||
signIn(fakeAccount) {
|
||||
const url = `${this.redirect_uri.split('?')[0]}?code=${
|
||||
fakeAccount.accessor
|
||||
}&state=success`;
|
||||
if (Ember.testing) {
|
||||
this.router.transitionTo(url);
|
||||
} else {
|
||||
window.location = url;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
failToSignIn() {
|
||||
const url = `${this.redirect_uri.split('?')[0]}?state=failure`;
|
||||
if (Ember.testing) {
|
||||
this.router.transitionTo(url);
|
||||
} else {
|
||||
window.location = url;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
// @ts-check
|
||||
import { inject as service } from '@ember/service';
|
||||
import { reads } from '@ember/object/computed';
|
||||
import Controller from '@ember/controller';
|
||||
|
@ -5,16 +6,25 @@ import { getOwner } from '@ember/application';
|
|||
import { alias } from '@ember/object/computed';
|
||||
import { action } from '@ember/object';
|
||||
import classic from 'ember-classic-decorator';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import Ember from 'ember';
|
||||
|
||||
@classic
|
||||
export default class Tokens extends Controller {
|
||||
@service token;
|
||||
@service store;
|
||||
@service router;
|
||||
|
||||
queryParams = ['code', 'state'];
|
||||
|
||||
@reads('token.secret') secret;
|
||||
|
||||
tokenIsValid = false;
|
||||
tokenIsInvalid = false;
|
||||
/**
|
||||
* @type {(null | "success" | "failure")} signInStatus
|
||||
*/
|
||||
@tracked
|
||||
signInStatus = null;
|
||||
|
||||
@alias('token.selfToken') tokenRecord;
|
||||
|
||||
resetStore() {
|
||||
|
@ -25,22 +35,27 @@ export default class Tokens extends Controller {
|
|||
clearTokenProperties() {
|
||||
this.token.setProperties({
|
||||
secret: undefined,
|
||||
tokenNotFound: false,
|
||||
});
|
||||
this.setProperties({
|
||||
tokenIsValid: false,
|
||||
tokenIsInvalid: false,
|
||||
});
|
||||
this.signInStatus = null;
|
||||
// Clear out all data to ensure only data the anonymous token is privileged to see is shown
|
||||
this.resetStore();
|
||||
this.token.reset();
|
||||
this.store.findAll('auth-method');
|
||||
}
|
||||
|
||||
get authMethods() {
|
||||
return this.store.peekAll('auth-method');
|
||||
}
|
||||
|
||||
@action
|
||||
verifyToken() {
|
||||
const { secret } = this;
|
||||
this.clearTokenProperties();
|
||||
const TokenAdapter = getOwner(this).lookup('adapter:token');
|
||||
|
||||
this.set('token.secret', secret);
|
||||
this.set('secret', null);
|
||||
|
||||
TokenAdapter.findSelf().then(
|
||||
() => {
|
||||
|
@ -50,18 +65,95 @@ export default class Tokens extends Controller {
|
|||
// Refetch the token and associated policies
|
||||
this.get('token.fetchSelfTokenAndPolicies').perform().catch();
|
||||
|
||||
this.setProperties({
|
||||
tokenIsValid: true,
|
||||
tokenIsInvalid: false,
|
||||
});
|
||||
this.signInStatus = 'success';
|
||||
this.token.set('tokenNotFound', false);
|
||||
},
|
||||
() => {
|
||||
this.set('token.secret', undefined);
|
||||
this.setProperties({
|
||||
tokenIsValid: false,
|
||||
tokenIsInvalid: true,
|
||||
});
|
||||
this.signInStatus = 'failure';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a 20-char nonce, using window.crypto to
|
||||
// create a sufficiently-large output then trimming
|
||||
generateNonce() {
|
||||
let randomArray = new Uint32Array(10);
|
||||
crypto.getRandomValues(randomArray);
|
||||
return randomArray.join('').slice(0, 20);
|
||||
}
|
||||
|
||||
@action redirectToSSO(method) {
|
||||
const provider = method.name;
|
||||
const nonce = this.generateNonce();
|
||||
|
||||
window.localStorage.setItem('nomadOIDCNonce', nonce);
|
||||
window.localStorage.setItem('nomadOIDCAuthMethod', provider);
|
||||
|
||||
method
|
||||
.getAuthURL({
|
||||
AuthMethod: provider,
|
||||
ClientNonce: nonce,
|
||||
RedirectUri: Ember.testing
|
||||
? this.router.currentURL
|
||||
: window.location.toString(),
|
||||
})
|
||||
.then(({ AuthURL }) => {
|
||||
if (Ember.testing) {
|
||||
this.router.transitionTo(AuthURL.split('/ui')[1]);
|
||||
} else {
|
||||
window.location = AuthURL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@tracked code = null;
|
||||
@tracked state = null;
|
||||
|
||||
get isValidatingToken() {
|
||||
if (this.code && this.state === 'success') {
|
||||
this.validateSSO();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async validateSSO() {
|
||||
const res = await this.token.authorizedRequest(
|
||||
'/v1/acl/oidc/complete-auth',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
AuthMethod: window.localStorage.getItem('nomadOIDCAuthMethod'),
|
||||
ClientNonce: window.localStorage.getItem('nomadOIDCNonce'),
|
||||
Code: this.code,
|
||||
State: this.state,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.token.set('secret', data.ACLToken);
|
||||
this.verifyToken();
|
||||
this.state = null;
|
||||
this.code = null;
|
||||
} else {
|
||||
this.state = 'failure';
|
||||
this.code = null;
|
||||
}
|
||||
}
|
||||
|
||||
get SSOFailure() {
|
||||
return this.state === 'failure';
|
||||
}
|
||||
|
||||
get canSignIn() {
|
||||
return !this.tokenRecord || this.tokenRecord.isExpired;
|
||||
}
|
||||
|
||||
get shouldShowPolicies() {
|
||||
return this.tokenRecord;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
// @ts-check
|
||||
import Model from '@ember-data/model';
|
||||
import { attr } from '@ember-data/model';
|
||||
|
||||
export default class AuthMethodModel extends Model {
|
||||
@attr('string') name;
|
||||
@attr('string') type;
|
||||
@attr('string') tokenLocality;
|
||||
@attr('string') maxTokenTTL;
|
||||
@attr('boolean') default;
|
||||
@attr('date') createTime;
|
||||
@attr('number') createIndex;
|
||||
@attr('date') modifyTime;
|
||||
@attr('number') modifyIndex;
|
||||
|
||||
getAuthURL(params) {
|
||||
return this.store.adapterFor('authMethod').getAuthURL(params);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -98,4 +98,8 @@ Router.map(function () {
|
|||
path: '/path/*absolutePath',
|
||||
});
|
||||
});
|
||||
// Mirage-only route for testing OIDC flow
|
||||
if (config['ember-cli-mirage']) {
|
||||
this.route('oidc-mock');
|
||||
}
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ export default class ApplicationRoute extends Route {
|
|||
@service system;
|
||||
@service store;
|
||||
@service token;
|
||||
@service router;
|
||||
|
||||
queryParams = {
|
||||
region: {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class OidcMockRoute extends Route {
|
||||
// This route only exists for testing SSO/OIDC flow in development, backed by our mirage server.
|
||||
// This route won't load outside of a mirage environment, nor will the model hook here return anything meaningful.
|
||||
model() {
|
||||
return this.store.findAll('token');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
// @ts-check
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class SettingsTokensRoute extends Route {
|
||||
@service store;
|
||||
model() {
|
||||
return {
|
||||
authMethods: this.store.findAll('auth-method'),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import ApplicationSerializer from './application';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
export default class AuthMethodSerializer extends ApplicationSerializer {
|
||||
primaryKey = 'Name';
|
||||
}
|
|
@ -77,7 +77,7 @@ export default class KeyboardService extends Service {
|
|||
'Go to Clients': ['g', 'c'],
|
||||
'Go to Topology': ['g', 't'],
|
||||
'Go to Evaluations': ['g', 'e'],
|
||||
'Go to ACL Tokens': ['g', 'a'],
|
||||
'Go to Profile': ['g', 'p'],
|
||||
'Next Subnav': ['Shift+ArrowRight'],
|
||||
'Previous Subnav': ['Shift+ArrowLeft'],
|
||||
'Previous Main Section': ['Shift+ArrowUp'],
|
||||
|
@ -126,7 +126,7 @@ export default class KeyboardService extends Service {
|
|||
rebindable: true,
|
||||
},
|
||||
{
|
||||
label: 'Go to ACL Tokens',
|
||||
label: 'Go to Profile',
|
||||
action: () => this.router.transitionTo('settings.tokens'),
|
||||
rebindable: true,
|
||||
},
|
||||
|
|
|
@ -3,18 +3,25 @@ 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;
|
||||
|
||||
tokenNotFound = false;
|
||||
|
||||
@computed
|
||||
get secret() {
|
||||
return window.localStorage.nomadTokenSecret;
|
||||
|
@ -39,6 +46,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;
|
||||
}
|
||||
})
|
||||
|
@ -71,6 +81,7 @@ export default class TokenService extends Service {
|
|||
|
||||
@task(function* () {
|
||||
yield this.fetchSelfToken.perform();
|
||||
this.kickoffTokenTTLMonitoring();
|
||||
if (this.aclEnabled) {
|
||||
yield this.fetchSelfTokenPolicies.perform();
|
||||
}
|
||||
|
@ -109,7 +120,69 @@ 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 });
|
||||
window.localStorage.removeItem('nomadOIDCNonce');
|
||||
window.localStorage.removeItem('nomadOIDCAuthMethod');
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -50,3 +50,4 @@
|
|||
@import './components/keyboard-shortcuts-modal';
|
||||
@import './components/services';
|
||||
@import './components/task-sub-row';
|
||||
@import './components/authorization';
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
.authorization-page {
|
||||
|
||||
.sign-in-methods {
|
||||
h3, p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.sso-auth-methods {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.status-notifications {
|
||||
&.is-half {
|
||||
width: 50%;
|
||||
}
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.or-divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
height: 2rem;
|
||||
|
||||
&:before {
|
||||
border-bottom: 1px solid $ui-gray-200;
|
||||
position: relative;
|
||||
top: 50%;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: $ui-gray-700;
|
||||
background-color: white;
|
||||
padding: 0 1rem;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
align-content: center;
|
||||
display: inline-grid;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -88,3 +88,35 @@
|
|||
font-weight: $weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.mock-sso-provider {
|
||||
margin: 25vh auto;
|
||||
width: 500px;
|
||||
top: 25vh;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
box-shadow: 0 0 0 100vw rgba(0, 2, 30, 0.8);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
background-color: white;
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.providers {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
button {
|
||||
background-color: #444;
|
||||
color: white;
|
||||
&.error {
|
||||
background-color: darkred;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,7 +3,11 @@
|
|||
align-items: center;
|
||||
|
||||
&.is-primary {
|
||||
background: linear-gradient(to right, $nomad-green-darker, $nomad-green-dark);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
$nomad-green-darker,
|
||||
$nomad-green-dark
|
||||
);
|
||||
height: 3.5rem;
|
||||
color: $primary-invert;
|
||||
padding-left: 20px;
|
||||
|
@ -147,4 +151,27 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-dropdown {
|
||||
padding: 0.5rem 1rem 0.5rem 0.75rem;
|
||||
background-color: transparent;
|
||||
border: none !important;
|
||||
height: auto;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:focus {
|
||||
background-color: #21a572;
|
||||
}
|
||||
|
||||
.ember-power-select-prefix {
|
||||
color: rgba($primary-invert, 0.8);
|
||||
}
|
||||
.ember-power-select-selected-item {
|
||||
margin-left: 0;
|
||||
border: none;
|
||||
}
|
||||
.ember-power-select-status-icon {
|
||||
border-top-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
$bonusRightPadding: 20px;
|
||||
|
||||
section.notifications {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
|
@ -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%);
|
||||
|
@ -54,5 +56,10 @@ section.notifications {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-action-button {
|
||||
width: calc(100% + $bonusRightPadding - 1rem);
|
||||
margin: 1.5rem 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)}}>×</span>
|
||||
<span class="close-button" role="button" {{on "click"
|
||||
(queue
|
||||
(action close)
|
||||
(action (optional flash.customCloseAction))
|
||||
)
|
||||
}}>×</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>
|
||||
|
|
|
@ -60,9 +60,9 @@
|
|||
>
|
||||
Documentation
|
||||
</a>
|
||||
<LinkTo @route="settings.tokens" class="navbar-item" {{keyboard-shortcut menuLevel=true pattern=(array "g" "a") }}>
|
||||
ACL Tokens
|
||||
</LinkTo>
|
||||
{{#if this.shouldShowProfileNav}}
|
||||
<ProfileNavbarItem />
|
||||
{{/if}}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="navbar is-secondary">
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
{{page-title "Mock OIDC Test Page"}}
|
||||
|
||||
<section class="mock-sso-provider">
|
||||
<h1>OIDC Test route: {{this.auth_method}}</h1>
|
||||
<h2>(Mirage only)</h2>
|
||||
<div class="providers">
|
||||
{{#each this.model as |fakeAccount|}}
|
||||
<button type="button" class="button" {{on "click" (fn this.signIn fakeAccount)}}>
|
||||
Sign In as {{fakeAccount.name}}
|
||||
</button>
|
||||
{{/each}}
|
||||
<button type="button" class="button error" {{on "click" this.failToSignIn}}>
|
||||
Simulate Failure
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{{outlet}}
|
|
@ -1,98 +1,161 @@
|
|||
{{page-title "Tokens"}}
|
||||
<section class="section">
|
||||
<h1 class="title">Access Control Tokens</h1>
|
||||
<div class="columns">
|
||||
<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>
|
||||
{{page-title "Authorization"}}
|
||||
<section class="section authorization-page">
|
||||
{{#if this.isValidatingToken}}
|
||||
<LoadingSpinner />
|
||||
{{else}}
|
||||
<h1 class="title">Authorization and access control</h1>
|
||||
|
||||
<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>
|
||||
</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 class="status-notifications {{if this.canSignIn "is-half"}}">
|
||||
|
||||
{{#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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.tokenIsInvalid}}
|
||||
{{#if (eq this.signInStatus "failure")}}
|
||||
<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>
|
||||
<p>The token secret you have provided does not match an existing token, or has expired.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/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
|
||||
{{#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 authentication 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">Sign In Again</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if (eq this.signInStatus "success")}}
|
||||
<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>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#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>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.SSOFailure}}
|
||||
<div data-test-sso-error class="notification is-danger column">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h3 class="title is-4">Failed to sign in with SSO</h3>
|
||||
<p>Your OIDC provider has failed on sign in; please try again or contact your SSO administrator.</p>
|
||||
</div>
|
||||
<div class="column is-centered is-minimum">
|
||||
<button data-test-sso-error-clear class="button" {{action (mut this.state)}} type="button">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
{{#if this.canSignIn}}
|
||||
<div class="column is-half sign-in-methods">
|
||||
{{#if this.authMethods.length}}
|
||||
<h3 class="title is-4">Sign in with SSO</h3>
|
||||
<p>Sign in to Nomad using the configured authorization provider. After logging in, the policies and rules for the token will be listed.</p>
|
||||
<div class="sso-auth-methods">
|
||||
{{#each this.model.authMethods as |method|}}
|
||||
<button
|
||||
data-test-auth-method
|
||||
class="button is-primary"
|
||||
onclick={{action "redirectToSSO" method}}
|
||||
type="button"
|
||||
>Sign in with with {{method.name}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
<span class="or-divider"><span>Or</span></span>
|
||||
{{/if}}
|
||||
|
||||
<h3 class="title is-4">Sign in with token</h3>
|
||||
<p>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.</p>
|
||||
<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 😵 --}}
|
||||
{{autofocus}}
|
||||
{{on "input" (action (mut this.secret) value="target.value")}}
|
||||
@enter={{this.verifyToken}}
|
||||
data-test-token-secret />
|
||||
</div>
|
||||
<p class="help">Sent with every request to determine authorization</p>
|
||||
<button disabled={{not this.secret}} data-test-token-submit class="button is-primary" {{action "verifyToken"}} type="button">Set Token</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.shouldShowPolicies}}
|
||||
<div class="column">
|
||||
{{#unless this.tokenRecord.isExpired}}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h3 data-test-token-name class="title is-4">Token: {{this.tokenRecord.name}}</h3>
|
||||
<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>
|
||||
<div class="column is-minimum">
|
||||
<button data-test-token-clear class="button is-primary" {{action "clearTokenProperties"}} type="button">Sign Out</button>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{/if}}
|
||||
</section>
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -16,6 +16,9 @@ export default Factory.extend({
|
|||
UI: {
|
||||
Enabled: true,
|
||||
},
|
||||
ACL: {
|
||||
Enabled: true
|
||||
},
|
||||
Version: {
|
||||
Version: '1.1.0',
|
||||
VersionMetadata: 'ent',
|
||||
|
|
|
@ -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(),
|
||||
});
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -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]'),
|
||||
|
|
Loading…
Reference in New Issue