Merge pull request #429 from hashicorp/feature-oidc-authenticate
Feature oidc authenticate
This commit is contained in:
55 files changed
+937
-223
No files matched your search
@@ -8,4 +8,18 @@ export default class AuthMethodModel extends GeneratedAuthMethodModel {
|
||||
* @type {boolean}
|
||||
*/
|
||||
@equal('primary', true) isPrimary;
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isPassword() {
|
||||
return this.type === 'password';
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isOIDC() {
|
||||
return this.type === 'oidc';
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function () {
|
||||
if (scope.primaryAuthMethodId === id) {
|
||||
scope.update({ primaryAuthMethodId: null });
|
||||
}
|
||||
return new Response(204);
|
||||
return new Response(202);
|
||||
});
|
||||
|
||||
// Auth Method Accounts
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import factory from '../generated/factories/auth-method';
|
||||
import { trait } from 'ember-cli-mirage';
|
||||
|
||||
const types = ['password', 'oidc'];
|
||||
|
||||
export default factory.extend({
|
||||
id: (i) => `auth-method-id-${i}`,
|
||||
|
||||
// Cycle through available types
|
||||
type: (i) => types[i % types.length],
|
||||
|
||||
/**
|
||||
* Adds accounts (with associated users) to auth method.
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Response } from 'miragejs';
|
||||
// */
|
||||
// export default function authenticateHandler(schema, request) {
|
||||
// const payload = JSON.parse(request.requestBody);
|
||||
// if (payload.credentials.login_name === 'error') {
|
||||
// if (payload.attributes.login_name === 'error') {
|
||||
// return new Response(400);
|
||||
// } else {
|
||||
// const cookieName = config.auth.passwordCookieName;
|
||||
@@ -23,32 +23,98 @@ 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 commandHandlers = {
|
||||
password: {
|
||||
login: (payload, scopeAttrs) => {
|
||||
if (payload.attributes.login_name === 'error') {
|
||||
return new Response(400);
|
||||
} else {
|
||||
return new Response(
|
||||
200,
|
||||
{},
|
||||
{
|
||||
attributes: {
|
||||
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: '',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* OIDC authentication is a two-step process:
|
||||
*
|
||||
* 1. `authenticate:start` kicks of authentication by requesting some details
|
||||
* from the Boundary server about the request, including the third-party
|
||||
* URL to which to redirect the user.
|
||||
* 2. `authenticate` accepts the token_id and state parameters in
|
||||
* order to retrieve a Boundary token. This endpoint may be polled until
|
||||
* the authentication flow is completed.
|
||||
*/
|
||||
oidc: {
|
||||
start: () =>
|
||||
new Response(
|
||||
200,
|
||||
{},
|
||||
{
|
||||
attributes: {
|
||||
auth_url: 'https://www.duckduckgo.com',
|
||||
token_id: 'token_1234',
|
||||
},
|
||||
}
|
||||
),
|
||||
token: (_, scopeAttrs) => {
|
||||
oidcAttemptCounter++;
|
||||
if (oidcAttemptCounter < oidcRequiredAttempts) {
|
||||
return new Response(202);
|
||||
} else {
|
||||
return new Response(
|
||||
200,
|
||||
{},
|
||||
{
|
||||
attributes: {
|
||||
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: '',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function authHandler({ scopes, authMethods }, request) {
|
||||
const payload = JSON.parse(request.requestBody);
|
||||
if (payload.credentials.login_name === 'error') {
|
||||
return new Response(400);
|
||||
} else {
|
||||
const id = request.params.id_method.split(':')[0];
|
||||
const authMethod = authMethods.find(id);
|
||||
const scope = scopes.find(authMethod.scopeId);
|
||||
const scopeAttrs = this.serialize(scopes.find(scope.id));
|
||||
return new Response(
|
||||
200,
|
||||
{},
|
||||
{
|
||||
scope: scopeAttrs,
|
||||
id: 'token123',
|
||||
token: 'thetokenstring',
|
||||
account_id: '1',
|
||||
user_id: 'authenticateduser',
|
||||
auth_method_id: 'authmethod123',
|
||||
created_time: '',
|
||||
updated_time: '',
|
||||
last_used_time: '',
|
||||
expiration_time: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
const [, id] = request.params.id_method.match(/(?<id>.[^:]*)/);
|
||||
const { command } = payload;
|
||||
const authMethod = authMethods.find(id);
|
||||
const scope = scopes.find(authMethod.scopeId);
|
||||
const scopeAttrs = this.serialize(scopes.find(scope.id));
|
||||
return commandHandlers[authMethod.type][command](payload, scopeAttrs);
|
||||
}
|
||||
|
||||
export function deauthHandler() {
|
||||
|
||||
@@ -4,10 +4,31 @@ import fetch from 'fetch';
|
||||
|
||||
/**
|
||||
* Encapsulates common authenticator functionality.
|
||||
*
|
||||
* Upon session invalidation, deauthentication should be attempted at the URL
|
||||
* generated by `buildDeauthEndpointURL` on a best-effort basis.
|
||||
*
|
||||
* When a session is restored, a call should be made to the endpoint specified
|
||||
* by `buildTokenValidationEndpointURL`. If this endpoint responds with 401 or
|
||||
* 404, then the session is invalid and the restoration should be rejected.
|
||||
* All other responses should resolve the session restoration successfully.
|
||||
*/
|
||||
export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
||||
// =unimplemented 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 a scope URL with which to deauthenticate.
|
||||
* @override
|
||||
@@ -51,9 +72,10 @@ export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
||||
*/
|
||||
async restore(data) {
|
||||
if (!data) return reject();
|
||||
return this.validateToken(data.token, data.id).then(() =>
|
||||
this.normalizeData(data)
|
||||
);
|
||||
return this.validateToken(
|
||||
data.attributes.token,
|
||||
data.attributes.id
|
||||
).then(() => this.normalizeData(data));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +87,10 @@ export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
||||
* @return {object}
|
||||
*/
|
||||
normalizeData(data, username) {
|
||||
// Pull fields up from `data.attributes` for easier access in JavaScript.
|
||||
// The `attributes` field exists on the Go side for its convenience but is
|
||||
// unnecessary here.
|
||||
Object.assign(data, data.attributes);
|
||||
// Add booleans indicated the scope type
|
||||
data.isGlobal = data?.scope?.type === 'global';
|
||||
data.isOrg = data?.scope?.type === 'org';
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import BaseAuthenticator from './base';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { reject } from 'rsvp';
|
||||
import fetch from 'fetch';
|
||||
|
||||
/**
|
||||
* The OIDC base authenticator encapsulates the multistep OIDC flow.
|
||||
*
|
||||
* 1. Start authentication flow: this step is actually a combination of two
|
||||
* sub steps:
|
||||
* 1. Initiate a call via `startAuthentication` to the API at
|
||||
* `buildAuthEndpointURL` using the `start` command to fetch the URL of
|
||||
* the external authentication provider (`auth_url`).
|
||||
* 2. Open `auth_url`. This may be either inline (same tab)
|
||||
* or external in a new tab or browser window.
|
||||
* 2. Requests the token from `buildAuthEndpointURL` using the `token` command.
|
||||
* A token isn't guaranteed to be ready, so the OIDC flow can be in a
|
||||
* "pending" state.
|
||||
*
|
||||
* This authenticator should not be used directly because it does not specify
|
||||
* URL-building endpoints of its own.
|
||||
* To use, generate an application authenticator in your app
|
||||
* `authenticators/application.js` and extend this class.
|
||||
*/
|
||||
export default class OIDCAuthenticator extends BaseAuthenticator {
|
||||
// =services
|
||||
|
||||
@service session;
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* 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. The response data is saved for later and
|
||||
* authentication enters a pending state.
|
||||
*
|
||||
* @param {boolean} requestCookies request cookie tokens (default `true`)
|
||||
* @param {object} options
|
||||
* @return {object}
|
||||
*/
|
||||
async startAuthentication(options) {
|
||||
const url = this.buildAuthEndpointURL(options);
|
||||
const body = JSON.stringify({ command: 'start' });
|
||||
const response = await fetch(url, { method: 'post', body });
|
||||
const json = await response.json();
|
||||
if (response.status < 400) {
|
||||
// Store meta about the pending OIDC flow
|
||||
this.session.set('data.pending', {
|
||||
oidc: json,
|
||||
});
|
||||
return json;
|
||||
} else {
|
||||
return reject();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC is a _process_ and does not have a singular "authenticate" step. Thus
|
||||
* this authenticate method simply normalizes the data passed into it once
|
||||
* `attemptFetchToken` completes. Notably, no requests are made by calling
|
||||
* authenticate. A Boundary token must already have been obtained.
|
||||
* @param {object} json
|
||||
* @return {object}
|
||||
*/
|
||||
async authenticate(json) {
|
||||
return this.normalizeData(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* This request attempts to fetch a Boundary token. This method represents a
|
||||
* single request the polling sequence used to wait for authentication.
|
||||
* There are three possible outcomes:
|
||||
*
|
||||
* 1. If the token is ready, it is persisted into the session, the session
|
||||
* enters an authenticated state, and this method returns `true`.
|
||||
* 2. If the token is not yet ready, this method returns false.
|
||||
* 3. If the request errors, this method returns to a rejection,
|
||||
* requiring appropriate error handling.
|
||||
*/
|
||||
async attemptFetchToken(options) {
|
||||
// Get the URL and setup the request body
|
||||
const url = this.buildAuthEndpointURL(options);
|
||||
const body = JSON.stringify({
|
||||
command: 'token',
|
||||
attributes: {
|
||||
//state: this.session.get('data.pending.oidc.attributes.state'),
|
||||
token_id: this.session.get('data.pending.oidc.attributes.token_id'),
|
||||
},
|
||||
});
|
||||
// Fetch the endpoint and get the response JSON
|
||||
const response = await fetch(url, { method: 'post', body });
|
||||
if (response.status === 202) {
|
||||
// The token isn't ready yet, keep trying.
|
||||
return false;
|
||||
} else if (response.status < 400) {
|
||||
// Response was successful, meaning a token was obtained.
|
||||
// Authenticate with the session service using the response JSON.
|
||||
const json = await response.json();
|
||||
await this.session.authenticate('authenticator:oidc', json);
|
||||
return true;
|
||||
} else {
|
||||
// Response errors, return a rejection for further handling.
|
||||
return reject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,21 +34,6 @@ import fetch from 'fetch';
|
||||
*
|
||||
*/
|
||||
export default class PasswordAuthenticator extends BaseAuthenticator {
|
||||
// =unimplemented 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 },
|
||||
} */) {}
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
@@ -71,8 +56,9 @@ export default class PasswordAuthenticator extends BaseAuthenticator {
|
||||
options
|
||||
) {
|
||||
const body = JSON.stringify({
|
||||
command: 'login',
|
||||
token_type: requestCookies ? 'cookie' : null,
|
||||
credentials: { login_name, password },
|
||||
attributes: { login_name, password },
|
||||
});
|
||||
const authEndpointURL = this.buildAuthEndpointURL(options);
|
||||
const response = await fetch(authEndpointURL, { method: 'post', body });
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import OIDCAuthenticator from 'auth/authenticators/oidc';
|
||||
|
||||
export default class ApplicationOIDCAuthenticator extends OIDCAuthenticator {
|
||||
buildTokenValidationEndpointURL(id) {
|
||||
return `/validate/${id}`;
|
||||
}
|
||||
|
||||
buildAuthEndpointURL() {
|
||||
return '/authenticate';
|
||||
}
|
||||
|
||||
buildDeauthEndpointURL() {
|
||||
return '/deauthenticate';
|
||||
}
|
||||
}
|
||||
@@ -32,29 +32,25 @@ module('Unit | Authenticator | base', function (hooks) {
|
||||
|
||||
test('it passes token validation on restoration if receiving 200', async function (assert) {
|
||||
assert.expect(1);
|
||||
const mockData = { id: 'token_123' };
|
||||
const id = 'token_123';
|
||||
const mockData = { attributes: { id } };
|
||||
const authenticator = this.owner.lookup('authenticator:base');
|
||||
this.server.get(
|
||||
authenticator.buildTokenValidationEndpointURL(mockData.id),
|
||||
() => {
|
||||
assert.ok(true, 'token validation was requested');
|
||||
return new Response(200);
|
||||
}
|
||||
);
|
||||
this.server.get(authenticator.buildTokenValidationEndpointURL(id), () => {
|
||||
assert.ok(true, 'token validation was requested');
|
||||
return new Response(200);
|
||||
});
|
||||
await authenticator.restore(mockData);
|
||||
});
|
||||
|
||||
test('it fails token validation on restoration if receiving 401', async function (assert) {
|
||||
assert.expect(2);
|
||||
const mockData = { id: 'token_123' };
|
||||
const id = 'token_123';
|
||||
const mockData = { attributes: { id } };
|
||||
const authenticator = this.owner.lookup('authenticator:base');
|
||||
this.server.get(
|
||||
authenticator.buildTokenValidationEndpointURL(mockData.id),
|
||||
() => {
|
||||
assert.ok(true, 'token validation was requested');
|
||||
return new Response(401);
|
||||
}
|
||||
);
|
||||
this.server.get(authenticator.buildTokenValidationEndpointURL(id), () => {
|
||||
assert.ok(true, 'token validation was requested');
|
||||
return new Response(401);
|
||||
});
|
||||
try {
|
||||
await authenticator.restore(mockData);
|
||||
} catch (e) {
|
||||
@@ -64,15 +60,13 @@ module('Unit | Authenticator | base', function (hooks) {
|
||||
|
||||
test('it fails token validation on restoration if receiving 404', async function (assert) {
|
||||
assert.expect(2);
|
||||
const mockData = { id: 'token_123' };
|
||||
const id = 'token_123';
|
||||
const mockData = { attributes: { id } };
|
||||
const authenticator = this.owner.lookup('authenticator:base');
|
||||
this.server.get(
|
||||
authenticator.buildTokenValidationEndpointURL(mockData.id),
|
||||
() => {
|
||||
assert.ok(true, 'token validation was requested');
|
||||
return new Response(404);
|
||||
}
|
||||
);
|
||||
this.server.get(authenticator.buildTokenValidationEndpointURL(id), () => {
|
||||
assert.ok(true, 'token validation was requested');
|
||||
return new Response(404);
|
||||
});
|
||||
try {
|
||||
await authenticator.restore(mockData);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { Response } from 'miragejs';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
|
||||
module('Unit | Authenticator | OIDC', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
test('`startAuthentication` initiates the authentication flow and returns the response', async function (assert) {
|
||||
assert.expect(2);
|
||||
const authenticator = this.owner.lookup('authenticator:oidc');
|
||||
this.server.post(
|
||||
authenticator.buildAuthEndpointURL(),
|
||||
() => new Response(200, {}, { foo: 'bar' })
|
||||
);
|
||||
const response = await authenticator.startAuthentication();
|
||||
assert.ok(true, 'authentication succeeded');
|
||||
assert.deepEqual(response, { foo: 'bar' });
|
||||
});
|
||||
|
||||
test('`startAuthentication` rejects/errors if API returns an error response', async function (assert) {
|
||||
assert.expect(1);
|
||||
const authenticator = this.owner.lookup('authenticator:oidc');
|
||||
this.server.post(
|
||||
authenticator.buildAuthEndpointURL(),
|
||||
() => new Response(400)
|
||||
);
|
||||
// Because assert.throws doesn't work with async errors/promise rejections
|
||||
try {
|
||||
await authenticator.startAuthentication();
|
||||
} catch (e) {
|
||||
assert.ok(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -47,8 +47,9 @@ module('Unit | Authenticator | password', function (hooks) {
|
||||
(schema, request) => {
|
||||
const json = JSON.parse(request.requestBody);
|
||||
assert.deepEqual(json, {
|
||||
command: 'login',
|
||||
token_type: 'cookie',
|
||||
credentials: {
|
||||
attributes: {
|
||||
login_name: 'foo',
|
||||
password: 'bar',
|
||||
},
|
||||
|
||||
+9
-21
@@ -1,17 +1,15 @@
|
||||
import BasePasswordAuthenticator from 'auth/authenticators/password';
|
||||
import BaseOIDCAuthenticator from 'auth/authenticators/oidc';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
/**
|
||||
* A username/password authenticator that authenticates with
|
||||
* a scope/auth-method endpoint. Authentication occurs on an auth method
|
||||
* endpoint, while deauthentication occurs on a scope endpoint.
|
||||
* An OIDC authenticator that handles flow kick-off and polling.
|
||||
*/
|
||||
export default class PasswordAuthenticator extends BasePasswordAuthenticator {
|
||||
export default class OIDCAuthenticator extends BaseOIDCAuthenticator {
|
||||
// =services
|
||||
|
||||
@service store;
|
||||
|
||||
// =attributes
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Generates an auth method URL with which to authenticate.
|
||||
@@ -30,19 +28,6 @@ export default class PasswordAuthenticator extends BasePasswordAuthenticator {
|
||||
return adapter.buildURL('auth-method', authMethodID, options, 'findRecord');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a scope URL with which to deauthenticate.
|
||||
* @override
|
||||
* @param {object} options
|
||||
* @param {string} scopeID
|
||||
* @return {string}
|
||||
*/
|
||||
buildDeauthEndpointURL({ scope: { id: scopeID } }) {
|
||||
const adapter = this.store.adapterFor('application');
|
||||
const options = { adapterOptions: { method: 'deauthenticate' } };
|
||||
return adapter.buildURL('scope', scopeID, options, 'findRecord');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an auth token validation URL used to check tokens on restoration.
|
||||
* @override
|
||||
@@ -62,7 +47,7 @@ export default class PasswordAuthenticator extends BasePasswordAuthenticator {
|
||||
*/
|
||||
authenticate() {
|
||||
return super.authenticate(...arguments).then((data) => {
|
||||
const token = data?.token;
|
||||
const token = data?.attributes?.token;
|
||||
this.addTokenToAuthorization(token);
|
||||
return data;
|
||||
});
|
||||
@@ -76,7 +61,7 @@ export default class PasswordAuthenticator extends BasePasswordAuthenticator {
|
||||
* @return {object}
|
||||
*/
|
||||
restore(data) {
|
||||
const token = data?.token;
|
||||
const token = data?.attributes?.token;
|
||||
this.addTokenToAuthorization(token);
|
||||
return super.restore(data);
|
||||
}
|
||||
@@ -85,6 +70,9 @@ export default class PasswordAuthenticator extends BasePasswordAuthenticator {
|
||||
* Assigns a token string all future requests via the `Authorization` header
|
||||
* on the application adapter prototype. Only application and adapters that
|
||||
* extend the application adapter receive this header (which is most).
|
||||
*
|
||||
* TODO: if cookies were requested, this step should be skipped.
|
||||
*
|
||||
* @param {string} token
|
||||
*/
|
||||
addTokenToAuthorization(token) {
|
||||
@@ -62,7 +62,7 @@ export default class PasswordAuthenticator extends BasePasswordAuthenticator {
|
||||
*/
|
||||
authenticate() {
|
||||
return super.authenticate(...arguments).then((data) => {
|
||||
const token = data?.token;
|
||||
const token = data?.attributes?.token;
|
||||
this.addTokenToAuthorization(token);
|
||||
return data;
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export default class PasswordAuthenticator extends BasePasswordAuthenticator {
|
||||
* @return {object}
|
||||
*/
|
||||
restore(data) {
|
||||
const token = data?.token;
|
||||
const token = data?.attributes?.token;
|
||||
this.addTokenToAuthorization(token);
|
||||
return super.restore(data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<Rose::Form
|
||||
class="full-width"
|
||||
@onSubmit={{@submit}}
|
||||
@cancel={{false}}
|
||||
@disabled={{@disabled}}
|
||||
as |form|
|
||||
>
|
||||
|
||||
<form.actions
|
||||
@submitText={{t "actions.authenticate"}}
|
||||
@showCancel={{false}} />
|
||||
|
||||
</Rose::Form>
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from 'core/authenticators/oidc';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from 'core/components/form/authenticate/oidc';
|
||||
@@ -37,6 +37,7 @@
|
||||
"ember-loading": "^0.4.0",
|
||||
"ember-page-title": "^6.0.3",
|
||||
"ember-route-action-helper": "^2.0.8",
|
||||
"ember-feature-flags": "^6.0.0",
|
||||
"rose": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -26,13 +26,11 @@ module('Unit | Authenticator | password', function (hooks) {
|
||||
|
||||
test('it adds an authorization header to application adapter on restore', async function (assert) {
|
||||
assert.expect(1);
|
||||
const mockData = { id: 'token1234', token: 'token1234' };
|
||||
this.server.get(
|
||||
authenticator.buildTokenValidationEndpointURL(mockData.id),
|
||||
() => {
|
||||
return new Response(200);
|
||||
}
|
||||
);
|
||||
const id = 'token1234';
|
||||
const mockData = { attributes: { id, token: 'token1234' } };
|
||||
this.server.get(authenticator.buildTokenValidationEndpointURL(id), () => {
|
||||
return new Response(200);
|
||||
});
|
||||
authenticator.restore(mockData);
|
||||
assert.equal(applicationAdapter.headers.Authorization, 'Bearer token1234');
|
||||
});
|
||||
|
||||
@@ -27,8 +27,12 @@ auth-method:
|
||||
no-primary-auth-method:
|
||||
title: "Recommendation: Select a Primary Auth Method"
|
||||
description: A primary auth method auto-creates users when they authenticate for the first time. Scopes without primary auth methods may experience authentication failures.
|
||||
pending:
|
||||
title: Authentication Pending
|
||||
description: Complete authentication in the newly-opened window.
|
||||
types:
|
||||
password: Password
|
||||
oidc: OIDC
|
||||
actions:
|
||||
create: Create Auth Method
|
||||
delete: Delete Auth Method
|
||||
@@ -42,6 +46,7 @@ auth-method:
|
||||
remove-as-primary: Remove primary auth method?
|
||||
make-primary-confirm: "A primary auth method auto-creates users on their first authentication attempt if needed. Note: if you change the primary auth method, the previous primary will no longer auto-create users."
|
||||
remove-as-primary-confirm: Removing the primary auth method may lead to authentication failures for this scope.
|
||||
no-see-window: Don't see the new window?
|
||||
group:
|
||||
title: Group
|
||||
title_plural: Groups
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.rose-dialog-cover {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
@@ -16,6 +17,8 @@
|
||||
|
||||
.rose-dialog-cover-header,
|
||||
.rose-dialog-cover-body {
|
||||
position: relative;
|
||||
top: -10vh;
|
||||
max-width: 650px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class ScopesScopeAuthenticateMethodOidcController extends Controller {
|
||||
// =services
|
||||
|
||||
@service session;
|
||||
|
||||
// =attributes
|
||||
|
||||
/**
|
||||
* Authetnication URL for the pending OIDC flow, if any.
|
||||
* @type {?string}
|
||||
*/
|
||||
get authURL() {
|
||||
return this.session.data.pending.oidc.attributes.auth_url;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ Router.map(function () {
|
||||
this.route('scopes', function () {
|
||||
this.route('scope', { path: ':scope_id' }, function () {
|
||||
this.route('authenticate', function () {
|
||||
this.route('method', { path: ':auth_method_id' });
|
||||
this.route('method', { path: ':auth_method_id' }, function () {
|
||||
this.route('oidc');
|
||||
});
|
||||
});
|
||||
|
||||
this.route('scopes', function () {});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { getOwner } from '@ember/application';
|
||||
import { action } from '@ember/object';
|
||||
import loading from 'ember-loading/decorator';
|
||||
import { notifyError } from 'core/decorators/notify';
|
||||
@@ -23,6 +24,27 @@ export default class ScopesScopeAuthenticateMethodRoute extends Route {
|
||||
return this.store.findRecord('auth-method', id, adapterOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async startOIDCAuthentication(authenticatorName, options) {
|
||||
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
|
||||
const json = await oidc.startAuthentication(options);
|
||||
await this.openExternalOIDCFlow(json.attributes.auth_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);
|
||||
}
|
||||
|
||||
// =actions
|
||||
|
||||
/**
|
||||
@@ -37,10 +59,23 @@ export default class ScopesScopeAuthenticateMethodRoute extends Route {
|
||||
const authMethod = this.modelFor('scopes.scope.authenticate.method');
|
||||
const authenticatorName = `authenticator:${authMethod.type}`;
|
||||
const requestCookies = false;
|
||||
await this.session.authenticate(authenticatorName, creds, requestCookies, {
|
||||
scope,
|
||||
authMethod,
|
||||
});
|
||||
this.transitionTo('index');
|
||||
switch (authMethod.type) {
|
||||
case 'password':
|
||||
await this.session.authenticate(
|
||||
authenticatorName,
|
||||
creds,
|
||||
requestCookies,
|
||||
{ scope, authMethod }
|
||||
);
|
||||
this.transitionTo('index');
|
||||
break;
|
||||
case 'oidc':
|
||||
await this.startOIDCAuthentication(authenticatorName, {
|
||||
scope,
|
||||
authMethod,
|
||||
});
|
||||
this.transitionTo('scopes.scope.authenticate.method.oidc');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ScopesScopeAuthenticateMethodIndexRoute extends Route {}
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { action } from '@ember/object';
|
||||
import { notifyError } from 'core/decorators/notify';
|
||||
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();
|
||||
}
|
||||
/* eslint-disable-next-line prettier/prettier */
|
||||
}).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.attemptFetchToken({ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* An error in this route indicates authentication failed. The user is
|
||||
* notified and returned to the index.
|
||||
*/
|
||||
@action
|
||||
@notifyError(() => 'errors.authentication-failed.title', { catch: true })
|
||||
error(e) {
|
||||
this.transitionTo('scopes.scope.authenticate.method.index');
|
||||
// rethrow the error to activate the notifyError decorator
|
||||
throw e;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -58,12 +58,30 @@
|
||||
{{#each @model as |authMethod|}}
|
||||
<body.row as |row|>
|
||||
<row.headerCell>
|
||||
<LinkTo
|
||||
@route="scopes.scope.auth-methods.auth-method"
|
||||
@model={{authMethod.id}}
|
||||
>
|
||||
{{authMethod.displayName}}
|
||||
</LinkTo>
|
||||
{{!
|
||||
Show link to auth method route if non-OIDC and when
|
||||
oidc-crud feature is enabled, otherwise show just the name
|
||||
(when the method is OIDC and oidc-crud is disabled)
|
||||
}}
|
||||
{{#if authMethod.isOIDC}}
|
||||
{{#if (feature-flag 'oidc-crud')}}
|
||||
<LinkTo
|
||||
@route="scopes.scope.auth-methods.auth-method"
|
||||
@model={{authMethod.id}}
|
||||
>
|
||||
{{authMethod.displayName}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
{{authMethod.displayName}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<LinkTo
|
||||
@route="scopes.scope.auth-methods.auth-method"
|
||||
@model={{authMethod.id}}
|
||||
>
|
||||
{{authMethod.displayName}}
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
<p>{{authMethod.description}}</p>
|
||||
</row.headerCell>
|
||||
<row.cell>
|
||||
|
||||
@@ -28,15 +28,31 @@
|
||||
<Rose::Separator />
|
||||
|
||||
<Rose::Nav::Tabs as |nav|>
|
||||
{{#each @model.authMethods as |authMethod|}}
|
||||
<nav.link @route="scopes.scope.authenticate.method" @model={{authMethod.id}}>
|
||||
{{#if authMethod.name}}
|
||||
{{authMethod.name}}
|
||||
{{else}}
|
||||
{{t (concat "resources.auth-method.types." authMethod.type)}}
|
||||
{{/if}}
|
||||
</nav.link>
|
||||
{{/each}}
|
||||
{{#if (feature-flag 'oidc')}}
|
||||
{{! OIDC enable: list all auth methods }}
|
||||
{{#each @model.authMethods as |authMethod|}}
|
||||
<nav.link @route="scopes.scope.authenticate.method" @model={{authMethod.id}}>
|
||||
{{#if authMethod.name}}
|
||||
{{authMethod.name}}
|
||||
{{else}}
|
||||
{{t (concat "resources.auth-method.types." authMethod.type)}}
|
||||
{{/if}}
|
||||
</nav.link>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{! OIDC disabled: filter out OIDC auth methods}}
|
||||
{{#each @model.authMethods as |authMethod|}}
|
||||
{{#unless authMethod.isOIDC}}
|
||||
<nav.link @route="scopes.scope.authenticate.method" @model={{authMethod.id}}>
|
||||
{{#if authMethod.name}}
|
||||
{{authMethod.name}}
|
||||
{{else}}
|
||||
{{t (concat "resources.auth-method.types." authMethod.type)}}
|
||||
{{/if}}
|
||||
</nav.link>
|
||||
{{/unless}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</Rose::Nav::Tabs>
|
||||
|
||||
{{#if @model.authMethods}}
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
<Form::Authenticate
|
||||
@model={{@model}}
|
||||
@submit={{route-action "authenticate"}}
|
||||
@disabled={{is-loading}}
|
||||
/>
|
||||
{{outlet}}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{#if (feature-flag 'oidc')}}
|
||||
{{! OIDC enabled: show form for any auth method}}
|
||||
<Form::Authenticate
|
||||
@model={{@model}}
|
||||
@submit={{route-action "authenticate"}}
|
||||
@disabled={{is-loading}}
|
||||
/>
|
||||
{{else}}
|
||||
{{! OIDC disabled: hide OIDC auth form}}
|
||||
{{#unless @model.isOIDC}}
|
||||
<Form::Authenticate
|
||||
@model={{@model}}
|
||||
@submit={{route-action "authenticate"}}
|
||||
@disabled={{is-loading}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Rose::Dialog::Cover>
|
||||
<:header>
|
||||
{{inline-svg "logo-app-full"}}
|
||||
<h1>{{t 'resources.auth-method.messages.pending.title'}}</h1>
|
||||
</:header>
|
||||
<:body>
|
||||
<p>{{t 'resources.auth-method.messages.pending.description'}}</p>
|
||||
<p>
|
||||
{{t 'resources.auth-method.questions.no-see-window'}}
|
||||
<a
|
||||
href="{{this.authURL}}"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{t 'actions.retry'}}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<LinkTo @route="scopes.scope.authenticate.method">
|
||||
{{t 'actions.cancel'}}
|
||||
</LinkTo>
|
||||
</p>
|
||||
</:body>
|
||||
</Rose::Dialog::Cover>
|
||||
@@ -36,6 +36,7 @@ module.exports = function (environment) {
|
||||
|
||||
notifyTimeout: 4000,
|
||||
sessionPollingTimeoutSeconds: 2.5,
|
||||
oidcPollingTimeoutSeconds: 1,
|
||||
|
||||
documentation: {
|
||||
baseURL: 'https://boundaryproject.io/help/admin-ui',
|
||||
@@ -85,6 +86,8 @@ module.exports = function (environment) {
|
||||
},
|
||||
|
||||
featureFlags: {
|
||||
oidc: true,
|
||||
'oidc-crud': false,
|
||||
'primary-auth-method': false,
|
||||
},
|
||||
};
|
||||
@@ -122,6 +125,8 @@ module.exports = function (environment) {
|
||||
|
||||
// Enable features in development
|
||||
ENV.featureFlags['primary-auth-method'] = true;
|
||||
ENV.featureFlags['oidc'] = true;
|
||||
//ENV.featureFlags['oidc-crud'] = true;
|
||||
}
|
||||
|
||||
if (environment === 'test') {
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
"ember-concurrency": "^1.3.0",
|
||||
"ember-exam": "^6.0.1",
|
||||
"ember-export-application-global": "^2.0.1",
|
||||
"ember-feature-flags": "^6.0.0",
|
||||
"ember-inline-svg": "^1.0.0",
|
||||
"ember-load-initializers": "^2.1.2",
|
||||
"ember-maybe-import-regenerator": "^0.1.6",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg id="LOGOS" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 377 132"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#f04e54;}</style></defs><path class="cls-1" d="M125.45,41.34V35.68h-5.18v5.66h-2.64V27.75h2.64v5.7h5.18v-5.7h2.64V41.34Zm12.32,0h-2.09l-.19-.67a5.69,5.69,0,0,1-3,.87c-1.87,0-2.66-1.23-2.66-2.92,0-2,.9-2.75,3-2.75h2.46v-1c0-1.09-.32-1.47-1.95-1.47a14.85,14.85,0,0,0-2.83.3l-.32-1.87a14,14,0,0,1,3.5-.46c3.21,0,4.15,1.08,4.15,3.54Zm-2.55-3.7h-1.89c-.84,0-1.07.22-1.07,1s.23,1,1,1a4.1,4.1,0,0,0,1.93-.51Zm7.91,3.9a12.53,12.53,0,0,1-3.52-.56L140,39.1a11.5,11.5,0,0,0,3,.43c1.13,0,1.3-.24,1.3-1s-.12-.9-1.78-1.29c-2.49-.58-2.79-1.18-2.79-3.08s.9-2.83,3.82-2.83a14.07,14.07,0,0,1,3.06.34l-.25,2a19,19,0,0,0-2.81-.28c-1.11,0-1.3.24-1.3.84,0,.79.06.85,1.44,1.19,2.85.73,3.13,1.09,3.13,3.1S146.21,41.54,143.13,41.54Zm11.7-.2V34.5c0-.53-.23-.79-.81-.79a7.22,7.22,0,0,0-2.67.83v6.8H148.8V27.56l2.55.38v4.34a9.38,9.38,0,0,1,3.73-.94c1.7,0,2.31,1.14,2.31,2.89v7.11Zm4.71-11.19v-2.4h2.55v2.4Zm0,11.19v-9.8h2.55v9.8Zm4.6-9.72c0-2.46,1.48-3.88,5-3.88a16.63,16.63,0,0,1,3.8.44l-.3,2.19a21.49,21.49,0,0,0-3.41-.34c-1.83,0-2.41.6-2.41,2v5.15c0,1.43.58,2,2.41,2a21.57,21.57,0,0,0,3.41-.35l.3,2.2a16.63,16.63,0,0,1-3.8.44c-3.48,0-5-1.43-5-3.88Zm14.41,9.92c-3.5,0-4.45-1.85-4.45-3.86V35.2c0-2,1-3.86,4.45-3.86S183,33.19,183,35.2v2.48C183,39.69,182.05,41.54,178.55,41.54Zm0-8.11c-1.37,0-1.89.58-1.89,1.69v2.64c0,1.1.52,1.69,1.89,1.69s1.88-.59,1.88-1.69V35.12C180.43,34,179.91,33.43,178.55,33.43Zm11.64.16A20.64,20.64,0,0,0,187.48,35v6.32h-2.55v-9.8h2.16l.16,1.09a11.23,11.23,0,0,1,2.69-1.29Zm10.22,4.49c0,2.17-1,3.46-3.38,3.46a14.74,14.74,0,0,1-2.72-.28v4l-2.56.38V31.54h2l.25.82a5.54,5.54,0,0,1,3.22-1c2.06,0,3.15,1.16,3.15,3.4Zm-6.1,1.11a11.85,11.85,0,0,0,2.26.26c.93,0,1.28-.43,1.28-1.31V34.68c0-.81-.31-1.25-1.26-1.25a3.66,3.66,0,0,0-2.28.89Z"/><path class="cls-1" d="M139.64,70.53c3.08-1.33,4.48-3.21,4.48-6.53V59.82c0-5.45-3.09-8.59-10.72-8.59H117.55V91.52h16.94c7.33,0,11-3.56,11-9.25V78C145.51,74,143.33,71.31,139.64,70.53Zm-14.46-13h7.32c2.78,0,4,1,4,3.27v3.57c0,2.36-1.08,3.75-4.06,3.75h-7.25ZM137.89,81.3c0,3-1.46,3.93-4.91,3.93h-7.8v-11h8.47c3,0,4.24,1.33,4.24,4.06Z"/><path class="cls-1" d="M161.54,61.45c-10.11,0-12.82,5.57-12.82,11.62v7.44c0,6,2.71,11.62,12.82,11.62s12.83-5.57,12.83-11.62V73.07C174.37,67,171.64,61.45,161.54,61.45ZM167,80.76c0,3.32-1.51,5.08-5.45,5.08s-5.44-1.76-5.44-5.08V72.83c0-3.33,1.51-5.08,5.44-5.08S167,69.5,167,72.83Z"/><path class="cls-1" d="M196.51,62.06V82.51c-2.66,1.39-5.87,2.48-7.68,2.48s-2.36-.78-2.36-2.36V62.06h-7.38V83.42c0,5.26,1.75,8.71,6.65,8.71A29.44,29.44,0,0,0,197.54,89l.72,2.48h5.64V62.06Z"/><path class="cls-1" d="M228.4,61.45a29.61,29.61,0,0,0-11.8,3.09l-.72-2.48h-5.64V91.52h7.39V71.08c2.66-1.39,5.87-2.48,7.68-2.48s2.36.78,2.36,2.35V91.52h7.38V70.17C235.05,64.9,233.3,61.45,228.4,61.45Z"/><path class="cls-1" d="M257.5,50V62.31a40.19,40.19,0,0,0-7.87-.86c-6.83,0-9.74,3.88-9.74,10.41v10c0,6.71,3.15,10.22,9.08,10.22A15.49,15.49,0,0,0,258.29,89l.72,2.48h5.87V49Zm0,33.16a10.43,10.43,0,0,1-6.59,2.66c-2.73,0-3.63-1.33-3.63-3.75V71.68c0-2.66,1-3.93,3.68-3.93a32.33,32.33,0,0,1,6.54.79Z"/><path class="cls-1" d="M280.8,61.45a38.34,38.34,0,0,0-10.11,1.4l.91,5.62a41.47,41.47,0,0,1,8.17-.9c4.71,0,5.62,1.15,5.62,4.42v3.08h-7.08c-6,0-8.59,2.3-8.59,8.29,0,5.08,2.3,8.77,7.68,8.77a16.18,16.18,0,0,0,8.78-2.6l.54,2h6.06V72.1C292.78,64.72,290.05,61.45,280.8,61.45Zm4.59,23.3a11.57,11.57,0,0,1-5.56,1.51c-2.3,0-3-.91-3-3,0-2.23.67-2.9,3.09-2.9h5.44Z"/><path class="cls-1" d="M313,61.45a33,33,0,0,0-7.74,3.88l-.48-3.27h-6.24V91.52h7.38v-19a57.85,57.85,0,0,1,7.81-4.3Z"/><path class="cls-1" d="M335.67,62.06,328.9,84.44l-6.72-22.38h-7.56l9.32,29.46h2.59l-3.68,11.92h7l3.93-11.92,9.43-29.46Z"/><polygon class="cls-2" points="44.94 102.47 44.94 97.64 54.96 97.64 54.96 94.67 50.1 94.67 50.1 89.82 73.03 89.82 62.38 71.38 73.03 52.93 49.53 52.93 49.53 78.51 36.88 78.51 36.88 40.29 80.4 40.29 87.67 52.87 76.98 71.38 87.88 90.26 80.84 102.47 44.94 102.47"/><rect class="cls-2" x="42.04" y="89.82" width="4.85" height="4.85"/><rect class="cls-2" x="36.88" y="97.61" width="4.85" height="4.85"/></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -66,9 +66,12 @@ module('Acceptance | authentication', function (hooks) {
|
||||
scope = { id: orgScope.id, type: orgScope.type };
|
||||
globalAuthMethod = this.server.create('auth-method', {
|
||||
scope: globalScope,
|
||||
type: 'password',
|
||||
});
|
||||
authMethod = this.server.create('auth-method', {
|
||||
scope: orgScope,
|
||||
type: 'password',
|
||||
});
|
||||
this.server.create('user', { scope: globalScope });
|
||||
authMethod = this.server.create('auth-method', { scope: orgScope });
|
||||
orgScopeID = orgScope.id;
|
||||
globalAuthMethodID = globalAuthMethod.id;
|
||||
authMethodID = authMethod.id;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module(
|
||||
'Unit | Controller | scopes/scope/authenticate/method/oidc',
|
||||
function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
// TODO: Replace this with your real tests.
|
||||
test('it exists', function (assert) {
|
||||
let controller = this.owner.lookup(
|
||||
'controller:scopes/scope/authenticate/method/oidc'
|
||||
);
|
||||
assert.ok(controller);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module(
|
||||
'Unit | Route | scopes/scope/authenticate/method/index',
|
||||
function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup(
|
||||
'route:scopes/scope/authenticate/method/index'
|
||||
);
|
||||
assert.ok(route);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module(
|
||||
'Unit | Route | scopes/scope/authenticate/method/oidc',
|
||||
function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup(
|
||||
'route:scopes/scope/authenticate/method/oidc'
|
||||
);
|
||||
assert.ok(route);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class ScopesScopeAuthenticateMethodOidcController extends Controller {
|
||||
// =services
|
||||
|
||||
@service session;
|
||||
|
||||
// =attributes
|
||||
|
||||
/**
|
||||
* Authetnication URL for the pending OIDC flow, if any.
|
||||
* @type {?string}
|
||||
*/
|
||||
get authURL() {
|
||||
return this.session.data.pending.oidc.attributes.auth_url;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ Router.map(function () {
|
||||
this.route('scopes', function () {
|
||||
this.route('scope', { path: ':scope_id' }, function () {
|
||||
this.route('authenticate', function () {
|
||||
this.route('method', { path: ':auth_method_id' });
|
||||
this.route('method', { path: ':auth_method_id' }, function () {
|
||||
this.route('oidc');
|
||||
});
|
||||
});
|
||||
this.route('projects', function () {
|
||||
this.route('targets', function () {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { getOwner } from '@ember/application';
|
||||
import { action } from '@ember/object';
|
||||
import loading from 'ember-loading/decorator';
|
||||
import { notifyError } from 'core/decorators/notify';
|
||||
@@ -9,6 +10,7 @@ export default class ScopesScopeAuthenticateMethodRoute extends Route {
|
||||
|
||||
@service session;
|
||||
@service notify;
|
||||
@service ipc;
|
||||
@service intl;
|
||||
|
||||
// =methods
|
||||
@@ -23,6 +25,26 @@ export default class ScopesScopeAuthenticateMethodRoute extends Route {
|
||||
return this.store.findRecord('auth-method', id, adapterOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async startOIDCAuthentication(authenticatorName, options) {
|
||||
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
|
||||
const json = await oidc.startAuthentication(options);
|
||||
await this.openExternalOIDCFlow(json.attributes.auth_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) {
|
||||
await this.ipc.invoke('openExternal', url);
|
||||
}
|
||||
|
||||
// =actions
|
||||
|
||||
/**
|
||||
@@ -37,10 +59,23 @@ export default class ScopesScopeAuthenticateMethodRoute extends Route {
|
||||
const authMethod = this.modelFor('scopes.scope.authenticate.method');
|
||||
const authenticatorName = `authenticator:${authMethod.type}`;
|
||||
const requestCookies = false;
|
||||
await this.session.authenticate(authenticatorName, creds, requestCookies, {
|
||||
scope,
|
||||
authMethod,
|
||||
});
|
||||
this.replaceWith('index');
|
||||
switch (authMethod.type) {
|
||||
case 'password':
|
||||
await this.session.authenticate(
|
||||
authenticatorName,
|
||||
creds,
|
||||
requestCookies,
|
||||
{ scope, authMethod }
|
||||
);
|
||||
this.transitionTo('index');
|
||||
break;
|
||||
case 'oidc':
|
||||
await this.startOIDCAuthentication(authenticatorName, {
|
||||
scope,
|
||||
authMethod,
|
||||
});
|
||||
this.transitionTo('scopes.scope.authenticate.method.oidc');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ScopesScopeAuthenticateMethodIndexRoute extends Route {}
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { action } from '@ember/object';
|
||||
import { notifyError } from 'core/decorators/notify';
|
||||
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();
|
||||
}
|
||||
/* eslint-disable-next-line prettier/prettier */
|
||||
}).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.attemptFetchToken({ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* An error in this route indicates authentication failed. The user is
|
||||
* notified and returned to the index.
|
||||
*/
|
||||
@action
|
||||
@notifyError(() => 'errors.authentication-failed.title', { catch: true })
|
||||
error(e) {
|
||||
this.transitionTo('scopes.scope.authenticate.method.index');
|
||||
// rethrow the error to activate the notifyError decorator
|
||||
throw e;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,11 +21,23 @@
|
||||
</Rose::Dropdown>
|
||||
|
||||
<Rose::Nav::Tabs as |nav|>
|
||||
{{#each @model.authMethods as |authMethod|}}
|
||||
<nav.link @route="scopes.scope.authenticate.method" @model={{authMethod.id}}>
|
||||
{{t (concat "resources.auth-method.types." authMethod.type)}}
|
||||
</nav.link>
|
||||
{{/each}}
|
||||
{{#if (feature-flag 'oidc')}}
|
||||
{{! OIDC enable: list all auth methods }}
|
||||
{{#each @model.authMethods as |authMethod|}}
|
||||
<nav.link @route="scopes.scope.authenticate.method" @model={{authMethod.id}}>
|
||||
{{t (concat "resources.auth-method.types." authMethod.type)}}
|
||||
</nav.link>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{! OIDC disabled: filter out OIDC auth methods}}
|
||||
{{#each @model.authMethods as |authMethod|}}
|
||||
{{#unless authMethod.isOIDC}}
|
||||
<nav.link @route="scopes.scope.authenticate.method" @model={{authMethod.id}}>
|
||||
{{t (concat "resources.auth-method.types." authMethod.type)}}
|
||||
</nav.link>
|
||||
{{/unless}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</Rose::Nav::Tabs>
|
||||
|
||||
{{#if @model.authMethods}}
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
<Form::Authenticate
|
||||
@model={{@model}}
|
||||
@submit={{route-action "authenticate"}}
|
||||
@disabled={{is-loading}}
|
||||
/>
|
||||
{{outlet}}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{#if (feature-flag 'oidc')}}
|
||||
{{! OIDC enabled: show form for any auth method}}
|
||||
<Form::Authenticate
|
||||
@model={{@model}}
|
||||
@submit={{route-action "authenticate"}}
|
||||
@disabled={{is-loading}}
|
||||
/>
|
||||
{{else}}
|
||||
{{! OIDC disabled: hide OIDC auth form}}
|
||||
{{#unless @model.isOIDC}}
|
||||
<Form::Authenticate
|
||||
@model={{@model}}
|
||||
@submit={{route-action "authenticate"}}
|
||||
@disabled={{is-loading}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Rose::Dialog::Cover>
|
||||
<:header>
|
||||
<h1>{{t 'resources.auth-method.messages.pending.title'}}</h1>
|
||||
</:header>
|
||||
<:body>
|
||||
<p>{{t 'resources.auth-method.messages.pending.description'}}</p>
|
||||
<p>
|
||||
{{t 'resources.auth-method.questions.no-see-window'}}
|
||||
<a
|
||||
href="{{this.authURL}}"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{t 'actions.retry'}}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<LinkTo @route="scopes.scope.authenticate.method">
|
||||
{{t 'actions.cancel'}}
|
||||
</LinkTo>
|
||||
</p>
|
||||
</:body>
|
||||
</Rose::Dialog::Cover>
|
||||
@@ -44,6 +44,7 @@ module.exports = function (environment) {
|
||||
|
||||
notifyTimeout: 4000,
|
||||
sessionPollingTimeoutSeconds: 2.5,
|
||||
oidcPollingTimeoutSeconds: 1,
|
||||
|
||||
documentation: {
|
||||
baseURL: 'https://boundaryproject.io/help/desktop',
|
||||
@@ -52,6 +53,10 @@ module.exports = function (environment) {
|
||||
sessions: '/sessions',
|
||||
},
|
||||
},
|
||||
|
||||
featureFlags: {
|
||||
oidc: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.ENABLE_MIRAGE) {
|
||||
@@ -69,6 +74,9 @@ module.exports = function (environment) {
|
||||
// of the UI, which only makes sense in development where the origin is
|
||||
// usually the same as the application origin.
|
||||
ENV.autoOrigin = true;
|
||||
|
||||
// Enable features in development
|
||||
ENV.featureFlags['oidc'] = true;
|
||||
}
|
||||
|
||||
if (environment === 'test') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { shell } = require('electron');
|
||||
const isDev = require('electron-is-dev');
|
||||
const handle = require('./ipc-handler');
|
||||
const boundaryCli = require('./boundary-cli');
|
||||
const origin = require('./origin.js');
|
||||
@@ -26,12 +27,17 @@ handle('resetOrigin', async (requestOrigin) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens the specified URL in an external browser. Only HTTPS URLs are allowed.
|
||||
* Opens the specified URL in an external browser. Only secure HTTPs URLs are
|
||||
* allowed except in the case of localhost (which enables development and
|
||||
* testing workflows). Insecure URLs are allowed in dev mode.
|
||||
*/
|
||||
handle('openExternal', async (href) => {
|
||||
if (href.startsWith('https://')) {
|
||||
const isSecure = href.startsWith('https://');
|
||||
const isLocalhost = href.startsWith('http://localhost')
|
||||
|| href.startsWith('http://127.0.0.1');
|
||||
if (isSecure || isLocalhost || isDev) {
|
||||
// openExternal is necessary in order to display documentation and to
|
||||
// support arbitrary OIDC flows. The protocol is validated.
|
||||
// support arbitrary OIDC flows. The protocol is validated (see above).
|
||||
shell.openExternal(href); /* eng-disable OPEN_EXTERNAL_JS_CHECK */
|
||||
} else {
|
||||
throw new Error(
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
"postinstall": "cd electron-app && yarn install",
|
||||
"posttest": "ember coverage-merge && yarn clean:coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"ember-named-blocks-polyfill": "^0.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@doyensec/electronegativity": "^1.8.1",
|
||||
"@ember/jquery": "^1.1.0",
|
||||
|
||||
@@ -87,9 +87,11 @@ module('Acceptance | authentication', function (hooks) {
|
||||
// create other resources
|
||||
instances.authMethods.global = this.server.create('auth-method', {
|
||||
scope: instances.scopes.global,
|
||||
type: 'password',
|
||||
});
|
||||
instances.authMethods.org = this.server.create('auth-method', {
|
||||
scope: instances.scopes.org,
|
||||
type: 'password',
|
||||
});
|
||||
|
||||
urls.scopes.global = `/scopes/${instances.scopes.global.id}`;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { Response } from 'miragejs';
|
||||
|
||||
module('Unit | Authenticator | password', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
let store;
|
||||
let applicationAdapter;
|
||||
let session;
|
||||
let authenticator;
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
store = this.owner.lookup('service:store');
|
||||
applicationAdapter = store.adapterFor('application');
|
||||
session = this.owner.lookup('service:session');
|
||||
authenticator = this.owner.lookup('authenticator:password');
|
||||
session.invalidate();
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
session.invalidate();
|
||||
});
|
||||
|
||||
test('it adds an authorization header to application adapter on restore', async function (assert) {
|
||||
assert.expect(1);
|
||||
const mockData = { id: 'token1234', token: 'token1234' };
|
||||
this.server.get(
|
||||
authenticator.buildTokenValidationEndpointURL(mockData.id),
|
||||
() => {
|
||||
return new Response(200);
|
||||
}
|
||||
);
|
||||
authenticator.restore(mockData);
|
||||
assert.equal(applicationAdapter.headers.Authorization, 'Bearer token1234');
|
||||
});
|
||||
|
||||
test('it adds an authorization header to application adapter on authenticate', async function (assert) {
|
||||
assert.expect(1);
|
||||
const creds = { identification: 'foo', password: 'bar' };
|
||||
const scope = { id: 'global' };
|
||||
const authMethod = { id: 'paum_1234' };
|
||||
this.server.create('scope', { id: 'global', type: 'global' });
|
||||
this.server.create('auth-method', { id: authMethod.id, scopeId: 'global' });
|
||||
await authenticator.authenticate(creds, null, { scope, authMethod });
|
||||
assert.equal(
|
||||
applicationAdapter.headers.Authorization,
|
||||
'Bearer thetokenstring'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module(
|
||||
'Unit | Controller | scopes/scope/authenticate/method/oidc',
|
||||
function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
// TODO: Replace this with your real tests.
|
||||
test('it exists', function (assert) {
|
||||
let controller = this.owner.lookup(
|
||||
'controller:scopes/scope/authenticate/method/oidc'
|
||||
);
|
||||
assert.ok(controller);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module(
|
||||
'Unit | Route | scopes/scope/authenticate/method/index',
|
||||
function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup(
|
||||
'route:scopes/scope/authenticate/method/index'
|
||||
);
|
||||
assert.ok(route);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module(
|
||||
'Unit | Route | scopes/scope/authenticate/method/oidc',
|
||||
function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup(
|
||||
'route:scopes/scope/authenticate/method/oidc'
|
||||
);
|
||||
assert.ok(route);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -7578,7 +7578,7 @@ debug@~4.1.0:
|
||||
dependencies:
|
||||
ms "^2.1.1"
|
||||
|
||||
debuglog@*, debuglog@^1.0.1:
|
||||
debuglog@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
|
||||
integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=
|
||||
@@ -12343,7 +12343,7 @@ import-lazy@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153"
|
||||
integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==
|
||||
|
||||
imurmurhash@*, imurmurhash@^0.1.4:
|
||||
imurmurhash@^0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
|
||||
integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
|
||||
@@ -14041,11 +14041,6 @@ lodash._baseflatten@^3.0.0:
|
||||
lodash.isarguments "^3.0.0"
|
||||
lodash.isarray "^3.0.0"
|
||||
|
||||
lodash._baseindexof@*:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash._baseindexof/-/lodash._baseindexof-3.1.0.tgz#fe52b53a1c6761e42618d654e4a25789ed61822c"
|
||||
integrity sha1-/lK1OhxnYeQmGNZU5KJXie1hgiw=
|
||||
|
||||
lodash._baseuniq@~4.6.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8"
|
||||
@@ -14054,16 +14049,11 @@ lodash._baseuniq@~4.6.0:
|
||||
lodash._createset "~4.0.0"
|
||||
lodash._root "~3.0.0"
|
||||
|
||||
lodash._bindcallback@*, lodash._bindcallback@^3.0.0:
|
||||
lodash._bindcallback@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e"
|
||||
integrity sha1-5THCdkTPi1epnhftlbNcdIeJOS4=
|
||||
|
||||
lodash._cacheindexof@*:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash._cacheindexof/-/lodash._cacheindexof-3.0.2.tgz#3dc69ac82498d2ee5e3ce56091bafd2adc7bde92"
|
||||
integrity sha1-PcaayCSY0u5ePOVgkbr9Ktx73pI=
|
||||
|
||||
lodash._createassigner@^3.0.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11"
|
||||
@@ -14073,19 +14063,12 @@ lodash._createassigner@^3.0.0:
|
||||
lodash._isiterateecall "^3.0.0"
|
||||
lodash.restparam "^3.0.0"
|
||||
|
||||
lodash._createcache@*:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash._createcache/-/lodash._createcache-3.1.2.tgz#56d6a064017625e79ebca6b8018e17440bdcf093"
|
||||
integrity sha1-VtagZAF2JeeevKa4AY4XRAvc8JM=
|
||||
dependencies:
|
||||
lodash._getnative "^3.0.0"
|
||||
|
||||
lodash._createset@~4.0.0:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26"
|
||||
integrity sha1-D0ZZ+7CddRlPqeK4imZE02PJ/iY=
|
||||
|
||||
lodash._getnative@*, lodash._getnative@^3.0.0:
|
||||
lodash._getnative@^3.0.0:
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
|
||||
integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=
|
||||
@@ -14303,7 +14286,7 @@ lodash.pick@^4.4.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"
|
||||
integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=
|
||||
|
||||
lodash.restparam@*, lodash.restparam@^3.0.0:
|
||||
lodash.restparam@^3.0.0:
|
||||
version "3.6.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
|
||||
integrity sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=
|
||||
|
||||
Reference in new issue
Block a user