diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ad1bd2a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,19 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[Makefile]
+indent_style = tab
+tab_width = 2
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..a5c1bc7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,105 @@
+# https://stackoverflow.com/a/23324703
+ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
+
+lua_family = 5.1
+lua_version = ${lua_family}.5
+lua_suffix =
+lua_build_platform = linux
+luarocks_version = 3.7.0
+
+lua_src = http://www.lua.org/ftp/lua-${lua_version}.tar.gz
+luarocks_src = http://luarocks.github.io/luarocks/releases/luarocks-${luarocks_version}.tar.gz
+
+lua_root = ${ROOT_DIR}/target/dist
+lua_mods = ${lua_root}/share/lua/${lua_family}
+lua_cmods = ${lua_root}/lib/lua/${lua_family}
+
+run_test_file = env \
+ LUA_PATH="${ROOT_DIR}/test/alt/?.lua;${lua_mods}/?.lua;${ROOT_DIR}/target/dist/etc/nginx/ssso/?.lua" \
+ LUA_CPATH="${lua_cmods}/?.so" \
+ ${lua_root}/bin/lua${lua_suffix}
+
+all: test
+
+test: test-env
+ ${run_test_file} ${ROOT_DIR}/test/aes.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/random.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/sha256.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/util.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/config.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/auth.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/nginx.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/identity.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/sites.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/crypto.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/login.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/sessions.utest.lua
+ ${run_test_file} ${ROOT_DIR}/test/anonymous1.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/anonymous2.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/anonymous3.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/anonymous4.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/anonymous5.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/login1.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/login2.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/login3.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/login4.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/login5.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/login6.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/login7.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/portal1.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/portal2.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/portal3.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/portal4.ctest.lua
+ ${run_test_file} ${ROOT_DIR}/test/portal5.ctest.lua
+
+test-env: run-env target/dist/etc/nginx/ssso ${lua_cmods}/bit32.so ${lua_cmods}/cjson.so ${lua_mods}/resty/easy-crypto.lua ${lua_mods}/luaunit.lua
+
+target/dist/etc/nginx/ssso: src test/global.json test/login test/portal test/sites
+ rm -rf target/dist/etc/nginx/ssso; \
+ mkdir -p target/dist/etc/nginx; \
+ cp -rs ${ROOT_DIR}/src target/dist/etc/nginx/ssso; \
+ cp -rs ${ROOT_DIR}/test/{global.json,login,portal,sites} target/dist/etc/nginx/ssso/
+
+run-env: ${lua_root}/bin/lua${lua_suffix}
+
+${lua_root}/bin/lua${lua_suffix}: target/src/lua/lua-${lua_version}.tar.gz
+ ( \
+ cd target/src/lua; \
+ rm -rf lua-${lua_version}; \
+ tar -xzf lua-${lua_version}.tar.gz; \
+ cd lua-${lua_version}; \
+ make ${lua_build_platform} test install INSTALL_TOP="${lua_root}" \
+ )
+
+${lua_root}/bin/luarocks: target/src/luarocks/luarocks-${luarocks_version}.tar.gz
+ ( \
+ cd target/src/luarocks; \
+ rm -rf luarocks-${luarocks_version}; \
+ tar -xzf luarocks-${luarocks_version}.tar.gz; \
+ cd luarocks-${luarocks_version}; \
+ ./configure --prefix="${lua_root}" --with-lua="${lua_root}" --lua-suffix=${lua_suffix}; \
+ make; \
+ make install \
+ )
+
+${lua_cmods}/bit32.so: ${lua_root}/bin/luarocks
+ ${lua_root}/bin/luarocks install bit32
+
+${lua_cmods}/cjson.so: ${lua_root}/bin/luarocks
+ ${lua_root}/bin/luarocks install lua-cjson
+
+${lua_mods}/resty/easy-crypto.lua: ${lua_root}/bin/luarocks
+ ${lua_root}/bin/luarocks install lua-easy-crypto
+
+${lua_mods}/luaunit.lua: ${lua_root}/bin/luarocks
+ ${lua_root}/bin/luarocks install luaunit
+
+target/src/lua/lua-${lua_version}.tar.gz:
+ mkdir -p target/src/lua; \
+ curl -so target/src/lua/lua-${lua_version}.tar.gz "${lua_src}"
+
+target/src/luarocks/luarocks-${luarocks_version}.tar.gz:
+ mkdir -p target/src/luarocks; \
+ curl -so target/src/luarocks/luarocks-${luarocks_version}.tar.gz "${luarocks_src}"
+
+.PHONY: all run-env test-env test
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e3d70fa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,217 @@
+# Simple-SSO
+
+This software is similar to [SSOwat](https://github.com/YunoHost/SSOwat) in purpose. It aims to implement a light-weight Single Sign-On layer between client HTTP software and HTTP-enabled applications hosted on a single [OpenResty](https://github.com/openresty/lua-nginx-module) server.
+The main target is self-hosting.
+
+> ℹ️ [OpenResty](https://openresty.org/) is an open-source package which bundles [Nginx](http://nginx.org/) web server [with LuaJIT](https://www.nginx.com/resources/wiki/modules/lua/) and some chosen libraries.
+
+## Goals / features — status
+
+> ℹ️ Each feature is backed by [tests](./test/).
+
+Goal / feature (functional) | status
+--------------------------- | ------
+A new site can be handled by dropping a site-specific configuration file in a dedicated folder | Done
+A site can be broken-down into sub-parts by a series of Lua regular expressions to match them | Done
+Each part of a site has its own access rules and authentication mode | Done
+A site part can be public: unauthenticated visitors can access it | Done
+A site part can be private: only authenticated users can access it | Done
+A site part can accept all authenticated users or grant access to a chosen list | Done
+A site part, even a public one, can be forbidden to some authenticated users | Done
+A rejected access is redirected to the login page | Done
+The login page can be customized | Done
+On successful login, the user is sent back to the page that was aimed at before authentication | Done
+A portal page is available, where each site part can add its own links | Done
+The portal page can be customized | Done
+HTTP Basic Authentication can be used instead of interactive login | Done
+Each site can configure its logout method to reach a “Single Sign-Off” | To do
+Users can change their password in the identity directory | To do
+
+Goal / feature (technical) | status
+-------------------------- | ------
+A [JWS](https://www.rfc-editor.org/rfc/rfc7515.html) is used for authentication | Done
+The [JWT](https://www.rfc-editor.org/rfc/rfc7519) is short-lived, and automatically renewed on each access | Done
+The JWT is self-sufficient to allow or deny access to a site | Done
+For better performance, the sites’ data is only read after a successful login (to build the first JWT), or to display the portal | Done
+The JWS is stored in a cookie with no impact on the integrated applications | Done
+The JWS can be stored in its regular header | To do
+Credentials and authorization data in the JWT are encrypted with 256-bit AES | Done
+Implementation of the OAuth2 and OpenID-Connect protocols | To do
+Implementation of the OpenID-Connect Discovery protocol | To do
+LDAP is usable as an authentication back-end | Done
+Any authentication back-end can be configured through shell commands | Done
+
+## Installation / usage
+
+A directory must be created (for example `/etc/nginx/simplesso/`) with this structure:
+
+```
+─┬─ login/
+ │ ├─ login.html
+ │ ├─ login.css
+ │ └─ login.js
+ ├─ portal/
+ │ ├─ portal.html
+ │ ├─ portal.css
+ │ └─ portal.js
+ ├─ sites/
+ │ ├─ SITE1.json
+ │ ├─ SITE2.json
+ │ └─ …
+ ├─ global.json
+ └─ *.lua
+```
+
+The `*.lua` files are all files in the [src/](./src/) source-code directory. Among these are ① `do_init.lua` and ② `do_access.lua`; these are the files that Nginx calls ① when starting, and then ② for each request, to check the access permissions.
+To this end, Nginx must be configured thus (assuming the directory created above is `/etc/nginx/simplesso/`):
+
+* In the `http {…}` section of `nginx.conf`, add this line:
+`init_by_lua_file /etc/nginx/simplesso/do_init.lua;`
+* In the `server {…}` section of `nginx.conf`, add this line:
+`access_by_lua_file /etc/nginx/simplesso/do_access.lua;`
+
+### The main configuration file: `global.json`
+
+The `global.json` file has this [JSON](https://www.json.org/) structure:
+
+```json
+{
+ "auth": {
+ "check": "AN AUTHENTICATING SHELL COMMAND"
+ },
+ "session_seconds": 300,
+ "sso_host": "example.org",
+ "sso_prefix": "/simple-sso"
+}
+```
+
+The `sso_host` and `sso_prefix` are simply the public path to your SSO, hosted on your OpenResty server. In the above example:
+
+* the login page would be at `https://example.org/simple-sso/login`;
+* the portal page would be at `https://example.org/simple-sso/portal`.
+
+The parameter `session_seconds` is the number of seconds (here: 5 minutes) after which a session expires in the absence of any request to the server; if there is a request, the session token is renewed, and the counter is reset.
+
+Finally, the `auth` array contains the shell commands that communicate with the authentication backend.
+Each command can make full use of the system shell features (loops, pipes, parameter expansion…).
+Here are the needed commands:
+
+* `check` is the command that checks the validity of given credentials. If 2 non-empty lines can be read in the standard output of this command, then the credentials are validated, else they are rejected.
+ * In the command, “`\ru.`” is replaced by the user identifier that was given, and “`\rp.`” is replaced by the password that was given. Both are properly secured for the shell: any newline character is removed, and each occurrence of “`"`” is escaped to become “`\"`”; thus “`\ru.`” and “`\rp.`” are expected to be used in double-quoted parameters in the chosen command.
+ * In the output of the command, the first line is used as the user _name_, and the second line is used as the user _email_.
+
+> ⚠️ Remember that the commands are written as _strings_ in a JSON file, and thus the [JSON syntax](https://www.json.org/) applies. In particular, literal characters `"` and `\` (backslash) would respectively appear as `\"` and `\\`.
+
+An [example `global.json` file](./doc/samples/global.json) is provided, with a command that can authenticate a user using an LDAP directory.
+
+### The login page
+
+Although [sample login files](./doc/samples/login/) are provided, each can be freely replaced, knowing these facts:
+
+* All three files must be encoded in UTF-8.
+* In `login.html`:
+ * only _one_ CSS file is allowed, and this file is `login.css`;
+ * only _one_ Javascript file is allowed, and this file is `login.js`;
+ * the placeholder “``” gets replaced at runtime with messages (if there are any), in this format:
+ ```html
+
+
An information message.
+
A warning message.
+ …
+
+ ```
+ * the login action must be “`login`” with method `POST`;
+ * the `form` parameters for the login and password must be named “`login`” and “`password`”;
+ * for the redirection to work, this hidden input must be provided:
+ ``
+ or the user will always get redirected to the portal after login.
+
+### The portal page
+
+Although [sample portal files](./doc/samples/portal/) are provided, each can be freely replaced, knowing these facts:
+
+* All three files must be encoded in UTF-8.
+* In `portal.html`:
+ * only _one_ CSS file is allowed, and this file is `portal.css`;
+ * only _one_ Javascript file is allowed, and this file is `portal.js`;
+ * the placeholders “`SSSO_USER`”, “`SSSO_NAME`”, and “`SSSO_EMAIL`” get respectively replaced at runtime by the logged-in user’s login identifier, name, and e-mail address, HTML-encoded;
+ * for this file to have any usefulness, it must contain this HTML fragment:
+ ``
+ which gets:
+ * _filled_ at runtime with a list of links if there are links authorized for the logged-in user:
+ ```html
+
+ ```
+ * or _replaced_ at runtime with a paragraph if no links are authorized for the logged-in user:
+ ```html
+
A substitute message.
+ ```
+
+### The sites’ description
+
+Each site is described as a [JSON object](https://www.json.org/) having one field named “`patterns`”, which is a list:
+
+```json
+{"patterns": [ … ]}
+```
+
+This list contains one JSON object per part of the site that needs to be described separately.
+A part of a site contains at least the field `lua_regex`, that lists the possible [patterns](https://www.lua.org/manual/5.3/manual.html#6.4.1) that an URL must match to be considered as being in this part of the site:
+
+```json
+{"lua_regex": [ … ]}
+```
+
+In addition, the part of the site may specify:
+
+* `public` (a boolean, `false` by default): whether unauthenticated users may visit this part of the site;
+* `allow` (a list, empty by default, i.e. nobody): what authenticated users are allowed to visit this part of the site (“`*`” means everyone);
+* `deny` (a list, empty by default, i.e. nobody): what authenticated users are denied access to this part of the site;
+* `actions` (a list of objects, empty by default): how the single sign-on should be forwarded to the site; each object in this list has three fields:
+ * `type` is either “`cookie`” (send a cookie) or “`header`” (send a header),
+ * `name` is the name of the cookie or header,
+ * `value` is the value to put in this cookie or header, where the following substitutions are done:
+ * “`\ru.`” gets replaced by the logged-in user identifier,
+ * “`\rp.`” gets replaced by the user’s password,
+ * “`\rn.`” gets replaced by the user’s name,
+ * “`\re.`” gets replaced by the user’s e-mail address,
+ * “`\rb64(…).`” gets replaced by the [Base64 encoding](https://datatracker.ietf.org/doc/html/rfc4648#section-4) of what is inside the parentheses,
+ * “`\ru64(…).`” gets replaced by the [Base64 encoding with the URL variant](https://datatracker.ietf.org/doc/html/rfc4648#section-5) of what is inside the parentheses;
+* `portal` (an object, empty by default): what links shall be added to the portal page; for each of this object:
+ * the key (i.e. field name) is the URL of the link, relative to the root of the server,
+ * the value is the label to show for this link.
+
+To summarise, here is a minimal, valid but useless, example:
+
+```json
+{"patterns": [{"lua_regex": ["/private"]}]}
+```
+
+And here is a fuller example:
+
+```json
+{"patterns": [
+ { "lua_regex": ["/site/assets", "/site/wiki"], "public": true, "allow": ["*"],
+ "actions": [
+ {"type": "header", "name": "X-WIKI-USER", "value": "\rn."},
+ {"type": "header", "name": "X-WIKI-MAIL", "value": "\re."}
+ ],
+ "portal": {
+ "/site/wiki/overview.html": "Wiki pages"
+ }
+ },
+ {"lua_regex": ["/site/admin"], "allow": ["*"], "deny": ["former-administrator"],
+ "actions": [
+ {"type": "header", "name": "Authorization", "value": "Basic \rb64(\ru.:\rp.)."}
+ ],
+ "portal": {
+ "/site/admin/plugins.php": "Wiki plugins",
+ "/site/admin/statistics.php": "Wiki visitors statistics"
+ }
+ }
+]}
+```
diff --git a/doc/samples/global.json b/doc/samples/global.json
new file mode 100644
index 0000000..8da972f
--- /dev/null
+++ b/doc/samples/global.json
@@ -0,0 +1,8 @@
+{
+ "auth": {
+ "check": "/usr/bin/ldapsearch -x -D \"uid=\ru.,ou=users,dc=example,dc=org\" -w \"\rp.\" -b 'ou=users,dc=example,dc=org' -s one -LLL -l 1 -z 1 \"(uid=\ru.)\" cn mail | /usr/bin/gawk '/^cn/{n=gensub(/cn: */,\"\",1)};/^mail/{m=gensub(/mail: */,\"\",1)};END{printf(\"%s\\n%s\\n\",n,m)}'"
+ },
+ "session_seconds": 3600,
+ "sso_host": "example.org",
+ "sso_prefix": "/ssso"
+}
diff --git a/doc/samples/login/login.css b/doc/samples/login/login.css
new file mode 100644
index 0000000..53ee7a0
--- /dev/null
+++ b/doc/samples/login/login.css
@@ -0,0 +1,32 @@
+body {
+ max-width: 100ex;
+ margin: 0 auto;
+ background-color: white;
+ color: black;
+ text-align: center;
+}
+form > * {
+ display: block;
+ clear: both;
+ margin: 0.5ex auto;
+}
+label input {
+ display: block;
+ width: 50%;
+ float: right;
+ margin: 0 0 1ex;
+}
+#messages {
+ margin: 1em 0;
+ background-color: rgba(255,0,0,0.1);
+ border: 0.3ex outset rgba(255,0,0,0.1);
+ border-radius: 1ex;
+ padding: 0 1ex;
+ text-align: left;
+}
+#messages .info {
+ color: orangered;
+}
+#messages .warning {
+ color: blue;
+}
diff --git a/doc/samples/login/login.html b/doc/samples/login/login.html
new file mode 100644
index 0000000..780cb0f
--- /dev/null
+++ b/doc/samples/login/login.html
@@ -0,0 +1,14 @@
+
+ Login
+
+
+
+
Welcome SSSO_NAME! Here are the pages you can open using your single sign-on:
+
+
diff --git a/doc/samples/portal/portal.js b/doc/samples/portal/portal.js
new file mode 100644
index 0000000..a688119
--- /dev/null
+++ b/doc/samples/portal/portal.js
@@ -0,0 +1 @@
+//JS
diff --git a/doc/samples/test_pages/private_restricted.php b/doc/samples/test_pages/private_restricted.php
new file mode 100644
index 0000000..5ccb6c1
--- /dev/null
+++ b/doc/samples/test_pages/private_restricted.php
@@ -0,0 +1,2 @@
+' .. util.str_to_html(cause) .. ""
+ else
+ paras = ""
+ end
+ if warnings then
+ for _, m in ipairs(warnings) do
+ paras = paras .. '
' .. util.str_to_html(m) .. "
"
+ end
+ end
+ if paras ~= "" then
+ html = html:gsub("", '' .. paras .. "", 1)
+ end
+ local back_url = req_data.query_params.back
+ if not back_url then
+ back_url = ""
+ end
+ html = html:gsub("BACK_URL", util.str_to_html(back_url))
+ return html
+end
+
+local function check_credentials_and_get_profile(user, password)
+ local user_data = auth.read_user(user, password)
+ if user_data then
+ log.debug("Credentials accepted for user " .. user_data.name)
+ return sites.class__profile:build_from_conf(user, password, user_data.name, user_data.email)
+ else
+ return nil
+ end
+end
+
+local function check_login(req_data)
+ req_data = req_data:with_post_parameters()
+ local user = req_data.query_params["login"] or ""
+ local password = req_data.query_params["password"] or ""
+ local profile = check_credentials_and_get_profile(user, password)
+ if profile then
+ log.debug("Building JWS")
+ local jws, tslimit = crypto.get_jws_and_tslimit(profile)
+ if not jws then
+ return nginx.answer_unexpected_error()
+ end
+ nginx.set_jws_cookie(jws, tslimit)
+ local back_url = req_data.query_params.back
+ log.debug("Redirecting")
+ if back_url then
+ return nginx.redirect_to_page(back_url)
+ else
+ return nginx.redirect_to_portal()
+ end
+ else
+ log.debug("Credentials rejected")
+ local html = inject_data(contents("login.html"), req_data, {
+ "These credentials were rejected."
+ })
+ return nginx.return_contents(html, "text/html; charset=UTF-8")
+ end
+end
+
+local function answer_request(req_data)
+ local login = conf.get_sso_prefix() .. "/login"
+ if req_data:is(login) then
+ if req_data:has_method("POST") then
+ log.info("Checking login")
+ return check_login(req_data)
+ else
+ local html = inject_data(contents("login.html"), req_data, nil)
+ return nginx.return_contents(html, "text/html; charset=UTF-8")
+ end
+ elseif req_data:is(login .. ".css") then
+ return nginx.return_contents(contents("login.css"), "text/css; charset=UTF-8")
+ elseif req_data:is(login .. ".js") then
+ return nginx.return_contents(contents("login.js"), "application/javascript; charset=UTF-8")
+ else
+ log.info("Unknown login file")
+ return nginx.answer_not_found(req_data)
+ end
+end
+
+return {
+ answer_request = answer_request,
+ check_credentials_and_get_profile = check_credentials_and_get_profile, -- TODO: test
+ set_root = set_root,
+}
diff --git a/src/ssso_nginx.lua b/src/ssso_nginx.lua
new file mode 100644
index 0000000..8808eff
--- /dev/null
+++ b/src/ssso_nginx.lua
@@ -0,0 +1,185 @@
+local ngx = require("ngx")
+local b64 = require("ssso_base64")
+local util = require("ssso_util")
+local conf = require("ssso_config")
+
+local class__request = {}
+
+local function str_starts_with(str, begin)
+ local i, _ = str:find(util.str_to_pattern(begin))
+ return 1 == i
+end
+
+function class__request:current()
+ local vars = ngx.var
+ local request = {
+ referer = vars.http_referer,
+ host = vars.host,
+ method = vars.request_method,
+ uri = vars.request_uri,
+ }
+ setmetatable(request, {__index = self})
+
+ if request.referer == "" then
+ request.referer = nil
+ end
+
+ local target, qp = request.uri, {}
+ local qm, _ = target:find("%?")
+ if qm then
+ qp = ngx.decode_args(target:sub(qm + 1))
+ target = target:sub(1, qm - 1)
+ end
+ request["target"] = target
+ request["query_params"] = qp
+
+ local https = vars.proxy_https or vars.https
+ if https and https ~= "" then
+ request["scheme"] = "https"
+ else
+ request["scheme"] = "http"
+ end
+ return request
+end
+
+function class__request:with_post_parameters()
+ ngx.req.read_body()
+ local args, _ = ngx.req.get_post_args()
+ if args then
+ for key, val in pairs(args) do
+ self.query_params[key] = val
+ end
+ end
+ return self
+end
+
+function class__request:has_method(method)
+ return string.upper(method) == string.upper(self.method)
+end
+
+function class__request:is(url)
+ return self.target == url
+end
+
+function class__request:matches(lua_pattern)
+ return self.target:match(lua_pattern)
+end
+
+function class__request:starts_with(prefix)
+ return str_starts_with(self.target, prefix)
+end
+
+function class__request:has_param(param, value)
+ return self.query_params[param] ~= nil and (value == nil or self.query_params[param] == value)
+end
+
+local function get_basic_auth()
+ local header = ngx.var.Authentication
+ if (not header) or (#header < 7) or (not str_starts_with(header, "Basic ")) then
+ return nil, nil
+ end
+ local text, _ = b64.decode_base64(header:sub(7))
+ if not text then
+ ngx.log(ngx.DEBUG, "Invalid Authentication header: " .. header)
+ return nil, nil
+ end
+ local colon
+ colon, _ = text:find(":")
+ if not colon then
+ return nil, nil
+ end
+ local login, password = "", ""
+ if colon > 1 then
+ login = text:sub(1, colon - 1)
+ end
+ if colon < #text then
+ password = text:sub(colon + 1, #text)
+ end
+ return login, password
+end
+
+local function get_jws_cookie()
+ return ngx.var.cookie_SSSO_TOKEN
+end
+
+local function set_jws_cookie(jws, tslimit)
+ ngx.header["Set-Cookie"] = "SSSO_TOKEN=" .. jws ..
+ "; Path=/; Expires=" .. ngx.cookie_time(tslimit) ..
+ "; Secure"
+end
+
+local function get_seconds_since_epoch()
+ return math.floor(ngx.now())
+end
+
+local function add_cookie(name, value)
+ local cookie = name .. "=" .. value
+ local old_cookie = ngx.var.http_cookie
+ if old_cookie and old_cookie ~= "" then
+ cookie = old_cookie .. "; " .. cookie
+ end
+ ngx.log(ngx.DEBUG, "Overriding request Cookie header: " .. cookie)
+ ngx.req.set_header("Cookie", cookie)
+end
+
+local function add_header(name, value)
+ ngx.log(ngx.DEBUG, "Setting request " .. name .. " header: " .. value)
+ ngx.req.set_header(name, value)
+end
+
+local function answer_not_found(req_data)
+ return ngx.exit(404)
+end
+
+local function answer_unexpected_error()
+ ngx.log(ngx.ERROR, "Unexpected Simple-SSO error.")
+ return ngx.exit(500)
+end
+
+local function redirect_to_page(uri)
+ return ngx.redirect("https://" .. conf.get_sso_host() .. uri, 302)
+end
+
+local function redirect_to_login(req_data, status)
+ return ngx.redirect("https://" ..
+ conf.get_sso_host() ..
+ conf.get_sso_prefix() ..
+ "/login?back=" ..
+ ngx.escape_uri(req_data.uri) ..
+ "&cause=" .. tostring(status),
+ 307)
+end
+
+local function redirect_to_portal()
+ return ngx.redirect("https://" .. conf.get_sso_host() .. conf.get_sso_prefix() .. "/portal", 307)
+end
+
+local function return_contents(contents, mime_and_charset)
+ ngx.header["Content-Type"] = mime_and_charset
+ ngx.header["Content-Length"] = tostring(#contents)
+ ngx.header["Cache-Control"] = "no-store,max-age=0"
+ ngx.say(contents)
+ -- TODO: CSRF
+ return ngx.exit(200)
+end
+
+local function forward_request(req_data)
+ return
+end
+
+return {
+ add_cookie = add_cookie,
+ add_header = add_header,
+ answer_not_found = answer_not_found,
+ class__request = class__request,
+ forward_request = forward_request,
+ get_basic_auth = get_basic_auth,
+ get_jws_cookie = get_jws_cookie,
+ get_seconds_since_epoch = get_seconds_since_epoch,
+ redirect_to_login = redirect_to_login,
+ redirect_to_page = redirect_to_page,
+ redirect_to_portal = redirect_to_portal,
+ return_contents = return_contents,
+ set_jws_cookie = set_jws_cookie,
+ answer_unexpected_error = answer_unexpected_error,
+}
diff --git a/src/ssso_oauth2.lua b/src/ssso_oauth2.lua
new file mode 100644
index 0000000..028bb83
--- /dev/null
+++ b/src/ssso_oauth2.lua
@@ -0,0 +1,14 @@
+local function answer_oidc_webfinger(req_data)
+ -- TODO
+ return "TODO"
+end
+
+local function answer_request(req_data, auth)
+ -- TODO
+ return "TODO"
+end
+
+return {
+ answer_oidc_webfinger = answer_oidc_webfinger,
+ answer_request = answer_request,
+}
diff --git a/src/ssso_portal.lua b/src/ssso_portal.lua
new file mode 100644
index 0000000..0fecac6
--- /dev/null
+++ b/src/ssso_portal.lua
@@ -0,0 +1,53 @@
+local util = require("ssso_util")
+local conf = require("ssso_config")
+local nginx = require("ssso_nginx")
+
+local root = ""
+
+local function set_root(prefix)
+ root = prefix .. "/portal/"
+end
+
+local function contents(relative)
+ local file = assert(io.open(root .. relative, "r"), "Cannot open portal file " .. root .. relative)
+ local data = file:read("*all")
+ file:close()
+ return data
+end
+
+local function inject_data(html, profile)
+ html = html:gsub("SSSO_USER", util.str_to_html(profile:user()))
+ html = html:gsub("SSSO_NAME", util.str_to_html(profile:name()))
+ html = html:gsub("SSSO_EMAIL", util.str_to_html(profile:email()))
+ local links = ""
+ local allowed = profile:authorized_links()
+ table.sort(allowed, function(a1, a2) return a1.label < a2.label end)
+ for _, allow in ipairs(allowed) do
+ links = links .. '