Skip to content
This repository has been archived by the owner on Aug 28, 2023. It is now read-only.

Issue #231 Support client_assertion for OIDC auth flow #275

Merged
merged 2 commits into from
Jan 6, 2017
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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,23 @@ passport.use(new OIDCStrategy({
Required to set to true if you want to use http url for redirectUrl like `http://localhost:3000`.

* `clientSecret` (Conditional)

When `responseType` is not `id_token`, we have to provide client credential to redeem the authorization code. This credential could be client secret or client assertion. Non-B2C tenant supports both flows, but B2C tenant only supports client secret flow.

Required if the `responseType` is not 'id_token'. This is the app key of your app in AAD. For B2C, the app key sometimes contains \, please replace \ with two \'s in the app key, otherwise \ will be treated as the beginning of an escaping character.

For B2C tenant: `clientSecret` is required if the `responseType` is not 'id_token'.

For non-B2C tenant: If `responseType` is not `id_token`, developer must provide either `clientSecret`, or `thumbprint` and `privatePEMKey`. We use `clientSecret` if it is provided; otherwise we use `thumbprint` and `privatePEMKey` for client assertion flow.

`clientSecret` is the app key of your app in AAD. For B2C, the app key sometimes contains \, please replace \ with two \'s in the app key, otherwise \ will be treated as the beginning of an escaping character.

* `thumbprint` (Conditional)

Required if you want to use client assertion flow. `thumbprint` is the base64url format of the thumbprint (hash value) of the public key.

* `privatePEMKey` (Conditional)

Required if you want to use client assertion flow. `privatePEMKey` is the private pem key string.

* `isB2C` (Conditional)

Required to set to true if you are using B2C tenant.
Expand Down
3 changes: 3 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ CONSTANTS.TENANTID_REGEX = /^[0-9a-zA-Z-]+$/;

CONSTANTS.CLOCK_SKEW = 300; // 5 minutes

CONSTANTS.CLIENT_ASSERTION_JWT_LIFETIME = 600; // 10 minutes
CONSTANTS.CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';

module.exports = CONSTANTS;
38 changes: 38 additions & 0 deletions lib/jsonWebToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

'use restrict';

const aadutils = require('./aadutils');
const CONSTANTS = require('./constants');
const jws = require('jws');

// check if two arrays have common elements
Expand Down Expand Up @@ -210,3 +212,39 @@ exports.verify = function(jwtString, PEMKey, options, callback) {

return done(null, payload);
};

/* Generate client assertion
*
* @params {String} clientID
* @params {String} token_endpoint
* @params {String} privatePEMKey
* @params {String} thumbprint
* @params {Function} callback
*/
exports.generateClientAssertion = function(clientID, token_endpoint, privatePEMKey, thumbprint, callback) {
var header = { 'x5t': thumbprint, 'alg': 'RS256', 'typ': 'JWT' };
var payload = {
sub: clientID,
iss: clientID,
jti: Date.now() + aadutils.uid(16),
nbf: Math.floor(Date.now()/1000),
exp: Math.floor(Date.now()/1000) + CONSTANTS.CLIENT_ASSERTION_JWT_LIFETIME,
aud: token_endpoint,
};

var clientAssertion;
var exception = null;

try {
clientAssertion = jws.sign({
header: header,
payload: payload,
privateKey: privatePEMKey
});
} catch (ex) {
exception = ex;
}

callback(exception, clientAssertion);
};

140 changes: 118 additions & 22 deletions lib/oidcstrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,28 @@ function onProfileLoaded(strategy, args) {
* The default value is false. It's OK to use http like 'http://localhost:3000' in the
* dev environment, but in production environment https should always be used.
*
* - `clientSecret` (1) Required if `responseType` is 'code', 'id_token code' or 'code id_token'
* - `clientSecret` (1) This option only applies when `responseType` is 'code', 'id_token code' or 'code id_token'.
* To redeem an authorization code, we can use either client secret flow or client assertion flow.
* (1.1) For B2C, clientSecret is required since client assertion is not supported
* (1.2) For non-B2C, both flows are supported. Developer must provide either clientSecret, or
* thumbprint and privatePEMKey. We use clientSecret if it is provided, otherwise we use
* thumbprint and privatePEMKey for the client assertion flow.
* (2) must be a string
* (3) Description:
* The app key of your app from AAD.
* NOTE: For B2C, the app key sometimes contains '\', please replace '\' with '\\' in the app key, otherwise
* '\' will be treated as the beginning of a escaping character
*
* - `thumbprint` (1) Required if you want to use client assertion to redeem an authorization code (non-B2C only)
* (2) must be a base64url encoded string
* (3) Description:
* The thumbprint (hash value) of the public key
*
* - `privatePEMKey` (1) Required if you want to use client assertion to redeem an authorization code (non-B2C only)
* (2) must be a pem key
* (3) Description:
* The private key used to sign the client assertion JWT
*
* - `isB2C` (1) Required for B2C
* (2) must be true for B2C, default is false
* (3) Description:
Expand Down Expand Up @@ -251,6 +266,8 @@ function onProfileLoaded(strategy, args) {
* redirectUrl: config.creds.redirectUrl,
* allowHttpForRedirectUrl: config.creds.allowHttpForRedirectUrl,
* clientSecret: config.creds.clientSecret,
* thumbprint: config.creds.thumbprint,
* privatePEMKey: config.crecs.privatePEMKey,
* isB2C: config.creds.isB2C,
* validateIssuer: config.creds.validateIssuer,
* issuer: config.creds.issuer,
Expand Down Expand Up @@ -445,6 +462,21 @@ function Strategy(options, verify) {
var validator = new Validator(validatorConfiguration);
validator.validate(itemsToValidate);

// validate client secret, thumbprint and privatePEMKey for hybrid and authorization flow
if (options.responseType !== 'id_token') {
if (options.isB2C && !options.clientSecret) {
// for B2C, clientSecret is required to redeem authorization code.
throw new Error('clientSecret must be provided for B2C hybrid flow and authorization code flow.');
} else if (!options.clientSecret) {
// for non-B2C, we can use either clientSecret or clientAssertion to redeem authorization code.
// Therefore, we need either clientSecret, or privatePEMKey and thumbprint (so we can create clientAssertion).
if (!options.privatePEMKey)
throw new Error('privatePEMKey is not provided. Please provide either clientSecret, or privatePEMKey and thumbprint.');
if (!options.thumbprint)
throw new Error('thumbprint is not provided. Please provide either clientSecret, or privatePEMKey and thumbprint.');
}
}

// we allow 'http' for the redirectUrl, but don't recommend using 'http'
if (urlValidator.isHttpUri(options.redirectUrl))
log.warn(`Using http for redirectUrl is not recommended, please consider using https`);
Expand Down Expand Up @@ -728,7 +760,7 @@ Strategy.prototype.setOptions = function setOptions(params, oauthConfig, options

// copy the fields needed into 'oauthConfig'
aadutils.copyObjectFields(metadata.oidc, oauthConfig, ['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint']);
aadutils.copyObjectFields(self._options, oauthConfig, ['clientID', 'clientSecret', 'responseType', 'responseMode', 'scope', 'redirectUrl']);
aadutils.copyObjectFields(self._options, oauthConfig, ['clientID', 'clientSecret', 'privatePEMKey', 'thumbprint', 'responseType', 'responseMode', 'scope', 'redirectUrl']);
oauthConfig.tenantIdOrName = params.tenantIdOrName;

// validate oauthConfig
Expand Down Expand Up @@ -1022,29 +1054,13 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(params, o
var issFromPrevIdToken = iss;
var subFromPrevIdToken = sub;

let libraryVersion = aadutils.getLibraryVersion();
let libraryVersionParameterName = aadutils.getLibraryVersionParameterName();
let libraryProduct = aadutils.getLibraryProduct();
let libraryProductParameterName = aadutils.getLibraryProductParameterName();

const oauth2 = new OAuth2(
oauthConfig.clientID, // consumerKey
oauthConfig.clientSecret, // consumer secret
'', // baseURL (empty string because we use absolute urls for authorize and token paths)
oauthConfig.authorization_endpoint, // authorizePath
oauthConfig.token_endpoint, // accessTokenPath
{libraryProductParameterName : libraryProduct,
libraryVersionParameterName : libraryVersion} // customHeaders
);

return oauth2.getOAuthAccessToken(code, {
grant_type: 'authorization_code',
redirect_uri: oauthConfig.redirectUrl,
scope: oauthConfig.scope
}, (getOAuthAccessTokenError, access_token, refresh_token, items) => {
return self._getAccessTokenBySecretOrAssertion(code, oauthConfig, next, (getOAuthAccessTokenError, items) => {
if (getOAuthAccessTokenError)
return next(new Error(`In _authCodeFlowHandler: failed to redeem authorization code: ${aadutils.getErrorMessage(getOAuthAccessTokenError)}`));

var access_token = items.access_token;
var refresh_token = items.refresh_token;

// id_token should be present
if (!items.id_token)
return next(new Error('In _authCodeFlowHandler: id_token is not received'));
Expand Down Expand Up @@ -1093,6 +1109,7 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(params, o
const userInfoURL = url.format(parsedUrl);

// ask oauth2 to use authorization header to bearer access token
var oauth2 = createOauth2Instance(oauthConfig);
oauth2.useAuthorizationHeaderforGET(true);
return oauth2.get(userInfoURL, access_token, (getUserInfoError, body) => {
if (getUserInfoError)
Expand Down Expand Up @@ -1211,6 +1228,61 @@ Strategy.prototype._flowInitializationHandler = function flowInitializationHandl
return self.redirect(location);
};

/**
* get access_token using client secret or client assertion
*
* @params {String} code
* @params {Object} oauthConfig
* @params {Function} callback
*/
Strategy.prototype._getAccessTokenBySecretOrAssertion = (code, oauthConfig, next, callback) => {
var post_headers = { 'Content-Type': 'application/x-www-form-urlencoded' };

var post_params = {
code: code,
client_id: oauthConfig.clientID,
redirect_uri: oauthConfig.redirectUrl,
scope: oauthConfig.scope,
grant_type: 'authorization_code'
};

if (oauthConfig.clientSecret) {
// use client secret if it exists
post_params['client_secret'] = oauthConfig.clientSecret;
log.info('In _getAccessTokenBySecretOrAssertion: we are using client secret');
} else {
// otherwise generate a client assertion
post_params['client_assertion_type'] = CONSTANTS.CLIENT_ASSERTION_TYPE;

jwt.generateClientAssertion(oauthConfig.clientID, oauthConfig.token_endpoint, oauthConfig.privatePEMKey, oauthConfig.thumbprint,
(err, assertion) => {
if (err)
return next(err);
else
post_params['client_assertion'] = assertion;
});

log.info('In _getAccessTokenBySecretOrAssertion: we created a client assertion with thumbprint ' + oauthConfig.thumbprint);
};

var post_data = querystring.stringify(post_params);

var oauth2 = createOauth2Instance(oauthConfig);

oauth2._request('POST', oauthConfig.token_endpoint, post_headers, post_data, null, (error, data, response) => {
if (error) callback(error);
else {
var results;
try {
results = JSON.parse(data);
} catch(e) {
results = querystring.parse(data);
}
callback(null, results);
}
});
};

/**
* fail and log the given message
*
Expand All @@ -1221,4 +1293,28 @@ Strategy.prototype.failWithLog = function(message) {
return this.fail(message);
};

/**
* create an oauth2 instance
*
* @params {Object} oauthConfig
*/
var createOauth2Instance = function(oauthConfig) {
let libraryVersion = aadutils.getLibraryVersion();
let libraryVersionParameterName = aadutils.getLibraryVersionParameterName();
let libraryProduct = aadutils.getLibraryProduct();
let libraryProductParameterName = aadutils.getLibraryProductParameterName();

var oauth2 = new OAuth2(
oauthConfig.clientID, // consumerKey
oauthConfig.clientSecret, // consumer secret
'', // baseURL (empty string because we use absolute urls for authorize and token paths)
oauthConfig.authorization_endpoint, // authorizePath
oauthConfig.token_endpoint, // accessTokenPath
{libraryProductParameterName : libraryProduct,
libraryVersionParameterName : libraryVersion} // customHeaders
);

return oauth2;
};

module.exports = Strategy;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"cache-manager": "^2.0.0",
"jws": "^3.1.3",
"lodash": "^4.11.2",
"oauth": "^0.9.14",
"oauth": "0.9.14",
"passport": "^0.3.2",
"pkginfo": "^0.4.0",
"request": "^2.72.0",
Expand Down
27 changes: 19 additions & 8 deletions test/Chai-passport_test/b2c_oidc_hybrid_and_code_flow_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ wfp+cqZbCd9TENyHaTb8iA27s+73L3ExOQIDAQAB\n\
var options = {
redirectUrl: 'https://localhost:3000/auth/openid/return',
clientID: 'f0b6e4eb-2d8c-40b6-b9c6-e26d1074846d',
clientSecret: 'secret',
identityMetadata: 'https://login.microsoftonline.com/mytenant.onmicrosoft.com/v2.0/.well-known/openid-configuration',
responseType: 'code id_token',
responseMode: 'form_post',
Expand All @@ -80,20 +81,30 @@ var testStrategy = new OIDCStrategy(options, function(profile, done) {
// mock the token response we want when we consume the code
var setTokenResponse = function(id_token_in_token_resp, access_token_in_token_resp) {
return () => {
OAuth2.prototype.getOAuthAccessToken = function(code, params, callback) {
params = {'id_token': id_token_in_token_resp, 'token_type': 'Bearer'};
callback(null, access_token_in_token_resp, null, params);
}
testStrategy._getAccessTokenBySecretOrAssertion = function(code, oauthConfig, next, callback) {
var params = {
'id_token': id_token_in_token_resp,
'token_type': 'Bearer',
'access_token': access_token_in_token_resp,
'refresh_token': null
};
callback(null, params);
};
};
};

// mock the token response we want when we consume the code, and use 'bearer' fpr token_type
var setTokenResponseWithLowerCaseBearer = function(id_token_in_token_resp, access_token_in_token_resp) {
return () => {
OAuth2.prototype.getOAuthAccessToken = function(code, params, callback) {
params = {'id_token': id_token_in_token_resp, 'token_type': 'bearer'};
callback(null, access_token_in_token_resp, null, params);
}
testStrategy._getAccessTokenBySecretOrAssertion = function(code, oauthConfig, next, callback) {
var params = {
'id_token': id_token_in_token_resp,
'token_type': 'Bearer',
'access_token': access_token_in_token_resp,
'refresh_token': null
};
callback(null, params);
};
};
};

Expand Down
12 changes: 9 additions & 3 deletions test/Chai-passport_test/oidc_hybrid_and_code_flow_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ KzveKf3l5UU3c6PkGy+BB3E/ChqFm6sPWwIDAQAB\n\
var options = {
redirectUrl: 'https://localhost:3000/auth/openid/return',
clientID: '2abf3a52-7d86-460b-a1ef-77dc43de8aad',
clientSecret: 'secret',
identityMetadata: 'https://login.microsoftonline.com/sijun.onmicrosoft.com/.well-known/openid-configuration',
responseType: 'id_token code',
responseMode: 'form_post',
Expand Down Expand Up @@ -105,9 +106,14 @@ var setUserInfoResponse = function(sub_choice) {
// mock the token response we want when we consume the code
var setTokenResponse = function(id_token_in_token_resp, access_token_in_token_resp) {
return () => {
OAuth2.prototype.getOAuthAccessToken = function(code, params, callback) {
params = {'id_token': id_token_in_token_resp, 'token_type': 'Bearer'};
callback(null, access_token_in_token_resp, refresh_token, params);
testStrategy._getAccessTokenBySecretOrAssertion = function(code, oauthConfig, next, callback) {
var params = {
'id_token': id_token_in_token_resp,
'token_type': 'Bearer',
'access_token': access_token_in_token_resp,
'refresh_token': refresh_token
};
callback(null, params);
}
};
};
Expand Down
Loading