Skip to content

Commit

Permalink
feat(#3): add support for passing an existent session cookie. (#4)
Browse files Browse the repository at this point in the history
in this case, the cookie is used instead of requesting one. 
if the cookie is expired and auth is passed along side it, an attempt to generate a new cookie is made.
  • Loading branch information
dianabarsan authored May 29, 2024
1 parent d52bfcc commit ea80e4c
Show file tree
Hide file tree
Showing 9 changed files with 337 additions and 80 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ const myOtherDb = new PouchDB(
'http://mysite:5984/mydb',
{ auth: { username: 'admin', password: 'pass' }
});
const sessionDb = new PouchDB('http://mysite:5984/mydb', { session: 'existent session cookie' });

await myDb.allDocs();
await myOtherDb.allDocs();
await sessionDb.allDocs();
```

## Overview
Expand All @@ -40,6 +42,8 @@ It supports authentication embedded in the CouchDb URL or as an additional optio

It regenerates the session cookie on expiry and retries the last request, the client should not expect a failed request for an expired cookie.

When given a `session` parameter, a new session will not be requested, instead the passed session cookie will be used as session authentication. If both `session` and `auth` are provided then the `auth` will only be used if the session token has expired.

### Testing

Testing requires `docker` and `docker-compose` to launch a CouchDb 3.3.3 container.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pouchdb-session-authentication",
"version": "1.1.0",
"version": "1.2.0",
"description": "Plugin that forces session authentication for PouchDb http adapter",
"main": "src/index.js",
"scripts": {
Expand All @@ -9,9 +9,10 @@
"test": "npm run lint && npm run unit",
"start-couch": "cd test/integration && docker-compose up -d",
"stop-couch": "cd test/integration && docker-compose down --remove-orphans",
"integration-auth": "npm run stop-couch && npm run start-couch && mocha ./test/integration/*.spec.js && npm run stop-couch",
"integration-url": "npm run stop-couch && npm run start-couch && AUTH_TYPE=url mocha ./test/integration/*.spec.js && npm run stop-couch",
"integration": "npm run integration-auth && npm run integration-url"
"integration-auth": "npm run stop-couch && npm run start-couch && mocha ./test/integration/credentials*.spec.js && npm run stop-couch",
"integration-url": "npm run stop-couch && npm run start-couch && AUTH_TYPE=url mocha ./test/integration/credentials*.spec.js && npm run stop-couch",
"integration-session": "npm run stop-couch && npm run start-couch && mocha ./test/integration/session*.spec.js && npm run stop-couch",
"integration": "npm run integration-auth && npm run integration-url && npm run integration-session"
},
"engines": {
"node": ">=16.12.0",
Expand Down
36 changes: 25 additions & 11 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const parseCookie = (response) => {

const getSessionKey = (db) => {
const sessionUrl = getSessionUrl(db);
return `${db.credentials.username}:${db.credentials.password}:${sessionUrl}`;
return `${db.credentials?.username}:${db.credentials?.password}:${db.session}:${sessionUrl}`;
};

const getSessionUrl = (db) => {
Expand All @@ -45,6 +45,10 @@ const getSessionUrl = (db) => {
};

const authenticate = async (db) => {
if (!db?.credentials?.username) {
return;
}

const url = getSessionUrl(db);

const headers = new Headers();
Expand All @@ -56,15 +60,19 @@ const authenticate = async (db) => {
return updateSession(db, response);
};

const updateSession = (db, response) => {
const session = parseCookie(response);
const setSession = (db, session) => {
if (session) {
const sessionKey = getSessionKey(db);
sessions[sessionKey] = session;
return session;
}
};

const updateSession = (db, response) => {
const session = parseCookie(response);
return setSession(db, session);
};

const invalidateSession = db => {
const sessionKey = getSessionKey(db);
delete sessions[sessionKey];
Expand All @@ -73,21 +81,27 @@ const invalidateSession = db => {
const extractAuth = (opts) => {
if (opts.auth) {
opts.credentials = opts.auth;
delete opts.auth;
}

const url = new URL(opts.name);
if (!url.username) {
return;
if (url.username) {
opts.credentials = {
username: url.username,
password: url.password
};
url.username = '';
url.password = '';
opts.name = url.toString();
}

opts.credentials = {
username: url.username,
password: url.password
};
if (opts.session) {
setSession(opts, { token: opts.session, expires: Number.MAX_SAFE_INTEGER });
}
};

const isValid = (session) => {
if (!session || !session.expires) {
if (!session?.expires) {
return false;
}
const isExpired = Date.now() > session.expires;
Expand All @@ -113,7 +127,7 @@ function wrapAdapter (PouchDB, HttpPouch) {
// eslint-disable-next-line func-style
function HttpSessionPouch(db, callback) {
extractAuth(db);
if (!db.credentials) {
if (!db.credentials && !db.session) {
HttpPouch.call(this, db, callback);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,20 @@ const getDb = (dbName, auth, authType, skip_setup = true) => {
return new PouchDb(`${utils.baseUrl}/${dbName}`, { skip_setup, auth });
};

describe(`integration with ${authType}`, function () {
describe(`integration with ${authType}`, async function () {
const dbName = 'testdb';
const db = getDb(dbName, utils.dbAuth, authType);
let db;

let tempDbName;
let tempAdminName;
let tempDb;

const wrongAuthError = 'Authentication required.';

this.timeout(12000);
before(async () => {
await utils.setupCouch(dbName);
db = getDb(dbName, utils.dbAuth, authType);
});

beforeEach(() => {
Expand All @@ -49,6 +52,7 @@ describe(`integration with ${authType}`, function () {
await utils.deleteAdmin(tempAdminName);
});


it('should setup session on first request and reuse session on subsequent request', async () => {
const collectLogs = await utils.getDockerContainerLogs();
await db.allDocs();
Expand Down Expand Up @@ -85,7 +89,7 @@ describe(`integration with ${authType}`, function () {
await db.allDocs();
await tempDb.allDocs();

const logs = await collectLogs();
const logs = await collectLogs(100);

expect(utils.getDbRequest(auth.username, logs, tempDbName, '/_all_docs').length).to.equal(1);
expect(utils.getSessionRequests(logs).length).to.equal(1);
Expand All @@ -102,18 +106,18 @@ describe(`integration with ${authType}`, function () {
const collectLogs = await utils.getDockerContainerLogs();
await tempDb.allDocs();
await utils.createAdmin(auth.username, 'password change');
await expect(tempDb.allDocs()).to.eventually.be.rejectedWith('Name or password is incorrect.');
await expect(tempDb.allDocs()).to.eventually.be.rejectedWith(wrongAuthError);
const logs = await collectLogs();

expect(utils.getSessionRequests(logs, false).length).to.equal(1);
expect(utils.getDbRequest('undefined', logs, tempDbName, '/_all_docs', false).length).to.equal(2);
});

it('should throw errors on invalid credentials', async () => {
const newDb = getDb(dbName, { username: utils.dbAuth.username, password: 'not the right password' }, authType);
const newDb = getDb(dbName, { username: utils.dbAuth.username, password: 'wrong password' }, authType);

const collectLogs = await utils.getDockerContainerLogs();
await expect(newDb.allDocs()).to.eventually.be.rejectedWith('Name or password is incorrect.');
await expect(newDb.allDocs()).to.eventually.be.rejectedWith(wrongAuthError);
const logs = await collectLogs();
expect(utils.getSessionRequests(logs, false).length).to.equal(1);
});
Expand Down
2 changes: 0 additions & 2 deletions test/integration/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: '3.9'

services:
couchdb:
image: couchdb:3.3.3
Expand Down
153 changes: 153 additions & 0 deletions test/integration/session-auth.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
require('chai').use(require('chai-as-promised'));
const PouchDb = require('pouchdb-core');
PouchDb.plugin(require('pouchdb-adapter-http'));
PouchDb.plugin(require('../../src/index'));

const { expect } = require('chai');
const uuid = require('uuid').v4;

const utils = require('./utils');
const { Headers } = require('pouchdb-fetch');

const getSession = async (auth) => {
const url = new URL(`${utils.baseUrl}/_session`);
url.username = auth.username;
url.password = auth.password;

const headers = new Headers();
headers.set('Content-Type', 'application/json');
headers.set('Accept', 'application/json');

const body = JSON.stringify({ name: auth.username, password: auth.password});
const response = await PouchDb.fetch(url.toString(), { method: 'POST', headers, body });

const cookie = response?.headers?.get('set-cookie');
const sessionCookieName = 'AuthSession';
const matches = cookie.match( new RegExp(`${sessionCookieName}=(.*)`));
if (!matches) {
return;
}

const parts = matches[1].split(';').map(item => item.trim().split('='));
return parts[0][0];
};

const getDb = async (dbName, auth, session = null, includeAuth = false) => {
const url = new URL(`${utils.baseUrl}/${dbName}`);
session = session || await getSession(auth);
const params = { skip_setup: true, session };
if (includeAuth) {
params.auth = auth;
}
const db = new PouchDb(url.toString(), params);
await PouchDb.fetch(url.toString()); // overwrite last docker log entry
return db;
};

describe('session auth type', function () {
const dbName = 'testdb';
let db;

let tempDbName;
let tempAdminName;
let tempDb;

this.timeout(12000);
before(async () => {
await utils.setupCouch(dbName);
db = await getDb(dbName, utils.dbAuth);
});

beforeEach(() => {
tempDbName = `temp${uuid()}`;
tempAdminName = `temp${uuid()}`;
});

afterEach(async () => {
try {
await tempDb?.destroy();
} catch (err) {
// will throw if db doesn't exist
}

await utils.deleteDb(tempDbName);
await utils.deleteAdmin(tempAdminName);
});

it('should use existent session when connecting to any DB', async () => {
await utils.createDb(tempDbName);
tempDb = await getDb(tempDbName, utils.dbAuth);

const collectLogs = await utils.getDockerContainerLogs();
await db.allDocs();
await db.allDocs();
await db.allDocs();
await tempDb.allDocs();
const logs = await collectLogs();

expect(utils.getSessionRequests(logs).length).to.equal(0);
expect(utils.getCookieAuthRequests(utils.dbAuth.username, logs).length).to.equal(4);
});

it('should fail if session is not valid', async () => {
await utils.createDb(tempDbName);

tempDb = await getDb(tempDbName, utils.dbAuth, 'invalid');

const collectLogs = await utils.getDockerContainerLogs();
await expect(tempDb.allDocs()).to.eventually.be.rejectedWith(
'Malformed AuthSession cookie. Please clear your cookies.'
);
const logs = await collectLogs();

expect(utils.getSessionRequests(logs).length).to.equal(0);
expect(utils.getCookieAuthRequests(utils.dbAuth.username, logs).length).to.equal(0);
});

it('should fail if session is invalid due to password change', async () => {
const auth = { username: tempAdminName, password: 'new_password' };
await utils.createAdmin(auth.username, auth.password);
await utils.createDb(tempDbName);

tempDb = await getDb(tempDbName, auth, null, true);

const collectLogs = await utils.getDockerContainerLogs();
await tempDb.allDocs();
await utils.createAdmin(auth.username, 'password change');
await expect(tempDb.allDocs()).to.eventually.be.rejectedWith('Authentication required.');
const logs = await collectLogs(1000);

expect(utils.getSessionRequests(logs, false).length).to.equal(1);
expect(utils.getDbRequest('undefined', logs, tempDbName, '/_all_docs', false).length).to.equal(2);
});

it('should try to get session again if credentials are provided but session is expired', async () => {
await utils.createDb(tempDbName);
tempDb = await getDb(tempDbName, utils.dbAuth, null, true);

const collectLogs = await utils.getDockerContainerLogs();
await utils.asyncTimeout(6000);
await tempDb.allDocs();
const logs = await collectLogs(1000);

expect(utils.getSessionRequests(logs, true).length).to.equal(1);
expect(utils.getDbRequest(utils.dbAuth.username, logs, tempDbName, '/_all_docs', true).length).to.equal(1);
});

it('should fail if neither session or auth works', async () => {
const auth = { username: tempAdminName, password: 'new_password' };
await utils.createAdmin(auth.username, auth.password);
await utils.createDb(tempDbName);

const session = await getSession(auth);
const url = new URL(`${utils.baseUrl}/${tempDbName}`);
tempDb = new PouchDb(url.toString(), { skip_setup: true, session, auth});
await utils.createAdmin(auth.username, 'password update');

const collectLogs = await utils.getDockerContainerLogs();
await expect(tempDb.allDocs()).to.eventually.be.rejectedWith('Authentication required.');
const logs = await collectLogs();

expect(utils.getSessionRequests(logs, false).length).to.equal(1);
});
});
4 changes: 2 additions & 2 deletions test/integration/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ const setConfig = async (section, config, value, remove = false) => {
};

const setIterations = (iterations) => setConfig('chttpd_auth', 'iterations', iterations);
const setAuthTimeout = (timeout) => setConfig('couch_httpd_auth', 'timeout', timeout);
const setAuthTimeout = (timeout) => setConfig('chttpd_auth', 'timeout', timeout);

const waitForCouchdb = async () => {
// eslint-disable-next-line no-constant-condition
Expand Down Expand Up @@ -227,7 +227,7 @@ const setupCouch = async (dbName) => {
await createAdmin(dbAuth.username, dbAuth.password);

await setIterations('50000');
await setAuthTimeout('5');
await setAuthTimeout('4');
await setConfig('log', 'level', 'debug');
await setConfig('chttpd', 'require_valid_user', 'true');
await createDb(dbName);
Expand Down
Loading

0 comments on commit ea80e4c

Please sign in to comment.