├── .gitignore ├── request.lua ├── openresty └── nginx │ ├── sites-enabled │ └── .gitignore │ ├── nginx.conf │ ├── sites-available │ └── oauth2.conf │ ├── env_vars.conf │ ├── endpoint_defaults.conf │ └── mime.types ├── vagrant ├── manifests │ ├── mongo.pp │ ├── site.pp │ ├── moonrocks.pp │ ├── lua.pp │ ├── oauth2.pp │ └── openresty.pp └── Vagrantfile ├── README.md ├── error ├── 400.lua ├── 403.lua ├── 409.lua ├── 401.lua ├── 404.lua ├── 405.lua └── 500.lua ├── util ├── math.lua ├── http.lua ├── httpAuthentication.lua └── token.lua ├── app.lua ├── lua-oauth2-server-0.1-0.rockspec ├── endpoint ├── token │ └── grant │ │ ├── password.lua │ │ ├── refresh_token.lua │ │ ├── implicit.lua │ │ └── authorization_code.lua ├── authorize.lua ├── client.lua └── token.lua ├── LICENSE ├── external └── authorizationStub.lua ├── context └── user.lua ├── spec ├── client_spec.lua └── token_spec.lua └── config.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .vagrant 2 | -------------------------------------------------------------------------------- /request.lua: -------------------------------------------------------------------------------- 1 | lusty:request() 2 | -------------------------------------------------------------------------------- /openresty/nginx/sites-enabled/.gitignore: -------------------------------------------------------------------------------- 1 | *.conf 2 | -------------------------------------------------------------------------------- /vagrant/manifests/mongo.pp: -------------------------------------------------------------------------------- 1 | package { "mongodb": 2 | ensure => "installed", 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | lua-oauth2-server 2 | ================= 3 | 4 | Lusty Lua implementation of an oauth2 server 5 | -------------------------------------------------------------------------------- /error/400.lua: -------------------------------------------------------------------------------- 1 | context.output = { 2 | message = "Bad Request.", 3 | status = 400 4 | } 5 | 6 | context.response.status = 400 7 | -------------------------------------------------------------------------------- /error/403.lua: -------------------------------------------------------------------------------- 1 | context.output = { 2 | message = "Forbidden.", 3 | status = 403 4 | } 5 | 6 | context.response.status = 403 7 | -------------------------------------------------------------------------------- /error/409.lua: -------------------------------------------------------------------------------- 1 | context.output = { 2 | message = "Conflict.", 3 | status = 409 4 | } 5 | 6 | context.response.status = 409 7 | -------------------------------------------------------------------------------- /error/401.lua: -------------------------------------------------------------------------------- 1 | context.output = { 2 | message = "You can't do that!", 3 | status = 401 4 | } 5 | 6 | context.response.status = 401 7 | -------------------------------------------------------------------------------- /error/404.lua: -------------------------------------------------------------------------------- 1 | context.output = { 2 | message = "Object not found.", 3 | status = 404 4 | } 5 | 6 | context.response.status = 404 7 | -------------------------------------------------------------------------------- /error/405.lua: -------------------------------------------------------------------------------- 1 | context.output = { 2 | message = "Method not allowed.", 3 | status = 405 4 | } 5 | 6 | context.response.status = 405 7 | -------------------------------------------------------------------------------- /vagrant/manifests/site.pp: -------------------------------------------------------------------------------- 1 | import 'mongo.pp' 2 | import 'lua.pp' 3 | import 'openresty.pp' 4 | import 'moonrocks.pp' 5 | import 'oauth2.pp' 6 | -------------------------------------------------------------------------------- /util/math.lua: -------------------------------------------------------------------------------- 1 | local function randomize () 2 | local fl = io.open("/dev/urandom"); 3 | local res = 0; 4 | for f = 1, 4 do res = res*256+(fl:read(1)):byte(1, 1); end; 5 | fl:close(); 6 | math.randomseed(res); 7 | end; 8 | randomize() 9 | -------------------------------------------------------------------------------- /openresty/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user root root; 2 | 3 | error_log /var/log/openresty/error.log info; 4 | 5 | worker_processes 1; 6 | events { 7 | worker_connections 256; 8 | } 9 | 10 | include /source/openresty/nginx/env_vars.conf; 11 | include /source/openresty/nginx/sites-enabled/*.conf; 12 | -------------------------------------------------------------------------------- /vagrant/manifests/moonrocks.pp: -------------------------------------------------------------------------------- 1 | file { "/etc/luarocks/config.lua": 2 | 3 | require => Exec["luarocks"], 4 | content => "rocks_servers = {\n [[http://rocks.moonscript.org]],\n [[https://raw.github.com/keplerproject/rocks/master]]\n}\n\nrocks_trees = {\n home..[[/.luarocks]],\n [[/usr/local]]\n}", 5 | } 6 | 7 | exec { "moonrocks": 8 | require => File["/etc/luarocks/config.lua"], 9 | command => "/bin/true", 10 | } 11 | -------------------------------------------------------------------------------- /app.lua: -------------------------------------------------------------------------------- 1 | local server = require 'lusty-nginx' --load my chosen server module 2 | local configure = require 'lusty-config'--load my chosen configurator 3 | local config = require 'config' --load config table 4 | 5 | lusty = require 'lusty'() --load and create lusty 6 | configure(lusty, config) --configure using config table 7 | lusty = server(lusty) --add the server wrapper, return 8 | -------------------------------------------------------------------------------- /vagrant/manifests/lua.pp: -------------------------------------------------------------------------------- 1 | package { 'luajit': 2 | ensure => 'installed', 3 | } 4 | 5 | package { 'luarocks': 6 | ensure => 'installed', 7 | } 8 | 9 | file { '/usr/bin/lua': 10 | require => [ 11 | Package["luarocks"], 12 | Package["luajit"] 13 | ], 14 | ensure => 'link', 15 | target => '/usr/bin/luajit', 16 | } 17 | 18 | package { 'lua-sec': 19 | ensure => 'installed', 20 | } 21 | 22 | exec { "luarocks": 23 | require => [ 24 | File["/usr/bin/lua"], 25 | Package['lua-sec'], 26 | ], 27 | command => "/bin/true", 28 | } 29 | -------------------------------------------------------------------------------- /openresty/nginx/sites-available/oauth2.conf: -------------------------------------------------------------------------------- 1 | 2 | http { 3 | lua_code_cache off; 4 | 5 | lua_package_path '/source/?.lua;;'; 6 | init_by_lua_file '/source/app.lua'; 7 | 8 | limit_req_zone $binary_remote_addr zone=one:10m rate=5r/s; 9 | limit_req_status 429; 10 | 11 | server { 12 | listen 80; 13 | 14 | include /source/openresty/nginx/endpoint_defaults.conf; 15 | 16 | location / { 17 | #limit_req zone=one burst=5 nodelay; 18 | rewrite ^/$ /index; 19 | default_type text/html; 20 | content_by_lua_file '/source/request.lua'; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /util/http.lua: -------------------------------------------------------------------------------- 1 | local http = require 'socket.http' 2 | local ltn12 = require 'ltn12' 3 | local json = require 'dkjson' 4 | 5 | local function request(u, method, headers, body) 6 | local t = {} 7 | local source = nil 8 | if body then 9 | local reqbody = json.encode(body) 10 | source = ltn12.source.string(reqbody) 11 | headers["content-length"] = string.len(reqbody) 12 | end 13 | local r, c, h = http.request{ 14 | url = u, 15 | method = method, 16 | headers = headers, 17 | sink = ltn12.sink.table(t), 18 | source = source, 19 | } 20 | local data = table.concat(t) 21 | return json.decode(data), c, h 22 | end 23 | 24 | return { 25 | request = request 26 | } 27 | -------------------------------------------------------------------------------- /error/500.lua: -------------------------------------------------------------------------------- 1 | context.log("Server Error: "..context.request.url, "error") 2 | if not context.output then context.output = {} end 3 | context.output.error = {} 4 | 5 | for _, error in pairs(context.error) do 6 | --Build stack trace 7 | local trace = {} 8 | local headerRemoved = false 9 | for line in error.trace:gmatch("[^\r\n]+") do 10 | if headerRemoved then 11 | line = line:gsub("^%s*", "") 12 | if string.sub(line, 1, 1) ~= '[' then 13 | trace[#trace+1]=line 14 | end 15 | else 16 | headerRemoved = true 17 | end 18 | end 19 | 20 | local message = error.message 21 | if type(message) == "table" then 22 | message = message[1] 23 | end 24 | 25 | context.output.error[#context.output.error+1] = { 26 | message = message, 27 | trace = trace, 28 | } 29 | end 30 | 31 | context.output.status = context.response.status 32 | -------------------------------------------------------------------------------- /lua-oauth2-server-0.1-0.rockspec: -------------------------------------------------------------------------------- 1 | package = "lua-oauth2-server" 2 | version = "0.1-0" 3 | source = { 4 | url = "", 5 | dir = "." 6 | } 7 | description = { 8 | summary = "", 9 | detailed = [[ 10 | ]] 11 | } 12 | dependencies = { 13 | "lua >= 5.1", 14 | "busted >= 1.5.0", 15 | "luacrypto >= 0.3.2-1", 16 | "lusty >= 0.2-0", 17 | "lusty-config >= 0.2-0", 18 | "lusty-json >= 0.3-0", 19 | "lusty-form >= 0.1-2", 20 | "lua-cjson >= 2.1.0-1", 21 | "lusty-log >= 0.1-0", 22 | "lusty-log-console >= 0.1-0", 23 | "lusty-nginx >= 0.1-0", 24 | "lusty-rewrite-param >= 0.4-0", 25 | "lusty-request-pattern >= 0.1-0", 26 | "lusty-request-file >= 0.3-0", 27 | "lusty-error-status >= 0.2-0", 28 | "lusty-store-mongo == 0.9-1", 29 | "basexx >= 0.1.0-1", 30 | "uuid >= 0.2-1", 31 | "jwt >= 0.1-1", 32 | } 33 | build = { 34 | type = "builtin", 35 | modules = { 36 | }, 37 | install = { 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /openresty/nginx/env_vars.conf: -------------------------------------------------------------------------------- 1 | env APP_OAUTH2_LOGIN_URL; 2 | env APP_OAUTH2_AUTHORIZATION_CLIENT_ID; 3 | env APP_OAUTH2_AUTHORIZATION_CLIENT_SECRET; 4 | 5 | env APP_OAUTH2_USER_DB_COLLECTION; 6 | env APP_OAUTH2_USER_DB_HOST; 7 | env APP_OAUTH2_USER_DB_PORT; 8 | env APP_OAUTH2_USER_DB_NAME; 9 | env APP_OAUTH2_USER_DB_TIMEOUT; 10 | 11 | env APP_OAUTH2_TOKEN_DB_COLLECTION; 12 | env APP_OAUTH2_TOKEN_DB_HOST; 13 | env APP_OAUTH2_TOKEN_DB_PORT; 14 | env APP_OAUTH2_TOKEN_DB_NAME; 15 | env APP_OAUTH2_TOKEN_DB_TIMEOUT; 16 | 17 | env APP_OAUTH2_AUTHORIZATION_DB_COLLECTION; 18 | env APP_OAUTH2_AUTHORIZATION_DB_HOST; 19 | env APP_OAUTH2_AUTHORIZATION_DB_PORT; 20 | env APP_OAUTH2_AUTHORIZATION_DB_NAME; 21 | env APP_OAUTH2_AUTHORIZATION_DB_TIMEOUT; 22 | 23 | env APP_OAUTH2_CLIENT_DB_COLLECTION; 24 | env APP_OAUTH2_CLIENT_DB_HOST; 25 | env APP_OAUTH2_CLIENT_DB_PORT; 26 | env APP_OAUTH2_CLIENT_DB_NAME; 27 | env APP_OAUTH2_CLIENT_DB_TIMEOUT; 28 | 29 | -------------------------------------------------------------------------------- /openresty/nginx/endpoint_defaults.conf: -------------------------------------------------------------------------------- 1 | resolver 8.8.4.4; 2 | 3 | open_file_cache max=200000 inactive=20s; 4 | open_file_cache_valid 30s; 5 | open_file_cache_min_uses 2; 6 | open_file_cache_errors on; 7 | 8 | access_log off; 9 | 10 | sendfile on; 11 | 12 | tcp_nopush on; 13 | tcp_nodelay on; 14 | 15 | keepalive_timeout 15; 16 | keepalive_requests 100000; 17 | reset_timedout_connection on; 18 | 19 | client_header_timeout 10; 20 | client_body_timeout 10; 21 | send_timeout 10; 22 | 23 | client_body_buffer_size 8K; 24 | client_header_buffer_size 1k; 25 | client_max_body_size 2m; 26 | large_client_header_buffers 2 1k; 27 | 28 | gzip on; 29 | gzip_min_length 10240; 30 | gzip_proxied expired no-cache no-store private auth; 31 | gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml; 32 | gzip_disable "MSIE [1-6]\."; 33 | 34 | location ~ /\. { 35 | deny all; 36 | access_log off; 37 | log_not_found off; 38 | } 39 | -------------------------------------------------------------------------------- /util/httpAuthentication.lua: -------------------------------------------------------------------------------- 1 | local basexx = require 'basexx' 2 | 3 | local methods = { 4 | basic = function(data) 5 | local clientInfo = basexx.from_base64(data) 6 | local parts = {} 7 | for part in clientInfo:gmatch("%w+") do parts[#parts+1] = part end 8 | 9 | local authentication = nil 10 | if #parts == 2 then 11 | authentication = { 12 | method = 'basic', 13 | client_id = parts[1], 14 | client_secret = parts[2], 15 | } 16 | end 17 | return authentication 18 | end, 19 | 20 | bearer = function(data) 21 | return { 22 | method= 'bearer', 23 | token = data, 24 | } 25 | end, 26 | } 27 | 28 | return function(authHeader) 29 | if authHeader then 30 | local parts = {} 31 | for part in authHeader:gmatch("%S+") do parts[#parts+1] = part end 32 | if #parts == 2 then 33 | local method = methods[parts[1]:lower()] 34 | return method and method(parts[2]) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /endpoint/token/grant/password.lua: -------------------------------------------------------------------------------- 1 | local Query = require 'lusty-store.query' 2 | local Token = require 'util.token' 3 | 4 | return function(client, context) 5 | 6 | local store = context.store.token 7 | local input = context.input 8 | 9 | if input.username and input.password then 10 | local user_id = context.user(input.username).login(input.password) 11 | if user_id then 12 | local token = store.get(Query().user_id.eq(user_id).client_id.eq(client.client_id).expires_in.gte(os.time()))[1] 13 | if not token then 14 | token = Token(context, client, user_id, type(input.scope)=="table" and input.scope or {input.scope}) 15 | store.post(token) 16 | end 17 | token._id = nil 18 | token.user_id = nil 19 | token.client_id = nil 20 | token.expires_in = token.expires_in - os.time() 21 | context.output = token 22 | context.response.status = 201 23 | else 24 | context.response.headers['WWW-Authenticate'] = 'Basic' 25 | context.response.status = 401 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /util/token.lua: -------------------------------------------------------------------------------- 1 | return function(context, client, user_id, scope) 2 | local uuid = context.global.uuid 3 | local jwt = context.global.jwt 4 | local alg = context.global.jws.algorithm 5 | 6 | local now = os.time() 7 | 8 | local access_claims = { 9 | exp = now + (client.access_token_expires_in or 3600), 10 | nbf = now, 11 | iat = now, 12 | jti = uuid(), 13 | sub = user_id, 14 | iss = client.client_id, 15 | } 16 | 17 | local refresh_claims = { 18 | exp = now + (client.refresh_token_expires_in or 2628000), 19 | nbf = now, 20 | iat = now, 21 | jti = uuid(), 22 | sub = user_id, 23 | iss = client.client_id, 24 | } 25 | 26 | local token = { 27 | token_type = "bearer", 28 | client_id = client.client_id, 29 | user_id = user_id, 30 | scope = scope, 31 | access_token = jwt.encode(access_claims, alg, client.client_secret), 32 | refresh_token = jwt.encode(refresh_claims, alg, client.client_secret), 33 | expires_in = now + (client.token_expires_in or 3600), 34 | } 35 | return token 36 | end 37 | -------------------------------------------------------------------------------- /endpoint/authorize.lua: -------------------------------------------------------------------------------- 1 | local loginUrl = os.getenv('APP_OAUTH2_LOGIN_URL') or "http://localhost/login" 2 | local Query = require 'lusty-store.query' 3 | local methods = { 4 | GET = function(self) 5 | 6 | local store = context.store.token 7 | local query = context.request.query 8 | 9 | if query.client_id and query.response_type then 10 | local client = context.store.client.get(Query().id.is(query.client_id))[1] 11 | if client then 12 | context.response.status = 302 13 | context.response.headers.location = loginUrl.."?"..context.request.queryString 14 | if not context.request.query.redirect_uri then 15 | context.response.headers.location = context.response.headers.location..'&redirect_uri='..client.redirect_uri 16 | end 17 | else 18 | context.response.status = 404 19 | end 20 | end 21 | end, 22 | } 23 | 24 | context.output = nil 25 | 26 | local method = methods[context.request.method] 27 | if method then 28 | context.response.status = 400 29 | return method(methods) 30 | else 31 | context.response.status = 405 32 | end 33 | -------------------------------------------------------------------------------- /endpoint/token/grant/refresh_token.lua: -------------------------------------------------------------------------------- 1 | local Query = require 'lusty-store.query' 2 | local Token = require 'util.token' 3 | 4 | return function(client, context) 5 | 6 | local store = context.store.token 7 | local input = context.input 8 | if input.refresh_token then 9 | local q = Query().refresh_token.eq(input.refresh_token).fields({_id=0}) 10 | local token = store.get(q)[1] 11 | if token then 12 | local data = context.global.jwt.decode(token.refresh_token) 13 | local client = context.store.client.get(Query().client_id.eq(data.iss))[1] 14 | if client and input.client_id == client.client_id then 15 | token = Token(context, client, data.sub, token.scope) 16 | store.put(q, token) 17 | token.client_id = nil 18 | token.user_id = nil 19 | token._id = nil 20 | token.expires_in = token.expires_in - os.time() 21 | context.response.status = 200 22 | context.output = token 23 | else 24 | context.response.status = 403 25 | end 26 | else 27 | context.response.status = 403 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Olivine Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /external/authorizationStub.lua: -------------------------------------------------------------------------------- 1 | local users = config.users 2 | local userById = {} 3 | local userByName = {} 4 | 5 | for _, user in pairs(users) do 6 | userById[user.id] = user 7 | userByName[user.name] = user 8 | end 9 | 10 | local userMethods = { 11 | login = function(context) 12 | local user = userByName[context.user.id] 13 | if user and user.password == context.password then 14 | return user.id 15 | end 16 | return false 17 | end 18 | } 19 | 20 | local clientMethods = { 21 | canAdd = function(context) 22 | local user = userById[context.user.id] 23 | if user and user.admin then 24 | return true 25 | end 26 | return false 27 | end, 28 | canEdit = function(context) 29 | local user = userById[context.user.id] 30 | if user and user.admin then 31 | return true 32 | end 33 | return false 34 | end 35 | } 36 | 37 | return { 38 | handler = function(context) 39 | if context.user.method then 40 | local method = userMethods[context.user.method] 41 | return method and method(context) 42 | elseif context.client.method then 43 | local method = clientMethods[context.client.method] 44 | return method and method(context) 45 | end 46 | end, 47 | } 48 | -------------------------------------------------------------------------------- /endpoint/token/grant/implicit.lua: -------------------------------------------------------------------------------- 1 | local Query = require 'lusty-store.query' 2 | local Token = require 'util.token' 3 | 4 | return function(client, context) 5 | 6 | local authentication = require 'util.httpAuthentication'(context.request.headers.authorization) 7 | local store = context.store.token 8 | local input = context.input 9 | 10 | if authentication and authentication.method == "bearer" then 11 | 12 | local token = store.get(Query().access_token.eq(authentication.token).fields({_id=0}))[1] 13 | local token_client = context.store.client.get(Query().client_id.eq(token.client_id).fields({_id=0}))[1] 14 | 15 | if token and token_client and token_client.trusted then 16 | 17 | local q = Query().user_id.eq(token.user_id).client_id.eq(client.client_id).expires_in.gte(os.time()) 18 | 19 | local token2 = store.get(q)[1] 20 | if not token2 then 21 | token2 = Token(context, client, token.user_id, type(input.scope) == "table" and input.scope or {input.scope}) 22 | token2.refresh_token = nil 23 | end 24 | 25 | store.put(q, token2) 26 | token2.expires_in = token2.expires_in - os.time() 27 | token2._id = nil 28 | token2.client_id = nil 29 | token2.user_id = nil 30 | token2.refresh_token = nil 31 | context.response.status = 201 32 | context.output = token2 33 | else 34 | context.response.status = 401 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /context/user.lua: -------------------------------------------------------------------------------- 1 | local clientMethods = { 2 | canEdit = function(state) 3 | state.context = context 4 | state.client.method = 'canEdit' 5 | return context.lusty:publish({'user', 'client', 'canEdit'}, state)[1] 6 | end, 7 | canAdd = function(state) 8 | state.context = context 9 | state.client.method = 'canAdd' 10 | return context.lusty:publish({'user', 'client', 'canAdd'}, state)[1] 11 | end 12 | } 13 | 14 | local methods = { 15 | client = function(state) 16 | return function(client_id) 17 | state.client = {id = client_id} 18 | local __meta = { 19 | __index = function(self, key) 20 | local method = clientMethods[key] 21 | return method and method(state) 22 | end, 23 | __newindex = function(self, key, value) 24 | error('Read Only') 25 | end 26 | } 27 | return setmetatable({}, __meta) 28 | end 29 | 30 | end, 31 | login = function(state) 32 | return function(password) 33 | state.context = context 34 | state.user.method = 'login' 35 | state.password = password 36 | return context.lusty:publish({'user', 'login'}, state)[1] 37 | end 38 | end 39 | } 40 | 41 | local user = function(id) 42 | local state = { user = {id = id}} 43 | local __meta = { 44 | __index = function(self, key) 45 | local method = methods[key] 46 | return method and method(state) 47 | end, 48 | __newindex = function(self, key, value) 49 | error('Read Only') 50 | end 51 | } 52 | return setmetatable({}, __meta) 53 | end 54 | 55 | context.user = user 56 | -------------------------------------------------------------------------------- /vagrant/manifests/oauth2.pp: -------------------------------------------------------------------------------- 1 | package { 'libexpat1-dev': 2 | ensure => 'installed', 3 | } 4 | 5 | exec { "oauth2_make_app": 6 | require => [ 7 | Exec["openresty"], 8 | Exec["moonrocks"] 9 | ], 10 | cwd => "/source", 11 | command => "/usr/bin/luarocks make", 12 | } 13 | 14 | exec { "oauth2_trusted_client": 15 | require => [ 16 | Package["mongodb"], 17 | Package["openssl"], 18 | ], 19 | command => "/usr/bin/mongo --eval \"db.client.insert({'client_id':'trusted', 'client_secret':'c907e5e522151738bdbc0f0d0d21beec6d4c123b414cc309aa18602702ab40d0d8b30baf2e40c877f8bbeb061a90137981db0de5a8a20b6fb8bda762f9ad1811', 'trusted':true, 'redirect_uri':'http://localhost/trusted_redirect'})\" localhost/oauth2", 20 | } 21 | 22 | exec { "oauth2_untrusted_client": 23 | require => [ 24 | Package["mongodb"], 25 | Package["openssl"], 26 | ], 27 | command => "/usr/bin/mongo --eval \"db.client.insert({'client_id':'untrusted', 'client_secret':'c907e5e522151738bdbc0f0d0d21beec6d4c123b414cc309aa18602702ab40d0d8b30baf2e40c877f8bbeb061a90137981db0de5a8a20b6fb8bda762f9ad1811', 'redirect_uri':'http://localhost/untrusted_redirect'})\" localhost/oauth2", 28 | } 29 | 30 | file { "/etc/openresty/nginx/": 31 | require => [ 32 | Exec["openresty"], 33 | ], 34 | force => true, 35 | ensure => "link", 36 | target => "/source/openresty/nginx", 37 | } 38 | 39 | 40 | file { "/etc/openresty/nginx/sites-enabled/oauth2.conf": 41 | ensure => "link", 42 | target => "/source/openresty/nginx/sites-available/oauth2.conf", 43 | } 44 | 45 | exec { "oauth2": 46 | require => [ 47 | File["/etc/openresty/nginx"], 48 | File["/etc/default/openresty"], 49 | File["/etc/openresty/nginx/sites-enabled/oauth2.conf"], 50 | Exec["oauth2_make_app"], 51 | ], 52 | command => "/usr/sbin/service openresty restart", 53 | } 54 | -------------------------------------------------------------------------------- /endpoint/client.lua: -------------------------------------------------------------------------------- 1 | local store = context.store.client 2 | local Query = require 'lusty-store.query' 3 | local input = context.input 4 | local authentication = require 'util.httpAuthentication'(context.request.headers.authorization) 5 | local json = require 'cjson' 6 | 7 | local methods = { 8 | POST = function(self) 9 | if authentication and authentication.method == "bearer" then 10 | local res = context.request.sub('/token/'..authentication.token) 11 | if res.status == 200 then 12 | local token = json.decode(res.body) 13 | local client = store.get(Query().client_id.eq(token.iss))[1] 14 | 15 | if client and client.trusted then 16 | if context.user(token.sub).client().canAdd then 17 | if input.redirect_uri then 18 | local original_secret = context.global.uuid() 19 | local client = { 20 | trusted = input.trusted, 21 | token_expires_in = input.token_expires_in, 22 | client_id = context.global.uuid(), 23 | client_secret = context.global.crypto.digest(context.global.hash.algorithm, original_secret), 24 | redirect_uri = context.input.redirect_uri, 25 | } 26 | store.post(client) 27 | client.client_secret = original_secret 28 | client._id = nil 29 | context.output = client 30 | context.response.status = 201 31 | end 32 | else 33 | context.response.status = 401 34 | end 35 | else 36 | context.response.status = 401 37 | end 38 | else 39 | context.response.status = 401 40 | end 41 | else 42 | context.response.status = 401 43 | end 44 | end, 45 | } 46 | context.output = nil 47 | local method = methods[context.request.method] 48 | if method then 49 | context.response.status = 400 50 | return method(methods) 51 | else 52 | context.response.status = 405 53 | end 54 | -------------------------------------------------------------------------------- /endpoint/token/grant/authorization_code.lua: -------------------------------------------------------------------------------- 1 | local Query = require 'lusty-store.query' 2 | local Token = require 'util.token' 3 | 4 | return function(client, context) 5 | 6 | local store = context.store.token 7 | local input = context.input 8 | 9 | if input.code then 10 | 11 | local q = Query().authorization_code.eq(input.code) 12 | local auth = context.store.authorization.get(q)[1] 13 | if auth and auth.client_id == client.client_id then 14 | local q2 = Query().user_id.eq(auth.user_id).client_id.eq(client.client_id).expires_in.gte(os.time()) 15 | local token = store.get(q2)[1] 16 | if not token then 17 | token = Token(context, auth.app, auth.user, auth.scope) 18 | end 19 | token.scope = auth.scope 20 | store.put(q2, token) 21 | token.expires_in = token.expires_in - os.time() 22 | token._id = nil 23 | context.response.status = 201 24 | context.output = token 25 | auth.authorization_code = nil 26 | context.store.authorization.put(q, auth) 27 | end 28 | 29 | elseif input.access_token then 30 | 31 | local token = store.get(Query().access_token.eq(input.access_token))[1] 32 | local token_client = context.store.client.get(Query().client_id.eq(token.client_id))[1] 33 | if token and token_client and token_client.trusted then 34 | 35 | local q = Query().user_id.eq(token.user_id).client_id.eq(client.client_id) 36 | local auth = context.store.authorization.get(q)[1] 37 | if not auth then 38 | auth = { 39 | user_id = token.user_id, 40 | client_id = client.client_id, 41 | scope = type(input.scope) == "table" and input.scope or {input.scope} 42 | } 43 | end 44 | auth.authorization_code = context.global.uuid() 45 | 46 | context.store.authorization.put(q, auth) 47 | 48 | context.response.status = 201 49 | 50 | context.output = { 51 | authorization_code = auth.authorization_code 52 | } 53 | else 54 | context.response.status = 403 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /vagrant/manifests/openresty.pp: -------------------------------------------------------------------------------- 1 | $openresty_version = "ngx_openresty-1.5.8.1" 2 | $openresty_source = "http://openresty.org/download/${openresty_version}.tar.gz" 3 | 4 | package { 'libreadline-dev': 5 | ensure => 'installed', 6 | } 7 | package { 'libncurses5-dev': 8 | ensure => 'installed', 9 | } 10 | package { 'libpcre3-dev': 11 | ensure => 'installed', 12 | } 13 | package { 'libssl-dev': 14 | ensure => 'installed', 15 | } 16 | package { 'perl': 17 | ensure => 'installed', 18 | } 19 | package { 'make': 20 | ensure => 'installed', 21 | } 22 | package { 'openssl': 23 | ensure => 'installed', 24 | } 25 | 26 | file { "/var/log/openresty": 27 | ensure => "directory", 28 | } 29 | 30 | file { "/etc/default/openresty": 31 | ensure => 'present', 32 | content => "", 33 | } 34 | 35 | file { "/var/log/openresty/error.log": 36 | require => [ 37 | File["/var/log/openresty"], 38 | ], 39 | ensure => 'present', 40 | content => "", 41 | } 42 | 43 | exec { "openresty_download_source": 44 | command => "/usr/bin/wget -O /tmp/openresty.tar.gz ${openresty_source}", 45 | } 46 | 47 | exec { "openresty_extract_source": 48 | require => Exec["openresty_download_source"], 49 | command => "/bin/tar xzvf /tmp/openresty.tar.gz", 50 | cwd => "/tmp/", 51 | } 52 | 53 | exec { "openresty_configure_source": 54 | require => [ Exec["openresty_extract_source"], 55 | Package["libreadline-dev"], 56 | Package["libncurses5-dev"], 57 | Package["libpcre3-dev"], 58 | Package["libssl-dev"], 59 | Package["perl"], 60 | Package["make"], 61 | Package["openssl"], 62 | ], 63 | cwd => "/tmp/${openresty_version}/", 64 | command => "/tmp/${openresty_version}/configure --with-luajit --with-http_ssl_module --prefix=/var/lib/openresty --conf-path=/etc/openresty/nginx/nginx.conf", 65 | } 66 | 67 | exec { "openresty_make_source": 68 | cwd => "/tmp/${openresty_version}/", 69 | require => Exec["openresty_configure_source"], 70 | command => "/usr/bin/make", 71 | } 72 | 73 | exec { "openresty_make_install_source": 74 | cwd => "/tmp/${openresty_version}/", 75 | require => Exec["openresty_make_source"], 76 | command => "/usr/bin/make install", 77 | } 78 | 79 | exec { "openresty": 80 | command => "/bin/true", 81 | require => [ Exec["openresty_make_install_source"], 82 | File["/etc/init.d/openresty"], 83 | File["/etc/init/openresty.conf"], 84 | File["/var/log/openresty/error.log"] 85 | ], 86 | } 87 | 88 | file { '/etc/init.d/openresty': 89 | ensure => 'link', 90 | target => '/lib/init/upstart-job', 91 | } 92 | 93 | file { '/etc/init/openresty.conf': 94 | content => "description \"Openresty\"\nstart on filesystem and net-device-up IFACE!=lo\nstop on runlevel [!2345]\nenv DAEMON=/var/lib/openresty/nginx/sbin/nginx\nenv PID=/var/lib/openresty/nginx/logs/nginx.pid\nrespawn\npre-start script\n \$DAEMON -s stop 2> /dev/null || true\n if [ -f /etc/default/openresty ]; then . /etc/default/openresty; fi\n \$DAEMON -t > /dev/null\n \$DAEMON\nend script\nscript\n sleepWhileAppIsUp(){\n while pidof \$1 >/dev/null; do\n sleep 1\n done\n}\nsleepWhileAppIsUp \$DAEMON\nend script\npost-stop script\n if pidof > /dev/null \$DAEMON;\n then\n \$DAEMON -s stop\n fi\nend script", 95 | } 96 | -------------------------------------------------------------------------------- /openresty/nginx/mime.types: -------------------------------------------------------------------------------- 1 | 2 | types { 3 | text/html html htm shtml; 4 | text/css css; 5 | text/xml xml; 6 | image/gif gif; 7 | image/jpeg jpeg jpg; 8 | application/x-javascript js; 9 | application/atom+xml atom; 10 | application/rss+xml rss; 11 | 12 | text/mathml mml; 13 | text/plain txt; 14 | text/vnd.sun.j2me.app-descriptor jad; 15 | text/vnd.wap.wml wml; 16 | text/x-component htc; 17 | 18 | image/png png; 19 | image/tiff tif tiff; 20 | image/vnd.wap.wbmp wbmp; 21 | image/x-icon ico; 22 | image/x-jng jng; 23 | image/x-ms-bmp bmp; 24 | image/svg+xml svg svgz; 25 | image/webp webp; 26 | 27 | application/java-archive jar war ear; 28 | application/mac-binhex40 hqx; 29 | application/msword doc; 30 | application/pdf pdf; 31 | application/postscript ps eps ai; 32 | application/rtf rtf; 33 | application/vnd.ms-excel xls; 34 | application/vnd.ms-powerpoint ppt; 35 | application/vnd.wap.wmlc wmlc; 36 | application/vnd.google-earth.kml+xml kml; 37 | application/vnd.google-earth.kmz kmz; 38 | application/x-7z-compressed 7z; 39 | application/x-cocoa cco; 40 | application/x-java-archive-diff jardiff; 41 | application/x-java-jnlp-file jnlp; 42 | application/x-makeself run; 43 | application/x-perl pl pm; 44 | application/x-pilot prc pdb; 45 | application/x-rar-compressed rar; 46 | application/x-redhat-package-manager rpm; 47 | application/x-sea sea; 48 | application/x-shockwave-flash swf; 49 | application/x-stuffit sit; 50 | application/x-tcl tcl tk; 51 | application/x-x509-ca-cert der pem crt; 52 | application/x-xpinstall xpi; 53 | application/xhtml+xml xhtml; 54 | application/zip zip; 55 | 56 | application/octet-stream bin exe dll; 57 | application/octet-stream deb; 58 | application/octet-stream dmg; 59 | application/octet-stream eot; 60 | application/octet-stream iso img; 61 | application/octet-stream msi msp msm; 62 | 63 | audio/midi mid midi kar; 64 | audio/mpeg mp3; 65 | audio/ogg ogg; 66 | audio/x-m4a m4a; 67 | audio/x-realaudio ra; 68 | 69 | video/3gpp 3gpp 3gp; 70 | video/mp4 mp4; 71 | video/mpeg mpeg mpg; 72 | video/quicktime mov; 73 | video/webm webm; 74 | video/x-flv flv; 75 | video/x-m4v m4v; 76 | video/x-mng mng; 77 | video/x-ms-asf asx asf; 78 | video/x-ms-wmv wmv; 79 | video/x-msvideo avi; 80 | } 81 | -------------------------------------------------------------------------------- /spec/client_spec.lua: -------------------------------------------------------------------------------- 1 | describe("Client Endpoint Specification", function() 2 | local http = require 'util.http' 3 | local json = require 'dkjson' 4 | local admin_token_trusted 5 | local admin_token_untrusted 6 | local user_token 7 | local clients = {} 8 | 9 | setup(function() 10 | local res, code = http.request( 11 | 'http://localhost/token', 12 | 'POST', 13 | { 14 | ['Content-Type'] = "application/json", 15 | }, 16 | { 17 | grant_type = "password", 18 | client_id = "trusted", 19 | client_secret = "oauth2", 20 | username = "admin", 21 | password = "admin" 22 | } 23 | ) 24 | if code ~= 201 then error(json.encode(res)) end 25 | admin_token_trusted = res.access_token 26 | local res, code = http.request( 27 | 'http://localhost/token', 28 | 'POST', 29 | { 30 | ['Content-Type'] = "application/json", 31 | ['Authorization'] = "Bearer "..admin_token_trusted, 32 | }, 33 | { 34 | grant_type = "implicit", 35 | client_id = "untrusted", 36 | username = "admin", 37 | password = "admin" 38 | } 39 | ) 40 | if code ~= 201 then error(json.encode(res)) end 41 | admin_token_untrusted = res.access_token 42 | local res = http.request( 43 | 'http://localhost/token', 44 | 'POST', 45 | { 46 | ['Content-Type'] = "application/json", 47 | }, 48 | { 49 | grant_type = "password", 50 | client_id = "trusted", 51 | client_secret = "oauth2", 52 | username = "test", 53 | password = "test" 54 | } 55 | ) 56 | user_token = res.access_token 57 | end) 58 | 59 | teardown(function() 60 | http.request('http://localhost/token/'..admin_token_untrusted, 'DELETE') 61 | http.request('http://localhost/token/'..admin_token_trusted, 'DELETE') 62 | http.request('http://localhost/token/'..user_token, 'DELETE') 63 | end) 64 | 65 | it("tests client creation by admin user", function() 66 | local headers = { ["Content-Type"] = "application/json", Authorization = "Bearer "..admin_token_trusted } 67 | local body = { 68 | redirect_uri = "http://localhost/" 69 | } 70 | local client, code = http.request('http://localhost/client', 'POST', headers, body) 71 | assert(code == 201) 72 | assert(client.client_id) 73 | assert(client.client_secret) 74 | assert(client.redirect_uri) 75 | end) 76 | 77 | it("ensures non admin user cannot create clients", function() 78 | local headers = { ["Content-Type"] = "application/json", Authorization = "Bearer "..user_token } 79 | local body = { 80 | redirect_uri = "http://localhost/" 81 | } 82 | local client = http.request('http://localhost/client', 'POST', headers, body) 83 | assert.Equal(401, client.status) 84 | end) 85 | 86 | it("ensures invalid token cannot create clients", function() 87 | local headers = { ["Content-Type"] = "application/json", Authorization = "Bearer ".."sdasf" } 88 | local body = { 89 | redirect_uri = "http://localhost/" 90 | } 91 | local client = http.request('http://localhost/client', 'POST', headers, body) 92 | assert.Equal(401, client.status) 93 | end) 94 | 95 | it("ensures token from untrusted client cannot create new clients", function() 96 | local headers = { ["Content-Type"] = "application/json", Authorization = "Bearer "..admin_token_untrusted } 97 | local body = { 98 | redirect_uri = "http://localhost/" 99 | } 100 | local client = http.request('http://localhost/client', 'POST', headers, body) 101 | assert.Equal(401, client.status) 102 | end) 103 | end) 104 | -------------------------------------------------------------------------------- /endpoint/token.lua: -------------------------------------------------------------------------------- 1 | local basexx = require 'basexx' 2 | local store = context.store.token 3 | local Query = require 'lusty-store.query' 4 | local authentication = require 'util.httpAuthentication'(context.request.headers.authorization) 5 | 6 | local grant = context.global.grant 7 | 8 | context.response.headers['Cache-Control'] = "no-store" 9 | context.response.headers['Pragma'] = "no-cache" 10 | 11 | local methods = { 12 | --[[ 13 | validate token 14 | ]] 15 | GET = function(self) 16 | local token = token 17 | if token then 18 | local jwt = context.global.jwt 19 | local data = jwt.decode(token) 20 | if data then 21 | local client = context.store.client.get(Query().client_id.eq(data.iss))[1] 22 | if client then 23 | data = jwt.decode(token, client.client_secret) 24 | if data then 25 | if data.exp > os.time() then 26 | local token = store.get(Query().access_token.eq(token))[1] 27 | if token then 28 | context.response.status = 200 29 | context.output = data 30 | else 31 | context.response.status = 404 32 | end 33 | else 34 | context.response.status = 404 35 | end 36 | end 37 | end 38 | else 39 | context.response.status = 404 40 | end 41 | end 42 | end, 43 | 44 | --[[ 45 | create token 46 | ]] 47 | POST = function(self) 48 | local input = context.input 49 | if input and input.grant_type and (grant.secret[input.grant_type] or grant.client[input.grant_type] or grant.trusted[input.grant_type]) then 50 | local client_id, client_secret 51 | if authentication and authentication.method == 'basic' and authentication.client_id and authentication.client_secret then 52 | client_id = authentication.client_id 53 | client_secret = authentication.client_secret 54 | else 55 | client_id = input.client_id 56 | client_secret = input.client_secret 57 | end 58 | 59 | if client_id then 60 | 61 | if not client_secret then 62 | local client = context.store.client.get(Query().client_id.is(client_id))[1] 63 | 64 | if client then 65 | --execute client id secured grant 66 | local func = grant.client[input.grant_type] 67 | if func then func(client, context) end 68 | end 69 | 70 | else 71 | 72 | --lookup client 73 | local client = context.store.client.get(Query(). 74 | client_id.eq(client_id). 75 | client_secret.eq( 76 | context.global.crypto.digest(context.global.hash.algorithm, client_secret) 77 | ) 78 | )[1] 79 | 80 | if client then 81 | 82 | local func 83 | if client.trusted then 84 | func = grant.trusted[input.grant_type] 85 | end 86 | --execute both secret and client id secured grants 87 | func = func or grant.client[input.grant_type] or grant.secret[input.grant_type] 88 | if func then func(client, context) end 89 | 90 | else 91 | 92 | context.response.status = 401 93 | context.response.headers['WWW-Authenticate'] = 'Basic' 94 | end 95 | end 96 | end 97 | end 98 | end, 99 | 100 | --[[ 101 | revoke token 102 | ]] 103 | DELETE = function(self) 104 | local q = Query().access_token.eq(token).fields({_id=0,refresh_token=0}) 105 | local token = store.get(q)[1] 106 | if token then 107 | store.delete(q) 108 | token.expires_in = 0 109 | context.response.status=200 110 | context.output = token 111 | else 112 | context.response.status = 404 113 | end 114 | end, 115 | } 116 | 117 | local method = methods[context.request.method] 118 | if method then 119 | context.response.status = 400 120 | return method(methods) 121 | else 122 | context.response.status = 405 123 | end 124 | -------------------------------------------------------------------------------- /vagrant/Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! 5 | VAGRANTFILE_API_VERSION = "2" 6 | 7 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 8 | # All Vagrant configuration is done here. The most common configuration 9 | # options are documented and commented below. For a complete reference, 10 | # please see the online documentation at vagrantup.com. 11 | 12 | # Every Vagrant virtual environment requires a box to build off of. 13 | config.vm.box = "raring64" 14 | config.vm.box_url = "http://goo.gl/Y4aRr" 15 | 16 | # The url from where the 'config.vm.box' box will be fetched if it 17 | # doesn't already exist on the user's system. 18 | # config.vm.box_url = "http://domain.com/path/to/above.box" 19 | 20 | # Create a forwarded port mapping which allows access to a specific port 21 | # within the machine from a port on the host machine. In the example below, 22 | # accessing "localhost:8080" will access port 80 on the guest machine. 23 | config.vm.network :forwarded_port, guest: 80, host: 80 24 | 25 | # Create a private network, which allows host-only access to the machine 26 | # using a specific IP. 27 | # config.vm.network :private_network, ip: "192.168.33.10" 28 | 29 | # Create a public network, which generally matched to bridged network. 30 | # Bridged networks make the machine appear as another physical device on 31 | # your network. 32 | # config.vm.network :public_network 33 | 34 | # If true, then any SSH connections made will enable agent forwarding. 35 | # Default value: false 36 | config.ssh.forward_agent = true 37 | 38 | # Share an additional folder to the guest VM. The first argument is 39 | # the path on the host to the actual folder. The second argument is 40 | # the path on the guest to mount the folder. And the optional third 41 | # argument is a set of non-required options. 42 | config.vm.synced_folder "../", "/source" 43 | # Provider-specific configuration so you can fine-tune various 44 | # backing providers for Vagrant. These expose provider-specific options. 45 | # Example for VirtualBox: 46 | # 47 | # config.vm.provider :virtualbox do |vb| 48 | # # Don't boot with headless mode 49 | # vb.gui = true 50 | # 51 | # # Use VBoxManage to customize the VM. For example to change memory: 52 | # vb.customize ["modifyvm", :id, "--memory", "1024"] 53 | # end 54 | # 55 | # View the documentation for the provider you're using for more 56 | # information on available options. 57 | 58 | # Enable provisioning with Puppet stand alone. Puppet manifests 59 | # are contained in a directory path relative to this Vagrantfile. 60 | # You will need to create the manifests directory and a manifest in 61 | # the file base.pp in the manifests_path directory. 62 | # 63 | # An example Puppet manifest to provision the message of the day: 64 | # 65 | # # group { "puppet": 66 | # # ensure => "present", 67 | # # } 68 | # # 69 | # # File { owner => 0, group => 0, mode => 0644 } 70 | # # 71 | # # file { '/etc/motd': 72 | # # content => "Welcome to your Vagrant-built virtual machine! 73 | # # Managed by Puppet.\n" 74 | # # } 75 | # 76 | config.vm.provision :puppet do |puppet| 77 | puppet.manifests_path = "manifests" 78 | puppet.manifest_file = "site.pp" 79 | end 80 | 81 | # Enable provisioning with chef solo, specifying a cookbooks path, roles 82 | # path, and data_bags path (all relative to this Vagrantfile), and adding 83 | # some recipes and/or roles. 84 | # 85 | # config.vm.provision :chef_solo do |chef| 86 | # chef.cookbooks_path = "../my-recipes/cookbooks" 87 | # chef.roles_path = "../my-recipes/roles" 88 | # chef.data_bags_path = "../my-recipes/data_bags" 89 | # chef.add_recipe "mysql" 90 | # chef.add_role "web" 91 | # 92 | # # You may also specify custom JSON attributes: 93 | # chef.json = { :mysql_password => "foo" } 94 | # end 95 | 96 | # Enable provisioning with chef server, specifying the chef server URL, 97 | # and the path to the validation key (relative to this Vagrantfile). 98 | # 99 | # The Opscode Platform uses HTTPS. Substitute your organization for 100 | # ORGNAME in the URL and validation key. 101 | # 102 | # If you have your own Chef Server, use the appropriate URL, which may be 103 | # HTTP instead of HTTPS depending on your configuration. Also change the 104 | # validation key to validation.pem. 105 | # 106 | # config.vm.provision :chef_client do |chef| 107 | # chef.chef_server_url = "https://api.opscode.com/organizations/ORGNAME" 108 | # chef.validation_key_path = "ORGNAME-validator.pem" 109 | # end 110 | # 111 | # If you're using the Opscode platform, your validator client is 112 | # ORGNAME-validator, replacing ORGNAME with your organization name. 113 | # 114 | # If you have your own Chef Server, the default validation client name is 115 | # chef-validator, unless you changed the configuration. 116 | # 117 | # chef.validation_client_name = "ORGNAME-validator" 118 | end 119 | -------------------------------------------------------------------------------- /config.lua: -------------------------------------------------------------------------------- 1 | local json = require 'cjson' 2 | local env = os.getenv 3 | local crypto = require 'crypto' 4 | local jwt = require 'jwt' 5 | local file = 'lusty-request-file.request.file' 6 | local pattern = 'lusty-request-pattern.request.pattern' 7 | 8 | --set up good randomization 9 | require 'util.math' 10 | 11 | local uuid = require 'uuid' 12 | uuid.randomseed(math.random(9223372036854775807)) 13 | 14 | local users = { 15 | { id = 1, name = "admin", password = "admin", admin = true}, 16 | { id = 2, name = "test", password = "test"}, 17 | } 18 | 19 | --grant types, and in what situations they may be used 20 | --secret may only be used when a valid client_id and secret are present 21 | --trusted is when secret requirements are met and the client is a trusted one 22 | --client is when a client_id is present 23 | local grant = { 24 | secret = { 25 | refresh_token = require 'endpoint.token.grant.refresh_token', 26 | }, 27 | client = { 28 | implicit = require 'endpoint.token.grant.implicit', 29 | authorization_code= require 'endpoint.token.grant.authorization_code', 30 | }, 31 | trusted = { 32 | password = require 'endpoint.token.grant.password', 33 | } 34 | } 35 | 36 | local global = { 37 | grant = grant, 38 | json = json, 39 | uuid = uuid, 40 | jwt = jwt, 41 | crypto= crypto, 42 | jws = { 43 | algorithm = env("APP_OAUTH2_JWS_ALGORITHM") or "HS256", 44 | }, 45 | hash = { 46 | algorithm = env("APP_OAUTH2_HASH_ALGORITHM") or "sha512", 47 | }, 48 | token = { 49 | expires = tonumber(env("APP_OAUTH2_TOKEN_EXPIRES")) or 3600 50 | }, 51 | } 52 | 53 | return { 54 | global = global, 55 | 56 | subscribers = { 57 | ['rewrite'] = { 58 | ['lusty-rewrite-param.rewrite.header'] = { header = "accept", param = "_accept" }, 59 | ['lusty-rewrite-param.rewrite.header'] = { header = "content-type", param = "_content-type" }, 60 | ['lusty-rewrite-param.rewrite.header'] = { header= "range", param = "_range" }, 61 | ['lusty-rewrite-param.rewrite.header'] = { header= "authorization", param = "_authorization" }, 62 | ['lusty-rewrite-param.rewrite.method'] = { param = "_method" }, 63 | ['lusty-rewrite-param.rewrite.body'] = { param = "_body" }, 64 | }, 65 | 66 | ['input'] = { 67 | -- decode json input if it exists in the body data. 68 | -- you can provide -- options to the handler as a table. 69 | -- in this case, we are passing in a json encoding/decoding function. 70 | ['lusty-form.input.form'] = {}, 71 | ['lusty-json.input.json'] = { json = global.json } 72 | }, 73 | 74 | -- / is routed to /index in nginx 75 | ['request'] = { [pattern] = { 76 | patterns = { 77 | { ['token[/]?{token}'] = 'endpoint.token' }, 78 | { ['authorize'] = 'endpoint.authorize' }, 79 | { ['client[/]?{clientId}'] = 'endpoint.client' }, 80 | { ['user[/]?{userId}'] = 'endpoint.user' }, 81 | } 82 | }}, 83 | 84 | ['request:400'] = {[file] = 'error.400'}, 85 | ['request:401'] = {[file] = 'error.401'}, 86 | ['request:403'] = {[file] = 'error.403'}, 87 | ['request:404'] = {[file] = 'error.404'}, 88 | ['request:405'] = {[file] = 'error.405'}, 89 | ['request:409'] = {[file] = 'error.409'}, 90 | ['request:500'] = {[file] = 'error.500'}, 91 | 92 | ['error'] = { 93 | ['lusty-error-status.error.status'] = { 94 | prefix = {{'input'}}, 95 | status = { 96 | [400] = {{'request:400'}}, 97 | [401] = {{'request:401'}}, 98 | [403] = {{'request:403'}}, 99 | [404] = {{'request:404'}}, 100 | [405] = {{'request:405'}}, 101 | [409] = {{'request:409'}}, 102 | [500] = {{'request:500'}}, 103 | }, 104 | suffix = {{'output'}} 105 | } 106 | }, 107 | 108 | -- capture json requests to output handler data as json 109 | ['output'] = { 110 | ['lusty-json.output.json'] = { json = global.json, default = true } 111 | }, 112 | 113 | ['user'] = { 114 | ['external.authorizationStub'] = { 115 | users = users, 116 | } 117 | }, 118 | 119 | ['store:token'] = { 120 | ['lusty-store-mongo.store.mongo'] = { 121 | collection = env('APP_OAUTH2_TOKEN_DB_COLLECTION') or 'token', 122 | host = env('APP_OAUTH2_TOKEN_DB_HOST') or '127.0.0.1', 123 | port = env('APP_OAUTH2_TOKEN_DB_PORT') or 27017, 124 | database = env('APP_OAUTH2_TOKEN_DB_NAME') or 'oauth2', 125 | timeout = env('APP_OAUTH2_TOKEN_DB_TIMEOUT') or 5000 126 | } 127 | }, 128 | 129 | ['store:authorization'] = { 130 | ['lusty-store-mongo.store.mongo'] = { 131 | collection = env('APP_OAUTH2_AUTHORIZATION_DB_COLLECTION') or 'authorization', 132 | host = env('APP_OAUTH2_AUTHORIZATION_DB_HOST') or '127.0.0.1', 133 | port = env('APP_OAUTH2_AUTHORIZATION_DB_PORT') or 27017, 134 | database = env('APP_OAUTH2_AUTHORIZATION_DB_NAME') or 'oauth2', 135 | timeout = env('APP_OAUTH2_AUTHORIZATION_DB_TIMEOUT') or 5000 136 | } 137 | }, 138 | 139 | ['store:client'] = { 140 | ['lusty-store-mongo.store.mongo'] = { 141 | collection = env('APP_OAUTH2_CLIENT_DB_COLLECTION') or 'client', 142 | host = env('APP_OAUTH2_CLIENT_DB_HOST') or '127.0.0.1', 143 | port = env('APP_OAUTH2_CLIENT_DB_PORT') or 27017, 144 | database = env('APP_OAUTH2_CLIENT_DB_NAME') or 'oauth2', 145 | timeout = env('APP_OAUTH2_CLIENT_DB_TIMEOUT') or 5000 146 | } 147 | }, 148 | 149 | -- log events should write to the console 150 | -- log events should also go up to nginx 151 | ['log'] = { 152 | 'lusty-log-console.log.console' 153 | } 154 | }, 155 | 156 | -- as requests come in, fire these events in order (corresponding to 157 | -- subscribers above) 158 | publishers = { 159 | {'rewrite'}, 160 | {'input'}, 161 | {'request'}, 162 | {'error'}, 163 | {'output'}, 164 | }, 165 | 166 | -- bind context methods to the context object that is passed around, so you 167 | -- can use things like context.log and context.store from within your handler 168 | context = { 169 | ['lusty-log.context.log'] = { level = 'debug' }, 170 | ['lusty-store.context.store'] = {}, 171 | ['context.user'] = {}, 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /spec/token_spec.lua: -------------------------------------------------------------------------------- 1 | describe("Token Endpoint Specification", function() 2 | local http = require 'util.http' 3 | local json = require 'dkjson' 4 | local jwt = require 'jwt' 5 | 6 | it("tests token creation grant type password", function() 7 | local token, code = http.request( 8 | 'http://localhost/token', 9 | 'POST', 10 | { 11 | ['Content-Type'] = "application/json", 12 | }, 13 | { 14 | grant_type = "password", 15 | client_id = "trusted", 16 | client_secret = "oauth2", 17 | username = "test", 18 | password = "test" 19 | } 20 | ) 21 | assert(code == 201) 22 | assert(token.access_token) 23 | assert(token.refresh_token) 24 | local data = jwt.decode(token.access_token) 25 | assert(data.sub == 2) 26 | assert(data.iss == "trusted") 27 | local data = jwt.decode(token.refresh_token) 28 | assert(data.sub == 2) 29 | assert(data.iss == "trusted") 30 | end) 31 | 32 | it("ensures token creation with grant type password can not be done from an untrusted client", function() 33 | local token, code = http.request( 34 | 'http://localhost/token', 35 | 'POST', 36 | { 37 | ['Content-Type'] = "application/json", 38 | }, 39 | { 40 | grant_type = "password", 41 | client_id = "untrusted", 42 | client_secret = "oauth2", 43 | username = "test", 44 | password = "test" 45 | } 46 | ) 47 | assert(code == 400) 48 | end) 49 | 50 | it("tests grant type implicit", function() 51 | local trusted_token, code = http.request( 52 | 'http://localhost/token', 53 | 'POST', 54 | { 55 | ['Content-Type'] = "application/json", 56 | }, 57 | { 58 | grant_type = "password", 59 | client_id = "trusted", 60 | client_secret = "oauth2", 61 | username = "test", 62 | password = "test" 63 | } 64 | ) 65 | assert(code == 201) 66 | local token, code = http.request( 67 | 'http://localhost/token', 68 | 'POST', 69 | { 70 | ['Content-Type'] = "application/json", 71 | ['Authorization'] = "Bearer "..trusted_token.access_token, 72 | }, 73 | { 74 | grant_type = "implicit", 75 | client_id = "untrusted", 76 | } 77 | ) 78 | if code ~= 201 then error(json.encode(token)) end 79 | assert.equal(code, 201) 80 | assert(token.access_token) 81 | assert(token.refresh_token == nil) 82 | assert(token.expires_in) 83 | local data = jwt.decode(token.access_token) 84 | assert(data.sub == 2) 85 | assert(data.iss == "untrusted") 86 | end) 87 | 88 | it("ensures grant type implicit cannot be done using an untrusted token", function() 89 | local trusted_token, code = http.request( 90 | 'http://localhost/token', 91 | 'POST', 92 | { 93 | ['Content-Type'] = "application/json", 94 | }, 95 | { 96 | grant_type = "password", 97 | client_id = "trusted", 98 | client_secret = "oauth2", 99 | username = "test", 100 | password = "test" 101 | } 102 | ) 103 | assert(code == 201) 104 | local untrusted_token, code = http.request( 105 | 'http://localhost/token', 106 | 'POST', 107 | { 108 | ['Content-Type'] = "application/json", 109 | ['Authorization'] = "Bearer "..trusted_token.access_token, 110 | }, 111 | { 112 | grant_type = "implicit", 113 | client_id = "untrusted", 114 | } 115 | ) 116 | assert(code == 201) 117 | local token, code = http.request( 118 | 'http://localhost/token', 119 | 'POST', 120 | { 121 | ['Content-Type'] = "application/json", 122 | ['Authorization'] = "Bearer "..untrusted_token.access_token, 123 | }, 124 | { 125 | grant_type = "implicit", 126 | client_id = "untrusted", 127 | } 128 | ) 129 | assert.equal(code, 401) 130 | end) 131 | 132 | it("tests refresh token", function() 133 | local original_token, code = http.request( 134 | 'http://localhost/token', 135 | 'POST', 136 | { 137 | ['Content-Type'] = "application/json", 138 | }, 139 | { 140 | grant_type = "password", 141 | client_id = "trusted", 142 | client_secret = "oauth2", 143 | username = "test", 144 | password = "test" 145 | } 146 | ) 147 | assert(code == 201) 148 | local token, code = http.request( 149 | 'http://localhost/token', 150 | 'POST', 151 | { 152 | ['Content-Type'] = "application/json", 153 | }, 154 | { 155 | grant_type = "refresh_token", 156 | client_id = "trusted", 157 | client_secret = "oauth2", 158 | refresh_token = original_token.refresh_token 159 | } 160 | ) 161 | if code ~= 200 then error(json.encode(token)) end 162 | assert.equal(code, 200) 163 | assert(token.access_token) 164 | assert(token.access_token ~= original_token.access_token) 165 | assert(token.refresh_token) 166 | assert(token.refresh_token ~= original_token.refresh_token) 167 | assert(token.expires_in == 3600) 168 | local data = jwt.decode(token.access_token) 169 | assert(data.sub == 2) 170 | assert(data.iss == "trusted") 171 | local data = jwt.decode(token.refresh_token) 172 | assert(data.sub == 2) 173 | assert(data.iss == "trusted") 174 | local old_token, code = http.request( 175 | 'http://localhost/token/'..original_token.access_token, 176 | 'GET', 177 | { 178 | ['Content-Type'] = "application/json", 179 | } 180 | ) 181 | assert(code == 404) 182 | end) 183 | 184 | it("ensures refresh token from an invalid client fails", function() 185 | local original_token, code = http.request( 186 | 'http://localhost/token', 187 | 'POST', 188 | { 189 | ['Content-Type'] = "application/json", 190 | }, 191 | { 192 | grant_type = "password", 193 | client_id = "trusted", 194 | client_secret = "oauth2", 195 | username = "test", 196 | password = "test" 197 | } 198 | ) 199 | assert(code == 201) 200 | local token, code = http.request( 201 | 'http://localhost/token', 202 | 'POST', 203 | { 204 | ['Content-Type'] = "application/json", 205 | }, 206 | { 207 | grant_type = "refresh_token", 208 | client_id = "untrusted", 209 | client_secret = "oauth2", 210 | refresh_token = original_token.refresh_token 211 | } 212 | ) 213 | assert.equal(code, 403) 214 | end) 215 | 216 | it("ensures unknown refresh token fails", function() 217 | local token, code = http.request( 218 | 'http://localhost/token', 219 | 'POST', 220 | { 221 | ['Content-Type'] = "application/json", 222 | }, 223 | { 224 | grant_type = "refresh_token", 225 | client_id = "untrusted", 226 | client_secret = "oauth2", 227 | refresh_token = "unknown", 228 | } 229 | ) 230 | assert.equal(code, 403) 231 | end) 232 | 233 | it("ensures tokens are signed by client secrets", function() 234 | local token, code = http.request( 235 | 'http://localhost/token', 236 | 'POST', 237 | { 238 | ['Content-Type'] = "application/json", 239 | }, 240 | { 241 | grant_type = "password", 242 | client_id = "trusted", 243 | client_secret = "oauth2", 244 | username = "test", 245 | password = "test" 246 | } 247 | ) 248 | local claim = jwt.decode(token.access_token, "oauth2") 249 | assert(claim) 250 | end) 251 | end) 252 | --------------------------------------------------------------------------------