Merge changes Ic7a5a3f9,I6d6da523,I6c88e9fd

* changes:
  Support for faster Gerrit CORS
  Add auth provider for GAPI OAUTH2 and tests
  Extract auth logic from gr-rest-api-interface
This commit is contained in:
Viktar Donich
2017-06-21 17:19:40 +00:00
committed by Gerrit Code Review
7 changed files with 475 additions and 39 deletions

View File

@@ -0,0 +1,193 @@
// Copyright (C) 2017 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
(function(window) {
'use strict';
// Prevent redefinition.
if (window.GrGapiAuth) { return; }
const EMAIL_SCOPE = 'email';
function GrGapiAuth() {}
GrGapiAuth._loadGapiPromise = null;
GrGapiAuth._setupPromise = null;
GrGapiAuth._refreshTokenPromise = null;
GrGapiAuth._sharedAuthToken = null;
GrGapiAuth._oauthClientId = null;
GrGapiAuth._oauthEmail = null;
GrGapiAuth.prototype.fetch = function(url, options) {
options = Object.assign({}, options);
return this._getAccessToken().then(
token => window.FASTER_GERRIT_CORS ?
this._fasterGerritCors(url, options, token) :
this._defaultFetch(url, options, token)
);
};
GrGapiAuth.prototype._defaultFetch = function(url, options, token) {
if (token) {
options.headers = options.headers || new Headers();
options.headers.append('Authorization', `Bearer ${token}`);
if (!url.startsWith('/a/')) {
url = '/a' + url;
}
}
return fetch(url, options);
};
GrGapiAuth.prototype._fasterGerritCors = function(url, options, token) {
const method = options.method || 'GET';
if (method === 'GET') {
return fetch(url, options);
}
const params = [];
if (token) {
params.push(`access_token=${token}`);
}
const contentType = options.headers && options.headers.get('Content-Type');
if (contentType) {
options.headers.set('Content-Type', 'text/plain');
params.push(`$ct=${encodeURIComponent(contentType)}`);
}
params.push(`$m=${method}`);
url = url + (url.indexOf('?') === -1 ? '?' : '') + params.join('&');
options.method = 'POST';
return fetch(url, options);
};
GrGapiAuth.prototype._getAccessToken = function() {
if (this._isTokenValid(GrGapiAuth._sharedAuthToken)) {
return Promise.resolve(GrGapiAuth._sharedAuthToken.access_token);
}
if (!GrGapiAuth._refreshTokenPromise) {
GrGapiAuth._refreshTokenPromise = this._loadGapi()
.then(() => this._configureOAuthLibrary())
.then(() => this._refreshToken())
.then(token => {
GrGapiAuth._sharedAuthToken = token;
GrGapiAuth._refreshTokenPromise = null;
return this._getAccessToken();
}).catch(err => {
console.error(err);
});
}
return GrGapiAuth._refreshTokenPromise;
};
GrGapiAuth.prototype._isTokenValid = function(token) {
if (!token) { return false; }
if (!token.access_token || !token.expires_at) { return false; }
const expiration = new Date(parseInt(token.expires_at, 10) * 1000);
if (Date.now() >= expiration) { return false; }
return true;
};
GrGapiAuth.prototype._loadGapi = function() {
if (!GrGapiAuth._loadGapiPromise) {
GrGapiAuth._loadGapiPromise = new Promise((resolve, reject) => {
const scriptEl = document.createElement('script');
scriptEl.defer = true;
scriptEl.async = true;
scriptEl.src = 'https://apis.google.com/js/platform.js';
scriptEl.onerror = reject;
scriptEl.onload = resolve;
document.body.appendChild(scriptEl);
});
}
return GrGapiAuth._loadGapiPromise;
};
GrGapiAuth.prototype._configureOAuthLibrary = function() {
if (!GrGapiAuth._setupPromise) {
GrGapiAuth._setupPromise = new Promise(
resolve => gapi.load('config_min', resolve)
)
.then(() => this._getOAuthConfig())
.then(config => {
if (config.hasOwnProperty('auth_url') && config.auth_url) {
gapi.config.update('oauth-flow/authUrl', config.auth_url);
}
if (config.hasOwnProperty('proxy_url') && config.proxy_url) {
gapi.config.update('oauth-flow/proxyUrl', config.proxy_url);
}
GrGapiAuth._oauthClientId = config.client_id;
GrGapiAuth._oauthEmail = config.email;
// Loading auth has a side-effect. The URLs should be set before
// loading it.
return new Promise(
resolve => gapi.load('auth', () => gapi.auth.init(resolve))
);
});
}
return GrGapiAuth._setupPromise;
};
GrGapiAuth.prototype._refreshToken = function() {
const opts = {
client_id: GrGapiAuth._oauthClientId,
immediate: true,
scope: EMAIL_SCOPE,
login_hint: GrGapiAuth._oauthEmail,
};
return new Promise((resolve, reject) => {
gapi.auth.authorize(opts, token => {
if (!token) {
reject('No token returned');
} else if (token.error) {
reject(token.error);
} else {
resolve(token);
}
});
});
};
GrGapiAuth.prototype._getOAuthConfig = function() {
const authConfigURL = '/accounts/self/oauthconfig';
const opts = {
headers: new Headers({Accept: 'application/json'}),
credentials: 'same-origin',
};
return fetch(authConfigURL, opts).then(response => {
if (!response.ok) {
console.error(response.statusText);
if (response.body && response.body.then) {
return response.body.then(text => {
return Promise.reject(text);
});
}
if (response.statusText) {
return Promise.reject(response.statusText);
} else {
return Promise.reject('_getOAuthConfig' + response.status);
}
}
return this._getResponseObject(response);
});
};
GrGapiAuth.prototype._getResponseObject = function(response) {
const JSON_PREFIX = ')]}\'';
return response.text().then(text => {
return JSON.parse(text.substring(JSON_PREFIX.length));
});
},
window.GrGapiAuth = GrGapiAuth;
})(window);

View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<!--
Copyright (C) 2017 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
<title>gr-gapi-auth</title>
<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
<script src="../../../bower_components/web-component-tester/browser.js"></script>
<link rel="import" href="../../../test/common-test-setup.html"/>
<script src="gr-gapi-auth.js"></script>
<script>
suite('gr-rest-api-interface tests', () => {
let auth;
let sandbox;
setup(() => {
sandbox = sinon.sandbox.create();
auth = new GrGapiAuth();
window.gapi = {
load: sandbox.stub().callsArg(1),
config: {
update: sandbox.stub(),
},
auth: {
init: sandbox.stub().callsArg(0),
authorize: sandbox.stub(),
},
};
sandbox.stub(window, 'fetch').returns(Promise.resolve());
});
teardown(() => {
delete window.gapi;
sandbox.restore();
});
test('exists', () => {
assert.isOk(auth);
});
test('fetch signed in', () => {
sandbox.stub(auth, '_getAccessToken').returns(Promise.resolve('foo'));
return auth.fetch('/url', {bar: 'bar'}).then(() => {
assert.isTrue(auth._getAccessToken.called);
const [url, options] = fetch.lastCall.args;
assert.equal(url, '/a/url');
assert.equal(options.bar, 'bar');
assert.equal(options.headers.get('Authorization'), 'Bearer foo');
});
});
test('fetch not signed in', () => {
sandbox.stub(auth, '_getAccessToken').returns(Promise.resolve());
return auth.fetch('/url', {bar: 'bar'}).then(() => {
assert.isTrue(auth._getAccessToken.called);
const [url, options] = fetch.lastCall.args;
assert.equal(url, '/url');
assert.equal(options.bar, 'bar');
assert.isUndefined(options.headers);
});
});
test('_getAccessToken returns valid shared token', () => {
GrGapiAuth._sharedAuthToken = {access_token: 'foo'};
sandbox.stub(auth, '_isTokenValid').returns(true);
return auth._getAccessToken().then(token => {
assert.equal(token, 'foo');
});
});
test('_getAccessToken refreshes token', () => {
const token = {access_token: 'foo'};
sandbox.stub(auth, '_loadGapi').returns(Promise.resolve());
sandbox.stub(auth, '_configureOAuthLibrary').returns(Promise.resolve());
sandbox.stub(auth, '_refreshToken').returns(Promise.resolve(token));
sandbox.stub(auth, '_isTokenValid').returns(true)
.onFirstCall().returns(false);
return auth._getAccessToken().then(token => {
assert.isTrue(auth._loadGapi.called);
assert.isTrue(auth._configureOAuthLibrary.called);
assert.isTrue(auth._refreshToken.called);
assert.equal(token, 'foo');
});
});
test('_isTokenValid', () => {
assert.isFalse(auth._isTokenValid());
assert.isFalse(auth._isTokenValid({}));
assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
assert.isFalse(auth._isTokenValid({
access_token: 'foo',
expires_at: Date.now()/1000 - 1,
}));
assert.isTrue(auth._isTokenValid({
access_token: 'foo',
expires_at: Date.now()/1000 + 1,
}));
});
test('_configureOAuthLibrary', () => {
sandbox.stub(auth, '_getOAuthConfig').returns({
auth_url: 'some_auth_url',
proxy_url: 'some_proxy_url',
client_id: 'some_client_id',
email: 'some_email',
});
return auth._configureOAuthLibrary().then(() => {
assert.isTrue(gapi.load.calledWith('config_min'));
assert.isTrue(auth._getOAuthConfig.called);
assert.isTrue(gapi.config.update.calledWith(
'oauth-flow/authUrl', 'some_auth_url'));
assert.isTrue(gapi.config.update.calledWith(
'oauth-flow/proxyUrl', 'some_proxy_url'));
assert.equal(GrGapiAuth._oauthClientId, 'some_client_id');
assert.equal(GrGapiAuth._oauthEmail, 'some_email');
assert.isTrue(gapi.auth.init.called);
assert.isTrue(gapi.load.calledWith('auth'));
});
});
test('_refreshToken no token', () => {
gapi.auth.authorize.callsArgWith(1, null);
return auth._refreshToken().catch(reason => {
assert.equal(reason, 'No token returned');
});
});
test('_refreshToken error', () => {
gapi.auth.authorize.callsArgWith(1, {error: 'some error'});
return auth._refreshToken().catch(reason => {
assert.equal(reason, 'some error');
});
});
test('_refreshToken', () => {
const token = {};
gapi.auth.authorize.callsArgWith(1, token);
return auth._refreshToken().then(t => {
assert.strictEqual(token, t);
});
});
test('_getOAuthConfig', () => {
const config = {};
fetch.returns(Promise.resolve({ok: true}));
sandbox.stub(auth, '_getResponseObject').returns(config);
return auth._getOAuthConfig().then(c => {
const [url, options] = fetch.lastCall.args;
assert.equal(url, '/accounts/self/oauthconfig');
assert.equal(options.credentials, 'same-origin');
assert.equal(options.headers.get('Accept'), 'application/json');
assert.strictEqual(c, config);
});
});
suite('faster gerrit cors', () => {
setup(() => {
window.FASTER_GERRIT_CORS = true;
});
teardown(() => {
delete window.FASTER_GERRIT_CORS;
});
test('PUT works', () => {
sandbox.stub(auth, '_getAccessToken').returns(Promise.resolve('foo'));
const originalOptions = {
method: 'PUT',
headers: new Headers({'Content-Type': 'mail/pigeon'}),
};
return auth.fetch('/url', originalOptions).then(() => {
assert.isTrue(auth._getAccessToken.called);
const [url, options] = fetch.lastCall.args;
assert.include(url, '$ct=mail%2Fpigeon');
assert.include(url, '$m=PUT');
assert.include(url, 'access_token=foo');
assert.equal(options.method, 'POST');
assert.equal(options.headers.get('Content-Type'), 'text/plain');
});
});
});
});
</script>

View File

@@ -0,0 +1,49 @@
// Copyright (C) 2017 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
(function(window) {
'use strict';
// Prevent redefinition.
if (window.GrGerritAuth) { return; }
function GrGerritAuth() {}
GrGerritAuth.prototype._getCookie = function(name) {
const key = name + '=';
let result = '';
document.cookie.split(';').some(c => {
c = c.trim();
if (c.startsWith(key)) {
result = c.substring(key.length);
return true;
}
});
return result;
};
GrGerritAuth.prototype.fetch = function(url, opt_options) {
const options = Object.assign({}, opt_options);
if (options.method && options.method !== 'GET') {
const token = this._getCookie('XSRF_TOKEN');
if (token) {
options.headers = options.headers || new Headers();
options.headers.append('X-Gerrit-Auth', token);
}
}
options.credentials = 'same-origin';
return fetch(url, options);
};
window.GrGerritAuth = GrGerritAuth;
})(window);

View File

@@ -17,10 +17,14 @@ limitations under the License.
<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
<link rel="import" href="../../../bower_components/polymer/polymer.html">
<!-- NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 -->
<script src="../../../bower_components/es6-promise/dist/es6-promise.min.js"></script>
<script src="../../../bower_components/fetch/fetch.js"></script>
<dom-module id="gr-rest-api-interface">
<script src="gr-rest-api-interface.js"></script>
<!-- NB: Order is important, because of namespaced classes. -->
<script src="gr-gerrit-auth.js"></script>
<script src="gr-gapi-auth.js"></script>
<script src="gr-reviewer-updates-parser.js"></script>
<script src="gr-rest-api-interface.js"></script>
</dom-module>

View File

@@ -27,6 +27,8 @@
SEND_DIFF_DRAFT: 'sendDiffDraft',
};
let auth = null;
Polymer({
is: 'gr-rest-api-interface',
@@ -62,20 +64,13 @@
},
},
fetchJSON(url, opt_errFn, opt_cancelCondition, opt_params,
opt_opts) {
opt_opts = opt_opts || {};
// Issue 5715, This can be reverted back once
// iOS 10.3 and mac os 10.12.4 has the fetch api fix.
const fetchOptions = {
credentials: 'same-origin',
};
if (opt_opts.headers !== undefined) {
fetchOptions['headers'] = opt_opts.headers;
}
created() {
auth = window.USE_GAPI_AUTH ? new GrGapiAuth() : new GrGerritAuth();
},
fetchJSON(url, opt_errFn, opt_cancelCondition, opt_params) {
const urlWithParams = this._urlWithParams(url, opt_params);
return fetch(urlWithParams, fetchOptions).then(response => {
return auth.fetch(urlWithParams).then(response => {
if (opt_cancelCondition && opt_cancelCondition()) {
response.body.cancel();
return;
@@ -716,22 +711,17 @@
},
send(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) {
const headers = new Headers({
'X-Gerrit-Auth': this._getCookie('XSRF_TOKEN'),
});
const options = {
method,
headers,
credentials: 'same-origin',
};
const options = {method};
if (opt_body) {
headers.append('Content-Type', opt_contentType || 'application/json');
options.headers = new Headers({
'Content-Type': opt_contentType || 'application/json',
});
if (typeof opt_body !== 'string') {
opt_body = JSON.stringify(opt_body);
}
options.body = opt_body;
}
return fetch(this.getBaseUrl() + url, options).then(response => {
return auth.fetch(this.getBaseUrl() + url, options).then(response => {
if (!response.ok) {
if (opt_errFn) {
opt_errFn.call(opt_ctx || null, response);
@@ -901,21 +891,6 @@
});
},
_getCookie(name) {
const key = name + '=';
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
let c = cookies[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.startsWith(key)) {
return c.substring(key.length, c.length);
}
}
return '';
},
getCommitInfo(project, commit) {
return this.fetchJSON(
'/projects/' + encodeURIComponent(project) +
@@ -923,7 +898,7 @@
},
_fetchB64File(url) {
return fetch(this.getBaseUrl() + url, {credentials: 'same-origin'})
return auth.fetch(this.getBaseUrl() + url)
.then(response => {
if (!response.ok) { return Promise.reject(response.statusText); }
const type = response.headers.get('X-FYI-Content-Type');

View File

@@ -658,5 +658,20 @@ limitations under the License.
assert.isTrue(element._fetchSharedCacheURL.lastCall
.calledWithExactly('/projects/?d&n=26&S=25&m=test'));
});
test('gerrit auth is used by default', () => {
sandbox.stub(GrGerritAuth.prototype, 'fetch').returns(Promise.resolve());
element.fetchJSON('foo');
assert(GrGerritAuth.prototype.fetch.called);
});
test('gapi auth is enabled with USE_GAPI_AUTH', () => {
window.USE_GAPI_AUTH = true;
sandbox.stub(GrGapiAuth.prototype, 'fetch').returns(Promise.resolve());
element = fixture('basic');
element.fetchJSON('foo');
assert(GrGapiAuth.prototype.fetch.called);
delete window.USE_GAPI_AUTH;
});
});
</script>

View File

@@ -118,6 +118,7 @@ limitations under the License.
'shared/gr-linked-chip/gr-linked-chip_test.html',
'shared/gr-linked-text/gr-linked-text_test.html',
'shared/gr-list-view/gr-list-view_test.html',
'shared/gr-rest-api-interface/gr-gapi-auth_test.html',
'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
'shared/gr-select/gr-select_test.html',