From f4e00566fa2bf03d098377ddc36dcce0647624e5 Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Wed, 29 Mar 2023 15:06:25 -0400 Subject: [PATCH] [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 --- ui/app/adapters/token.js | 22 ++- ui/app/controllers/settings/tokens.js | 144 +++++++++++++++--- ui/app/models/auth-method.js | 4 + ui/app/styles/components/authorization.scss | 13 +- ui/app/templates/settings/tokens.hbs | 45 +++++- ui/jsconfig.json | 10 +- ui/mirage/config.js | 17 +++ ui/mirage/scenarios/default.js | 5 + ui/tests/acceptance/token-test.js | 155 ++++++++++++++++++++ 9 files changed, 367 insertions(+), 48 deletions(-) diff --git a/ui/app/adapters/token.js b/ui/app/adapters/token.js index 00edc5ca8..9fad9eac4 100644 --- a/ui/app/adapters/token.js +++ b/ui/app/adapters/token.js @@ -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) { diff --git a/ui/app/controllers/settings/tokens.js b/ui/app/controllers/settings/tokens.js index c019087f8..e581d6e28 100644 --- a/ui/app/controllers/settings/tokens.js +++ b/ui/app/controllers/settings/tokens.js @@ -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} + */ 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); diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js index 2cfb05b94..e7662f5ba 100644 --- a/ui/app/models/auth-method.js +++ b/ui/app/models/auth-method.js @@ -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; diff --git a/ui/app/styles/components/authorization.scss b/ui/app/styles/components/authorization.scss index 16a5b3922..5553327ab 100644 --- a/ui/app/styles/components/authorization.scss +++ b/ui/app/styles/components/authorization.scss @@ -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; } } -} \ No newline at end of file + + .control.with-jwt-selector { + display: flex; + gap: 0.5rem; + } +} diff --git a/ui/app/templates/settings/tokens.hbs b/ui/app/templates/settings/tokens.hbs index dd8dc64c0..1f57a0d8a 100644 --- a/ui/app/templates/settings/tokens.hbs +++ b/ui/app/templates/settings/tokens.hbs @@ -18,6 +18,17 @@ {{/if}} + {{#if (eq this.signInStatus "jwtFailure")}} +
+
+
+

JWT Failed to Authenticate

+

You passed in a JWT, but no JWT auth methods were found

+
+
+
+ {{/if}} + {{#if this.tokenRecord.isExpired}}
@@ -72,11 +83,11 @@
{{#if this.canSignIn}}