@jhvjkcyyfdxghjk/passport-openidconnect
Version:
OpenID Connect authentication strategy for Passport - without forced openid scope.
565 lines (519 loc) • 21.4 kB
JavaScript
/**
* Module dependencies.
*/
const passport = require("passport-strategy"),
crypto = require("crypto"),
url = require("url"),
util = require("util"),
utils = require("./utils"),
OAuth2 = require("oauth").OAuth2,
Profile = require("./profile"),
Context = require("./context"),
SessionStateStore = require("./state/session"),
AuthorizationError = require("./errors/authorizationerror"),
TokenError = require("./errors/tokenerror"),
InternalOAuthError = require("./errors/internaloautherror");
/**
* Callback for VerifyFunction to return either a valid user profile, false or error
*
* @callback VerifyCallback
* @param {Error | string | null} err
* @param {*} [user] - user object defined by the app or false if authentication fails at application.
* @param {*} [info] - optional `info` argument will be passed, containing additional details provided by the strategy's verify callback.
* @see https://www.passportjs.org/concepts/authentication/strategies/
*/
/**
* Function to process authenticated info and return a valid user profile via {@link VerifyCallback}
*
* @typedef {{
* (issuer: string, profile: Profile.Profile, done: VerifyCallback): void;
* (issuer: string, profile: Profile.Profile, context: Context.AuthContext, done: VerifyCallback): void;
* (issuer: string, profile: Profile.Profile, context: Context.AuthContext, idToken: string, done: VerifyCallback): void;
* (issuer: string, profile: Profile.Profile, context: Context.AuthContext, idToken: string, accessToken: string, refreshToken: string, done: VerifyCallback): void;
* (issuer: string, profile: Profile.Profile, context: Context.AuthContext, idToken: string, accessToken: string, refreshToken: string, params: any, done: VerifyCallback): void;
* (issuer: string, uiProfile: Profile.MergedProfile, idProfile: Profile.Profile, context: Context.AuthContext, idToken: string, accessToken: string, refreshToken: string, params: any, done: VerifyCallback): void;
* (req: http.IncomingMessage, issuer: string, profile: Profile.Profile, done: VerifyCallback): void;
* (req: http.IncomingMessage, issuer: string, profile: Profile.Profile, context: Context.AuthContext, done: VerifyCallback): void;
* (req: http.IncomingMessage, issuer: string, profile: Profile.Profile, context: Context.AuthContext, idToken: string, done: VerifyCallback): void;
* (req: http.IncomingMessage, issuer: string, profile: Profile.Profile, context: Context.AuthContext, idToken: string, accessToken: string, refreshToken: string, done: VerifyCallback): void;
* (req: http.IncomingMessage, issuer: string, profile: Profile.Profile, context: Context.AuthContext, idToken: string, accessToken: string, refreshToken: string, params: any, done: VerifyCallback): void;
* (req: http.IncomingMessage, issuer: string, uiProfile: Profile.MergedProfile, idProfile: Profile.Profile, context: Context.AuthContext, idToken: string, accessToken: string, refreshToken: string, params: any, done: VerifyCallback): void;
* }} VerifyFunction
*/
/**
* Callback to determine if loading user profile via /userInfo endpoint should be skipped.
*
* @typedef {{
* (req: http.IncomingMessage, claims: any): boolean;
* (req: http.IncomingMessage, claims: any, done: (err: Error | null, skip?: boolean) => void): void;
* }} SkipUserProfileFunc
*/
/**
* Creates an instance of `OpenIDConnectStrategy`.
*
* 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 - config params for the passport strategy.
* @param {string} options.issuer
* @param {string} options.authorizationURL
* @param {string} options.tokenURL
* @param {string} options.callbackURL
* @param {string} options.userInfoURL
* @param {string} options.clientID
* @param {string} options.clientSecret
* @param {string} [options.acrValues]
* @param {http.Agent} [options.agent]
* @param {object} [options.claims]
* @param {http.OutgoingHttpHeaders} [options.customHeaders]
* @param {string} [options.display]
* @param {string} [options.idTokenHint]
* @param {string} [options.loginHint]
* @param {string} [options.maxAge]
* @param {string} [options.prompt]
* @param {boolean} [options.proxy]
* @param {string} [options.responseMode]
* @param {string | string[]} [options.scope]
* @param {string} [options.uiLocales]
* @param {boolean} [options.nonce]
* @param {boolean} [options.passReqToCallback] - If defined, the `Request` object will be passed into {@link VerifyFunction}
* @param {string} [options.pkce] - defines a PKCE protocol to use. If not defined, PKCE is disabled.
* @param {string} [options.sessionKey] - unqiue session id for this issuer. If none is given, issuer's hostname is used.
* @param {SessionStateStore} [options.store] - custom session store instance
* @param {SkipUserProfileFunc | boolean} [options.skipUserProfile] - determines if user data is loaded from /userInfo endpoint.
* @param {VerifyFunction} verify - {@link VerifyFunction} callback
*
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
*
* @constructor
*/
function Strategy(options, verify) {
options = options || {};
if (!verify) {
throw new TypeError("OpenIDConnectStrategy requires a verify function");
}
if (!options.issuer) {
throw new TypeError("OpenIDConnectStrategy requires an issuer option");
}
if (!options.authorizationURL) {
throw new TypeError(
"OpenIDConnectStrategy requires an authorizationURL option"
);
}
if (!options.tokenURL) {
throw new TypeError("OpenIDConnectStrategy requires a tokenURL option");
}
if (!options.clientID) {
throw new TypeError("OpenIDConnectStrategy requires a clientID option");
}
passport.Strategy.call(this);
this.name = "openidconnect";
this._verify = verify;
this._passReqToCallback = options.passReqToCallback;
// NOTE: The _oauth2 property is considered "protected". Subclasses are
// allowed to use it when making protected resource requests to retrieve
// the user profile.
this._oauth2 = new OAuth2(
options.clientID,
options.clientSecret,
"",
options.authorizationURL,
options.tokenURL,
options.customHeaders
);
this._oauth2.useAuthorizationHeaderforGET(true);
if (options.agent) {
this._oauth2.setAgent(options.agent);
}
this._issuer = options.issuer;
this._callbackURL = options.callbackURL;
this._scope = options.scope;
this._responseMode = options.responseMode;
this._prompt = options.prompt;
this._trustProxy = options.proxy;
this._display = options.display;
this._uiLocales = options.uiLocales;
this._loginHint = options.loginHint;
this._maxAge = options.maxAge;
this._acrValues = options.acrValues;
this._idTokenHint = options.idTokenHint;
this._claims = options.claims;
this._userInfoURL = options.userInfoURL;
this._nonce = options.nonce;
this._pkce = options.pkce;
this._originalReqProp = options.originalReqProp;
const key =
options.sessionKey ||
this.name + ":" + url.parse(options.authorizationURL).hostname;
this._stateStore = options.store || new SessionStateStore({ key: key });
// This determine if /userInfo endpoint is called.
this._skipUserProfile =
options.skipUserProfile === undefined
? function () {
if (
(options.passReqToCallback && verify.length >= 10) ||
(!options.passReqToCallback && verify.length >= 9)
) {
return false;
}
return true;
}
: options.skipUserProfile;
}
/**
* Inherit from `passport.Strategy`.
*/
util.inherits(Strategy, passport.Strategy);
/**
* Authenticate request by delegating to OpenID Connect provider.
*
* @param {http.IncomingMessage} req - request object of the incoming http message
* @param {object} options - additional options, supercedes any values pass into the Strategy constructor
* @param {string} [options.callbackURL]
* @param {string} [options.display]
* @param {string} [options.loginHint]
* @param {string | string[]} [options.scope]
* @param {*} [options.state]
*
* @override
*/
Strategy.prototype.authenticate = function (req, options) {
options = options || {};
const 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
)
);
}
}
let callbackURL = options.callbackURL || self._callbackURL;
if (callbackURL) {
const 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, { proxy: self._trustProxy }),
callbackURL
);
}
}
if (req.query && req.query.code) {
// form the /token request using response from /authorize
// using the auth code grant flow
/** @type {SessionStateStore.SessionVerifyCallback} */
function restored(err, ctx, state) {
if (err) {
return self.error(err);
}
if (!ctx) {
return self.fail(state, 403);
}
const code = req.query.code;
const params = { grant_type: "authorization_code" };
if (callbackURL) {
params.redirect_uri = callbackURL;
}
// add code_verifier param if pkce is enabled
if (self._pkce) {
if (!ctx.verifier) {
return self.error(
new Error(
"PKCE flag is defined but verifier string is not found in session ctx"
)
);
}
params.code_verifier = ctx.verifier;
}
self._oauth2.getOAuthAccessToken(
code,
params,
function (err, accessToken, refreshToken, params) {
if (err) {
if (err.statusCode && err.data) {
try {
console.log(err);
const json = JSON.parse(err.data);
if (json.error) {
return self.error(
new TokenError(
json.error_description,
json.error,
json.error_uri
)
);
}
// eslint-disable-next-line no-empty
} catch (e) {
// trap it here and do nothing to the json parse throws
// and let InternalOAuthError return it instead
}
}
return self.error(
new InternalOAuthError(
"Failed to obtain access token",
err
)
);
}
self._shouldLoadUserProfile(
req,
function (err, load) {
if (err) {
return self.error(err);
}
/**
* Callback to handle result from GET /userInfo endpont
*
* @param {Profile.MergedProfile} [uiProfile]
* @param {*} [json]
* @param {*} [body]
* @returns {void}
*/
function loaded(uiProfile, json, body) {
/** @type {VerifyCallback} */
function verified(err, user, info) {
if (err) {
return self.error(err);
}
if (!user) {
return self.fail(info);
}
info = info || {};
// return session appState if available
if (state) {
info.state = state;
}
self.success(user, info);
} // verified
/** @type {Profile.Profile} */
let profile = {};
utils.merge(profile, uiProfile);
if (uiProfile) {
uiProfile._raw = body;
uiProfile._json = json;
}
}
if (!load) {
return loaded();
}
self._oauth2.get(
self._userInfoURL,
accessToken,
function (err, body, res) {
if (err) {
return self.error(
new InternalOAuthError(
"Failed to fetch user profile",
err
)
);
}
let json;
try {
json = JSON.parse(body);
} catch (ex) {
return self.error(
new Error(
"Failed to parse user profile"
)
);
}
/** @type {Profile.Profile} */
const uiProfile = Profile.parse(json);
loaded(uiProfile, json, body);
}
);
}
); // self._shouldLoadUserProfile
}
); // oauth2.getOAuthAccessToken
} // restored
const state = req.query.state;
try {
self._stateStore.verify(req, state, restored);
} catch (ex) {
return self.error(ex);
}
} else {
// form the /authorize request
const params = this.authorizationParams(options);
params.response_type = "code";
if (this._responseMode) {
params.response_mode = this._responseMode;
}
params.client_id = this._oauth2._clientId;
if (callbackURL) {
params.redirect_uri = callbackURL;
}
let scope = options.scope || this._scope;
if (scope) {
if (typeof scope == "string") {
scope = scope.split(" ");
}
if (Array.isArray(scope)) {
params.scope = scope.join(" ");
}
} else {
params.scope = "openid";
}
const prompt = options.prompt || this._prompt;
if (prompt) {
params.prompt = prompt;
}
const display = options.display || this._display;
if (display) {
params.display = display;
}
const uiLocales = this._uiLocales;
if (uiLocales) {
params.ui_locales = uiLocales;
}
const loginHint = options.loginHint || this._loginHint;
if (loginHint) {
params.login_hint = loginHint;
}
const maxAge = this._maxAge;
if (maxAge) {
params.max_age = maxAge;
}
const acrValues = this._acrValues;
if (acrValues) {
params.acr_values = acrValues;
}
const idTokenHint = this._idTokenHint;
if (idTokenHint) {
params.id_token_hint = idTokenHint;
}
const nonce = this._nonce;
if (nonce) {
params.nonce = utils.uid(20);
}
/** @type {SessionStateStore.SessionContext} */
const ctx = {};
if (params.max_age) {
ctx.maxAge = params.max_age;
ctx.issued = new Date();
}
if (params.nonce) {
ctx.nonce = params.nonce;
}
const state = options.state;
// generate pkce params if enabled
const pkce = this._pkce;
if (pkce) {
const verifier = crypto.pseudoRandomBytes(32).toString("base64url");
if (pkce === "S256") {
params.code_challenge = crypto
.createHash("sha256")
.update(verifier)
.digest()
.toString("base64url");
} else if (pkce === "plain") {
params.code_challenge = verifier;
} else {
return self.error(
new Error(
"Unsupported code verifier transformation method: " +
pkce
)
);
}
params.code_challenge_method = "S256";
ctx.verifier = verifier;
}
/**
* After session is stored, process the `/authorize` HTTP call to OP
* @type {SessionStateStore.SessionStoreCallback}
*/
function stored(err, handle) {
if (err) {
return self.error(err);
}
if (!handle) {
return self.error(
new Error(
"OpenID Connect state store did not yield state for authentication request"
)
);
}
params.state = handle;
const parsed = url.parse(self._oauth2._authorizeUrl, true);
utils.merge(parsed.query, params);
delete parsed.search;
const location = url.format(parsed);
self.redirect(location);
} // stored
try {
this._stateStore.store(req, ctx, state, stored);
} catch (ex) {
return this.error(ex);
}
}
};
/**
* 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 {http.IncomingMessage} req
* @param {LoadUserProfileCallback} done
* @api private
*/
Strategy.prototype._shouldLoadUserProfile = function (req, done) {
if (
typeof this._skipUserProfile == "function" &&
this._skipUserProfile.length > 2
) {
// async
this._skipUserProfile(req, function (err, skip) {
if (err) {
return done(err);
}
if (!skip) {
return done(null, true);
}
return done(null, false);
});
} else {
const skip =
typeof this._skipUserProfile == "function"
? this._skipUserProfile(req)
: this._skipUserProfile;
if (!skip) {
return done(null, true);
}
return done(null, false);
}
};
/**
* @callback LoadUserProfileCallback
* @param {http.IncomingMessage} req
* @param {boolean} skip
* @return {void}
*/
/**
* Expose `Strategy`.
*/
module.exports = Strategy;