Skip to content

Commit

Permalink
ui: Model Layer for SSO Support (#7771)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
johncowen authored and John Cowen committed May 12, 2020
1 parent ed2444c commit 6d7a95f
Show file tree
Hide file tree
Showing 24 changed files with 434 additions and 35 deletions.
4 changes: 4 additions & 0 deletions ui-v2/app/adapters/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
97 changes: 97 additions & 0 deletions ui-v2/app/adapters/oidc-provider.js
Original file line number Diff line number Diff line change
@@ -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
);
},
});
3 changes: 2 additions & 1 deletion ui-v2/app/adapters/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
`;
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions ui-v2/app/initializers/oidc-provider.js
Original file line number Diff line number Diff line change
@@ -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,
};
15 changes: 15 additions & 0 deletions ui-v2/app/models/oidc-provider.js
Original file line number Diff line number Diff line change
@@ -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'),
});
1 change: 1 addition & 0 deletions ui-v2/app/models/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default Model.extend({
Description: attr('string', {
defaultValue: '',
}),
meta: attr(),
Datacenter: attr('string'),
Namespace: attr('string'),
Local: attr('boolean'),
Expand Down
36 changes: 21 additions & 15 deletions ui-v2/app/serializers/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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] || {};
Expand All @@ -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()],
};
Expand Down
30 changes: 30 additions & 0 deletions ui-v2/app/serializers/oidc-provider.js
Original file line number Diff line number Diff line change
@@ -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
);
},
});
3 changes: 3 additions & 0 deletions ui-v2/app/serializers/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
28 changes: 21 additions & 7 deletions ui-v2/app/services/client/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Loading

0 comments on commit 6d7a95f

Please sign in to comment.