Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ in your manifest file, ensure that the following scope is included:
## Redirect URI

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

For this library (and the Apps Script functionality in general) the URL will always
be in the following format:
For this library (and the Apps Script functionality in general) the URL will
always be in the following format:

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

Expand Down Expand Up @@ -350,8 +351,15 @@ headers.

The most common of these is the `client_credentials` grant type, which often
requires that the client ID and secret are passed in the Authorization header.
See the sample [`TwitterAppOnly.gs`](samples/TwitterAppOnly.gs) for more
information.
When using this grant type, if you set a client ID and secret using
`setClientId()` and `setClientSecret()` respectively then an
`Authorization: Basic ...` header will be added to the token request
automatically, since this is what most OAuth2 providers require. If your
provider uses a different method of authorization then don't set the client ID
and secret and add an authorization header manually.

See the sample [`TwitterAppOnly.gs`](samples/TwitterAppOnly.gs) for a working
example.


## Compatibility
Expand Down
10 changes: 4 additions & 6 deletions samples/Domo.gs
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,13 @@ function getService() {
// Set the endpoint URLs.
.setTokenUrl('https://api.domo.com/oauth/token')

// Set the client ID and secret.
.setClientId(CLIENT_ID)
.setClientSecret(CLIENT_SECRET)

// Sets the custom grant type to use.
.setGrantType('client_credentials')

// Sets the required Authorization header.
.setTokenHeaders({
Authorization: 'Basic ' +
Utilities.base64Encode(CLIENT_ID + ':' + CLIENT_SECRET)
})

// Set the property store where authorized tokens should be persisted.
.setPropertyStore(PropertiesService.getUserProperties());
}
10 changes: 4 additions & 6 deletions samples/TwitterAppOnly.gs
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,13 @@ function getService() {
// Set the endpoint URLs.
.setTokenUrl('https://api.twitter.com/oauth2/token')

// Set the client ID and secret.
.setClientId(CLIENT_ID)
.setClientSecret(CLIENT_SECRET)

// Sets the custom grant type to use.
.setGrantType('client_credentials')

// Sets the required Authorization header.
.setTokenHeaders({
Authorization: 'Basic ' +
Utilities.base64Encode(CLIENT_ID + ':' + CLIENT_SECRET)
})

// Set the property store where authorized tokens should be persisted.
.setPropertyStore(PropertiesService.getUserProperties());
}
18 changes: 16 additions & 2 deletions src/Service.js
Original file line number Diff line number Diff line change
Expand Up @@ -698,8 +698,8 @@ Service_.prototype.lockable_ = function(func) {

/**
* Obtain an access token using the custom grant type specified. Most often
* this will be "client_credentials", in which case make sure to also specify an
* Authorization header if required by your OAuth provider.
* this will be "client_credentials", and a client ID and secret are set an
* "Authorization: Basic ..." header will be added using those values.
*/
Service_.prototype.exchangeGrant_ = function() {
validate_({
Expand All @@ -710,6 +710,20 @@ Service_.prototype.exchangeGrant_ = function() {
grant_type: this.grantType_
};
payload = extend_(payload, this.params_);

// For the client_credentials grant type, add a basic authorization header:
// - If the client ID and client secret are set.
// - No authorization header has been set yet.
var lowerCaseHeaders = toLowerCaseKeys_(this.tokenHeaders_);
if (this.grantType_ === 'client_credentials' &&
this.clientId_ &&
this.clientSecret_ &&
(!lowerCaseHeaders || !lowerCaseHeaders.authorization)) {
this.tokenHeaders_ = this.tokenHeaders_ || {};
this.tokenHeaders_.authorization = 'Basic ' +
Utilities.base64Encode(this.clientId_ + ':' + this.clientSecret_);
}

var token = this.fetchToken_(payload);
this.saveToken_(token);
};
19 changes: 19 additions & 0 deletions src/Utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,22 @@ function extend_(destination, source) {
}
return destination;
}

/* exported toLowerCaseKeys_ */
/**
* Gets a copy of an object with all the keys converted to lower-case strings.
*
* @param {Object} obj The object to copy.
* @return {Object} a shallow copy of the object with all lower-case keys.
*/
function toLowerCaseKeys_(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
// For each key in the source object, add a lower-case version to a new
// object, and return it.
return Object.keys(obj).reduce(function(result, k) {
result[k.toLowerCase()] = obj[k];
return result;
}, {});
}
2 changes: 1 addition & 1 deletion test/mocks/urlfetchapp.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ var MockUrlFetchApp = function() {

MockUrlFetchApp.prototype.fetch = function(url, optOptions) {
var delay = this.delayFunction();
var result = this.resultFunction();
var result = this.resultFunction(url, optOptions);
if (delay) {
sleep(delay).wait();
}
Expand Down
109 changes: 109 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ var mocks = {
}
},
UrlFetchApp: new MockUrlFetchApp(),
Utilities: {
base64Encode: function(data) {
return Buffer.from(data).toString('base64');
}
},
__proto__: gas.globalMockDefault
};
var OAuth2 = gas.require('./src', mocks);
Expand Down Expand Up @@ -236,6 +241,81 @@ describe('Service', function() {
});
});
});

describe('#exchangeGrant_()', function() {
var toLowerCaseKeys_ = OAuth2.toLowerCaseKeys_;

it('should not set auth header if the grant type is not client_credentials',
function(done) {
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
done();
};
var service = OAuth2.createService('test')
.setGrantType('fake')
.setTokenUrl('http://www.example.com');
service.exchangeGrant_();
});

it('should not set auth header if the client ID is not set',
function(done) {
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
done();
};
var service = OAuth2.createService('test')
.setGrantType('client_credentials')
.setTokenUrl('http://www.example.com');
service.exchangeGrant_();
});

it('should not set auth header if the client secret is not set',
function(done) {
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
done();
};
var service = OAuth2.createService('test')
.setGrantType('client_credentials')
.setTokenUrl('http://www.example.com')
.setClientId('abc');
service.exchangeGrant_();
});

it('should not set auth header if it is already set',
function(done) {
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
assert.equal(toLowerCaseKeys_(urlOptions.headers).authorization,
'something');
done();
};
var service = OAuth2.createService('test')
.setGrantType('client_credentials')
.setTokenUrl('http://www.example.com')
.setClientId('abc')
.setClientSecret('def')
.setTokenHeaders({
authorization: 'something'
});
service.exchangeGrant_();
});

it('should set the auth header for the client_credentials grant type, if ' +
'the client ID and client secret are set and the authorization header' +
'is not already set', function(done) {
mocks.UrlFetchApp.resultFunction = function(url, urlOptions) {
assert.equal(toLowerCaseKeys_(urlOptions.headers).authorization,
'Basic YWJjOmRlZg==');
done();
};
var service = OAuth2.createService('test')
.setGrantType('client_credentials')
.setTokenUrl('http://www.example.com')
.setClientId('abc')
.setClientSecret('def');
service.exchangeGrant_();
});
});
});

describe('Utilities', function() {
Expand All @@ -255,4 +335,33 @@ describe('Utilities', function() {
assert.deepEqual(o, {foo: [100], bar: 2, baz: {}});
});
});

describe('#toLowerCaseKeys_()', function() {
var toLowerCaseKeys_ = OAuth2.toLowerCaseKeys_;

it('should contain only lower-case keys', function() {
var data = {
'a': true,
'A': true,
'B': true,
'Cc': true,
'D2': true,
'E!@#': true
};
var lowerCaseData = toLowerCaseKeys_(data);
assert.deepEqual(lowerCaseData, {
'a': true,
'b': true,
'cc': true,
'd2': true,
'e!@#': true
});
});

it('should handle null, undefined, and empty objects', function() {
assert.isNull(toLowerCaseKeys_(null));
assert.isUndefined(toLowerCaseKeys_(undefined));
assert.isEmpty(toLowerCaseKeys_({}));
});
});
});