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

Commit

Permalink
Merge pull request #291 from AzureAD/dev
Browse files Browse the repository at this point in the history
Release 3.0.6
  • Loading branch information
lovemaths authored Apr 7, 2017
2 parents 211ab6b + 18bd605 commit d1fb360
Show file tree
Hide file tree
Showing 13 changed files with 1,292 additions and 24 deletions.
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
<a name="3.0.5"></a>
<a name="3.0.6"></a>
# 3.0.6

## OIDCStrategy

### New features

* [#285](https://github.com/AzureAD/passport-azure-ad/issues/285) express-session free support

We used to save state etc in express session, so you cannot be session free even if `{ session : fase }`
option is used in `passport.authenticate`. Now we provide an option to save state etc in cookie via
encryption and decryption, so OIDCStrategy no longer relies on express session.

More details can be found in README.md, section 5.1.4.

# 3.0.5

## OIDCStrategy
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ passport.use(new OIDCStrategy({
scope: config.creds.scope,
loggingLevel: config.creds.loggingLevel,
nonceLifetime: config.creds.nonceLifetime,
nonceMaxAmount: config.creds.nonceMaxAmount,
useCookieInsteadOfSession: config.creds.useCookieInsteadOfSession,
cookieEncryptionKeys: config.creds.cookieEncryptionKeys,
clockSkew: config.creds.clockSkew,
},
function(iss, sub, profile, accessToken, refreshToken, done) {
Expand Down Expand Up @@ -174,6 +177,17 @@ passport.use(new OIDCStrategy({
This option is required if you want to accept and decrypt id_token in JWE Compact Serialization format. See
section 5.1.1.4 for more details.

* `useCookieInsteadOfSession` (Conditional)

Passport-azure-ad saves state and nonce in session by default for validation purpose. If `useCookieInsteadOfSession` is set to true, passport-azure-ad will encrypt the state/nonce and
put them into cookie instead. This is helpful when we want to be completely session-free, in other words, when you use { session: false } option in passport.authenticate function.
If `useCookieInsteadOfSession` is set to true, you must provide `cookieEncryptionKeys` for cookie encryption and decryption.

* `cookieEncryptionKeys` (Conditional)

If `useCookieInsteadOfSession` is set to true, you must provide `cookieEncryptionKeys`. It is an array of the following format: [ {key: '...', 'iv': '...' }, {key: '...', 'iv': '...' }, ...]. key could be any string of length 32, and iv could be any string of length 12. We always use the first set of key/iv to encrypt cookie, but we try all the key/iv to decrypt cookie.
This is helpful if you want to do key rollover. The encryption/decryption algorithm we use is aes-256-gcm. You can limit the cookie amount and expiration using `nonceLifetime` and `nonceMaxAmount` options.

* `scope` (Optional)

List of scope values besides `openid` indicating the required scope of the access token for accessing the requested resource. For example, ['email', 'profile']. If you need refresh_token for v2 endpoint, then you have to include the 'offline_access' scope.
Expand All @@ -186,6 +200,10 @@ passport.use(new OIDCStrategy({

The lifetime of nonce in session in seconds. The default value is 3600 seconds.

* `nonceMaxAmount` (Optional)

The max amount of nonce you want to keep in session or cookies. The default number is 10. The oldest nonce(s) will be removed if the total number exceeds. (You can keep this number very small because nonce will be removed from session or cookies after validation. This mainly handles the case when user opens more than one login tabs at once and wants to go back to the first login page to type user credentials. Each login tab results in a nonce in session or cookie, so we only honor the most recent nonceMaxAmount many login tabs.)

* `clockSkew` (Optional)

This value is the clock skew (in seconds) allowed in token validation. It must be a positive integer. The default value is 300 seconds.
Expand Down Expand Up @@ -357,6 +375,8 @@ the strategy.

* `prompt`: v1 and v2 endpoint support `login`, `consent` and `admin_conset`; B2C endpoint only supports `login`.

* `response`: this is required if you want to use cookie instead of session to save state/nonce. See section 5.1.4.

Example:

```
Expand All @@ -365,6 +385,30 @@ Example:
passport.authenticate('azuread-openidconnect', { tenantIdOrName: 'contoso.onmicrosoft.com' });
```

#### 5.1.4 Session free support

Passport framework uses session to keep a persistent login session. As a plug in, we also use session to store state and nonce by default, regardless whether you use { session: false } option in passport.authenticate or not. To be completely session free, you must configure passport-azure-ad to create state/nonce cookie instead of saving them in session. Please follow the following example:

```
passport.use(new OIDCStrategy({
...
nonceLifetime: 600, // state/nonce cookie expiration in seconds
nonceMaxAmount: 5, // max amount of state/nonce cookie you want to keep (cookie is deleted after validation so this can be very small)
useCookieInsteadOfSession: true, // use cookie, not session
cookieEncryptionKeys: [ { key: '12345678901234567890123456789012', 'iv': '123456789012' }], // encrypt/decrypt key and iv, see `cookieEncryptionKeys` instruction in section 5.1.1.2
},
// any supported verify callback
function(iss, sub, profile, accessToken, refreshToken, done) {
...
});
// must pass the response object to passport.authenticate, since we will use response object to set cookie
app.get('/login', function(req, res, next) => {
passport.authenticate('azuread-openidconnect', { session: false, response: res })(req, res, next);
});
```

### 5.2 BearerStrategy

#### 5.2.1 Configure strategy and provide callback function
Expand Down
159 changes: 159 additions & 0 deletions lib/cookieContentHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Copyright (c) Microsoft Corporation
* All Rights Reserved
* MIT License
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this
* software and associated documentation files (the "Software"), to deal in the Software
* without restriction, including without limitation the rights to use, copy, modify,
* merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
* OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
* OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

'use restrict';

var crypto = require('crypto');
var createBuffer = require('./jwe').createBuffer;

/*
* the handler for state/nonce/policy
* @maxAmount - the max amount of {state: x, nonce: x, policy: x} tuples you want to save in cookie
* @maxAge - when a tuple in session expires in seconds
* @cookieEncryptionKeys
* - keys used to encrypt and decrypt cookie
*/
function CookieContentHandler(maxAmount, maxAge, cookieEncryptionKeys) {
if (!maxAge || (typeof maxAge !== 'number' || maxAge <= 0))
throw new Error('CookieContentHandler: maxAge must be a positive number');
this.maxAge = maxAge; // seconds

if (!maxAmount || (typeof maxAmount !== 'number' || maxAmount <= 0 || maxAmount % 1 !== 0))
throw new Error('CookieContentHandler: maxAmount must be a positive integer');
this.maxAmount = maxAmount;

if (!cookieEncryptionKeys || !Array.isArray(cookieEncryptionKeys) || cookieEncryptionKeys.length === 0)
throw new Error('CookieContentHandler: cookieEncryptionKeys must be a non-emptry array');

for (var i = 0; i < cookieEncryptionKeys.length; i++) {
var item = cookieEncryptionKeys[i];
if (!item.key || !item.iv)
throw new Error(`CookieContentHandler: array item ${i+1} in cookieEncryptionKeys must have the form { key: , iv: }`);
if (item.key.length !== 32)
throw new Error(`CookieContentHandler: key number ${i+1} is ${item.key.length} bytes, expected: 32 bytes`);
if (item.iv.length !== 12)
throw new Error(`CookieContentHandler: iv number ${i+1} is ${item.iv.length} bytes, expected: 12 bytes`);
}

this.cookieEncryptionKeys = cookieEncryptionKeys;
}

CookieContentHandler.prototype.findAndDeleteTupleByState = function(req, res, stateToFind) {
if (!req.cookies)
throw new Error('Cookie is not found in request. Did you forget to use cookie parsing middleware such as cookie-parser?');

var cookieEncryptionKeys = this.cookieEncryptionKeys;

var tuple = null;

// try every key and every cookie
for (var i = 0; i < cookieEncryptionKeys.length; i++) {
var item = cookieEncryptionKeys[i];
var key = createBuffer(item.key);
var iv = createBuffer(item.iv);

for (var cookie in req.cookies) {
if (req.cookies.hasOwnProperty(cookie) && cookie.startsWith('passport-aad.')) {
var encrypted = cookie.substring(13);

try {
var decrypted = decryptCookie(encrypted, key, iv);
tuple = JSON.parse(decrypted);
} catch (ex) {
continue;
}

if (tuple.state === stateToFind) {
res.clearCookie(cookie);
return tuple;
}
}
}
}

return null;
};

CookieContentHandler.prototype.add = function(req, res, tupleToAdd) {
var cookies = [];

// collect the related cookies
for (var cookie in req.cookies) {
if (req.cookies.hasOwnProperty(cookie) && cookie.startsWith('passport-aad.'))
cookies.push(cookie);
}

// only keep the most recent maxAmount-1 many cookies
if (cookies.length > this.maxAmount - 1) {
cookies.sort();

var numberToRemove = cookies.length - (this.maxAmount - 1);

for (var i = 0; i < numberToRemove; i++) {
res.clearCookie(cookies[0]);
cookies.shift();
}
}

// add the new cookie

var tupleString = JSON.stringify(tupleToAdd);

var item = this.cookieEncryptionKeys[0];
var key = createBuffer(item.key);
var iv = createBuffer(item.iv);

var encrypted = encryptCookie(tupleString, key, iv);

res.cookie('passport-aad.' + Date.now() + '.' + encrypted, 0, { maxAge: this.maxAge * 1000, httpOnly: true });
};

var encryptCookie = function(content, key, iv) {
var cipher = crypto.createCipheriv('aes-256-gcm', key, iv);

var encrypted = cipher.update(content, 'utf8', 'hex');
encrypted += cipher.final('hex');
var authTag = cipher.getAuthTag().toString('hex');

return encrypted + '.' + authTag;
};

var decryptCookie = function(encrypted, key, iv) {
var parts = encrypted.split('.');
if (parts.length !== 3)
throw new Error('invalid cookie');

// the first part is timestamp, ignore it
var content = createBuffer(parts[1], 'hex');
var authTag = createBuffer(parts[2], 'hex');

var decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
var decrypted = decipher.update(content, 'hex', 'utf8');
decrypted += decipher.final('utf8');

return decrypted;
};

exports.CookieContentHandler = CookieContentHandler;

6 changes: 6 additions & 0 deletions lib/jsonWebToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,19 @@ exports.verify = function(jwtString, PEMKey, options, callback) {
// - always validate
// - allow payload.aud to be an array of audience
// - options.audience must be a string
// - if there are multiple audiences, then azp claim must exist and must equal client_id
if (typeof options.audience === 'string')
options.audience = [options.audience, 'spn:' + options.audience];
if (options.allowMultiAudiencesInToken === false && Array.isArray(payload.aud) && payload.aud.length > 1)
return done(new Error('mulitple audience in token is not allowed'));
var payload_audience = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!hasCommonElem(options.audience, payload_audience))
return done(new Error('jwt audience is invalid. expected: ' + options.audience));
if (payload_audience.length > 1) {
if (typeof payload.azp !== 'string' || payload.azp !== options.clientID)
return done(new Error('jwt azp is invalid, expected: ' + options.clientID));
}


// (4) expiration
// - check the existence and the format of payload.exp
Expand Down
3 changes: 3 additions & 0 deletions lib/jwe.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,4 +522,7 @@ exports.decrypt = (jweString, jweKeyStore, log, callback) => {
return callback(content_result.error, content_result.content);
};

exports.createBuffer = createBuffer;



Loading

0 comments on commit d1fb360

Please sign in to comment.