Discourse SSO in Nginx

If you are using Nginx (or the awesome OpenResty) to handle you user authentication, you can handle the Discourse SSO on the Nginx too!

This assumes you are using the ideas on this article to handle authentication:


The following code implements the Discourse SSO protocol in Lua, and even sets custom fields and the user birthday date for the cakeday plugin:

-- First we inline this PR https://github.com/openresty/lua-resty-string/pull/18
-- You can remove this after @agentzh merges this patch by @ddragosd

-- Adds HMAC support to Lua with multiple algorithms, via OpenSSL and FFI
-- Author: ddragosd@gmail.com
-- Date: 16/05/14

local ffi = require "ffi"
local ffi_new = ffi.new
local ffi_str = ffi.string
local C = ffi.C
local resty_string = require "resty.string"
local setmetatable = setmetatable
local error = error

local _M = { _VERSION = '0.09' }

local mt = { __index = _M }

-- EVP_MD is defined in openssl/evp.h
-- HMAC is defined in openssl/hmac.h
typedef struct env_md_st EVP_MD;
typedef struct env_md_ctx_st EVP_MD_CTX;
unsigned char *HMAC(const EVP_MD *evp_md, const void *key, int key_len,
            const unsigned char *d, size_t n, unsigned char *md,
            unsigned int *md_len);
const EVP_MD *EVP_sha1(void);
const EVP_MD *EVP_sha224(void);
const EVP_MD *EVP_sha256(void);
const EVP_MD *EVP_sha384(void);
const EVP_MD *EVP_sha512(void);

-- table definind the available algorithms and the length of each digest
-- for more information @see: http://csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf
local available_algorithms = {
    sha1   = { alg = C.EVP_sha1(),   length = 160/8  },
    sha224 = { alg = C.EVP_sha224(), length = 224/8  },
    sha256 = { alg = C.EVP_sha256(), length = 256/8  },
    sha384 = { alg = C.EVP_sha384(), length = 384/8  },
    sha512 = { alg = C.EVP_sha512(), length = 512/8  }

-- 64 is the max lenght and it covers up to sha512 algorithm
local digest_len = ffi_new("int[?]", 64)
local buf = ffi_new("char[?]", 64)

function _M.new(self)
    return setmetatable({}, mt)

local function getDigestAlgorithm(dtype)
    local md_name = available_algorithms[dtype]
    if ( md_name == nil ) then
        error("attempt to use unknown algorithm: '" .. dtype ..
                "'.\n Available algorithms are: sha1,sha224,sha256,sha384,sha512")
    return md_name.alg, md_name.length

-- Returns the HMAC digest. The hashing algorithm is defined by the dtype parameter.
-- The optional raw flag, defaulted to false, is a boolean indicating whether the output should be a direct binary
-- equivalent of the HMAC or formatted as a hexadecimal string (the default)
-- @param self
-- @param dtype The hashing algorithm to use is specified by dtype
-- @param key The secret
-- @param msg The message to be signed
-- @param raw When true, it returns the binary format, else, the hex format is returned
function _M.digest(self, dtype, key, msg, raw)
    local evp_md, digest_length_int = getDigestAlgorithm(dtype)
    if key == nil or msg == nil then
        error("attempt to digest with a null key or message")

    C.HMAC(evp_md, key, #key, msg, #msg, buf, digest_len)

    if raw == true then
        return ffi_str(buf,digest_length_int)

    return resty_string.to_hex(ffi_str(buf,digest_length_int))

-- End of HMAC-SHA256

-- Here we start the integration

-- We assume you already have global variables with your user identification!
-- Here the user_id if avaliable on the auth_user nginx global variable set by a access_by_lua directive
-- For a how to read https://www.stavros.io/posts/writing-an-nginx-authentication-module-in-lua/
-- We are going to get more user info on a public api and implement the Discourse SSO protocol

cjson = require "cjson"
base64 = require "base64"

secret = "discoursessopassword" -- This one must match your discourse config
local args = ngx.req.get_uri_args()

local payload_raw = args["sso"]
local payload_unescaped = ngx.unescape_uri(payload_raw)
payload_decoded = base64.decode(payload_unescaped)

if args["sig"] == _M.digest(nil, "sha256", secret, payload_unescaped) then
    -- Signature matches

    local auth = ngx.location.capture("/user_details_api_endpoint?username="..ngx.var.auth_user)
    local user = cjson.decode(auth.body)

    -- Example of the response from the api
    --local user = cjson.decode("{\"start\": 1435952130436, \"elapsed\": 0, \"data\": [{\"user_id\":\"321654\",\"user_full_name\":\"RAFAEL DOS SANTOS SILVA\",\"user_position\":\"Senior Enginner\",\"birthday\":\"1987-07-13\",\"email\":\"rafael.silva@example.com\"}]}")

    local user_id = user["data"][1]["user_id"]
    local user_full_name = user["data"][1]["user_full_name"]
    local email = user["data"][1]["email"]
    local avatar = "https://example.com/avatar/"..user_id
    local user_location = user["data"][1]["user_location"]
    local user_position = user["data"][1]["user_position"]
    local birthday = user["data"][1]["birthday"]

    local response_payload = payload_decoded.."&username="..user_id.."&email="..email.."&external_id="..user_id.."&name="..user_full_name.."&custom.user_field_1="..user_location.."&custom.user_field_2="..user_position.."&custom.date_of_birth="..birthday
    local response_payload_encoded = ngx.encode_base64(response_payload)
    local response_payload_escaped = ngx.escape_uri(response_payload_encoded)
    local response_signature = _M.digest(nil, "sha256", secret, response_payload_encoded)
    -- Signature mismatch

Then you create a location block like the following:

   location /sso-forum {
          access_by_lua_file       "lua/auth/access.lua"; # This file contains the code with authentication like on the stavros post and sets nginx.var.auth_user
          content_by_lua_file      "lua/discourse/sso.lua"; # This file contains the code on this post

Hope it helps someone!