@hapi/bell
Version:
Third-party login plugin for hapi
734 lines (530 loc) • 22.1 kB
JavaScript
'use strict';
const Crypto = require('crypto');
const Querystring = require('querystring');
const Url = require('url');
const Boom = require('@hapi/boom');
const Bounce = require('@hapi/bounce');
const Cryptiles = require('@hapi/cryptiles');
const Hoek = require('@hapi/hoek');
const Wreck = require('@hapi/wreck');
const internals = {
nonceLength: 22,
codeVerifierLength: 128
};
exports.v1 = function (settings) {
const client = new internals.Client(settings);
return async function (request, h) {
const cookie = settings.cookie;
const name = settings.name;
const protocol = internals.getProtocol(request, settings);
// Prepare credentials
const credentials = {
provider: name
};
// Bail if the upstream service returns an error
if (request.query.error === 'access_denied' ||
request.query.denied) {
return h.unauthenticated(Boom.internal('Application rejected'), { credentials });
}
// Error if not https but cookie is secure
if (protocol !== 'https' &&
settings.isSecure) {
return h.unauthenticated(Boom.internal('Invalid setting - isSecure must be set to false for non-https server'), { credentials });
}
// Sign-in Initialization
if (!request.query.oauth_token) {
credentials.query = request.query;
// Obtain temporary OAuth credentials
const oauth_callback = internals.location(request, protocol, settings.location);
try {
var { payload: temp } = await client.temporary(oauth_callback);
}
catch (err) {
return h.unauthenticated(err, { credentials });
}
const state = {
token: temp.oauth_token,
secret: temp.oauth_token_secret,
query: request.query
};
h.state(cookie, state);
const authQuery = {
...internals.resolveProviderParams(request, settings.providerParams),
oauth_token: temp.oauth_token,
...(settings.allowRuntimeProviderParams && request.query)
};
return h.redirect(settings.provider.auth + '?' + internals.queryString(authQuery)).takeover();
}
// Authorization callback
if (!request.query.oauth_verifier) {
return h.unauthenticated(Boom.internal('Missing verifier parameter in ' + name + ' authorization response'), { credentials });
}
const state = request.state[cookie];
if (!state) {
return internals.refreshRedirect(request, name, protocol, settings, credentials, h);
}
credentials.query = state.query;
h.unstate(cookie);
if (request.query.oauth_token !== state.token) {
return h.unauthenticated(Boom.internal(name + ' authorized request token mismatch'), { credentials });
}
// Obtain token OAuth credentials
try {
var { payload: token } = await client.token(state.token, request.query.oauth_verifier, state.secret);
}
catch (err) {
return h.unauthenticated(err, { credentials });
}
credentials.token = token.oauth_token;
credentials.secret = token.oauth_token_secret;
if (!settings.provider.profile ||
settings.skipProfile) {
return h.authenticated({ credentials });
}
// Obtain user profile
const get = async (uri, params = {}) => {
if (settings.profileParams) {
Object.assign(params, settings.profileParams);
}
const { payload: resource } = await client.resource('get', uri, params, { token: token.oauth_token, secret: token.oauth_token_secret });
return resource;
};
try {
await settings.provider.profile.call(settings, credentials, token, get);
}
catch (err) {
return h.unauthenticated(err, { credentials });
}
return h.authenticated({ credentials });
};
};
exports.v2 = function (settings) {
return async function (request, h) {
const cookie = settings.cookie;
const name = settings.name;
const protocol = internals.getProtocol(request, settings);
// Prepare credentials
const credentials = {
provider: name
};
// Bail if the upstream service returns an error
if (request.query.error === 'access_denied' ||
request.query.denied) {
return h.unauthenticated(Boom.internal(`App rejected: ${request.query.error_description || request.query.denied || 'No information provided'}`), { credentials });
}
// Error if not https but cookie is secure
if (protocol !== 'https' &&
settings.isSecure) {
return h.unauthenticated(Boom.internal('Invalid setting - isSecure must be set to false for non-https server'), { credentials });
}
// Sign-in Initialization
if (!request.query.code) {
credentials.query = request.query;
const nonce = Cryptiles.randomAlphanumString(internals.nonceLength);
const query = {
...internals.resolveProviderParams(request, settings.providerParams),
...(settings.allowRuntimeProviderParams && request.query),
client_id: settings.clientId,
response_type: 'code',
redirect_uri: internals.location(request, protocol, settings.location),
state: nonce
};
if (settings.runtimeStateCallback) {
const runtimeState = settings.runtimeStateCallback(request);
if (runtimeState) {
query.state += runtimeState;
}
}
let scope = settings.scope ?? settings.provider.scope;
if (typeof scope === 'function') {
scope = scope(request);
}
if (scope) {
query.scope = scope.join(settings.provider.scopeSeparator ?? ' ');
}
const state = {
nonce,
query: request.query
};
if (settings.provider.pkce) {
state.codeVerifier = internals.createCodeVerifier();
if (settings.provider.pkce === 'S256') {
// S256
query.code_challenge = internals.createCodeChallenge(state.codeVerifier);
query.code_challenge_method = 'S256';
}
else {
// plain
query.code_challenge = state.codeVerifier;
query.code_challenge_method = 'plain';
}
}
h.state(cookie, state);
return h.redirect(settings.provider.auth + '?' + internals.queryString(query)).takeover();
}
// Authorization callback
const state = request.state[cookie];
if (!state) {
return internals.refreshRedirect(request, name, protocol, settings, credentials, h);
}
credentials.query = state.query;
h.unstate(cookie);
const requestState = request.query.state ?? '';
if (state.nonce !== requestState.substr(0, Math.min(requestState.length, internals.nonceLength))) {
return h.unauthenticated(Boom.internal('Incorrect ' + name + ' state parameter'), { credentials });
}
const query = {
grant_type: 'authorization_code',
code: request.query.code,
redirect_uri: internals.location(request, protocol, settings.location),
...internals.resolveProviderParams(request, settings.tokenParams)
};
if (settings.provider.pkce) {
query.code_verifier = state.codeVerifier;
}
if (settings.provider.useParamsAuth) {
query.client_id = settings.clientId;
if (typeof settings.clientSecret === 'string') {
query.client_secret = settings.clientSecret;
}
else if (typeof settings.clientSecret === 'function') {
query.client_secret = settings.clientSecret();
}
}
const requestOptions = {
payload: internals.queryString(query),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
};
if (!settings.provider.useParamsAuth) {
requestOptions.headers.Authorization = 'Basic ' + (Buffer.from(settings.clientId + ':' + settings.clientSecret, 'utf8')).toString('base64');
}
if (settings.provider.headers) {
Hoek.merge(requestOptions.headers, settings.provider.headers);
}
if (typeof settings.clientSecret === 'object') {
Hoek.merge(requestOptions, settings.clientSecret);
}
// Obtain token
try {
var { res: tokenRes, payload } = await Wreck.post(settings.provider.token, requestOptions);
}
catch (err) {
return h.unauthenticated(Boom.internal('Failed obtaining ' + name + ' access token', err), { credentials });
}
if (tokenRes.statusCode < 200 ||
tokenRes.statusCode > 299) {
return h.unauthenticated(Boom.internal('Failed obtaining ' + name + ' access token', payload), { credentials });
}
try {
payload = internals.parse(payload);
}
catch (err) {
Bounce.rethrow(err, 'system');
return h.unauthenticated(Boom.internal('Received invalid payload from ' + name + ' access token endpoint', payload), { credentials });
}
credentials.token = payload.access_token;
credentials.refreshToken = payload.refresh_token;
credentials.expiresIn = payload.expires_in;
if (!settings.provider.profile || settings.skipProfile) {
return h.authenticated({ credentials, artifacts: payload });
}
// Obtain user profile
const get = async (uri, params = {}) => {
const getOptions = {
headers: {
Authorization: 'Bearer ' + payload.access_token
}
};
if (settings.profileParams) {
Hoek.merge(params, settings.profileParams);
}
if (settings.provider.headers) {
Hoek.merge(getOptions.headers, settings.provider.headers);
}
const getQuery = (Object.keys(params).length ? '?' + internals.queryString(params) : '');
try {
var { res, payload: response } = await Wreck[settings.provider.profileMethod](uri + getQuery, getOptions);
}
catch (err) {
throw Boom.internal('Failed obtaining ' + name + ' user profile', err);
}
if (res.statusCode !== 200) {
throw Boom.internal('Failed obtaining ' + name + ' user profile', response);
}
try {
response = internals.parse(response);
}
catch (err) {
Bounce.rethrow(err, 'system');
throw Boom.internal('Received invalid payload from ' + name + ' user profile', response);
}
return response;
};
try {
await settings.provider.profile.call(settings, credentials, payload, get);
}
catch (err) {
return h.unauthenticated(err, { credentials });
}
return h.authenticated({ credentials, artifacts: payload });
};
};
internals.refreshRedirect = function (request, name, protocol, settings, credentials, h) {
// Workaround for some browsers where due to CORS and the redirection method, the state
// cookie is not included with the request unless the request comes directly from the same origin.
if (request.query.refresh) {
return h.unauthenticated(Boom.internal('Missing ' + name + ' request token cookie'), { credentials });
}
const refreshQuery = Object.assign({}, request.query, { refresh: 1 });
const refreshUrl = internals.location(request, protocol, settings.location) + '?' + internals.queryString(refreshQuery);
return h.response(`<html><head><meta http-equiv="refresh" content="0;URL='${refreshUrl}'"></head><body></body></html>`).takeover();
};
exports.Client = internals.Client = function (options) {
this.provider = options.name;
this.settings = {
temporary: internals.Client.baseUri(options.provider.temporary),
token: internals.Client.baseUri(options.provider.token),
clientId: options.clientId,
clientSecret: options.provider.signatureMethod === 'RSA-SHA1' ? options.clientSecret : internals.encode(options.clientSecret ?? '') + '&',
signatureMethod: options.provider.signatureMethod
};
this._wreckOptions = { ...options.wreck };
};
internals.Client.prototype.temporary = function (oauth_callback) {
// Temporary Credentials (2.1)
const oauth = {
oauth_callback
};
return this._request('post', this.settings.temporary, null, oauth, { desc: 'temporary credentials' });
};
internals.Client.prototype.token = function (oauthToken, oauthVerifier, tokenSecret) {
// Token Credentials (2.3)
const oauth = {
oauth_token: oauthToken,
oauth_verifier: oauthVerifier
};
return this._request('post', this.settings.token, null, oauth, { secret: tokenSecret, desc: 'token credentials' });
};
internals.Client.prototype.resource = function (method, uri, params, options) {
// Making Requests (3.1)
const oauth = {
oauth_token: options.token
};
return this._request(method, uri, params, oauth, options);
};
internals.Client.prototype._request = async function (method, uri, params, oauth, options) {
method = method.toLowerCase();
// Prepare generic OAuth parameters
oauth.oauth_nonce = Cryptiles.randomAlphanumString(internals.nonceLength);
oauth.oauth_timestamp = Math.floor(Date.now() / 1000).toString();
oauth.oauth_consumer_key = this.settings.clientId;
oauth.oauth_signature_method = this.settings.signatureMethod;
oauth.oauth_signature = this.signature(method, uri, params, oauth, options.secret);
// Calculate OAuth header
const requestOptions = {
...this._wreckOptions,
headers: {
Authorization: internals.Client.header(oauth)
}
};
if (params) {
const paramsString = internals.queryString(params);
if (method === 'get') {
uri += '?' + paramsString;
}
else {
requestOptions.payload = paramsString;
requestOptions.headers['content-type'] = 'application/x-www-form-urlencoded';
}
}
if (options.stream) {
return Wreck.request(method, uri, requestOptions);
}
const desc = (options.desc ?? 'resource');
try {
const { res, payload } = await Wreck[method](uri, requestOptions);
var result = { payload: payload.toString(), statusCode: res.statusCode };
}
catch (err) {
throw Boom.internal(`Failed obtaining ${this.provider} ${desc}`, err);
}
if (result.statusCode !== 200) {
throw Object.assign(Boom.internal(`Failed obtaining ${this.provider} ${desc}`, result.payload), result);
}
if (!options.raw) {
try {
result.payload = internals.parse(result.payload);
}
catch (err) {
Bounce.rethrow(err, 'system');
throw Object.assign(Boom.internal(`Received invalid payload from ${this.provider} ${desc} endpoint`, result.payload), result);
}
}
return result;
};
internals.Client.header = function (oauth) {
// Authorization Header (3.5.1)
let header = 'OAuth ';
const names = Object.keys(oauth);
for (let i = 0; i < names.length; ++i) {
const name = names[i];
header += (i ? ', ' : '') + name + '="' + internals.encode(oauth[name]) + '"';
}
return header;
};
internals.Client.baseUri = function (uri) {
// Base String URI (3.4.1.2)
const resource = Url.parse(uri, true);
const protocol = resource.protocol.toLowerCase();
const isDefaultPort = resource.port && ((protocol === 'http:' && resource.port === '80') || (protocol === 'https:' && resource.port === '443'));
return protocol + '//' + resource.hostname.toLowerCase() + (isDefaultPort || !resource.port ? '' : ':' + resource.port) + resource.pathname;
};
internals.Client.prototype.signature = function (method, baseUri, params, oauth, tokenSecret) {
// Parameters Normalization (3.4.1.3.2)
const normalized = [];
const normalize = function (source) {
const names = Object.keys(source);
for (let i = 0; i < names.length; ++i) {
const name = names[i];
const value = source[name];
const encodedName = internals.encode(name);
if (Array.isArray(value)) {
for (let j = 0; j < value.length; ++j) {
normalized.push([encodedName, internals.encode(value[j])]);
}
}
else {
normalized.push([encodedName, internals.encode(value)]);
}
}
};
if (params) {
normalize(params);
}
normalize(oauth);
normalized.sort((a, b) => {
return (a[0] < b[0] ? -1
: (a[0] > b[0] ? 1
: (a[1] < b[1] ? -1
: (a[1] > b[1] ? 1 : 0))));
});
let normalizedParam = '';
for (let i = 0; i < normalized.length; ++i) {
normalizedParam += (i ? '&' : '') + normalized[i][0] + '=' + normalized[i][1];
}
// String Construction (3.4.1.1)
const baseString = internals.encode(method.toUpperCase()) + '&' +
internals.encode(baseUri) + '&' +
internals.encode(normalizedParam);
if (oauth.oauth_signature_method === 'RSA-SHA1') { // RSA-SHA1 (3.4.3)
return Crypto.createSign('sha1').update(baseString).sign(this.settings.clientSecret, 'base64');
}
// HMAC-SHA1 (3.4.2)
const key = tokenSecret ? (this.settings.clientSecret + internals.encode(tokenSecret)) : this.settings.clientSecret;
return Crypto.createHmac('sha1', key).update(baseString).digest('base64');
};
internals.encodeLookup = function () {
const lookup = {};
for (let i = 0; i < 128; ++i) {
if ((i >= 48 && i <= 57) || // 09
(i >= 65 && i <= 90) || // AZ
(i >= 97 && i <= 122) || // az
i === 45 || // -
i === 95 || // _
i === 46 || // .
i === 126) { // ~
lookup[i] = String.fromCharCode(i);
}
else {
lookup[i] = '%' + i.toString(16).toUpperCase();
}
}
return lookup;
}();
internals.encode = function (string) {
if (!string) {
return '';
}
// Percent Encoding (3.6)
let encoded = '';
for (let i = 0; i < string.length; ++i) {
encoded += internals.encodeLookup[string.charCodeAt(i)];
}
return encoded;
};
internals.parse = function (payload) {
payload = Buffer.isBuffer(payload) ? payload.toString() : payload;
if (payload.trim()[0] === '{') {
try {
return JSON.parse(payload);
}
catch (err) {
throw Boom.internal('Invalid JSON payload', err); // Convert JSON errors to application errors
}
}
return Querystring.parse(payload);
};
internals.location = function (request, protocol, location) {
if (typeof location === 'function') {
return location(request) || internals.location(request, protocol);
}
if (location) {
return location + request.path;
}
return protocol + '://' + request.info.host + request.path;
};
// Provide own QS implementation for cross node version support
internals.encodePrimitive = function (value) {
const type = typeof value;
if (type === 'boolean') {
return value ? 'true' : 'false';
}
if (type === 'number') {
return isFinite(value) ? value.toString() : '';
}
return internals.encode(value);
};
internals.Client.queryString = internals.queryString = function (params) {
const keys = Object.keys(params);
const fields = [];
for (let i = 0; i < keys.length; ++i) {
const key = keys[i];
const value = params[key];
const ks = internals.encodePrimitive(key) + '=';
if (Array.isArray(value)) {
for (let j = 0; j < value.length; ++j) {
fields.push(ks + internals.encodePrimitive(value[j]));
}
}
else {
fields.push(ks + internals.encodePrimitive(value));
}
}
return fields.join('&');
};
internals.createCodeChallenge = function (codeVerifier) {
return Crypto.createHash('sha256')
.update(codeVerifier, 'ascii')
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};
internals.createCodeVerifier = function () {
return Cryptiles.randomAlphanumString(internals.codeVerifierLength);
};
internals.getProtocol = function (request, settings) {
if (settings.forceHttps) {
return 'https';
}
const location = internals.location(request, request.server.info.protocol, settings.location);
if (location.indexOf('https:') !== -1) {
return 'https';
}
return request.server.info.protocol;
};
internals.resolveProviderParams = function (request, params) {
return (typeof params === 'function' ? params(request) : params) ?? {};
};