Skip to content

Commit 38f986c

Browse files
authored
Merge pull request #446 from davidpatrick/idtoken-validation
Improved OIDC compliance
2 parents 03f4fd9 + 59f760b commit 38f986c

5 files changed

Lines changed: 451 additions & 17 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"husky": "^3.0.1",
4747
"jsdoc": "^3.6.3",
4848
"json-loader": "^0.5.7",
49+
"jws": "^3.2.2",
4950
"minami": "^1.2.3",
5051
"mocha": "^6.2.0",
5152
"mocha-junit-reporter": "^1.23.1",

src/auth/OAUthWithIDTokenValidation.js

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ var jwksClient = require('jwks-rsa');
33
var Promise = require('bluebird');
44

55
var ArgumentError = require('rest-facade').ArgumentError;
6+
var validateIdToken = require('./idToken').validate;
67

78
var HS256_IGNORE_VALIDATION_MESSAGE =
89
'Validation of `id_token` requires a `clientSecret` when using the HS256 algorithm. To ensure tokens are validated, please switch the signing algorithm to RS256 or provide a `clientSecret` in the constructor.';
@@ -80,25 +81,37 @@ OAUthWithIDTokenValidation.prototype.create = function(params, data, cb) {
8081
});
8182
}
8283
return new Promise((res, rej) => {
83-
jwt.verify(
84-
r.id_token,
85-
getKey,
86-
{
87-
algorithms: this.supportedAlgorithms,
88-
audience: this.clientId,
89-
issuer: 'https://' + this.domain + '/'
90-
},
91-
function(err) {
92-
if (!err) {
93-
return res(r);
94-
}
84+
var options = {
85+
algorithms: this.supportedAlgorithms,
86+
audience: this.clientId,
87+
issuer: 'https://' + this.domain + '/'
88+
};
89+
90+
if (data.nonce) {
91+
options.nonce = data.nonce;
92+
}
93+
94+
if (data.maxAge) {
95+
options.maxAge = data.maxAge;
96+
}
97+
98+
jwt.verify(r.id_token, getKey, options, function(err) {
99+
if (err) {
95100
if (err.message && err.message.includes(HS256_IGNORE_VALIDATION_MESSAGE)) {
96101
console.warn(HS256_IGNORE_VALIDATION_MESSAGE);
97-
return res(r);
102+
} else {
103+
return rej(err);
98104
}
99-
return rej(err);
100105
}
101-
);
106+
107+
try {
108+
validateIdToken(r.id_token, options);
109+
} catch (idTokenError) {
110+
return rej(idTokenError);
111+
}
112+
113+
return res(r);
114+
});
102115
});
103116
}
104117
return r;

src/auth/idToken.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
var urlDecodeB64 = function(data) {
2+
return Buffer.from(data, 'base64').toString('utf8');
3+
};
4+
5+
/**
6+
* Decodes a string token into the 3 parts, throws if the format is invalid
7+
* @param token
8+
*/
9+
var decode = function(token) {
10+
var parts = token.split('.');
11+
12+
if (parts.length !== 3) {
13+
throw new Error('ID token could not be decoded');
14+
}
15+
16+
return {
17+
_raw: token,
18+
header: JSON.parse(urlDecodeB64(parts[0])),
19+
payload: JSON.parse(urlDecodeB64(parts[1])),
20+
signature: parts[2]
21+
};
22+
};
23+
24+
var DEFAULT_LEEWAY = 60; //default clock-skew, in seconds
25+
26+
/**
27+
* Validator for ID Tokens following OIDC spec.
28+
* @param token the string token to verify
29+
* @param options the options required to run this verification
30+
* @returns A promise containing the decoded token payload, or throws an exception if validation failed
31+
*/
32+
var validate = function(token, options) {
33+
if (!token) {
34+
throw new Error('ID token is required but missing');
35+
}
36+
37+
var decodedToken = decode(token);
38+
39+
// Check algorithm
40+
var header = decodedToken.header;
41+
if (header.alg !== 'RS256' && header.alg !== 'HS256') {
42+
throw new Error(
43+
`Signature algorithm of "${header.alg}" is not supported. Expected the ID token to be signed with "RS256" or "HS256".`
44+
);
45+
}
46+
47+
var payload = decodedToken.payload;
48+
49+
// Issuer
50+
if (!payload.iss || typeof payload.iss !== 'string') {
51+
throw new Error('Issuer (iss) claim must be a string present in the ID token');
52+
}
53+
if (payload.iss !== options.issuer) {
54+
throw new Error(
55+
`Issuer (iss) claim mismatch in the ID token; expected "${options.issuer}", found "${payload.iss}"`
56+
);
57+
}
58+
59+
// Subject
60+
if (!payload.sub || typeof payload.sub !== 'string') {
61+
throw new Error('Subject (sub) claim must be a string present in the ID token');
62+
}
63+
64+
// Audience
65+
if (!payload.aud || !(typeof payload.aud === 'string' || Array.isArray(payload.aud))) {
66+
throw new Error(
67+
'Audience (aud) claim must be a string or array of strings present in the ID token'
68+
);
69+
}
70+
if (Array.isArray(payload.aud) && !payload.aud.includes(options.audience)) {
71+
throw new Error(
72+
`Audience (aud) claim mismatch in the ID token; expected "${
73+
options.audience
74+
}" but was not one of "${payload.aud.join(', ')}"`
75+
);
76+
} else if (typeof payload.aud === 'string' && payload.aud !== options.audience) {
77+
throw new Error(
78+
`Audience (aud) claim mismatch in the ID token; expected "${options.audience}" but found "${payload.aud}"`
79+
);
80+
}
81+
82+
// --Time validation (epoch)--
83+
var now = Math.floor(Date.now() / 1000);
84+
var leeway = options.leeway || DEFAULT_LEEWAY;
85+
86+
// Expires at
87+
if (!payload.exp || typeof payload.exp !== 'number') {
88+
throw new Error('Expiration Time (exp) claim must be a number present in the ID token');
89+
}
90+
var expTime = payload.exp + leeway;
91+
92+
if (now > expTime) {
93+
throw new Error(
94+
`Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expTime})`
95+
);
96+
}
97+
98+
// Issued at
99+
if (!payload.iat || typeof payload.iat !== 'number') {
100+
throw new Error('Issued At (iat) claim must be a number present in the ID token');
101+
}
102+
103+
// Nonce
104+
if (options.nonce) {
105+
if (!payload.nonce || typeof payload.nonce !== 'string') {
106+
throw new Error('Nonce (nonce) claim must be a string present in the ID token');
107+
}
108+
if (payload.nonce !== options.nonce) {
109+
throw new Error(
110+
`Nonce (nonce) claim mismatch in the ID token; expected "${options.nonce}", found "${payload.nonce}"`
111+
);
112+
}
113+
}
114+
115+
// Authorized party
116+
if (Array.isArray(payload.aud) && payload.aud.length > 1) {
117+
if (!payload.azp || typeof payload.azp !== 'string') {
118+
throw new Error(
119+
'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values'
120+
);
121+
}
122+
if (payload.azp !== options.audience) {
123+
throw new Error(
124+
`Authorized Party (azp) claim mismatch in the ID token; expected "${options.audience}", found "${payload.azp}"`
125+
);
126+
}
127+
}
128+
129+
// Authentication time
130+
if (options.maxAge) {
131+
if (!payload.auth_time || typeof payload.auth_time !== 'number') {
132+
throw new Error(
133+
'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'
134+
);
135+
}
136+
137+
var authValidUntil = payload.auth_time + options.maxAge + leeway;
138+
if (now > authValidUntil) {
139+
throw new Error(
140+
`Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Currrent time (${now}) is after last auth at ${authValidUntil}`
141+
);
142+
}
143+
}
144+
145+
return decodedToken;
146+
};
147+
148+
module.exports = {
149+
decode: decode,
150+
validate: validate
151+
};

test/auth/oauth-with-idtoken-validation.tests.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ describe('OAUthWithIDTokenValidation', function() {
118118
sinon.stub(jwt, 'verify').callsFake(function(idtoken, getKey, options, callback) {
119119
callback(null, { verification: 'result' });
120120
});
121-
var oauthWithValidation = new OAUthWithIDTokenValidation(oauth, {});
121+
OAUthWithIDTokenValidationProxy = proxyquire('../../src/auth/OAUthWithIDTokenValidation', {
122+
'./idToken': { validate: token => token }
123+
});
124+
125+
var oauthWithValidation = new OAUthWithIDTokenValidationProxy(oauth, {});
122126
oauthWithValidation.create(PARAMS, DATA).then(function(r) {
123127
expect(r).to.be.eql({ id_token: 'foobar' });
124128
done();
@@ -162,6 +166,10 @@ describe('OAUthWithIDTokenValidation', function() {
162166
return new Promise(res => res({ id_token: 'foobar' }));
163167
}
164168
};
169+
OAUthWithIDTokenValidationProxy = proxyquire('../../src/auth/OAUthWithIDTokenValidation', {
170+
'./idToken': { validate: token => token }
171+
});
172+
165173
sinon.stub(jwt, 'verify').callsFake(function(idtoken, getKey, options, callback) {
166174
getKey({ alg: 'HS256' }, function(err, key) {
167175
expect(err.message).to.contain(
@@ -170,7 +178,7 @@ describe('OAUthWithIDTokenValidation', function() {
170178
callback(err, key);
171179
});
172180
});
173-
var oauthWithValidation = new OAUthWithIDTokenValidation(oauth, {});
181+
var oauthWithValidation = new OAUthWithIDTokenValidationProxy(oauth, {});
174182
oauthWithValidation.create(PARAMS, DATA, function(err, response) {
175183
expect(err).to.be.null;
176184
expect(response).to.be.eql({ id_token: 'foobar' });

0 commit comments

Comments
 (0)