Skip to content

Commit 606bcbe

Browse files
author
Eric Koleda
authored
Honor expiration time in id_token. (#259)
1 parent 2081201 commit 606bcbe

File tree

6 files changed

+267
-19
lines changed

6 files changed

+267
-19
lines changed

package-lock.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
"gulp-expose": "0.0.7",
2929
"gulp-rename": "^1.4.0",
3030
"jsdoc": "^3.6.4",
31-
"mocha": "^4.1.0"
31+
"mocha": "^4.1.0",
32+
"urlsafe-base64": "^1.0.0"
3233
},
3334
"scripts": {
3435
"preversion": "npm test && cd src/ && clasp push",

src/Service.js

+22-16
Original file line numberDiff line numberDiff line change
@@ -636,20 +636,35 @@ Service_.prototype.getToken = function(optSkipMemoryCheck) {
636636
};
637637

638638
/**
639-
* Determines if a retrieved token is still valid.
639+
* Determines if a retrieved token is still valid. This will return false if
640+
* either the authorization token or the ID token has expired.
640641
* @param {Object} token The token to validate.
641642
* @return {boolean} True if it has expired, false otherwise.
642643
* @private
643644
*/
644645
Service_.prototype.isExpired_ = function(token) {
646+
var expired = false;
647+
var now = getTimeInSeconds_(new Date());
648+
649+
// Check the authorization token's expiration.
645650
var expiresIn = token.expires_in_sec || token.expires_in || token.expires;
646-
if (!expiresIn) {
647-
return false;
648-
} else {
651+
if (expiresIn) {
649652
var expiresTime = token.granted_time + Number(expiresIn);
650-
var now = getTimeInSeconds_(new Date());
651-
return expiresTime - now < Service_.EXPIRATION_BUFFER_SECONDS_;
653+
if (expiresTime - now < Service_.EXPIRATION_BUFFER_SECONDS_) {
654+
expired = true;
655+
}
652656
}
657+
658+
// Check the ID token's expiration, if it exists.
659+
if (token.id_token) {
660+
var payload = decodeJwt_(token.id_token);
661+
if (payload.exp &&
662+
payload.exp - now < Service_.EXPIRATION_BUFFER_SECONDS_) {
663+
expired = true;
664+
}
665+
}
666+
667+
return expired;
653668
};
654669

655670
/**
@@ -700,10 +715,6 @@ Service_.prototype.createJwt_ = function() {
700715
'Token URL': this.tokenUrl_,
701716
'Issuer or Client ID': this.issuer_ || this.clientId_
702717
});
703-
var header = {
704-
alg: 'RS256',
705-
typ: 'JWT'
706-
};
707718
var now = new Date();
708719
var expires = new Date(now.getTime());
709720
expires.setMinutes(expires.getMinutes() + this.expirationMinutes_);
@@ -725,12 +736,7 @@ Service_.prototype.createJwt_ = function() {
725736
claimSet[key] = additionalClaims[key];
726737
});
727738
}
728-
var toSign = Utilities.base64EncodeWebSafe(JSON.stringify(header)) + '.' +
729-
Utilities.base64EncodeWebSafe(JSON.stringify(claimSet));
730-
var signatureBytes =
731-
Utilities.computeRsaSha256Signature(toSign, this.privateKey_);
732-
var signature = Utilities.base64EncodeWebSafe(signatureBytes);
733-
return toSign + '.' + signature;
739+
return encodeJwt_(claimSet, this.privateKey_);
734740
};
735741

736742
/**

src/Utilities.js

+35-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ function extend_(destination, source) {
8282
* Gets a copy of an object with all the keys converted to lower-case strings.
8383
*
8484
* @param {Object} obj The object to copy.
85-
* @return {Object} a shallow copy of the object with all lower-case keys.
85+
* @return {Object} A shallow copy of the object with all lower-case keys.
8686
*/
8787
function toLowerCaseKeys_(obj) {
8888
if (obj === null || typeof obj !== 'object') {
@@ -95,3 +95,37 @@ function toLowerCaseKeys_(obj) {
9595
return result;
9696
}, {});
9797
}
98+
99+
/* exported encodeJwt_ */
100+
/**
101+
* Encodes and signs a JWT.
102+
*
103+
* @param {Object} payload The JWT payload.
104+
* @param {string} key The key to use when generating the signature.
105+
* @return {string} The encoded and signed JWT.
106+
*/
107+
function encodeJwt_(payload, key) {
108+
var header = {
109+
alg: 'RS256',
110+
typ: 'JWT'
111+
};
112+
var toSign = Utilities.base64EncodeWebSafe(JSON.stringify(header)) + '.' +
113+
Utilities.base64EncodeWebSafe(JSON.stringify(payload));
114+
var signatureBytes =
115+
Utilities.computeRsaSha256Signature(toSign, key);
116+
var signature = Utilities.base64EncodeWebSafe(signatureBytes);
117+
return toSign + '.' + signature;
118+
}
119+
120+
/* exported decodeJwt_ */
121+
/**
122+
* Decodes and returns the parts of the JWT. The signature is not verified.
123+
*
124+
* @param {string} jwt The JWT to decode.
125+
* @return {Object} The decoded payload.
126+
*/
127+
function decodeJwt_(jwt) {
128+
var payload = jwt.split('.')[1];
129+
var blob = Utilities.newBlob(Utilities.base64DecodeWebSafe(payload));
130+
return JSON.parse(blob.getDataAsString());
131+
}

test/mocks/blob.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
var MockBlob = function(buffer) {
2+
this.buffer = buffer;
3+
};
4+
5+
MockBlob.prototype.getDataAsString = function() {
6+
return this.buffer.toString();
7+
};
8+
9+
module.exports = MockBlob;

test/test.js

+193-1
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,29 @@ var MockProperties = require('./mocks/properties');
55
var MockCache = require('./mocks/cache');
66
var MockLock = require('./mocks/lock');
77
var MockScriptApp = require('./mocks/script');
8+
var MockBlob = require('./mocks/blob');
89
var Future = require('fibers/future');
10+
var URLSafeBase64 = require('urlsafe-base64');
911

1012
var mocks = {
1113
ScriptApp: new MockScriptApp(),
1214
UrlFetchApp: new MockUrlFetchApp(),
1315
Utilities: {
1416
base64Encode: function(data) {
1517
return Buffer.from(data).toString('base64');
16-
}
18+
},
19+
base64EncodeWebSafe: function(data) {
20+
return URLSafeBase64.encode(Buffer.from(data));
21+
},
22+
base64DecodeWebSafe: function(data) {
23+
return URLSafeBase64.decode(data);
24+
},
25+
computeRsaSha256Signature: function(data, key) {
26+
return Math.random().toString(36);
27+
},
28+
newBlob: function(data) {
29+
return new MockBlob(data);
30+
},
1731
},
1832
__proto__: gas.globalMockDefault
1933
};
@@ -483,6 +497,155 @@ describe('Service', function() {
483497
assert.equal(state.arguments.foo, 'bar');
484498
});
485499
});
500+
501+
describe('#isExpired_()', function() {
502+
const NOW_SECONDS = OAuth2.getTimeInSeconds_(new Date());
503+
const ONE_HOUR_AGO_SECONDS = NOW_SECONDS - 360;
504+
505+
506+
it('should return false if there is no expiration time in the token',
507+
function() {
508+
var service = OAuth2.createService('test')
509+
.setPropertyStore(new MockProperties())
510+
.setCache(new MockCache());
511+
var token = {};
512+
513+
assert.isFalse(service.isExpired_(token));
514+
});
515+
516+
it('should return false if before the time in expires_in', function() {
517+
var service = OAuth2.createService('test')
518+
.setPropertyStore(new MockProperties())
519+
.setCache(new MockCache());
520+
var token = {
521+
expires_in: 360, // One hour.
522+
granted_time: NOW_SECONDS,
523+
};
524+
525+
assert.isFalse(service.isExpired_(token));
526+
});
527+
528+
it('should return true if past the time in "expires_in"', function() {
529+
var service = OAuth2.createService('test')
530+
.setPropertyStore(new MockProperties())
531+
.setCache(new MockCache());
532+
var token = {
533+
expires_in: 60, // One minute.
534+
granted_time: ONE_HOUR_AGO_SECONDS,
535+
};
536+
537+
assert.isTrue(service.isExpired_(token));
538+
});
539+
540+
it('should return false if before the time in "expires"', function() {
541+
var service = OAuth2.createService('test')
542+
.setPropertyStore(new MockProperties())
543+
.setCache(new MockCache());
544+
var token = {
545+
expires_in: 360, // One hour.
546+
granted_time: NOW_SECONDS,
547+
};
548+
549+
assert.isFalse(service.isExpired_(token));
550+
});
551+
552+
it('should return true if past the time in "expires"', function() {
553+
var service = OAuth2.createService('test')
554+
.setPropertyStore(new MockProperties())
555+
.setCache(new MockCache());
556+
var token = {
557+
expires_in: 60, // One minute.
558+
granted_time: ONE_HOUR_AGO_SECONDS,
559+
};
560+
561+
assert.isTrue(service.isExpired_(token));
562+
});
563+
564+
it('should return false if before the time in "expires_in_sec"',
565+
function() {
566+
var service = OAuth2.createService('test')
567+
.setPropertyStore(new MockProperties())
568+
.setCache(new MockCache());
569+
var token = {
570+
expires_in: 360, // One hour.
571+
granted_time: NOW_SECONDS,
572+
};
573+
574+
assert.isFalse(service.isExpired_(token));
575+
});
576+
577+
it('should return true if past the time in "expires_in_sec"', function() {
578+
var service = OAuth2.createService('test')
579+
.setPropertyStore(new MockProperties())
580+
.setCache(new MockCache());
581+
var token = {
582+
expires_in: 60, // One minute.
583+
granted_time: ONE_HOUR_AGO_SECONDS,
584+
};
585+
586+
assert.isTrue(service.isExpired_(token));
587+
});
588+
589+
it('should return true if within the buffer', function() {
590+
var service = OAuth2.createService('test')
591+
.setPropertyStore(new MockProperties())
592+
.setCache(new MockCache());
593+
var token = {
594+
expires_in: 30, // 30 seconds.
595+
granted_time: NOW_SECONDS,
596+
};
597+
598+
assert.isTrue(service.isExpired_(token));
599+
});
600+
601+
it('should return true if past the JWT expiration', function() {
602+
var service = OAuth2.createService('test')
603+
.setPropertyStore(new MockProperties())
604+
.setCache(new MockCache());
605+
var idToken = OAuth2.encodeJwt_({
606+
exp: NOW_SECONDS - 60, // One minute ago.
607+
}, 'key');
608+
var token = {
609+
id_token: idToken,
610+
};
611+
612+
assert.isTrue(service.isExpired_(token));
613+
});
614+
615+
it('should return false if the JWT is expired but the token is not',
616+
function() {
617+
var service = OAuth2.createService('test')
618+
.setPropertyStore(new MockProperties())
619+
.setCache(new MockCache());
620+
var idToken = OAuth2.encodeJwt_({
621+
exp: NOW_SECONDS - 60, // One minute ago.
622+
}, 'key');
623+
var token = {
624+
id_token: idToken,
625+
expires_in: 360, // One hour.
626+
granted_time: NOW_SECONDS,
627+
};
628+
629+
assert.isTrue(service.isExpired_(token));
630+
});
631+
632+
it('should return false if the token expired but the JWT is not',
633+
function() {
634+
var service = OAuth2.createService('test')
635+
.setPropertyStore(new MockProperties())
636+
.setCache(new MockCache());
637+
var idToken = OAuth2.encodeJwt_({
638+
exp: NOW_SECONDS + 360, // One hour from now.
639+
}, 'key');
640+
var token = {
641+
id_token: idToken,
642+
expires_in: 60, // One minute.
643+
granted_time: ONE_HOUR_AGO_SECONDS,
644+
};
645+
646+
assert.isTrue(service.isExpired_(token));
647+
});
648+
});
486649
});
487650

488651
describe('Utilities', function() {
@@ -531,4 +694,33 @@ describe('Utilities', function() {
531694
assert.isEmpty(toLowerCaseKeys_({}));
532695
});
533696
});
697+
698+
describe('#encodeJwt_()', function() {
699+
var encodeJwt_ = OAuth2.encodeJwt_;
700+
701+
it('should encode correctly', function() {
702+
var payload = {
703+
'foo': 'bar'
704+
};
705+
706+
var jwt = encodeJwt_(payload, 'key');
707+
var parts = jwt.split('.');
708+
709+
// Expexted values from jwt.io.
710+
assert.equal(parts[0], 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9');
711+
assert.equal(parts[1], 'eyJmb28iOiJiYXIifQ');
712+
});
713+
});
714+
715+
describe('#decodeJwt_()', function() {
716+
var decodeJwt_ = OAuth2.decodeJwt_;
717+
718+
it('should decode correctly', function() {
719+
var jwt = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.sig';
720+
721+
var payload = decodeJwt_(jwt);
722+
723+
assert.deepEqual(payload, {'foo': 'bar'});
724+
});
725+
});
534726
});

0 commit comments

Comments
 (0)