This repository was archived by the owner on Aug 7, 2021. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 145
Add support for certificate based confidential client auth. acquireTokenWithClientCertificate #32
Merged
Merged
Changes from 10 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
17808dc
Implement creation of self signed jwt for assertion based auth.
RandalliLama 354704b
Fix bug in self signed JWT creation. The header must contain the cer…
RandalliLama 52d47f6
This should have been part of the last commit, 354704bba47babd3de3958…
RandalliLama 0e929d5
Tests were broken by a change in the nock package. This fixes the is…
RandalliLama 4a697cb
Get basic cert auth happy path implemented.
RandalliLama e1defc3
Merge branch 'dev' into cert_auth
RandalliLama b506e3e
Update basic self-signed-jwt test to actually check that the returned…
RandalliLama df0beef
Complete self-sign-jwt validation code and tests.
RandalliLama f8767c8
Add end to end tests for client assertion flow.
RandalliLama 7619e39
Add sample for certificate/client assertion flow.
RandalliLama 277f933
Update logging message that was cut and pasted.
RandalliLama File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
/* | ||
/* | ||
* @copyright | ||
* Copyright © Microsoft Open Technologies, Inc. | ||
* | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
/* | ||
* @copyright | ||
* Copyright © Microsoft Open Technologies, Inc. | ||
* | ||
* All Rights Reserved | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http: *www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* THIS CODE IS PROVIDED *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS | ||
* OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION | ||
* ANY IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A | ||
* PARTICULAR PURPOSE, MERCHANTABILITY OR NON-INFRINGEMENT. | ||
* | ||
* See the Apache License, Version 2.0 for the specific language | ||
* governing permissions and limitations under the License. | ||
*/ | ||
'use strict'; | ||
|
||
var jwtConstants = require('./constants').Jwt; | ||
var Logger = require('./log').Logger; | ||
var util = require('./util'); | ||
|
||
require('date-utils'); | ||
var jws = require('jws'); | ||
var uuid = require('node-uuid'); | ||
|
||
/** | ||
* JavaScript dates are in milliseconds, but JWT dates are in seconds. | ||
* This function does the conversion. | ||
* @param {Date} date | ||
* @return {string} | ||
*/ | ||
function dateGetTimeInSeconds(date) { | ||
return Math.floor(date.getTime()/1000); | ||
} | ||
|
||
/** | ||
* Constructs a new SelfSignedJwt object. | ||
* @param {object} callContext Context specific to this token request. | ||
* @param {Authority} authority The authority to be used as the JWT audience. | ||
* @param {string} clientId The client id of the calling app. | ||
*/ | ||
function SelfSignedJwt(callContext, authority, clientId) { | ||
this._log = new Logger('SelfSignedJwt', callContext._logContext); | ||
this._callContext = callContext; | ||
|
||
this._authority = authority; | ||
this._tokenEndpoint = authority.tokenEndpoint; | ||
this._clientId = clientId; | ||
} | ||
|
||
/** | ||
* This wraps date creation in order to make unit testing easier. | ||
* @return {Date} | ||
*/ | ||
SelfSignedJwt.prototype._getDateNow = function() { | ||
return new Date(); | ||
}; | ||
|
||
SelfSignedJwt.prototype._getNewJwtId = function() { | ||
return uuid.v4(); | ||
}; | ||
|
||
/** | ||
* A regular certificate thumbprint is a hex encode string of the binary certificate | ||
* hash. For some reason teh x5t value in a JWT is a url save base64 encoded string | ||
* instead. This function does the conversion. | ||
* @param {string} thumbprint A hex encoded certificate thumbprint. | ||
* @return {string} A url safe base64 encoded certificate thumbprint. | ||
*/ | ||
SelfSignedJwt.prototype._createx5tValue = function(thumbprint) { | ||
var hexString = thumbprint.replace(/:/g, '').replace(/ /g, ''); | ||
var base64 = (new Buffer(hexString, 'hex')).toString('base64'); | ||
return util.convertRegularToUrlSafeBase64EncodedString(base64); | ||
}; | ||
|
||
/** | ||
* Creates the JWT header. | ||
* @param {string} thumbprint A hex encoded certificate thumbprint. | ||
* @return {object} | ||
*/ | ||
SelfSignedJwt.prototype._createHeader = function(thumbprint) { | ||
var x5t = this._createx5tValue(thumbprint); | ||
var header = { typ: 'JWT', alg: 'RS256', x5t : x5t }; | ||
|
||
this._log.verbose('Creating self signed JWT header. x5t: ' + x5t); | ||
|
||
return header; | ||
}; | ||
|
||
/** | ||
* Creates the JWT payload. | ||
* @return {object} | ||
*/ | ||
SelfSignedJwt.prototype._createPayload = function() { | ||
var now = this._getDateNow(); | ||
var expires = (new Date(now.getTime())).addMinutes(jwtConstants.SELF_SIGNED_JWT_LIFETIME); | ||
|
||
this._log.verbose('Creating self signed JWT payload. Expires: ' + expires + ' NotBefore: ' + now); | ||
|
||
var jwtPayload = {}; | ||
jwtPayload[jwtConstants.AUDIENCE] = this._tokenEndpoint; | ||
jwtPayload[jwtConstants.ISSUER] = this._clientId; | ||
jwtPayload[jwtConstants.SUBJECT] = this._clientId; | ||
jwtPayload[jwtConstants.NOT_BEFORE] = dateGetTimeInSeconds(now); | ||
jwtPayload[jwtConstants.EXPIRES_ON] = dateGetTimeInSeconds(expires); | ||
jwtPayload[jwtConstants.JWT_ID] = this._getNewJwtId(); | ||
|
||
return jwtPayload; | ||
}; | ||
|
||
SelfSignedJwt.prototype._throwOnInvalidJwtSignature = function(jwt) { | ||
var jwtSegments = jwt.split('.'); | ||
|
||
if (3 > jwtSegments.length || !jwtSegments[2]) { | ||
throw this._log.createError('Failed to sign JWT. This is most likely due to an invalid certificate.'); | ||
} | ||
|
||
return; | ||
}; | ||
|
||
SelfSignedJwt.prototype._signJwt = function(header, payload, certificate) { | ||
var jwt = jws.sign({ header : header, payload : payload, secret : certificate}); | ||
this._throwOnInvalidJwtSignature(jwt); | ||
return jwt; | ||
}; | ||
|
||
SelfSignedJwt.prototype._reduceThumbprint = function(thumbprint) { | ||
var canonical = thumbprint.toLowerCase().replace(/ /g, '').replace(/:/g, ''); | ||
this._throwOnInvalidThumbprint(canonical); | ||
return canonical; | ||
}; | ||
|
||
var numCharIn128BitHexString = 128/8*2; | ||
var numCharIn160BitHexString = 160/8*2; | ||
var thumbprintSizes = {}; | ||
thumbprintSizes[numCharIn128BitHexString] = true; | ||
thumbprintSizes[numCharIn160BitHexString] = true; | ||
var thumbprintRegExp = /^[a-f\d]*$/; | ||
|
||
SelfSignedJwt.prototype._throwOnInvalidThumbprint = function(thumbprint) { | ||
if (!thumbprintSizes[thumbprint.length] || !thumbprintRegExp.test(thumbprint)) { | ||
throw this._log.createError('The thumbprint does not match a known format'); | ||
} | ||
}; | ||
|
||
/** | ||
* Creates a self signed JWT that can be used as a client_assertion. | ||
* @param {string} certificate A PEM encoded certificate private key. | ||
* @param {string} thumbprint A hex encoded thumbprint of the certificate. | ||
* @return {string} A self signed JWT token. | ||
*/ | ||
SelfSignedJwt.prototype.create = function(certificate, thumbprint) { | ||
thumbprint = this._reduceThumbprint(thumbprint); | ||
var header = this._createHeader(thumbprint); | ||
|
||
var payload = this._createPayload(); | ||
|
||
var jwt = this._signJwt(header, payload, certificate); | ||
return jwt; | ||
}; | ||
|
||
module.exports = SelfSignedJwt; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,6 +25,7 @@ var CacheDriver = require('./cache-driver'); | |
var Logger = require('./log').Logger; | ||
var Mex = require('./mex'); | ||
var OAuth2Client = require('./oauth2client'); | ||
var SelfSignedJwt = require('./self-signed-jwt'); | ||
var UserRealm = require('./user-realm'); | ||
var WSTrustRequest = require('./wstrust-request'); | ||
|
||
|
@@ -76,6 +77,10 @@ TokenRequest.prototype._createOAuth2Client = function() { | |
return new OAuth2Client(this._callContext, this._authenticationContext._authority); | ||
}; | ||
|
||
TokenRequest.prototype._createSelfSignedJwt = function() { | ||
return new SelfSignedJwt(this._callContext, this._authenticationContext._authority, this._clientId); | ||
}; | ||
|
||
TokenRequest.prototype._oauthGetToken = function(oauthParameters, callback) { | ||
var client = this._createOAuth2Client(); | ||
client.getToken(oauthParameters, callback); | ||
|
@@ -112,13 +117,14 @@ TokenRequest.prototype._createCacheQuery = function() { | |
if (this._userId) { | ||
query.userId = this._userId; | ||
} else { | ||
this._log.verbose('No userId passed.'); | ||
this._log.verbose('No userId passed for cache query.'); | ||
} | ||
|
||
return query; | ||
}; | ||
|
||
TokenRequest.prototype._getToken = function(callback, getTokenFunc) { | ||
|
||
TokenRequest.prototype._getTokenWithCacheWrapper = function(callback, getTokenFunc) { | ||
var self = this; | ||
this._cacheDriver = this._createCacheDriver(); | ||
var cacheQuery = this._createCacheQuery(); | ||
|
@@ -353,24 +359,24 @@ TokenRequest.prototype.getTokenWithUsernamePassword = function(username, passwor | |
this._log.info('Acquiring token with username password'); | ||
|
||
this._userId = username; | ||
this._getToken(callback, function(innerCallback) { | ||
this._getTokenWithCacheWrapper(callback, function(getTokenCompleteCallback) { | ||
var self = this; | ||
this._userRealm = this._createUserRealmRequest(username); | ||
this._userRealm.discover(function(err) { | ||
if (err) { | ||
innerCallback(err); | ||
getTokenCompleteCallback(err); | ||
return; | ||
} | ||
|
||
switch(self._userRealm.accountType) { | ||
case AccountType.Managed: | ||
self._getTokenUsernamePasswordManaged(username, password, innerCallback); | ||
self._getTokenUsernamePasswordManaged(username, password, getTokenCompleteCallback); | ||
return; | ||
case AccountType.Federated: | ||
self._getTokenUsernamePasswordFederated(username, password, innerCallback); | ||
self._getTokenUsernamePasswordFederated(username, password, getTokenCompleteCallback); | ||
return; | ||
default: | ||
innerCallback(self._log.createError('Server returned an unknown AccountType: ' + self._userRealm.AccountType)); | ||
getTokenCompleteCallback(self._log.createError('Server returned an unknown AccountType: ' + self._userRealm.AccountType)); | ||
} | ||
}); | ||
}); | ||
|
@@ -385,12 +391,12 @@ TokenRequest.prototype.getTokenWithUsernamePassword = function(username, passwor | |
TokenRequest.prototype.getTokenWithClientCredentials = function(clientSecret, callback) { | ||
this._log.info('Getting token with client credentials.'); | ||
|
||
this._getToken(callback, function(innerCallback) { | ||
this._getTokenWithCacheWrapper(callback, function(getTokenCompleteCallback) { | ||
var oauthParameters = this._createOAuthParameters(OAuth2GrantType.CLIENT_CREDENTIALS); | ||
|
||
oauthParameters[OAuth2Parameters.CLIENT_SECRET] = clientSecret; | ||
|
||
this._oauthGetToken(oauthParameters, innerCallback); | ||
this._oauthGetToken(oauthParameters, getTokenCompleteCallback); | ||
}); | ||
}; | ||
|
||
|
@@ -455,11 +461,59 @@ TokenRequest.prototype.getTokenFromCacheWithRefresh = function(userId, callback) | |
this._log.info('Getting token from cache with refresh if necessary.'); | ||
|
||
this._userId = userId; | ||
this._getToken(callback, function(innerCallback){ | ||
this._getTokenWithCacheWrapper(callback, function(getTokenCompleteCallback) { | ||
// If this method was called then no cached entry was found. Since | ||
// this particular version of acquireToken can only retrieve tokens | ||
// from the cache, return an error. | ||
innerCallback(self._log.createError('Entry not found in cache.')); | ||
getTokenCompleteCallback(self._log.createError('Entry not found in cache.')); | ||
}); | ||
}; | ||
|
||
/** | ||
* Creates a self signed jwt. | ||
* @param {string} authorityUrl | ||
* @param {string} certificate A PEM encoded certificate private key. | ||
* @param {string} thumbprint | ||
* @return {string} A self signed JWT | ||
*/ | ||
TokenRequest.prototype._createJwt = function(authorityUrl, certificate, thumbprint) { | ||
var jwt; | ||
var ssj = this._createSelfSignedJwt(); | ||
jwt = ssj.create(certificate, thumbprint); | ||
if (!jwt) { | ||
throw this._log.createError('Failed to create JWT'); | ||
} | ||
|
||
return jwt; | ||
}; | ||
|
||
/** | ||
* Obtains a token via a certificate. The certificate is used to generate a self signed | ||
* JWT token that is passed as a client_assertion. | ||
* @param {string} certificate A PEM encoded certificate private key. | ||
* @param {string} thumbprint A hex encoded thumbprint of the certificate. | ||
* @param {AcquireTokenCallback} callback | ||
*/ | ||
TokenRequest.prototype.getTokenWithCertificate = function(certificate, thumbprint, callback) { | ||
|
||
this._log.info('Getting a new token from a refresh token.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Log message needs to be updated here. |
||
|
||
var authorityUrl = this._authenticationContext._authority; | ||
|
||
var jwt; | ||
try { | ||
jwt = this._createJwt(authorityUrl, certificate, thumbprint); | ||
} catch (err) { | ||
callback(err); | ||
return; | ||
} | ||
|
||
var oauthParameters = this._createOAuthParameters(OAuth2GrantType.CLIENT_CREDENTIALS); | ||
oauthParameters[OAuth2Parameters.CLIENT_ASSERTION_TYPE] = OAuth2GrantType.JWT_BEARER; | ||
oauthParameters[OAuth2Parameters.CLIENT_ASSERTION] = jwt; | ||
|
||
this._getTokenWithCacheWrapper(callback, function(getTokenCompleteCallback) { | ||
this._oauthGetToken(oauthParameters, getTokenCompleteCallback); | ||
}); | ||
}; | ||
|
||
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We abstract out client certificate using class ClientAssertionCertificate which has the Sign method (and maybe a hash method). This way, the object can be created differently on different platforms and the API still have the same OM.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We also have another class named ClientAssertion and an AcquireToken API for it, so developer can create the assertion once and pass it multiple times.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One idea we discussed in the API review meetings was to get rid of ClientAssertionCertificate and add a factory method (or constructor) to ClientAssertion to create it using a certificate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@afshins I don't think that the ClientAssertionCertificate class makes sense in Node. There is only one kind of certificate in Node and that is a PEM file. There is no need to create the cert differently on different platforms.
I can get to the version that accepts an already generated assertion later. I don't think we need to block this update on that particular version.
In general, Node is much lighter weight than C#, and generally doesn't involve a whole lot of classes. Since there is no static type checking it doesn't make as much sense to have a bunch of classes. In node strings are acceptable. I believe that it would be unnatural in node to force the developer to deal with a bunch of other classes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair enough. We need to make OM natural to the platform, so feel free to follow the common pattern.