[ui] Web sign-in with JWT (#16625)

* Bones of JWT detection

* JWT to token pipeline complete

* Some live-demo fixes for template language

* findSelf and loginJWT funcs made async

* Acceptance tests and mirage mocks for JWT login

* [ui] Allow for multiple JWT auth methods in the UI (#16665)

* Split selectable jwt methods

* repositions the dropdown to be next to the input field
This commit is contained in:
Phil Renaud
2023-03-29 15:06:25 -04:00
committed by Piotr Kazmierczak
parent 58c4374e7c
commit f4e00566fa
9 changed files with 367 additions and 48 deletions

View File

@@ -21,15 +21,23 @@ export default class TokenAdapter extends ApplicationAdapter {
return `${this.buildURL()}/${singularize(modelName)}/${identifier}`;
}
findSelf() {
return this.ajax(`${this.buildURL()}/token/self`, 'GET').then((token) => {
const store = this.store;
store.pushPayload('token', {
tokens: [token],
});
async findSelf() {
const response = await this.ajax(`${this.buildURL()}/token/self`, 'GET');
const normalized = this.store.normalize('token', response);
const tokenRecord = this.store.push(normalized);
return tokenRecord;
}
return store.peekRecord('token', store.normalize('token', token).data.id);
async loginJWT(LoginToken, AuthMethodName) {
const response = await this.ajax(`${this.buildURL()}/login`, 'POST', {
data: {
AuthMethodName,
LoginToken,
},
});
const normalized = this.store.normalize('token', response);
const tokenRecord = this.store.push(normalized);
return tokenRecord;
}
exchangeOneTimeToken(oneTimeToken) {

View File

@@ -1,6 +1,5 @@
// @ts-check
import { inject as service } from '@ember/service';
import { reads } from '@ember/object/computed';
import Controller from '@ember/controller';
import { getOwner } from '@ember/application';
import { alias } from '@ember/object/computed';
@@ -9,18 +8,24 @@ import classic from 'ember-classic-decorator';
import { tracked } from '@glimmer/tracking';
import Ember from 'ember';
/**
* @type {RegExp}
*/
const JWT_MATCH_EXPRESSION = /^[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+$/;
@classic
export default class Tokens extends Controller {
@service token;
@service store;
@service router;
@service notifications;
queryParams = ['code', 'state'];
queryParams = ['code', 'state', 'jwtAuthMethod'];
@reads('token.secret') secret;
@tracked secret = this.token.secret;
/**
* @type {(null | "success" | "failure")} signInStatus
* @type {(null | "success" | "failure" | "jwtFailure")} signInStatus
*/
@tracked
signInStatus = null;
@@ -44,35 +49,126 @@ export default class Tokens extends Controller {
this.store.findAll('auth-method');
}
/**
* @returns {import('@ember/array/mutable').default<import('../../models/auth-method').default>}
*/
get authMethods() {
return this.store.peekAll('auth-method');
return this.model?.authMethods || [];
}
get hasJWTAuthMethods() {
return this.authMethods.any((method) => method.type === 'JWT');
}
get nonTokenAuthMethods() {
return this.authMethods.rejectBy('type', 'JWT');
}
get JWTAuthMethods() {
return this.authMethods.filterBy('type', 'JWT');
}
get JWTAuthMethodOptions() {
return this.JWTAuthMethods.map((method) => ({
key: method.name,
label: method.name,
}));
}
get defaultJWTAuthMethod() {
return (
this.JWTAuthMethods.findBy('default', true) || this.JWTAuthMethods[0]
);
}
@action
verifyToken() {
setCurrentAuthMethod() {
if (!this.jwtAuthMethod) {
this.jwtAuthMethod = this.defaultJWTAuthMethod?.name;
}
}
/**
* @type {string}
*/
@tracked jwtAuthMethod;
/**
* @type {boolean}
*/
get currentSecretIsJWT() {
return this.secret?.length > 36 && this.secret.match(JWT_MATCH_EXPRESSION);
}
@action
async verifyToken() {
const { secret } = this;
this.clearTokenProperties();
/**
* @type {import('../../adapters/token').default}
*/
// Ember currently lacks types for getOwner: https://github.com/emberjs/ember.js/issues/19916
// @ts-ignore
const TokenAdapter = getOwner(this).lookup('adapter:token');
this.set('token.secret', secret);
this.set('secret', null);
const isJWT = secret.length > 36 && secret.match(JWT_MATCH_EXPRESSION);
TokenAdapter.findSelf().then(
() => {
// Clear out all data to ensure only data the new token is privileged to see is shown
this.resetStore();
if (isJWT) {
const methodName = this.jwtAuthMethod;
// Refetch the token and associated policies
this.get('token.fetchSelfTokenAndPolicies').perform().catch();
this.signInStatus = 'success';
this.token.set('tokenNotFound', false);
},
() => {
this.set('token.secret', undefined);
this.signInStatus = 'failure';
// If user passes a JWT token, but there is no JWT auth method, throw an error
if (!methodName) {
this.token.set('secret', undefined);
this.signInStatus = 'jwtFailure';
return;
}
);
this.clearTokenProperties();
// Set bearer token instead of findSelf etc.
TokenAdapter.loginJWT(secret, methodName).then(
(token) => {
this.token.setProperties({
secret: token.secret,
tokenNotFound: false,
});
this.set('secret', null);
// Clear out all data to ensure only data the new token is privileged to see is shown
this.resetStore();
// Refetch the token and associated policies
this.token.get('fetchSelfTokenAndPolicies').perform().catch();
this.signInStatus = 'success';
},
() => {
this.token.set('secret', undefined);
this.signInStatus = 'failure';
}
);
} else {
this.clearTokenProperties();
this.token.set('secret', secret);
this.set('secret', null);
TokenAdapter.findSelf().then(
() => {
// Clear out all data to ensure only data the new token is privileged to see is shown
this.resetStore();
// Refetch the token and associated policies
this.token.get('fetchSelfTokenAndPolicies').perform().catch();
this.signInStatus = 'success';
this.token.set('tokenNotFound', false);
},
() => {
this.token.set('secret', undefined);
this.signInStatus = 'failure';
}
);
}
}
// Generate a 20-char nonce, using window.crypto to
@@ -158,7 +254,7 @@ export default class Tokens extends Controller {
this.code = null;
// Refetch the token and associated policies
this.get('token.fetchSelfTokenAndPolicies').perform().catch();
this.token.get('fetchSelfTokenAndPolicies').perform().catch();
this.signInStatus = 'success';
this.token.set('tokenNotFound', false);

View File

@@ -4,6 +4,10 @@ import { attr } from '@ember-data/model';
export default class AuthMethodModel extends Model {
@attr('string') name;
/**
* @type {'JWT' | 'OIDC'}
*/
@attr('string') type;
@attr('string') tokenLocality;
@attr('string') maxTokenTTL;

View File

@@ -1,7 +1,7 @@
.authorization-page {
.sign-in-methods {
h3, p {
h3,
p {
margin-bottom: 1.5rem;
}
@@ -30,7 +30,7 @@
border-bottom: 1px solid $ui-gray-200;
position: relative;
top: 50%;
content: "";
content: '';
display: block;
width: 100%;
height: 0px;
@@ -47,4 +47,9 @@
display: inline-grid;
}
}
}
.control.with-jwt-selector {
display: flex;
gap: 0.5rem;
}
}

View File

@@ -18,6 +18,17 @@
</div>
{{/if}}
{{#if (eq this.signInStatus "jwtFailure")}}
<div data-test-token-error class="notification is-danger">
<div class="columns">
<div class="column">
<h3 class="title is-4">JWT Failed to Authenticate</h3>
<p>You passed in a JWT, but no JWT auth methods were found</p>
</div>
</div>
</div>
{{/if}}
{{#if this.tokenRecord.isExpired}}
<div data-test-token-expired class="notification is-danger">
<div class="columns">
@@ -72,11 +83,11 @@
<div class="columns">
{{#if this.canSignIn}}
<div class="column is-half sign-in-methods">
{{#if this.authMethods.length}}
{{#if this.nonTokenAuthMethods.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|}}
{{#each this.nonTokenAuthMethods as |method|}}
<button
data-test-auth-method
class="button is-primary"
@@ -90,22 +101,40 @@
{{/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">
<p>Clusters that use Access Control Lists require tokens to perform certain tasks. By providing a token Secret ID{{#if this.hasJWTAuthMethods}} or <a href="https://jwt.io/" target="_blank" rel="noopener noreferrer">JWT</a>{{/if}}, each future request will be authenticated, potentially authorizing read access to additional information.</p>
<label class="label" for="token-input">Secret ID{{#if this.hasJWTAuthMethods}} or JWT{{/if}}</label>
<div class="control {{if (and this.currentSecretIsJWT (gt this.JWTAuthMethods.length 1)) "with-jwt-selector"}}">
<Input
id="token-input"
class="input"
@type="text"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
{{!-- FIXME this placeholder gets read out by VoiceOver sans dashes 😵 --}}
placeholder="{{if this.hasJWTAuthMethods "36-character token secret or JWT" "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"}}"
{{autofocus}}
{{on "input" (action (mut this.secret) value="target.value")}}
@enter={{this.verifyToken}}
data-test-token-secret />
{{#if this.currentSecretIsJWT}}
{{did-insert (action this.setCurrentAuthMethod)}}
{{#if (gt this.JWTAuthMethods.length 1)}}
<SingleSelectDropdown
data-test-select-jwt
@label="Sign-in method"
@options={{this.JWTAuthMethodOptions}}
@selection={{this.jwtAuthMethod}}
@onSelect={{fn (mut this.jwtAuthMethod)}}
/>
{{/if}}
{{/if}}
</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>
<button disabled={{not this.secret}} data-test-token-submit class="button is-primary" {{action "verifyToken"}} type="button">
{{#if this.currentSecretIsJWT}}
Sign in with JWT
{{else}}
Sign in with secret
{{/if}}
</button>
</div>
{{/if}}

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"target": "es5",
},
"compilerOptions": {
"experimentalDecorators": true,
"target": "es2015",
"moduleResolution": "node"
}
}

View File

@@ -477,6 +477,23 @@ export default function () {
return new Response(400, {}, null);
});
this.post('/acl/login', function (schema, { requestBody }) {
const { LoginToken } = JSON.parse(requestBody);
const tokenType = LoginToken.endsWith('management')
? 'management'
: 'client';
const isBad = LoginToken.endsWith('bad');
if (isBad) {
return new Response(403, {}, null);
} else {
const token = schema.tokens
.all()
.models.find((token) => token.type === tokenType);
return this.serialize(token);
}
});
this.get('/acl/token/:id', function ({ tokens }, req) {
const token = tokens.find(req.params.id);
const secret = req.requestHeaders['X-Nomad-Token'];

View File

@@ -238,6 +238,7 @@ function smallCluster(server) {
server.create('auth-method', { name: 'vault' });
server.create('auth-method', { name: 'auth0' });
server.create('auth-method', { name: 'cognito' });
server.create('auth-method', { name: 'JWT-Local', type: 'JWT' });
}
function mediumCluster(server) {
@@ -570,6 +571,10 @@ Secret: ${token.secretId}
Accessor: ${token.accessorId}
`);
console.log(
'Alternatively, log in with a JWT. If it ends with `management`, you have full access. If it ends with `bad`, you`ll get an error. Otherwise, you`ll get a token with limited access.'
);
});
}

View File

@@ -14,6 +14,10 @@ import faker from 'nomad-ui/mirage/faker';
import moment from 'moment';
import { run } from '@ember/runloop';
import { allScenarios } from '../../mirage/scenarios/default';
import {
selectChoose,
clickTrigger,
} from 'ember-power-select/test-support/helpers';
let job;
let node;
@@ -414,6 +418,157 @@ module('Acceptance | tokens', function (hooks) {
assert.ok(Tokens.ssoErrorMessage);
});
test('JWT Sign-in flow: OIDC methods only', async function (assert) {
server.create('auth-method', { name: 'Vault', type: 'OIDC' });
server.create('auth-method', { name: 'Auth0', type: 'OIDC' });
await Tokens.visit();
assert
.dom('[data-test-auth-method]')
.exists({ count: 2 }, 'Both OIDC methods shown');
assert
.dom('label[for="token-input"]')
.hasText(
'Secret ID',
'Secret ID input shown without JWT info when no such method exists'
);
});
test('JWT Sign-in flow: JWT method', async function (assert) {
server.create('auth-method', { name: 'Vault', type: 'OIDC' });
server.create('auth-method', { name: 'Auth0', type: 'OIDC' });
server.create('auth-method', { name: 'JWT-Local', type: 'JWT' });
await Tokens.visit();
assert
.dom('[data-test-auth-method]')
.exists(
{ count: 2 },
'The newly added JWT method does not add a 3rd Auth Method button'
);
assert
.dom('label[for="token-input"]')
.hasText('Secret ID or JWT', 'Secret ID input now shows JWT info');
// Expect to be signed in as a manager
await Tokens.secret(
'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.management'
).submit();
assert.ok(currentURL().startsWith('/settings/tokens'));
assert.dom('[data-test-token-name]').includesText('Token: Manager');
await Tokens.clear();
// Expect to be signed in as a client
await Tokens.secret(
'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.whateverlol'
).submit();
assert.ok(currentURL().startsWith('/settings/tokens'));
assert.dom('[data-test-token-name]').includesText(
`Token: ${
server.db.tokens.filter((token) => {
return token.type === 'client';
})[0].name
}`
);
await Tokens.clear();
// Expect to an error on bad JWT
await Tokens.secret(
'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bad'
).submit();
assert.ok(currentURL().startsWith('/settings/tokens'));
assert.dom('[data-test-token-error]').exists();
});
test('JWT Sign-in flow: JWT Method Selector, Single JWT', async function (assert) {
server.create('auth-method', { name: 'Vault', type: 'OIDC' });
server.create('auth-method', { name: 'Auth0', type: 'OIDC' });
server.create('auth-method', { name: 'JWT-Local', type: 'JWT' });
await Tokens.visit();
assert
.dom('[data-test-token-submit]')
.exists(
{ count: 1 },
'Submit token/JWT button exists with only a single JWT '
);
assert
.dom('[data-test-token-submit]')
.hasText(
'Sign in with secret',
'Submit token/JWT button has correct text with only a single JWT '
);
await Tokens.secret('very-short-secret');
assert
.dom('[data-test-token-submit]')
.hasText(
'Sign in with secret',
'A short secret still shows the "secret" verbiage on the button'
);
await Tokens.secret(
'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.whateverlol'
);
assert
.dom('[data-test-token-submit]')
.hasText(
'Sign in with JWT',
'A JWT-shaped secret will change button text to reflect JWT sign-in'
);
assert
.dom('[data-test-select-jwt]')
.doesNotExist('No JWT selector shown with only a single method');
});
test('JWT Sign-in flow: JWT Method Selector, Multiple JWT', async function (assert) {
server.create('auth-method', { name: 'Vault', type: 'OIDC' });
server.create('auth-method', { name: 'Auth0', type: 'OIDC' });
server.create('auth-method', {
name: 'JWT-Local',
type: 'JWT',
default: false,
});
server.create('auth-method', {
name: 'JWT-Regional',
type: 'JWT',
default: false,
});
server.create('auth-method', {
name: 'JWT-Global',
type: 'JWT',
default: true,
});
await Tokens.visit();
assert
.dom('[data-test-token-submit]')
.exists(
{ count: 1 },
'Submit token/JWT button exists with only a single JWT '
);
assert
.dom('[data-test-select-jwt]')
.doesNotExist('No JWT selector shown with an empty token/secret');
await Tokens.secret(
'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.whateverlol'
);
assert
.dom('[data-test-select-jwt]')
.exists({ count: 1 }, 'JWT selector shown with multiple JWT methods');
assert.equal(
currentURL(),
'/settings/tokens?jwtAuthMethod=JWT-Global',
'Default JWT method is selected'
);
await clickTrigger('[data-test-select-jwt]');
assert.dom('.dropdown-options').exists('Dropdown options are shown');
await selectChoose('[data-test-select-jwt]', 'JWT-Regional');
console.log(currentURL());
assert.equal(
currentURL(),
'/settings/tokens?jwtAuthMethod=JWT-Regional',
'Selected JWT method is shown'
);
});
test('when the ott exchange fails an error is shown', async function (assert) {
await visit('/?ott=fake');