Skip to content

Commit

Permalink
Initial authentication/authorization module (#1)
Browse files Browse the repository at this point in the history
* Create an auth module that can be register with Koop.
* Module's authentication functions are intended to be applied to a provider Model's prototype
  • Loading branch information
rgwozdz authored May 22, 2018
1 parent 63620cb commit c8b58f4
Show file tree
Hide file tree
Showing 11 changed files with 261 additions and 2 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ test/test.log.**
.flowconfig
.vscode
.idea
dist
dist
**/user-store.json
8 changes: 8 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
language: node_js
node_js:
- "8"
sudo: false
cache:
- node_modules
script:
- npm test
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Koop-Auth-Direct-File
## A authentication module for implementing direct authentication from client to Koop server with a file-based user-store

## Authentication pattern

The authentication module implemented here uses a *direct authentication* pattern; it receives user credentials (username/password) from a client and authenticates those credentials against an identity/user-store. Requests with valid credentials are issued an access-token (a string of encoded-data); The access token is encoded with the use of a secret known only to the Koop server. The access-token expires and becomes invalid after a certain period (default of 60 minutes).

![get access token](https://gist.githubusercontent.com/rgwozdz/e44f3686abe40360532fbcc6dccf225d/raw/9768df32fc62e99ce7383c124cab8efdf45b1e18/koop-direct-auth-access-token.png)

The issued access-token should be attached to all subsequent service requests by the client. When the server receives a request, it will check for the presence of an access-token and reject any requests that are missing such token. If the token is present, the server attempts to decode it with its stored secret. Failure to decode results in a request rejection. Once decoded, the server checks the token's expiration-date and rejects any token with a date that is out of range. If the token is not expired, the request for the desired resource proceeds.

![enter image description here](https://gist.githubusercontent.com/rgwozdz/e44f3686abe40360532fbcc6dccf225d/raw/9768df32fc62e99ce7383c124cab8efdf45b1e18/koop-direct-auth-resources.png)

## Example of Koop authentication implementation

The [server.js](./server.js) file provides an example of securing a provider's resources. Start by requiring the authentication module. Pass it a secret and the file path of your user-store.

let auth = require('./koop-auth-direct/src')('pass-in-your-secret', `${__dirname}/user-store.json`)
koop.register(auth)

Then require and register your providers.

const provider = require('./')
koop.register(provider)

The authentication module will configure and add its `authorize`, `authenticate`, and `authenticationSpecification` functions to the provider's model prototype. Output services will leverage these functions to secure the service endpoints and properly route requests to authenticate.

Finally, create a JSON file store. This should be an array of objects with properties `username` and `password`. Set an environment variable `USER_STORE` with the path of the file relative to the root of the repository (e.g, `USER_STORE=./user-store.json`)

## Authentication API

### (secret, options) ⇒ <code>Object</code>
* configure the authentication module with secret use for token encoding/decoding

| Param | Type | Description |
| --- | --- | --- |
| secret | <code>string</code> | secret for encoding/decoding tokens |
| userStoreFilePath | <code>string</code> | path to the JSON file containing the array of username/password objects |
| options | <code>object</code> | options object |
| options.tokenExpirationMinutes | <code>integer</code> | minutes until token expires (default 60) |
Binary file added docs/koop-direct-auth-access-token.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/koop-direct-auth-resources.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"description": "Module for implementing a direct authentication pattern with Koop",
"main": "dist/index.js",
"scripts": {
"clean": "rm -rf dist",
"compile": "buble -i src -o dist",
"package": "npm run clean && npm run compile",
"test": "standard && tape test/*.js | tap-spec"
},
"repository": {
Expand All @@ -14,12 +17,16 @@
"license": "Apache-2.0",
"devDependencies": {
"buble": "^0.19.3",
"proxyquire": "^2.0.1",
"standard": "^11.0.1",
"tap-spec": "^4.1.1",
"tape": "^4.6.3"
},
"bugs": {
"url": "https://github.com/koopjs/koop-auth-direct/issues"
},
"homepage": "https://github.com/koopjs/koop-auth-direct#readme"
"homepage": "https://github.com/koopjs/koop-auth-direct#readme",
"dependencies": {
"jsonwebtoken": "^8.2.1"
}
}
94 changes: 94 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const fs = require('fs')
const jwt = require('jsonwebtoken')
const validateCredentials = require('./validate-credentials')
const TOKEN_EXPIRATION_MINTUES = 60
let _tokenExpirationMinutes
let _secret
let _userStoreFilePath

/**
* configure auth functions
* @param {string} secret - secret for generating tokens
* @param {string} userStoreFilePath - file path of user store JSON file
* @param {object} options
* @param {integer} options.tokenExpirationMinutes - number of minutes until token expires
*/
function auth (secret, userStoreFilePath, options = {}) {
// Throw error if user-store file does not exist
fs.stat(userStoreFilePath, function (err, stats) {
if (err) throw err
})

_secret = secret
_userStoreFilePath = userStoreFilePath
_tokenExpirationMinutes = options.tokenExpirationMinutes || TOKEN_EXPIRATION_MINTUES

// Ensure token expiration is an integer greater than 5
if (!Number.isInteger(_tokenExpirationMinutes) || _tokenExpirationMinutes < 5) throw new Error(`"tokenExpirationMinutes" must be an integer >= 5`)

return {
type: 'auth',
getAuthenticationSpecification,
authenticate,
authorize
}
}

/**
* Parameterize a "authenticationSpecification" function with the name of a provider
* @param {string} providerNamespace
*/
function getAuthenticationSpecification (providerNamespace) {
return function authenticationSpecification () {
return {
provider: providerNamespace,
secured: true
}
}
}

/**
* Authenticate a user's submitted credentials
* @param {string} username
* @param {strting} password
* @returns {Promise}
*/
function authenticate (username, password) {
return new Promise((resolve, reject) => {
// Validate user's credentials
validateCredentials(username, password, _userStoreFilePath)
.then(valid => {
// If credentials were not valid, reject
if (!valid) {
let err = new Error('Invalid credentials.')
err.code = 401
reject(err)// Create access token
}
let expires = Date.now() + (_tokenExpirationMinutes * 60 * 1000)
let json = {
token: jwt.sign({exp: Math.floor(expires / 1000), sub: username}, _secret),
expires
}
resolve(json)
})
.catch(err => {
reject(err)
})
})
}

function authorize (token) {
return new Promise((resolve, reject) => {
// Verify token with async decoded function
jwt.verify(token, _secret, function (err, decoded) {
// If token invalid, reject
if (err) {
err.code = 401
reject(err)
}
resolve(decoded)
})
})
}

module.exports = auth
30 changes: 30 additions & 0 deletions src/validate-credentials.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const fs = require('fs')

/**
* Validate username and password.
* @param {string} username
* @param {string} password
* @param {string} userStoreFilePath path to user-store file
* @returns {Promise}
*/
function validateFileBasedCredentials (username, password, userStoreFilePath) {
const promise = new Promise((resolve, reject) => {
fs.readFile(userStoreFilePath, function (err, dataBuffer) {
if (err) return reject(err)

let userStore = JSON.parse(dataBuffer.toString())

const user = userStore.find(user => {
return user.username === username
})

if (!user || user.password !== password) {
resolve(false)
}
resolve(true)
})
})
return promise
}

module.exports = validateFileBasedCredentials
69 changes: 69 additions & 0 deletions test/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const test = require('tape')
const path = require('path')
const jwt = require('jsonwebtoken')
const providerMock = {
name: 'test-provider',
Model: function () {}
}
const secret = 'secret'
const auth = require('../src')(secret, path.join(__dirname, '/fixtures/user-store.json'))

test('authorize success', async function (t) {
t.plan(1)
// Mock token
const token = jwt.sign({exp: Math.floor(Date.now() / 1000) + 120, iss: providerMock.name, sub: 'username'}, secret)
let decoded = await auth.authorize(token)
t.equals(decoded.iss, providerMock.name)
})

test('authorize failure - no token', async function (t) {
t.plan(1)
try {
await auth.authorize(undefined)
} catch (err) {
t.equals(err.code, 401)
}
})

test('authorize failure - expired token', async function (t) {
t.plan(1)
// Mock token
const token = jwt.sign({exp: Math.floor(Date.now() / 1000) - 120, iss: providerMock.name, sub: 'username'}, secret)
try {
await auth.authorize(token)
} catch (err) {
t.equals(err.code, 401)
}
})

test('authenticate success', async function (t) {
t.plan(2)
let result = await auth.authenticate('jerry', 'garcia')
t.equals(typeof result.token, 'string')
t.equals(typeof result.expires, 'number')
})

test('authenticate failure', async function (t) {
t.plan(2)
try {
await auth.authenticate('lou', 'reed')
} catch (err) {
t.equals(err.code, 401)
t.equals(err.message, 'Invalid credentials.')
}
})

test('authenticationSpecifiction', t => {
t.plan(2)
let authenticationSpecification = auth.getAuthenticationSpecification(providerMock.name)
let result = authenticationSpecification()
t.equals(result.secured, true)
t.equals(result.provider, providerMock.name)
})

test('tokenExpirationMinutes - invalid setting', t => {
t.plan(1)
t.throws(function () {
require('../src')(secret, path.join(__dirname, '/fixtures/user-store.json'), {tokenExpirationMinutes: -1})
}, /"tokenExpirationMinutes" must be an integer >= 5/)
})
7 changes: 7 additions & 0 deletions test/fixtures/user-store.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"username": "jerry",
"password": "garcia"

}
]
3 changes: 3 additions & 0 deletions user-store.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[{
"username": "rich", "password": "rich"
}]

0 comments on commit c8b58f4

Please sign in to comment.