mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 16:05:42 +03:00
[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:
committed by
Piotr Kazmierczak
parent
58c4374e7c
commit
f4e00566fa
@@ -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) {
|
||||
|
||||
@@ -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,17 +49,107 @@ 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);
|
||||
const isJWT = secret.length > 36 && secret.match(JWT_MATCH_EXPRESSION);
|
||||
|
||||
if (isJWT) {
|
||||
const methodName = this.jwtAuthMethod;
|
||||
|
||||
// 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(
|
||||
@@ -63,17 +158,18 @@ export default class Tokens extends Controller {
|
||||
this.resetStore();
|
||||
|
||||
// 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);
|
||||
},
|
||||
() => {
|
||||
this.set('token.secret', undefined);
|
||||
this.token.set('secret', undefined);
|
||||
this.signInStatus = 'failure';
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a 20-char nonce, using window.crypto to
|
||||
// create a sufficiently-large output then trimming
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"target": "es5",
|
||||
},
|
||||
|
||||
"target": "es2015",
|
||||
"moduleResolution": "node"
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user