Skip to content

Commit 5d8f892

Browse files
author
Eric Koleda
committed
Locking (in progress)
1 parent 6b5950e commit 5d8f892

File tree

4 files changed

+127
-8
lines changed

4 files changed

+127
-8
lines changed

src/Service.gs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ var Service_ = function(serviceName) {
4444
*/
4545
Service_.EXPIRATION_BUFFER_SECONDS_ = 60;
4646

47+
/**
48+
* The number of seconds that a token should remain in the cache.
49+
* @type {number}
50+
* @private
51+
*/
52+
Service_.CACHE_EXPIRATION_SECONDS_ = 6 * 60 * 60;
53+
54+
/**
55+
* The number of seconds that a token should remain in the cache.
56+
* @type {number}
57+
* @private
58+
*/
59+
Service_.LOCK_EXPIRATION_MILLISECONDS_ = 30 * 1000;
60+
4761
/**
4862
* Sets the service's authorization base URL (required). For Google services this URL should be
4963
* https://accounts.google.com/o/oauth2/auth.
@@ -173,6 +187,18 @@ Service_.prototype.setCache = function(cache) {
173187
return this;
174188
};
175189

190+
/**
191+
* Sets the lock to use when checking and refreshing credentials (optional). Using a lock will
192+
* ensure that only one execution will be able to access the stored credentials at a time. This can
193+
* prevent race conditions that arise when two executions attempt to refresh an expired token.
194+
* @param {LockService.Lock} cache The lock to use when accessing credentials.
195+
* @return {Service_} This service, for chaining.
196+
*/
197+
Service_.prototype.setLock = function(lock) {
198+
this.lock_ = lock;
199+
return this;
200+
};
201+
176202
/**
177203
* Sets the scope or scopes to request during the authorization flow (optional). If the scope value
178204
* is an array it will be joined using the separator before being sent to the server, which is
@@ -329,27 +355,32 @@ Service_.prototype.handleCallback = function(callbackRequest) {
329355
* @return {boolean} true if the user has access to the service, false otherwise.
330356
*/
331357
Service_.prototype.hasAccess = function() {
358+
if (this.lock_) {
359+
this.lock_.waitLock(Service_.LOCK_EXPIRATION_MILLISECONDS_);
360+
}
361+
var result = true;
332362
var token = this.getToken();
333363
if (!token || this.isExpired_(token)) {
334364
if (token && token.refresh_token) {
335365
try {
336366
this.refresh();
337367
} catch (e) {
338368
this.lastError_ = e;
339-
return false;
369+
result = false;
340370
}
341371
} else if (this.privateKey_) {
342372
try {
343373
this.exchangeJwt_();
344374
} catch (e) {
345375
this.lastError_ = e;
346-
return false;
376+
result = false;
347377
}
348-
} else {
349-
return false;
350378
}
351379
}
352-
return true;
380+
if (this.lock_) {
381+
this.lock_.releaseLock();
382+
}
383+
return result;
353384
};
354385

355386
/**
@@ -458,6 +489,9 @@ Service_.prototype.parseToken_ = function(content) {
458489
* requested when the token was authorized.
459490
*/
460491
Service_.prototype.refresh = function() {
492+
if (this.lock_) {
493+
this.lock_.waitLock(Service_.LOCK_EXPIRATION_MILLISECONDS_);
494+
}
461495
validate_({
462496
'Client ID': this.clientId_,
463497
'Client Secret': this.clientSecret_,
@@ -494,6 +528,9 @@ Service_.prototype.refresh = function() {
494528
newToken.refresh_token = token.refresh_token;
495529
}
496530
this.saveToken_(newToken);
531+
if (this.lock_) {
532+
this.lock_.releaseLock();
533+
}
497534
};
498535

499536
/**
@@ -509,7 +546,7 @@ Service_.prototype.saveToken_ = function(token) {
509546
var value = JSON.stringify(token);
510547
this.propertyStore_.setProperty(key, value);
511548
if (this.cache_) {
512-
this.cache_.put(key, value, 21600);
549+
this.cache_.put(key, value, Service_.CACHE_EXPIRATION_SECONDS_);
513550
}
514551
this.token_ = token;
515552
};
@@ -541,7 +578,7 @@ Service_.prototype.getToken = function() {
541578
// Check PropertiesService store.
542579
if ((token = this.propertyStore_.getProperty(key))) {
543580
if (this.cache_) {
544-
this.cache_.put(key, token, 21600);
581+
this.cache_.put(key, token, Service_.CACHE_EXPIRATION_SECONDS_);
545582
}
546583
token = JSON.parse(token);
547584
this.token_ = token;

test/mocks/lock.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
var locked = false;
2+
3+
var MockLock = function() {
4+
this.hasLock = false;
5+
};
6+
7+
MockCache.prototype.waitLock = function(timeoutInMillis) {
8+
var start = new Date();
9+
do {
10+
if (!locked) {
11+
locked = true;
12+
this.hasLock = true;
13+
return;
14+
}
15+
} while (timeDiffInMillis(new Date(), start) > timeoutInMillis);
16+
throw new Error('Unable to get lock');
17+
};
18+
19+
MockCache.prototype.releaseLock = function() {
20+
if (!this.hasLock) {
21+
throw new Error('Not your lock');
22+
}
23+
locked = false;
24+
this.hasLock = false;
25+
};
26+
27+
function timeDiffInMillis(a, b) {
28+
return a.getTime() - b.getTime();
29+
}
30+
31+
module.exports = MockCache;

test/mocks/urlfetchapp.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
var MockUrlFetchApp = function() {
2+
this.delay = 0;
3+
this.result = '';
4+
};
5+
6+
MockUrlFetchApp.prototype.fetch = function(url, opt_options) {
7+
var result = this.result;
8+
var delay = this.delay;
9+
if (delay) {
10+
await timeout(delay);
11+
}
12+
return {
13+
getContentText: () => result,
14+
getResponseCode: () => 200
15+
};
16+
};
17+
18+
function timeout(ms) {
19+
return new Promise(resolve => setTimeout(resolve, ms));
20+
}
21+
22+
module.exports = MockUrlFetchApp;

test/test.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
var assert = require('chai').assert;
22
var _ = require('underscore');
33
var gas = require('gas-local');
4+
var MockUrlFetchApp = require('./mocks/urlfetchapp');
45
var MockProperties = require('./mocks/properties');
56
var MockCache = require('./mocks/cache');
67

@@ -14,7 +15,8 @@ var mocks = {
1415
getScriptId: function() {
1516
return '12345';
1617
}
17-
}
18+
},
19+
UrlFetchApp: new MockUrlFetchApp()
1820
};
1921
var options = {
2022
filter: function(f) {
@@ -115,4 +117,31 @@ describe('Service', function() {
115117
assert.notExists(properties.getProperty(key));
116118
});
117119
});
120+
121+
describe('#hasAccess()', function() {
122+
it('should use the lock to prevent concurrend access', function() {
123+
var token = {
124+
granted_time: 0,
125+
expires_in: 0,
126+
refresh_token: 'bar'
127+
};
128+
var properties = new MockProperties({
129+
'oauth2.test': JSON.stringify(token)
130+
});
131+
132+
mocks.UrlFetchApp.delay = 1000;
133+
mocks.UrlFetchApp.result = JSON.stringify({
134+
access_token: 'foo'
135+
});
136+
137+
var executions = _.range(2).map(function() {
138+
var service = OAuth2.createService('test')
139+
.setPropertyStore(properties)
140+
.setLock(new MockLock());
141+
return new Promise((resolve) => resolve(service.hasAccess()));
142+
});
143+
144+
Promise.all(executions);
145+
});
146+
});
118147
});

0 commit comments

Comments
 (0)