@webqit/oauth2-node-client
Version:
OAuth2 Authorization Code Flow library for Nodejs.
264 lines (251 loc) • 10.1 kB
JavaScript
/**
* @imports
*/
import Url from 'url';
import Request from 'request';
import Sessions from 'client-sessions';
import Jsonwebtoken from 'jsonwebtoken';
import _intersect from '@webqit/util/arr/intersect.js';
import _arrFrom from '@webqit/util/arr/from.js';
import _promise from '@webqit/util/js/promise.js';
/**
* OAuth util class
*/
export default class OAuth2CodeFlow {
/**
* Returns a function suitable as a middleware.
*
* @param object params
*
* @return function
*/
static createMiddleware(params) {
var sessionMiddleware = Sessions({
cookieName: 'authSession', // cookie name dictates the key name added to the request object
secret: params.sesskey, // should be a large unguessable string
duration: 24 * 60 * 60 * 1000, // how long the session will stay valid in ms
activeDuration: 1000 * 60 * 5 // if expiresIn < activeDuration, the session will be extended by activeDuration milliseconds
});
return async (request, response, next) => {
return await sessionMiddleware(request, response, async () => {
request.oauth = new OAuth2CodeFlow(request, response, params);
return await next();
});
};
}
/**
* Creates an auth API
*
* @param object params
* clientId,
* clientSecret,
* endpoints,
* baseURL,
* login,
* token,
* logout,
* callbacks
* baseURL,
* login,
* logout
* sesskey,
* @param object request
* @param object response
* '
* @return void
*/
constructor(request, response, params) {
this.request = request;
this.response = response;
this.params = params;
this.endpoints = {
loginUrl: params.endpoints.baseUrl + params.endpoints.login,
tokenUrl: params.endpoints.baseUrl + params.endpoints.token,
logoutUrl: params.endpoints.baseUrl + params.endpoints.logout,
};
this.callbacks = {
loginUrl: params.callbacks.baseUrl + params.callbacks.login,
logoutUrl: params.callbacks.baseUrl + params.callbacks.logout,
};
this.credentials = this.request.authSession.oauth;
}
/**
* Checks if the current session is authenticated,
* and otpionally, with the specified scopes.
* Initiates the Authentication Code Flow if not.
*
* (Be sure to end current running code after calling this function.)
*
* @param array scopes - Optional "scopes" to require.
* @param string audience - Optional "audience" to require.
*
* @return void
*/
login(scopes = [], audience = null) {
// Already authenticated?
var credentials;
if (credentials = this.isLoggedIn(scopes, audience)) {
return credentials;
}
// Initiate Authentication Code Flow
this.requestToken(scopes, audience);
}
/**
* Checks if the current session is authenticated,
* and otpionally, with the specified scopes.
*
* @param array scopes - Optional "scopes" to check.
* @param string audience - Optional "audience" to check.
*
* @return object
*/
isLoggedIn(scopes = [], audience = null) {
if (!this.credentials) {
return false;
}
var givenScopeds = _arrFrom(scopes);
if (givenScopeds.length) {
var activeScope = (this.credentials.scope || '').split(' ').map(s => s.trim());
if (_intersect(givenScopeds, activeScope).length !== givenScopeds.length) {
return false;
}
}
if (audience) {
var activeAud = this.credentials.identity.aud.split(' ').map(s => s.trim());
if (_intersect(_arrFrom(audience), activeAud).length !== activeAud.length) {
return false;
}
}
return this.credentials;
}
/**
* Terminates the current login session.
*
* @param Bool fromSource
*
* @return void
*/
logout(fromSource = false) {
delete this.credentials;
if (this.request.authSession) {
delete this.request.authSession.oauth;
}
if (fromSource && this.endpoints.logoutUrl) {
var rdr = this.endpoints.logoutUrl
+ '?client_id=' + this.params.clientId
+ '&returnTo=' + this.callbacks.logoutUrl;
this.response.writeHead(302, {Location: rdr});
this.response.end();
}
}
/**
* Initiates the OAuth2 Authentication Code Flow
* by sending the client to the specified IdP.
*
* (Be sure to end current running code after calling this function.)
*
* @param array scopes - Optional "scopes" parameter for the request.
* @param string audience - Optional "audience" parameter for the request.
*
* @return void
*/
requestToken(scopes = [], audience = null) {
// Is code auth
var i = 0, oauthStateCode = '';
if (this.request.authSession) {
while(i < 1) {oauthStateCode += Math.random(); i ++;}
this.request.authSession.oauthState = {
oauthStateCode,
initiatorURL: this.request.url,
};
}
var rdr = this.endpoints.loginUrl
+ '?response_type=code'
+ '&client_id=' + this.params.clientId
+ '&redirect_uri=' + this.callbacks.loginUrl
+ (scopes.length ? '&scope=' + _arrFrom(scopes).join('%20') : '') // "offline_access" - to include refresh_token
+ (audience ? '&audience=' + audience : '')
+ (oauthStateCode ? '&state=' + oauthStateCode : '');
this.response.writeHead(302, {Location: rdr});
this.response.end();
}
/**
* Handles the redirection from the OAuth2 Authentication Code Flow;
* expects to see the "code" and "state" parameter in the URL.
*
* Exchanges the recieved "code" for tokens and stores the result
* as "oauth" in the user session.
*
* On success, redirects the client back to the URL that initiated the
* Authentication Code Flow.
*
* @param function callback
*
* @return Promise
*/
async handleToken(callback = null) {
var oauthState, url = Url.parse(this.request.url, true);
if (!url.query.code) {
return;
}
if (this.request.authSession && this.request.authSession.oauthState) {
if (url.query.state !== this.request.authSession.oauthState.oauthStateCode) {
throw new Error('Invalid request; state mismatch.');
}
oauthState = this.request.authSession.oauthState;
delete this.request.authSession.oauthState;
} else if (url.query.state) {
throw new Error('Invalid request; unexpected "state" parameter.');
}
var options = {
method: 'POST',
url: this.endpoints.tokenUrl,
headers: {'content-type': 'application/x-www-form-urlencoded'},
form: {
grant_type: 'authorization_code', // or refresh_token
client_id: this.params.clientId,
client_secret: this.params.clientSecret, // not needed for type refresh_token
code: url.query.code, // not needed for type refresh_token
redirect_uri: this.callbacks.loginUrl, // not needed for type refresh_token
// refresh_token: the body.refresh_token in previous request
},
//headers: {'content-type': 'application/x-www-form-urlencoded'}, body: 'gdgdgd=333222', //or form: {}
//headers: {'content-type': 'application/json'}, body: '{"Hey":"Hello"}',
};
var data = await _promise((resolve, reject) => {
Request(options, (error, response, body) => {
if (error || response.statusCode !== 200) {
reject('Authentication error; ' + (error || body));
return;
}
resolve(JSON.parse(body));
});
});
var credentials = { ...data };
if (data.id_token) {
data.id_token = Jsonwebtoken.decode(data.id_token, {complete: true});
// Verify signing algorithm - "data.id_token.header.alg" - HS256, RS256
// Verify token audience claims - "data.id_token.payload.aud" - roughly this.params.clientId
// Verify permissions (scopes) - "data.id_token.payload.scopes" - from the initiator request
// Verify issuer claims - "data.id_token.payload.iss" - usually the domain part in this.endpoints.loginUrl
// Verify expiration - "data.id_token.payload.exp" - must be after the current date/time
// Starts a login session
delete credentials.id_token;
}
if (callback) {
credentials.identity = await callback(data);
} else {
credentials.identity = (data.id_token || {}).payload;
}
this.credentials = credentials;
if (this.request.authSession && this.params.sesskey) {
this.request.authSession.oauth = credentials;
}
// Redirect back to initiator URL
if (oauthState && oauthState.initiatorURL !== this.request.url) {
this.response.writeHead(302, {Location: oauthState.initiatorURL});
this.response.end();
return 302;
}
}
};