casualjim
3/18/2016 - 11:35 PM

oauth.nginx.lua

-- import requirements
local cjson = require "cjson"

-- Ubuntu broke the install. Puts the source in /usr/share/lua/5.1/https.lua,
-- but since the source defines itself as the module "ssl.https", after we
-- load the source, we need to grab the actual thing. Building from source
-- wasn't practical.
require "https"
local https = require "ssl.https"
local ltn12  = require("ltn12")

-- setup some app-level vars
local client_id = ngx.var.ngo_client_id
local client_secret = ngx.var.ngo_client_secret
local domain = ngx.var.ngo_domain
local cb_scheme = ngx.var.ngo_callback_scheme or ngx.var.scheme
local cb_server_name = ngx.var.ngo_callback_host or ngx.var.server_name
local cb_uri = ngx.var.ngo_callback_uri or "/_oauth"
local cb_url = cb_scheme.."://"..cb_server_name..cb_uri
local signout_uri = ngx.var.ngo_signout_uri or "/_signout"
local debug = ngx.var.ngo_debug
local whitelist = ngx.var.ngo_orgs or "reverb,wordnik"
local secure_cookies = ngx.var.ngo_secure_cookies

local uri_args = ngx.req.get_uri_args()
local ngx_headers = ngx.req.get_headers()

function get_basic_auth()
  local header =  ngx_headers['Authorization']
  if header == nil or header:find(" ") == nil then
    return
  end

  local divider = header:find(' ')
  local auth_type = header:sub(0, divider-1)
  if auth_type ~= 'Basic' and auth_type ~= 'token' then
    return
  elseif auth_type ~= 'Basic' then
    local auth = ngx.decode_base64(header:sub(divider+1))
    if auth == nil or auth:find(':') == nil then
      return
    end

    local divider2 = auth:find(':')
    if divider2 == nil or divider2 == "" then
      return auth
    else
      return auth:sub(0, divider2 - 1)
    end
  else
    local auth = header:sub(divider + 1)
    if auth == nil then
      return
    end

    return auth
  end
  return  
end

function set_cookie(access_token, userinfo)
  local cookie_tail = ";version=1;path=/;HttpOnly"
  if secure_cookies then
    cookie_tail = cookie_tail..";secure"
  end

  local name = userinfo["name"]
  if name and name ~= "" then
    name = ngx.escape_uri(name)
  end
  local email = userinfo["email"]
  if email and email ~= "" then
    email = ngx.escape_uri(email)
  end
  local picture = userinfo["avatar_url"]
  if picture and picture ~= "" then
    picture = ngx.escape_uri(name)
  end

  ngx.header["Set-Cookie"] = {
    "AccessToken="..access_token..cookie_tail,
    "Name="..name..cookie_tail,
    "Email="..email..cookie_tail,
    "Picture="..picture..cookie_tail
  }
end

function delete_cookie()
  ngx.header["Set-Cookie"] = {
    "AccessToken=deleted;version=1;path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
    "Name=;version=1;path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
    "Email=;version=1;path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
    "Picture=;version=1;path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
  }
end

function validate_token( access_token )
  if debug then
    ngx.log(ngx.ERR, "DEBUG: validate_token "..(access_token or "NO TOKEN!!!!"))
  end
  if not access_token then
    unauthenticated()
  end
  -- body
  local userinfo = validate_user(access_token)
  if not userinfo or not validate_org_membership(access_token, userinfo) then
    unauthenticated()     
  end
  return userinfo
end

function unauthenticated()
  -- body
  -- ngx.header.content_type = 'text/plain'
  -- ngx.header.www_authenticate = 'Basic realm=""'
  ngx.status = ngx.HTTP_UNAUTHORIZED
  delete_cookie()
  ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

function validate_org_membership(access_token, userinfo) 
  local send_headers = {
    Authorization = "token "..access_token,
  }
  local orgs_table = {}
  local res3, code3, headers3, status3 = https.request({
    url = "https://api.github.com/user/orgs",
    method = "GET",
    headers = send_headers,
    sink = ltn12.sink.table(orgs_table)
  })

  if code3~=200 then
    ngx.log(ngx.ERR, "received "..code2.." from https://api.github.com/user/orgs")
    return false
  end

  if debug then
    ngx.log(ngx.ERR, "DEBUG: orgs response "..res3..code3..status3..table.concat(orgs_table))
  end

  local orgs = cjson.decode( table.concat(orgs_table))
  local is_member = false

  for i, org in pairs(orgs) do
    if string.find(whitelist, org.login) then
      is_member = true
    end
  end

  return is_member
end

function validate_user(access_token)
  local send_headers = {
    Authorization = "token "..access_token,
  }

  local result_table = {}
  local res2, code2, headers2, status2 = https.request({
    url = "https://api.github.com/user",
    method = "GET",
    headers = send_headers,
    sink = ltn12.sink.table(result_table),
  })

  if code2~=200 then
    ngx.log(ngx.ERR, "received "..code2.." from https://api.github.com/user")
    return nil -- ngx.exit(ngx.HTTP_UNAUTHORIZED)
  end

  if debug then
    ngx.log(ngx.ERR, "DEBUG: userinfo response "..res2.." "..code2.." "..status2.." "..table.concat(result_table))
  end

  return cjson.decode( table.concat(result_table) )
end

function fetch_access_token(auth_code)
  local init_send_headers = {
    Accept = "application/json"
  }

  -- TODO: Switch to NBIO sockets
  -- If I get around to working luasec, this says how to pass a function which
  -- can generate a socket, needed for NBIO using nginx cosocket
  -- http://lua-users.org/lists/lua-l/2009-02/msg00251.html
  local bd = "code="..ngx.escape_uri(auth_code).."&client_id="..client_id.."&client_secret="..client_secret.."&redirect_uri="..ngx.escape_uri(cb_url)

  local res, code, headers, status = https.request(
    "https://github.com/login/oauth/access_token",
    bd
  )

  if debug then
    ngx.log(ngx.ERR, "DEBUG: token response "..res..code..status)
  end

  ngx.log(ngx.ERR, "received "..code..res.." from https://github.com/login/oauth/access_token")
  if code~=200 then
    ngx.log(ngx.ERR, "received "..code.." from https://github.com/login/oauth/access_token")
    return nil
  end

  -- use version 1 cookies so we don't have to encode. MSIE-old beware
  local json  = ngx.decode_args(res, 0)
  return json.access_token
end

function fetch_auth_code()
  -- If no access token and this isn't the callback URI, redirect to oauth
  if ngx.var.uri ~= cb_uri then
    -- Redirect to the /oauth endpoint, request access to ALL scopes
    return ngx.redirect("https://github.com/login/oauth/authorize?client_id="..client_id.."&scope=user:email,read:org&response_type=code&redirect_uri="..ngx.escape_uri(cb_url).."&state="..ngx.escape_uri(ngx.var.uri).."&login_hint="..ngx.escape_uri(domain))
  end

  -- Fetch the authorization code from the parameters
  local auth_code = uri_args["code"]
  local auth_error = uri_args["error"]

  if auth_error then
    ngx.log(ngx.ERR, "received "..auth_error.." from https://github.com/login/oauth/authorize")
    unauthenticated()
  end

  if debug then
    ngx.log(ngx.ERR, "DEBUG: fetching token for auth code "..auth_code)
  end
  return auth_code
end

-- See https://developers.google.com/accounts/docs/OAuth2WebServer
if ngx.var.uri == signout_uri then
  delete_cookie()
  return ngx.redirect(ngx.var.scheme.."://"..ngx.var.server_name)
end

if ngx.var.uri == "/favicon.ico" then 
  return 
end

-- start the actual authentication process
local access_token = get_basic_auth()
if debug then
  ngx.log(ngx.ERR, "basic auth access_token: "..(access_token or "NO TOKEN"))
end
if not access_token then
  access_token = uri_args["access_token"]  
  if debug then
    ngx.log(ngx.ERR, "query string access_token: "..(access_token or "NO TOKEN"))
  end
  if not access_token then
    access_token = ngx.var.cookie_AccessToken   
    if debug then
      ngx.log(ngx.ERR, "cookie access_token: "..(access_token or "NO TOKEN"))
    end 
  end
end

if access_token then 
  if debug then
    ngx.log(ngx.ERR, "got access_token: "..(access_token or "NO TOKEN"))
  end
  -- validate_token(access_token)
  if debug then
    ngx.log(ngx.ERR, "DEBUG: authorized "..access_token)
  end
  return 
else  
  local auth_code = fetch_auth_code()
  if debug then
    ngx.log(ngx.ERR, "DEBUG: fetched auth_code "..(auth_code or "NO CODE"))
  end
  access_token = fetch_access_token(auth_code)
  if debug then
    ngx.log(ngx.ERR, "DEBUG: resolved access_token "..(auth_code or "NO TOKEN"))
  end

  local userinfo = validate_token(access_token)
  set_cookie(access_token, userinfo)

  -- Redirect
  if debug then
    ngx.log(ngx.ERR, "DEBUG: authorized "..userinfo["email"]..", redirecting to "..uri_args["state"])
  end
  ngx.redirect(uri_args["state"])
end