├── .gitignore ├── README.md ├── api.js ├── backends └── memory.js ├── circle.yml ├── handlers.js ├── index.js ├── package.json ├── password.js ├── queries.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Micro auth 2 | 3 | ### Summary 4 | 5 | Minimal authentication API built on Zeit's [Micro](https://github.com/zeit/micro). 6 | 7 | [![CircleCI](https://circleci.com/gh/possibilities/micro-auth.svg?style=svg)](https://circleci.com/gh/possibilities/micro-auth) 8 | 9 | ### Usage 10 | 11 | ``` 12 | AUTHENTICATION_SECRET_KEY=secret123 npm start 13 | ``` 14 | 15 | ### Configuration 16 | 17 | The app is configured via environment variables (see usage above). Possible values: 18 | 19 | #### `AUTHENTICATION_SECRET_KEY` | required 20 | 21 | The key used to encode [JWTs](https://jwt.io) returned from the service 22 | 23 | #### `API_PORT` | optional, default 3000 24 | 25 | The api app will bind to this port 26 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | const microApi = require('micro-api') 2 | 3 | const { 4 | signUp, 5 | signIn, 6 | checkUsername 7 | } = require('./handlers') 8 | 9 | const api = microApi([ 10 | { 11 | method: 'post', 12 | path: '/sign-up', 13 | handler: signUp 14 | }, 15 | { 16 | method: 'post', 17 | path: '/sign-in', 18 | handler: signIn 19 | }, 20 | { 21 | method: 'GET', 22 | path: '/check-username/:username', 23 | handler: checkUsername 24 | } 25 | ]) 26 | 27 | module.exports = api 28 | -------------------------------------------------------------------------------- /backends/memory.js: -------------------------------------------------------------------------------- 1 | const find = require('lodash.find') 2 | const uuid = require('uuid') 3 | 4 | const memory = () => { 5 | const _inMemoryBackend = {} 6 | 7 | return { 8 | save (type, data) { 9 | if (!_inMemoryBackend[type]) { 10 | _inMemoryBackend[type] = {} 11 | } 12 | 13 | const id = uuid() 14 | const savedData = Object.assign({}, { id }, data) 15 | 16 | // persist 17 | _inMemoryBackend[type][id] = savedData 18 | 19 | return Promise.resolve(savedData) 20 | }, 21 | 22 | find (type, query) { 23 | if (!_inMemoryBackend[type]) { 24 | _inMemoryBackend[type] = {} 25 | } 26 | 27 | const items = Object.keys(_inMemoryBackend[type]).map(id => { 28 | return _inMemoryBackend[type][id] 29 | }) 30 | 31 | const item = find(items, query) 32 | 33 | return Promise.resolve(item) 34 | } 35 | } 36 | } 37 | 38 | module.exports = memory 39 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.9 4 | -------------------------------------------------------------------------------- /handlers.js: -------------------------------------------------------------------------------- 1 | const omit = require('lodash.omit') 2 | const jwt = require('jsonwebtoken') 3 | const queries = require('./queries') 4 | const memoryBackend = require('./backends/memory') 5 | 6 | const { 7 | findUserForUsername, 8 | findUserForCredentials, 9 | addUser 10 | } = queries 11 | 12 | if (!process.env.AUTHENTICATION_SECRET_KEY) { 13 | console.error('AUTHENTICATION_SECRET_KEY environment variable is required') 14 | process.exit(1) 15 | } 16 | 17 | const database = memoryBackend() 18 | 19 | const createToken = async user => { 20 | const userView = omit(user, 'password') 21 | return jwt.sign(userView, process.env.AUTHENTICATION_SECRET_KEY) 22 | } 23 | 24 | const isPasswordStrong = password => password.length >= 6 // TODO 25 | const isUsernameValid = username => /^[A-Za-z0-9]+$/.test(username) 26 | 27 | const userExists = async username => 28 | !!(await findUserForUsername(database, username)) 29 | 30 | // POST /sign-up 31 | const signUp = async ({ body: user }) => { 32 | if (!user.username) throw new Error('username is required') 33 | if (!isUsernameValid(user.username)) throw new Error('username is not valid') 34 | if (!user.password) throw new Error('password is required') 35 | if (!isPasswordStrong(user.password)) { 36 | throw new Error('password must be at least 6 characters') 37 | } 38 | if (await userExists(user.username)) { 39 | throw new Error(`an account already exists for '${user.username}'`) 40 | } 41 | 42 | const newUser = await addUser(database, user) 43 | const token = await createToken(newUser) 44 | 45 | return Object.assign({}, newUser, { token }) 46 | } 47 | 48 | // POST /sign-in 49 | const signIn = async ({ body: credentials }) => { 50 | if (!credentials.username) throw new Error('username is required') 51 | if (!credentials.password) throw new Error('password is required') 52 | 53 | const user = await findUserForCredentials(database, credentials) 54 | if (user) { 55 | const token = await createToken(user) 56 | return Object.assign({}, omit(user, 'password'), { token }) 57 | } 58 | 59 | throw new Error(`error signing in '${credentials.username}'`) 60 | } 61 | 62 | // GET /check-username 63 | const checkUsername = async ({ params: { username } }) => { 64 | const user = await findUserForUsername(database, username) 65 | return user !== null; 66 | } 67 | 68 | module.exports = { 69 | signUp, 70 | signIn, 71 | checkUsername 72 | } 73 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const api = require('./api') 2 | const micro = require('micro') 3 | const microCors = require('micro-cors') 4 | 5 | const cors = microCors() 6 | 7 | const port = process.env.API_PORT || 3000 8 | 9 | const app = micro(cors(api)) 10 | app.listen(port) 11 | 12 | console.info(`listening on port ${port}...`) 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "micro-auth", 3 | "description": "Minimal authentication API built on Zeit's Micro", 4 | "version": "0.0.0", 5 | "engines": { 6 | "node": ">=6" 7 | }, 8 | "repository": { 9 | "url": "https://github.com/possibilities/micro-ruth", 10 | "type": "git" 11 | }, 12 | "author": "Mike Bannister ", 13 | "scripts": { 14 | "start": "NODE_ENV=production async-node index.js", 15 | "dev": "NODE_ENV=development async-node index.js", 16 | "test": "AUTHENTICATION_SECRET_KEY=test ava --fail-fast --verbose", 17 | "test:watch": "npm test -- --watch", 18 | "pretest": "npm run lint", 19 | "lint": "standard" 20 | }, 21 | "dependencies": { 22 | "async-to-gen": "^1.3.0", 23 | "bcrypt": "^1.0.2", 24 | "jsonwebtoken": "^7.2.1", 25 | "lodash.find": "^4.6.0", 26 | "lodash.omit": "^4.5.0", 27 | "micro": "^6.1.0", 28 | "micro-api": "^0.0.11", 29 | "micro-cors": "^0.0.1", 30 | "uuid": "^3.0.1" 31 | }, 32 | "devDependencies": { 33 | "ava": "^0.17.0", 34 | "babel-polyfill": "^6.20.0", 35 | "request": "^2.79.0", 36 | "request-promise": "^4.1.1", 37 | "standard": "^8.6.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /password.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt') 2 | 3 | const generateSalt = () => 4 | new Promise((resolve, reject) => bcrypt.genSalt(10, (error, salt) => { 5 | if (error) { 6 | reject(error) 7 | } else { 8 | resolve(salt) 9 | } 10 | })) 11 | 12 | const hash = password => { 13 | return new Promise(async (resolve, reject) => { 14 | const salt = await generateSalt() 15 | bcrypt.hash(password, salt, (error, hash) => { 16 | if (error) { 17 | reject(error) 18 | } else { 19 | resolve(hash) 20 | } 21 | }) 22 | }) 23 | } 24 | 25 | const compare = async (pass1, pass2) => { 26 | return new Promise((resolve, reject) => { 27 | bcrypt.compare(pass1, pass2, (error, isPasswordValid) => { 28 | if (error) { 29 | reject(error) 30 | } else { 31 | resolve(isPasswordValid) 32 | } 33 | }) 34 | }) 35 | } 36 | 37 | module.exports = { hash, compare } 38 | -------------------------------------------------------------------------------- /queries.js: -------------------------------------------------------------------------------- 1 | const password = require('./password') 2 | const omit = require('lodash.omit') 3 | 4 | const findUserForUsername = (database, username) => 5 | database.find('users', { username }) 6 | 7 | const findUserForCredentials = async (database, credentials) => { 8 | const user = await findUserForUsername(database, credentials.username) 9 | if (user && await password.compare(credentials.password, user.password)) { 10 | return user 11 | } 12 | } 13 | 14 | const addUser = async (database, user) => { 15 | const encryptedPassword = await password.hash(user.password) 16 | 17 | const userWithEncryptedPassword = Object.assign( 18 | {}, 19 | user, 20 | { password: encryptedPassword } 21 | ) 22 | 23 | const savedUser = await database.save('users', userWithEncryptedPassword) 24 | 25 | return omit(savedUser, 'password') 26 | } 27 | 28 | module.exports = { findUserForUsername, findUserForCredentials, addUser } 29 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import 'async-to-gen/register' 2 | import 'babel-polyfill' 3 | 4 | import test from 'ava' 5 | import listen from 'test-listen' 6 | import jwt from 'jsonwebtoken' 7 | 8 | import microAuth from './api' 9 | import micro from 'micro' 10 | import request from 'request-promise' 11 | 12 | const decodeToken = token => 13 | jwt.verify(token, process.env.AUTHENTICATION_SECRET_KEY) 14 | 15 | const testRequestOptions = { 16 | json: true, 17 | // Otherwise request-promise just gives the body 18 | resolveWithFullResponse: true, 19 | // Don't reject messages that come back with error code (e.g. 404, 500s) 20 | simple: false 21 | } 22 | 23 | test('signs up a new user', async t => { 24 | const router = micro(microAuth) 25 | const apiUrl = await listen(router) 26 | 27 | const signUpEndpoint = `${apiUrl}/sign-up` 28 | const signUpResponse = await request.post({ 29 | body: { 30 | username: 'mikebannister', 31 | password: 'password' 32 | }, 33 | url: signUpEndpoint, 34 | ...testRequestOptions 35 | }) 36 | 37 | const { id, username, token } = signUpResponse.body 38 | const { username: usernameInToken } = decodeToken(token) 39 | 40 | t.truthy(id) 41 | t.deepEqual(username, 'mikebannister') 42 | t.deepEqual(signUpResponse.statusCode, 200) 43 | t.deepEqual(usernameInToken, 'mikebannister') 44 | }) 45 | 46 | test('fails to sign up an existing user', async t => { 47 | const router = micro(microAuth) 48 | const apiUrl = await listen(router) 49 | 50 | const signUpEndpoint = `${apiUrl}/sign-up` 51 | const firstSignUpResponse = await request.post({ 52 | body: { 53 | username: 'mikebannister', 54 | password: 'password' 55 | }, 56 | url: signUpEndpoint, 57 | ...testRequestOptions 58 | }) 59 | 60 | t.deepEqual(firstSignUpResponse.statusCode, 200) 61 | 62 | const secondSignUpResponse = await request.post({ 63 | body: { 64 | username: 'mikebannister', 65 | password: 'password' 66 | }, 67 | url: signUpEndpoint, 68 | ...testRequestOptions 69 | }) 70 | 71 | t.deepEqual(secondSignUpResponse.statusCode, 500) 72 | t.deepEqual( 73 | secondSignUpResponse.body.message, 74 | `an account already exists for 'mikebannister'` 75 | ) 76 | }) 77 | 78 | test('signs in with correct credentials', async t => { 79 | const router = micro(microAuth) 80 | const apiUrl = await listen(router) 81 | 82 | // Sign up 83 | const signUpEndpoint = `${apiUrl}/sign-up` 84 | const signUpResponse = await request.post({ 85 | body: { 86 | username: 'mikebannister', 87 | password: 'password' 88 | }, 89 | url: signUpEndpoint, 90 | ...testRequestOptions 91 | }) 92 | 93 | // Sign up succeeds 94 | t.deepEqual(signUpResponse.statusCode, 200) 95 | 96 | // Then try to sign in 97 | const signInEndpoint = `${apiUrl}/sign-in` 98 | const signInResponse = await request.post({ 99 | body: { 100 | username: 'mikebannister', 101 | password: 'password' 102 | }, 103 | url: signInEndpoint, 104 | ...testRequestOptions 105 | }) 106 | 107 | const { id, username, token } = signInResponse.body 108 | const { username: usernameInToken } = decodeToken(token) 109 | 110 | t.truthy(id) 111 | t.deepEqual(username, 'mikebannister') 112 | t.deepEqual(signInResponse.statusCode, 200) 113 | t.deepEqual(usernameInToken, 'mikebannister') 114 | }) 115 | 116 | test('fails to sign in with incorrect credentials', async t => { 117 | const router = micro(microAuth) 118 | const apiUrl = await listen(router) 119 | 120 | // Sign up 121 | const signUpEndpoint = `${apiUrl}/sign-up` 122 | const signUpResponse = await request.post({ 123 | body: { 124 | username: 'mikebannister', 125 | password: 'password' 126 | }, 127 | url: signUpEndpoint, 128 | ...testRequestOptions 129 | }) 130 | 131 | // Sign up succeeds 132 | t.deepEqual(signUpResponse.statusCode, 200) 133 | 134 | // Then try to sign in 135 | const signInEndpoint = `${apiUrl}/sign-in` 136 | const signInResponse = await request.post({ 137 | body: { 138 | username: 'mikebannister', 139 | password: 'wrongpassword' 140 | }, 141 | url: signInEndpoint, 142 | ...testRequestOptions 143 | }) 144 | 145 | // Sign in fails 146 | t.deepEqual(signInResponse.statusCode, 500) 147 | t.deepEqual( 148 | signInResponse.body.message, 149 | `error signing in 'mikebannister'` 150 | ) 151 | }) 152 | 153 | test('checks username that exists', async t => { 154 | const router = micro(microAuth) 155 | const apiUrl = await listen(router) 156 | 157 | const signUpEndpoint = `${apiUrl}/sign-up` 158 | const signUpResponse = await request.post({ 159 | body: { 160 | username: 'mikebannister', 161 | password: 'password' 162 | }, 163 | url: signUpEndpoint, 164 | ...testRequestOptions 165 | }) 166 | 167 | // Sign up succeeds 168 | t.deepEqual(signUpResponse.statusCode, 200) 169 | 170 | const checkUsernameEndpoint = `${apiUrl}/check-username/mikebannister` 171 | const checkUsernameResponse = await request.get({ 172 | url: checkUsernameEndpoint, 173 | ...testRequestOptions 174 | }) 175 | 176 | const { username } = checkUsernameResponse.body 177 | 178 | t.deepEqual(checkUsernameResponse.statusCode, 200) 179 | t.deepEqual(username, 'mikebannister') 180 | }) 181 | 182 | test('checks username that does not exists', async t => { 183 | const router = micro(microAuth) 184 | const apiUrl = await listen(router) 185 | 186 | const checkUsernameEndpoint = `${apiUrl}/check-username/mikebannister` 187 | const checkUsernameResponse = await request.get({ 188 | url: checkUsernameEndpoint, 189 | ...testRequestOptions 190 | }) 191 | 192 | t.deepEqual(checkUsernameResponse.statusCode, 404) 193 | }) 194 | 195 | test('fails to sign up without a username', async t => { 196 | const router = micro(microAuth) 197 | const apiUrl = await listen(router) 198 | 199 | const signUpEndpoint = `${apiUrl}/sign-up` 200 | const signUpResponse = await request.post({ 201 | url: signUpEndpoint, 202 | body: { password: 'password' }, 203 | ...testRequestOptions 204 | }) 205 | 206 | t.deepEqual(signUpResponse.statusCode, 500) 207 | t.deepEqual( 208 | signUpResponse.body.message, 209 | 'username is required' 210 | ) 211 | }) 212 | 213 | test('fails to sign up without valid username', async t => { 214 | const router = micro(microAuth) 215 | const apiUrl = await listen(router) 216 | 217 | const signUpEndpoint = `${apiUrl}/sign-up` 218 | const signUpResponse = await request.post({ 219 | url: signUpEndpoint, 220 | body: { username: 'mike@moof', password: 'password' }, 221 | ...testRequestOptions 222 | }) 223 | 224 | t.deepEqual(signUpResponse.statusCode, 500) 225 | t.deepEqual( 226 | signUpResponse.body.message, 227 | 'username is not valid' 228 | ) 229 | }) 230 | 231 | test('fails to sign up with weak password', async t => { 232 | const router = micro(microAuth) 233 | const apiUrl = await listen(router) 234 | 235 | const signUpEndpoint = `${apiUrl}/sign-up` 236 | const signUpResponse = await request.post({ 237 | url: signUpEndpoint, 238 | body: { username: 'mikebannister', password: 'pass' }, 239 | ...testRequestOptions 240 | }) 241 | 242 | t.deepEqual(signUpResponse.statusCode, 500) 243 | t.deepEqual( 244 | signUpResponse.body.message, 245 | 'password must be at least 6 characters' 246 | ) 247 | }) 248 | 249 | test('fails to sign up without a password', async t => { 250 | const router = micro(microAuth) 251 | const apiUrl = await listen(router) 252 | 253 | const signUpEndpoint = `${apiUrl}/sign-up` 254 | const signUpResponse = await request.post({ 255 | url: signUpEndpoint, 256 | body: { username: 'mikebannister' }, 257 | ...testRequestOptions 258 | }) 259 | 260 | t.deepEqual(signUpResponse.statusCode, 500) 261 | t.deepEqual( 262 | signUpResponse.body.message, 263 | 'password is required' 264 | ) 265 | }) 266 | 267 | test('fails to sign in without a username', async t => { 268 | const router = micro(microAuth) 269 | const apiUrl = await listen(router) 270 | 271 | const signUpEndpoint = `${apiUrl}/sign-in` 272 | const signUpResponse = await request.post({ 273 | url: signUpEndpoint, 274 | body: { password: 'password' }, 275 | ...testRequestOptions 276 | }) 277 | 278 | t.deepEqual(signUpResponse.statusCode, 500) 279 | t.deepEqual( 280 | signUpResponse.body.message, 281 | 'username is required' 282 | ) 283 | }) 284 | 285 | test('fails to sign in without a password', async t => { 286 | const router = micro(microAuth) 287 | const apiUrl = await listen(router) 288 | 289 | const signUpEndpoint = `${apiUrl}/sign-in` 290 | const signUpResponse = await request.post({ 291 | url: signUpEndpoint, 292 | body: { username: 'mikebannister' }, 293 | ...testRequestOptions 294 | }) 295 | 296 | t.deepEqual(signUpResponse.statusCode, 500) 297 | t.deepEqual( 298 | signUpResponse.body.message, 299 | 'password is required' 300 | ) 301 | }) 302 | --------------------------------------------------------------------------------