Software /
code /
prosody-modules
Changeset
5676:62c6e17a5e9d
Merge
author | Stephen Paul Weber <singpolyma@singpolyma.net> |
---|---|
date | Mon, 18 Sep 2023 08:24:19 -0500 |
parents | 5675:eade7ff9f52c (current diff) 5671:c217f4edfc4f (diff) |
children | 5677:31e56562f9bd |
files | |
diffstat | 65 files changed, 2335 insertions(+), 1026 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.editorconfig Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,34 @@ +# https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 150 + +[*.json] +# json_pp -json_opt canonical,pretty +indent_size = 3 +indent_style = space + +[{README,COPYING,CONTRIBUTING,TODO}{,.markdown,.md}] +# pandoc -s -t markdown +indent_size = 4 +indent_style = space + +[*.py] +indent_size = 4 +indent_style = space + +[*.{xml,svg}] +# xmllint --nsclean --encode UTF-8 --noent --format - +indent_size = 2 +indent_style = space + +[*.yaml] +indent_size = 2 +indent_style = space
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/misc/lnav/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,6 @@ +% Prosody log format for lnav + +This is a format definition that allows <https://lnav.org/> to better +handle Prosody logs. + +Install it using `lnav -i ./prosody.json`
--- a/misc/lnav/prosody.json Mon Sep 18 08:22:07 2023 -0500 +++ b/misc/lnav/prosody.json Mon Sep 18 08:24:19 2023 -0500 @@ -14,7 +14,7 @@ "ordered-by-time" : true, "regex" : { "standard" : { - "pattern" : "^(?<timestamp>\\w{3} \\d{2} \\d{2}:\\d{2}:\\d{2})\\s+(?<loggername>\\S+)\\s+(?<loglevel>debug|info|warn|error)\\s+(?<message>.+)$" + "pattern" : "^(?<timestamp>\\w{3} \\d{2} \\d{2}:\\d{2}:\\d{2}\\s+)(?<loggername>\\S+)\\s+(?<loglevel>debug|info|warn|error)\\s+(?<message>.+)$" } }, "sample" : [ @@ -23,7 +23,9 @@ } ], "timestamp-field" : "timestamp", - "timestamp-format" : "%b %d %H:%M:%S ", + "timestamp-format" : [ + "%b %d %H:%M:%S " + ], "title" : "Prosody log", "url" : "https://prosody.im/doc/logging", "value" : {
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/misc/mtail/prosody.mtail Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,13 @@ +counter prosody_log_messages by level + +/^(?P<date>(?P<legacy_date>\w+\s+\d+\s+\d+:\d+:\d+)|(?P<rfc3339_date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+[+-]\d{2}:\d{2})) (?P<sink>\S+)\s(?P<loglevel>\w+)\s(?P<message>.*)/ { + len($legacy_date) > 0 { + strptime($2, "Jan _2 15:04:05") + } + len($rfc3339_date) > 0 { + strptime($rfc3339_date, "2006-01-02T03:04:05-0700") + } + $loglevel != "" { + prosody_log_messages[$loglevel]++ + } +}
--- a/mod_auth_oauth_external/README.md Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_auth_oauth_external/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -50,6 +50,8 @@ logging in the field specified by `oauth_external_username_field`. Commonly the [OpenID `UserInfo` endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) + If left unset, only `SASL PLAIN` is supported and the username + provided there is assumed correct. `oauth_external_username_field` : String. Default is `"preferred_username"`. Field in the JSON @@ -72,21 +74,30 @@ : String. Client ID used to identify Prosody during the resource owner password grant. +`oauth_external_client_secret` +: String. Client secret used to identify Prosody during the resource + owner password grant. + +`oauth_external_scope` +: String. Defaults to `"openid"`. Included in request for resource + owner password grant. + # Compatibility ## Prosody Version Status - --------- --------------- + --------- ----------------------------------------------- trunk works - 0.12.x does not work - 0.11.x does not work + 0.12.x OAUTHBEARER will not work, otherwise untested + 0.11.x OAUTHBEARER will not work, otherwise untested ## Identity Provider Tested with - [KeyCloak](https://www.keycloak.org/) +- [Mastodon](https://joinmastodon.org/) # Future work
--- a/mod_auth_oauth_external/mod_auth_oauth_external.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_auth_oauth_external/mod_auth_oauth_external.lua Mon Sep 18 08:24:19 2023 -0500 @@ -1,5 +1,6 @@ local http = require "net.http"; local async = require "util.async"; +local jid = require "util.jid"; local json = require "util.json"; local sasl = require "util.sasl"; @@ -15,7 +16,8 @@ -- XXX Hold up, does whatever done here even need any of these things? Are we -- the OAuth client? Is the XMPP client the OAuth client? What are we??? local client_id = module:get_option_string("oauth_external_client_id"); --- TODO -- local client_secret = module:get_option_string("oauth_external_client_secret"); +local client_secret = module:get_option_string("oauth_external_client_secret"); +local scope = module:get_option_string("oauth_external_scope", "openid"); --[[ More or less required endpoints digraph "oauth endpoints" { @@ -28,6 +30,32 @@ local host = module.host; local provider = {}; +local function not_implemented() + return nil, "method not implemented" +end + +-- With proper OAuth 2, most of these should be handled at the atuhorization +-- server, no there. +provider.test_password = not_implemented; +provider.get_password = not_implemented; +provider.set_password = not_implemented; +provider.create_user = not_implemented; +provider.delete_user = not_implemented; + +function provider.user_exists(_username) + -- Can this even be done in a generic way in OAuth 2? + -- OIDC and WebFinger perhaps? + return true; +end + +function provider.users() + -- TODO this could be done by recording known users locally + return function () + module:log("debug", "User iteration not supported"); + return nil; + end +end + function provider.get_sasl_handler() local profile = {}; profile.http_client = http.default; -- TODO configurable @@ -35,14 +63,16 @@ if token_endpoint and allow_plain then local map_username = function (username, _realm) return username; end; --jid.join; -- TODO configurable function profile:plain_test(username, password, realm) + username = jid.unescape(username); -- COMPAT Mastodon local tok, err = async.wait_for(self.profile.http_client:request(token_endpoint, { headers = { ["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"; ["Accept"] = "application/json" }; body = http.formencode({ grant_type = "password"; client_id = client_id; + client_secret = client_secret; username = map_username(username, realm); password = password; - scope = "openid"; + scope = scope; }); })) if err or not (tok.code >= 200 and tok.code < 300) then @@ -52,6 +82,12 @@ if not token_resp or string.lower(token_resp.token_type or "") ~= "bearer" then return false, nil; end + if not validation_endpoint then + -- We're not going to get more info, only the username + self.username = jid.escape(username); + self.token_info = token_resp; + return true, true; + end local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint, { headers = { ["Authorization"] = "Bearer " .. token_resp.access_token; ["Accept"] = "application/json" } })); if err then @@ -61,36 +97,38 @@ return false, nil; end local response = json.decode(ret.body); - if type(response) ~= "table" or (response[username_field]) ~= username then + if type(response) ~= "table" then + return false, nil, nil; + elseif type(response[username_field]) ~= "string" then return false, nil, nil; end - if response.jid then - self.username, self.realm, self.resource = jid.prepped_split(response.jid, true); - end - self.role = response.role; + self.username = jid.escape(response[username_field]); self.token_info = response; return true, true; end end - function profile:oauthbearer(token) - if token == "" then - return false, nil, extra; - end + if validation_endpoint then + function profile:oauthbearer(token) + if token == "" then + return false, nil, extra; + end - local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint, - { headers = { ["Authorization"] = "Bearer " .. token; ["Accept"] = "application/json" } })); - if err then - return false, nil, extra; + local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint, { + headers = { ["Authorization"] = "Bearer " .. token; ["Accept"] = "application/json" }; + })); + if err then + return false, nil, extra; + end + local response = ret and json.decode(ret.body); + if not (ret.code >= 200 and ret.code < 300) then + return false, nil, response or extra; + end + if type(response) ~= "table" or type(response[username_field]) ~= "string" then + return false, nil, nil; + end + + return jid.escape(response[username_field]), true, response; end - local response = ret and json.decode(ret.body); - if not (ret.code >= 200 and ret.code < 300) then - return false, nil, response or extra; - end - if type(response) ~= "table" or type(response[username_field]) ~= "string" then - return false, nil, nil; - end - - return response[username_field], true, response; end return sasl.new(host, profile); end
--- a/mod_bidi/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_bidi/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -1,11 +1,15 @@ --- labels: -- 'Stage-Stable' -summary: 'XEP-0288: Bidirectional Server-to-Server Connections' -... +- Stage-Stable +summary: "XEP-0288: Bidirectional Server-to-Server Connections" +--- -Introduction -============ +::: {.alert .alert-warning} +This module is unreliable when used with Prosody 0.12, switch to +[mod_s2s_bidi][doc:modules:mod_s2s_bidi] +::: + +# Introduction This module implements [XEP-0288: Bidirectional Server-to-Server Connections](http://xmpp.org/extensions/xep-0288.html). It allows @@ -14,13 +18,9 @@ Install and enable it like any other module. It has no configuration. -Compatibility -============= +# Compatibility - ------- -------------------------- - trunk Bidi available natively with [mod_s2s_bidi][doc:modules:mod_s2s_bidi] - 0.11 Works - 0.10 Works - 0.9 Works - 0.8 Works (use the 0.8 repo) - ------- -------------------------- + ------ ------------------------------------------- + 0.12 Bidi available natively with [mod_s2s_bidi][doc:modules:mod_s2s_bidi] + 0.11 Works + ------ -------------------------------------------
--- a/mod_client_management/README.md Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_client_management/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -35,6 +35,12 @@ prosodyctl shell user clients user@example.com ``` +To revoke access from particular client: + +```shell +prosodyctl shell user revoke_client user@example.com grant/xxxxx +``` + ## Compatibility Requires Prosody trunk (as of 2023-03-29). Not compatible with Prosody 0.12
--- a/mod_client_management/mod_client_management.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_client_management/mod_client_management.lua Mon Sep 18 08:24:19 2023 -0500 @@ -10,8 +10,8 @@ local strict = module:get_option_boolean("enforce_client_ids", false); -module:default_permission("prosody:user", ":list-clients"); -module:default_permission("prosody:user", ":manage-clients"); +module:default_permission("prosody:registered", ":list-clients"); +module:default_permission("prosody:registered", ":manage-clients"); local tokenauth = module:depends("tokenauth"); local mod_fast = module:depends("sasl2_fast"); @@ -35,6 +35,8 @@ if not (sasl_agent or token_agent) then return; end return { software = sasl_agent and sasl_agent.software or token_agent and token_agent.name or nil; + software_id = token_agent and token_agent.id or nil; + software_version = token_agent and token_agent.version or nil; uri = token_agent and token_agent.uri or nil; device = sasl_agent and sasl_agent.device or nil; }; @@ -250,6 +252,7 @@ type = "access"; first_seen = grant.created; last_seen = grant.accessed; + expires = grant.expires; active = { grant = grant; }; @@ -276,6 +279,17 @@ return active_clients; end +local function user_agent_tostring(user_agent) + if user_agent then + if user_agent.software then + if user_agent.software_version then + return user_agent.software .. "/" .. user_agent.software_version; + end + return user_agent.software; + end + end +end + function revoke_client_access(username, client_selector) if client_selector then local c_type, c_id = client_selector:match("^(%w+)/(.+)$"); @@ -309,6 +323,13 @@ local ok = tokenauth.revoke_grant(username, c_id); if not ok then return nil, "internal-server-error"; end return true; + elseif c_type == "software" then + local active_clients = get_active_clients(username); + for _, client in ipairs(active_clients) do + if client.user_agent and client.user_agent.software == c_id or user_agent_tostring(client.user_agent) == c_id then + return revoke_client_access(username, client.id); + end + end end end @@ -348,7 +369,7 @@ local user_agent = st.stanza("user-agent"); if client.user_agent then if client.user_agent.software then - user_agent:text_tag("software", client.user_agent.software); + user_agent:text_tag("software", client.user_agent.software, { id = client.user_agent.software_id; version = client.user_agent.software_version }); end if client.user_agent.device then user_agent:text_tag("device", client.user_agent.device); @@ -417,23 +438,40 @@ return true, "No clients associated with this account"; end + local function date_or_time(last_seen) + return last_seen and os.date(math.abs(os.difftime(os.time(), last_seen)) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen); + end + + local date_or_time_width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S")); + local colspec = { + { title = "ID"; key = "id"; width = "1p" }; { title = "Software"; key = "user_agent"; width = "1p"; - mapper = function(user_agent) - return user_agent and user_agent.software; - end; + mapper = user_agent_tostring; + }; + { + title = "First seen"; + key = "first_seen"; + width = date_or_time_width; + align = "right"; + mapper = date_or_time; }; { title = "Last seen"; key = "last_seen"; - width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S")); + width = date_or_time_width; align = "right"; - mapper = function(last_seen) - return os.date(os.difftime(os.time(), last_seen) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen); - end; + mapper = date_or_time; + }; + { + title = "Expires"; + key = "expires"; + width = date_or_time_width; + align = "right"; + mapper = date_or_time; }; { title = "Authentication"; @@ -456,4 +494,18 @@ print(string.rep("-", self.session.width)); return true, ("%d clients"):format(#clients); end + + function console_env.user:revoke_client(user_jid, selector) -- luacheck: ignore 212/self + local username, host = jid.split(user_jid); + local mod = prosody.hosts[host] and prosody.hosts[host].modules.client_management; + if not mod then + return false, ("Host does not exist on this server, or does not have mod_client_management loaded"); + end + + local revoked, err = revocation_errors.coerce(mod.revoke_client_access(username, selector)); + if not revoked then + return false, err.text or err; + end + return true, "Client access revoked"; + end end);
--- a/mod_cloud_notify_extensions/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_cloud_notify_extensions/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -38,13 +38,10 @@ There is no configuration for this module, just add it to modules\_enabled as normal. -Compatibility -============= +# Compatibility - ----- ------- - 0.12 Works - ----- ------- - 0.11 Should work - ----- ------- - trunk Works - ----- ------- + ------- ------------- + 0.12 Works + 0.11 Should work + trunk Works + ------- -------------
--- a/mod_compat_roles/mod_compat_roles.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_compat_roles/mod_compat_roles.lua Mon Sep 18 08:24:19 2023 -0500 @@ -33,8 +33,12 @@ local role_inheritance = { ["prosody:operator"] = "prosody:admin"; - ["prosody:admin"] = "prosody:user"; - ["prosody:user"] = "prosody:restricted"; + ["prosody:admin"] = "prosody:member"; + ["prosody:member"] = "prosody:registered"; + ["prosody:registered"] = "prosody:guest"; + + -- COMPAT + ["prosody:user"] = "prosody:registered"; }; local function role_may(host, role_name, permission)
--- a/mod_default_bookmarks/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_default_bookmarks/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -31,13 +31,15 @@ Then add a list of the default rooms you want: - default_bookmarks = { - { jid = "room@conference.example.com", name = "The Room" }; - -- Specifying a password is supported: - { jid = "secret-room@conference.example.com", name = "A Secret Room", password = "secret" }; - -- You can also use this compact syntax: - "yetanother@conference.example.com"; -- this will get "yetanother" as name - }; +``` lua +default_bookmarks = { + { jid = "room@conference.example.com"; name = "The Room"; autojoin = true }; + -- Specifying a password is supported: + { jid = "secret-room@conference.example.com"; name = "A Secret Room"; password = "secret"; autojoin = true }; + -- You can also use this compact syntax: + "yetanother@conference.example.com"; -- this will get "yetanother" as name +}; +``` Compatibility -------------
--- a/mod_firewall/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_firewall/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -10,6 +10,8 @@ mod_firewall.definitions: definitions.lib.lua mod_firewall.marks: marks.lib.lua mod_firewall.test: test.lib.lua + copy_directories: + - scripts --- ------------------------------------------------------------------------ @@ -253,12 +255,13 @@ ### Sender/recipient matching - Condition Matches - ------------- ------------------------------------------------------- - `FROM` The JID in the 'from' attribute matches the given JID. - `TO` The JID in the 'to' attribute matches the given JID. - `TO SELF` The stanza is sent by any of a user's resources to their own bare JID. - `TO FULL JID` The stanza is addressed to a valid full JID on the local server (full JIDs include a resource at the end, and only exist for the lifetime of a single session, therefore the recipient must be online, or this check will not match). + Condition Matches + --------------- ------------------------------------------------------- + `FROM` The JID in the 'from' attribute matches the given JID. + `TO` The JID in the 'to' attribute matches the given JID. + `TO SELF` The stanza is sent by any of a user's resources to their own bare JID. + `TO FULL JID` The stanza is addressed to a **valid** full JID on the local server (full JIDs include a resource at the end, and only exist for the lifetime of a single session, therefore the recipient **must be online**, or this check will not match). + `FROM FULL JID` The stanza is from a full JID (unlike `TO FULL JID` this check is on the format of the JID only). The TO and FROM conditions both accept wildcards in the JID when it is enclosed in angle brackets ('\<...\>'). For example:
--- a/mod_firewall/actions.lib.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_firewall/actions.lib.lua Mon Sep 18 08:24:19 2023 -0500 @@ -220,11 +220,29 @@ end function action_handlers.MARK_USER(name) - return [[if session.firewall_marks then session.firewall_marks.]]..idsafe(name)..[[ = current_timestamp; end]], { "timestamp" }; + return ([[if session.username and session.host == current_host then + fire_event("firewall/marked/user", { + username = session.username; + mark = %q; + timestamp = current_timestamp; + }); + else + log("warn", "Attempt to MARK a remote user - only local users may be marked"); + end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name)), { + "current_host"; + "timestamp"; + }; end function action_handlers.UNMARK_USER(name) - return [[if session.firewall_marks then session.firewall_marks.]]..idsafe(name)..[[ = nil; end]], { "timestamp" }; + return ([[if session.username and session.host == current_host then + fire_event("firewall/unmarked/user", { + username = session.username; + mark = %q; + }); + else + log("warn", "Attempt to UNMARK a remote user - only local users may be marked"); + end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name)); end function action_handlers.ADD_TO(spec)
--- a/mod_firewall/conditions.lib.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_firewall/conditions.lib.lua Mon Sep 18 08:24:19 2023 -0500 @@ -67,6 +67,10 @@ return compile_jid_match("from", from), { "split_from" }; end +function condition_handlers.FROM_FULL_JID() + return "not "..compile_jid_match_part("from_resource", nil), { "split_from" }; +end + function condition_handlers.FROM_EXACTLY(from) local metadeps = {}; return ("from == %s"):format(metaq(from, metadeps)), { "from", unpack(metadeps) }; @@ -310,7 +314,9 @@ error("Error parsing mark name, see documentation for usage examples"); end if time then - return ("(current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0)) < %d"):format(idsafe(name), tonumber(time)), { "timestamp" }; + return ([[( + current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0) + ) < %d]]):format(idsafe(name), tonumber(time)), { "timestamp" }; end return ("not not (session.firewall_marks and session.firewall_marks."..idsafe(name)..")"); end @@ -341,7 +347,13 @@ if not (search_name) then error("Error parsing SCAN expression, syntax: SEARCH for PATTERN in LIST"); end - return ("scan_list(list_%s, %s)"):format(list_name, "tokens_"..search_name.."_"..pattern_name), { "scan_list", "tokens:"..search_name.."-"..pattern_name, "list:"..list_name }; + return ("scan_list(list_%s, %s)"):format( + list_name, + "tokens_"..search_name.."_"..pattern_name + ), { + "scan_list", + "tokens:"..search_name.."-"..pattern_name, "list:"..list_name + }; end -- COUNT: lines in body < 10 @@ -361,7 +373,12 @@ end local comp_op = comparator_expression:gsub("%s+", ""); assert(valid_comp_ops[comp_op], "Error parsing COUNT expression, unknown comparison operator: "..comp_op); - return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format(search_name, pattern_name, comp_op, value), { "it_count", "search:"..search_name, "pattern:"..pattern_name }; + return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format( + search_name, pattern_name, comp_op, value + ), { + "it_count", + "search:"..search_name, "pattern:"..pattern_name + }; end return condition_handlers;
--- a/mod_firewall/marks.lib.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_firewall/marks.lib.lua Mon Sep 18 08:24:19 2023 -0500 @@ -1,23 +1,35 @@ local mark_storage = module:open_store("firewall_marks"); +local mark_map_storage = module:open_store("firewall_marks", "map"); local user_sessions = prosody.hosts[module.host].sessions; -module:hook("resource-bind", function (event) - local session = event.session; - local username = session.username; - local user = user_sessions[username]; - local marks = user.firewall_marks; - if not marks then - marks = mark_storage:get(username) or {}; - user.firewall_marks = marks; -- luacheck: ignore 122 +module:hook("firewall/marked/user", function (event) + local user = user_sessions[event.username]; + local marks = user and user.firewall_marks; + if user and not marks then + -- Load marks from storage to cache on the user object + marks = mark_storage:get(event.username) or {}; + user.firewall_marks = marks; --luacheck: ignore 122 + end + if marks then + marks[event.mark] = event.timestamp; + end + local ok, err = mark_map_storage:set(event.username, event.mark, event.timestamp); + if not ok then + module:log("error", "Failed to mark user %q with %q: %s", event.username, event.mark, err); end - session.firewall_marks = marks; -end); + return true; +end, -1); -module:hook("resource-unbind", function (event) - local session = event.session; - local username = session.username; - local marks = session.firewall_marks; - mark_storage:set(username, marks); -end); - +module:hook("firewall/unmarked/user", function (event) + local user = user_sessions[event.username]; + local marks = user and user.firewall_marks; + if marks then + marks[event.mark] = nil; + end + local ok, err = mark_map_storage:set(event.username, event.mark, nil); + if not ok then + module:log("error", "Failed to unmark user %q with %q: %s", event.username, event.mark, err); + end + return true; +end, -1);
--- a/mod_firewall/mod_firewall.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_firewall/mod_firewall.lua Mon Sep 18 08:24:19 2023 -0500 @@ -316,7 +316,7 @@ local condition_handlers = module:require("conditions"); local action_handlers = module:require("actions"); -if module:get_option_boolean("firewall_experimental_user_marks", false) then +if module:get_option_boolean("firewall_experimental_user_marks", true) then module:require"marks"; end @@ -742,3 +742,43 @@ print("end -- End of file "..filename); end end + + +-- Console + +local console_env = module:shared("/*/admin_shell/env"); + +console_env.firewall = {}; + +function console_env.firewall:mark(user_jid, mark_name) + local username, host = jid.split(user_jid); + if not username or not hosts[host] then + return nil, "Invalid JID supplied"; + elseif not idsafe(mark_name) then + return nil, "Invalid characters in mark name"; + end + if not module:context(host):fire_event("firewall/marked/user", { + username = session.username; + mark = mark_name; + timestamp = os.time(); + }) then + return nil, "Mark not set - is mod_firewall loaded on that host?"; + end + return true, "User marked"; +end + +function console_env.firewall:unmark(jid, mark_name) + local username, host = jid.split(user_jid); + if not username or not hosts[host] then + return nil, "Invalid JID supplied"; + elseif not idsafe(mark_name) then + return nil, "Invalid characters in mark name"; + end + if not module:context(host):fire_event("firewall/unmarked/user", { + username = session.username; + mark = mark_name; + }) then + return nil, "Mark not removed - is mod_firewall loaded on that host?"; + end + return true, "User unmarked"; +end
--- a/mod_firewall/scripts/spam-blocking.pfw Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_firewall/scripts/spam-blocking.pfw Mon Sep 18 08:24:19 2023 -0500 @@ -97,6 +97,12 @@ TYPE: groupchat PASS. +# Mediated MUC invitations are naturally from 'strangers' and have special +# handling. We lean towards accepting them, unless overridden by custom rules. +NOT FROM FULL JID? +INSPECT: {http://jabber.org/protocol/muc#user}x/invite +JUMP CHAIN=user/spam_check_muc_invite + # Non-chat message types often generate pop-ups in clients, # so we won't accept them from strangers NOT TYPE: chat @@ -138,6 +144,18 @@ ################################################################## +#### Rules for MUC invitations ################################### + +::user/spam_check_muc_invite + +# This chain can be used to inspect the invitation and determine +# the appropriate action. Otherwise, we proceed with the default +# action below. +JUMP CHAIN=user/spam_check_muc_invite_custom + +# Allow mediated MUC invitations by default +PASS. + #### Stanzas reaching this chain will be rejected ################ ::user/spam_reject @@ -151,7 +169,7 @@ ################################################################## -#### Stanzas that may be spam, but we're not sure either way###### +#### Stanzas that may be spam, but we're not sure either way ##### ::user/spam_handle_unknown # This chain can be used by other scripts
--- a/mod_firewall/scripts/spam-blocklists.pfw Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_firewall/scripts/spam-blocklists.pfw Mon Sep 18 08:24:19 2023 -0500 @@ -8,3 +8,13 @@ CHECK LIST: blocklist contains $<@from|host> BOUNCE=policy-violation (Your server is blocked due to spam) + +::user/spam_check_muc_invite_custom + +# Check the server we received the invitation from +CHECK LIST: blocklist contains $<@from|host> +BOUNCE=policy-violation (Your server is blocked due to spam) + +# Check the inviter's JID against the blocklist, too +CHECK LIST: blocklist contains $<{http://jabber.org/protocol/muc#user}x/invite@from|host> +BOUNCE=policy-violation (Your server is blocked due to spam)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_groups_oidc/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,12 @@ +--- +summary: OIDC group membership in UserInfo +labels: +- Stage-Alpha +rockspec: + dependencies: + - mod_http_oauth2 >= 200 + - mod_groups_internal +--- + +This module exposes [mod_groups_internal] groups to +[OAuth 2.0][mod_http_oauth2] clients via a `groups` scope/claim.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_groups_oidc/mod_groups_oidc.lua Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,15 @@ +local array = require "util.array"; + +module:add_item("openid-claim", "groups"); + +local group_memberships = module:open_store("groups", "map"); +local function user_groups(username) + return pairs(group_memberships:get_all(username) or {}); +end + +module:hook("token/userinfo", function(event) + local userinfo = event.userinfo; + if event.claims:contains("groups") then + userinfo.groups = array(user_groups(event.username)); + end +end);
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_debug/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,40 @@ +--- +summary: HTTP module returning info about requests for debugging +--- + +This module returns some info about HTTP requests as Prosody sees them +from an endpoint like `http://xmpp.example.net:5281/debug`. This can be +used to validate [reverse-proxy configuration][doc:http] and similar use +cases. + +# Example + +``` +$ curl -sSf https://xmpp.example.net:5281/debug | json_pp +{ + "body" : "", + "headers" : { + "accept" : "*/*", + "host" : "xmpp.example.net:5281", + "user_agent" : "curl/7.74.0" + }, + "httpversion" : "1.1", + "id" : "jmFROQKoduU3", + "ip" : "127.0.0.1", + "method" : "GET", + "path" : "/debug", + "secure" : true, + "url" : { + "path" : "/debug" + } +} +``` + +# Configuration + +HTTP Methods handled can be configured via the `http_debug_methods` +setting. By default, the most common methods are already enabled. + +```lua +http_debug_methods = { "GET"; "HEAD"; "DELETE"; "OPTIONS"; "PATCH"; "POST"; "PUT" }; +```
--- a/mod_http_debug/mod_http_debug.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_debug/mod_http_debug.lua Mon Sep 18 08:24:19 2023 -0500 @@ -1,26 +1,34 @@ local json = require "util.json" module:depends("http") +local function handle_request(event) + local request = event.request; + (request.log or module._log)("debug", "%s -- %s %q HTTP/%s -- %q -- %s", request.ip, request.method, request.url, request.httpversion, request.headers, request.body); + return { + status_code = 200; + headers = { content_type = "application/json" }; + host = module.host; + body = json.encode { + body = request.body; + headers = request.headers; + httpversion = request.httpversion; + id = request.id; + ip = request.ip; + method = request.method; + path = request.path; + secure = request.secure; + url = request.url; + }; + } +end + +local methods = module:get_option_set("http_debug_methods", { "GET"; "HEAD"; "DELETE"; "OPTIONS"; "PATCH"; "POST"; "PUT" }); +local route = {}; +for method in methods do + route[method] = handle_request; + route[method .. " /*"] = handle_request; +end + module:provides("http", { - route = { - GET = function(event) - local request = event.request; - return { - status_code = 200; - headers = { - content_type = "application/json", - }, - body = json.encode { - body = request.body; - headers = request.headers; - httpversion = request.httpversion; - ip = request.ip; - method = request.method; - path = request.path; - secure = request.secure; - url = request.url; - } - } - end; - } - }) + route = route; +})
--- a/mod_http_dir_listing/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_dir_listing/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -2,9 +2,9 @@ rockspec: build: copy_directories: - - mod_http_dir_listing/http_dir_listing/resources + - http_dir_listing/resources modules: - mod_http_dir_listing: mod_http_dir_listing/http_dir_listing/mod_http_dir_listing.lua + mod_http_dir_listing: http_dir_listing/mod_http_dir_listing.lua summary: HTTP directory listing ...
--- a/mod_http_dir_listing2/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_dir_listing2/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -1,6 +1,10 @@ --- summary: HTTP directory listing -... +rockspec: + build: + copy_directories: + - resources +--- Introduction ============
--- a/mod_http_muc_log/mod_http_muc_log.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_muc_log/mod_http_muc_log.lua Mon Sep 18 08:24:19 2023 -0500 @@ -128,17 +128,42 @@ local presence_logged = module:get_option_boolean("muc_log_presences", false); -local function hide_presence(request) +local function show_presence(request) --> boolean|nil + -- boolean -> yes or no + -- nil -> dunno if not presence_logged then - return false; + -- No presence stored, skip + return nil; end if request.url.query then local data = httplib.formdecode(request.url.query); - if data then - return data.p == "h" + if type(data) == "table" then + if data.p == "s" or data.p == "h" then + return data.p == "s"; + end end end - return false; +end + +local function presence_with(request) + local show = show_presence(request); + if show == true then + return nil; -- no filter, everything + elseif show == false or show == nil then + -- only messages + return "message<groupchat"; + end +end + +local function presence_query(request) -- > ?p=[sh] + local show = show_presence(request); + if show == true then + return { p = "s" } + elseif show == false then + return { p = "h" } + else + return nil; + end end local function get_dates(room) --> { integer, ... } @@ -254,7 +279,8 @@ room = room_obj._data; jid = room_obj.jid; jid_node = jid_split(room_obj.jid); - hide_presence = hide_presence(request); + q = presence_query(request); + show_presence = show_presence(request); presence_available = presence_logged; dates = date_list; links = { @@ -268,10 +294,16 @@ local function logs_page(event, path) local request, response = event.request, event.response; - local room, date = path:match("^([^/]+)/([^/]*)/?$"); - if not room then + -- /room --> 303 /room/ + -- /room/ --> calendar view + -- /room/yyyy-mm-dd --> logs view + -- /room/yyyy-mm-dd/* --> 404 + local room, date = path:match("^([^/]+)/([^/]*)$"); + if not room and not path:find"/" then response.headers.location = url.build({ path = path .. "/" }); return 303; + elseif not room then + return 404; end room = nodeprep(room); if not room then @@ -300,7 +332,7 @@ local iter, err = archive:find(room, { ["start"] = day_start; ["end"] = day_start + 86399; - ["with"] = hide_presence(request) and "message<groupchat" or nil; + ["with"] = presence_with(request); }); if not iter then module:log("warn", "Could not search archive: %s", err or "no error"); @@ -475,7 +507,8 @@ room = room_obj._data; jid = room_obj.jid; jid_node = jid_split(room_obj.jid); - hide_presence = hide_presence(request); + q = presence_query(request); + show_presence = show_presence(request); presence_available = presence_logged; lang = room_obj.get_language and room_obj:get_language(); lines = logs; @@ -524,7 +557,8 @@ static = "./@static"; title = module:get_option_string("name", "Prosody Chatrooms"); jid = module.host; - hide_presence = hide_presence(request); + q = presence_query(request); + show_presence = show_presence(request); presence_available = presence_logged; rooms = room_list; dates = {}; -- COMPAT util.interpolation {nil|func#...} bug
--- a/mod_http_muc_log/res/http_muc_log.html Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_muc_log/res/http_muc_log.html Mon Sep 18 08:24:19 2023 -0500 @@ -19,7 +19,7 @@ <li class="button"><a href="{room.webchat_url}">Join via web</a></li> } {links# -<li><a class="{item.rel?}" href="{item.href}{hide_presence&?p=h}" rel="{item.rel?}">{item.text}</a></li>} +<li><a class="{item.rel?}" href="{item.href}{q&?{q%{idx}={item}}}" rel="{item.rel?}">{item.text}</a></li>} </ul> </nav> </header> @@ -28,7 +28,7 @@ <nav> <dl class="room-list"> {rooms# -<dt {item.lang&lang="{item.lang}"} class="name"><a href="{item.href}{hide_presence&?p=h}">{item.name}</a></dt> +<dt {item.lang&lang="{item.lang}"} class="name"><a href="{item.href}{q&?{q%{idx}={item}}}">{item.name}</a></dt> <dd {item.lang&lang="{item.lang}"} class="description">{item.description?}</dd>} </dl> {dates|calendarize# @@ -38,7 +38,7 @@ <caption>{item.month}</caption> <thead><tr><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th><th>Sun</th></tr></thead> <tbody>{item.weeks# -<tr>{item.days#<td>{item.href&<a href="{item.href}{hide_presence&?p=h}">}<span>{item.day? }</span>{item.href&</a>}</td>}</tr>} +<tr>{item.days#<td>{item.href&<a href="{item.href}{q&?{q%{idx}={item}}}">}<span>{item.day? }</span>{item.href&</a>}</td>}</tr>} </tbody> </table> } @@ -48,8 +48,8 @@ <div> {presence_available&<form> <label> -<input name="p" value="h" type="checkbox"{hide_presence& checked}> -<span>Hide joins and parts</span> + <input name="p" value="s" type="checkbox"{show_presence& checked}> +<span>show joins and parts</span> </label> <noscript> <button type="submit">Apply</button> @@ -72,7 +72,7 @@ <footer> <nav> <ul>{links# -<li><a class="{item.rel?}" href="{item.href}{hide_presence&?p=h}" rel="{item.rel?}">{item.text}</a></li>} +<li><a class="{item.rel?}" href="{item.href}{q&?{q%{idx}={item}}}" rel="{item.rel?}">{item.text}</a></li>} </ul> </nav> <br>
--- a/mod_http_oauth2/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_oauth2/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -1,26 +1,27 @@ --- labels: - Stage-Alpha -summary: 'OAuth2 API' rockspec: build: copy_directories: - html -... +summary: OAuth 2.0 Authorization Server API +--- ## Introduction This module implements an [OAuth2](https://oauth.net/2/)/[OpenID Connect -(OIDC)](https://openid.net/connect/) provider HTTP frontend on top of +(OIDC)](https://openid.net/connect/) Authorization Server on top of Prosody's usual internal authentication backend. OAuth and OIDC are web standards that allow you to provide clients and third-party applications limited access to your account, without sharing your password with them. -With this module deployed, software that supports OAuth can obtain "access -tokens" from Prosody which can then be used to connect to XMPP accounts using -the 'OAUTHBEARER' SASL mechanism or via non-XMPP interfaces such as [mod_rest]. +With this module deployed, software that supports OAuth can obtain +"access tokens" from Prosody which can then be used to connect to XMPP +accounts using the [OAUTHBEARER SASL mechanism][rfc7628] or via non-XMPP +interfaces such as [mod_rest]. Although this module has been around for some time, it has recently been significantly extended and largely rewritten to support OAuth/OIDC more fully. @@ -36,9 +37,10 @@ - [example shell script for mod_rest](https://hg.prosody.im/prosody-modules/file/tip/mod_rest/example/rest.sh) - *(we need you!)* -Support for OAUTHBEARER has been added to the Lua XMPP library, [verse](https://code.matthewwild.co.uk/verse). -If you know of additional implementations, or are motivated to work on one, -please let us know! We'd be happy to help (e.g. by providing a test server). +Support for [OAUTHBEARER][rfc7628] has been added to the Lua XMPP +library, [verse](https://code.matthewwild.co.uk/verse). If you know of +additional implementations, or are motivated to work on one, please let +us know! We'd be happy to help (e.g. by providing a test server). ## Standards support @@ -46,11 +48,14 @@ - [RFC 6749: The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749) - [RFC 7009: OAuth 2.0 Token Revocation](https://www.rfc-editor.org/rfc/rfc7009) +- [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html) - [RFC 7628: A Set of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth](https://www.rfc-editor.org/rfc/rfc7628) - [RFC 7636: Proof Key for Code Exchange by OAuth Public Clients](https://www.rfc-editor.org/rfc/rfc7636) +- [RFC 8628: OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628) +- [RFC 9207: OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html) - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) -- [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) & [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html) -- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) +- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) (_partial, e.g. missing JWKS_) +- [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) ## Configuration @@ -60,7 +65,7 @@ a client requests access. Built-in pages are provided, but you may also theme or entirely override them. -This module honours the 'site_name' configuration option that is also used by +This module honours the `site_name` configuration option that is also used by a number of other modules: ```lua @@ -73,13 +78,11 @@ oauth2_template_path = "/etc/prosody/custom-oauth2-templates" ``` -Some templates support additional variables, that can be provided by the -'oauth2_template_style' option: +If you know what features your templates use use you can adjust the +`Content-Security-Policy` header to only allow what is needed: ```lua -oauth2_template_style = { - background_colour = "#ffffff"; -} +oauth2_security_policy = "default-src 'self'" -- this is the default ``` ### Token parameters @@ -88,8 +91,8 @@ The defaults are recommended. ```lua -oauth2_access_token_ttl = 86400 -- 24 hours -oauth2_refresh_token_ttl = nil -- unlimited unless revoked by the user +oauth2_access_token_ttl = 3600 -- one hour +oauth2_refresh_token_ttl = 604800 -- one week ``` ### Dynamic client registration @@ -106,14 +109,110 @@ oauth2_registration_ttl = nil -- unlimited by default ``` +Registering a client is described in +[RFC7591](https://www.rfc-editor.org/rfc/rfc7591.html). + +In addition to the requirements in the RFC, the following requirements +are enforced: + +`client_name` +: **MUST** be present, is shown to users in consent screen. + +`client_uri` +: **MUST** be present and **MUST** be a `https://` URL. + +`redirect_uris` + +: **MUST** contain at least one valid URI. Different rules apply + depending on the value of `application_type`, see below. + +`application_type` + +: Optional, defaults to `web`. Determines further restrictions for + `redirect_uris`. The following values are supported: + + `web` *(default)* + : For web clients. With this, `redirect_uris` **MUST** be + `https://` URIs and **MUST** use the same hostname part as the + `client_uri`. + + `native` + : For native e.g. desktop clients etc. `redirect_uris` **MUST** + match one of: + + - Loopback HTTP URI, e.g. `http://127.0.0.1/` or + `http://[::1]` + - Application-specific scheme, e.g. `com.example.app:/` + - The special OOB URI `urn:ietf:wg:oauth:2.0:oob` + +`tos_uri`, `policy_uri` +: Informative URLs pointing to Terms of Service and Service Policy + document **MUST** use the same scheme (i.e. `https://`) and hostname + as the `client_uri`. + +#### Registration Examples + +In short registration works by POST-ing a JSON structure describing your +client to an endpoint: + +``` bash +curl -sSf https://xmpp.example.net/oauth2/register \ + -H Content-Type:application/json \ + -H Accept:application/json \ + --data ' +{ + "client_name" : "My Application", + "client_uri" : "https://app.example.com/", + "redirect_uris" : [ + "https://app.example.com/redirect" + ] +} +' +``` + +Another example with more fields: + +``` bash +curl -sSf https://xmpp.example.net/oauth2/register \ + -H Content-Type:application/json \ + -H Accept:application/json \ + --data ' +{ + "application_type" : "native", + "client_name" : "Desktop Chat App", + "client_uri" : "https://app.example.org/", + "contacts" : [ + "support@example.org" + ], + "policy_uri" : "https://app.example.org/about/privacy", + "redirect_uris" : [ + "http://localhost:8080/redirect", + "org.example.app:/redirect" + ], + "scope" : "xmpp", + "software_id" : "32a0a8f3-4016-5478-905a-c373156eca73", + "software_version" : "3.4.1", + "tos_uri" : "https://app.example.org/about/terms" +} +' +``` + ### Supported flows +- Authorization Code grant, optionally with Proof Key for Code Exchange +- Device Authorization Grant +- Resource owner password grant *(likely to be phased out in the future)* +- Implicit flow *(disabled by default)* +- Refresh Token grants + Various flows can be disabled and enabled with `allowed_oauth2_grant_types` and `allowed_oauth2_response_types`: ```lua +-- These examples reflect the defaults allowed_oauth2_grant_types = { "authorization_code"; -- authorization code grant + "device_code"; "password"; -- resource owner password grant } @@ -123,16 +222,17 @@ } ``` -The [Proof Key for Code Exchange][RFC 7636] mitigation method can be -made required: +The [Proof Key for Code Exchange][RFC 7636] mitigation method is +optional by default but can be made required: ```lua -oauth2_require_code_challenge = true +oauth2_require_code_challenge = true -- default is false ``` Further, individual challenge methods can be enabled or disabled: ```lua +-- These reflects the default allowed_oauth2_code_challenge_methods = { "plain"; -- the insecure one "S256"; @@ -147,6 +247,7 @@ ```lua oauth2_terms_url = "https://example.com/terms-of-service.html" oauth2_policy_url = "https://example.com/service-policy.pdf" +-- These are unset by default ``` ## Deployment notes @@ -156,7 +257,7 @@ This module does not provide an interface for users to manage what they have granted access to their account! (e.g. to view and revoke clients they have previously authorized). It is recommended to join this module with -mod_client_management to provide such access. However, at the time of writing, +[mod_client_management] to provide such access. However, at the time of writing, no XMPP clients currently support the protocol used by that module. We plan to work on additional interfaces in the future. @@ -164,12 +265,22 @@ OAuth supports "scopes" as a way to grant clients limited access. -There are currently no standard scopes defined for XMPP. This is something -that we intend to change, e.g. by definitions provided in a future XEP. This -means that clients you authorize currently have unrestricted access to your -account (including the ability to change your password and lock you out!). So, -for now, while using OAuth clients can prevent leaking your password to them, -it is not currently suitable for connecting untrusted clients to your account. +There are currently no standard scopes defined for XMPP. This is +something that we intend to change, e.g. by definitions provided in a +future XEP. This means that clients you authorize currently have to +choose between unrestricted access to your account (including the +ability to change your password and lock you out!) and zero access. So, +for now, while using OAuth clients can prevent leaking your password to +them, it is not currently suitable for connecting untrusted clients to +your account. + +As a first step, the `xmpp` scope is supported, and corresponds to +whatever permissions the user would have when logged in over XMPP. + +Further, known Prosody roles can be used as scopes. + +OpenID scopes such as `openid` and `profile` can be used for "Login +with XMPP" without granting access to more than limited profile details. ## Compatibility
--- a/mod_http_oauth2/html/consent.html Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_oauth2/html/consent.html Mon Sep 18 08:24:19 2023 -0500 @@ -1,21 +1,25 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> -<meta charset="utf-8"> +<meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{site_name} - Authorize {client.client_name}</title> -<link rel="stylesheet" href="style.css"> +<link rel="stylesheet" href="style.css" /> </head> <body> - <main> - {state.error&<div class="error"> +{state.error& + <dialog open="" class="error"> <p>{state.error}</p> - </div>} - + <form method="dialog"><button>dismiss</button></form> + </dialog>} + <header> <h1>{site_name}</h1> + </header> + <main> <fieldset> <legend>Authorize new application</legend> <p>A new application wants to connect to your account.</p> + <form method="post"> <dl> <dt>Name</dt> <dd>{client.client_name}</dd> @@ -29,23 +33,21 @@ {client.policy_uri& <dt>Policy</dt> <dd><a href="{client.policy_uri}">View policy</a></dd>} + + <dt>Requested permissions</dt> + <dd>{scopes# + <input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item}" checked="" /><label class="scope" for="scope_{idx}">{item}</label>} + </dd> </dl> <p>To allow <em>{client.client_name}</em> to access your account - <em>{state.user.username}@{state.user.host}</em> and associated data, - select 'Allow'. Otherwise, select 'Deny'. + <em>{state.user.username}@{state.user.host}</em> and associated data, + select 'Allow'. Otherwise, select 'Deny'. </p> - <form method="post"> - <details><summary>Requested permissions</summary>{scopes# - <input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item}" checked><label class="scope" for="scope_{idx}">{item}</label>}{roles& - <select name="role">{roles# - <option value="{item.name}"{item.selected& selected}>{item.name}</option>} - </select>} - </details> - <input type="hidden" name="user_token" value="{state.user.token}"> - <button type="submit" name="consent" value="denied">Deny</button> - <button type="submit" name="consent" value="granted">Allow</button> + <input type="hidden" name="user_token" value="{state.user.token}"> + <button type="submit" name="consent" value="denied">Deny</button> + <button type="submit" name="consent" value="granted">Allow</button> </form> </fieldset> </main>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_oauth2/html/device.html Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8" /> +<meta name="viewport" content="width=device-width, initial-scale=1" /> +<title>{site_name} - Authorize{client&d} Device</title> +<link rel="stylesheet" href="style.css" /> +</head> +<body> +{error& + <dialog open="" class="error"> + <p>{error.text}</p> + <form method="dialog"><button>dismiss</button></form> + </dialog>} + <header> + <h1>{site_name}</h1> + </header> + <main> + <fieldset> + <legend>Device Authorization</legend> +{client& + <p>Authorization completed. You can go back to + <em>{client.client_name}</em>.</p>} +{client~ + <p>Enter the code to continue.</p> + <form method="get"> + <input type="text" name="user_code" placeholder="XXXX-XXXX" aria-label="Code" required="" /> + <button type="submit">Continue</button> + </form>} + </fieldset> + </main> +</body> +</html>
--- a/mod_http_oauth2/html/error.html Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_oauth2/html/error.html Mon Sep 18 08:24:19 2023 -0500 @@ -1,14 +1,16 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> -<meta charset="utf-8"> +<meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{site_name} - Error</title> -<link rel="stylesheet" href="style.css"> +<link rel="stylesheet" href="style.css" /> </head> <body> + <header> + <h1>{site_name}</h1> + </header> <main> - <h1>{site_name}<h1> <h2>Authentication error</h2> <p>There was a problem with the authentication request. If you were trying to sign in to a third-party application, you may want to report this issue to the developers.</p>
--- a/mod_http_oauth2/html/login.html Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_oauth2/html/login.html Mon Sep 18 08:24:19 2023 -0500 @@ -1,24 +1,30 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> -<meta charset="utf-8"> +<meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{site_name} - Sign in</title> -<link rel="stylesheet" href="style.css"> +<link rel="stylesheet" href="style.css" /> </head> <body> +{state.error& + <dialog open="" class="error"> + <p>{state.error}</p> + <form method="dialog"><button>dismiss</button></form> + </dialog>} + <header> + <h1>{site_name}</h1> + </header> <main> - <h1>{site_name}</h1> <fieldset> <legend>Sign in</legend> <p>Sign in to your account to continue.</p> - {state.error&<div class="error"> - <p>{state.error}</p> - </div>} <form method="post"> - <input type="text" name="username" placeholder="Username" aria-label="Username" required autofocus><br/> - <input type="password" name="password" placeholder="Password" aria-label="Password" autocomplete="current-password" required><br/> - <input type="submit" value="Sign in"> + <input type="text" name="username" placeholder="Username" aria-label="Username" + autocomplete="username" required="" {extra.username_hint~autofocus=""} {extra.username_hint&value="{extra.username_hint?}"} /><br/> + <input type="password" name="password" placeholder="Password" aria-label="Password" + autocomplete="current-password" required="" {extra.username_hint&autofocus=""} /><br /> + <input type="submit" value="Sign in" /> </form> </fieldset> </main>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_oauth2/html/oob.html Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8" /> +<meta name="viewport" content="width=device-width, initial-scale=1" /> +<title>{site_name} - Authorization Code</title> +<link rel="stylesheet" href="style.css" /> +</head> +<body> + <header> + <h1>{site_name}</h1> + </header> + <main> + <h2>Your Authorization Code</h2> + <p>Here’s your authorization code, copy and paste it into {client.client_name}</p> + <div class="oob"> + <p><input readonly="" name="authorization_code" value="{authorization_code}" aria-label="Authorization Code"></p> + </div> + </main> +</body> +</html>
--- a/mod_http_oauth2/html/style.css Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_oauth2/html/style.css Mon Sep 18 08:24:19 2023 -0500 @@ -1,6 +1,5 @@ body { - margin-top:14%; text-align:center; background-color:#f8f8f8; font-family:sans-serif @@ -21,12 +20,28 @@ .error { - margin: 0.75em; + margin: 0.75em auto; background-color: #f8d7da; color: #842029; border: solid 1px #f5c2c7; } +.oob +{ + background-color: #d7daf8; + border: solid 1px #c2c7f5; + color: #202984; + margin: 0.75em; +} +.oob input { + font-size: xx-large; + font-family: monospace; + background-color: inherit; + color: inherit; + border: none; + padding: 1ex 2em; +} + input { margin: 0.3rem; padding: 0.2rem; @@ -37,7 +52,7 @@ text-align: left; } -main { +header, main, footer { max-width: 600px; padding: 0 1.5em 1.5em 1.5em; } @@ -71,6 +86,10 @@ color: #f8d7da; background-color: #842029; } + .oob { + color: #d7daf8; + background-color: #202984; + } :link @@ -86,7 +105,10 @@ @media(min-width: 768px) { - main + body { + margin-top:14vh; + } + header, main, footer { margin-left: auto; margin-right: auto;
--- a/mod_http_oauth2/mod_http_oauth2.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_http_oauth2/mod_http_oauth2.lua Mon Sep 18 08:24:19 2023 -0500 @@ -1,22 +1,23 @@ -local hashes = require "util.hashes"; +local usermanager = require "core.usermanager"; +local url = require "socket.url"; +local array = require "util.array"; local cache = require "util.cache"; +local encodings = require "util.encodings"; +local errors = require "util.error"; +local hashes = require "util.hashes"; local http = require "util.http"; +local id = require "util.id"; +local it = require "util.iterators"; local jid = require "util.jid"; local json = require "util.json"; -local usermanager = require "core.usermanager"; -local errors = require "util.error"; -local url = require "socket.url"; -local id = require "util.id"; -local encodings = require "util.encodings"; -local base64 = encodings.base64; +local schema = require "util.jsonschema"; +local jwt = require "util.jwt"; local random = require "util.random"; -local schema = require "util.jsonschema"; local set = require "util.set"; -local jwt = require"util.jwt"; -local it = require "util.iterators"; -local array = require "util.array"; local st = require "util.stanza"; +local base64 = encodings.base64; + local function b64url(s) return (base64.encode(s):gsub("[+/=]", { ["+"] = "-", ["/"] = "_", ["="] = "" })) end @@ -27,6 +28,24 @@ end end +local function strict_formdecode(query) + if not query then + return nil; + end + local params = http.formdecode(query); + if type(params) ~= "table" then + return nil, "no-pairs"; + end + local dups = {}; + for _, pair in ipairs(params) do + if dups[pair.name] then + return nil, "duplicate"; + end + dups[pair.name] = true; + end + return params; +end + local function read_file(base_path, fn, required) local f, err = io.open(base_path .. "/" .. fn); if not f then @@ -41,10 +60,15 @@ return data; end +local allowed_locales = module:get_option_array("allowed_oauth2_locales", {}); +-- TODO Allow translations or per-locale templates somehow. + local template_path = module:get_option_path("oauth2_template_path", "html"); local templates = { login = read_file(template_path, "login.html", true); consent = read_file(template_path, "consent.html", true); + oob = read_file(template_path, "oob.html", true); + device = read_file(template_path, "device.html", true); error = read_file(template_path, "error.html", true); css = read_file(template_path, "style.css"); js = read_file(template_path, "script.js"); @@ -52,27 +76,33 @@ local site_name = module:get_option_string("site_name", module.host); -local _render_html = require"util.interpolation".new("%b{}", st.xml_escape); +local security_policy = module:get_option_string("oauth2_security_policy", "default-src 'self'"); + +local render_html = require"util.interpolation".new("%b{}", st.xml_escape); local function render_page(template, data, sensitive) data = data or {}; data.site_name = site_name; local resp = { - status_code = 200; + status_code = data.error and data.error.code or 200; headers = { ["Content-Type"] = "text/html; charset=utf-8"; - ["Content-Security-Policy"] = "default-src 'self'"; + ["Content-Security-Policy"] = security_policy; + ["Referrer-Policy"] = "no-referrer"; ["X-Frame-Options"] = "DENY"; ["Cache-Control"] = (sensitive and "no-store" or "no-cache")..", private"; + ["Pragma"] = "no-cache"; }; - body = _render_html(template, data); + body = render_html(template, data); }; return resp; end +local authorization_server_metadata = nil; + local tokens = module:depends("tokenauth"); -local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 86400); -local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", nil); +local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 3600); +local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", 604800); -- Used to derive client_secret from client_id, set to enable stateless dynamic registration. local registration_key = module:get_option_string("oauth2_registration_key"); @@ -84,26 +114,60 @@ local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", false); local verification_key; -local jwt_sign, jwt_verify; +local sign_client, verify_client; if registration_key then -- Tie it to the host if global verification_key = hashes.hmac_sha256(registration_key, module.host); - jwt_sign, jwt_verify = jwt.init(registration_algo, registration_key, registration_key, registration_options); + sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options); end +local new_device_token, verify_device_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 }); + +-- verify and prepare client structure +local function check_client(client_id) + if not verify_client then + return nil, "client-registration-not-enabled"; + end + + local ok, client = verify_client(client_id); + if not ok then + return ok, client; + end + + client.client_hash = b64url(hashes.sha256(client_id)); + return client; +end + +-- scope : string | array | set +-- +-- at each step, allow the same or a subset of scopes +-- (all ( client ( grant ( token ) ) )) +-- preserve order since it determines role if more than one granted + +-- string -> array local function parse_scopes(scope_string) return array(scope_string:gmatch("%S+")); end -local openid_claims = set.new({ "openid", "profile"; "email"; "address"; "phone" }); +local openid_claims = set.new(); +module:add_item("openid-claim", "openid"); +module:handle_items("openid-claim", function(event) + authorization_server_metadata = nil; + openid_claims:add(event.item); +end, function() + authorization_server_metadata = nil; + openid_claims = set.new(module:get_host_items("openid-claim")); +end, true); + +-- array -> array, array, array local function split_scopes(scope_list) local claims, roles, unknown = array(), array(), array(); local all_roles = usermanager.get_all_roles(module.host); for _, scope in ipairs(scope_list) do if openid_claims:contains(scope) then claims:push(scope); - elseif all_roles[scope] then + elseif scope == "xmpp" or all_roles[scope] then roles:push(scope); else unknown:push(scope); @@ -113,32 +177,29 @@ end local function can_assume_role(username, requested_role) - return usermanager.user_can_assume_role(username, module.host, requested_role); + return requested_role == "xmpp" or usermanager.user_can_assume_role(username, module.host, requested_role); +end + +-- function (string) : function(string) : boolean +local function role_assumable_by(username) + return function(role) + return can_assume_role(username, role); + end end -local function select_role(username, requested_roles) - if requested_roles then - for _, requested_role in ipairs(requested_roles) do - if can_assume_role(username, requested_role) then - return requested_role; - end - end - end - -- otherwise the default role - return usermanager.get_user_role(username, module.host).name; +-- string, array --> array +local function user_assumable_roles(username, requested_roles) + return array.filter(requested_roles, role_assumable_by(username)); end +-- string, string|nil --> string, string local function filter_scopes(username, requested_scope_string) - local granted_scopes, requested_roles; + local requested_scopes, requested_roles = split_scopes(parse_scopes(requested_scope_string or "")); - if requested_scope_string then -- Specific role(s) requested - granted_scopes, requested_roles = split_scopes(parse_scopes(requested_scope_string)); - else - granted_scopes = array(); - end + local granted_roles = user_assumable_roles(username, requested_roles); + local granted_scopes = requested_scopes + granted_roles; - local selected_role = select_role(username, requested_roles); - granted_scopes:push(selected_role); + local selected_role = granted_roles[1]; return granted_scopes:concat(" "), selected_role; end @@ -155,9 +216,8 @@ return code_expired(code) end); --- Periodically clear out unredeemed codes. Does not need to be exact, expired --- codes are rejected if tried. Mostly just to keep memory usage in check. -module:hourly("Clear expired authorization codes", function() +-- Clear out unredeemed codes so they don't linger in memory. +module:daily("Clear expired authorization codes", function() local k, code = codes:tail(); while code and code_expired(code) do codes:set(k, nil); @@ -169,11 +229,13 @@ return (module:http_url(nil, "/"):gsub("/$", "")); end +-- Non-standard special redirect URI that has the AS show the authorization +-- code to the user for them to copy-paste into the client, which can then +-- continue as if it received it via redirect. +local oob_uri = "urn:ietf:wg:oauth:2.0:oob"; +local device_uri = "urn:ietf:params:oauth:grant-type:device_code"; + local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" }); -local function is_secure_redirect(uri) - local u = url.parse(uri); - return u.scheme ~= "http" or loopbacks:contains(u.host); -end local function oauth_error(err_name, err_desc) return errors.new({ @@ -189,7 +251,13 @@ -- properties that are deemed useful e.g. in case tokens issued to a certain -- client needs to be revoked local function client_subset(client) - return { name = client.client_name; uri = client.client_uri; id = client.software_id; version = client.software_version }; + return { + name = client.client_name; + uri = client.client_uri; + id = client.software_id; + version = client.software_version; + hash = client.client_hash; + }; end local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info) @@ -201,21 +269,30 @@ token_data = nil; end - local refresh_token; local grant = refresh_token_info and refresh_token_info.grant; if not grant then -- No existing grant, create one - grant = tokens.create_grant(token_jid, token_jid, default_refresh_ttl, token_data); - -- Create refresh token for the grant if desired - refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant, nil, nil, "oauth2-refresh"); - else - -- Grant exists, reuse existing refresh token - refresh_token = refresh_token_info.token; - - refresh_token_info.grant = nil; -- Prevent reference loop + grant = tokens.create_grant(token_jid, token_jid, nil, token_data); end - local access_token, access_token_info = tokens.create_token(token_jid, grant, role, default_access_ttl, "oauth2"); + if refresh_token_info then + -- out with the old refresh tokens + local ok, err = tokens.revoke_token(refresh_token_info.token); + if not ok then + module:log("error", "Could not revoke refresh token: %s", err); + return 500; + end + end + -- in with the new refresh token + local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh"); + + if role == "xmpp" then + -- Special scope meaning the users default role. + local user_default_role = usermanager.get_user_role(jid.node(token_jid), module.host); + role = user_default_role and user_default_role.name; + end + + local access_token, access_token_info = tokens.create_token(token_jid, grant.id, role, default_access_ttl, "oauth2"); local expires_at = access_token_info.expires; return { @@ -228,6 +305,17 @@ }; end +local function normalize_loopback(uri) + local u = url.parse(uri); + if u.scheme == "http" and loopbacks:contains(u.host) then + u.authority = nil; + u.host = "::1"; + u.port = nil; + return url.build(u); + end + -- else, not a valid loopback uri +end + local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string if not query_redirect_uri then if #client.redirect_uris ~= 1 then @@ -237,18 +325,47 @@ -- When only a single URI is registered, that's the default return client.redirect_uris[1]; end + if query_redirect_uri == device_uri and client.grant_types then + for _, grant_type in ipairs(client.grant_types) do + if grant_type == device_uri then + return query_redirect_uri; + end + end + -- Tried to use device authorization flow without registering it. + return; + end -- Verify the client-provided URI matches one previously registered for _, redirect_uri in ipairs(client.redirect_uris) do if query_redirect_uri == redirect_uri then return redirect_uri end end + -- The authorization server MUST allow any port to be specified at the time + -- of the request for loopback IP redirect URIs, to accommodate clients that + -- obtain an available ephemeral port from the operating system at the time + -- of the request. + -- https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-08.html#section-8.4.2 + local loopback_redirect_uri = normalize_loopback(query_redirect_uri); + if loopback_redirect_uri then + for _, redirect_uri in ipairs(client.redirect_uris) do + if loopback_redirect_uri == normalize_loopback(redirect_uri) then + return query_redirect_uri; + end + end + end end local grant_type_handlers = {}; local response_type_handlers = {}; local verifier_transforms = {}; +function grant_type_handlers.implicit() + -- Placeholder to make discovery work correctly. + -- Access tokens are delivered via redirect when using the implict flow, not + -- via the token endpoint, so how did you get here? + return oauth_error("invalid_request"); +end + function grant_type_handlers.password(params) local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)")); local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'")); @@ -277,8 +394,19 @@ return oauth_error("invalid_request", "PKCE required"); end + local prefix = "authorization_code:"; local code = id.medium(); - local ok = codes:set(params.client_id .. "#" .. code, { + if params.redirect_uri == device_uri then + local is_device, device_state = verify_device_token(params.state); + if is_device then + -- reconstruct the device_code + prefix = "device_code:"; + code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code)); + else + return oauth_error("invalid_request"); + end + end + local ok = codes:set(prefix.. params.client_id .. "#" .. code, { expires = os.time() + 600; granted_jid = granted_jid; granted_scopes = granted_scopes; @@ -288,29 +416,21 @@ id_token = id_token; }); if not ok then - return {status_code = 429}; + return oauth_error("temporarily_unavailable"); end local redirect_uri = get_redirect_uri(client, params.redirect_uri); - if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" then - -- TODO some nicer template page - -- mod_http_errors will set content-type to text/html if it catches this - -- event, if not text/plain is kept for the fallback text. - local response = { status_code = 200; headers = { content_type = "text/plain" } } - response.body = module:context("*"):fire_event("http-message", { - response = response; - title = "Your authorization code"; - message = "Here's your authorization code, copy and paste it into " .. (client.client_name or "your client"); - extra = code; - }) or ("Here's your authorization code:\n%s\n"):format(code); - return response; + if redirect_uri == oob_uri then + return render_page(templates.oob, { client = client; authorization_code = code }, true); + elseif redirect_uri == device_uri then + return render_page(templates.device, { client = client }, true); elseif not redirect_uri then - return 400; + return oauth_error("invalid_redirect_uri"); end local redirect = url.parse(redirect_uri); - local query = http.formdecode(redirect.query or ""); + local query = strict_formdecode(redirect.query); if type(query) ~= "table" then query = {}; end table.insert(query, { name = "code", value = code }); table.insert(query, { name = "iss", value = get_issuer() }); @@ -322,6 +442,8 @@ return { status_code = 303; headers = { + cache_control = "no-store"; + pragma = "no-cache"; location = url.build(redirect); }; } @@ -337,13 +459,15 @@ local token_info = new_access_token(granted_jid, granted_role, granted_scopes, client, nil); local redirect = url.parse(get_redirect_uri(client, params.redirect_uri)); - if not redirect then return 400; end + if not redirect then return oauth_error("invalid_redirect_uri"); end token_info.state = params.state; redirect.fragment = http.formencode(token_info); return { status_code = 303; headers = { + cache_control = "no-store"; + pragma = "no-cache"; location = url.build(redirect); }; } @@ -362,11 +486,12 @@ if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end if not params.code then return oauth_error("invalid_request", "missing 'code'"); end if params.scope and params.scope ~= "" then + -- FIXME allow a subset of granted scopes return oauth_error("invalid_scope", "unknown scope requested"); end - local client_ok, client = jwt_verify(params.client_id); - if not client_ok then + local client = check_client(params.client_id); + if not client then return oauth_error("invalid_client", "incorrect credentials"); end @@ -374,11 +499,12 @@ module:log("debug", "client_secret mismatch"); return oauth_error("invalid_client", "incorrect credentials"); end - local code, err = codes:get(params.client_id .. "#" .. params.code); + local code, err = codes:get("authorization_code:" .. params.client_id .. "#" .. params.code); if err then error(err); end -- MUST NOT use the authorization code more than once, so remove it to -- prevent a second attempted use - codes:set(params.client_id .. "#" .. params.code, nil); + -- TODO if a second attempt *is* made, revoke any tokens issued + codes:set("authorization_code:" .. params.client_id .. "#" .. params.code, nil); if not code or type(code) ~= "table" or code_expired(code) then module:log("debug", "authorization_code invalid or expired: %q", code); return oauth_error("invalid_client", "incorrect credentials"); @@ -400,8 +526,8 @@ if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end if not params.refresh_token then return oauth_error("invalid_request", "missing 'refresh_token'"); end - local client_ok, client = jwt_verify(params.client_id); - if not client_ok then + local client = check_client(params.client_id); + if not client then return oauth_error("invalid_client", "incorrect credentials"); end @@ -415,12 +541,58 @@ return oauth_error("invalid_grant", "invalid refresh token"); end + local refresh_token_client = refresh_token_info.grant.data.oauth2_client; + if not refresh_token_client.hash or refresh_token_client.hash ~= client.client_hash then + module:log("warn", "OAuth client %q (%s) tried to use refresh token belonging to %q (%s)", client.client_name, client.client_hash, + refresh_token_client.name, refresh_token_client.hash); + return oauth_error("unauthorized_client", "incorrect credentials"); + end + + local refresh_scopes = refresh_token_info.grant.data.oauth2_scopes; + + if params.scope then + local granted_scopes = set.new(parse_scopes(refresh_scopes)); + local requested_scopes = parse_scopes(params.scope); + refresh_scopes = array.filter(requested_scopes, function(scope) + return granted_scopes:contains(scope); + end):concat(" "); + end + + local username = jid.split(refresh_token_info.jid); + local new_scopes, role = filter_scopes(username, refresh_scopes); + -- new_access_token() requires the actual token refresh_token_info.token = params.refresh_token; - return json.encode(new_access_token( - refresh_token_info.jid, refresh_token_info.role, refresh_token_info.grant.data.oauth2_scopes, client, nil, refresh_token_info - )); + return json.encode(new_access_token(refresh_token_info.jid, role, new_scopes, client, nil, refresh_token_info)); +end + +grant_type_handlers[device_uri] = function(params) + if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end + if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end + if not params.device_code then return oauth_error("invalid_request", "missing 'device_code'"); end + + local client = check_client(params.client_id); + if not client then + return oauth_error("invalid_client", "incorrect credentials"); + end + + if not verify_client_secret(params.client_id, params.client_secret) then + module:log("debug", "client_secret mismatch"); + return oauth_error("invalid_client", "incorrect credentials"); + end + + local code = codes:get("device_code:" .. params.client_id .. "#" .. params.device_code); + if type(code) ~= "table" or code_expired(code) then + return oauth_error("expired_token"); + elseif code.error then + return code.error; + elseif not code.granted_jid then + return oauth_error("authorization_pending"); + end + codes:set("device_code:" .. params.client_id .. "#" .. params.device_code, nil); + + return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token)); end -- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients @@ -467,7 +639,7 @@ user = { username = username; host = module.host; - token = new_user_token({ username = username, host = module.host }); + token = new_user_token({ username = username; host = module.host; auth_time = os.time() }); }; }; elseif form.user_token and form.consent then @@ -479,14 +651,14 @@ }; end - local scope = array():append(form):filter(function(field) - return field.name == "scope" or field.name == "role"; - end):pluck("value"):concat(" "); + local scopes = array():append(form):filter(function(field) + return field.name == "scope"; + end):pluck("value"); user.token = form.user_token; return { user = user; - scope = scope; + scopes = scopes; consent = form.consent == "granted"; }; end @@ -527,6 +699,7 @@ local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'")); local request_username, request_host, request_resource = jid.prepped_split(request_jid); if params.scope then + -- TODO shouldn't we support scopes / roles here? return oauth_error("invalid_scope", "unknown scope requested"); end if not request_host or request_host ~= module.host then @@ -546,18 +719,20 @@ grant_type_handlers.authorization_code = nil; end +local function render_error(err) + return render_page(templates.error, { error = err }); +end + -- OAuth errors should be returned to the client if possible, i.e. by -- appending the error information to the redirect_uri and sending the -- redirect to the user-agent. In some cases we can't do this, e.g. if -- the redirect_uri is missing or invalid. In those cases, we render an -- error directly to the user-agent. -local function error_response(request, err) - local q = request.url.query and http.formdecode(request.url.query); - local redirect_uri = q and q.redirect_uri; - if not redirect_uri or not is_secure_redirect(redirect_uri) then - module:log("warn", "Missing or invalid redirect_uri <%s>, rendering error to user-agent", redirect_uri or ""); - return render_page(templates.error, { error = err }); +local function error_response(request, redirect_uri, err) + if not redirect_uri or redirect_uri == oob_uri then + return render_error(err); end + local q = strict_formdecode(request.url.query); local redirect_query = url.parse(redirect_uri); local sep = redirect_query.query and "&" or "?"; redirect_uri = redirect_uri @@ -567,12 +742,25 @@ return { status_code = 303; headers = { + cache_control = "no-store"; + pragma = "no-cache"; location = redirect_uri; }; }; end -local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password", "refresh_token"}) +local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", { + "authorization_code"; + "password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used. + "refresh_token"; + device_uri; +}) +if allowed_grant_type_handlers:contains("device_code") then + -- expand short form because that URI is long + module:log("debug", "Expanding %q to %q in '%s'", "device_code", device_uri, "allowed_oauth2_grant_types"); + allowed_grant_type_handlers:remove("device_code"); + allowed_grant_type_handlers:add(device_uri); +end for handler_type in pairs(grant_type_handlers) do if not allowed_grant_type_handlers:contains(handler_type) then module:log("debug", "Grant type %q disabled", handler_type); @@ -607,9 +795,11 @@ local credentials = get_request_credentials(event.request); event.response.headers.content_type = "application/json"; - local params = http.formdecode(event.request.body); + event.response.headers.cache_control = "no-store"; + event.response.headers.pragma = "no-cache"; + local params = strict_formdecode(event.request.body); if not params then - return error_response(event.request, oauth_error("invalid_request")); + return oauth_error("invalid_request", "Could not parse request body as 'application/x-www-form-urlencoded'"); end if credentials and credentials.type == "basic" then @@ -621,7 +811,7 @@ local grant_type = params.grant_type local grant_handler = grant_type_handlers[grant_type]; if not grant_handler then - return error_response(event.request, oauth_error("unsupported_grant_type")); + return oauth_error("invalid_request", "No such grant type."); end return grant_handler(params); end @@ -629,55 +819,102 @@ local function handle_authorization_request(event) local request = event.request; + -- Directly returning errors to the user before we have a validated client object if not request.url.query then - return error_response(request, oauth_error("invalid_request")); + return render_error(oauth_error("invalid_request", "Missing query parameters")); end - local params = http.formdecode(request.url.query); + local params = strict_formdecode(request.url.query); if not params then - return error_response(request, oauth_error("invalid_request")); + return render_error(oauth_error("invalid_request", "Invalid query parameters")); end - if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end + if not params.client_id then + return render_error(oauth_error("invalid_request", "Missing 'client_id' parameter")); + end - local ok, client = jwt_verify(params.client_id); + local client = check_client(params.client_id); - if not ok then - return oauth_error("invalid_client", "incorrect credentials"); + if not client then + return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter")); end + local redirect_uri = get_redirect_uri(client, params.redirect_uri); + if not redirect_uri then + return render_error(oauth_error("invalid_request", "Invalid 'redirect_uri' parameter")); + end + -- From this point we know that redirect_uri is safe to use + local client_response_types = set.new(array(client.response_types or { "code" })); client_response_types = set.intersection(client_response_types, allowed_response_type_handlers); if not client_response_types:contains(params.response_type) then - return oauth_error("invalid_client", "response_type not allowed"); + return error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not allowed")); + end + + local requested_scopes = parse_scopes(params.scope or ""); + if client.scope then + local client_scopes = set.new(parse_scopes(client.scope)); + requested_scopes:filter(function(scope) + return client_scopes:contains(scope); + end); + end + + -- The 'prompt' parameter from OpenID Core + local prompt = set.new(parse_scopes(params.prompt or "select_account login consent")); + if prompt:contains("none") then + -- Client wants no interaction, only confirmation of prior login and + -- consent, but this is not implemented. + return error_response(request, redirect_uri, oauth_error("interaction_required")); + elseif not prompt:contains("select_account") and not params.login_hint then + -- TODO If the login page is split into account selection followed by login + -- (e.g. password), and then the account selection could be skipped iff the + -- 'login_hint' parameter is present. + return error_response(request, redirect_uri, oauth_error("account_selection_required")); + elseif not prompt:contains("login") then + -- Currently no cookies or such are used, so login is required every time. + return error_response(request, redirect_uri, oauth_error("login_required")); + elseif not prompt:contains("consent") then + -- Are there any circumstances when consent would be implied or assumed? + return error_response(request, redirect_uri, oauth_error("consent_required")); end local auth_state = get_auth_state(request); if not auth_state.user then -- Render login page - return render_page(templates.login, { state = auth_state, client = client }); + local extra = {}; + if params.login_hint then + extra.username_hint = (jid.prepped_split(params.login_hint)); + end + return render_page(templates.login, { state = auth_state; client = client; extra = extra }); elseif auth_state.consent == nil then -- Render consent page - local scopes, requested_roles = split_scopes(parse_scopes(params.scope or "")); - local default_role = select_role(auth_state.user.username, requested_roles); - local roles = array(it.values(usermanager.get_all_roles(module.host))):filter(function(role) - return can_assume_role(auth_state.user.username, role.name); - end):sort(function(a, b) - return (a.priority or 0) < (b.priority or 0) - end):map(function(role) - return { name = role.name; selected = role.name == default_role }; - end); - if not roles[2] then - -- Only one role to choose from, might as well skip the selector - roles = nil; - end - return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes; roles = roles }, true); + local scopes, roles = split_scopes(requested_scopes); + roles = user_assumable_roles(auth_state.user.username, roles); + return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true); elseif not auth_state.consent then -- Notify client of rejection - return error_response(request, oauth_error("access_denied")); + if redirect_uri == device_uri then + local is_device, device_state = verify_device_token(params.state); + if is_device then + local device_code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code)); + local code = codes:get("device_code:" .. params.client_id .. "#" .. device_code); + code.error = oauth_error("access_denied"); + code.expires = os.time() + 60; + codes:set("device_code:" .. params.client_id .. "#" .. device_code, code); + end + end + return error_response(request, redirect_uri, oauth_error("access_denied")); end -- else auth_state.consent == true - params.scope = auth_state.scope; + local granted_scopes = auth_state.scopes + if client.scope then + local client_scopes = set.new(parse_scopes(client.scope)); + granted_scopes:filter(function(scope) + return client_scopes:contains(scope); + end); + end + + params.scope = granted_scopes:concat(" "); local user_jid = jid.join(auth_state.user.username, module.host); local client_secret = make_client_secret(params.client_id); @@ -686,18 +923,135 @@ iss = get_issuer(); sub = url.build({ scheme = "xmpp"; path = user_jid }); aud = params.client_id; + auth_time = auth_state.user.auth_time; nonce = params.nonce; }); local response_type = params.response_type; local response_handler = response_type_handlers[response_type]; if not response_handler then - return error_response(request, oauth_error("unsupported_response_type")); + return error_response(request, redirect_uri, oauth_error("unsupported_response_type")); + end + local ret = response_handler(client, params, user_jid, id_token); + if errors.is_err(ret) then + return error_response(request, redirect_uri, ret); + end + return ret; +end + +local function handle_device_authorization_request(event) + local request = event.request; + + local credentials = get_request_credentials(request); + + local params = strict_formdecode(request.body); + if not params then + return render_error(oauth_error("invalid_request", "Invalid query parameters")); + end + + if credentials and credentials.type == "basic" then + -- client_secret_basic converted internally to client_secret_post + params.client_id = http.urldecode(credentials.username); + local client_secret = http.urldecode(credentials.password); + + if not verify_client_secret(params.client_id, client_secret) then + module:log("debug", "client_secret mismatch"); + return oauth_error("invalid_client", "incorrect credentials"); + end + else + return 401; + end + + local client = check_client(params.client_id); + + if not client then + return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter")); + end + + if not set.new(client.grant_types):contains(device_uri) then + return render_error(oauth_error("invalid_client", "Client not registered for device authorization grant")); + end + + local requested_scopes = parse_scopes(params.scope or ""); + if client.scope then + local client_scopes = set.new(parse_scopes(client.scope)); + requested_scopes:filter(function(scope) + return client_scopes:contains(scope); + end); end - return response_handler(client, params, user_jid, id_token); + + -- TODO better code generator, this one should be easy to type from a + -- screen onto a phone + local user_code = (id.tiny() .. "-" .. id.tiny()):upper(); + local collisions = 0; + while codes:get("authorization_code:" .. device_uri .. "#" .. user_code) do + collisions = collisions + 1; + if collisions > 10 then + return oauth_error("temporarily_unavailable"); + end + user_code = (id.tiny() .. "-" .. id.tiny()):upper(); + end + -- device code should be derivable after consent but not guessable by the user + local device_code = b64url(hashes.hmac_sha256(verification_key, user_code)); + local verification_uri = module:http_url() .. "/device"; + local verification_uri_complete = verification_uri .. "?" .. http.formencode({ user_code = user_code }); + + local expires = os.time() + 600; + local dc_ok = codes:set("device_code:" .. params.client_id .. "#" .. device_code, { expires = expires }); + local uc_ok = codes:set("user_code:" .. user_code, + { user_code = user_code; expires = expires; client_id = params.client_id; + scope = requested_scopes:concat(" ") }); + if not dc_ok or not uc_ok then + return oauth_error("temporarily_unavailable"); + end + + return { + headers = { content_type = "application/json"; cache_control = "no-store"; pragma = "no-cache" }; + body = json.encode { + device_code = device_code; + user_code = user_code; + verification_uri = verification_uri; + verification_uri_complete = verification_uri_complete; + expires_in = 600; + interval = 5; + }; + } end +local function handle_device_verification_request(event) + local request = event.request; + local params = strict_formdecode(request.url.query); + if not params or not params.user_code then + return render_page(templates.device, { client = false }); + end + + local device_info = codes:get("user_code:" .. params.user_code); + if not device_info or code_expired(device_info) or not codes:set("user_code:" .. params.user_code, nil) then + return render_page(templates.device, { + client = false; + error = oauth_error("expired_token", "Incorrect or expired code"); + }); + end + + return { + status_code = 303; + headers = { + location = module:http_url() .. "/authorize" .. "?" .. http.formencode({ + client_id = device_info.client_id; + redirect_uri = device_uri; + response_type = "code"; + scope = device_info.scope; + state = new_device_token({ user_code = params.user_code }); + }); + }; + } +end + +local strict_auth_revoke = module:get_option_boolean("oauth2_require_auth_revoke", false); + local function handle_revocation_request(event) local request, response = event.request, event.response; + response.headers.cache_control = "no-store"; + response.headers.pragma = "no-cache"; if request.headers.authorization then local credentials = get_request_credentials(request); if not credentials or credentials.type ~= "basic" then @@ -708,9 +1062,14 @@ if not verify_client_secret(credentials.username, credentials.password) then return 401; end + -- TODO check that it's their token I guess? + elseif strict_auth_revoke then + -- Why require auth to revoke a leaked token? + response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name); + return 401; end - local form_data = http.formdecode(event.request.body or ""); + local form_data = strict_formdecode(event.request.body); if not form_data or not form_data.token then response.headers.accept = "application/x-www-form-urlencoded"; return 415; @@ -724,6 +1083,7 @@ end local registration_schema = { + title = "OAuth 2.0 Dynamic Client Registration Protocol"; type = "object"; required = { -- These are shown to users in the template @@ -733,14 +1093,24 @@ "redirect_uris"; }; properties = { - redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } }; + redirect_uris = { + title = "List of Redirect URIs"; + type = "array"; + minItems = 1; + uniqueItems = true; + items = { title = "Redirect URI"; type = "string"; format = "uri" }; + }; token_endpoint_auth_method = { + title = "Token Endpoint Authentication Method"; type = "string"; enum = { "none"; "client_secret_post"; "client_secret_basic" }; default = "client_secret_basic"; }; grant_types = { + title = "Grant Types"; type = "array"; + minItems = 1; + uniqueItems = true; items = { type = "string"; enum = { @@ -751,35 +1121,111 @@ "refresh_token"; "urn:ietf:params:oauth:grant-type:jwt-bearer"; "urn:ietf:params:oauth:grant-type:saml2-bearer"; + device_uri; }; }; default = { "authorization_code" }; }; - application_type = { type = "string"; enum = { "native"; "web" }; default = "web" }; - response_types = { type = "array"; items = { type = "string"; enum = { "code"; "token" } }; default = { "code" } }; - client_name = { type = "string" }; - client_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - logo_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - scope = { type = "string" }; - contacts = { type = "array"; items = { type = "string"; format = "email" } }; - tos_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - policy_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - jwks_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - jwks = { type = "object"; description = "JSON Web Key Set, RFC 7517" }; - software_id = { type = "string"; format = "uuid" }; - software_version = { type = "string" }; - }; - luaPatternProperties = { - -- Localized versions of descriptive properties and URIs - ["^client_name#"] = { description = "Localized version of 'client_name'"; type = "string" }; - ["^[a-z_]+_uri#"] = { type = "string"; format = "uri"; luaPattern = "^https:" }; + application_type = { + title = "Application Type"; + description = "Determines which kinds of redirect URIs the client may register. \z + The value 'web' limits the client to https:// URLs with the same hostname as in 'client_uri' \z + while the value 'native' allows either loopback http:// URLs or application specific URIs."; + type = "string"; + enum = { "native"; "web" }; + default = "web"; + }; + response_types = { + title = "Response Types"; + type = "array"; + minItems = 1; + uniqueItems = true; + items = { type = "string"; enum = { "code"; "token" } }; + default = { "code" }; + }; + client_name = { + title = "Client Name"; + description = "Human-readable name of the client, presented to the user in the consent dialog."; + type = "string"; + }; + client_uri = { + title = "Client URL"; + description = "Should be an link to a page with information about the client."; + type = "string"; + format = "uri"; + pattern = "^https:"; + }; + logo_uri = { + title = "Logo URL"; + description = "URL to the clients logotype (not currently used)."; + type = "string"; + format = "uri"; + pattern = "^https:"; + }; + scope = { + title = "Scopes"; + description = "Space-separated list of scopes the client promises to restrict itself to."; + type = "string"; + }; + contacts = { + title = "Contact Addresses"; + description = "Addresses, typically email or URLs where the client developers can be contacted."; + type = "array"; + minItems = 1; + items = { type = "string"; format = "email" }; + }; + tos_uri = { + title = "Terms of Service URL"; + description = "Link to Terms of Service for the client, presented to the user in the consent dialog. \z + MUST be a https:// URL with hostname matching that of 'client_uri'."; + type = "string"; + format = "uri"; + pattern = "^https:"; + }; + policy_uri = { + title = "Privacy Policy URL"; + description = "Link to a Privacy Policy for the client. MUST be a https:// URL with hostname matching that of 'client_uri'."; + type = "string"; + format = "uri"; + pattern = "^https:"; + }; + software_id = { + title = "Software ID"; + description = "Unique identifier for the client software, common for all instances. Typically an UUID."; + type = "string"; + format = "uuid"; + }; + software_version = { + title = "Software Version"; + description = "Version of the client software being registered. \z + E.g. to allow revoking all related tokens in the event of a security incident."; + type = "string"; + example = "2.3.1"; + }; }; } +-- Limit per-locale fields to allowed locales, partly to keep size of client_id +-- down, partly because we don't yet use them for anything. +-- Only relevant for user-visible strings and URIs. +if allowed_locales[1] then + local props = registration_schema.properties; + for _, locale in ipairs(allowed_locales) do + props["client_name#" .. locale] = props["client_name"]; + props["client_uri#" .. locale] = props["client_uri"]; + props["logo_uri#" .. locale] = props["logo_uri"]; + props["tos_uri#" .. locale] = props["tos_uri"]; + props["policy_uri#" .. locale] = props["policy_uri"]; + end +end + local function redirect_uri_allowed(redirect_uri, client_uri, app_type) local uri = url.parse(redirect_uri); + if not uri.scheme then + return false; -- no relative URLs + end if app_type == "native" then - return uri.scheme == "http" and loopbacks:contains(uri.host) or uri.scheme ~= "https"; + return uri.scheme == "http" and loopbacks:contains(uri.host) or redirect_uri == oob_uri or uri.scheme:find(".", 1, true) ~= nil; elseif app_type == "web" then return uri.scheme == "https" and uri.host == client_uri.host; end @@ -790,6 +1236,16 @@ return nil, oauth_error("invalid_request", "Failed schema validation."); end + local client_uri = url.parse(client_metadata.client_uri); + if not client_uri or client_uri.scheme ~= "https" or loopbacks:contains(client_uri.host) then + return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri"); + end + + if not client_metadata.application_type and redirect_uri_allowed(client_metadata.redirect_uris[1], client_uri, "native") then + client_metadata.application_type = "native"; + -- else defaults to "web" + end + -- Fill in default values for propname, propspec in pairs(registration_schema.properties) do if client_metadata[propname] == nil and type(propspec) == "table" and propspec.default ~= nil then @@ -797,9 +1253,11 @@ end end - local client_uri = url.parse(client_metadata.client_uri); - if not client_uri or client_uri.scheme ~= "https" or loopbacks:contains(client_uri.host) then - return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri"); + -- MUST ignore any metadata that it does not understand + for propname in pairs(client_metadata) do + if not registration_schema.properties[propname] then + client_metadata[propname] = nil; + end end for _, redirect_uri in ipairs(client_metadata.redirect_uris) do @@ -816,19 +1274,6 @@ end end - for k, v in pairs(client_metadata) do - local base_k = k:match"^([^#]+)#" or k; - if not registration_schema.properties[base_k] or k:find"^client_uri#" then - -- Ignore and strip unknown extra properties - client_metadata[k] = nil; - elseif k:find"_uri#" then - -- Localized URIs should be secure too - if not redirect_uri_allowed(v, client_uri, "web") then - return nil, oauth_error("invalid_client_metadata", "Invalid, insecure or inappropriate informative URI"); - end - end - end - local grant_types = set.new(client_metadata.grant_types); local response_types = set.new(client_metadata.response_types); @@ -844,18 +1289,21 @@ return nil, oauth_error("invalid_client_metadata", "No allowed 'response_types' specified"); end - -- Ensure each signed client_id JWT is unique, short ID and issued at - -- timestamp should be sufficient to rule out brute force attacks - client_metadata.nonce = id.short(); - -- Do we want to keep everything? - local client_id = jwt_sign(client_metadata); + local client_id = sign_client(client_metadata); client_metadata.client_id = client_id; client_metadata.client_id_issued_at = os.time(); if client_metadata.token_endpoint_auth_method ~= "none" then - local client_secret = make_client_secret(client_id); + -- Ensure that each client_id JWT with a client_secret is unique. + -- A short ID along with the issued at timestamp should be sufficient to + -- rule out brute force attacks. + -- Not needed for public clients without a secret, but those are expected + -- to be uncommon since they can only do the insecure implicit flow. + client_metadata.nonce = id.short(); + + local client_secret = make_client_secret(client_id, client_metadata); client_metadata.client_secret = client_secret; client_metadata.client_secret_expires_at = 0; @@ -879,7 +1327,11 @@ return { status_code = 201; - headers = { content_type = "application/json" }; + headers = { + cache_control = "no-store"; + pragma = "no-cache"; + content_type = "application/json"; + }; body = json.encode(response); }; end @@ -888,6 +1340,8 @@ module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled") handle_authorization_request = nil handle_register_request = nil + handle_device_authorization_request = nil + handle_device_verification_request = nil end local function handle_userinfo_request(event) @@ -941,6 +1395,7 @@ module:depends("http"); module:provides("http", { + cors = { enabled = true; credentials = true }; route = { -- OAuth 2.0 in 5 simple steps! -- This is the normal 'authorization_code' flow. @@ -948,9 +1403,14 @@ -- Step 1. Create OAuth client ["POST /register"] = handle_register_request; + -- Device flow + ["POST /device"] = handle_device_authorization_request; + ["GET /device"] = handle_device_verification_request; + -- Step 2. User-facing login and consent view ["GET /authorize"] = handle_authorization_request; ["POST /authorize"] = handle_authorization_request; + ["OPTIONS /authorize"] = { status_code = 403; body = "" }; -- Step 3. User is redirected to the 'redirect_uri' along with an -- authorization code. In the insecure 'implicit' flow, the access token @@ -972,7 +1432,7 @@ headers = { ["Content-Type"] = "text/css"; }; - body = _render_html(templates.css, module:get_option("oauth2_template_style")); + body = templates.css; } or nil; ["GET /script.js"] = templates.js and { headers = { @@ -1002,37 +1462,51 @@ -- OIDC Discovery +function get_authorization_server_metadata() + if authorization_server_metadata then + return authorization_server_metadata; + end + authorization_server_metadata = { + -- RFC 8414: OAuth 2.0 Authorization Server Metadata + issuer = get_issuer(); + authorization_endpoint = handle_authorization_request and module:http_url() .. "/authorize" or nil; + token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil; + registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil; + scopes_supported = usermanager.get_all_roles + and array(it.keys(usermanager.get_all_roles(module.host))):push("xmpp"):append(array(openid_claims:items())); + response_types_supported = array(it.keys(response_type_handlers)); + token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" }); + op_policy_uri = module:get_option_string("oauth2_policy_url", nil); + op_tos_uri = module:get_option_string("oauth2_terms_url", nil); + revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil; + revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" }); + device_authorization_endpoint = handle_device_authorization_request and module:http_url() .. "/device"; + code_challenge_methods_supported = array(it.keys(verifier_transforms)); + grant_types_supported = array(it.keys(grant_type_handlers)); + response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" }); + authorization_response_iss_parameter_supported = true; + service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html"); + ui_locales_supported = allowed_locales[1] and allowed_locales; + + -- OpenID + userinfo_endpoint = handle_register_request and module:http_url() .. "/userinfo" or nil; + jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata + id_token_signing_alg_values_supported = { "HS256" }; -- The algorithm RS256 MUST be included, but we use HS256 and client_secret as shared key. + } + return authorization_server_metadata; +end + module:provides("http", { name = "oauth2-discovery"; default_path = "/.well-known/oauth-authorization-server"; + cors = { enabled = true }; route = { - ["GET"] = { - headers = { content_type = "application/json" }; - body = json.encode { - -- RFC 8414: OAuth 2.0 Authorization Server Metadata - issuer = get_issuer(); - authorization_endpoint = handle_authorization_request and module:http_url() .. "/authorize" or nil; - token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil; - jwks_uri = nil; -- TODO? - registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil; - scopes_supported = usermanager.get_all_roles and array(it.keys(usermanager.get_all_roles(module.host))):append(array(openid_claims:items())); - response_types_supported = array(it.keys(response_type_handlers)); - token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" }); - op_policy_uri = module:get_option_string("oauth2_policy_url", nil); - op_tos_uri = module:get_option_string("oauth2_terms_url", nil); - revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil; - revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" }); - code_challenge_methods_supported = array(it.keys(verifier_transforms)); - grant_types_supported = array(it.keys(response_type_handlers)):map(tmap { token = "implicit"; code = "authorization_code" }); - response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" }); - authorization_response_iss_parameter_supported = true; - service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html"); - - -- OpenID - userinfo_endpoint = handle_register_request and module:http_url() .. "/userinfo" or nil; - id_token_signing_alg_values_supported = { "HS256" }; - }; - }; + ["GET"] = function() + return { + headers = { content_type = "application/json" }; + body = json.encode(get_authorization_server_metadata()); + } + end }; });
--- a/mod_invites/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_invites/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -1,18 +1,24 @@ --- labels: -- 'Stage-Beta' +- 'Stage-Merged' summary: 'Invite management module for Prosody' ... Introduction ============ +::: {.alert .alert-info} +This module has been merged into Prosody as +[mod_invites][doc:modules:mod_invites]. Users of Prosody **0.12** +and later should not install this version. +::: + This module is part of the suite of modules that implement invite-based account registration for Prosody. The other modules are: -- [mod_invites_adhoc] +- [mod_invites_adhoc][doc:modules:mod_invites_adhoc] +- [mod_invites_register][doc:modules:mod_invites_register] - [mod_invites_page] -- [mod_invites_register] - [mod_invites_register_web] - [mod_invites_api] - [mod_register_apps]
--- a/mod_invites_adhoc/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_invites_adhoc/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -1,18 +1,24 @@ --- labels: -- 'Stage-Beta' +- 'Stage-Merged' summary: 'Enable ad-hoc command for XMPP clients to create invitations' ... Introduction ============ +::: {.alert .alert-info} +This module has been merged into Prosody as +[mod_invites_adhoc][doc:modules:mod_invites_adhoc]. Users of Prosody **0.12** +and later should not install this version. +::: + This module is part of the suite of modules that implement invite-based account registration for Prosody. The other modules are: -- [mod_invites] +- [mod_invites][doc:modules:mod_invites] +- [mod_invites_register][doc:modules:mod_invites_register] - [mod_invites_page] -- [mod_invites_register] - [mod_invites_register_web] - [mod_invites_api] - [mod_register_apps] @@ -48,4 +54,4 @@ The `allow_user_invites` option should be set as desired. However it is strongly recommended to leave the other option (`allow_contact_invites`) -at its default to provide the best user experience. \ No newline at end of file +at its default to provide the best user experience.
--- a/mod_invites_adhoc/mod_invites_adhoc.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_invites_adhoc/mod_invites_adhoc.lua Mon Sep 18 08:24:19 2023 -0500 @@ -19,7 +19,11 @@ if module.may then if allow_user_invites then - module:default_permission("prosody:user", ":invite-new-users"); + if require "core.features".available:contains("split-user-roles") then + module:default_permission("prosody:registered", ":invite-new-users"); + else -- COMPAT + module:default_permission("prosody:user", ":invite-new-users"); + end end if not allow_user_invite_roles:empty() or not deny_user_invite_roles:empty() then return error("allow_user_invites_by_roles and deny_user_invites_by_roles are deprecated options"); @@ -57,7 +61,11 @@ return module:may(":invite-new-users", context); elseif usermanager.get_roles then -- COMPAT w/0.12 local user_roles = usermanager.get_roles(jid, module.host); - if not user_roles then return; end + if not user_roles then + -- User has no roles we can check, just return default + return allow_user_invites; + end + if user_roles["prosody:admin"] then return true; end
--- a/mod_invites_page/mod_invites_page.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_invites_page/mod_invites_page.lua Mon Sep 18 08:24:19 2023 -0500 @@ -39,6 +39,9 @@ else http_files = module:depends"http_files"; end + elseif prosody.process_type and module.get_option_period then + module:depends("http"); + http_files = require "net.http.files"; end -- Calculate automatic base_url default base_url = module.http_url and module:http_url();
--- a/mod_invites_register/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_invites_register/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -1,17 +1,23 @@ --- labels: -- 'Stage-Beta' +- 'Stage-Merged' summary: 'Allow account registration using invite tokens' ... Introduction ============ +::: {.alert .alert-info} +This module has been merged into Prosody as +[mod_invites_register][doc:modules:mod_invites_register]. Users of +Prosody **0.12** and later should not install this version. +::: + This module is part of the suite of modules that implement invite-based account registration for Prosody. The other modules are: -- [mod_invites] -- [mod_invites_adhoc] +- [mod_invites][doc:modules:mod_invites] +- [mod_invites_adhoc][doc:modules:mod_invites_adhoc] - [mod_invites_page] - [mod_invites_register_web] - [mod_invites_api]
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_measure_lua/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,19 @@ +This module provides two [metrics][doc:statistics]: + +`lua_heap_bytes` +: Bytes of memory as reported by `collectgarbage("count")`{.lua} + +`lua_info` +: Provides the current Lua version as a label + +``` openmetrics +# HELP lua_info Lua runtime version +# UNIT lua_info +# TYPE lua_info gauge +lua_info{version="Lua 5.4"} 1 +# HELP lua_heap_bytes Memory used by objects under control of the Lua +garbage collector +# UNIT lua_heap_bytes bytes +# TYPE lua_heap_bytes gauge +lua_heap_bytes 8613218 +```
--- a/mod_muc_block_pm/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_muc_block_pm/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -1,12 +1,11 @@ --- -summary: Prevent unaffiliated MUC participants from sending PMs +summary: Prevent MUC participants from sending PMs --- # Introduction -This module prevents unaffiliated users from sending private messages in -chat rooms, unless someone with an affiliation (member, admin etc) -messages them first. +This module prevents *participants* from sending private messages to +anyone except *moderators*. # Configuration @@ -23,6 +22,5 @@ Branch State -------- ----------------- - 0.9 Works - 0.10 Should work - 0.11 Should work + 0.11 Will **not** work + 0.12 Should work
--- a/mod_muc_block_pm/mod_muc_block_pm.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_muc_block_pm/mod_muc_block_pm.lua Mon Sep 18 08:24:19 2023 -0500 @@ -1,29 +1,26 @@ -local bare_jid = require"util.jid".bare; -local st = require"util.stanza"; +local st = require "util.stanza"; + +module:hook("muc-disco#info", function(event) + table.insert(event.form, { name = "muc#roomconfig_allowpm"; value = "moderators" }); +end); --- Support both old and new MUC code -local mod_muc = module:depends"muc"; -local rooms = rawget(mod_muc, "rooms"); -local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or - function (jid) - return rooms[jid]; +module:hook("muc-private-message", function(event) + local stanza, room = event.stanza, event.room; + local from_occupant = room:get_occupant_by_nick(stanza.attr.from); + + if from_occupant and from_occupant.role == "moderator" then + return -- moderators may message anyone end -module:hook("message/full", function(event) - local stanza, origin = event.stanza, event.origin; - if stanza.attr.type == "error" then - return + local to_occupant = room:get_occupant_by_nick(stanza.attr.to) + if to_occupant and to_occupant.role == "moderator" then + return -- messaging moderators is ok end - local to, from = stanza.attr.to, stanza.attr.from; - local room = get_room_from_jid(bare_jid(to)); - local to_occupant = room and room._occupants[to]; - local from_occupant = room and room._occupants[room._jid_nick[from]] - if not ( to_occupant and from_occupant ) then return end - if from_occupant.affiliation then - to_occupant._pm_block_override = true; - elseif not from_occupant._pm_block_override then - origin.send(st.error_reply(stanza, "cancel", "not-authorized", "Private messages are disabled")); - return true; + if to_occupant.bare_jid == from_occupant.bare_jid then + return -- to yourself is okay, used by some clients to sync read state in public channels end + + room:route_to_occupant(from_occupant, st.error_reply(stanza, "cancel", "policy-violation", "Private messages are disabled", room.jid)) + return false; end, 1);
--- a/mod_muc_defaults/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_muc_defaults/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -4,7 +4,7 @@ ## Configuration -Under your MUC component, add a `muc_defaults` option with the relevant settings. +Under your MUC component, add a `default_mucs` option with the relevant settings. ``` Component "conference.example.org" "muc" @@ -12,7 +12,7 @@ "muc_defaults"; } - muc_defaults = { + default_mucs = { { jid_node = "trollbox", affiliations = {
--- a/mod_muc_limits/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_muc_limits/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -30,16 +30,22 @@ Add the module to the MUC host (not the global modules\_enabled): - Component "conference.example.com" "muc" - modules_enabled = { "muc_limits" } +```lua +Component "conference.example.com" "muc" + modules_enabled = { "muc_limits" } +``` You can define (globally or per-MUC component) the following options: - Name Default value Description - ------------------------ --------------- ---------------------------------------------- - muc\_event\_rate 0.5 The maximum number of events per second. - muc\_burst\_factor 6 Allow temporary bursts of this multiple. - muc\_max\_nick\_length 23 The maximum allowed length of user nicknames + Name Default value Description + --------------------------- --------------- ---------------------------------------------------------- + muc_event_rate 0.5 The maximum number of events per second. + muc_burst_factor 6 Allow temporary bursts of this multiple. + muc_max_nick_length 23 The maximum allowed length of user nicknames + muc_max_char_count 5664 The maximum allowed number of bytes in a message + muc_max_line_count 23 The maximum allowed number of lines in a message + muc_limit_base_cost 1 Base cost of sending a stanza + muc_line_count_multiplier 0.1 Additional cost of each newline in the body of a message For more understanding of how these values are used, see the algorithm section below. @@ -66,15 +72,7 @@ Compatibility ============= - ------- ------------------ + ------- ------- trunk Works 0.11 Works - 0.10 Works - 0.9 Works - 0.8 Doesn't work[^1] - ------- ------------------ - -[^1]: This module can be made to work in 0.8 (and *maybe* previous - versions) of Prosody by copying the new - [util.throttle](http://hg.prosody.im/trunk/raw-file/fc8a22936b3c/util/throttle.lua) - into your Prosody source directory (into the util/ subdirectory). + ------- -------
--- a/mod_muc_limits/mod_muc_limits.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_muc_limits/mod_muc_limits.lua Mon Sep 18 08:24:19 2023 -0500 @@ -13,6 +13,11 @@ local burst = math.max(module:get_option_number("muc_burst_factor", 6), 1); local max_nick_length = module:get_option_number("muc_max_nick_length", 23); -- Default chosen through scientific methods +local max_line_count = module:get_option_number("muc_max_line_count", 23); -- Default chosen through s/scientific methods/copy and paste/ +local max_char_count = module:get_option_number("muc_max_char_count", 5664); -- Default chosen by multiplying a number by 23 +local base_cost = math.max(module:get_option_number("muc_limit_base_cost", 1), 0); +local line_multiplier = math.max(module:get_option_number("muc_line_count_multiplier", 0.1), 0); + local join_only = module:get_option_boolean("muc_limit_joins_only", false); local dropped_count = 0; local dropped_jids; @@ -46,7 +51,25 @@ throttle = new_throttle(period*burst, burst); room.throttle = throttle; end - if not throttle:poll(1) then + local cost = base_cost; + local body = stanza:get_child_text("body"); + if body then + -- TODO calculate a text diagonal cross-section or some mathemagical + -- number, maybe some cost multipliers + if #body > max_char_count then + origin.send(st.error_reply(stanza, "modify", "policy-violation", "Your message is too long, please write a shorter one") + :up():tag("x", { xmlns = xmlns_muc })); + return true; + end + local body_lines = select(2, body:gsub("\n[^\n]*", "")); + if body_lines > max_line_count then + origin.send(st.error_reply(stanza, "modify", "policy-violation", "Your message is too long, please write a shorter one"):up() + :tag("x", { xmlns = xmlns_muc; })); + return true; + end + cost = cost + (body_lines * line_multiplier); + end + if not throttle:poll(cost) then module:log("debug", "Dropping stanza for %s@%s from %s, over rate limit", dest_room, dest_host, from_jid); if not dropped_jids then dropped_jids = { [from_jid] = true, from_jid }; @@ -60,7 +83,6 @@ return true; end local reply = st.error_reply(stanza, "wait", "policy-violation", "The room is currently overactive, please try again later"); - local body = stanza:get_child_text("body"); if body then reply:up():tag("body"):text(body):up(); end
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_muc_members_json/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,81 @@ +--- +labels: +- 'Stage-Beta' +summary: 'Import MUC membership info from a JSON file' +... + +Introduction +============ + +This module allows you to import MUC membership information from an external +URL in JSON format. + +Details +======= + +If you have an organization or community and lots of members and/or channels, +it can be frustrating to manage MUC affiliations manually. This module will +fetch a JSON file from a configured URL, and use that to automatically set the +MUC affiliations. + +It also supports hats/badges. + +Configuration +============= + +Add the module to the MUC host (not the global modules\_enabled): + + Component "conference.example.com" "muc" + modules_enabled = { "muc_members_json" } + +You can define (globally or per-MUC component) the following options: + + Name Description + --------------------- -------------------------------------------------- + muc_members_json_url The URL to the JSON file describing memberships + muc_members_json_mucs The MUCs to manage, and their associated configuration + +The `muc_members_json_mucs` setting determines which rooms will be managed by +the plugin, and how to map roles to hats (if desired). + +``` +muc_members_json_mucs = { + myroom = { + member_hat = { + id = "urn:uuid:6a1b143a-1c5c-11ee-80aa-4ff1ce4867dc"; + title = "Cool Member"; + }; + }; +} +``` + +JSON format +=========== + +``` +{ + "members": [ + { + "jids": ["user@example.com"] + }, + { + "jids": ["user2@example.com"] + }, + { + "jids": ["user3@example.com"], + roles: ["janitor"] + } + ] +} +``` + +Each member must have a `jids` field, and optionally a `roles` field. + +Compatibility +============= + + ------- ------------------ + trunk Works + 0.12 Works + ------- ------------------ +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_muc_members_json/mod_muc_members_json.lua Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,93 @@ +local http = require "net.http"; +local json = require "util.json"; + +local json_url = assert(module:get_option_string("muc_members_json_url"), "muc_members_json_url required"); +local managed_mucs = module:get_option("muc_members_json_mucs"); + +local mod_muc = module:depends("muc"); + +--[[ +{ + xsf = { + team_hats = { + board = { + id = "xmpp:xmpp.org/hats/board"; + title = "Board"; + }; + }; + member_hat = { + id = "xmpp:xmpp.org/hats/member"; + title = "XSF member"; + }; + }; + iteam = { + team_hats = { + iteam = { + id = "xmpp:xmpp.org/hats/iteam"; + title = "Infra team"; + }; + }; + }; +} +--]] + +local function get_hats(member_info, muc_config) + local hats = {}; + if muc_config.member_hat then + hats[muc_config.member_hat.id] = { + title = muc_config.member_hat.title; + active = true; + }; + end + if muc_config.team_hats and member_info.roles then + for _, role in ipairs(member_info.roles) do + local hat = muc_config.team_hats[role]; + if hat then + hats[hat.id] = { + title = hat.title; + active = true; + }; + end + end + end + return hats; +end + +function module.load() + http.request(json_url) + :next(function (result) + return json.decode(result.body); + end) + :next(function (data) + module:log("debug", "DATA: %s", require "util.serialization".serialize(data, "debug")); + + for name, muc_config in pairs(managed_mucs) do + local muc_jid = name.."@"..module.host; + local muc = mod_muc.get_room_from_jid(muc_jid); + module:log("warn", "%s -> %s -> %s", name, muc_jid, muc); + if muc then + local jids = {}; + for _, member_info in ipairs(data.members) do + for _, member_jid in ipairs(member_info.jids) do + jids[member_jid] = true; + local affiliation = muc:get_affiliation(member_jid); + if not affiliation then + muc:set_affiliation(true, member_jid, "member", "imported membership"); + muc:set_affiliation_data(member_jid, "source", module.name); + end + muc:set_affiliation_data(member_jid, "hats", get_hats(member_info, muc_config)); + end + end + -- Remove affiliation from folk who weren't in the source data but previously were + for jid, aff, data in muc:each_affiliation() do + if not jids[jid] and data.source == module.name then + muc:set_affiliation(true, jid, "none", "imported membership lost"); + end + end + end + end + + end):catch(function (err) + module:log("error", "FAILED: %s", err); + end); +end
--- a/mod_muc_moderation/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_muc_moderation/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -1,3 +1,7 @@ +--- +summary: Let moderators remove spam and abuse messages +--- + # Introduction This module implements [XEP-0425: Message Moderation]. @@ -24,6 +28,7 @@ - Basic functionality with Prosody 0.11.x and later - Full functionality with Prosody 0.12.x and `internal` or `sql` storage^[Replacing moderated messages with tombstones requires new storage API methods.] +- Works with [mod_storage_xmlarchive] ## Clients @@ -33,7 +38,7 @@ ### Feature requests -- [Conv](https://github.com/iNPUTmice/Conversations/issues/3722)[ersa](https://github.com/iNPUTmice/Conversations/issues/3920)[tions](https://github.com/iNPUTmice/Conversations/issues/4227) +- [Conversations](https://codeberg.org/iNPUTmice/Conversations/issues/20) - [Dino](https://github.com/dino/dino/issues/1133) - [Poezio](https://lab.louiz.org/poezio/poezio/-/issues/3543) - [Profanity](https://github.com/profanity-im/profanity/issues/1336)
--- a/mod_muc_moderation/mod_muc_moderation.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_muc_moderation/mod_muc_moderation.lua Mon Sep 18 08:24:19 2023 -0500 @@ -27,6 +27,7 @@ -- Namespaces local xmlns_fasten = "urn:xmpp:fasten:0"; local xmlns_moderate = "urn:xmpp:message-moderate:0"; +local xmlns_occupant_id = "urn:xmpp:occupant-id:0"; local xmlns_retract = "urn:xmpp:message-retract:0"; -- Discovering support @@ -95,11 +96,31 @@ announcement:text_tag("reason", reason); end + local moderated_occupant_id = original:get_child("occupant-id", xmlns_occupant_id); + if room.get_occupant_id and moderated_occupant_id then + announcement:add_direct_child(moderated_occupant_id); + end + + local actor_occupant = room:get_occupant_by_real_jid(actor) or room:new_occupant(jid.bare(actor), actor_nick); + if room.get_occupant_id then + -- This isn't a regular broadcast message going through the events occupant_id.lib hooks so we do this here + announcement:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) })) + end + if muc_log_archive.set and retract then local tombstone = st.message({ from = original.attr.from, type = "groupchat", id = original.attr.id }) :tag("moderated", { xmlns = xmlns_moderate, by = actor_nick }) :tag("retracted", { xmlns = xmlns_retract, stamp = dt.datetime() }):up(); + if room.get_occupant_id then + tombstone:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) })) + + if moderated_occupant_id then + -- Copy occupant id from moderated message + tombstone:add_child(moderated_occupant_id); + end + end + if reason then tombstone:text_tag("reason", reason); end
--- a/mod_oidc_userinfo_vcard4/README.md Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_oidc_userinfo_vcard4/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -4,7 +4,7 @@ - Stage-Alpha rockspec: dependencies: - - mod_http_oauth2 + - mod_http_oauth2 >= 200 --- This module extracts profile details from the user's [vcard4][XEP-0292]
--- a/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua Mon Sep 18 08:24:19 2023 -0500 @@ -1,11 +1,14 @@ -- Provide OpenID UserInfo data to mod_http_oauth2 -- Alternatively, separate module for the whole HTTP endpoint? -- -local nodeprep = require "util.encodings".stringprep.nodeprep; +module:add_item("openid-claim", "address"); +module:add_item("openid-claim", "email"); +module:add_item("openid-claim", "phone"); +module:add_item("openid-claim", "profile"); local mod_pep = module:depends "pep"; -local gender_map = { M = "male"; F = "female"; O = "other"; N = "nnot applicable"; U = "unknown" } +local gender_map = { M = "male"; F = "female"; O = "other"; N = "not applicable"; U = "unknown" } module:hook("token/userinfo", function(event) local pep_service = mod_pep.get_pep_service(event.username);
--- a/mod_pubsub_alertmanager/README.md Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_pubsub_alertmanager/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -93,3 +93,21 @@ `alertmanager_node_template` : Template for the pubsub node name, defaults to `"{path?alerts}"` + +`alertmanager_path_configs` +: Per-path configuration variables (see below). + +### Per-path configuration + +It's possible to override configuration options based on the path suffix. For +example, if a request is made to `http://prosody/pubsub_alertmanager/foo` the +path suffix is `foo`. You can then supply the following configuration: + +``` lua +alertmanager_path_configs = { + foo = { + node_template = "alerts/{alert.labels.severity}"; + publisher = "user@example.net"; + }; +} +```
--- a/mod_pubsub_alertmanager/mod_pubsub_alertmanager.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_pubsub_alertmanager/mod_pubsub_alertmanager.lua Mon Sep 18 08:24:19 2023 -0500 @@ -29,11 +29,16 @@ return 202; end -local node_template = module:get_option_string("alertmanager_node_template", "{path?alerts}"); +local global_node_template = module:get_option_string("alertmanager_node_template", "{path?alerts}"); +local path_configs = module:get_option("alertmanager_path_configs", {}); function handle_POST(event, path) local request = event.request; + local config = path_configs[path] or {}; + local node_template = config.node_template or global_node_template; + local publisher = config.publisher or request.ip; + local payload = json.decode(event.request.body); if type(payload) ~= "table" then return 400; end if payload.version ~= "4" then return 501; end @@ -55,7 +60,7 @@ end local node = render(node_template, {alert = alert, path = path, payload = payload, request = request}); - local ret = publish_payload(node, request.ip, uuid_generate(), item); + local ret = publish_payload(node, publisher, uuid_generate(), item); if ret ~= 202 then return ret end
--- a/mod_pubsub_feeds/README.markdown Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_pubsub_feeds/README.markdown Mon Sep 18 08:24:19 2023 -0500 @@ -35,27 +35,27 @@ [XEP-0060](http://xmpp.org/extensions/xep-0060.html). Results are in [ATOM 1.0 format](http://atomenabled.org/) for easy consumption. -# PubSubHubbub +# WebSub {#pubsubhubbub} -This module also implements a -[PubSubHubbub](http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html) -subscriber. This allows feeds that have an associated "hub" to push -updates when they are published. +This module also implements [WebSub](https://www.w3.org/TR/websub/), +formerly known as +[PubSubHubbub](http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html). +This allows "feed hubs" to instantly push feed updates to subscribers. -Not all feeds support this. - -It needs to expose a HTTP callback endpoint to work. +This may be removed in the future since it does not seem to be oft used +anymore. # Option summary - Option Description - ---------------------- ------------------------------------------------------------------------- - `feeds` A list of virtual nodes to create and their associated Atom or RSS URL. - `feed_pull_interval` Number of minutes between polling for new results (default 15) - `use_pubsubhubub` Set to `false` to disable PubSubHubbub + Option Description + ------------------------------ -------------------------------------------------------------------------- + `feeds` A list of virtual nodes to create and their associated Atom or RSS URL. + `feed_pull_interval_seconds` Number of seconds between polling for new results (default 15 *minutes*) + `use_pubsubhubub` Set to `true` to enable WebSub # Compatibility - ----- ------- - 0.9 Works - ----- ------- + ------ ------- + 0.12 Works + 0.11 Works + ------ -------
--- a/mod_pubsub_feeds/mod_pubsub_feeds.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_pubsub_feeds/mod_pubsub_feeds.lua Mon Sep 18 08:24:19 2023 -0500 @@ -1,17 +1,4 @@ -- Fetches Atom feeds and publishes to PubSub nodes --- --- Config: --- Component "pubsub.example.com" "pubsub" --- modules_enabled = { --- "pubsub_feeds"; --- } --- feeds = { -- node -> url --- prosody_blog = "http://blog.prosody.im/feed/atom.xml"; --- } --- feed_pull_interval = 20 -- minutes --- --- Reference --- http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.4.html local pubsub = module:depends"pubsub"; @@ -36,7 +23,7 @@ return nil, "unsupported-format"; end -local use_pubsubhubub = module:get_option_boolean("use_pubsubhubub", true); +local use_pubsubhubub = module:get_option_boolean("use_pubsubhubub", false); if use_pubsubhubub then module:depends"http"; end @@ -46,7 +33,8 @@ local formencode = http.formencode; local feed_list = module:shared("feed_list"); -local refresh_interval = module:get_option_number("feed_pull_interval", 15) * 60; +local legacy_refresh_interval = module:get_option_number("feed_pull_interval", 15); +local refresh_interval = module:get_option_number("feed_pull_interval_seconds", legacy_refresh_interval*60); local lease_length = tostring(math.floor(module:get_option_number("feed_lease_length", 86400))); function module.load() @@ -60,7 +48,12 @@ end new_feed_list[node] = true; if not feed_list[node] then - feed_list[node] = { url = url; node = node; last_update = 0 }; + local ok, err = pubsub.service:create(node, true); + if ok or err == "conflict" then + feed_list[node] = { url = url; node = node; last_update = 0 }; + else + module:log("error", "Could not create node %s: %s", node, err); + end else feed_list[node].url = url; end @@ -75,58 +68,68 @@ end end -function update_entry(item) +function update_entry(item, data) local node = item.node; - module:log("debug", "parsing %d bytes of data in node %s", #item.data or 0, node) - local feed, err = parse_feed(item.data); + module:log("debug", "parsing %d bytes of data in node %s", #data or 0, node) + local feed, err = parse_feed(data); if not feed then module:log("error", "Could not parse feed %q: %s", item.url, err); - module:log("debug", "Feed data:\n%s\n.", item.data); + module:log("debug", "Feed data:\n%s\n.", data); return; end local entries = {}; for entry in feed:childtags("entry") do table.insert(entries, entry); end - local ok, items = pubsub.service:get_items(node, true); + local ok, last_id = pubsub.service:get_last_item(node, true); if not ok then - local ok, err = pubsub.service:create(node, true); - if not ok then - module:log("error", "Could not create node %s: %s", node, err); - return; + module:log("error", "PubSub node %q missing: %s", node, last_id); + return + end + + local start_from = #entries; + for i, entry in ipairs(entries) do + local id = entry:get_child_text("id"); + if not id then + local link = entry:get_child("link"); + if link then + module:log("debug", "Feed %q item %s is missing an id, using <link> instead", item.url, entry:top_tag()); + id = link and link.attr.href; + else + module:log("error", "Feed %q item %s is missing both id and link, this feed is unusable", item.url, entry:top_tag()); + return; + end + entry:text_tag("id", id); end - items = {}; + + if last_id == id then + -- This should be the first item that we already have. + start_from = i-1; + break + end end - for i = #entries, 1, -1 do -- Feeds are usually in reverse order + + for i = start_from, 1, -1 do -- Feeds are usually in reverse order local entry = entries[i]; entry.attr.xmlns = xmlns_atom; - local e_published = entry:get_child_text("published"); - e_published = e_published and dt_parse(e_published); - local e_updated = entry:get_child_text("updated"); - e_updated = e_updated and dt_parse(e_updated); + local id = entry:get_child_text("id"); - local timestamp = e_updated or e_published or nil; - --module:log("debug", "timestamp is %s, item.last_update is %s", tostring(timestamp), tostring(item.last_update)); + local timestamp = dt_parse(entry:get_child_text("published")); + if not timestamp then + timestamp = time(); + entry:text_tag("published", dt_datetime(timestamp)); + end + if not timestamp or not item.last_update or timestamp > item.last_update then - local id = entry:get_child_text("id"); - if not id then - local link = entry:get_child("link"); - id = link and link.attr.href; - end - if not id then - -- Sigh, no link? - id = feed.url .. "#" .. hmac_sha1(feed.url, tostring(entry), true) .. "@" .. dt_datetime(timestamp); - end - if not items[id] then - local xitem = st.stanza("item", { id = id, xmlns = "http://jabber.org/protocol/pubsub" }):add_child(entry); - -- TODO Put data from /feed into item/source + local xitem = st.stanza("item", { id = id, xmlns = "http://jabber.org/protocol/pubsub" }):add_child(entry); + -- TODO Put data from /feed into item/source - --module:log("debug", "publishing to %s, id %s", node, id); - local ok, err = pubsub.service:publish(node, true, id, xitem); - if not ok then - module:log("error", "Publishing to node %s failed: %s", node, err); - end + local ok, err = pubsub.service:publish(node, true, id, xitem); + if not ok then + module:log("error", "Publishing to node %s failed: %s", node, err); + elseif timestamp then + item.last_update = timestamp; end end end @@ -148,20 +151,18 @@ end function fetch(item, callback) -- HTTP Pull - local headers = { }; - if item.data and item.etag then - headers["If-None-Match"] = item.etag; - end + local headers = { + ["If-None-Match"] = item.etag; + ["Accept"] = "application/atom+xml, application/x-rss+xml, application/xml"; + }; http.request(item.url, { headers = headers }, function(data, code, resp) if code == 200 then - item.data = data; - if callback then callback(item) end - item.last_update = time(); + if callback then callback(item, data) end if resp.headers then item.etag = resp.headers.etag end elseif code == 304 then - item.last_update = time(); + module:log("debug", "No updates to %q", item.url); elseif code == 301 and resp.headers.location then module:log("info", "Feed %q has moved to %q", item.url, resp.headers.location); elseif code <= 100 then @@ -268,9 +269,7 @@ end module:log("debug", "Valid signature"); end - feed.data = body; - update_entry(feed); - feed.last_update = time(); + update_entry(feed, body); return 202; end return 400;
--- a/mod_rest/example/prosody_oauth.py Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_rest/example/prosody_oauth.py Mon Sep 18 08:24:19 2023 -0500 @@ -16,6 +16,9 @@ "client_name": client_name, "client_uri": client_uri, "redirect_uris": [redirect_uri], + "application_type": redirect_uri[:8] == "https://" + and "web" + or "native", }, ).json()
--- a/mod_rest/example/rest.sh Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_rest/example/rest.sh Mon Sep 18 08:24:19 2023 -0500 @@ -5,23 +5,25 @@ # Dependencies: # - https://httpie.io/ -# - https://github.com/stedolan/jq -# - some sort of XDG 'open' command +# - https://hg.sr.ht/~zash/httpie-oauth2 + +# shellcheck disable=SC1091 # Settings HOST="" DOMAIN="" -AUTH_METHOD="session-read-only" -AUTH_ID="rest" - if [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/restrc" ]; then # Config file can contain the above settings source "${XDG_CONFIG_HOME:-$HOME/.config}/restrc" + + if [ -z "${SCOPE:-}" ]; then + SCOPE="openid xmpp" + fi fi - + if [[ $# == 0 ]]; then - echo "${0##*/} [-h HOST] [-u USER|--login] [/path] kind=(message|presence|iq) ...." + echo "${0##*/} [-h HOST] [/path] kind=(message|presence|iq) ...." # Last arguments are handed to HTTPie, so refer to its docs for further details exit 0 fi @@ -45,80 +47,6 @@ fi fi -if [[ "$1" == "-u" ]]; then - # -u username - AUTH_METHOD="auth" - AUTH_ID="$2" - shift 2 -elif [[ "$1" == "-rw" ]]; then - # To e.g. save Accept headers to the session - AUTH_METHOD="session" - shift 1 -fi - -if [[ "$1" == "--login" ]]; then - shift 1 - - # Check cache for OAuth client - if [ -f "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" ]; then - source "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" - fi - - OAUTH_META="$(http --check-status --json "https://$HOST/.well-known/oauth-authorization-server" Accept:application/json)" - AUTHORIZATION_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.authorization_endpoint')" - TOKEN_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.token_endpoint')" - - if [ -z "${OAUTH_CLIENT_INFO:-}" ]; then - # Register a new OAuth client - REGISTRATION_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.registration_endpoint')" - OAUTH_CLIENT_INFO="$(http --check-status "$REGISTRATION_ENDPOINT" Content-Type:application/json Accept:application/json client_name=rest.sh client_uri="https://modules.prosody.im/mod_rest" application_type=native software_id=0bdb0eb9-18e8-43af-a7f6-bd26613374c0 redirect_uris:='["urn:ietf:wg:oauth:2.0:oob"]')" - mkdir -p "${XDG_CACHE_HOME:-$HOME/.cache}/rest/" - typeset -p OAUTH_CLIENT_INFO >> "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" - fi - - CLIENT_ID="$(echo "$OAUTH_CLIENT_INFO" | jq -e -r '.client_id')" - CLIENT_SECRET="$(echo "$OAUTH_CLIENT_INFO" | jq -e -r '.client_secret')" - - if [ -n "${REFRESH_TOKEN:-}" ]; then - TOKEN_RESPONSE="$(http --check-status --form "$TOKEN_ENDPOINT" 'grant_type=refresh_token' "client_id=$CLIENT_ID" "client_secret=$CLIENT_SECRET" "refresh_token=$REFRESH_TOKEN")" - ACCESS_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')" - if [ "$ACCESS_TOKEN" == "null" ]; then - ACCESS_TOKEN="" - fi - fi - - if [ -z "${ACCESS_TOKEN:-}" ]; then - CODE_CHALLENGE="$(head -c 33 /dev/urandom | base64 | tr /+ _-)" - open "$AUTHORIZATION_ENDPOINT?response_type=code&client_id=$CLIENT_ID&code_challenge=$CODE_CHALLENGE&scope=openid+prosody:user" - read -p "Paste authorization code: " -s -r AUTHORIZATION_CODE - - TOKEN_RESPONSE="$(http --check-status --form "$TOKEN_ENDPOINT" 'grant_type=authorization_code' "client_id=$CLIENT_ID" "client_secret=$CLIENT_SECRET" "code=$AUTHORIZATION_CODE" code_verifier="$CODE_CHALLENGE")" - ACCESS_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -e -r '.access_token')" - REFRESH_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token')" - - if [ "$REFRESH_TOKEN" != "null" ]; then - # FIXME Better type check would be nice, but nobody should ever have the - # string "null" as a legitimate refresh token... - typeset -p REFRESH_TOKEN >> "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" - fi - - if [ -n "${COLORTERM:-}" ]; then - echo -ne '\e[1K\e[G' - else - echo - fi - fi - - USERINFO_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.userinfo_endpoint')" - http --check-status -b --session rest "$USERINFO_ENDPOINT" "Authorization:Bearer $ACCESS_TOKEN" Accept:application/json >&2 - AUTH_METHOD="session-read-only" - AUTH_ID="rest" -fi - -if [[ $# == 0 ]]; then - # Just login? - exit 0 -fi # For e.g /disco/example.com and such GET queries GET_PATH="" @@ -127,4 +55,4 @@ shift 1 fi -http --check-status -p b "--$AUTH_METHOD" "$AUTH_ID" "https://$HOST/rest$GET_PATH" "$@" +https --check-status -p b --session rest -A oauth2 -a "$HOST" --oauth2-scope "$SCOPE" "$HOST/rest$GET_PATH" "$@"
--- a/mod_rest/mod_rest.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_rest/mod_rest.lua Mon Sep 18 08:24:19 2023 -0500 @@ -294,6 +294,7 @@ local function handle_request(event, path) local request, response = event.request, event.response; + local log = request.log or module._log; local from; local origin; local echo = path == "echo"; @@ -308,8 +309,9 @@ return post_errors.new("unauthz"); end from = jid.join(origin.username, origin.host, origin.resource); + origin.full_jid = from; origin.type = "c2s"; - origin.log = module._log; + origin.log = log; end local payload, err = parse_request(request, path); if not payload then @@ -352,7 +354,7 @@ ["xml:lang"] = payload.attr["xml:lang"], }; - module:log("debug", "Received[rest]: %s", payload:top_tag()); + log("debug", "Received[rest]: %s", payload:top_tag()); local send_type = decide_type((request.headers.accept or "") ..",".. (request.headers.content_type or ""), supported_outputs) if echo then @@ -395,7 +397,7 @@ local p = module:send_iq(payload, origin, iq_timeout):next( function (result) - module:log("debug", "Sending[rest]: %s", result.stanza:top_tag()); + log("debug", "Sending[rest]: %s", result.stanza:top_tag()); response.headers.content_type = send_type; if responses[1] then local tail = responses[#responses]; @@ -410,11 +412,11 @@ end, function (error) if not errors.is_err(error) then - module:log("error", "Uncaught native error: %s", error); + log("error", "Uncaught native error: %s", error); return select(2, errors.coerce(nil, error)); elseif error.context and error.context.stanza then response.headers.content_type = send_type; - module:log("debug", "Sending[rest]: %s", error.context.stanza:top_tag()); + log("debug", "Sending[rest]: %s", error.context.stanza:top_tag()); return encode(send_type, error.context.stanza); else return error; @@ -430,7 +432,7 @@ return p; else function origin.send(stanza) - module:log("debug", "Sending[rest]: %s", stanza:top_tag()); + log("debug", "Sending[rest]: %s", stanza:top_tag()); response.headers.content_type = send_type; response:send(encode(send_type, stanza)); return true;
--- a/mod_rest/res/openapi.yaml Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_rest/res/openapi.yaml Mon Sep 18 08:24:19 2023 -0500 @@ -1,6 +1,5 @@ --- openapi: 3.0.1 - info: title: mod_rest API version: 0.3.2 @@ -10,14 +9,12 @@ and a simplified JSON mapping. license: name: MIT - paths: - /rest: post: summary: Send stanzas and receive responses. Webhooks work the same way. tags: - - generic + - generic security: - basic: [] - token: [] @@ -25,35 +22,33 @@ requestBody: $ref: '#/components/requestBodies/common' responses: - '200': + "200": $ref: '#/components/responses/success' - '202': + "202": $ref: '#/components/responses/sent' - /rest/{kind}/{type}/{to}: post: summary: Even more RESTful mapping with certain components in the path. tags: - - generic + - generic security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/kind' - - $ref: '#/components/parameters/type' - - $ref: '#/components/parameters/to' + - $ref: '#/components/parameters/kind' + - $ref: '#/components/parameters/type' + - $ref: '#/components/parameters/to' requestBody: $ref: '#/components/requestBodies/common' responses: - '200': + "200": $ref: '#/components/responses/success' - /rest/echo: post: summary: Build as stanza and return it for inspection. tags: - - debug + - debug security: - basic: [] - token: [] @@ -61,22 +56,21 @@ requestBody: $ref: '#/components/requestBodies/common' responses: - '200': + "200": $ref: '#/components/responses/success' - /rest/ping/{to}: get: tags: - - query + - query summary: Ping a local or remote server or other entity security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' + - $ref: '#/components/parameters/to' responses: - '200': + "200": description: Test reachability of some address content: application/json: @@ -85,21 +79,19 @@ application/xmpp+xml: schema: $ref: '#/components/schemas/iq_pong' - - /rest/version/{to}: get: tags: - - query + - query summary: Ask what software version is used. security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' + - $ref: '#/components/parameters/to' responses: - '200': + "200": description: Version query response content: application/json: @@ -108,155 +100,146 @@ application/xmpp+xml: schema: $ref: '#/components/schemas/iq_result_version' - /rest/disco/{to}: get: tags: - - query + - query summary: Query a remote entity for supported features security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' + - $ref: '#/components/parameters/to' responses: - '200': + "200": $ref: '#/components/responses/success' - /rest/items/{to}: get: tags: - - query + - query summary: Query an entity for related services, chat rooms or other items security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' + - $ref: '#/components/parameters/to' responses: - '200': + "200": $ref: '#/components/responses/success' - /rest/extdisco/{to}: get: tags: - - query + - query summary: Query for external services (usually STUN and TURN) security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' - - name: type - in: query - schema: - type: string - example: stun + - $ref: '#/components/parameters/to' + - name: type + in: query + schema: + type: string + example: stun responses: - '200': + "200": $ref: '#/components/responses/success' - - /rest/archive/{to}: get: tags: - - query + - query summary: Query a message archive security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' - - name: with - in: query - schema: - type: string - - name: start - in: query - schema: - type: string - - name: end - in: query - schema: - type: string - - name: before-id - in: query - schema: - type: string - - name: after-id - in: query - schema: - type: string - - name: ids - in: query - schema: - type: string - description: comma-separated list of archive ids - - name: after - in: query - schema: - type: string - - name: before - in: query - schema: - type: string - - name: max - in: query - schema: - type: integer + - $ref: '#/components/parameters/to' + - name: with + in: query + schema: + type: string + - name: start + in: query + schema: + type: string + - name: end + in: query + schema: + type: string + - name: before-id + in: query + schema: + type: string + - name: after-id + in: query + schema: + type: string + - name: ids + in: query + schema: + type: string + description: comma-separated list of archive ids + - name: after + in: query + schema: + type: string + - name: before + in: query + schema: + type: string + - name: max + in: query + schema: + type: integer responses: - '200': + "200": $ref: '#/components/responses/success' - /rest/lastactivity/{to}: get: tags: - - query + - query summary: Query last activity of an entity. Sometimes used as "uptime" for servers. security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' + - $ref: '#/components/parameters/to' responses: - '200': + "200": $ref: '#/components/responses/success' - /rest/stats/{to}: get: tags: - - query + - query summary: Query an entity for statistics security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' + - $ref: '#/components/parameters/to' responses: - '200': + "200": $ref: '#/components/responses/success' - /rest/upload_request/{to}: get: tags: - - query + - query summary: Lorem ipsum security: - - basic: [] - - token: [] - - oauth2: [] + - basic: [] + - token: [] + - oauth2: [] parameters: - - $ref: '#/components/parameters/to' + - $ref: '#/components/parameters/to' responses: - '200': + "200": $ref: '#/components/responses/success' - components: schemas: stanza: @@ -271,7 +254,6 @@ - $ref: '#/components/schemas/message' - $ref: '#/components/schemas/presence' - $ref: '#/components/schemas/iq' - message: type: object xml: @@ -281,18 +263,17 @@ description: Which kind of stanza type: string enum: - - message + - message type: type: string enum: - - chat - - error - - groupchat - - headline - - normal + - chat + - error + - groupchat + - headline + - normal xml: attribute: true - to: $ref: '#/components/schemas/to' from: @@ -301,7 +282,6 @@ $ref: '#/components/schemas/id' lang: $ref: '#/components/schemas/lang' - body: $ref: '#/components/schemas/body' subject: @@ -310,7 +290,6 @@ $ref: '#/components/schemas/thread' invite: $ref: '#/components/schemas/invite' - state: $ref: '#/components/schemas/state' nick: @@ -319,7 +298,6 @@ $ref: '#/components/schemas/delay' replace: $ref: '#/components/schemas/replace' - html: $ref: '#/components/schemas/html' oob: @@ -344,19 +322,14 @@ $ref: '#/components/schemas/displayed' encryption: $ref: '#/components/schemas/encryption' - archive: $ref: '#/components/schemas/archive_result' - dataform: $ref: '#/components/schemas/dataform' - forwarded: $ref: '#/components/schemas/forwarded' - error: $ref: '#/components/schemas/error' - presence: type: object properties: @@ -364,7 +337,7 @@ description: Which kind of stanza type: string enum: - - presence + - presence type: type: string enum: @@ -385,14 +358,12 @@ $ref: '#/components/schemas/id' lang: $ref: '#/components/schemas/lang' - show: $ref: '#/components/schemas/show' status: $ref: '#/components/schemas/status' priority: $ref: '#/components/schemas/priority' - caps: $ref: '#/components/schemas/caps' nick: @@ -403,13 +374,10 @@ $ref: '#/components/schemas/vcard_update' idle_since: $ref: '#/components/schemas/idle_since' - muc: $ref: '#/components/schemas/muc' - error: $ref: '#/components/schemas/error' - iq: type: object properties: @@ -417,14 +385,14 @@ description: Which kind of stanza type: string enum: - - iq + - iq type: type: string enum: - - get - - set - - result - - error + - get + - set + - result + - error xml: attribute: true to: @@ -435,7 +403,6 @@ $ref: '#/components/schemas/id' lang: $ref: '#/components/schemas/lang' - ping: $ref: '#/components/schemas/ping' version: @@ -448,7 +415,6 @@ $ref: '#/components/schemas/items' command: $ref: '#/components/schemas/command' - stats: $ref: '#/components/schemas/stats' payload: @@ -463,10 +429,8 @@ $ref: '#/components/schemas/upload_request' upload_slot: $ref: '#/components/schemas/upload_slot' - error: $ref: '#/components/schemas/error' - iq_pong: description: Test reachability of some XMPP address type: object @@ -476,10 +440,9 @@ type: type: string enum: - - result + - result xml: attribute: true - iq_result_version: description: Version query response type: object @@ -489,60 +452,56 @@ type: type: string enum: - - result + - result xml: attribute: true version: $ref: '#/components/schemas/version' - kind: description: Which kind of stanza type: string enum: - - message - - presence - - iq - + - message + - presence + - iq type: description: Stanza type type: string enum: - - chat - - normal - - headline - - groupchat - - get - - set - - result - - available - - unavailable - - subscribe - - subscribed - - unsubscribe - - unsubscribed + - chat + - normal + - headline + - groupchat + - get + - set + - result + - available + - unavailable + - subscribe + - subscribed + - unsubscribe + - unsubscribed xml: attribute: true - to: - description: recipient + description: the intended recipient for the stanza example: alice@example.com + format: xmpp-jid type: string xml: attribute: true - from: - description: the sender - example: bob@localhost.example + description: the sender of the stanza + example: bob@example.net + format: xmpp-jid type: string xml: attribute: true - id: description: Reasonably unique id. mod_rest generates one if left out. type: string xml: attribute: true - lang: description: Language code example: en @@ -550,17 +509,14 @@ prefix: xml attribute: true type: string - body: description: Human-readable chat message example: Hello, World! type: string - subject: description: Subject of message or group chat example: Talking about stuff type: string - thread: description: Message thread identifier properties: @@ -572,26 +528,22 @@ type: string xml: text: true - show: description: indicator of availability, ie away or not type: string enum: - - away - - chat - - dnd - - xa - + - away + - chat + - dnd + - xa status: description: Textual status message. type: string - priority: description: Presence priority type: integer maximum: 127 minimum: -128 - state: description: Chat state notifications, e.g. "is typing..." type: string @@ -599,30 +551,27 @@ namespace: http://jabber.org/protocol/chatstates x_name_is_value: true enum: - - active - - inactive - - gone - - composing - - paused + - active + - inactive + - gone + - composing + - paused example: composing - nick: type: string description: Nickname of the sender xml: name: nick namespace: http://jabber.org/protocol/nick - delay: type: string format: date-time - description: Timestamp of when a stanza was delayed, in ISO 8601 / XEP-0082 - format. + description: Timestamp of when a stanza was delayed, in ISO 8601 / XEP-0082 format. + title: 'XEP-0203: Delayed Delivery' xml: name: delay namespace: urn:xmpp:delay x_single_attribute: stamp - replace: type: string description: ID of message being replaced (e.g. for corrections) @@ -630,7 +579,6 @@ name: replace namespace: urn:xmpp:message-correct:0 x_single_attribute: id - muc: description: Multi-User-Chat related type: object @@ -661,14 +609,12 @@ format: date-time xml: attribute: true - - invite: description: Invite to a group chat - title: "XEP-0249: Direct MUC Invitations" + title: 'XEP-0249: Direct MUC Invitations' type: object required: - - jid + - jid xml: name: x namespace: jabber:x:conference @@ -698,21 +644,18 @@ description: Whether the group chat continues a one-to-one chat xml: attribute: true - html: description: HTML version of 'body' example: <body><p>Hello!</p></body> type: string - ping: description: A ping. type: boolean enum: - - true + - true xml: name: ping namespace: urn:xmpp:ping - version: type: object description: Software version query @@ -727,116 +670,111 @@ type: string example: Linux required: - - name - - version + - name + - version xml: name: query namespace: jabber:iq:version - disco: description: Discover supported features oneOf: - - description: A full response - type: object - properties: - features: - description: List of URIs indicating supported features - type: array - items: + - description: A full response + type: object + properties: + features: + description: List of URIs indicating supported features + type: array + items: + type: string + identities: + description: List of abstract identities or types that describe the entity + type: array + example: + - name: Prosody + type: im + category: server + items: + type: object + properties: + name: + type: string + type: + type: string + category: + type: string + node: type: string - identities: - description: List of abstract identities or types that describe the - entity - type: array - example: - - name: Prosody - type: im - category: server - items: + extensions: type: object - properties: - name: - type: string - type: - type: string - category: - type: string - node: - type: string - extensions: - type: object - - description: A query with a node, or an empty response with a node - type: string - - description: Either a query, or an empty response - type: boolean - + - description: A query with a node, or an empty response with a node + type: string + - description: Either a query, or an empty response + type: boolean items: description: List of references to other entities oneOf: - - description: List of items referenced - type: array - items: - properties: - jid: - type: string - description: Address of item - node: - type: string - name: - type: string - description: Descriptive name - required: - - jid - type: object - - type: string - description: A query with a node, or an empty reply list with a node - - description: An items query or empty list - type: boolean - enum: - - true - + - description: List of items referenced + type: array + items: + properties: + jid: + type: string + description: Address of item + node: + type: string + name: + type: string + description: Descriptive name + required: + - jid + type: object + - type: string + description: A query with a node, or an empty reply list with a node + - description: An items query or empty list + type: boolean + enum: + - true command: description: Ad-hoc commands. oneOf: - - type: object - properties: - data: - $ref: '#/components/schemas/formdata' - action: - type: string - note: - type: object - properties: - text: - type: string - type: - type: string - enum: - - info - - warn - - error - form: - $ref: '#/components/schemas/dataform' - sessionid: - type: string - status: - type: string - node: - type: string - actions: - type: object - properties: - complete: - type: boolean - prev: - type: boolean - next: - type: boolean - execute: - type: string - - type: string - description: Call a command by 'node' id, without arguments - + - type: object + properties: + data: + $ref: '#/components/schemas/formdata' + action: + type: string + note: + type: object + properties: + text: + type: string + type: + type: string + enum: + - info + - warn + - error + form: + $ref: '#/components/schemas/dataform' + sessionid: + type: string + status: + type: string + node: + type: string + actions: + type: object + properties: + complete: + type: boolean + prev: + type: boolean + next: + type: boolean + execute: + type: string + - type: string + description: Call a command by 'node' id, without arguments oob: type: object description: Reference a media file @@ -852,7 +790,6 @@ desc: description: Optional description type: string - payload: title: 'XEP-0335: JSON Containers' description: A piece of arbitrary JSON with a type field attached @@ -870,7 +807,6 @@ datatype: example: urn:example:my-json#payload type: string - rsm: title: 'XEP-0059: Result Set Management' xml: @@ -892,7 +828,6 @@ type: string first: type: string - archive_query: title: 'XEP-0313: Message Archive Management' type: object @@ -908,7 +843,6 @@ xml: name: query namespace: urn:xmpp:mam:2 - archive_result: title: 'XEP-0313: Message Archive Management' xml: @@ -922,7 +856,6 @@ attribute: true forward: $ref: '#/components/schemas/forwarded' - forwarded: title: 'XEP-0297: Stanza Forwarding' xml: @@ -934,7 +867,6 @@ $ref: '#/components/schemas/message' delay: $ref: '#/components/schemas/delay' - dataform: description: Data form type: object @@ -952,10 +884,10 @@ value: description: Field value oneOf: - - type: string - - type: array - items: - type: string + - type: string + - type: array + items: + type: string type: description: Type of form field type: string @@ -974,23 +906,21 @@ type: type: string enum: - - form - - submit - - cancel - - result + - form + - submit + - cancel + - result instructions: type: string - formdata: description: Simplified data form carrying only values type: object additionalProperties: oneOf: - - type: string - - type: array - items: - type: string - + - type: string + - type: array + items: + type: string stats: description: Statistics type: array @@ -1013,7 +943,6 @@ type: string xml: attribute: true - lastactivity: type: object xml: @@ -1029,7 +958,6 @@ type: string xml: text: true - caps: type: object xml: @@ -1052,7 +980,6 @@ type: string xml: attribute: true - vcard_update: type: object xml: @@ -1062,7 +989,6 @@ photo: type: string example: adc83b19e793491b1c6ea0fd8b46cd9f32e592fc - reactions: type: object xml: @@ -1081,35 +1007,31 @@ xml: wrapped: false name: reactions - occupant_id: type: string xml: namespace: urn:xmpp:occupant-id:0 x_single_attribute: id name: occupant-id - attach_to: type: string xml: namespace: urn:xmpp:message-attaching:1 x_single_attribute: id name: attach-to - fallback: type: boolean xml: namespace: urn:xmpp:fallback:0 x_name_is_value: true name: fallback - stanza_ids: type: array items: type: object required: - - id - - by + - id + - by xml: namespace: urn:xmpp:sid:0 name: stanza-id @@ -1123,7 +1045,6 @@ attribute: true format: xmpp-jid type: string - reference: type: object xml: @@ -1149,9 +1070,8 @@ attribute: true type: string required: - - type - - uri - + - type + - uri reply: title: 'XEP-0461: Message Replies' description: Reference a message being replied to @@ -1168,20 +1088,17 @@ type: string xml: attribute: true - markable: type: boolean xml: namespace: urn:xmpp:chat-markers:0 x_name_is_value: true - displayed: type: string description: Message ID of a message that has been displayed xml: namespace: urn:xmpp:chat-markers:0 x_single_attribute: id - idle_since: type: string xml: @@ -1189,7 +1106,6 @@ x_single_attribute: since name: idle format: date-time - gateway: type: object xml: @@ -1202,7 +1118,6 @@ type: string jid: type: string - extdisco: type: object xml: @@ -1219,8 +1134,8 @@ xml: name: service required: - - type - - host + - type + - host properties: transport: xml: @@ -1260,7 +1175,6 @@ attribute: true type: string type: array - register: type: object description: Register with a service @@ -1313,9 +1227,8 @@ name: type: string required: - - username - - password - + - username + - password upload_slot: type: object xml: @@ -1335,17 +1248,17 @@ items: type: object required: - - name - - value + - name + - value xml: name: header properties: name: type: string enum: - - Authorization - - Cookie - - Expires + - Authorization + - Cookie + - Expires xml: attribute: true value: @@ -1363,8 +1276,8 @@ upload_request: type: object required: - - filename - - size + - filename + - size xml: name: request namespace: urn:xmpp:http:upload:0 @@ -1381,7 +1294,6 @@ type: integer xml: attribute: true - encryption: title: 'XEP-0380: Explicit Message Encryption' type: string @@ -1389,7 +1301,6 @@ x_single_attribute: namespace name: encryption namespace: urn:xmpp:eme:0 - error: description: Description of something gone wrong. See the Stanza Errors section in RFC 6120. type: object @@ -1398,22 +1309,48 @@ description: General category of error type: string enum: - - auth - - cancel - - continue - - modify - - wait + - auth + - cancel + - continue + - modify + - wait condition: description: Specific error condition. type: string - # enum: [ full list available in RFC 6120 ] + enum: + - bad-request + - conflict + - feature-not-implemented + - forbidden + - gone + - internal-server-error + - item-not-found + - jid-malformed + - not-acceptable + - not-allowed + - not-authorized + - policy-violation + - recipient-unavailable + - redirect + - registration-required + - remote-server-not-found + - remote-server-timeout + - resource-constraint + - service-unavailable + - subscription-required + - undefined-condition + - unexpected-request code: description: Legacy numeric error code. Similar to HTTP status codes. type: integer text: description: Description of error intended for human eyes. type: string - + by: + description: Originator of the error, when different from the stanza @from attribute + type: string + xml: + attribute: true securitySchemes: token: description: Tokens from mod_http_oauth2. @@ -1435,7 +1372,6 @@ prosody:user: Regular user privileges prosody:admin: Administrator privileges prosody:operator: Server operator privileges - requestBodies: common: required: true @@ -1449,7 +1385,6 @@ application/x-www-form-urlencoded: schema: description: A subset of the JSON schema, only top level string fields. - responses: success: description: The stanza was sent and returned a response. @@ -1471,9 +1406,7 @@ example: Hello type: string sent: - description: The stanza was sent without problem, and without response, - so an empty reply. - + description: The stanza was sent without problem, and without response, so an empty reply. parameters: to: name: to @@ -1493,5 +1426,3 @@ required: true schema: $ref: '#/components/schemas/type' - -...
--- a/mod_rest/res/schema-xmpp.json Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_rest/res/schema-xmpp.json Mon Sep 18 08:24:19 2023 -0500 @@ -108,6 +108,7 @@ } }, "delay" : { + "description" : "Timestamp of when a stanza was delayed, in ISO 8601 / XEP-0082 format.", "format" : "date-time", "title" : "XEP-0203: Delayed Delivery", "type" : "string", @@ -204,7 +205,7 @@ }, "to" : { "description" : "the intended recipient for the stanza", - "example" : "alice@another.example", + "example" : "alice@example.com", "format" : "xmpp-jid", "type" : "string", "xml" : { @@ -697,6 +698,12 @@ "forward" : { "$ref" : "#/properties/message/properties/forwarded" }, + "id" : { + "type" : "string", + "xml" : { + "attribute" : true + } + }, "queryid" : { "type" : "string", "xml" : {
--- a/mod_restrict_xmpp/mod_restrict_xmpp.lua Mon Sep 18 08:22:07 2023 -0500 +++ b/mod_restrict_xmpp/mod_restrict_xmpp.lua Mon Sep 18 08:24:19 2023 -0500 @@ -3,7 +3,18 @@ local set = require "util.set"; local st = require "util.stanza"; -module:default_permission("prosody:user", "xmpp:federate"); +local normal_user_role = "prosody:registered"; +local limited_user_role = "prosody:guest"; + +local features = require "core.features"; + +-- COMPAT +if not features.available:contains("split-user-roles") then + normal_user_role = "prosody:user"; + limited_user_role = "prosody:restricted"; +end + +module:default_permission(normal_user_role, "xmpp:federate"); module:hook("route/remote", function (event) if not module:may("xmpp:federate", event) then if event.stanza.attr.type ~= "result" and event.stanza.attr.type ~= "error" then @@ -93,12 +104,12 @@ --module:default_permission("prosody:restricted", "xmpp:account:read"); --module:default_permission("prosody:restricted", "xmpp:account:write"); -module:default_permission("prosody:restricted", "xmpp:account:messages:read"); -module:default_permission("prosody:restricted", "xmpp:account:messages:write"); +module:default_permission(limited_user_role, "xmpp:account:messages:read"); +module:default_permission(limited_user_role, "xmpp:account:messages:write"); for _, property_list in ipairs({ iq_namespaces, legacy_storage_nodes, pep_nodes }) do for account_property in set.new(array.collect(it.values(property_list))) do - module:default_permission("prosody:restricted", "xmpp:account:"..account_property..":read"); - module:default_permission("prosody:restricted", "xmpp:account:"..account_property..":write"); + module:default_permission(limited_user_role, "xmpp:account:"..account_property..":read"); + module:default_permission(limited_user_role, "xmpp:account:"..account_property..":write"); end end
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_s2sout_override/README.md Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,54 @@ +--- +summary: Override s2s connection targets +--- + +This module replaces [mod_s2soutinjection] and uses more modern and +reliable methods for overriding connection targets. + +# Configuration + +Enable the module as usual, then specify a map of XMPP remote hostnames +to URIs like `"tcp://host.example:port"`, to have Prosody connect there +instead of doing normal DNS SRV resolution. + +Currently supported schemes are `tcp://` and `tls://`. A future version +could support more methods including alternate SRV lookup targets or +even UNIX sockets. + +URIs with IP addresses like `tcp://127.0.0.1:9999` will bypass A/AAAA +DNS lookups. + +The special target `"*"` may be used to redirect all servers that don't have +an exact match. + +One-level wildcards like `"*.example.net"` also work. + +Standard DNS SRV resolution can be restored by specifying a truthy value. + +```lua +-- Global section +modules_enabled = { + -- other global modules + "s2sout_override"; +} + +s2sout_override = { + ["example.com"] = "tcp://other.host.example:5299"; + ["xmpp.example.net"] = "tcp://localhost:5999"; + ["secure.example"] = "tls://127.0.0.1:5270"; + ["*.allthese.example"] = = "tcp://198.51.100.123:9999"; + + -- catch-all: + ["*"] = "tls://127.0.0.1:5370"; + -- bypass the catch-all, use standard DNS SRV: + ["jabber.example"] = true; +} +``` + +# Compatibility + +Prosody version status +--------------- ---------- +0.12.4 Will work +0.12.3 Will not work +0.11 Will not work
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_s2sout_override/mod_s2sout_override.lua Mon Sep 18 08:24:19 2023 -0500 @@ -0,0 +1,19 @@ +--% requires: s2sout-pre-connect-event + +local url = require"socket.url"; +local basic_resolver = require "net.resolvers.basic"; + +local override_for = module:get_option(module.name, {}); -- map of host to "tcp://example.com:5269" + +module:hook("s2sout-pre-connect", function(event) + local override = override_for[event.session.to_host] or override_for[event.session.to_host:gsub("^[^.]+%.", "*.")] or override_for["*"]; + if type(override) == "string" then + override = url.parse(override); + end + if type(override) == "table" and override.scheme == "tcp" and type(override.host) == "string" then + event.resolver = basic_resolver.new(override.host, tonumber(override.port) or 5269, override.scheme, {}); + elseif type(override) == "table" and override.scheme == "tls" and type(override.host) == "string" then + event.resolver = basic_resolver.new(override.host, tonumber(override.port) or 5270, "tcp", + { servername = event.session.to_host; sslctx = event.session.ssl_ctx }); + end +end);