passport-openidconnect-kc-idp
Version:
OpenID Connect authentication strategy for Passport.
425 lines (362 loc) • 17.6 kB
JavaScript
/**
* Module dependencies.
*/
var passport = require('passport-strategy')
, url = require('url')
, querystring= require('querystring')
, util = require('util')
, utils = require('./utils')
, OAuth2 = require('oauth').OAuth2
, SessionStateStore = require('./state/session')
//, setup = require('./setup')
, InternalOAuthError = require('./errors/internaloautherror')
, AuthorizationError = require('./errors/authorizationerror');
/**
* `Strategy` constructor.
*
* The OpenID Connect authentication strategy authenticates requests using
* OpenID Connect, which is an identity layer on top of the OAuth 2.0 protocol.
*
* @param {Object} options
* @param {Function} verify
* @api public
*/
function Strategy(options, verify) {
options = options || {};
passport.Strategy.call(this);
this.name = 'openidconnect';
this._verify = verify;
// TODO: What's the recommended field name for OpenID Connect?
this._identifierField = options.identifierField || 'openid_identifier';
this._scope = options.scope;
this._kcIdpHint = options.kc_idp_hint;
this._passReqToCallback = options.passReqToCallback;
this._skipUserProfile = (options.skipUserProfile === undefined) ? false : options.skipUserProfile;
this._setup = undefined;
this._key = options.sessionKey || (this.name + ':' + url.parse(options.authorizationURL).hostname);
this._stateStore = options.store || new SessionStateStore({ key: this._key });
if (options.authorizationURL && options.tokenURL) {
// This OpenID Connect strategy is configured to work with a specific
// provider. Override the discovery process with pre-configured endpoints.
this.configure(require('./setup/manual')(options));
//this.configure(require('./setup/dynamic')(options));
} else {
this.configure(require('./setup/dynamic')(options));
}
}
/**
* Inherit from `passport.Strategy`.
*/
util.inherits(Strategy, passport.Strategy);
/**
* Authenticate request by delegating to an OpenID Connect provider.
*
* @param {Object} req
* @param {Object} options
* @api protected
*/
Strategy.prototype.authenticate = function(req, options) {
options = options || {};
var self = this;
if (req.query && req.query.error) {
if (req.query.error == 'access_denied') {
return this.fail({ message: req.query.error_description });
} else {
return this.error(new AuthorizationError(req.query.error_description, req.query.error, req.query.error_uri));
}
}
if (req.query && req.query.code) {
function loaded(err, ok, state) {
if (err) { return self.error(err); }
if (!ok) {
return self.fail(state, 403);
}
var code = req.query.code;
var meta = state;
var callbackURL = meta.callbackURL;
var oauth2 = self._getOAuth2Client(meta);
oauth2.getOAuthAccessToken(code, { grant_type: 'authorization_code', redirect_uri: callbackURL }, function(err, accessToken, refreshToken, params) {
if (err) { return self.error(new InternalOAuthError('failed to obtain access token', err)); }
var idToken = params['id_token'];
if (!idToken) { return self.error(new Error('ID Token not present in token response')); }
var idTokenSegments = idToken.split('.')
, jwtClaimsStr
, jwtClaims;
try {
jwtClaimsStr = new Buffer(idTokenSegments[1], 'base64').toString();
jwtClaims = JSON.parse(jwtClaimsStr);
} catch (ex) {
return self.error(ex);
}
var missing = ['iss', 'sub', 'aud', 'exp', 'iat'].filter( function(param) { return !jwtClaims[param] } );
if (missing.length) return self.error(new Error('id token is missing required parameter(s) - ' + missing.join(', ')));
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 1.
if (jwtClaims.iss !== meta.issuer) return self.error(new Error('id token not issued by correct OpenID provider - ' +
'expected: ' + meta.issuer + ' | from: ' + jwtClaims.iss));
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - checks 2 and 3.
if (typeof jwtClaims.aud === 'string') {
if (jwtClaims.aud !== meta.clientID) return self.error(new Error('aud parameter does not include this client - is: '
+ jwtClaims.aud + '| expected: ' + meta.clientID));
} else if (Array.isArray(jwtClaims.aud)) {
if (jwtClaims.aud.indexOf(meta.clientID) === -1) return self.error(new Error('aud parameter does not include this client - is: ' +
jwtClaims.aud + ' | expected to include: ' + meta.clientID));
if (jwtClaims.length > 1 && !jwtClaims.azp) return self.error(new Error('azp parameter required with multiple audiences'));
} else {
return self.error(new Error('Invalid aud parameter type'));
}
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 4.
if (jwtClaims.azp && jwtClaims.azp !== meta.clientID) return self.error(new Error('this client is not the authorized party - ' +
'expected: ' + meta.clientID + ' | is: ' + jwtClaims.azp));
// Possible TODO: Add accounting for some clock skew.
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 5.
if (jwtClaims.exp < (Date.now() / 1000)) return self.error(new Error('id token has expired'));
// Note: https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - checks 6 and 7 are out of scope of this library.
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 8.
if (meta.params.max_age && (!jwtClaims.auth_time || ((meta.timestamp - meta.params.max_age) > jwtClaims.auth_time))) {
return self.error(new Error('auth_time in id_token not included or too old'));
}
if (meta.params.nonce && (!jwtClaims.nonce || jwtClaims.nonce !== meta.params.nonce)) {
return self.error(new Error('Invalid nonce in id_token'));
}
var iss = jwtClaims.iss;
var sub = jwtClaims.sub;
// Prior to OpenID Connect Basic Client Profile 1.0 - draft 22, the
// "sub" claim was named "user_id". Many providers still issue the
// claim under the old field, so fallback to that.
if (!sub) {
sub = jwtClaims.user_id;
}
self._shouldLoadUserProfile(iss, sub, function(err, load) {
if (err) { return self.error(err); };
if (load) {
var parsed = url.parse(meta.userInfoURL, true);
parsed.query['schema'] = 'openid';
delete parsed.search;
var userInfoURL = url.format(parsed);
// NOTE: We are calling node-oauth's internal `_request` function (as
// opposed to `get`) in order to send the access token in the
// `Authorization` header rather than as a query parameter.
//
// Additionally, the master branch of node-oauth (as of
// 2013-02-16) will include the access token in *both* headers
// and query parameters, which is a violation of the spec.
// Setting the fifth argument of `_request` to `null` works
// around this issue. I've noted this in comments here:
// https://github.com/ciaranj/node-oauth/issues/117
//oauth2.get(userInfoURL, accessToken, function (err, body, res) {
oauth2._request("GET", userInfoURL, { 'Authorization': "Bearer " + accessToken, 'Accept': "application/json" }, null, null, function (err, body, res) {
if (err) { return self.error(new InternalOAuthError('failed to fetch user profile', err)); }
var profile = {};
try {
var json = JSON.parse(body);
profile.id = json.sub;
// Prior to OpenID Connect Basic Client Profile 1.0 - draft 22, the
// "sub" key was named "user_id". Many providers still use the old
// key, so fallback to that.
if (!profile.id) {
profile.id = json.user_id;
}
profile.displayName = json.name;
profile.name = { familyName: json.family_name,
givenName: json.given_name,
middleName: json.middle_name };
profile._raw = body;
profile._json = json;
onProfileLoaded(profile);
} catch(ex) {
return self.error(ex);
}
});
} else {
onProfileLoaded();
}
function onProfileLoaded(profile) {
function verified(err, user, info) {
if (err) { return self.error(err); }
if (!user) { return self.fail(info); }
info = info || {};
if (state) { info.state = state; }
self.success(user, info);
}
if (self._passReqToCallback) {
var arity = self._verify.length;
if (arity == 9) {
self._verify(req, iss, sub, profile, jwtClaims, accessToken, refreshToken, params, verified);
} else if (arity == 8) {
self._verify(req, iss, sub, profile, accessToken, refreshToken, params, verified);
} else if (arity == 7) {
self._verify(req, iss, sub, profile, accessToken, refreshToken, verified);
} else if (arity == 5) {
self._verify(req, iss, sub, profile, verified);
} else { // arity == 4
self._verify(req, iss, sub, verified);
}
} else {
var arity = self._verify.length;
if (arity == 8) {
self._verify(iss, sub, profile, jwtClaims, accessToken, refreshToken, params, verified);
} else if (arity == 7) {
self._verify(iss, sub, profile, accessToken, refreshToken, params, verified);
} else if (arity == 6) {
self._verify(iss, sub, profile, accessToken, refreshToken, verified);
} else if (arity == 4) {
self._verify(iss, sub, profile, verified);
} else { // arity == 3
self._verify(iss, sub, verified);
}
}
} // onProfileLoaded
}); // self._shouldLoadUserProfile
}); // oauth2.getOAuthAccessToken
} // loaded
var state = req.query.state;
try {
self._stateStore.verify(req, state, loaded);
} catch (ex) {
return self.error(ex);
}
} else {
// The request being authenticated is initiating OpenID Connect
// authentication. Prior to redirecting to the provider, configuration will
// be loaded. The configuration is typically either pre-configured or
// discovered dynamically. When using dynamic discovery, a user supplies
// their identifer as input.
var identifier;
if (req.body && req.body[this._identifierField]) {
identifier = req.body[this._identifierField];
} else if (req.query && req.query[this._identifierField]) {
identifier = req.query[this._identifierField];
}
// FIXME: Hard coded for test purposes:
//identifier = 'acct:paulej@packetizer.com';
this._setup(identifier, function(err, config) {
if (err) { return self.error(err); }
// Required Parameters
var meta = config;
var callbackURL = options.callbackURL || config.callbackURL;
if (callbackURL) {
var parsed = url.parse(callbackURL);
if (!parsed.protocol) {
// The callback URL is relative, resolve a fully qualified URL from the
// URL of the originating request.
callbackURL = url.resolve(utils.originalURL(req), callbackURL);
}
}
meta.callbackURL = callbackURL;
var params = self.authorizationParams(options);
params['response_type'] = 'code';
params['client_id'] = config.clientID;
params.kc_idp_hint = self._kcIdpHint;
if (callbackURL) { params.redirect_uri = callbackURL; }
var scope = options.scope || self._scope;
if (Array.isArray(scope)) { scope = scope.join(' '); }
if (scope) {
params.scope = 'openid ' + scope;
} else {
params.scope = 'openid';
}
// Optional Parameters
var simple_optional_params = ['max_age', 'ui_locals', 'id_token_hint', 'login_hint', 'acr_values'];
simple_optional_params.filter( x => { return x in config } ).map( y => { params[y] = config[y] } );
if (config.display && ['page', 'popup', 'touch', 'wap'].indexOf(config.display) !== -1) params.display = config.display;
if (config.prompt && ['none', 'login', 'consent', 'select_account'].indexOf(config.prompt) !== -1) params.prompt = config.prompt;
if (config.nonce && typeof config.nonce === 'boolean') { params.nonce = utils.uid(20); }
if (config.nonce && typeof config.nonce === 'number') { params.nonce = utils.uid(config.nonce); }
if (config.nonce && typeof config.nonce === 'string') { params.nonce = config.nonce; }
if (params.max_age) meta.timestamp = Math.floor(Date.now() / 1000);
meta.params = params;
for (param in params) {
if (meta[param]) delete meta[param]; // Remove redundant information.
}
// State Storage/Management
function stored(err, state) {
if (err) { return self.error(err); }
if (!state) { return self.error(new Error('Unable to generate required state parameter')); }
params.state = state;
var location = config.authorizationURL + '?' + querystring.stringify(params);
self.redirect(location);
}
try {
var arity = self._stateStore.store.length;
if (arity == 3) {
self._stateStore.store(req, meta, stored);
} else { // arity == 2
self._stateStore.store(req, stored);
}
} catch (ex) {
return self.error(ex);
}
}); // this.configure
}
}
/**
* Register a function used to configure the strategy.
*
* OpenID Connect is an identity layer on top of OAuth 2.0. OAuth 2.0 requires
* knowledge of certain endpoints (authorization, token, etc.) as well as a
* client identifier (and corresponding secret) registered at the authorization
* server.
*
* Configuration functions are responsible for loading this information. This
* is typically done via one of two popular mechanisms:
*
* - The configuration is known ahead of time, and pre-configured via options
* to the strategy.
* - The configuration is dynamically loaded, using optional discovery and
* registration specifications. (Note: Providers are not required to
* implement support for dynamic discovery and registration. As such, there
* is no guarantee that this will result in successfully initiating OpenID
* Connect authentication.)
*
* @param {Function} fn
* @api public
*/
Strategy.prototype.configure = function(identifier, done) {
this._setup = identifier;
}
/**
* Return extra parameters to be included in the authorization request.
*
* Some OpenID Connect providers allow additional, non-standard parameters to be
* included when requesting authorization. Since these parameters are not
* standardized by the OpenID Connect specification, OpenID Connect-based
* authentication strategies can overrride this function in order to populate
* these parameters as required by the provider.
*
* @param {Object} options
* @return {Object}
* @api protected
*/
Strategy.prototype.authorizationParams = function(options) {
return {};
}
/**
* Check if should load user profile, contingent upon options.
*
* @param {String} issuer
* @param {String} subject
* @param {Function} done
* @api private
*/
Strategy.prototype._shouldLoadUserProfile = function(issuer, subject, done) {
if (typeof this._skipUserProfile == 'function' && this._skipUserProfile.length > 1) {
// async
this._skipUserProfile(issuer, subject, function(err, skip) {
if (err) { return done(err); }
if (!skip) { return done(null, true); }
return done(null, false);
});
} else {
var skip = (typeof this._skipUserProfile == 'function') ? this._skipUserProfile(issuer, subject) : this._skipUserProfile;
if (!skip) { return done(null, true); }
return done(null, false);
}
}
Strategy.prototype._getOAuth2Client = function (config) {
return new OAuth2(config.clientID, config.clientSecret,
'', config.authorizationURL, config.tokenURL);
}
/**
* Expose `Strategy`.
*/
module.exports = Strategy;