From 3084d6cf06a1de1b8c034aaff9e85ca1e92bc286 Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Fri, 22 Jan 2021 17:01:12 -0500 Subject: [PATCH] feature: initial OIDC authentication flow --- addons/api/mirage/route-handlers/auth.js | 27 +++++- addons/auth/addon/authenticators/oidc.js | 87 +++++++++++++------ addons/core/addon/authenticators/oidc.js | 30 ++++++- .../scopes/scope/authenticate/method.js | 22 ++++- .../scopes/scope/authenticate/method/oidc.js | 63 ++++++++++++++ .../scopes/scope/authenticate/method/oidc.hbs | 2 +- ui/admin/config/environment.js | 1 + 7 files changed, 201 insertions(+), 31 deletions(-) diff --git a/addons/api/mirage/route-handlers/auth.js b/addons/api/mirage/route-handlers/auth.js index 8f5d48b3f..bc2deb61c 100644 --- a/addons/api/mirage/route-handlers/auth.js +++ b/addons/api/mirage/route-handlers/auth.js @@ -23,6 +23,12 @@ import { Response } from 'miragejs'; // } // } +// Quick and dirty counter system to simulate the polling behavior +// of the API. If the OIDC flow is pending, the route returns 100. +// If complete it returns 200 with the token JSON. +let oidcAttemptCounter = 0; +const oidcRequiredAttempts = 3; + const methodHandlers = { password: { authenticate: (payload, scopeAttrs) => { @@ -44,6 +50,7 @@ const methodHandlers = { } } }, + /** * OIDC authentication is a two-step process: * @@ -62,7 +69,25 @@ const methodHandlers = { token_request_id: 'token_request_1234', state: 'base_58_encoded_string' }), - authenticate: () => new Response(200) // TODO + authenticate: (_, scopeAttrs) => { + oidcAttemptCounter++; + if (oidcAttemptCounter < oidcRequiredAttempts) { + return new Response(100); + } else { + return new Response(200, {}, { + scope: scopeAttrs, + id: 'token123', + token: 'thetokenstring', + account_id: '1', + user_id: 'user123', + auth_method_id: 'authmethod123', + created_time: '', + updated_time: '', + last_used_time: '', + expiration_time: '' + }); + } + } } }; diff --git a/addons/auth/addon/authenticators/oidc.js b/addons/auth/addon/authenticators/oidc.js index bd88bdb0b..4554a9357 100644 --- a/addons/auth/addon/authenticators/oidc.js +++ b/addons/auth/addon/authenticators/oidc.js @@ -1,5 +1,6 @@ import BaseAuthenticator from './base'; -import { resolve, reject } from 'rsvp'; +import { inject as service } from '@ember/service'; +import { reject } from 'rsvp'; import fetch from 'fetch'; /** @@ -7,56 +8,92 @@ import fetch from 'fetch'; */ export default class OIDCAuthenticator extends BaseAuthenticator { - // =unimplemented methods - // TODO: implement ;-) + // =services + @service session; + + // =methods + + /** + * Generates an auth method URL with which to authenticate. + * @override + * @param {object} options + * @param {string} options.scope.scope_id + * @param {string} options.authMethod.id + * @return {string} + */ buildAuthEndpointURL(/* { scope: { id: scopeID }, authMethod: { id: authMethodID }, } */) { } + /** + * Generates an auth method URL with which to start authentication. + * @override + * @param {object} options + * @param {string} options.scope.scope_id + * @param {string} options.authMethod.id + * @return {string} + */ buildStartAuthEndpointURL(/* { scope: { id: scopeID }, authMethod: { id: authMethodID }, } */) { } /** + * Kicks-off the OIDC flow: calls the `authenticate:start` action on the + * specified auth method, which returns some meta data along with an + * authorization request URL. * + * @param {boolean} requestCookies request cookie tokens (default `true`) + * @param {object} options + * @return {Promise} */ - async startAuthentication( - creds, - requestCookies = true, - options - ) { + async startAuthentication(requestCookies = true, options) { + const url = this.buildStartAuthEndpointURL(options); const body = JSON.stringify({ token_type: requestCookies ? 'cookie' : null }); - const authEndpointURL = this.buildStartAuthEndpointURL(options); - const response = await fetch(authEndpointURL, { method: 'post', body }); + const response = await fetch(url, { method: 'post', body }); const json = await response.json(); - const normalized = this.normalizeData(json); if (response.status < 400) { - await this.openExternalOIDCFlow(normalized.authorization_request_url); - resolve(normalized); + // Store meta about the pending OIDC flow + this.session.set('data.pending', { + oidc: json + }); + return json; } else { - reject(); + return reject(); } } /** - * Opens the specified URL in a new tab or window. By default this uses - * `window.open`, but may be overriden. - * @param {string} url + * */ - async openExternalOIDCFlow(url) { - await window.open(url); + async authenticate(json) { + console.info('Authenticated with OIDC provider'); + return this.normalizeData(json); } - async authenticate( - // creds, - // requestCookies = true, - // options - ) { - return false; + /** + * + */ + async attemptOIDCAuthentication(options) { + const url = this.buildAuthEndpointURL(options); + const body = JSON.stringify({ + state: this.session.get('data.pending.oidc.state'), + token_request_id: this.session.get('data.pending.oidc.token_request_id') + }); + const response = await fetch(url, { method: 'post', body }); + const json = await response.json(); + if (response.status === 100) { + return false; + } else if (response.status < 400) { + // Authentication with OIDC is indirect... + await this.session.authenticate('authenticator:oidc', json); + return true; + } else { + return reject(); + } } } diff --git a/addons/core/addon/authenticators/oidc.js b/addons/core/addon/authenticators/oidc.js index a805b79d6..4df2735f9 100644 --- a/addons/core/addon/authenticators/oidc.js +++ b/addons/core/addon/authenticators/oidc.js @@ -10,7 +10,7 @@ export default class OIDCAuthenticator extends BaseOIDCAuthenticator { @service store; - // =attributes + // =methods /** * Generates an auth method URL with which to authenticate. @@ -34,4 +34,32 @@ export default class OIDCAuthenticator extends BaseOIDCAuthenticator { return adapter.buildURL('auth-method', authMethodID, options, 'findRecord'); } + /** + * Generates an auth method URL with which to authenticate. + * @override + * @param {object} options + * @param {string} options.scope.scope_id + * @param {string} options.authMethod.id + * @return {string} + */ + buildAuthEndpointURL({ + scope: { id: scopeID }, + authMethod: { id: authMethodID }, + }) { + const adapter = this.store.adapterFor('application'); + const options = { adapterOptions: { scopeID, method: 'authenticate' } }; + return adapter.buildURL('auth-method', authMethodID, options, 'findRecord'); + } + + /** + * Generates an auth token validation URL used to check tokens on restoration. + * @override + * @param {string} tokenID + * @return {string} + */ + buildTokenValidationEndpointURL(tokenID) { + const adapter = this.store.adapterFor('auth-token'); + return adapter.buildURL('auth-token', tokenID, {}, 'findRecord'); + } + } diff --git a/ui/admin/app/routes/scopes/scope/authenticate/method.js b/ui/admin/app/routes/scopes/scope/authenticate/method.js index e5297c6bc..787c42644 100644 --- a/ui/admin/app/routes/scopes/scope/authenticate/method.js +++ b/ui/admin/app/routes/scopes/scope/authenticate/method.js @@ -51,7 +51,6 @@ export default class ScopesScopeAuthenticateMethodRoute extends Route { case 'oidc': await this.startOIDCAuthentication( authenticatorName, - creds, requestCookies, { scope, authMethod } ); @@ -60,8 +59,25 @@ export default class ScopesScopeAuthenticateMethodRoute extends Route { } } - async startOIDCAuthentication(authenticatorName, creds, requestCookies, options) { + /** + * + */ + async startOIDCAuthentication(authenticatorName, requestCookies, options) { const oidc = getOwner(this).lookup(authenticatorName); - oidc.startAuthentication(creds, requestCookies, options); + // TODO: delegate this call from the session service so that we don't have + // to look up the authenticator directly + const json = await oidc.startAuthentication(requestCookies, options); + await this.openExternalOIDCFlow(json.authorization_request_url); } + + /** + * Opens the specified URL in a new tab or window. By default this uses + * `window.open`, but may be overriden. + * @param {string} url + */ + async openExternalOIDCFlow(url) { + // TODO don't use window directly + await window.open(url); + } + } diff --git a/ui/admin/app/routes/scopes/scope/authenticate/method/oidc.js b/ui/admin/app/routes/scopes/scope/authenticate/method/oidc.js index 55d368443..a31f44a98 100644 --- a/ui/admin/app/routes/scopes/scope/authenticate/method/oidc.js +++ b/ui/admin/app/routes/scopes/scope/authenticate/method/oidc.js @@ -1,4 +1,67 @@ import Route from '@ember/routing/route'; +import { getOwner } from '@ember/application'; +import { inject as service } from '@ember/service'; +import { task, timeout } from 'ember-concurrency'; +import config from '../../../../../config/environment'; + +const POLL_TIMEOUT_SECONDS = config.oidcPollingTimeoutSeconds; export default class ScopesScopeAuthenticateMethodOidcRoute extends Route { + + // =services + + @service session; + + // =attributes + + /** + * A simple Ember Concurrency-based polling task that refreshes the route + * every POLL_TIMEOUT_SECONDS seconds. This is necessary to display changes + * to session `status` that may occur. + * + * NOTE: tasks are sort of attributes and sort of methods, but they are not + * language-level constructs. Thus we annotate this task as if it + * is an attribute. + * @type {Task} + */ + @task(function * () { + while(!this.session.isAuthenticated) { + yield timeout(POLL_TIMEOUT_SECONDS * 1000); + yield this.refresh(); + } + }).drop() poller; + + // =methods + + model() { + const scope = this.modelFor('scopes.scope'); + const authMethod = this.modelFor('scopes.scope.authenticate.method'); + const authenticatorName = `authenticator:${authMethod.type}`; + const oidc = getOwner(this).lookup(authenticatorName); + // TODO: delegate this call from the session service so that we don't have + // to look up the authenticator directly + return oidc.attemptOIDCAuthentication({ scope, authMethod }); + } + + /** + * If authenticated, redirects to index for further processing. + */ + redirect() { + if (this.session.isAuthenticated) this.replaceWith('index'); + } + + /** + * When this route is activated (entered), begin polling for changes. + */ + activate() { + this.poller.perform(); + } + + /** + * When this route is deactivated (exited), stop polling for changes. + */ + deactivate() { + this.poller.cancelAll(); + } + } diff --git a/ui/admin/app/templates/scopes/scope/authenticate/method/oidc.hbs b/ui/admin/app/templates/scopes/scope/authenticate/method/oidc.hbs index c3aa832be..60c849cf2 100644 --- a/ui/admin/app/templates/scopes/scope/authenticate/method/oidc.hbs +++ b/ui/admin/app/templates/scopes/scope/authenticate/method/oidc.hbs @@ -1 +1 @@ -OIDC yay! +Checking for OIDC ready … diff --git a/ui/admin/config/environment.js b/ui/admin/config/environment.js index 7cd8efa3e..00c5d66aa 100644 --- a/ui/admin/config/environment.js +++ b/ui/admin/config/environment.js @@ -36,6 +36,7 @@ module.exports = function (environment) { notifyTimeout: 4000, sessionPollingTimeoutSeconds: 2.5, + oidcPollingTimeoutSeconds: 1, documentation: { baseURL: 'https://boundaryproject.io/help/admin-ui',