--- /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);
--- a/mod_welcome_page/README.markdown Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_welcome_page/README.markdown Mon Sep 18 08:24:19 2023 -0500
@@ -4,7 +4,6 @@
summary: 'Serve a welcome page to users'
rockspec:
dependencies:
- - mod_invites
- mod_http_libjs
build:
copy_directories: