ui: Model Layer for SSO Support (#7771)

* ui: Adds model layer required for SSO

1. oidc-provider ember-data triplet plus repo, plus addition of torii
addon
2. Make blocking queries support a Cache-Control: no-cache header
3. Tweaks to the token model layer in preparation for SSO work

* Fix up meta related Cache-Control tests

* Add tests adapter tests for URL shapes

* Reset Cache-Control to the original value, return something from logout
This commit is contained in:
John Cowen 2020-05-05 17:29:35 +01:00 committed by John Cowen
parent ed2444c0b5
commit 6d7a95f82d
24 changed files with 434 additions and 35 deletions

View File

@ -124,6 +124,10 @@ export default Adapter.extend({
} catch (e) {
error = e;
}
// TODO: This comes originates from ember-data
// This can be confusing if you need to use this with Promise.reject
// Consider changing this to return the error and then
// throw from the call site instead
throw error;
},
query: function(store, type, query) {

View File

@ -0,0 +1,97 @@
import Adapter from './application';
import { inject as service } from '@ember/service';
import { env } from 'consul-ui/env';
import nonEmptySet from 'consul-ui/utils/non-empty-set';
let Namespace;
if (env('CONSUL_NSPACES_ENABLED')) {
Namespace = nonEmptySet('Namespace');
} else {
Namespace = () => ({});
}
export default Adapter.extend({
env: service('env'),
requestForQuery: function(request, { dc, ns, index }) {
return request`
GET /v1/internal/ui/oidc-auth-methods?${{ dc }}
${{
index,
...this.formatNspace(ns),
}}
`;
},
requestForQueryRecord: function(request, { dc, ns, id }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
POST /v1/acl/oidc/auth-url?${{ dc }}
Cache-Control: no-store
${{
...Namespace(ns),
AuthMethod: id,
RedirectURI: `${this.env.var('CONSUL_BASE_UI_URL')}/torii/redirect.html`,
}}
`;
},
requestForAuthorize: function(request, { dc, ns, id, code, state }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
if (typeof code === 'undefined') {
throw new Error('You must specify an code');
}
if (typeof state === 'undefined') {
throw new Error('You must specify an state');
}
return request`
POST /v1/acl/oidc/callback?${{ dc }}
Cache-Control: no-store
${{
...Namespace(ns),
AuthMethod: id,
Code: code,
State: state,
}}
`;
},
requestForLogout: function(request, { id }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
POST /v1/acl/logout
Cache-Control: no-store
X-Consul-Token: ${id}
`;
},
authorize: function(store, type, id, snapshot) {
return this.request(
function(adapter, request, serialized, unserialized) {
return adapter.requestForAuthorize(request, serialized, unserialized);
},
function(serializer, respond, serialized, unserialized) {
return serializer.respondForAuthorize(respond, serialized, unserialized);
},
snapshot,
type.modelName
);
},
logout: function(store, type, id, snapshot) {
return this.request(
function(adapter, request, serialized, unserialized) {
return adapter.requestForLogout(request, serialized, unserialized);
},
function(serializer, respond, serialized, unserialized) {
// its ok to return nothing here for the moment at least
return {};
},
snapshot,
type.modelName
);
},
});

View File

@ -104,6 +104,7 @@ export default Adapter.extend({
return request`
GET /v1/acl/token/self?${{ dc }}
X-Consul-Token: ${secret}
Cache-Control: no-store
${{ index }}
`;
@ -132,7 +133,7 @@ export default Adapter.extend({
return adapter.requestForSelf(request, serialized, data);
},
function(serializer, respond, serialized, data) {
return serializer.respondForQueryRecord(respond, serialized, data);
return serializer.respondForSelf(respond, serialized, data);
},
unserialized,
type.modelName

View File

@ -0,0 +1,37 @@
import Oauth2CodeProvider from 'torii/providers/oauth2-code';
const NAME = 'oidc-with-url';
const Provider = Oauth2CodeProvider.extend({
name: NAME,
buildUrl: function() {
return this.baseUrl;
},
open: function(options) {
const name = this.get('name'),
url = this.buildUrl(),
responseParams = ['state', 'code'],
responseType = 'code';
return this.get('popup')
.open(url, responseParams, options)
.then(function(authData) {
// the same as the parent class but with an authorizationState added
return {
authorizationState: authData.state,
authorizationCode: decodeURIComponent(authData[responseType]),
provider: name,
};
});
},
close: function() {
const popup = this.get('popup.remote') || {};
if (typeof popup.close === 'function') {
return popup.close();
}
},
});
export function initialize(application) {
application.register(`torii-provider:${NAME}`, Provider);
}
export default {
initialize,
};

View File

@ -0,0 +1,15 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'Name';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
meta: attr(),
Datacenter: attr('string'),
DisplayName: attr('string'),
Kind: attr('string'),
Namespace: attr('string'),
AuthURL: attr('string'),
});

View File

@ -20,6 +20,7 @@ export default Model.extend({
Description: attr('string', {
defaultValue: '',
}),
meta: attr(),
Datacenter: attr('string'),
Namespace: attr('string'),
Local: attr('boolean'),

View File

@ -7,6 +7,7 @@ import {
HEADERS_DATACENTER as HTTP_HEADERS_DATACENTER,
HEADERS_NAMESPACE as HTTP_HEADERS_NAMESPACE,
} from 'consul-ui/utils/http/consul';
import { CACHE_CONTROL as HTTP_HEADERS_CACHE_CONTROL } from 'consul-ui/utils/http/headers';
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
import { NSPACE_KEY } from 'consul-ui/models/nspace';
import createFingerprinter from 'consul-ui/utils/create-fingerprinter';
@ -101,7 +102,7 @@ export default Serializer.extend({
// this could get confusing if you tried to override
// say `normalizeQueryResponse`
// TODO: consider creating a method for each one of the `normalize...Response` family
normalizeResponse: function(store, primaryModelClass, payload, id, requestType) {
normalizeResponse: function(store, modelClass, payload, id, requestType) {
// Pick the meta/headers back off the payload and cleanup
// before we go through serializing
const headers = payload[HTTP_HEADERS_SYMBOL] || {};
@ -114,34 +115,39 @@ export default Serializer.extend({
// (which was the reason for the Symbol-like property earlier)
// use a method modelled on ember-data methods so we have the opportunity to
// do this on a per-model level
const meta = this.normalizeMeta(
store,
primaryModelClass,
headers,
normalizedPayload,
id,
requestType
);
if (requestType === 'queryRecord') {
const meta = this.normalizeMeta(store, modelClass, headers, normalizedPayload, id, requestType);
if (requestType !== 'query') {
normalizedPayload.meta = meta;
}
return this._super(
const res = this._super(
store,
primaryModelClass,
modelClass,
{
meta: meta,
[primaryModelClass.modelName]: normalizedPayload,
[modelClass.modelName]: normalizedPayload,
},
id,
requestType
);
// If the result of the super normalizeResponse is undefined
// its because the JSONSerializer (which REST inherits from)
// doesn't recognise the requestType, in this case its likely to be an 'action'
// request rather than a specific 'load me some data' one.
// Therefore its ok to bypass the store here for the moment
// we currently use this for self, but it also would affect any custom
// methods that use a serializer in our custom service/store
if (typeof res === 'undefined') {
return payload;
}
return res;
},
timestamp: function() {
return new Date().getTime();
},
normalizeMeta: function(store, primaryModelClass, headers, payload, id, requestType) {
normalizeMeta: function(store, modelClass, headers, payload, id, requestType) {
const meta = {
cursor: headers[HTTP_HEADERS_INDEX],
cacheControl: headers[HTTP_HEADERS_CACHE_CONTROL.toLowerCase()],
cursor: headers[HTTP_HEADERS_INDEX.toLowerCase()],
dc: headers[HTTP_HEADERS_DATACENTER.toLowerCase()],
nspace: headers[HTTP_HEADERS_NAMESPACE.toLowerCase()],
};

View File

@ -0,0 +1,30 @@
import Serializer from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/oidc-provider';
export default Serializer.extend({
primaryKey: PRIMARY_KEY,
slugKey: SLUG_KEY,
respondForAuthorize: function(respond, serialized, data) {
// we avoid the parent serializer here as it tries to create a
// fingerprint for an 'action' request
// but we still need to pass the headers through
return respond((headers, body) => {
return this.attachHeaders(headers, body, data);
});
},
respondForQueryRecord: function(respond, query) {
// add the name and nspace here so we can merge this
// TODO: Look to see if we always want the merging functionality
return this._super(
cb =>
respond((headers, body) =>
cb(headers, {
Name: query.id,
Namespace: query.ns,
...body,
})
),
query
);
},
});

View File

@ -31,6 +31,9 @@ export default Serializer.extend(WithPolicies, WithRoles, {
}
return data;
},
respondForSelf: function(respond, query) {
return this.respondForQueryRecord(respond, query);
},
respondForUpdateRecord: function(respond, serialized, data) {
return this._super(
cb =>

View File

@ -2,6 +2,10 @@
import Service, { inject as service } from '@ember/service';
import { get, set } from '@ember/object';
import { CACHE_CONTROL, CONTENT_TYPE } from 'consul-ui/utils/http/headers';
import { HEADERS_TOKEN as CONSUL_TOKEN } from 'consul-ui/utils/http/consul';
import { env } from 'consul-ui/env';
import getObjectPool from 'consul-ui/utils/get-object-pool';
import Request from 'consul-ui/utils/http/request';
@ -29,7 +33,7 @@ class HTTPError extends Error {
}
}
const dispose = function(request) {
if (request.headers()['content-type'] === 'text/event-stream') {
if (request.headers()[CONTENT_TYPE.toLowerCase()] === 'text/event-stream') {
const xhr = request.connection();
// unsent and opened get aborted
// headers and loading means wait for it
@ -127,30 +131,40 @@ export default Service.extend({
const [url, ...headerParts] = urlParts.join(' ').split('\n');
return client.settings.findBySlug('token').then(function(token) {
const requestHeaders = createHeaders(headerParts);
const headers = {
// default to application/json
...{
'Content-Type': 'application/json; charset=utf-8',
[CONTENT_TYPE]: 'application/json; charset=utf-8',
},
// add any application level headers
...{
'X-Consul-Token': typeof token.SecretID === 'undefined' ? '' : token.SecretID,
[CONSUL_TOKEN]: typeof token.SecretID === 'undefined' ? '' : token.SecretID,
},
// but overwrite or add to those from anything in the specific request
...createHeaders(headerParts),
...requestHeaders,
};
// We use cache-control in the response
// but we don't want to send it, but we artificially
// tag it onto the response below if it is set on the request
delete headers[CACHE_CONTROL];
return new Promise(function(resolve, reject) {
const options = {
url: url.trim(),
method: method,
contentType: headers['Content-Type'],
contentType: headers[CONTENT_TYPE],
// type: 'json',
complete: function(xhr, textStatus) {
client.complete(this.id);
},
success: function(response, status, xhr) {
const headers = createHeaders(xhr.getAllResponseHeaders().split('\n'));
if (typeof requestHeaders[CACHE_CONTROL] !== 'undefined') {
// if cache-control was on the request, artificially tag
// it back onto the response, also see comment above
headers[CACHE_CONTROL] = requestHeaders[CACHE_CONTROL];
}
const respond = function(cb) {
return cb(headers, response);
};
@ -191,7 +205,7 @@ export default Service.extend({
// for write-like actions
// potentially we should change things so you _have_ to do that
// as doing it this way is a little magical
if (method !== 'GET' && headers['Content-Type'].indexOf('json') !== -1) {
if (method !== 'GET' && headers[CONTENT_TYPE].indexOf('json') !== -1) {
options.data = JSON.stringify(body);
} else {
// TODO: Does this need urlencoding? Assuming jQuery does this
@ -204,7 +218,7 @@ export default Service.extend({
// also see adapters/kv content-types in requestForCreate/UpdateRecord
// also see https://github.com/hashicorp/consul/issues/3804
options.contentType = 'application/json; charset=utf-8';
headers['Content-Type'] = options.contentType;
headers[CONTENT_TYPE] = options.contentType;
//
options.beforeSend = function(xhr) {
if (headers) {

View File

@ -0,0 +1,56 @@
import RepositoryService from 'consul-ui/services/repository';
import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
import { set } from '@ember/object';
const modelName = 'oidc-provider';
const OAUTH_PROVIDER_NAME = 'oidc-with-url';
export default RepositoryService.extend({
manager: service('torii'),
init: function() {
this._super(...arguments);
this.provider = getOwner(this).lookup(`torii-provider:${OAUTH_PROVIDER_NAME}`);
},
getModelName: function() {
return modelName;
},
authorize: function(id, code, state, dc, nspace, configuration = {}) {
const query = {
id: id,
code: code,
state: state,
dc: dc,
ns: nspace,
};
return this.store.authorize(this.getModelName(), query);
},
logout: function(id, code, state, dc, nspace, configuration = {}) {
// TODO: Temporarily call this secret, as we alreayd do that with
// self in the `store` look to see whether we should just call it id like
// the rest
const query = {
id: id,
};
return this.store.logout(this.getModelName(), query);
},
close: function() {
this.manager.close(OAUTH_PROVIDER_NAME);
},
findCodeByURL: function(src) {
// TODO: Maybe move this to the provider itself
set(this.provider, 'baseUrl', src);
return this.manager.open(OAUTH_PROVIDER_NAME, {}).catch(e => {
let err;
switch (true) {
case e.message.startsWith('remote was closed'):
err = new Error('Remote was closed');
err.statusCode = 499;
break;
default:
err = new Error(e.message);
err.statusCode = 500;
}
this.store.adapterFor(this.getModelName()).error(err);
});
},
});

View File

@ -21,7 +21,16 @@ export default Store.extend({
self: function(modelName, token) {
// TODO: no normalization, type it properly for the moment
const adapter = this.adapterFor(modelName);
return adapter.self(this, { modelName: modelName }, token.secret, token);
const serializer = this.serializerFor(modelName);
const modelClass = { modelName: modelName };
// self is the only custom store method that goes through the serializer for the moment
// this means it will have its meta data set correctly
// if other methods need meta adding, then this should be carried over to
// other methods. Ideally this would have been done from the outset
// TODO: Carry this over to the other methods ^
return adapter
.self(this, modelClass, token.secret, token)
.then(payload => serializer.normalizeResponse(this, modelClass, payload, token, 'self'));
},
//
// TODO: This one is only for nodes, should fail nicely if you call it
@ -31,10 +40,21 @@ export default Store.extend({
const adapter = this.adapterFor(modelName);
return adapter.queryLeader(this, { modelName: modelName }, null, query);
},
// TODO: This one is only for ACL, should fail nicely if you call it
// for anything other than ACLs for good DX
// TODO: This one is only for nspaces and OIDC, should fail nicely if you call it
// for anything other than nspaces/OIDC for good DX
authorize: function(modelName, query = {}) {
// TODO: no normalization, type it properly for the moment
return this.adapterFor(modelName).authorize(this, { modelName: modelName }, null, query);
const adapter = this.adapterFor(modelName);
const serializer = this.serializerFor(modelName);
const modelClass = { modelName: modelName };
return adapter
.authorize(this, modelClass, null, query)
.then(payload =>
serializer.normalizeResponse(this, modelClass, payload, undefined, 'authorize')
);
},
logout: function(modelName, query = {}) {
const adapter = this.adapterFor(modelName);
const modelClass = { modelName: modelName };
return adapter.logout(this, modelClass, query.id, query);
},
});

View File

@ -96,10 +96,13 @@ export default function(EventSource, backoff = create5xxBackoff()) {
// pick off the `cursor` from the meta and add it to configuration
// along with cursor validation
configuration.cursor = validateCursor(meta.cursor, configuration.cursor);
configuration.cacheControl = meta.cacheControl;
}
this.currentEvent = event;
this.dispatchEvent(this.currentEvent);
const throttledResolve = throttle(configuration, this.currentEvent, this.previousEvent);
if ((configuration.cacheControl || '').indexOf('no-store') === -1) {
this.currentEvent = event;
}
this.dispatchEvent(event);
const throttledResolve = throttle(configuration, event, this.previousEvent);
this.previousEvent = this.currentEvent;
return throttledResolve(result);
});

View File

@ -1,7 +1,8 @@
// TODO: Need to make all these headers capital case
export const HEADERS_NAMESPACE = 'X-Consul-Namespace';
export const HEADERS_DATACENTER = 'x-consul-datacenter';
export const HEADERS_INDEX = 'x-consul-index';
export const HEADERS_DIGEST = 'x-consul-contenthash';
export const HEADERS_DATACENTER = 'X-Consul-Datacenter';
export const HEADERS_INDEX = 'X-Consul-Index';
export const HEADERS_TOKEN = 'X-Consul-Token';
export const HEADERS_DIGEST = 'X-Consul-ContentHash';
//
export const HEADERS_SYMBOL = '__consul_ui_http_headers__';

View File

@ -0,0 +1,2 @@
export const CACHE_CONTROL = 'Cache-Control';
export const CONTENT_TYPE = 'Content-Type';

View File

@ -123,7 +123,8 @@
"prettier": "^1.10.2",
"qunit-dom": "^1.0.0",
"tape": "^4.13.0",
"text-encoding": "^0.7.0"
"text-encoding": "^0.7.0",
"torii": "^0.10.1"
},
"engines": {
"node": "10.* || >= 12"

View File

@ -0,0 +1,79 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { env } from '../../../env';
const shouldHaveNspace = function(nspace) {
return typeof nspace !== 'undefined' && env('CONSUL_NSPACES_ENABLED');
};
module('Integration | Adapter | oidc-provider', function(hooks) {
setupTest(hooks);
const dc = 'dc-1';
const id = 'slug';
const undefinedNspace = 'default';
[undefinedNspace, 'team-1', undefined].forEach(nspace => {
test('requestForQuery returns the correct url/method', function(assert) {
const adapter = this.owner.lookup('adapter:oidc-provider');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/internal/ui/oidc-auth-methods?dc=${dc}`;
let actual = adapter.requestForQuery(client.url, {
dc: dc,
ns: nspace,
});
actual = actual.split('\n');
assert.equal(actual.shift().trim(), expected);
actual = actual.join('\n').trim();
assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`);
});
test('requestForQueryRecord returns the correct url/method', function(assert) {
const adapter = this.owner.lookup('adapter:oidc-provider');
const client = this.owner.lookup('service:client/http');
const expected = `POST /v1/acl/oidc/auth-url?dc=${dc}`;
const actual = adapter
.requestForQueryRecord(client.url, {
dc: dc,
id: id,
ns: nspace,
})
.split('\n')
.shift();
assert.equal(actual, expected);
});
test("requestForQueryRecord throws if you don't specify an id", function(assert) {
const adapter = this.owner.lookup('adapter:oidc-provider');
const client = this.owner.lookup('service:client/http');
assert.throws(function() {
adapter.requestForQueryRecord(client.url, {
dc: dc,
});
});
});
test('requestForAuthorize returns the correct url/method', function(assert) {
const adapter = this.owner.lookup('adapter:oidc-provider');
const client = this.owner.lookup('service:client/http');
const expected = `POST /v1/acl/oidc/callback?dc=${dc}`;
const actual = adapter
.requestForAuthorize(client.url, {
dc: dc,
id: id,
code: 'code',
state: 'state',
ns: nspace,
})
.split('\n')
.shift();
assert.equal(actual, expected);
});
test('requestForLogout returns the correct url/method', function(assert) {
const adapter = this.owner.lookup('adapter:oidc-provider');
const client = this.owner.lookup('service:client/http');
const expected = `POST /v1/acl/logout`;
const actual = adapter
.requestForLogout(client.url, {
id: id,
})
.split('\n')
.shift();
assert.equal(actual, expected);
});
});
});

View File

@ -28,6 +28,7 @@ test('findBySlug returns the correct data for item endpoint', function(assert) {
Datacenter: dc,
uid: `["default","${dc}","${id}"]`,
meta: {
cacheControl: undefined,
cursor: undefined,
},
},

View File

@ -63,6 +63,7 @@ test('findBySlug returns the correct data for item endpoint', function(assert) {
Datacenter: dc,
uid: `["${nspace}","${dc}","${item.ID}"]`,
meta: {
cacheControl: undefined,
cursor: undefined,
dc: dc,
nspace: nspace,

View File

@ -71,6 +71,7 @@ const undefinedNspace = 'default';
Namespace: item.Namespace || undefinedNspace,
uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.ID}"]`,
meta: {
cacheControl: undefined,
cursor: undefined,
dc: dc,
nspace: item.Namespace || undefinedNspace,

View File

@ -118,6 +118,7 @@ const undefinedNspace = 'default';
service.Tags = [...new Set(payload.Nodes[0].Service.Tags)];
service.Namespace = payload.Namespace;
service.meta = {
cacheControl: undefined,
cursor: undefined,
dc: dc,
nspace: payload.Namespace,

View File

@ -69,6 +69,12 @@ const undefinedNspace = 'default';
CreateTime: new Date(item.CreateTime),
Namespace: item.Namespace || undefinedNspace,
uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.AccessorID}"]`,
meta: {
cacheControl: undefined,
cursor: undefined,
dc: dc,
nspace: item.Namespace || undefinedNspace,
},
Policies: createPolicies(item),
});
})

View File

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Adapter | oidc-provider', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let adapter = this.owner.lookup('adapter:oidc-provider');
assert.ok(adapter);
});
});

View File

@ -12420,6 +12420,13 @@ toidentifier@1.0.0:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
torii@^0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/torii/-/torii-0.10.1.tgz#caad0a81e82189fc0483b65e68ee28041ad3590f"
integrity sha512-csUz/coeSumt9FjyIXLpRj0ii7TfH3fUm3x9rdf+XXnJ0tVTKqwCRynwY0HKuNkGzACyR84hog3B9a8BQefBHA==
dependencies:
ember-cli-babel "^6.11.0"
tough-cookie@^2.3.3, tough-cookie@^2.4.3, tough-cookie@~2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"