Skip to content

Commit

Permalink
Merge pull request #446 from davidpatrick/idtoken-validation
Browse files Browse the repository at this point in the history
Improved OIDC compliance
  • Loading branch information
davidpatrick authored Jan 15, 2020
2 parents 03f4fd9 + 59f760b commit 38f986c
Show file tree
Hide file tree
Showing 5 changed files with 451 additions and 17 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"husky": "^3.0.1",
"jsdoc": "^3.6.3",
"json-loader": "^0.5.7",
"jws": "^3.2.2",
"minami": "^1.2.3",
"mocha": "^6.2.0",
"mocha-junit-reporter": "^1.23.1",
Expand Down
43 changes: 28 additions & 15 deletions src/auth/OAUthWithIDTokenValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ var jwksClient = require('jwks-rsa');
var Promise = require('bluebird');

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

var HS256_IGNORE_VALIDATION_MESSAGE =
'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.';
Expand Down Expand Up @@ -80,25 +81,37 @@ OAUthWithIDTokenValidation.prototype.create = function(params, data, cb) {
});
}
return new Promise((res, rej) => {
jwt.verify(
r.id_token,
getKey,
{
algorithms: this.supportedAlgorithms,
audience: this.clientId,
issuer: 'https://' + this.domain + '/'
},
function(err) {
if (!err) {
return res(r);
}
var options = {
algorithms: this.supportedAlgorithms,
audience: this.clientId,
issuer: 'https://' + this.domain + '/'
};

if (data.nonce) {
options.nonce = data.nonce;
}

if (data.maxAge) {
options.maxAge = data.maxAge;
}

jwt.verify(r.id_token, getKey, options, function(err) {
if (err) {
if (err.message && err.message.includes(HS256_IGNORE_VALIDATION_MESSAGE)) {
console.warn(HS256_IGNORE_VALIDATION_MESSAGE);
return res(r);
} else {
return rej(err);
}
return rej(err);
}
);

try {
validateIdToken(r.id_token, options);
} catch (idTokenError) {
return rej(idTokenError);
}

return res(r);
});
});
}
return r;
Expand Down
151 changes: 151 additions & 0 deletions src/auth/idToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
var urlDecodeB64 = function(data) {
return Buffer.from(data, 'base64').toString('utf8');
};

/**
* Decodes a string token into the 3 parts, throws if the format is invalid
* @param token
*/
var decode = function(token) {
var parts = token.split('.');

if (parts.length !== 3) {
throw new Error('ID token could not be decoded');
}

return {
_raw: token,
header: JSON.parse(urlDecodeB64(parts[0])),
payload: JSON.parse(urlDecodeB64(parts[1])),
signature: parts[2]
};
};

var DEFAULT_LEEWAY = 60; //default clock-skew, in seconds

/**
* Validator for ID Tokens following OIDC spec.
* @param token the string token to verify
* @param options the options required to run this verification
* @returns A promise containing the decoded token payload, or throws an exception if validation failed
*/
var validate = function(token, options) {
if (!token) {
throw new Error('ID token is required but missing');
}

var decodedToken = decode(token);

// Check algorithm
var header = decodedToken.header;
if (header.alg !== 'RS256' && header.alg !== 'HS256') {
throw new Error(
`Signature algorithm of "${header.alg}" is not supported. Expected the ID token to be signed with "RS256" or "HS256".`
);
}

var payload = decodedToken.payload;

// Issuer
if (!payload.iss || typeof payload.iss !== 'string') {
throw new Error('Issuer (iss) claim must be a string present in the ID token');
}
if (payload.iss !== options.issuer) {
throw new Error(
`Issuer (iss) claim mismatch in the ID token; expected "${options.issuer}", found "${payload.iss}"`
);
}

// Subject
if (!payload.sub || typeof payload.sub !== 'string') {
throw new Error('Subject (sub) claim must be a string present in the ID token');
}

// Audience
if (!payload.aud || !(typeof payload.aud === 'string' || Array.isArray(payload.aud))) {
throw new Error(
'Audience (aud) claim must be a string or array of strings present in the ID token'
);
}
if (Array.isArray(payload.aud) && !payload.aud.includes(options.audience)) {
throw new Error(
`Audience (aud) claim mismatch in the ID token; expected "${
options.audience
}" but was not one of "${payload.aud.join(', ')}"`
);
} else if (typeof payload.aud === 'string' && payload.aud !== options.audience) {
throw new Error(
`Audience (aud) claim mismatch in the ID token; expected "${options.audience}" but found "${payload.aud}"`
);
}

// --Time validation (epoch)--
var now = Math.floor(Date.now() / 1000);
var leeway = options.leeway || DEFAULT_LEEWAY;

// Expires at
if (!payload.exp || typeof payload.exp !== 'number') {
throw new Error('Expiration Time (exp) claim must be a number present in the ID token');
}
var expTime = payload.exp + leeway;

if (now > expTime) {
throw new Error(
`Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expTime})`
);
}

// Issued at
if (!payload.iat || typeof payload.iat !== 'number') {
throw new Error('Issued At (iat) claim must be a number present in the ID token');
}

// Nonce
if (options.nonce) {
if (!payload.nonce || typeof payload.nonce !== 'string') {
throw new Error('Nonce (nonce) claim must be a string present in the ID token');
}
if (payload.nonce !== options.nonce) {
throw new Error(
`Nonce (nonce) claim mismatch in the ID token; expected "${options.nonce}", found "${payload.nonce}"`
);
}
}

// Authorized party
if (Array.isArray(payload.aud) && payload.aud.length > 1) {
if (!payload.azp || typeof payload.azp !== 'string') {
throw new Error(
'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values'
);
}
if (payload.azp !== options.audience) {
throw new Error(
`Authorized Party (azp) claim mismatch in the ID token; expected "${options.audience}", found "${payload.azp}"`
);
}
}

// Authentication time
if (options.maxAge) {
if (!payload.auth_time || typeof payload.auth_time !== 'number') {
throw new Error(
'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'
);
}

var authValidUntil = payload.auth_time + options.maxAge + leeway;
if (now > authValidUntil) {
throw new Error(
`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}`
);
}
}

return decodedToken;
};

module.exports = {
decode: decode,
validate: validate
};
12 changes: 10 additions & 2 deletions test/auth/oauth-with-idtoken-validation.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ describe('OAUthWithIDTokenValidation', function() {
sinon.stub(jwt, 'verify').callsFake(function(idtoken, getKey, options, callback) {
callback(null, { verification: 'result' });
});
var oauthWithValidation = new OAUthWithIDTokenValidation(oauth, {});
OAUthWithIDTokenValidationProxy = proxyquire('../../src/auth/OAUthWithIDTokenValidation', {
'./idToken': { validate: token => token }
});

var oauthWithValidation = new OAUthWithIDTokenValidationProxy(oauth, {});
oauthWithValidation.create(PARAMS, DATA).then(function(r) {
expect(r).to.be.eql({ id_token: 'foobar' });
done();
Expand Down Expand Up @@ -162,6 +166,10 @@ describe('OAUthWithIDTokenValidation', function() {
return new Promise(res => res({ id_token: 'foobar' }));
}
};
OAUthWithIDTokenValidationProxy = proxyquire('../../src/auth/OAUthWithIDTokenValidation', {
'./idToken': { validate: token => token }
});

sinon.stub(jwt, 'verify').callsFake(function(idtoken, getKey, options, callback) {
getKey({ alg: 'HS256' }, function(err, key) {
expect(err.message).to.contain(
Expand All @@ -170,7 +178,7 @@ describe('OAUthWithIDTokenValidation', function() {
callback(err, key);
});
});
var oauthWithValidation = new OAUthWithIDTokenValidation(oauth, {});
var oauthWithValidation = new OAUthWithIDTokenValidationProxy(oauth, {});
oauthWithValidation.create(PARAMS, DATA, function(err, response) {
expect(err).to.be.null;
expect(response).to.be.eql({ id_token: 'foobar' });
Expand Down
Loading

0 comments on commit 38f986c

Please sign in to comment.