Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved OIDC compliance #446

Merged
merged 7 commits into from
Jan 15, 2020
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
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', {
davidpatrick marked this conversation as resolved.
Show resolved Hide resolved
'./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