Changeset

5627:89b6d0e09b86

Merge upstream.
author Trần H. Trung <xmpp:trần.h.trung@trung.fun>
date Wed, 26 Jul 2023 18:45:41 +0700
parents 5595:f7410850941f (current diff) 5626:81042c2a235a (diff)
children 5628:a74b07764d3f
files
diffstat 13 files changed, 481 insertions(+), 97 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.editorconfig	Wed Jul 26 18:45:41 2023 +0700
@@ -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
--- a/mod_client_management/README.md	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_client_management/README.md	Wed Jul 26 18:45:41 2023 +0700
@@ -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	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_client_management/mod_client_management.lua	Wed Jul 26 18:45:41 2023 +0700
@@ -278,6 +278,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+)/(.+)$");
@@ -311,6 +322,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
 
@@ -420,13 +438,12 @@
 		end
 
 		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 = "Last seen";
@@ -434,7 +451,7 @@
 				width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S"));
 				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);
+					return last_seen and os.date(os.difftime(os.time(), last_seen) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen);
 				end;
 			};
 			{
@@ -458,4 +475,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_default_bookmarks/README.markdown	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_default_bookmarks/README.markdown	Wed Jul 26 18:45:41 2023 +0700
@@ -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_http_muc_log/mod_http_muc_log.lua	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_http_muc_log/mod_http_muc_log.lua	Wed Jul 26 18:45:41 2023 +0700
@@ -294,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
--- a/mod_http_oauth2/README.markdown	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_http_oauth2/README.markdown	Wed Jul 26 18:45:41 2023 +0700
@@ -51,6 +51,7 @@
 - [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 Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) (_partial, e.g. missing JWKS_)
@@ -99,8 +100,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
@@ -211,7 +212,8 @@
 ### Supported flows
 
 -   Authorization Code grant, optionally with Proof Key for Code Exchange
--   Resource owner password grant
+-   Device Authorization Grant
+-   Resource owner password grant *(likely to be phased out in the future)*
 -   Implicit flow *(disabled by default)*
 -   Refresh Token grants
 
@@ -222,6 +224,7 @@
 -- These examples reflect the defaults
 allowed_oauth2_grant_types = {
 	"authorization_code"; -- authorization code grant
+	"device_code";
 	"password"; -- resource owner password grant
 }
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/device.html	Wed Jul 26 18:45:41 2023 +0700
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+<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>
+	<main>
+	<h1>{site_name}</h1>
+	<fieldset>
+	<legend>Device Authorization</legend>
+	{error&<div class="error">
+		<p>{error.text}</p>
+	</div>}
+{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="user-code" required >
+		<input type="submit" value="Continue">
+	</form>}
+	</fieldset>
+	</main>
+</body>
+</html>
+
--- a/mod_http_oauth2/mod_http_oauth2.lua	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Wed Jul 26 18:45:41 2023 +0700
@@ -68,6 +68,7 @@
 	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");
@@ -100,8 +101,8 @@
 
 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");
@@ -120,6 +121,8 @@
 	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
@@ -213,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);
@@ -231,6 +233,7 @@
 -- 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" });
 
@@ -266,18 +269,23 @@
 		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;
 	end
 
+	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, nil, "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);
@@ -317,6 +325,15 @@
 		-- 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
@@ -342,6 +359,13 @@
 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'"));
@@ -371,7 +395,14 @@
 	end
 
 	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
+			code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code));
+		end
+	end
+	local ok = codes:set("authorization_code:" .. params.client_id .. "#" .. code, {
 		expires = os.time() + 600;
 		granted_jid = granted_jid;
 		granted_scopes = granted_scopes;
@@ -387,6 +418,8 @@
 	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
 	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 oauth_error("invalid_redirect_uri");
 	end
@@ -462,12 +495,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
 	-- TODO if a second attempt *is* made, revoke any tokens issued
-	codes:set(params.client_id .. "#" .. params.code, nil);
+	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");
@@ -530,6 +563,34 @@
 	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.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.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
 
 function verifier_transforms.plain(code_verifier)
@@ -688,7 +749,14 @@
 	"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);
@@ -727,7 +795,7 @@
 	event.response.headers.pragma = "no-cache";
 	local params = strict_formdecode(event.request.body);
 	if not params then
-		return 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
@@ -739,7 +807,7 @@
 	local grant_type = params.grant_type
 	local grant_handler = grant_type_handlers[grant_type];
 	if not grant_handler then
-		return oauth_error("invalid_request");
+		return oauth_error("invalid_request", "No such grant type.");
 	end
 	return grant_handler(params);
 end
@@ -820,6 +888,16 @@
 		return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true);
 	elseif not auth_state.consent then
 		-- Notify client of rejection
+		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
@@ -856,6 +934,113 @@
 	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
+
+	-- 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 dc_ok = codes:set("device_code:" .. params.client_id .. "#" .. device_code, { expires = os.time() + 1200 });
+	local uc_ok = codes:set("user_code:" .. user_code,
+		{ user_code = user_code; expires = os.time() + 600; 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 function handle_revocation_request(event)
 	local request, response = event.request, event.response;
 	response.headers.cache_control = "no-store";
@@ -886,6 +1071,7 @@
 end
 
 local registration_schema = {
+	title = "OAuth 2.0 Dynamic Client Registration Protocol";
 	type = "object";
 	required = {
 		-- These are shown to users in the template
@@ -895,13 +1081,21 @@
 		"redirect_uris";
 	};
 	properties = {
-		redirect_uris = { type = "array"; minItems = 1; uniqueItems = true; 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;
@@ -915,27 +1109,87 @@
 					"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" };
+		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 = { type = "string" };
-		client_uri = { type = "string"; format = "uri"; pattern = "^https:" };
-		logo_uri = { type = "string"; format = "uri"; pattern = "^https:" };
-		scope = { type = "string" };
-		contacts = { type = "array"; minItems = 1; items = { type = "string"; format = "email" } };
-		tos_uri = { type = "string"; format = "uri"; pattern = "^https:" };
-		policy_uri = { type = "string"; format = "uri"; pattern = "^https:" };
-		software_id = { type = "string"; format = "uuid" };
-		software_version = { type = "string" };
+		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";
+		};
 	};
 }
 
@@ -1069,6 +1323,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)
@@ -1130,6 +1386,10 @@
 		-- 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;
@@ -1203,11 +1463,9 @@
 		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(response_type_handlers)):map(tmap {
-			token = "implicit";
-			code = "authorization_code";
-		});
+		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");
--- a/mod_muc_block_pm/README.markdown	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_muc_block_pm/README.markdown	Wed Jul 26 18:45:41 2023 +0700
@@ -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	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_muc_block_pm/mod_muc_block_pm.lua	Wed Jul 26 18:45:41 2023 +0700
@@ -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_limits/README.markdown	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_muc_limits/README.markdown	Wed Jul 26 18:45:41 2023 +0700
@@ -30,18 +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
-  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
+  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.
@@ -68,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	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_muc_limits/mod_muc_limits.lua	Wed Jul 26 18:45:41 2023 +0700
@@ -15,6 +15,8 @@
 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;
@@ -49,7 +51,7 @@
 		throttle = new_throttle(period*burst, burst);
 		room.throttle = throttle;
 	end
-	local cost = 1;
+	local cost = base_cost;
 	local body = stanza:get_child_text("body");
 	if body then
 		-- TODO calculate a text diagonal cross-section or some mathemagical
@@ -65,7 +67,7 @@
 				:tag("x", { xmlns = xmlns_muc; }));
 			return true;
 		end
-		cost = cost + body_lines;
+		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);
--- a/mod_muc_moderation/mod_muc_moderation.lua	Wed Jul 26 18:43:45 2023 +0700
+++ b/mod_muc_moderation/mod_muc_moderation.lua	Wed Jul 26 18:45:41 2023 +0700
@@ -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