feature: initial OIDC authentication flow
This commit is contained in:
7 files changed
+201
-31
No files matched your search
@@ -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: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
OIDC yay!
|
||||
Checking for OIDC ready …
|
||||
@@ -36,6 +36,7 @@ module.exports = function (environment) {
|
||||
|
||||
notifyTimeout: 4000,
|
||||
sessionPollingTimeoutSeconds: 2.5,
|
||||
oidcPollingTimeoutSeconds: 1,
|
||||
|
||||
documentation: {
|
||||
baseURL: 'https://boundaryproject.io/help/admin-ui',
|
||||
|
||||
Reference in new issue
Block a user