Skip to content

Commit 08d9d45

Browse files
author
Eric Koleda
committed
Add locking around refresh()
1 parent 306e10f commit 08d9d45

File tree

3 files changed

+99
-35
lines changed

3 files changed

+99
-35
lines changed

src/Service.gs

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -504,38 +504,40 @@ Service_.prototype.refresh = function() {
504504
'Token URL': this.tokenUrl_
505505
});
506506

507-
var token = this.getToken();
508-
if (!token.refresh_token) {
509-
throw new Error('Offline access is required.');
510-
}
511-
var headers = {
512-
'Accept': this.tokenFormat_
513-
};
514-
if (this.tokenHeaders_) {
515-
headers = extend_(headers, this.tokenHeaders_);
516-
}
517-
var tokenPayload = {
518-
refresh_token: token.refresh_token,
519-
client_id: this.clientId_,
520-
client_secret: this.clientSecret_,
521-
grant_type: 'refresh_token'
522-
};
523-
if (this.tokenPayloadHandler_) {
524-
tokenPayload = this.tokenPayloadHandler_(tokenPayload);
525-
}
526-
// Use the refresh URL if specified, otherwise fallback to the token URL.
527-
var url = this.refreshUrl_ || this.tokenUrl_;
528-
var response = UrlFetchApp.fetch(url, {
529-
method: 'post',
530-
headers: headers,
531-
payload: tokenPayload,
532-
muteHttpExceptions: true
507+
this.lockable_(function() {
508+
var token = this.getToken();
509+
if (!token.refresh_token) {
510+
throw new Error('Offline access is required.');
511+
}
512+
var headers = {
513+
'Accept': this.tokenFormat_
514+
};
515+
if (this.tokenHeaders_) {
516+
headers = extend_(headers, this.tokenHeaders_);
517+
}
518+
var tokenPayload = {
519+
refresh_token: token.refresh_token,
520+
client_id: this.clientId_,
521+
client_secret: this.clientSecret_,
522+
grant_type: 'refresh_token'
523+
};
524+
if (this.tokenPayloadHandler_) {
525+
tokenPayload = this.tokenPayloadHandler_(tokenPayload);
526+
}
527+
// Use the refresh URL if specified, otherwise fallback to the token URL.
528+
var url = this.refreshUrl_ || this.tokenUrl_;
529+
var response = UrlFetchApp.fetch(url, {
530+
method: 'post',
531+
headers: headers,
532+
payload: tokenPayload,
533+
muteHttpExceptions: true
534+
});
535+
var newToken = this.getTokenFromResponse_(response);
536+
if (!newToken.refresh_token) {
537+
newToken.refresh_token = token.refresh_token;
538+
}
539+
this.saveToken_(newToken);
533540
});
534-
var newToken = this.getTokenFromResponse_(response);
535-
if (!newToken.refresh_token) {
536-
newToken.refresh_token = token.refresh_token;
537-
}
538-
this.saveToken_(newToken);
539541
};
540542

541543
/**

test/mocks/urlfetchapp.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
var Future = require('fibers/future');
22

33
var MockUrlFetchApp = function() {
4-
this.delay = 0;
4+
this.delayFunction = () => 0;
55
this.resultFunction = () => '';
66
};
77

88
MockUrlFetchApp.prototype.fetch = function(url, optOptions) {
9-
var delay = this.delay;
9+
var delay = this.delayFunction();
10+
var result = this.resultFunction();
1011
if (delay) {
1112
sleep(delay).wait();
1213
}
1314
return {
14-
getContentText: this.resultFunction,
15+
getContentText: () => result,
1516
getResponseCode: () => 200
1617
};
1718
};

test/test.js

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('Service', function() {
147147
var properties = new MockProperties();
148148
properties.setProperty('oauth2.test', JSON.stringify(token));
149149

150-
mocks.UrlFetchApp.delay = 100;
150+
mocks.UrlFetchApp.delayFunction = () => 100;
151151
mocks.UrlFetchApp.resultFunction = () =>
152152
JSON.stringify({
153153
access_token: Math.random().toString(36)
@@ -181,8 +181,69 @@ describe('Service', function() {
181181
});
182182
});
183183
});
184+
185+
describe('#refresh()', function() {
186+
/*
187+
A race condition can occur when two executions attempt to refresh the
188+
token at the same time. Some OAuth implementations only allow one
189+
valid access token at a time, so we need to ensure that the last access
190+
token granted is the one that is persisted. To replicate this, we have the
191+
first exeuction wait longer for it's response to return through the
192+
"network" and have the second execution get it's response back sooner.
193+
*/
194+
it('should use the lock to prevent race conditions', function(done) {
195+
var token = {
196+
granted_time: 100,
197+
expires_in: 100,
198+
refresh_token: 'bar'
199+
};
200+
var properties = new MockProperties();
201+
properties.setProperty('oauth2.test', JSON.stringify(token));
202+
203+
var count = 0;
204+
mocks.UrlFetchApp.resultFunction = function() {
205+
return JSON.stringify({
206+
access_token: 'token' + count++
207+
});
208+
};
209+
var delayGenerator = function*() {
210+
yield 100;
211+
yield 10;
212+
}();
213+
mocks.UrlFetchApp.delayFunction = function() {
214+
return delayGenerator.next().value;
215+
};
216+
217+
var refreshToken = function() {
218+
var service = OAuth2.createService('test')
219+
.setClientId('abc')
220+
.setClientSecret('def')
221+
.setTokenUrl('http://www.example.com')
222+
.setPropertyStore(properties)
223+
.setLock(new MockLock());
224+
service.refresh();
225+
}.future();
226+
227+
Future.task(function() {
228+
var first = refreshToken();
229+
var second = refreshToken();
230+
Future.wait(first, second);
231+
return [first.get(), second.get()];
232+
}).resolve(function(err) {
233+
if (err) {
234+
done(err);
235+
}
236+
var storedToken = JSON.parse(properties.getProperty('oauth2.test'));
237+
assert.equal(storedToken.access_token, 'token1');
238+
done();
239+
});
240+
});
241+
});
242+
184243
});
185244

245+
246+
186247
describe('Utilities', function() {
187248
describe('#extend_()', function() {
188249
var extend_ = OAuth2.extend_;

0 commit comments

Comments
 (0)