[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:
Phil Renaud 2022-11-28 10:44:52 -05:00 committed by GitHub
parent 752955e876
commit ffd16dfec6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1020 additions and 105 deletions

3
.changelog/15073.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: redirect users to Sign In should their tokens ever come back expired or not-found
```

3
.changelog/15091.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: give users a notification if their token is going to expire within the next 10 minutes
```

View File

@ -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,
},
});
}
}

View File

@ -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
);
}
}

View File

@ -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}}

View File

@ -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];
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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');
}
});

View File

@ -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)) {
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);
}
}
}
}

View File

@ -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');
}
}

View File

@ -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'),
};
}
}

View File

@ -0,0 +1,7 @@
import ApplicationSerializer from './application';
import classic from 'ember-classic-decorator';
@classic
export default class AuthMethodSerializer extends ApplicationSerializer {
primaryKey = 'Name';
}

View File

@ -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,
},

View File

@ -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) {

View File

@ -50,3 +50,4 @@
@import './components/keyboard-shortcuts-modal';
@import './components/services';
@import './components/task-sub-row';
@import './components/authorization';

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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">

View File

@ -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}}

View File

@ -1,43 +1,37 @@
{{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="status-notifications {{if this.canSignIn "is-half"}}">
{{#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 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>
<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>
{{/if}}
{{#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 is-info" {{action "clearTokenProperties"}} type="button">Clear Token</button>
<button data-test-token-clear class="button" {{action "clearTokenProperties"}} type="button">Sign In Again</button>
</div>
</div>
</div>
{{#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}}
{{else}}
{{#if (eq this.signInStatus "success")}}
<div data-test-token-success class="notification is-success">
<div class="columns">
<div class="column">
@ -47,23 +41,89 @@
</div>
</div>
{{/if}}
{{/if}}
{{#if this.tokenIsInvalid}}
<div data-test-token-error class="notification is-danger">
{{#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">Token Failed to Authenticate</h3>
<p>The token secret you have provided does not match an existing token.</p>
<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.tokenRecord}}
<h3 class="title is-4">Token: {{this.tokenRecord.name}}</h3>
<div class="content">
{{#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 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")}}
@ -91,8 +151,11 @@
</div>
{{/each}}
{{/if}}
{{/unless}}
</div>
{{/if}}
</div>
</div>
{{/if}}
</section>

View File

@ -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) {

View File

@ -16,6 +16,9 @@ export default Factory.extend({
UI: {
Enabled: true,
},
ACL: {
Enabled: true
},
Version: {
Version: '1.1.0',
VersionMetadata: 'ent',

View File

@ -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(),
});

View File

@ -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),
});
}
},
});

View File

@ -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);
}

View File

@ -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');
});
});

View File

@ -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 its 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');

View File

@ -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(),
}),
},
},
},

View File

@ -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]'),