Skip to content

Commit 2ee9a86

Browse files
johncowenkaxcode
authored andcommitted
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
1 parent 299dd3b commit 2ee9a86

File tree

24 files changed

+434
-35
lines changed

24 files changed

+434
-35
lines changed

ui-v2/app/adapters/http.js

+4
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ export default Adapter.extend({
124124
} catch (e) {
125125
error = e;
126126
}
127+
// TODO: This comes originates from ember-data
128+
// This can be confusing if you need to use this with Promise.reject
129+
// Consider changing this to return the error and then
130+
// throw from the call site instead
127131
throw error;
128132
},
129133
query: function(store, type, query) {

ui-v2/app/adapters/oidc-provider.js

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import Adapter from './application';
2+
import { inject as service } from '@ember/service';
3+
4+
import { env } from 'consul-ui/env';
5+
import nonEmptySet from 'consul-ui/utils/non-empty-set';
6+
7+
let Namespace;
8+
if (env('CONSUL_NSPACES_ENABLED')) {
9+
Namespace = nonEmptySet('Namespace');
10+
} else {
11+
Namespace = () => ({});
12+
}
13+
export default Adapter.extend({
14+
env: service('env'),
15+
requestForQuery: function(request, { dc, ns, index }) {
16+
return request`
17+
GET /v1/internal/ui/oidc-auth-methods?${{ dc }}
18+
19+
${{
20+
index,
21+
...this.formatNspace(ns),
22+
}}
23+
`;
24+
},
25+
requestForQueryRecord: function(request, { dc, ns, id }) {
26+
if (typeof id === 'undefined') {
27+
throw new Error('You must specify an id');
28+
}
29+
return request`
30+
POST /v1/acl/oidc/auth-url?${{ dc }}
31+
Cache-Control: no-store
32+
33+
${{
34+
...Namespace(ns),
35+
AuthMethod: id,
36+
RedirectURI: `${this.env.var('CONSUL_BASE_UI_URL')}/torii/redirect.html`,
37+
}}
38+
`;
39+
},
40+
requestForAuthorize: function(request, { dc, ns, id, code, state }) {
41+
if (typeof id === 'undefined') {
42+
throw new Error('You must specify an id');
43+
}
44+
if (typeof code === 'undefined') {
45+
throw new Error('You must specify an code');
46+
}
47+
if (typeof state === 'undefined') {
48+
throw new Error('You must specify an state');
49+
}
50+
return request`
51+
POST /v1/acl/oidc/callback?${{ dc }}
52+
Cache-Control: no-store
53+
54+
${{
55+
...Namespace(ns),
56+
AuthMethod: id,
57+
Code: code,
58+
State: state,
59+
}}
60+
`;
61+
},
62+
requestForLogout: function(request, { id }) {
63+
if (typeof id === 'undefined') {
64+
throw new Error('You must specify an id');
65+
}
66+
return request`
67+
POST /v1/acl/logout
68+
Cache-Control: no-store
69+
X-Consul-Token: ${id}
70+
`;
71+
},
72+
authorize: function(store, type, id, snapshot) {
73+
return this.request(
74+
function(adapter, request, serialized, unserialized) {
75+
return adapter.requestForAuthorize(request, serialized, unserialized);
76+
},
77+
function(serializer, respond, serialized, unserialized) {
78+
return serializer.respondForAuthorize(respond, serialized, unserialized);
79+
},
80+
snapshot,
81+
type.modelName
82+
);
83+
},
84+
logout: function(store, type, id, snapshot) {
85+
return this.request(
86+
function(adapter, request, serialized, unserialized) {
87+
return adapter.requestForLogout(request, serialized, unserialized);
88+
},
89+
function(serializer, respond, serialized, unserialized) {
90+
// its ok to return nothing here for the moment at least
91+
return {};
92+
},
93+
snapshot,
94+
type.modelName
95+
);
96+
},
97+
});

ui-v2/app/adapters/token.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export default Adapter.extend({
104104
return request`
105105
GET /v1/acl/token/self?${{ dc }}
106106
X-Consul-Token: ${secret}
107+
Cache-Control: no-store
107108
108109
${{ index }}
109110
`;
@@ -132,7 +133,7 @@ export default Adapter.extend({
132133
return adapter.requestForSelf(request, serialized, data);
133134
},
134135
function(serializer, respond, serialized, data) {
135-
return serializer.respondForQueryRecord(respond, serialized, data);
136+
return serializer.respondForSelf(respond, serialized, data);
136137
},
137138
unserialized,
138139
type.modelName
+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Oauth2CodeProvider from 'torii/providers/oauth2-code';
2+
const NAME = 'oidc-with-url';
3+
const Provider = Oauth2CodeProvider.extend({
4+
name: NAME,
5+
buildUrl: function() {
6+
return this.baseUrl;
7+
},
8+
open: function(options) {
9+
const name = this.get('name'),
10+
url = this.buildUrl(),
11+
responseParams = ['state', 'code'],
12+
responseType = 'code';
13+
return this.get('popup')
14+
.open(url, responseParams, options)
15+
.then(function(authData) {
16+
// the same as the parent class but with an authorizationState added
17+
return {
18+
authorizationState: authData.state,
19+
authorizationCode: decodeURIComponent(authData[responseType]),
20+
provider: name,
21+
};
22+
});
23+
},
24+
close: function() {
25+
const popup = this.get('popup.remote') || {};
26+
if (typeof popup.close === 'function') {
27+
return popup.close();
28+
}
29+
},
30+
});
31+
export function initialize(application) {
32+
application.register(`torii-provider:${NAME}`, Provider);
33+
}
34+
35+
export default {
36+
initialize,
37+
};

ui-v2/app/models/oidc-provider.js

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Model from 'ember-data/model';
2+
import attr from 'ember-data/attr';
3+
4+
export const PRIMARY_KEY = 'uid';
5+
export const SLUG_KEY = 'Name';
6+
export default Model.extend({
7+
[PRIMARY_KEY]: attr('string'),
8+
[SLUG_KEY]: attr('string'),
9+
meta: attr(),
10+
Datacenter: attr('string'),
11+
DisplayName: attr('string'),
12+
Kind: attr('string'),
13+
Namespace: attr('string'),
14+
AuthURL: attr('string'),
15+
});

ui-v2/app/models/token.js

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default Model.extend({
2020
Description: attr('string', {
2121
defaultValue: '',
2222
}),
23+
meta: attr(),
2324
Datacenter: attr('string'),
2425
Namespace: attr('string'),
2526
Local: attr('boolean'),

ui-v2/app/serializers/application.js

+21-15
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
HEADERS_DATACENTER as HTTP_HEADERS_DATACENTER,
88
HEADERS_NAMESPACE as HTTP_HEADERS_NAMESPACE,
99
} from 'consul-ui/utils/http/consul';
10+
import { CACHE_CONTROL as HTTP_HEADERS_CACHE_CONTROL } from 'consul-ui/utils/http/headers';
1011
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
1112
import { NSPACE_KEY } from 'consul-ui/models/nspace';
1213
import createFingerprinter from 'consul-ui/utils/create-fingerprinter';
@@ -101,7 +102,7 @@ export default Serializer.extend({
101102
// this could get confusing if you tried to override
102103
// say `normalizeQueryResponse`
103104
// TODO: consider creating a method for each one of the `normalize...Response` family
104-
normalizeResponse: function(store, primaryModelClass, payload, id, requestType) {
105+
normalizeResponse: function(store, modelClass, payload, id, requestType) {
105106
// Pick the meta/headers back off the payload and cleanup
106107
// before we go through serializing
107108
const headers = payload[HTTP_HEADERS_SYMBOL] || {};
@@ -114,34 +115,39 @@ export default Serializer.extend({
114115
// (which was the reason for the Symbol-like property earlier)
115116
// use a method modelled on ember-data methods so we have the opportunity to
116117
// do this on a per-model level
117-
const meta = this.normalizeMeta(
118-
store,
119-
primaryModelClass,
120-
headers,
121-
normalizedPayload,
122-
id,
123-
requestType
124-
);
125-
if (requestType === 'queryRecord') {
118+
const meta = this.normalizeMeta(store, modelClass, headers, normalizedPayload, id, requestType);
119+
if (requestType !== 'query') {
126120
normalizedPayload.meta = meta;
127121
}
128-
return this._super(
122+
const res = this._super(
129123
store,
130-
primaryModelClass,
124+
modelClass,
131125
{
132126
meta: meta,
133-
[primaryModelClass.modelName]: normalizedPayload,
127+
[modelClass.modelName]: normalizedPayload,
134128
},
135129
id,
136130
requestType
137131
);
132+
// If the result of the super normalizeResponse is undefined
133+
// its because the JSONSerializer (which REST inherits from)
134+
// doesn't recognise the requestType, in this case its likely to be an 'action'
135+
// request rather than a specific 'load me some data' one.
136+
// Therefore its ok to bypass the store here for the moment
137+
// we currently use this for self, but it also would affect any custom
138+
// methods that use a serializer in our custom service/store
139+
if (typeof res === 'undefined') {
140+
return payload;
141+
}
142+
return res;
138143
},
139144
timestamp: function() {
140145
return new Date().getTime();
141146
},
142-
normalizeMeta: function(store, primaryModelClass, headers, payload, id, requestType) {
147+
normalizeMeta: function(store, modelClass, headers, payload, id, requestType) {
143148
const meta = {
144-
cursor: headers[HTTP_HEADERS_INDEX],
149+
cacheControl: headers[HTTP_HEADERS_CACHE_CONTROL.toLowerCase()],
150+
cursor: headers[HTTP_HEADERS_INDEX.toLowerCase()],
145151
dc: headers[HTTP_HEADERS_DATACENTER.toLowerCase()],
146152
nspace: headers[HTTP_HEADERS_NAMESPACE.toLowerCase()],
147153
};
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import Serializer from './application';
2+
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/oidc-provider';
3+
4+
export default Serializer.extend({
5+
primaryKey: PRIMARY_KEY,
6+
slugKey: SLUG_KEY,
7+
respondForAuthorize: function(respond, serialized, data) {
8+
// we avoid the parent serializer here as it tries to create a
9+
// fingerprint for an 'action' request
10+
// but we still need to pass the headers through
11+
return respond((headers, body) => {
12+
return this.attachHeaders(headers, body, data);
13+
});
14+
},
15+
respondForQueryRecord: function(respond, query) {
16+
// add the name and nspace here so we can merge this
17+
// TODO: Look to see if we always want the merging functionality
18+
return this._super(
19+
cb =>
20+
respond((headers, body) =>
21+
cb(headers, {
22+
Name: query.id,
23+
Namespace: query.ns,
24+
...body,
25+
})
26+
),
27+
query
28+
);
29+
},
30+
});

ui-v2/app/serializers/token.js

+3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export default Serializer.extend(WithPolicies, WithRoles, {
3131
}
3232
return data;
3333
},
34+
respondForSelf: function(respond, query) {
35+
return this.respondForQueryRecord(respond, query);
36+
},
3437
respondForUpdateRecord: function(respond, serialized, data) {
3538
return this._super(
3639
cb =>

ui-v2/app/services/client/http.js

+21-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
import Service, { inject as service } from '@ember/service';
33
import { get, set } from '@ember/object';
44

5+
import { CACHE_CONTROL, CONTENT_TYPE } from 'consul-ui/utils/http/headers';
6+
7+
import { HEADERS_TOKEN as CONSUL_TOKEN } from 'consul-ui/utils/http/consul';
8+
59
import { env } from 'consul-ui/env';
610
import getObjectPool from 'consul-ui/utils/get-object-pool';
711
import Request from 'consul-ui/utils/http/request';
@@ -29,7 +33,7 @@ class HTTPError extends Error {
2933
}
3034
}
3135
const dispose = function(request) {
32-
if (request.headers()['content-type'] === 'text/event-stream') {
36+
if (request.headers()[CONTENT_TYPE.toLowerCase()] === 'text/event-stream') {
3337
const xhr = request.connection();
3438
// unsent and opened get aborted
3539
// headers and loading means wait for it
@@ -127,30 +131,40 @@ export default Service.extend({
127131
const [url, ...headerParts] = urlParts.join(' ').split('\n');
128132

129133
return client.settings.findBySlug('token').then(function(token) {
134+
const requestHeaders = createHeaders(headerParts);
130135
const headers = {
131136
// default to application/json
132137
...{
133-
'Content-Type': 'application/json; charset=utf-8',
138+
[CONTENT_TYPE]: 'application/json; charset=utf-8',
134139
},
135140
// add any application level headers
136141
...{
137-
'X-Consul-Token': typeof token.SecretID === 'undefined' ? '' : token.SecretID,
142+
[CONSUL_TOKEN]: typeof token.SecretID === 'undefined' ? '' : token.SecretID,
138143
},
139144
// but overwrite or add to those from anything in the specific request
140-
...createHeaders(headerParts),
145+
...requestHeaders,
141146
};
147+
// We use cache-control in the response
148+
// but we don't want to send it, but we artificially
149+
// tag it onto the response below if it is set on the request
150+
delete headers[CACHE_CONTROL];
142151

143152
return new Promise(function(resolve, reject) {
144153
const options = {
145154
url: url.trim(),
146155
method: method,
147-
contentType: headers['Content-Type'],
156+
contentType: headers[CONTENT_TYPE],
148157
// type: 'json',
149158
complete: function(xhr, textStatus) {
150159
client.complete(this.id);
151160
},
152161
success: function(response, status, xhr) {
153162
const headers = createHeaders(xhr.getAllResponseHeaders().split('\n'));
163+
if (typeof requestHeaders[CACHE_CONTROL] !== 'undefined') {
164+
// if cache-control was on the request, artificially tag
165+
// it back onto the response, also see comment above
166+
headers[CACHE_CONTROL] = requestHeaders[CACHE_CONTROL];
167+
}
154168
const respond = function(cb) {
155169
return cb(headers, response);
156170
};
@@ -191,7 +205,7 @@ export default Service.extend({
191205
// for write-like actions
192206
// potentially we should change things so you _have_ to do that
193207
// as doing it this way is a little magical
194-
if (method !== 'GET' && headers['Content-Type'].indexOf('json') !== -1) {
208+
if (method !== 'GET' && headers[CONTENT_TYPE].indexOf('json') !== -1) {
195209
options.data = JSON.stringify(body);
196210
} else {
197211
// TODO: Does this need urlencoding? Assuming jQuery does this
@@ -204,7 +218,7 @@ export default Service.extend({
204218
// also see adapters/kv content-types in requestForCreate/UpdateRecord
205219
// also see https://github.com/hashicorp/consul/issues/3804
206220
options.contentType = 'application/json; charset=utf-8';
207-
headers['Content-Type'] = options.contentType;
221+
headers[CONTENT_TYPE] = options.contentType;
208222
//
209223
options.beforeSend = function(xhr) {
210224
if (headers) {

0 commit comments

Comments
 (0)