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

Commit

Permalink
Issue #231 Support client_assertion for OIDC auth flow
Browse files Browse the repository at this point in the history
For hybrid/code flow, client secret or client assertion is required to redeem the authorization code for access token. This fix enables user to use client assertion.

1. Added `thumbprint` and `privatePEMKey` options, so user can use the client assertion flow. However, client secret flow takes precedence. We will use client secret flow if `clientSecret` option is provided by user, regardless the values of `thumbprint` and `privatePEMKey`.
2. Added unit tests and end to end tests for client assertion flow.
  • Loading branch information
lovemaths committed Jan 4, 2017
1 parent e905a1b commit 4025dff
Show file tree
Hide file tree
Showing 12 changed files with 314 additions and 40 deletions.
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;
37 changes: 37 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,38 @@ 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;

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

callback(null, 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 is not provided.');
} 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: ' + post_params['client_assertion']);
};

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

0 comments on commit 4025dff

Please sign in to comment.