TL;DR
How do I intercept a request, ping a different route for an ID, store the ID in a session, then continue the original request (especially PUT/POST with payloads) using the ID I just got?
Background
I am using HapiJS (8) to proxy requests from the client to an existing API (where I have no control over processes/logic). The API requires each request to contain a 'session ID' in either the query string or the payload (depending on the http method). In order to get a session ID, all I have to do is ask for one... there is no username/pwd required (it uses Basic auth in the headers). The session ID expires every 24 hours if it isn't renewed. Each client has their own session ID.
I am currently using hapi-auth-cookie to store the value of the session ID which gets queried when the ID is needed. If the ID is expired or null, I need to request a new one before the client's request can be successfully proxy'd to the API.
Current Solution
When the client's request method is 'GET', I am handling this challenge quite gracefully utilizing the appendNext configuration described in the hapi-auth-cookie docs. The request is intercepted by hapi-auth-cookie, if a new session ID is needed, a request is sent to that particular route to get it, the API returns the ID which is then assigned to the Hapi session, and then (using Wreck) a reply.redirect returns to the original GET request where it completes. Seamless and graceful.
However, I cannot figure out how to accomplish this same flow with different http methods that contain payloads of data.
Is there something besides reply.redirect that will accomplish the same goal while maintaining original payloads and methods? Or is there a much better way to do this in general?
The Code (for what currently works for 'GET' requests)
main app file (hapi-auth-cookie configs)
# register plugins
server.register require('./plugins')
, (err) ->
throw err if err
# set authentication (NLS) configs
server.auth.strategy 'session', 'cookie',
password: 'session_pwd'
cookie: 'ghsid'
redirectTo: '/api/SessionID' #get new session ID
isSecure: config.get 'ssl'
appendNext: true
ttl: config.get 'session_length'
Controller that leverages session authentication and invokes hapi-auth-cookie plugin:
simpleRequest:
auth: 'session'
handler: (request, reply) ->
qs = Qs.stringify request.query
request.papi_url = "/api/route/sample?#{qs}"
reply.proxy
mapUri: (request, reply) ->
auth = config.get 'basic_auth'
api_host = config.get 'api_host'
papi_url = request.papi_url
path = api_host + papi_url
next null, path, {authorization: auth}
Route for getting a new session ID
module.exports = [
{
path: '/api/SessionID'
method: 'GET'
config: SessionController.session
}
]
Session Controller
Wreck = require 'wreck'
config = require 'config'
module.exports =
session:
description: 'Get new session ID'
auth:
mode: 'try'
strategy: 'session'
plugins:
'hapi-auth-cookie':
redirectTo: false
handler: (request, reply) ->
# request configs
papi_url = "/Session"
api_host = config.get 'api_host'
url = api_host + papi_url
opts =
headers:
'Authorization': config.get 'basic_auth'
'content-type': 'application/json;charset=UTF-8'
# make request to PAPI
Wreck.post url, opts, (err, res, body) ->
throw new Error err if err
try
bdy = JSON.parse body
sess =
nls: bdy.SessionId
if bdy.SessionId
# authenticate user with NLS
request.auth.session.set sess
# redirect to initial route
reply.redirect request.url.query.next
else
return throw new Error
catch err
throw new Error err
Final solution
Based on Matt Harrison's answer, I created a custom plugin that gets registered as an authentication scheme so I can control this per route.
Here's the plugin code:
Wreck = require 'wreck'
config = require 'config'
exports.register = (server, options, next) ->
server.auth.scheme 'cookie', internals.implementation
next()
exports.register.attributes =
name: 'Hapi Session Interceptor'
version: '1.0.0'
internals = {}
internals.implementation = (server, options, next) ->
scheme = authenticate: (request, reply) ->
validate = ->
session = request.state.sessionID
unless session
return unauthenticated()
reply.continue(credentials: {'session': session})
unauthenticated = ->
api_url = "/SessionID"
api_host = config.get 'api_host'
url = api_host + api_url
opts =
headers:
'Authorization': config.get 'basic_auth'
'content-type': 'application/json;charset=UTF-8'
# make request to API
Wreck.post url, opts, (err, res, body) ->
throw new Error err if err
bdy = JSON.parse body
sess =
session: bdy.SessionId
if bdy.SessionId
reply.state 'sessionID', bdy.SessionId
reply.continue(credentials: sess)
else
return throw new Error
validate()
return scheme
Although not entirely faithful to your code, I've put together an example that has all the pieces I think you're working with.
I've made a service plugin to represent your API. The upstream plugin represents the actual upstream API you're proxying to.
All requests passthrough the service and are proxied to the upstream, which just prints out all of the headers and payload it received.
If the original request doesn't contain a cookie with a sessionId, a route is hit on the upstream to get one. A cookie is then set with this value when the response comes back down stream.
The code is here: https://github.com/mtharrison/hapijs-proxy-trouble
Try it out with curl and your browser.
GET: curl http://localhost:4000
POST W/PAYLOAD: curl -X POST -H "content-type: application/json" -d '{"example":"payload"}' http://localhost:4000
index.js
var Hapi = require('hapi');
var server = new Hapi.Server();
server.connection({ port: 4000, labels: ['service'] }); // Your service
server.connection({ port: 5000, labels: ['upstream']}); // Pretend upstream API
server.state('session', {
ttl: 24 * 60 * 60 * 1000,
isSecure: false,
path: '/',
encoding: 'base64json'
});
server.register([{
register: require('./service')
}, {
register: require('./upstream')
}],
function (err) {
if (err) {
throw err;
}
server.start(function () {
console.log('Started!');
});
});
service.js
var Wreck = require('wreck');
exports.register = function (server, options, next) {
// This is where the magic happens!
server.select('service').ext('onPreHandler', function (request, reply) {
var sessionId = request.state.session;
var _done = function () {
// Set the cookie and proceed to the route
request.headers['X-Session-Id'] = sessionId;
reply.state('session', sessionId);
reply.continue();
}
if (typeof sessionId !== 'undefined')
return _done();
// We don't have a sessionId, let's get one
Wreck.get('http://localhost:5000/sessionId', {json: true}, function (err, res, payload) {
if(err) {
throw err;
}
sessionId = payload.id;
_done();
});
});
server.select('service').route({
method: '*',
path: '/{p*}', // Proxies all routes and methods
handler: {
proxy: {
host: 'localhost',
port: 5000,
protocol: 'http',
passThrough: true
}
}
});
next();
};
exports.register.attributes = {
name: 'your-service'
};
upstream.js
exports.register = function (server, options, next) {
server.select('upstream').route([{
method: '*',
path: '/{p*}',
handler: function (request, reply) {
// Just prints out what it received for headers and payload
// To prove we got send the original payload and the sessionID header
reply({
originalHeaders: request.headers,
originalPayload: request.payload,
})
}
}, {
method: 'GET',
path: '/sessionId',
handler: function (request, reply) {
// Returns a random session id
reply({ id: (Math.floor(Math.random() * 1000)) });
}
}]);
next();
};
exports.register.attributes = {
name: 'upstream'
};
Related
I'm struggling with AXIOS: it seems that my post request is not using my Cookie.
First of all, I'm creating an Axios Instance as following:
const api = axios.create({
baseURL: 'http://mylocalserver:myport/api/',
header: {
'Content-type' : 'application/json',
},
withCredentials: true,
responseType: 'json'
});
The API I'm trying to interact with is requiring a password, thus I'm defining a variable containing my password:
const password = 'mybeautifulpassword';
First, I need to post a request to create a session, and get the cookie:
const createSession = async() => {
const response = await api.post('session', { password: password});
return response.headers['set-cookie'];
}
Now, by using the returned cookie (stored in cookieAuth variable), I can interact with the API.
I know there is an endpoint allowing me to retrieve informations:
const readInfo = async(cookieAuth) => {
return await api.get('endpoint/a', {
headers: {
Cookie: cookieAuth,
}
})
}
This is working properly.
It's another story when I want to launch a post request.
const createInfo = async(cookieAuth, infoName) => {
try {
const data = JSON.stringify({
name: infoName
})
return await api.post('endpoint/a', {
headers: {
Cookie: cookieAuth,
},
data: data,
})
} catch (error) {
console.log(error);
}
};
When I launch the createInfo method, I got a 401 status (Unauthorized). It looks like Axios is not using my cookieAuth for the post request...
If I'm using Postman to make the same request, it works...
What am I doing wrong in this code? Thanks a lot for your help
I finally found my mistake.
As written in the Axios Doc ( https://axios-http.com/docs/instance )
The specified config will be merged with the instance config.
after creating the instance, I must follow the following structure to perform a post requests:
axios#post(url[, data[, config]])
My requests is working now :
await api.post('endpoint/a', {data: data}, {
headers: {
'Cookie': cookiesAuth
}
});
I'm using http-proxy-middleware in NodeJS to proxy a PHP site that does authentication.
The PHP site returns a header "saml-user-id".
I use this saml-user-id to generate a token which I want to add as cookie. I need to lookup the user in the database, so it needs to work asynchronously.
The code looks like this:
// DOESN'T WORK
exports.proxySimpleSamlMiddleware = createProxyMiddleware(['/saml'], {
ssl: {
key: fs.readFileSync('/path/to/my.key'),
cert: fs.readFileSync('path/to/my.crt'),
},
target: 'https://127.0.0.1:8443',
secure: false,
logLevel: 'debug',
changeOrigin: true,
onProxyRes: async (proxyRes, req, res) => { // <-- ASYNCHRONOUS
let tokenCookie = 'token=';
if (proxyRes.headers['saml-user-id']) {
const userId = proxyRes.headers['saml-user-id'];
const token = await getNewToken(userId);
tokenCookie = `token=${token}; path=/; httponly; samesite=lax`;
}
// Set the 'token' cookie
// (simplified, in reality I read the existing cookies from the headers
// and add this cookie to the list)
proxyRes.headers['set-cookie'] = tokenCookie;
},
});
The above code doen't work. No headers are changed, and I get an error ("Cannot set headers after they are sent to the client"). I guess that this is because of the asynchronous function in onProxyRes.
If I change the code to this:
// DOESN'T WORK ASYNCHRONOUSLY
onProxyRes: (proxyRes, req, res) => { // <-- REMOVED ASYNC
let tokenCookie = 'token=my-new-token';
proxyRes.headers['set-cookie'] = tokenCookie;
},
Then the "token" cookie is set to "my-new-token" as expected. This is not an option because the change I want to make is asynchronous.
I have experimented with "responseIntercepter":
// DOESN'T WORK
selfHandleResponse: true,
onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
let tokenCookie = 'token=my-new-token';
proxyRes.headers['set-cookie'] = tokenCookie;
return responseBuffer;
}),
But this doesn't update the "token" cookie either.
My question:
Is there a way using http-proxy-middleware to change the headers asynchronously?
i have an app with Webserver implemented using Hapi JS, and whenever i opened an application with browser's tab i am able to see the cookies are injected in Request header.
but if i load my app inside third application through Iframe. there is missing cookies from the header . can you please some body help here .
Hapi JS code
server.auth.strategy('session', 'cookie', {
password: 'longpassword-should-be-32-characters-for-pulse',
cookie: 'my-app-sid',
redirectTo: '/',
ttl: 86400000,
isSecure: false,
validateFunc: isAuth
});
and my API call where the cookies missing from req header
getBookDetails = () => {
return {
auth: {
strategy: 'session'
},
handler: {
proxy: {
mapUri: (request, callback) => {
let url = 'https://mydemoapp.com'
let tokenHeaders = { token: request.auth.credentials.token,
assetid: request.headers['assetid'],
asseturl:request.headers['asseturl'],
deviceid: deviceid,
appversion: request.headers['appversion'],
'user-agent': request.headers['user-agent'],
'accept-language': request.headers['accept-language']
};
url = url + '/book/' + request.params.bookId;
callback(null, url, tokenHeaders);
},
onResponse: (err, res, request, reply) => {
wreck.read(res, { json: true, gzip: true }, (err, payload) => {
reply(payload);
});
}
}
}
}
}
i am suspecting that since my app is loaded inside the iframe of third party app. and while requesting the webserver URL mentioned above getBookDetails() it could'nt read the cookies by Iframe from the Parent app (Third party app).
can some one help please
I think the first place should be checked in your browser.
I'm using the http-proxy-middleware (https://github.com/chimurai/http-proxy-middleware#http-proxy-events) to implement a simple proxy (call it my-proxy/) to another REST API (call it /rest-api) that requires the user to pass an auth token in the HTTP header auth-token. The token can be fetched from an endpoint POST /rest-api/auth with the credentials in the body.
I want my proxy to take incoming requests and check if auth-token is set in the the request header, and if not perform a POST /rest-api/auth to retrieve the token and set auth-token in the header before passing the request to rest-api/.
In the proxy config I specify
onProxyReq: function (proxyReq, req, res) {
if (!req.header("auth-token")) {
const authRequest = request({
url: 'rest-api/auth',
method: 'POST',
json: {"username": "user", "password": "pass"}
},
function (error, resp, body) {
proxyReq.setHeader("auth-token", body.token)
}
);
}
}
I can see the body.token return the right token. However the setHeader call fails with Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client.
I think this means the request I modify has already been sent to rest-api/ before waiting for the callback, but I don't know how this is best solved in my scenario.
Any help?
I met this same issue today. I workaround this by using a separate middleware (before http proxy).
pseudo code
// fix token stuff here in a separate middleware
app.use('/api', async (req, res, next) => {
if (!req.session.token) {
const resToken = await axios.post(token_url, data, options)
req.session.token = resToken.data
}
next()
}
// proxy middleware options
const proxyOptions = {
target: config.gateway.url, // target host
changeOrigin: true,
pathRewrite: function(path, req) {
return path.replace('/api', '')
},
onProxyReq: function onProxyReq(proxyReq, req, res) {
// add custom header to request
let token = req.session.token
if (token) {
proxyReq.setHeader('Authorization', `bearer ${token.access_token}`)
}
},
logLevel: 'debug',
}
app.use(proxy('/api', proxyOptions))
Hope this helps!
I am using Hapi and this is my handler function:
function propertyDetailsValidateHandler(request, reply, source, error) {
console.log(request.state)
var data = joiValidationHelper.checkForErrors(request, error);
if (typeof data !== "undefined"){
return reply.view('property-details', data).code(400);
} else {
var details = request.state.details;
details.propertyType = request.payload.propertyType;
details.newBuild = request.payload.newBuild;
return reply.redirect('/property-details/postcode').state('details', details, {path: '/'});
}
}
And this is my test written using Jasmine:
describe('tell us about the property youre buying flow', function(){
it('test /property-details, status code and location', function(done){
var options = {
method: 'POST',
url: '/property-details',
headers: {
cookie: {details: { test: "test"}}
},
payload: {
propertyType: "freehold",
newBuild: true
}
};
server.inject(options, function(response){
detailsTestCookie = response.headers['set-cookie'][0].split(';')[0];
expect(response.statusCode).toBe(302);
expect(response.headers.location).toMatch("/property-details/postcode");
done();
});
});
})
The handler function runs correctly when I run my server and use the browser but when I run the test request.state is an empty object when I was expecting it to be the cookie I provided in the test hence my test fails as request.state.details is undefined. Is this the correct way to provide the headers with a cookie in my test?
This works in our project, using tape and Hapi.
var cookie = the_cookie_you_want_to_send;
Then in your test payload:
headers: { cookie: `details=${cookie}`}
The cookie needed to be encoded as that is how the cookie was registered in our server file:
server.state('details', {
ttl: null,
isSecure: false,
isHttpOnly: false,
encoding: 'base64json', //this is not encrypted just encoded
clearInvalid: false, // remove invalid cookies
strictHeader: false // don't allow violations of RFC 6265
});