Skip to content

Commit 9f80706

Browse files
author
Eric Koleda
committed
Add support for the Service Account (JWT Bearer) authorization flow.
1 parent 9303863 commit 9f80706

File tree

1 file changed

+157
-20
lines changed

1 file changed

+157
-20
lines changed

Service.gs

Lines changed: 157 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ var Service_ = function(serviceName) {
3030
this.tokenFormat_ = TOKEN_FORMAT.JSON;
3131
this.tokenHeaders_ = null;
3232
this.projectKey_ = eval('Script' + 'App').getProjectKey();
33+
this.expirationMinutes_ = 60;
3334
};
3435

3536
/**
@@ -72,7 +73,7 @@ Service_.prototype.setTokenFormat = function(tokenFormat) {
7273
};
7374

7475
/**
75-
* Sets the additional HTTP headers that should be sent when retrieving or
76+
* Sets the additional HTTP headers that should be sent when retrieving or
7677
* refreshing the access token.
7778
* @param Object.<string,string> tokenHeaders A map of header names to values.
7879
* @return {Service_} This service, for chaining.
@@ -185,6 +186,48 @@ Service_.prototype.setParam = function(name, value) {
185186
return this;
186187
};
187188

189+
/**
190+
* Sets the private key to use for Service Account authorization.
191+
* @param {string} privateKey The private key.
192+
* @return {Service_} This service, for chaining.
193+
*/
194+
Service_.prototype.setPrivateKey = function(privateKey) {
195+
this.privateKey_ = privateKey;
196+
return this;
197+
};
198+
199+
/**
200+
* Sets the issuer (iss) value to use for Service Account authorization.
201+
* If not set the client ID will be used instead.
202+
* @param {string} issuer This issuer value
203+
* @return {Service_} This service, for chaining.
204+
*/
205+
Service_.prototype.setIssuer = function(issuer) {
206+
this.issuer_ = issuer;
207+
return this;
208+
};
209+
210+
/**
211+
* Sets the subject (sub) value to use for Service Account authorization.
212+
* @param {string} subject This subject value
213+
* @return {Service_} This service, for chaining.
214+
*/
215+
Service_.prototype.setSubject = function(subject) {
216+
this.subject_ = subject;
217+
return this;
218+
};
219+
220+
/**
221+
* Sets number of minutes that a token obtained through Service Account authorization should be valid.
222+
* Default: 60 minutes.
223+
* @param {string} expirationMinutes The expiration duration in minutes.
224+
* @return {Service_} This service, for chaining.
225+
*/
226+
Service_.prototype.setExpirationMinutes = function(expirationMinutes) {
227+
this.expirationMinutes_ = expirationMinutes;
228+
return this;
229+
};
230+
188231
/**
189232
* Gets the authorization URL. The first step in getting an OAuth2 token is to
190233
* have the user visit this URL and approve the authorization request. The
@@ -273,23 +316,23 @@ Service_.prototype.handleCallback = function(callbackRequest) {
273316
*/
274317
Service_.prototype.hasAccess = function() {
275318
var token = this.getToken_();
276-
if (!token) {
277-
return false;
278-
}
279-
var expires_in = token.expires_in || token.expires;
280-
if (expires_in) {
281-
var expires_time = token.granted_time + expires_in;
282-
var now = getTimeInSeconds_(new Date());
283-
if (expires_time - now < Service_.EXPIRATION_BUFFER_SECONDS_) {
284-
if (token.refresh_token) {
285-
try {
286-
this.refresh();
287-
} catch (e) {
288-
return false;
289-
}
290-
} else {
319+
if (!token || this.isExpired_(token)) {
320+
if (token && token.refresh_token) {
321+
try {
322+
this.refresh();
323+
} catch (e) {
324+
this.lastError_ = e;
291325
return false;
292326
}
327+
} else if (this.privateKey_) {
328+
try {
329+
this.exchangeJwt_();
330+
} catch (e) {
331+
this.lastError_ = e;
332+
return false;
333+
}
334+
} else {
335+
return false;
293336
}
294337
}
295338
return true;
@@ -316,7 +359,16 @@ Service_.prototype.reset = function() {
316359
validate_({
317360
'Property store': this.propertyStore_
318361
});
319-
this.propertyStore_.deleteProperty(this.getPropertyKey(this.serviceName_));
362+
this.propertyStore_.deleteProperty(this.getPropertyKey_(this.serviceName_));
363+
};
364+
365+
/**
366+
* Gets the last error that occurred this execution when trying to automatically refresh
367+
* or generate an access token.
368+
* @return {Exception} An error, if any.
369+
*/
370+
Service_.prototype.getLastError = function() {
371+
return this.lastError_;
320372
};
321373

322374
/**
@@ -397,7 +449,7 @@ Service_.prototype.saveToken_ = function(token) {
397449
validate_({
398450
'Property store': this.propertyStore_
399451
});
400-
var key = this.getPropertyKey(this.serviceName_);
452+
var key = this.getPropertyKey_(this.serviceName_);
401453
var value = JSON.stringify(token);
402454
this.propertyStore_.setProperty(key, value);
403455
if (this.cache_) {
@@ -414,7 +466,7 @@ Service_.prototype.getToken_ = function() {
414466
validate_({
415467
'Property store': this.propertyStore_
416468
});
417-
var key = this.getPropertyKey(this.serviceName_);
469+
var key = this.getPropertyKey_(this.serviceName_);
418470
var token;
419471
if (this.cache_) {
420472
token = this.cache_.get(key);
@@ -438,6 +490,91 @@ Service_.prototype.getToken_ = function() {
438490
* @return {string} The property key.
439491
* @private
440492
*/
441-
Service_.prototype.getPropertyKey = function(serviceName) {
493+
Service_.prototype.getPropertyKey_ = function(serviceName) {
442494
return 'oauth2.' + serviceName;
443495
};
496+
497+
/**
498+
* Determines if a retrieved token is still valid.
499+
* @param {Object} token The token to validate.
500+
* @return {boolean} True if it has expired, false otherwise.
501+
* @private
502+
*/
503+
Service_.prototype.isExpired_ = function(token) {
504+
var expires_in = token.expires_in || token.expires;
505+
if (!expires_in) {
506+
return false;
507+
} else {
508+
var expires_time = token.granted_time + expires_in;
509+
var now = getTimeInSeconds_(new Date());
510+
return expires_time - now < Service_.EXPIRATION_BUFFER_SECONDS_;
511+
}
512+
};
513+
514+
/**
515+
* Uses the service account flow to exchange a signed JSON Web Token (JWT) for an
516+
* access token.
517+
*/
518+
Service_.prototype.exchangeJwt_ = function() {
519+
validate_({
520+
'Token URL': this.tokenUrl_
521+
});
522+
var jwt = this.createJwt_();
523+
var headers = {
524+
'Accept': this.tokenFormat_
525+
};
526+
if (this.tokenHeaders_) {
527+
headers = _.extend(headers, this.tokenHeaders_);
528+
}
529+
var response = UrlFetchApp.fetch(this.tokenUrl_, {
530+
method: 'post',
531+
headers: headers,
532+
payload: {
533+
assertion: jwt,
534+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer'
535+
},
536+
muteHttpExceptions: true
537+
});
538+
var token = this.parseToken_(response.getContentText());
539+
if (response.getResponseCode() != 200) {
540+
var reason = token.error ? token.error : response.getResponseCode();
541+
throw 'Error retrieving token: ' + reason;
542+
}
543+
this.saveToken_(token);
544+
};
545+
546+
/**
547+
* Creates a signed JSON Web Token (JWT) for use with Service Account authorization.
548+
* @return {string} The signed JWT.
549+
* @private
550+
*/
551+
Service_.prototype.createJwt_ = function() {
552+
validate_({
553+
'Private key': this.privateKey_,
554+
'Token URL': this.tokenUrl_,
555+
'Issuer or Client ID': this.issuer_ || this.clientId_
556+
});
557+
var header = {
558+
alg: 'RS256',
559+
typ: 'JWT'
560+
};
561+
var now = new Date();
562+
var expires = new Date(now.getTime());
563+
expires.setMinutes(expires.getMinutes() + this.expirationMinutes_);
564+
var claimSet = {
565+
iss: this.issuer_ || this.clientId_,
566+
aud: this.tokenUrl_,
567+
exp: Math.round(expires.getTime() / 1000),
568+
iat: Math.round(now.getTime() / 1000)
569+
};
570+
if (this.subject_) {
571+
claimSet['sub'] = this.subject_;
572+
}
573+
if (this.params_['scope']) {
574+
claimSet['scope'] = this.params_['scope'];
575+
}
576+
var toSign = Utilities.base64EncodeWebSafe(JSON.stringify(header)) + '.' + Utilities.base64EncodeWebSafe(JSON.stringify(claimSet));
577+
var signatureBytes = Utilities.computeRsaSha256Signature(toSign, this.privateKey_);
578+
var signature = Utilities.base64EncodeWebSafe(signatureBytes);
579+
return toSign + '.' + signature;
580+
};

0 commit comments

Comments
 (0)