Skip to content

Commit 1f95c32

Browse files
author
Eric Koleda
committed
Merge branch 'master' into locking
2 parents cd674ec + 3572ba7 commit 1f95c32

File tree

9 files changed

+227
-31
lines changed

9 files changed

+227
-31
lines changed

README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ in your manifest file, ensure that the following scope is included:
3131
## Redirect URI
3232

3333
Before you can start authenticating against an OAuth2 provider, you usually need
34-
to register your application with that OAuth2 provider and obtain a client ID and secret. Often
35-
a provider's registration screen requires you to enter a "Redirect URI", which is the
36-
URL that the user's browser will be redirected to after they've authorized access to their account at that provider.
34+
to register your application with that OAuth2 provider and obtain a client ID
35+
and secret. Often a provider's registration screen requires you to enter a
36+
"Redirect URI", which is the URL that the user's browser will be redirected to
37+
after they've authorized access to their account at that provider.
3738

38-
For this library (and the Apps Script functionality in general) the URL will always
39-
be in the following format:
39+
For this library (and the Apps Script functionality in general) the URL will
40+
always be in the following format:
4041

4142
https://script.google.com/macros/d/{SCRIPT ID}/usercallback
4243

@@ -350,8 +351,15 @@ headers.
350351
351352
The most common of these is the `client_credentials` grant type, which often
352353
requires that the client ID and secret are passed in the Authorization header.
353-
See the sample [`TwitterAppOnly.gs`](samples/TwitterAppOnly.gs) for more
354-
information.
354+
When using this grant type, if you set a client ID and secret using
355+
`setClientId()` and `setClientSecret()` respectively then an
356+
`Authorization: Basic ...` header will be added to the token request
357+
automatically, since this is what most OAuth2 providers require. If your
358+
provider uses a different method of authorization then don't set the client ID
359+
and secret and add an authorization header manually.
360+
361+
See the sample [`TwitterAppOnly.gs`](samples/TwitterAppOnly.gs) for a working
362+
example.
355363
356364
357365
## Compatibility

samples/Domo.gs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,13 @@ function getService() {
4141
// Set the endpoint URLs.
4242
.setTokenUrl('https://api.domo.com/oauth/token')
4343

44+
// Set the client ID and secret.
45+
.setClientId(CLIENT_ID)
46+
.setClientSecret(CLIENT_SECRET)
47+
4448
// Sets the custom grant type to use.
4549
.setGrantType('client_credentials')
4650

47-
// Sets the required Authorization header.
48-
.setTokenHeaders({
49-
Authorization: 'Basic ' +
50-
Utilities.base64Encode(CLIENT_ID + ':' + CLIENT_SECRET)
51-
})
52-
5351
// Set the property store where authorized tokens should be persisted.
5452
.setPropertyStore(PropertiesService.getUserProperties());
5553
}

samples/TwitterAppOnly.gs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,13 @@ function getService() {
4343
// Set the endpoint URLs.
4444
.setTokenUrl('https://api.twitter.com/oauth2/token')
4545

46+
// Set the client ID and secret.
47+
.setClientId(CLIENT_ID)
48+
.setClientSecret(CLIENT_SECRET)
49+
4650
// Sets the custom grant type to use.
4751
.setGrantType('client_credentials')
4852

49-
// Sets the required Authorization header.
50-
.setTokenHeaders({
51-
Authorization: 'Basic ' +
52-
Utilities.base64Encode(CLIENT_ID + ':' + CLIENT_SECRET)
53-
})
54-
5553
// Set the property store where authorized tokens should be persisted.
5654
.setPropertyStore(PropertiesService.getUserProperties());
5755
}

src/OAuth2.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ function createService(serviceName) {
6464
* @return {string} The redirect URI.
6565
*/
6666
function getRedirectUri(scriptId) {
67-
return Utilities.formatString(
68-
'https://script.google.com/macros/d/%s/usercallback', scriptId);
67+
return 'https://script.google.com/macros/d/' + encodeURIComponent(scriptId) +
68+
'/usercallback';
6969
}
7070

7171
if (typeof module === 'object') {

src/Service.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -713,8 +713,8 @@ Service_.prototype.lockable_ = function(func) {
713713

714714
/**
715715
* Obtain an access token using the custom grant type specified. Most often
716-
* this will be "client_credentials", in which case make sure to also specify an
717-
* Authorization header if required by your OAuth provider.
716+
* this will be "client_credentials", and a client ID and secret are set an
717+
* "Authorization: Basic ..." header will be added using those values.
718718
*/
719719
Service_.prototype.exchangeGrant_ = function() {
720720
validate_({
@@ -725,6 +725,20 @@ Service_.prototype.exchangeGrant_ = function() {
725725
grant_type: this.grantType_
726726
};
727727
payload = extend_(payload, this.params_);
728+
729+
// For the client_credentials grant type, add a basic authorization header:
730+
// - If the client ID and client secret are set.
731+
// - No authorization header has been set yet.
732+
var lowerCaseHeaders = toLowerCaseKeys_(this.tokenHeaders_);
733+
if (this.grantType_ === 'client_credentials' &&
734+
this.clientId_ &&
735+
this.clientSecret_ &&
736+
(!lowerCaseHeaders || !lowerCaseHeaders.authorization)) {
737+
this.tokenHeaders_ = this.tokenHeaders_ || {};
738+
this.tokenHeaders_.authorization = 'Basic ' +
739+
Utilities.base64Encode(this.clientId_ + ':' + this.clientSecret_);
740+
}
741+
728742
var token = this.fetchToken_(payload);
729743
this.saveToken_(token);
730744
};

src/Storage.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ function Storage_(prefix, properties, optCache) {
4040
*/
4141
Storage_.CACHE_EXPIRATION_TIME_SECONDS = 21600; // 6 hours.
4242

43+
/**
44+
* The special value to use in the cache to indicate that there is no value.
45+
* @type {string}
46+
* @private
47+
*/
48+
Storage_.CACHE_NULL_VALUE = '__NULL__';
49+
4350
/**
4451
* Gets a stored value.
4552
* @param {string} key The key.
@@ -48,21 +55,27 @@ Storage_.CACHE_EXPIRATION_TIME_SECONDS = 21600; // 6 hours.
4855
* @return {*} The stored value.
4956
*/
5057
Storage_.prototype.getValue = function(key, optSkipMemoryCheck) {
58+
var prefixedKey = this.getPrefixedKey_(key);
59+
var jsonValue;
60+
var value;
61+
5162
if (!optSkipMemoryCheck) {
5263
// Check in-memory cache.
53-
if (this.memory_[key]) {
54-
return this.memory_[key];
64+
if (value = this.memory_[key]) {
65+
if (value === Storage_.CACHE_NULL_VALUE) {
66+
return null;
67+
}
68+
return value;
5569
}
5670
}
5771

58-
var prefixedKey = this.getPrefixedKey_(key);
59-
var jsonValue;
60-
var value;
61-
6272
// Check cache.
6373
if (this.cache_ && (jsonValue = this.cache_.get(prefixedKey))) {
6474
value = JSON.parse(jsonValue);
6575
this.memory_[key] = value;
76+
if (value === Storage_.CACHE_NULL_VALUE) {
77+
return null;
78+
}
6679
return value;
6780
}
6881

@@ -77,7 +90,13 @@ Storage_.prototype.getValue = function(key, optSkipMemoryCheck) {
7790
return value;
7891
}
7992

80-
// Not found.
93+
// Not found. Store a special null value in the memory and cache to reduce
94+
// hits on the PropertiesService.
95+
this.memory_[key] = Storage_.CACHE_NULL_VALUE;
96+
if (this.cache_) {
97+
this.cache_.put(prefixedKey, JSON.stringify(Storage_.CACHE_NULL_VALUE),
98+
Storage_.CACHE_EXPIRATION_TIME_SECONDS);
99+
}
81100
return null;
82101
};
83102

src/Utilities.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,22 @@ function extend_(destination, source) {
7676
}
7777
return destination;
7878
}
79+
80+
/* exported toLowerCaseKeys_ */
81+
/**
82+
* Gets a copy of an object with all the keys converted to lower-case strings.
83+
*
84+
* @param {Object} obj The object to copy.
85+
* @return {Object} a shallow copy of the object with all lower-case keys.
86+
*/
87+
function toLowerCaseKeys_(obj) {
88+
if (obj === null || typeof obj !== 'object') {
89+
return obj;
90+
}
91+
// For each key in the source object, add a lower-case version to a new
92+
// object, and return it.
93+
return Object.keys(obj).reduce(function(result, k) {
94+
result[k.toLowerCase()] = obj[k];
95+
return result;
96+
}, {});
97+
}

test/mocks/urlfetchapp.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ var MockUrlFetchApp = function() {
1212

1313
MockUrlFetchApp.prototype.fetch = function(url, optOptions) {
1414
var delay = this.delayFunction();
15-
var result = this.resultFunction();
15+
var result = this.resultFunction(url, optOptions);
1616
if (delay) {
1717
sleep(delay).wait();
1818
}

test/test.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ var mocks = {
1313
}
1414
},
1515
UrlFetchApp: new MockUrlFetchApp(),
16+
Utilities: {
17+
base64Encode: function(data) {
18+
return Buffer.from(data).toString('base64');
19+
}
20+
},
1621
__proto__: gas.globalMockDefault
1722
};
1823
var OAuth2 = gas.require('./src', mocks);
@@ -108,6 +113,37 @@ describe('Service', function() {
108113

109114
assert.deepEqual(service.getToken(true), newToken);
110115
});
116+
117+
it('should load null tokens from the cache',
118+
function() {
119+
var cache = new MockCache();
120+
var properties = new MockProperties();
121+
for (var i = 0; i < 10; ++i) {
122+
var service = OAuth2.createService('test')
123+
.setPropertyStore(properties)
124+
.setCache(cache);
125+
service.getToken();
126+
}
127+
assert.equal(properties.counter, 1);
128+
});
129+
130+
it('should load null tokens from memory',
131+
function() {
132+
var cache = new MockCache();
133+
var properties = new MockProperties();
134+
var service = OAuth2.createService('test')
135+
.setPropertyStore(properties)
136+
.setCache(cache);
137+
138+
service.getToken();
139+
var cacheStart = cache.counter;
140+
var propertiesStart = properties.counter;
141+
for (var i = 0; i < 10; ++i) {
142+
service.getToken();
143+
}
144+
assert.equal(cache.counter, cacheStart);
145+
assert.equal(properties.counter, propertiesStart);
146+
});
111147
});
112148

113149
describe('#saveToken_()', function() {
@@ -294,6 +330,81 @@ describe('Service', function() {
294330
});
295331
});
296332
});
333+
334+
describe('#exchangeGrant_()', function() {
335+
var toLowerCaseKeys_ = OAuth2.toLowerCaseKeys_;
336+
337+
it('should not set auth header if the grant type is not client_credentials',
338+
function(done) {
339+
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
340+
assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
341+
done();
342+
};
343+
var service = OAuth2.createService('test')
344+
.setGrantType('fake')
345+
.setTokenUrl('http://www.example.com');
346+
service.exchangeGrant_();
347+
});
348+
349+
it('should not set auth header if the client ID is not set',
350+
function(done) {
351+
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
352+
assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
353+
done();
354+
};
355+
var service = OAuth2.createService('test')
356+
.setGrantType('client_credentials')
357+
.setTokenUrl('http://www.example.com');
358+
service.exchangeGrant_();
359+
});
360+
361+
it('should not set auth header if the client secret is not set',
362+
function(done) {
363+
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
364+
assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
365+
done();
366+
};
367+
var service = OAuth2.createService('test')
368+
.setGrantType('client_credentials')
369+
.setTokenUrl('http://www.example.com')
370+
.setClientId('abc');
371+
service.exchangeGrant_();
372+
});
373+
374+
it('should not set auth header if it is already set',
375+
function(done) {
376+
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
377+
assert.equal(toLowerCaseKeys_(urlOptions.headers).authorization,
378+
'something');
379+
done();
380+
};
381+
var service = OAuth2.createService('test')
382+
.setGrantType('client_credentials')
383+
.setTokenUrl('http://www.example.com')
384+
.setClientId('abc')
385+
.setClientSecret('def')
386+
.setTokenHeaders({
387+
authorization: 'something'
388+
});
389+
service.exchangeGrant_();
390+
});
391+
392+
it('should set the auth header for the client_credentials grant type, if ' +
393+
'the client ID and client secret are set and the authorization header' +
394+
'is not already set', function(done) {
395+
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
396+
assert.equal(toLowerCaseKeys_(urlOptions.headers).authorization,
397+
'Basic YWJjOmRlZg==');
398+
done();
399+
};
400+
var service = OAuth2.createService('test')
401+
.setGrantType('client_credentials')
402+
.setTokenUrl('http://www.example.com')
403+
.setClientId('abc')
404+
.setClientSecret('def');
405+
service.exchangeGrant_();
406+
});
407+
});
297408
});
298409

299410
describe('Utilities', function() {
@@ -313,4 +424,33 @@ describe('Utilities', function() {
313424
assert.deepEqual(o, {foo: [100], bar: 2, baz: {}});
314425
});
315426
});
427+
428+
describe('#toLowerCaseKeys_()', function() {
429+
var toLowerCaseKeys_ = OAuth2.toLowerCaseKeys_;
430+
431+
it('should contain only lower-case keys', function() {
432+
var data = {
433+
'a': true,
434+
'A': true,
435+
'B': true,
436+
'Cc': true,
437+
'D2': true,
438+
'E!@#': true
439+
};
440+
var lowerCaseData = toLowerCaseKeys_(data);
441+
assert.deepEqual(lowerCaseData, {
442+
'a': true,
443+
'b': true,
444+
'cc': true,
445+
'd2': true,
446+
'e!@#': true
447+
});
448+
});
449+
450+
it('should handle null, undefined, and empty objects', function() {
451+
assert.isNull(toLowerCaseKeys_(null));
452+
assert.isUndefined(toLowerCaseKeys_(undefined));
453+
assert.isEmpty(toLowerCaseKeys_({}));
454+
});
455+
});
316456
});

0 commit comments

Comments
 (0)