Changeset

5673:0eb2d5ea2428

merge
author Stephen Paul Weber <singpolyma@singpolyma.net>
date Sat, 06 May 2023 19:40:23 -0500
parents 5672:2c69577b28c2 (current diff) 5422:72f23107beb4 (diff)
children 5674:b40750891bee
files
diffstat 61 files changed, 3144 insertions(+), 315 deletions(-) [+]
line wrap: on
line diff
--- a/.luacheckrc	Wed Feb 22 22:47:45 2023 -0500
+++ b/.luacheckrc	Sat May 06 19:40:23 2023 -0500
@@ -59,6 +59,7 @@
 	"module.may",
 	"module.measure",
 	"module.metric",
+	"module.once",
 	"module.open_store",
 	"module.provides",
 	"module.remove_item",
--- a/mod_adhoc_oauth2_client/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_adhoc_oauth2_client/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -2,21 +2,20 @@
 labels:
 - Stage-Alpha
 summary: 'Create OAuth2 clients via ad-hoc command'
+rockspec:
+  dependencies:
+  - mod_http_oauth2
 ...
 
 Introduction
 ============
 
-Allows creating OAuth2 clients for use with [mod_http_oauth2]. Otherwise
-a work-in-progress intended for developers only!
-
-Configuration
-=============
-
-**TODO**
+[Ad-Hoc command][XEP-0050] interface to
+[dynamic OAuth2 registration](https://oauth.net/2/dynamic-client-registration/)
+provided by [mod_http_oauth2].
 
 Compatibility
 =============
 
-Probably Prosody trunk.
+Same as [mod_http_oauth2]
 
--- a/mod_adhoc_oauth2_client/mod_adhoc_oauth2_client.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_adhoc_oauth2_client/mod_adhoc_oauth2_client.lua	Sat May 06 19:40:23 2023 -0500
@@ -1,22 +1,20 @@
 local adhoc = require "util.adhoc";
 local dataforms = require "util.dataforms";
-local errors = require "util.error";
-local hashes = require "util.hashes";
-local id = require "util.id";
-local jid = require "util.jid";
-local base64 = require"util.encodings".base64;
 
-local clients = module:open_store("oauth2_clients", "map");
-
-local iteration_count = module:get_option_number("oauth2_client_iteration_count", 10000);
-local pepper = module:get_option_string("oauth2_client_pepper", "");
+local mod_http_oauth2 = module:depends"http_oauth2";
 
 local new_client = dataforms.new({
 	title = "Create OAuth2 client";
-	{var = "FORM_TYPE"; type = "hidden"; value = "urn:uuid:ff0d55ed-2187-4ee0-820a-ab633a911c14#create"};
-	{name = "name"; type = "text-single"; label = "Client name"; required = true};
-	{name = "description"; type = "text-multi"; label = "Description"};
-	{name = "info_url"; type = "text-single"; label = "Informative URL"; desc = "Link to information about your client"; datatype = "xs:anyURI"};
+	{ var = "FORM_TYPE"; type = "hidden"; value = "urn:uuid:ff0d55ed-2187-4ee0-820a-ab633a911c14#create" };
+	{ name = "client_name"; type = "text-single"; label = "Client name"; required = true };
+	{
+		name = "client_uri";
+		type = "text-single";
+		label = "Informative URL";
+		desc = "Link to information about your client. MUST be https URI.";
+		datatype = "xs:anyURI";
+		required = true;
+	};
 	{
 		name = "redirect_uri";
 		type = "text-single";
@@ -30,9 +28,9 @@
 local client_created = dataforms.new({
 	title = "New OAuth2 client created";
 	instructions = "Save these details, they will not be shown again";
-	{var = "FORM_TYPE"; type = "hidden"; value = "urn:uuid:ff0d55ed-2187-4ee0-820a-ab633a911c14#created"};
-	{name = "client_id"; type = "text-single"; label = "Client ID"};
-	{name = "client_secret"; type = "text-single"; label = "Client secret"};
+	{ var = "FORM_TYPE"; type = "hidden"; value = "urn:uuid:ff0d55ed-2187-4ee0-820a-ab633a911c14#created" };
+	{ name = "client_id"; type = "text-single"; label = "Client ID" };
+	{ name = "client_secret"; type = "text-single"; label = "Client secret" };
 })
 
 local function create_client(client, formerr, data)
@@ -41,23 +39,15 @@
 		for field, err in pairs(formerr) do table.insert(errmsg, field .. ": " .. err); end
 		return {status = "error"; error = {message = table.concat(errmsg, "\n")}};
 	end
-
-	local creator = jid.split(data.from);
-	local client_uid = id.short();
-	local client_id = jid.join(creator, module.host, client_uid);
-	local client_secret = id.long();
-	local salt = id.medium();
-	local i = iteration_count;
+	client.redirect_uris = { client.redirect_uri };
+	client.redirect_uri = nil;
 
-	client.secret_hash = base64.encode(hashes.pbkdf2_hmac_sha256(client_secret, salt .. pepper, i));
-	client.iteration_count = i;
-	client.salt = salt;
+	local client_metadata, err = mod_http_oauth2.create_client(client);
+	if err then return { status = "error"; error = err }; end
 
-	local ok, err = errors.coerce(clients:set(creator, client_uid, client));
-	module:log("info", "OAuth2 client %q created by %s", client_id, data.from);
-	if not ok then return {status = "canceled"; error = {message = err}}; end
+	module:log("info", "OAuth2 client %q %q created by %s", client.name, client.info_uri, data.from);
 
-	return {status = "completed"; result = {layout = client_created; values = {client_id = client_id; client_secret = client_secret}}};
+	return { status = "completed"; result = { layout = client_created; values = client_metadata } };
 end
 
 local handler = adhoc.new_simple_form(new_client, create_client);
--- a/mod_audit/README.md	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_audit/README.md	Sat May 06 19:40:23 2023 -0500
@@ -25,3 +25,27 @@
 allowed to store the data for the amount of time these modules will store it.
 Note that it is currently not possible to store different event types with
 different expiration times.
+
+## Viewing the log
+
+You can view the log using prosodyctl. This works even when Prosody is not
+running.
+
+For example, to view the full audit log for example.com:
+
+```shell
+prosodyctl mod_audit example.com
+```
+
+To view only host-wide events (those not attached to a specific user account),
+use the `--global` option (or use `--no-global` to hide such events):
+
+```shell
+prosodyctl mod_audit --global example.com
+```
+
+To narrow results to a specific user, specify their JID:
+
+```shell
+prosodyctl mod_audit user@example.com
+```
--- a/mod_audit/mod_audit.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_audit/mod_audit.lua	Sat May 06 19:40:23 2023 -0500
@@ -1,14 +1,34 @@
 module:set_global();
 
-local audit_log_limit = module:get_option_number("audit_log_limit", 10000);
-local cleanup_after = module:get_option_string("audit_log_expires_after", "2w");
-
 local time_now = os.time;
+local parse_duration = require "util.human.io".parse_duration;
+local ip = require "util.ip";
 local st = require "util.stanza";
 local moduleapi = require "core.moduleapi";
 
 local host_wide_user = "@";
 
+local cleanup_after = module:get_option_string("audit_log_expires_after", "28d");
+if cleanup_after == "never" then
+	cleanup_after = nil;
+else
+	cleanup_after = parse_duration(cleanup_after);
+end
+
+local attach_ips = module:get_option_boolean("audit_log_ips", true);
+local attach_ipv4_prefix = module:get_option_number("audit_log_ipv4_prefix", nil);
+local attach_ipv6_prefix = module:get_option_number("audit_log_ipv6_prefix", nil);
+
+local have_geoip, geoip = pcall(require, "geoip.country");
+local attach_location = have_geoip and module:get_option_boolean("audit_log_location", true);
+
+local geoip4_country, geoip6_country;
+if have_geoip and attach_location then
+	geoip4_country = geoip.open(module:get_option_string("geoip_ipv4_country", "/usr/share/GeoIP/GeoIP.dat"));
+	geoip6_country = geoip.open(module:get_option_string("geoip_ipv6_country", "/usr/share/GeoIP/GeoIPv6.dat"));
+end
+
+
 local stores = {};
 
 local function get_store(self, host)
@@ -23,6 +43,34 @@
 
 setmetatable(stores, { __index = get_store });
 
+local function prune_audit_log(host)
+	local before = os.time() - cleanup_after;
+	module:context(host):log("debug", "Pruning audit log for entries older than %s", os.date("%Y-%m-%d %R:%S", before));
+	local ok, err = stores[host]:delete(nil, { ["end"] = before });
+	if not ok then
+		module:context(host):log("error", "Unable to prune audit log: %s", err);
+		return;
+	end
+	local sum = tonumber(ok);
+	if sum then
+		module:context(host):log("debug", "Pruned %d expired audit log entries", sum);
+		return sum > 0;
+	end
+	module:context(host):log("debug", "Pruned expired audit log entries");
+	return true;
+end
+
+local function get_ip_network(ip_addr)
+	local _ip = ip.new_ip(ip_addr);
+	local proto = _ip.proto;
+	local network;
+	if proto == "IPv4" and attach_ipv4_prefix then
+		network = ip.truncate(_ip, attach_ipv4_prefix).normal.."/"..attach_ipv4_prefix;
+	elseif proto == "IPv6" and attach_ipv6_prefix then
+		network = ip.truncate(_ip, attach_ipv6_prefix).normal.."/"..attach_ipv6_prefix;
+	end
+	return network;
+end
 
 local function session_extra(session)
 	local attr = {
@@ -35,8 +83,22 @@
 		attr.type = session.type;
 	end
 	local stanza = st.stanza("session", attr);
-	if session.ip then
-		stanza:text_tag("remote-ip", session.ip);
+	if attach_ips and session.ip then
+		local remote_ip, network = session.ip;
+		if attach_ipv4_prefix or attach_ipv6_prefix then
+			network = get_ip_network(remote_ip);
+		end
+		stanza:text_tag("remote-ip", network or remote_ip);
+	end
+	if attach_location and session.ip then
+		local remote_ip = ip.new(session.ip);
+		local geoip_country = ip.proto == "IPv6" and geoip6_country or geoip4_country;
+		stanza:tag("location", {
+			country = geoip_country:query_by_addr(remote_ip.normal);
+		}):up();
+	end
+	if session.client_id then
+		stanza:text_tag("client", session.client_id);
 	end
 	return stanza
 end
@@ -55,7 +117,7 @@
 		attr.user = user_key;
 	end
 	local stanza = st.stanza("audit-event", attr);
-	if extra ~= nil then
+	if extra then
 		if extra.session then
 			local child = session_extra(extra.session);
 			if child then
@@ -63,7 +125,7 @@
 			end
 		end
 		if extra.custom then
-			for _, child in extra.custom do
+			for _, child in ipairs(extra.custom) do
 				if not st.is_stanza(child) then
 					error("all extra.custom items must be stanzas")
 				end
@@ -72,15 +134,155 @@
 		end
 	end
 
-	local id, err = stores[host]:append(nil, nil, stanza, time_now(), user_key);
-	if err then
-		module:log("error", "failed to persist audit event: %s", err);
-		return
+	local store = stores[host];
+	local id, err = store:append(nil, nil, stanza, extra and extra.timestamp or time_now(), user_key);
+	if not id then
+		if err == "quota-limit" then
+			local limit = store.caps and store.caps.quota or 1000;
+			local truncate_to = math.floor(limit * 0.99);
+			if type(cleanup_after) == "number" then
+				module:log("debug", "Audit log has reached quota - forcing prune");
+				if prune_audit_log(host) then
+					-- Retry append
+					id, err = store:append(nil, nil, stanza, extra and extra.timestamp or time_now(), user_key);
+				end
+			end
+			if not id and (store.caps and store.caps.truncate) then
+				module:log("debug", "Audit log has reached quota - truncating");
+				local truncated = store:delete(nil, {
+					truncate = truncate_to;
+				});
+				if truncated then
+					-- Retry append
+					id, err = store:append(nil, nil, stanza, extra and extra.timestamp or time_now(), user_key);
+				end
+			end
+		end
+		if not id then
+			module:log("error", "Failed to persist audit event: %s", err);
+			return;
+		end
 	else
-		module:log("debug", "persisted audit event %s as %s", stanza:top_tag(), id);
+		module:log("debug", "Persisted audit event %s as %s", stanza:top_tag(), id);
 	end
 end
 
 function moduleapi.audit(module, user, event_type, extra)
 	audit(module.host, user, "mod_" .. module:get_name(), event_type, extra);
 end
+
+function module.command(arg_)
+	local jid = require "util.jid";
+	local arg = require "util.argparse".parse(arg_, {
+		value_params = { "limit" };
+	 });
+
+	for k, v in pairs(arg) do print("U", k, v) end
+	local query_user, host = jid.prepped_split(arg[1]);
+
+	if arg.prune then
+		local sm = require "core.storagemanager";
+		if host then
+			sm.initialize_host(host);
+			prune_audit_log(host);
+		else
+			for _host in pairs(prosody.hosts) do
+				sm.initialize_host(_host);
+				prune_audit_log(_host);
+			end
+		end
+		return;
+	end
+
+	if not host then
+		print("EE: Please supply the host for which you want to show events");
+		return 1;
+	elseif not prosody.hosts[host] then
+		print("EE: Unknown host: "..host);
+		return 1;
+	end
+
+	require "core.storagemanager".initialize_host(host);
+	local store = stores[host];
+	local c = 0;
+
+	if arg.global then
+		if query_user then
+			print("WW: Specifying a user account is incompatible with --global. Showing only global events.");
+		end
+		query_user = "@";
+	end
+
+	local results, err = store:find(nil, {
+		with = query_user;
+		limit = arg.limit and tonumber(arg.limit) or nil;
+		reverse = true;
+	})
+	if not results then
+		print("EE: Failed to query audit log: "..tostring(err));
+		return 1;
+	end
+
+	local colspec = {
+		{ title = "Date", key = "when", width = 19, mapper = function (when) return os.date("%Y-%m-%d %R:%S", when); end };
+		{ title = "Source", key = "source", width = "2p" };
+		{ title = "Event", key = "event_type", width = "2p" };
+	};
+
+	if arg.show_user ~= false and (not arg.global and not query_user) or arg.show_user then
+		table.insert(colspec, {
+			title = "User", key = "username", width = "2p",
+			mapper = function (user)
+				if user == "@" then return ""; end
+				if user:sub(-#host-1, -1) == ("@"..host) then
+					return (user:gsub("@.+$", ""));
+				end
+			end;
+		});
+	end
+	if arg.show_ip ~= false and (not arg.global and attach_ips) or arg.show_ip then
+		table.insert(colspec, {
+			title = "IP", key = "ip", width = "2p";
+		});
+	end
+	if arg.show_location ~= false and (not arg.global and attach_location) or arg.show_location then
+		table.insert(colspec, {
+			title = "Location", key = "country", width = 2;
+		});
+	end
+
+	if arg.show_note then
+		table.insert(colspec, {
+			title = "Note", key = "note", width = "2p";
+		});
+	end
+
+	local row, width = require "util.human.io".table(colspec);
+
+	print(string.rep("-", width));
+	print(row());
+	print(string.rep("-", width));
+	for _, entry, when, user in results do
+		if arg.global ~= false or user ~= "@" then
+			c = c + 1;
+			print(row({
+				when = when;
+				source = entry.attr.source;
+				event_type = entry.attr.type:gsub("%-", " ");
+				username = user;
+				ip = entry:get_child_text("remote-ip");
+				location = entry:find("location@country");
+				note = entry:get_child_text("note");
+			}));
+		end
+	end
+	print(string.rep("-", width));
+	print(("%d records displayed"):format(c));
+end
+
+function module.add_host(host_module)
+	host_module:depends("cron");
+	host_module:daily("Prune audit logs", function ()
+		prune_audit_log(host_module.host);
+	end);
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_status/README.md	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,29 @@
+---
+summary: Log server status changes to audit log
+rockspec: {}
+...
+
+This module records server status (start, stop, crash) to the audit log
+maintained by [mod_audit].
+
+## Configuration
+
+There is a single option, `audit_status_heartbeat_interval` which specifies
+the interval at which the "server is running" heartbeat should be updated (it
+is stored in Prosody's configured storage backend).
+
+To detect crashes, Prosody periodically updates this value at the specified
+interval. A low value will update more frequently, which causes additional I/O
+for Prosody. A high value will give less accurate timestamps for "server
+crashed" events in the audit log.
+
+The default value is 60 (seconds).
+
+```lua
+audit_status_heartbeat_interval = 60
+```
+
+## Compatibility
+
+This module requires Prosody trunk (as of April 2023). It is not compatible
+with 0.12.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_status/mod_audit_status.lua	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,35 @@
+module:depends("audit");
+
+local st = require "util.stanza";
+
+-- Suppress warnings about module:audit()
+-- luacheck: ignore 143/module
+
+local heartbeat_interval = module:get_option_number("audit_status_heartbeat_interval", 60);
+
+local store = module:open_store(nil, "keyval+");
+
+module:hook_global("server-started", function ()
+	local recorded_status = store:get();
+	if recorded_status and recorded_status.status == "started" then
+		module:audit(nil, "server-crashed", { timestamp = recorded_status.heartbeat });
+	end
+	module:audit(nil, "server-started");
+	store:set_key(nil, "status", "started");
+end);
+
+module:hook_global("server-stopped", function ()
+	module:audit(nil, "server-stopped", {
+		custom = {
+			prosody.shutdown_reason and st.stanza("note"):text(prosody.shutdown_reason);
+		};
+	});
+	store:set_key(nil, "status", "stopped");
+end);
+
+if heartbeat_interval then
+	module:add_timer(0, function ()
+		store:set_key(nil, "heartbeat", os.time());
+		return heartbeat_interval;
+	end);
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_oauth_external/README.md	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,95 @@
+---
+summary: Authenticate against an external OAuth 2 IdP
+labels:
+- Stage-Alpha
+---
+
+This module provides external authentication via an external [AOuth
+2](https://datatracker.ietf.org/doc/html/rfc7628) authorization server
+and supports the [SASL OAUTHBEARER authentication][rfc7628]
+mechanism as well as PLAIN for legacy clients (this is all of them).
+
+# How it works
+
+Clients retrieve tokens somehow, then show them to Prosody, which asks
+the Authorization server to validate them, returning info about the user
+back to Prosody.
+
+Alternatively for legacy clients, Prosody receives the users username
+and password and retrieves a token itself, then proceeds as above.
+
+# Configuration
+
+## Example
+
+```lua
+-- authentication = "oauth_external"
+
+oauth_external_discovery_url = "https//auth.example.com/auth/realms/TheRealm/.well-known/openid-configuration"
+oauth_external_token_endpoint = "https//auth.example.com/auth/realms/TheRealm/protocol/openid-connect/token"
+oauth_external_validation_endpoint = "https//auth.example.com/auth/realms/TheRealm/protocol/openid-connect/userinfo"
+oauth_external_username_field = "xmpp_username"
+```
+
+
+## Common
+
+`oauth_external_issuer`
+:   Optional URL string representing the Authorization server identity.
+
+`oauth_external_discovery_url`
+:   Optional URL string pointing to [OAuth 2.0 Authorization Server
+    Metadata](https://oauth.net/2/authorization-server-metadata/). Lets
+    clients discover where they should retrieve access tokens from if
+    they don't have one yet. Default based on `oauth_external_issuer` is
+    set, otherwise empty.
+
+`oauth_external_validation_endpoint`
+:   URL string. The token validation endpoint, should validate the token
+    and return a JSON structure containing the username of the user
+    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)
+
+`oauth_external_username_field`
+:   String. Default is `"preferred_username"`. Field in the JSON
+    structure returned by the validation endpoint that contains the XMPP
+    localpart.
+
+## For SASL PLAIN
+
+`oauth_external_resource_owner_password`
+:   Boolean. Defaults to `true`. Whether to allow the *insecure*
+    [resource owner password
+    grant](https://oauth.net/2/grant-types/password/) and SASL PLAIN.
+
+`oauth_external_token_endpoint`
+:   URL string. OAuth 2 [Token
+    Endpoint](https://www.rfc-editor.org/rfc/rfc6749#section-3.2) used
+    to retrieve token in order to then retrieve the username.
+
+`oauth_external_client_id`
+:   String. Client ID used to identify Prosody during the resource owner
+    password grant.
+
+# Compatibility
+
+## Prosody
+
+  Version   Status
+  --------- ---------------
+  trunk     works
+  0.12.x    does not work
+  0.11.x    does not work
+
+## Identity Provider
+
+Tested with
+
+-   [KeyCloak](https://www.keycloak.org/)
+
+# Future work
+
+-   Automatically discover endpoints from Discovery URL
+-   Configurable input username mapping (e.g. user → user@host).
+-   [SCRAM over HTTP?!][rfc7804]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_oauth_external/mod_auth_oauth_external.lua	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,98 @@
+local http = require "net.http";
+local async = require "util.async";
+local json = require "util.json";
+local sasl = require "util.sasl";
+
+local issuer_identity = module:get_option_string("oauth_external_issuer");
+local oidc_discovery_url = module:get_option_string("oauth_external_discovery_url",
+	issuer_identity and issuer_identity .. "/.well-known/oauth-authorization-server" or nil);
+local validation_endpoint = module:get_option_string("oauth_external_validation_endpoint");
+local token_endpoint = module:get_option_string("oauth_external_token_endpoint");
+
+local username_field = module:get_option_string("oauth_external_username_field", "preferred_username");
+local allow_plain = module:get_option_boolean("oauth_external_resource_owner_password", true);
+
+-- 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");
+
+--[[ More or less required endpoints
+digraph "oauth endpoints" {
+issuer -> discovery -> { registration validation }
+registration -> { client_id client_secret }
+{ client_id client_secret validation } -> required
+}
+--]]
+
+local host = module.host;
+local provider = {};
+
+function provider.get_sasl_handler()
+	local profile = {};
+	profile.http_client = http.default; -- TODO configurable
+	local extra = { oidc_discovery_url = oidc_discovery_url };
+	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)
+			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;
+					username = map_username(username, realm);
+					password = password;
+					scope = "openid";
+				});
+			}))
+			if err or not (tok.code >= 200 and tok.code < 300) then
+				return false, nil;
+			end
+			local token_resp = json.decode(tok.body);
+			if not token_resp or string.lower(token_resp.token_type or "") ~= "bearer" then
+				return false, nil;
+			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
+				return false, nil;
+			end
+			if not (ret.code >= 200 and ret.code < 300) then
+				return false, nil;
+			end
+			local response = json.decode(ret.body);
+			if type(response) ~= "table" or (response[username_field]) ~= username 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.token_info = response;
+			return true, true;
+		end
+	end
+	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;
+		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
+
+module:provides("auth", provider);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_authz_delegate/README.md	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,24 @@
+---
+summary: Authorization delegation
+rockspec: {}
+...
+
+This module allows delegating authorization questions (role assignment and
+role policies) to another host within prosody.
+
+The primary use of this is for a group of virtual hosts to use a common
+authorization database, for example to allow a MUC component to grant
+administrative access to an admin on a corresponding user virtual host.
+
+## Configuration
+
+The following example will make all role assignments for local and remote JIDs
+from domain.example effective on groups.domain.example:
+
+```
+VirtualHost "domain.example"
+
+Component "groups.domain.example" "muc"
+    authorization = "delegate"
+    authz_delegate_to = "domain.example"
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_authz_delegate/mod_authz_delegate.lua	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,96 @@
+local target_host = assert(module:get_option("authz_delegate_to"));
+local this_host = module:get_host();
+
+local array = require"util.array";
+local jid_split = import("prosody.util.jid", "split");
+
+local hosts = prosody.hosts;
+
+function get_jids_with_role(role)  --luacheck: ignore 212/role
+	return nil
+end
+
+function get_user_role(user)
+	-- this is called where the JID belongs to the host this module is loaded on
+	-- that means we have to delegate that to get_jid_role with an appropriately composed JID
+	return hosts[target_host].authz.get_jid_role(user .. "@" .. this_host)
+end
+
+function set_user_role(user, role_name)  --luacheck: ignore 212/user 212/role_name
+	-- no roles for entities on this host.
+	return false, "cannot set user role on delegation target"
+end
+
+function get_user_secondary_roles(user)  --luacheck: ignore 212/user
+	-- no roles for entities on this host.
+	return {}
+end
+
+function add_user_secondary_role(user, role_name)  --luacheck: ignore 212/user 212/role_name
+	-- no roles for entities on this host.
+	return nil, "cannot set user role on delegation target"
+end
+
+function remove_user_secondary_role(user, role_name)  --luacheck: ignore 212/user 212/role_name
+	-- no roles for entities on this host.
+	return nil, "cannot set user role on delegation target"
+end
+
+function user_can_assume_role(user, role_name)  --luacheck: ignore 212/user 212/role_name
+	-- no roles for entities on this host.
+	return false
+end
+
+function get_jid_role(jid)
+	local user, host = jid_split(jid);
+	if host == target_host then
+		return hosts[target_host].authz.get_user_role(user);
+	end
+	return hosts[target_host].authz.get_jid_role(jid);
+end
+
+function set_jid_role(jid)  --luacheck: ignore 212/jid
+	-- TODO: figure out if there are actually legitimate uses for this...
+	return nil, "cannot set jid role on delegation target"
+end
+
+local default_permission_queue = array{};
+
+function add_default_permission(role_name, action, policy)
+	-- NOTE: we always record default permissions, because the delegated-to
+	-- host may be re-activated.
+	default_permission_queue:push({
+		role_name = role_name,
+		action = action,
+		policy = policy,
+	});
+	local target_host_object = hosts[target_host];
+	local authz = target_host_object and target_host_object.authz;
+	if not authz then
+		module:log("debug", "queueing add_default_permission call for later, %s is not active yet", target_host);
+		return;
+	end
+	return authz.add_default_permission(role_name, action, policy)
+end
+
+function get_role_by_name(role_name)
+	return hosts[target_host].authz.get_role_by_name(role_name)
+end
+
+function get_all_roles()
+	return hosts[target_host].authz.get_all_roles()
+end
+
+module:hook_global("host-activated", function(host)
+	if host == target_host then
+		local authz = hosts[target_host].authz;
+		module:log("debug", "replaying %d queued permission changes", #default_permission_queue);
+		assert(authz);
+		-- replay default permission changes, if any
+		for i, item in ipairs(default_permission_queue) do
+			authz.add_default_permission(item.role_name, item.action, item.policy);
+		end
+		-- NOTE: we do not clear that array here -- in case the target_host is
+		-- re-activated
+	end
+end, -10000)
--- a/mod_block_registrations/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_block_registrations/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -19,11 +19,11 @@
 
 You can then set some options to configure your desired policy:
 
-  Option                           Default         Description
-  -------------------------------- --------------- -------------------------------------------------------------------------------------------------------------------------------------------------
-  block\_registrations\_users      `{ "admin" }`   A list of reserved usernames
-  block\_registrations\_matching   `{ }`           A list of [Lua patterns](http://www.lua.org/manual/5.1/manual.html#5.4.1) matching reserved usernames (slower than block\_registrations\_users)
-  block\_registrations\_require    `nil`           A pattern that registered user accounts MUST match to be allowed
+  Option                         Default             Description
+  ------------------------------ ------------------- -----------------------------------------------------------------------------------------------------------------------------------------------
+  block_registrations_users      *See source code*   A list of reserved usernames
+  block_registrations_matching   `{ }`               A list of [Lua patterns](http://www.lua.org/manual/5.1/manual.html#5.4.1) matching reserved usernames (slower than block_registrations_users)
+  block_registrations_require    `nil`               A pattern that registered user accounts MUST match to be allowed
 
 Some examples:
 
@@ -36,9 +36,7 @@
 Compatibility
 =============
 
-  ----- -------------
-  0.9   Works
-  0.8   Should work
-  ----- -------------
-
-
+  ------ -------
+  0.12    Works
+  0.11    Work
+  ------ -------
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_client_management/README.md	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,146 @@
+---
+labels:
+- Stage-Beta
+summary: "Manage clients with access to your account"
+rockspec:
+  dependencies:
+  - mod_sasl2_fast
+---
+
+This module allows a user to identify what currently has access to their
+account.
+
+This module depends on [mod_sasl2_fast] and mod_tokenauth (bundled with
+Prosody). Both will be automatically loaded if this module is loaded.
+
+## Configuration
+
+| Name                      | Description                                            | Default         |
+|---------------------------|--------------------------------------------------------|-----------------|
+| enforce_client_ids        | Only allow SASL2-compatible clients                    | `false`         |
+
+When `enforce_client_ids` is not enabled, the client listing may be less accurate due to legacy clients,
+which can only be tracked by their resource, which is public information, not necessarily unique to a
+client instance, and is also exposed to other XMPP entities the user communicates with.
+
+When `enforce_client_ids` is enabled, clients that don't support SASL2 and provide a client id will be
+denied access.
+
+## Shell usage
+
+You can use this module via the Prosody shell. For example, to list a user's
+clients:
+
+```shell
+prosodyctl shell user clients user@example.com
+```
+
+## Compatibility
+
+Requires Prosody trunk (as of 2023-03-29). Not compatible with Prosody 0.12
+and earlier.
+
+## Developers
+
+### Protocol
+
+#### Listing clients
+
+To list clients that have access to the user's account, send the following
+stanza:
+
+```xml
+<iq id="p" type="get">
+  <list xmlns="xmpp:prosody.im/protocol/manage-clients"/>
+</iq>
+```
+
+The server will respond with a list of clients:
+
+```xml
+<iq id="p" to="mattj-gajim@auth2.superxmpp.com/gajim.UYJKBHKT" type="result" xmlns="jabber:client">
+  <clients xmlns="xmpp:prosody.im/protocol/manage-clients">
+    <client connected="true" id="client/zeiP41HLglIu" type="session">
+      <first-seen>2023-04-06T14:26:08Z</first-seen>
+      <last-seen>2023-04-06T14:37:25Z</last-seen>
+      <auth>
+        <password/>
+      </auth>
+      <user-agent>
+        <software>Gajim</software>
+        <uri>https://gajim.org/</uri>
+        <device>Juliet's laptop</device>
+      </user-agent>
+    </client>
+    <client connected="false" id="grant/HjEEr45_LQr" type="access">
+      <first-seen>2023-03-27T15:16:09Z</first-seen>
+      <last-seen>2023-03-27T15:37:24Z</last-seen>
+      <user-agent>
+        <software>REST client</software>
+      </user-agent>
+    </client>
+  </clients>
+</iq>
+```
+
+On the `<client/>` tag most things are self-explanatory. The following attributes
+are defined:
+
+- 'connected': a boolean that reflects whether this client has an active session
+on the server (i.e. this includes connected and "hibernating" sessions).
+- 'id': an opaque reference for the client, which can be used to revoke access.
+- 'type': either `"session"` if this client is known to have an active or inactive
+  client session on the server, or "access" if no session has been established (e.g.
+  it may have been granted access to the account, but only used non-XMPP APIs or
+  never logged in).
+
+The `<first-seen/>` and `<last-seen/>` elements contain timestamps that reflect
+when a client was first granted access to the user's account, and when it most
+recently used that access. For active sessions, it may reflect the current
+time or the time of the last login.
+
+The `<user-agent/>` element contains information about the client software. It
+may contain any of three optional child elements, each containing text content:
+
+- `<software/>` - the name of the software
+- `<uri/>` - a URI/URL for the client, such as a homepage
+- `<device/>` - a human-readable identifier/name for the device where the client
+  runs
+
+The `<auth/>` element lists the known authentication methods that the client
+has used to gain access to the account. The following elements are defined:
+
+- `<password/>` - the client has presented a valid password
+- `<grant/>` - the client has a valid authorization grant (e.g. via OAuth)
+- `<fast/>` - the client has active FAST tokens
+
+#### Revoking access
+
+To revoke a client's access, send a `<revoke/>` element with an 'id' attribute
+containing one of the client ids fetched from the list:
+
+```xml
+<iq id="p" type="set">
+  <revoke xmlns="xmpp:prosody.im/protocol/manage-clients" id="grant/HjEEr45_LQr" />
+</iq>
+```
+
+The server will respond with an empty result if the revocation succeeds:
+
+```xml
+<iq id="p" type="result" />
+```
+
+If the client has previously authenticated with a password, there is no way to
+revoke access except by changing the user's password. If you request
+revocation of such a client, the server will respond with a 'service-unavailable'
+error, with the 'password-reset-required' application error:
+
+```xml
+<iq id="p" type="error">
+  <error type="cancel">
+    <service-unavailable xmlns="xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'">
+    <password-reset-required xmlns="xmpp:prosody.im/protocol/manage-clients"/>
+  </error>
+</iq>
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_client_management/mod_client_management.lua	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,459 @@
+local modulemanager = require "core.modulemanager";
+local usermanager = require "core.usermanager";
+
+local array = require "util.array";
+local dt = require "util.datetime";
+local id = require "util.id";
+local it = require "util.iterators";
+local jid = require "util.jid";
+local st = require "util.stanza";
+
+local strict = module:get_option_boolean("enforce_client_ids", false);
+
+module:default_permission("prosody:user", ":list-clients");
+module:default_permission("prosody:user", ":manage-clients");
+
+local tokenauth = module:depends("tokenauth");
+local mod_fast = module:depends("sasl2_fast");
+
+local client_store = assert(module:open_store("clients", "keyval+"));
+--[[{
+	id = id;
+	first_seen =
+	last_seen =
+	user_agent = {
+		name =
+		os =
+	}
+--}]]
+
+local xmlns_sasl2 = "urn:xmpp:sasl:2";
+
+local function get_user_agent(sasl_handler, token_info)
+	local sasl_agent = sasl_handler and sasl_handler.user_agent;
+	local token_agent = token_info and token_info.data and token_info.data.oauth2_client;
+	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;
+		uri = token_agent and token_agent.uri or nil;
+		device = sasl_agent and sasl_agent.device or nil;
+	};
+end
+
+module:hook("sasl2/c2s/success", function (event)
+	local session = event.session;
+	local username, client_id = session.username, session.client_id;
+	local mechanism = session.sasl_handler.selected;
+	local token_info = session.sasl_handler.token_info;
+	local token_id = token_info and token_info.id or nil;
+
+	local now = os.time();
+	if client_id then -- SASL2, have client identifier
+		local is_new_client;
+
+		local client_state = client_store:get_key(username, client_id);
+		if not client_state then
+			is_new_client = true;
+			client_state = {
+				id = client_id;
+				first_seen = now;
+				user_agent = get_user_agent(session.sasl_handler, token_info);
+				full_jid = nil;
+				last_seen = nil;
+				mechanisms = {};
+			};
+		end
+		-- Update state
+		client_state.full_jid = session.full_jid;
+		client_state.last_seen = now;
+		client_state.mechanisms[mechanism] = now;
+		if session.sasl_handler.fast_auth then
+			client_state.fast_auth = now;
+		end
+		if token_id then
+			client_state.auth_token_id = token_id;
+		end
+		-- Store updated state
+		client_store:set_key(username, client_id, client_state);
+
+		if is_new_client then
+			module:fire_event("client_management/new-client", { client = client_state });
+		end
+	end
+end);
+
+local function find_client_by_resource(username, resource)
+	local full_jid = jid.join(username, module.host, resource);
+	local clients = client_store:get(username);
+	if not clients then return; end
+
+	for _, client_state in pairs(clients) do
+		if client_state.full_jid == full_jid then
+			return client_state;
+		end
+	end
+end
+
+module:hook("resource-bind", function (event)
+	local session = event.session;
+	if session.client_id then return; end
+	local is_new_client;
+	local client_state = find_client_by_resource(event.session.username, event.session.resource);
+	local now = os.time();
+	if not client_state then
+		is_new_client = true;
+		client_state = {
+			id = id.short();
+			first_seen = now;
+			user_agent = nil;
+			full_jid = nil;
+			last_seen = nil;
+			mechanisms = {};
+			legacy = true;
+		};
+	end
+
+	-- Update state
+	local legacy_info = session.client_management_info;
+	client_state.full_jid = session.full_jid;
+	client_state.last_seen = now;
+	client_state.mechanisms[legacy_info.mechanism] = now;
+	if legacy_info.fast_auth then
+		client_state.fast_auth = now;
+	end
+
+	local token_id = legacy_info.token_info and legacy_info.token_info.id;
+	if token_id then
+		client_state.auth_token_id = token_id;
+	end
+
+	-- Store updated state
+	client_store:set_key(session.username, client_state.id, client_state);
+
+	if is_new_client then
+		module:fire_event("client_management/new-client", { client = client_state });
+	end
+end);
+
+if strict then
+	module:hook_tag(xmlns_sasl2, "authenticate", function (session, auth)
+		local user_agent = auth:get_child("user-agent");
+		if not user_agent or not user_agent.attr.id then
+			local failure = st.stanza("failure", { xmlns = xmlns_sasl2 })
+				:tag("malformed-request", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl" }):up()
+				:text_tag("text", "Client identifier required but not supplied");
+			session.send(failure);
+			return true;
+		end
+	end, 500);
+
+	if modulemanager.get_modules_for_host(module.host):contains("saslauth") then
+		module:log("error", "mod_saslauth is enabled, but enforce_client_ids is enabled and will prevent it from working");
+	end
+
+	module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:auth", function (event)
+		-- Block legacy SASL, if for some reason it is being used (either mod_saslauth is loaded,
+		-- or clients try it without advertisement)
+		module:log("warn", "Blocking legacy SASL authentication because enforce_client_ids is enabled");
+		local failure = st.stanza("failure", { xmlns = xmlns_sasl2 })
+			:tag("malformed-request", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl" }):up()
+			:text_tag("text", "Legacy SASL authentication is not available on this server");
+		event.session.send(failure);
+		return true;
+	end);
+else
+	-- Legacy client compat code
+	module:hook("authentication-success", function (event)
+		local session = event.session;
+		if session.client_id then return; end -- SASL2 client
+
+		local sasl_handler = session.sasl_handler;
+		session.client_management_info = {
+			mechanism = sasl_handler.selected;
+			token_info = sasl_handler.token_info;
+			fast_auth = sasl_handler.fast_auth;
+		};
+	end);
+end
+
+local function is_password_mechanism(mech_name)
+	if mech_name == "OAUTHBEARER" then return false; end
+	if mech_name:match("^HT%-") then return false; end
+	return true;
+end
+
+local function is_client_active(client)
+	local username, host = jid.split(client.full_jid);
+	local account_info = usermanager.get_account_info(username, host);
+	local last_password_change = account_info and account_info.password_updated;
+
+	local status = {};
+
+	-- Check for an active token grant that has been previously used by this client
+	if client.auth_token_id then
+		local grant = tokenauth.get_grant_info(client.auth_token_id);
+		if grant then
+			status.grant = grant;
+		end
+	end
+
+	-- Check for active FAST tokens
+	if client.fast_auth then
+		if mod_fast.is_client_fast(username, client.id, last_password_change) then
+			status.fast = client.fast_auth;
+		end
+	end
+
+	-- Client has access if any password-based SASL mechanisms have been used since last password change
+	for mech, mech_last_used in pairs(client.mechanisms) do
+		if is_password_mechanism(mech) and (not last_password_change or mech_last_used >= last_password_change) then
+			status.password = mech_last_used;
+		end
+	end
+
+	if prosody.full_sessions[client.full_jid] then
+		status.connected = true;
+	end
+
+	if next(status) == nil then
+		return nil;
+	end
+	return status;
+end
+
+-- Public API
+--luacheck: ignore 131
+function get_active_clients(username)
+	local clients = client_store:get(username);
+	local active_clients = {};
+	local used_grants = {};
+
+	-- Go through known clients, check whether they could possibly log in
+	for client_id, client in pairs(clients or {}) do --luacheck: ignore 213/client_id
+		local active = is_client_active(client);
+		if active then
+			client.type = "session";
+			client.id = "client/"..client.id;
+			client.active = active;
+			table.insert(active_clients, client);
+			if active.grant then
+				used_grants[active.grant.id] = true;
+			end
+		end
+	end
+
+	-- Next, account for any grants that have been issued, but never actually logged in
+	for grant_id, grant in pairs(tokenauth.get_user_grants(username) or {}) do
+		if not used_grants[grant_id] then -- exclude grants already accounted for
+			table.insert(active_clients, {
+				id = "grant/"..grant_id;
+				type = "access";
+				first_seen = grant.created;
+				last_seen = grant.accessed;
+				active = {
+					grant = grant;
+				};
+				user_agent = get_user_agent(nil, grant);
+			});
+		end
+	end
+
+	table.sort(active_clients, function (a, b)
+		if a.last_seen and b.last_seen then
+			return a.last_seen < b.last_seen;
+		elseif not (a.last_seen or b.last_seen) then
+			if a.first_seen and b.first_seen then
+				return a.first_seen < b.first_seen;
+			end
+		elseif b.last_seen then
+			return true;
+		elseif a.last_seen then
+			return false;
+		end
+		return a.id < b.id;
+	end);
+
+	return active_clients;
+end
+
+function revoke_client_access(username, client_selector)
+	if client_selector then
+		local c_type, c_id = client_selector:match("^(%w+)/(.+)$");
+		if c_type == "client" then
+			local client = client_store:get_key(username, c_id);
+			if not client then
+				return nil, "item-not-found";
+			end
+			local status = is_client_active(client);
+			if status.connected then
+				local ok, err = prosody.full_sessions[client.full_jid]:close();
+				if not ok then return ok, err; end
+			end
+			if status.fast then
+				local ok = mod_fast.revoke_fast_tokens(username, client.id);
+				if not ok then return nil, "internal-server-error"; end
+			end
+			if status.grant then
+				local ok = tokenauth.revoke_grant(username, status.grant.id);
+				if not ok then return nil, "internal-server-error"; end
+			end
+			if status.password then
+				return nil, "password-reset-required";
+			end
+			return true;
+		elseif c_type == "grant" then
+			local grant = tokenauth.get_grant_info(username, c_id);
+			if not grant then
+				return nil, "item-not-found";
+			end
+			local ok = tokenauth.revoke_grant(username, c_id);
+			if not ok then return nil, "internal-server-error"; end
+			return true;
+		end
+	end
+
+	return nil, "item-not-found";
+end
+
+-- Protocol
+
+local xmlns_manage_clients = "xmpp:prosody.im/protocol/manage-clients";
+
+module:hook("iq-get/self/xmpp:prosody.im/protocol/manage-clients:list", function (event)
+	local origin, stanza = event.origin, event.stanza;
+
+	if not module:may(":list-clients", event) then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+		return true;
+	end
+
+	local reply = st.reply(stanza)
+		:tag("clients", { xmlns = xmlns_manage_clients });
+
+	local active_clients = get_active_clients(event.origin.username);
+	for _, client in ipairs(active_clients) do
+		local auth_type = st.stanza("auth");
+		if client.active then
+			if client.active.password then
+				auth_type:text_tag("password");
+			end
+			if client.active.grant then
+				auth_type:text_tag("bearer-token");
+			end
+			if client.active.fast then
+				auth_type:text_tag("fast");
+			end
+		end
+
+		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);
+			end
+			if client.user_agent.device then
+				user_agent:text_tag("device", client.user_agent.device);
+			end
+			if client.user_agent.uri then
+				user_agent:text_tag("uri", client.user_agent.uri);
+			end
+		end
+
+		local connected = client.active and client.active.connected;
+		reply:tag("client", { id = client.id, connected = connected and "true" or "false", type = client.type })
+			:text_tag("first-seen", dt.datetime(client.first_seen))
+			:text_tag("last-seen", dt.datetime(client.last_seen))
+			:add_child(auth_type)
+			:add_child(user_agent)
+			:up();
+	end
+	reply:up();
+
+	origin.send(reply);
+	return true;
+end);
+
+local revocation_errors = require "util.error".init(module.name, xmlns_manage_clients, {
+	["item-not-found"] = { "cancel", "item-not-found", "Client not found" };
+	["internal-server-error"] = { "wait", "internal-server-error", "Unable to revoke client access" };
+	["password-reset-required"] = { "cancel", "service-unavailable", "Password reset required", "password-reset-required" };
+});
+
+module:hook("iq-set/self/xmpp:prosody.im/protocol/manage-clients:revoke", function (event)
+	local origin, stanza = event.origin, event.stanza;
+
+	if not module:may(":manage-clients", event) then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+		return true;
+	end
+
+	local client_id = stanza.tags[1].attr.id;
+
+	local ok, err = revocation_errors.coerce(revoke_client_access(origin.username, client_id));
+	if not ok then
+		origin.send(st.error_reply(stanza, err));
+		return true;
+	end
+
+	origin.send(st.reply(stanza));
+	return true;
+end);
+
+
+-- Command
+
+module:once(function ()
+	local console_env = module:shared("/*/admin_shell/env");
+	if not console_env.user then return; end -- admin_shell probably not loaded
+
+	function console_env.user:clients(user_jid)
+		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 clients = mod.get_active_clients(username);
+		if not clients or #clients == 0 then
+			return true, "No clients associated with this account";
+		end
+
+		local colspec = {
+			{
+				title = "Software";
+				key = "user_agent";
+				width = "1p";
+				mapper = function(user_agent)
+					return user_agent and user_agent.software;
+				end;
+			};
+			{
+				title = "Last seen";
+				key = "last_seen";
+				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);
+				end;
+			};
+			{
+				title = "Authentication";
+				key = "active";
+				width = "2p";
+				mapper = function(active)
+					return array.collect(it.keys(active)):sort():concat(", ");
+				end;
+			};
+		};
+
+		local row = require "util.human.io".table(colspec, self.session.width);
+
+		local print = self.session.print;
+		print(row());
+		print(string.rep("-", self.session.width));
+		for _, client in ipairs(clients) do
+			print(row(client));
+		end
+		print(string.rep("-", self.session.width));
+		return true, ("%d clients"):format(#clients);
+	end
+end);
--- a/mod_cloud_notify/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_cloud_notify/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -93,6 +93,9 @@
 Compatibility
 =============
 
+**Note:** This module should be used with Lua 5.2 and higher. Using it with
+Lua 5.1 may cause push notifications to not be sent to some clients.
+
 ------ -----------------------------------------------------------------------------
   trunk  Works
   0.12   Works
--- a/mod_cloud_notify/mod_cloud_notify.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_cloud_notify/mod_cloud_notify.lua	Sat May 06 19:40:23 2023 -0500
@@ -28,6 +28,10 @@
 local id2node = {};
 local id2identifier = {};
 
+if _VERSION:match("5%.1") then
+	module:log("warn", "This module may behave incorrectly on Lua 5.1. It is recommended to upgrade to a newer Lua version.");
+end
+
 -- For keeping state across reloads while caching reads
 -- This uses util.cache for caching the most recent devices and removing all old devices when max_push_devices is reached
 local push_store = (function()
--- a/mod_cloud_notify_filters/mod_cloud_notify_filters.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_cloud_notify_filters/mod_cloud_notify_filters.lua	Sat May 06 19:40:23 2023 -0500
@@ -28,7 +28,12 @@
 	if filter_muted then
 		local muted_jids = {};
 		for item in filter_muted:childtags("item") do
-			muted_jids[jid.prep(item.attr.jid)] = true;
+			local room_jid = jid.prep(item.attr.jid);
+			if not room_jid then
+				module:log("warn", "Skipping invalid JID: <%s>", room_jid);
+			else
+				muted_jids[room_jid] = true;
+			end
 		end
 		event.push_info.muted_jids = muted_jids;
 	end
@@ -37,10 +42,15 @@
 	if filter_groupchat then
 		local groupchat_rules = {};
 		for item in filter_groupchat:childtags("room") do
-			groupchat_rules[jid.prep(item.attr.jid)] = {
-				when = item.attr.allow;
-				nick = item.attr.nick;
-			};
+			local room_jid = jid.prep(item.attr.jid);
+			if not room_jid then
+				module:log("warn", "Skipping invalid JID: <%s>", item.attr.jid);
+			else
+				groupchat_rules[room_jid] = {
+					when = item.attr.allow;
+					nick = item.attr.nick;
+				};
+			end
 		end
 		event.push_info.groupchat_rules = groupchat_rules;
 	end
--- a/mod_conversejs/mod_conversejs.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_conversejs/mod_conversejs.lua	Sat May 06 19:40:23 2023 -0500
@@ -79,9 +79,9 @@
 	end
 end
 
-local user_options = module:get_option("conversejs_options");
+local function get_converse_options()
+	local user_options = module:get_option("conversejs_options");
 
-local function get_converse_options()
 	local allow_registration = module:get_option_boolean("allow_registration", false);
 	local converse_options = {
 		-- Auto-detected connection endpoints
--- a/mod_firewall/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_firewall/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -605,9 +605,9 @@
 
 ### Route modification
 
-The most common actions modify the stanza's route in some way. Currently
-the first matching rule to do so will halt further processing of actions
-and rules (this may change in the future).
+The following common actions modify the stanza's route in some way. These
+rules will halt further processing of the stanza - no further actions will be
+executed, and no further rules will be checked.
 
   Action                  Description
   ----------------------- ---------------------------------------------------------------------------------------------------------------------------------------------------------
@@ -615,15 +615,41 @@
   `DROP.`                 Stop executing actions and rules on this stanza, and discard it.
   `DEFAULT.`              Stop executing actions and rules on this stanza, prevent any other scripts/modules from handling it, to trigger the appropriate default "unhandled stanza" behaviour. Do not use in custom chains (it is treated as PASS).
   `REDIRECT=jid`          Redirect the stanza to the given JID.
-  `REPLY=text`            Reply to the stanza (assumed to be a message) with the given text.
   `BOUNCE.`               Bounce the stanza with the default error (usually service-unavailable)
   `BOUNCE=error`          Bounce the stanza with the given error (MUST be a defined XMPP stanza error, see [RFC6120](http://xmpp.org/rfcs/rfc6120.html#stanzas-error-conditions).
   `BOUNCE=error (text)`   As above, but include the supplied human-readable text with a description of the error
-  `COPY=jid`              Make a copy of the stanza and send the copy to the specified JID. The copied stanza flows through Prosody's routing code, and as such is affected by firewall rules. Be careful to avoid loops.
-  `FORWARD=jid`           Forward a copy of the stanza to the given JID (using XEP-0297). The stanza will be sent from the current host's JID.
 
 **Note:** It is incorrect behaviour to reply to an 'error' stanza with another error, so BOUNCE will simply act the same as 'DROP' for stanzas that should not be bounced (error stanzas and iq results).
 
+### Replying and forwarding
+
+These actions cause a new stanza to be generated and sent somewhere.
+Processing of the original stanza will continue beyond these actions.
+
+  Action                   Description
+  ------------------------  ---------------------------------------------------------------------------------------------------------------------------------------------------------
+  `REPLY=text`             Reply to the stanza (assumed to be a message) with the given text.
+  `COPY=jid`               Make a copy of the stanza and send the copy to the specified JID. The copied stanza flows through Prosody's routing code, and as such is affected by firewall rules. Be careful to avoid loops.
+  `FORWARD=jid`            Forward a copy of the stanza to the given JID (using XEP-0297). The stanza will be sent from the current host's JID.
+
+### Reporting
+
+  Action                    Description
+  ------------------------  ---------------------------------------------------------------------------------------------------------------------------------------------------------
+  `REPORT=jid reason text`  Forwards the full stanza to `jid` with a XEP-0377 abuse report attached.
+
+Only the `jid` is mandatory. The `reason` parameter should be either `abuse`, `spam` or a custom URI. If not specified, it defaults to `abuse`.
+After the reason, some human-readable text may be included to explain the report.
+
+Example:
+
+```
+KIND: message
+TO: honeypot@example.com
+REPORT TO=antispam.example.com spam Caught by the honeypot!
+DROP.
+```
+
 ### Stanza modification
 
 These actions make it possible to modify the content and structure of a
--- a/mod_firewall/actions.lib.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_firewall/actions.lib.lua	Sat May 06 19:40:23 2023 -0500
@@ -241,4 +241,22 @@
 	       { "rostermanager", "core_post_stanza", "st", "split_to", "bare_to", "bare_from" };
 end
 
+function action_handlers.REPORT_TO(spec)
+	local where, reason, text = spec:match("^%s*(%S+) *(%S*) *(.*)$");
+	if reason == "spam" then
+		reason = "urn:xmpp:reporting:spam";
+	elseif reason == "abuse" or not reason then
+		reason = "urn:xmpp:reporting:abuse";
+	end
+	local code = [[
+		local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
+		local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza):up();
+		newstanza:tag("report", { xmlns = "urn:xmpp:reporting:1", reason = %q })
+		do local text = %q; if text ~= "" then newstanza:text_tag("text", text); end end
+		newstanza:up();
+		core_post_stanza(session, newstanza);
+	]];
+	return code:format(where, reason, text), { "core_post_stanza", "current_host", "st" };
+end
+
 return action_handlers;
--- a/mod_firewall/definitions.lib.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_firewall/definitions.lib.lua	Sat May 06 19:40:23 2023 -0500
@@ -197,7 +197,11 @@
 	-- TODO Invent some custom schema for this? Needed for just a set of strings?
 	pubsubitemid = {
 		init = function(self, pubsub_spec, opts)
-			local service_addr, node = pubsub_spec:match("^([^/]*)/(.*)");
+			local service_addr, node = pubsub_spec:match("^pubsubitemid:([^/]*)/(.*)");
+			if not service_addr then
+				module:log("warn", "Invalid list specification (expected 'pubsubitemid:<service>/<node>', got: '%s')", pubsub_spec);
+				return;
+			end
 			module:depends("pubsub_subscription");
 			module:add_item("pubsub-subscription", {
 					service = service_addr;
--- a/mod_firewall/mod_firewall.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_firewall/mod_firewall.lua	Sat May 06 19:40:23 2023 -0500
@@ -558,8 +558,12 @@
 	local function fire_event(name, data)
 		return module:fire_event(name, data);
 	end
+	local init_ok, initialized_chunk = pcall(chunk);
+	if not init_ok then
+		return nil, "Error initializing compiled rules: "..initialized_chunk;
+	end
 	return function (pass_return)
-		return chunk()(active_definitions, fire_event, logger(filename), module, pass_return); -- Returns event handler with upvalues
+		return initialized_chunk(active_definitions, fire_event, logger(filename), module, pass_return); -- Returns event handler with upvalues
 	end
 end
 
--- a/mod_http_admin_api/mod_http_admin_api.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_http_admin_api/mod_http_admin_api.lua	Sat May 06 19:40:23 2023 -0500
@@ -1,5 +1,6 @@
 local usermanager = require "core.usermanager";
 
+local jid = require "util.jid";
 local it = require "util.iterators";
 local json = require "util.json";
 local st = require "util.stanza";
@@ -48,6 +49,9 @@
 			event.response.headers.authorization = www_authenticate_header;
 			return false, 401;
 		end
+		-- FIXME this should probably live in mod_tokenauth or similar
+		session.type = "c2s";
+		session.full_jid = jid.join(session.username, session.host, session.resource);
 		event.session = session;
 		if not module:may(":access-admin-api", event) then
 			return false, 403;
--- a/mod_http_admin_api/openapi.yaml	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_http_admin_api/openapi.yaml	Sat May 06 19:40:23 2023 -0500
@@ -482,6 +482,21 @@
           type: string
           description: Display name of the user
           nullable: true
+        role:
+          type: string
+          description: Primary role of the user
+          nullable: true
+        secondary_roles:
+          type: array
+          description: List of additional roles assigned to the user
+          items:
+            type: string
+        roles:
+          type: array
+          description: List of roles assigned to the user (Legacy)
+          deprecated: true
+          items:
+            type: string
         email:
           type: string
           description: Optional email address for the user (NYI)
@@ -780,10 +795,10 @@
           - since
           properties:
             since:
-              type: float
+              type: number
               description: The metric epoch as UNIX timestamp
             value:
-              type: float
+              type: number
               description: Seconds of CPU time used since the metric epoch
         c2s:
           type: integer
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_debug/mod_http_debug.lua	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,26 @@
+local json = require "util.json"
+
+module:depends("http")
+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;
+		}
+	})
--- a/mod_http_oauth2/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_http_oauth2/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -2,19 +2,176 @@
 labels:
 - Stage-Alpha
 summary: 'OAuth2 API'
+rockspec:
+  build:
+    copy_directories:
+    - html
 ...
 
-Introduction
-============
+## Introduction
+
+This module implements an [OAuth2](https://oauth.net/2/)/[OpenID Connect
+(OIDC)](https://openid.net/connect/) provider HTTP frontend 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].
+
+Although this module has been around for some time, it has recently been
+significantly extended and largely rewritten to support OAuth/OIDC more fully.
+
+As of April 2023, it should be considered **alpha** stage. It works, we have
+tested it, but it has not yet seen wider review, testing and deployment. At
+this stage we recommend it for experimental and test deployments only. For
+specific information, see the [deployment notes section](#deployment-notes)
+below.
+
+Known client implementations:
+
+-   [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).
+
+## Standards support
+
+Notable supported standards:
 
-This module is a work-in-progress intended for developers only!
+- [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 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)
+- [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)
+
+## Configuration
+
+### Interface
+
+The module presents a web page to users to allow them to authenticate when
+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
+a number of other modules:
+
+```lua
+site_name = "My XMPP Server"
+```
+
+To provide custom templates, specify the path to the template directory:
+
+```lua
+oauth2_template_path = "/etc/prosody/custom-oauth2-templates"
+```
+
+Some templates support additional variables, that can be provided by the
+'oauth2_template_style' option:
+
+```lua
+oauth2_template_style = {
+  background_colour = "#ffffff";
+}
+```
+
+### Token parameters
+
+The following options configure the lifetime of tokens issued by the module.
+The defaults are recommended.
+
+```lua
+oauth2_access_token_ttl = 86400 -- 24 hours
+oauth2_refresh_token_ttl = nil -- unlimited unless revoked by the user
+```
 
-Configuration
-=============
+### Dynamic client registration
+
+To allow users to connect any compatible software, you should enable dynamic
+client registration.
+
+Dynamic client registration can be enabled by configuring a JWT key. Algorithm
+defaults to *HS256* lifetime defaults to forever.
+
+```lua
+oauth2_registration_key = "securely generated JWT key here"
+oauth2_registration_algorithm = "HS256"
+oauth2_registration_ttl = nil -- unlimited by default
+```
+
+### Supported flows
+
+Various flows can be disabled and enabled with
+`allowed_oauth2_grant_types` and `allowed_oauth2_response_types`:
 
-None currently.
+```lua
+allowed_oauth2_grant_types = {
+	"authorization_code"; -- authorization code grant
+	"password"; -- resource owner password grant
+}
+
+allowed_oauth2_response_types = {
+	"code"; -- authorization code flow
+    -- "token"; -- implicit flow disabled by default
+}
+```
+
+The [Proof Key for Code Exchange][RFC 7636] mitigation method can be
+made required:
+
+```lua
+oauth2_require_code_challenge = true
+```
+
+Further, individual challenge methods can be enabled or disabled:
 
-Compatibility
-=============
+```lua
+allowed_oauth2_code_challenge_methods = {
+    "plain"; -- the insecure one
+    "S256";
+}
+```
+
+### Policy documents
+
+Links to Terms of Service and Service Policy documents can be advertised
+for use by OAuth clients:
+
+```lua
+oauth2_terms_url = "https://example.com/terms-of-service.html"
+oauth2_policy_url = "https://example.com/service-policy.pdf"
+```
+
+## Deployment notes
+
+### Access management
 
-Requires Prosody 0.12+ or trunk.
+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,
+no XMPP clients currently support the protocol used by that module. We plan to
+work on additional interfaces in the future.
+
+### Scopes
+
+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.
+
+## Compatibility
+
+Requires Prosody trunk (April 2023), **not** compatible with Prosody 0.12 or
+earlier.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/consent.html	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html>
+<head>
+<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">
+</head>
+<body>
+	<main>
+	{state.error&<div class="error">
+		<p>{state.error}</p>
+	</div>}
+
+	<h1>{site_name}</h1>
+	<fieldset>
+	<legend>Authorize new application</legend>
+	<p>A new application wants to connect to your account.</p>
+	<dl>
+		<dt>Name</dt>
+		<dd>{client.client_name}</dd>
+		<dt>Website</dt>
+		<dd><a href="{client.client_uri}">{client.client_uri}</a></dd>
+
+		{client.tos_uri&
+		<dt>Terms of Service</dt>
+		<dd><a href="{client.tos_uri}">View terms</a></dd>}
+
+		{client.policy_uri&
+		<dt>Policy</dt>
+		<dd><a href="{client.policy_uri}">View policy</a></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'.
+	</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>
+	</form>
+	</fieldset>
+	</main>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/error.html	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html>
+<head>
+<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">
+</head>
+<body>
+	<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>
+	<div class="error">
+		<p>{error.text}</p>
+	</div>
+	</main>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/login.html	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+<head>
+<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">
+</head>
+<body>
+	<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">
+	</form>
+	</fieldset>
+	</main>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/style.css	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,95 @@
+body
+{
+	margin-top:14%;
+	text-align:center;
+	background-color:#f8f8f8;
+	font-family:sans-serif
+}
+
+h1
+{
+	font-size:xx-large;
+}
+
+legend {
+	font-size:x-large;
+}
+p
+{
+	font-size:large;
+}
+
+.error
+{
+	margin: 0.75em;
+	background-color: #f8d7da;
+	color: #842029;
+	border: solid 1px #f5c2c7;
+}
+
+input {
+	margin: 0.3rem;
+	padding: 0.2rem;
+	line-height: 1.5rem;
+	font-size: 110%;
+}
+h1, h2 {
+	text-align: left;
+}
+
+main {
+	max-width: 600px;
+	padding: 0 1.5em 1.5em 1.5em;
+}
+
+dt
+{
+	font-weight: bold;
+	margin: 0.5em 0 0 0;
+}
+
+dd
+{
+	margin: 0;
+}
+
+button, input[type=submit]
+{
+	padding: 0.5rem;
+	margin: 0.75rem;
+}
+
+@media(prefers-color-scheme:dark)
+{
+	body
+	{
+		background-color:#161616;
+		color:#eee;
+	}
+
+	.error {
+		color: #f8d7da;
+		background-color: #842029;
+	}
+
+
+	:link
+	{
+		color: #6197df;
+	}
+
+	:visited
+	{
+		color: #9a61df;
+	}
+}
+
+@media(min-width: 768px)
+{
+	main
+	{
+		margin-left: auto;
+		margin-right: auto;
+	}
+
+}
--- a/mod_http_oauth2/mod_http_oauth2.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Sat May 06 19:40:23 2023 -0500
@@ -6,51 +6,175 @@
 local usermanager = require "core.usermanager";
 local errors = require "util.error";
 local url = require "socket.url";
-local uuid = require "util.uuid";
+local id = require "util.id";
 local encodings = require "util.encodings";
 local base64 = encodings.base64;
+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 function b64url(s)
+	return (base64.encode(s):gsub("[+/=]", { ["+"] = "-", ["/"] = "_", ["="] = "" }))
+end
+
+local function tmap(t)
+	return function(k)
+		return t[k];
+	end
+end
+
+local function read_file(base_path, fn, required)
+	local f, err = io.open(base_path .. "/" .. fn);
+	if not f then
+		module:log(required and "error" or "debug", "Unable to load template file: %s", err);
+		if required then
+			return error("Failed to load templates");
+		end
+		return nil;
+	end
+	local data = assert(f:read("*a"));
+	assert(f:close());
+	return data;
+end
+
+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);
+	error = read_file(template_path, "error.html", true);
+	css = read_file(template_path, "style.css");
+	js = read_file(template_path, "script.js");
+};
+
+local site_name = module:get_option_string("site_name", module.host);
+
+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;
+		headers = {
+			["Content-Type"] = "text/html; charset=utf-8";
+			["Content-Security-Policy"] = "default-src 'self'";
+			["X-Frame-Options"] = "DENY";
+			["Cache-Control"] = (sensitive and "no-store" or "no-cache")..", private";
+		};
+		body = _render_html(template, data);
+	};
+	return resp;
+end
 
 local tokens = module:depends("tokenauth");
 
-local clients = module:open_store("oauth2_clients", "map");
+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);
+
+-- Used to derive client_secret from client_id, set to enable stateless dynamic registration.
+local registration_key = module:get_option_string("oauth2_registration_key");
+local registration_algo = module:get_option_string("oauth2_registration_algorithm", "HS256");
+local registration_ttl = module:get_option("oauth2_registration_ttl", nil);
+local registration_options = module:get_option("oauth2_registration_options",
+	{ default_ttl = registration_ttl; accept_expired = not registration_ttl });
+
+local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", false);
 
-local function filter_scopes(username, host, requested_scope_string)
-	if host ~= module.host then
-		return usermanager.get_jid_role(username.."@"..host, module.host).name;
-	end
+local verification_key;
+local jwt_sign, jwt_verify;
+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);
+end
+
+local function parse_scopes(scope_string)
+	return array(scope_string:gmatch("%S+"));
+end
 
-	if requested_scope_string then -- Specific role requested
-		-- TODO: The requested scope string is technically a space-delimited list
-		-- of scopes, but for simplicity we're mapping this slot to role names.
-		if usermanager.user_can_assume_role(username, module.host, requested_scope_string) then
-			return requested_scope_string;
+local openid_claims = set.new({ "openid", "profile"; "email"; "address"; "phone" });
+
+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
+			roles:push(scope);
+		else
+			unknown:push(scope);
 		end
 	end
+	return claims, roles, unknown;
+end
 
+local function can_assume_role(username, requested_role)
+	return usermanager.user_can_assume_role(username, module.host, requested_role);
+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;
 end
 
-local function code_expires_in(code)
-	return os.difftime(os.time(), code.issued);
+local function filter_scopes(username, requested_scope_string)
+	local granted_scopes, requested_roles;
+
+	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 selected_role = select_role(username, requested_roles);
+	granted_scopes:push(selected_role);
+
+	return granted_scopes:concat(" "), selected_role;
 end
 
-local function code_expired(code)
-	return code_expires_in(code) > 120;
+local function code_expires_in(code) --> number, seconds until code expires
+	return os.difftime(code.expires, os.time());
+end
+
+local function code_expired(code) --> boolean, true: has expired, false: still valid
+	return code_expires_in(code) < 0;
 end
 
 local codes = cache.new(10000, function (_, code)
 	return code_expired(code)
 end);
 
-module:add_timer(900, function()
+-- 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()
 	local k, code = codes:tail();
 	while code and code_expired(code) do
 		codes:set(k, nil);
 		k, code = codes:tail();
 	end
-	return code and code_expires_in(code) + 1 or 900;
 end)
 
+local function get_issuer()
+	return (module:http_url(nil, "/"):gsub("/$", ""));
+end
+
+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({
 		type = "modify";
@@ -61,19 +185,69 @@
 	});
 end
 
-local function new_access_token(token_jid, scope, ttl)
-	local token = tokens.create_jid_token(token_jid, token_jid, scope, ttl);
+-- client_id / client_metadata are pretty large, filter out a subset of
+-- 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 };
+end
+
+local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info)
+	local token_data = { oauth2_scopes = scope_string, oauth2_client = nil };
+	if client then
+		token_data.oauth2_client = client_subset(client);
+	end
+	if next(token_data) == nil then
+		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
+	end
+
+	local access_token, access_token_info = tokens.create_token(token_jid, grant, role, default_access_ttl, "oauth2");
+
+	local expires_at = access_token_info.expires;
 	return {
 		token_type = "bearer";
-		access_token = token;
-		expires_in = ttl;
-		scope = scope;
-		-- TODO: include refresh_token when implemented
+		access_token = access_token;
+		expires_in = expires_at and (expires_at - os.time()) or nil;
+		scope = scope_string;
+		id_token = id_token;
+		refresh_token = refresh_token or nil;
 	};
 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
+			-- Client registered multiple URIs, it needs specify which one to use
+			return;
+		end
+		-- When only a single URI is registered, that's the default
+		return client.redirect_uris[1];
+	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
+end
+
 local grant_type_handlers = {};
 local response_type_handlers = {};
+local verifier_transforms = {};
 
 function grant_type_handlers.password(params)
 	local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)"));
@@ -88,57 +262,99 @@
 	end
 
 	local granted_jid = jid.join(request_username, request_host, request_resource);
-	local granted_scopes = filter_scopes(request_username, request_host, params.scope);
-	return json.encode(new_access_token(granted_jid, granted_scopes, nil));
+	local granted_scopes, granted_role = filter_scopes(request_username, params.scope);
+	return json.encode(new_access_token(granted_jid, granted_role, granted_scopes, nil));
 end
 
-function response_type_handlers.code(params, granted_jid)
-	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
-	if not params.redirect_uri then return oauth_error("invalid_request", "missing 'redirect_uri'"); end
+function response_type_handlers.code(client, params, granted_jid, id_token)
+	local request_username, request_host = jid.split(granted_jid);
+	if not request_host or request_host ~= module.host then
+		return oauth_error("invalid_request", "invalid JID");
+	end
+	local granted_scopes, granted_role = filter_scopes(request_username, params.scope);
 
-	local client_owner, client_host, client_id = jid.prepped_split(params.client_id);
-	if client_host ~= module.host then
-		return oauth_error("invalid_client", "incorrect credentials");
-	end
-	local client, err = clients:get(client_owner, client_id);
-	if err then error(err); end
-	if not client then
-		return oauth_error("invalid_client", "incorrect credentials");
+	if pkce_required and not params.code_challenge then
+		return oauth_error("invalid_request", "PKCE required");
 	end
 
-	local granted_scopes = filter_scopes(client_owner, client_host, params.scope);
-
-	local code = uuid.generate();
+	local code = id.medium();
 	local ok = codes:set(params.client_id .. "#" .. code, {
-		issued = os.time();
+		expires = os.time() + 600;
 		granted_jid = granted_jid;
 		granted_scopes = granted_scopes;
+		granted_role = granted_role;
+		challenge = params.code_challenge;
+		challenge_method = params.code_challenge_method;
+		id_token = id_token;
 	});
 	if not ok then
 		return {status_code = 429};
 	end
 
-	local redirect = url.parse(params.redirect_uri);
+	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;
+	elseif not redirect_uri then
+		return 400;
+	end
+
+	local redirect = url.parse(redirect_uri);
+
 	local query = http.formdecode(redirect.query or "");
 	if type(query) ~= "table" then query = {}; end
-	table.insert(query, { name = "code", value = code })
+	table.insert(query, { name = "code", value = code });
+	table.insert(query, { name = "iss", value = get_issuer() });
 	if params.state then
 		table.insert(query, { name = "state", value = params.state });
 	end
 	redirect.query = http.formencode(query);
 
 	return {
-		status_code = 302;
+		status_code = 303;
 		headers = {
 			location = url.build(redirect);
 		};
 	}
 end
 
-local pepper = module:get_option_string("oauth2_client_pepper", "");
+-- Implicit flow
+function response_type_handlers.token(client, params, granted_jid)
+	local request_username, request_host = jid.split(granted_jid);
+	if not request_host or request_host ~= module.host then
+		return oauth_error("invalid_request", "invalid JID");
+	end
+	local granted_scopes, granted_role = filter_scopes(request_username, params.scope);
+	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
+	token_info.state = params.state;
+	redirect.fragment = http.formencode(token_info);
 
-local function verify_secret(stored, salt, i, secret)
-	return base64.decode(stored) == hashes.pbkdf2_hmac_sha256(secret, salt .. pepper, i);
+	return {
+		status_code = 303;
+		headers = {
+			location = url.build(redirect);
+		};
+	}
+end
+
+local function make_client_secret(client_id) --> client_secret
+	return hashes.hmac_sha256(verification_key, client_id, true);
+end
+
+local function verify_client_secret(client_id, client_secret)
+	return hashes.equals(make_client_secret(client_id), client_secret);
 end
 
 function grant_type_handlers.authorization_code(params)
@@ -149,49 +365,157 @@
 		return oauth_error("invalid_scope", "unknown scope requested");
 	end
 
-	local client_owner, client_host, client_id = jid.prepped_split(params.client_id);
-	if client_host ~= module.host then
-		module:log("debug", "%q ~= %q", client_host, module.host);
+	local client_ok, client = jwt_verify(params.client_id);
+	if not client_ok then
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
-	local client, err = clients:get(client_owner, client_id);
-	if err then error(err); end
-	if not client or not verify_secret(client.secret_hash, client.salt, client.iteration_count, params.client_secret) then
+
+	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, err = codes:get(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);
 	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");
 	end
-	assert(codes:set(client_owner, client_id .. "#" .. params.code, nil));
+
+	-- TODO Decide if the code should be removed or not when PKCE fails
+	local transform = verifier_transforms[code.challenge_method or "plain"];
+	if not transform then
+		return oauth_error("invalid_request", "unknown challenge transform method");
+	elseif transform(params.code_verifier) ~= code.challenge then
+		return oauth_error("invalid_grant", "incorrect credentials");
+	end
+
+	return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token));
+end
+
+function grant_type_handlers.refresh_token(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.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
+		return oauth_error("invalid_client", "incorrect credentials");
+	end
 
-	return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil));
+	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 refresh_token_info = tokens.get_token_info(params.refresh_token);
+	if not refresh_token_info or refresh_token_info.purpose ~= "oauth2-refresh" then
+		return oauth_error("invalid_grant", "invalid refresh token");
+	end
+
+	-- 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
+	));
+end
+
+-- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients
+
+function verifier_transforms.plain(code_verifier)
+	-- code_challenge = code_verifier
+	return code_verifier;
+end
+
+function verifier_transforms.S256(code_verifier)
+	-- code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
+	return code_verifier and b64url(hashes.sha256(code_verifier));
 end
 
-local function check_credentials(request, allow_token)
+-- Used to issue/verify short-lived tokens for the authorization process below
+local new_user_token, verify_user_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 });
+
+-- From the given request, figure out if the user is authenticated and has granted consent yet
+-- As this requires multiple steps (seek credentials, seek consent), we have a lot of state to
+-- carry around across requests. We also need to protect against CSRF and session mix-up attacks
+-- (e.g. the user may have multiple concurrent flows in progress, session cookies aren't unique
+--  to one of them).
+-- Our strategy here is to preserve the original query string (containing the authz request), and
+-- encode the rest of the flow in form POSTs.
+local function get_auth_state(request)
+	local form = request.method == "POST"
+	         and request.body
+	         and request.body ~= ""
+	         and request.headers.content_type == "application/x-www-form-urlencoded"
+	         and http.formdecode(request.body);
+
+	if type(form) ~= "table" then return {}; end
+
+	if not form.user_token then
+		-- First step: login
+		local username = encodings.stringprep.nodeprep(form.username);
+		local password = encodings.stringprep.saslprep(form.password);
+		if not (username and password) or not usermanager.test_password(username, module.host, password) then
+			return {
+				error = "Invalid username/password";
+			};
+		end
+		return {
+			user = {
+				username = username;
+				host = module.host;
+				token = new_user_token({ username = username, host = module.host });
+			};
+		};
+	elseif form.user_token and form.consent then
+		-- Second step: consent
+		local ok, user = verify_user_token(form.user_token);
+		if not ok then
+			return {
+				error = user == "token-expired" and "Session expired - try again" or nil;
+			};
+		end
+
+		local scope = array():append(form):filter(function(field)
+			return field.name == "scope" or field.name == "role";
+		end):pluck("value"):concat(" ");
+
+		user.token = form.user_token;
+		return {
+			user = user;
+			scope = scope;
+			consent = form.consent == "granted";
+		};
+	end
+
+	return {};
+end
+
+local function get_request_credentials(request)
+	if not request.headers.authorization then return; end
+
 	local auth_type, auth_data = string.match(request.headers.authorization, "^(%S+)%s(.+)$");
 
 	if auth_type == "Basic" then
 		local creds = base64.decode(auth_data);
-		if not creds then return false; end
+		if not creds then return; end
 		local username, password = string.match(creds, "^([^:]+):(.*)$");
-		if not username then return false; end
-		username, password = encodings.stringprep.nodeprep(username), encodings.stringprep.saslprep(password);
-		if not username then return false; end
-		if not usermanager.test_password(username, module.host, password) then
-			return false;
-		end
-		return username;
-	elseif auth_type == "Bearer" and allow_token then
-		local token_info = tokens.get_token_info(auth_data);
-		if not token_info or not token_info.session or token_info.session.host ~= module.host then
-			return false;
-		end
-		return token_info.session.username;
+		if not username then return; end
+		return {
+			type = "basic";
+			username = username;
+			password = password;
+		};
+	elseif auth_type == "Bearer" then
+		return {
+			type = "bearer";
+			bearer_token = auth_data;
+		};
 	end
+
 	return nil;
 end
 
@@ -210,7 +534,7 @@
 		end
 		if request_password == component_secret then
 			local granted_jid = jid.join(request_username, request_host, request_resource);
-			return json.encode(new_access_token(granted_jid, nil, nil));
+			return json.encode(new_access_token(granted_jid, nil, nil, nil));
 		end
 		return oauth_error("invalid_grant", "incorrect credentials");
 	end
@@ -218,69 +542,178 @@
 	-- TODO How would this make sense with components?
 	-- Have an admin authenticate maybe?
 	response_type_handlers.code = nil;
+	response_type_handlers.token = nil;
 	grant_type_handlers.authorization_code = nil;
-	check_credentials = function () return false end
+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 });
+	end
+	local redirect_query = url.parse(redirect_uri);
+	local sep = redirect_query.query and "&" or "?";
+	redirect_uri = redirect_uri
+		.. sep .. http.formencode(err.extra.oauth2_response)
+		.. "&" .. http.formencode({ state = q.state, iss = get_issuer() });
+	module:log("warn", "Sending error response to client via redirect to %s", redirect_uri);
+	return {
+		status_code = 303;
+		headers = {
+			location = redirect_uri;
+		};
+	};
+end
+
+local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password", "refresh_token"})
+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);
+		grant_type_handlers[handler_type] = nil;
+	else
+		module:log("debug", "Grant type %q enabled", handler_type);
+	end
+end
+
+-- "token" aka implicit flow is considered insecure
+local allowed_response_type_handlers = module:get_option_set("allowed_oauth2_response_types", {"code"})
+for handler_type in pairs(response_type_handlers) do
+	if not allowed_response_type_handlers:contains(handler_type) then
+		module:log("debug", "Response type %q disabled", handler_type);
+		response_type_handlers[handler_type] = nil;
+	else
+		module:log("debug", "Response type %q enabled", handler_type);
+	end
+end
+
+local allowed_challenge_methods = module:get_option_set("allowed_oauth2_code_challenge_methods", { "plain"; "S256" })
+for handler_type in pairs(verifier_transforms) do
+	if not allowed_challenge_methods:contains(handler_type) then
+		module:log("debug", "Challenge method %q disabled", handler_type);
+		verifier_transforms[handler_type] = nil;
+	else
+		module:log("debug", "Challenge method %q enabled", handler_type);
+	end
 end
 
 function handle_token_grant(event)
+	local credentials = get_request_credentials(event.request);
+
 	event.response.headers.content_type = "application/json";
 	local params = http.formdecode(event.request.body);
 	if not params then
-		return oauth_error("invalid_request");
+		return error_response(event.request, oauth_error("invalid_request"));
 	end
+
+	if credentials and credentials.type == "basic" then
+		-- client_secret_basic converted internally to client_secret_post
+		params.client_id = http.urldecode(credentials.username);
+		params.client_secret = http.urldecode(credentials.password);
+	end
+
 	local grant_type = params.grant_type
 	local grant_handler = grant_type_handlers[grant_type];
 	if not grant_handler then
-		return oauth_error("unsupported_grant_type");
+		return error_response(event.request, oauth_error("unsupported_grant_type"));
 	end
 	return grant_handler(params);
 end
 
 local function handle_authorization_request(event)
-	local request, response = event.request, event.response;
-	if not request.headers.authorization then
-		response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name);
-		return 401;
-	end
-	local user = check_credentials(request);
-	if not user then
-		return 401;
-	end
-	-- TODO ask user for consent here
+	local request = event.request;
+
 	if not request.url.query then
-		response.headers.content_type = "application/json";
-		return oauth_error("invalid_request");
+		return error_response(request, oauth_error("invalid_request"));
 	end
 	local params = http.formdecode(request.url.query);
 	if not params then
-		return oauth_error("invalid_request");
+		return error_response(request, oauth_error("invalid_request"));
+	end
+
+	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
+
+	local ok, client = jwt_verify(params.client_id);
+
+	if not ok then
+		return oauth_error("invalid_client", "incorrect credentials");
+	end
+
+	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");
 	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 });
+	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);
+	elseif not auth_state.consent then
+		-- Notify client of rejection
+		return error_response(request, oauth_error("access_denied"));
+	end
+	-- else auth_state.consent == true
+
+	params.scope = auth_state.scope;
+
+	local user_jid = jid.join(auth_state.user.username, module.host);
+	local client_secret = make_client_secret(params.client_id);
+	local id_token_signer = jwt.new_signer("HS256", client_secret);
+	local id_token = id_token_signer({
+		iss = get_issuer();
+		sub = url.build({ scheme = "xmpp"; path = user_jid });
+		aud = params.client_id;
+		nonce = params.nonce;
+	});
 	local response_type = params.response_type;
 	local response_handler = response_type_handlers[response_type];
 	if not response_handler then
-		response.headers.content_type = "application/json";
-		return oauth_error("unsupported_response_type");
+		return error_response(request, oauth_error("unsupported_response_type"));
 	end
-	return response_handler(params, jid.join(user, module.host));
+	return response_handler(client, params, user_jid, id_token);
 end
 
 local function handle_revocation_request(event)
 	local request, response = event.request, event.response;
-	if not request.headers.authorization then
-		response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name);
-		return 401;
-	elseif request.headers.content_type ~= "application/x-www-form-urlencoded"
-	or not request.body or request.body == "" then
-		return 400;
-	end
-	local user = check_credentials(request, true);
-	if not user then
-		return 401;
+	if request.headers.authorization then
+		local credentials = get_request_credentials(request);
+		if not credentials or credentials.type ~= "basic" then
+			response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name);
+			return 401;
+		end
+		-- OAuth "client" credentials
+		if not verify_client_secret(credentials.username, credentials.password) then
+			return 401;
+		end
 	end
 
-	local form_data = http.formdecode(event.request.body);
+	local form_data = http.formdecode(event.request.body or "");
 	if not form_data or not form_data.token then
-		return 400;
+		response.headers.accept = "application/x-www-form-urlencoded";
+		return 415;
 	end
 	local ok, err = tokens.revoke_token(form_data.token);
 	if not ok then
@@ -290,12 +723,268 @@
 	return 200;
 end
 
+local registration_schema = {
+	type = "object";
+	required = {
+		-- These are shown to users in the template
+		"client_name";
+		"client_uri";
+		-- We need at least one redirect URI for things to work
+		"redirect_uris";
+	};
+	properties = {
+		redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } };
+		token_endpoint_auth_method = {
+			type = "string";
+			enum = { "none"; "client_secret_post"; "client_secret_basic" };
+			default = "client_secret_basic";
+		};
+		grant_types = {
+			type = "array";
+			items = {
+				type = "string";
+				enum = {
+					"authorization_code";
+					"implicit";
+					"password";
+					"client_credentials";
+					"refresh_token";
+					"urn:ietf:params:oauth:grant-type:jwt-bearer";
+					"urn:ietf:params:oauth:grant-type:saml2-bearer";
+				};
+			};
+			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:" };
+	};
+}
+
+local function redirect_uri_allowed(redirect_uri, client_uri, app_type)
+	local uri = url.parse(redirect_uri);
+	if app_type == "native" then
+		return uri.scheme == "http" and loopbacks:contains(uri.host) or uri.scheme ~= "https";
+	elseif app_type == "web" then
+		return uri.scheme == "https" and uri.host == client_uri.host;
+	end
+end
+
+function create_client(client_metadata)
+	if not schema.validate(registration_schema, client_metadata) then
+		return nil, oauth_error("invalid_request", "Failed schema validation.");
+	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
+			client_metadata[propname] = propspec.default;
+		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");
+	end
+
+	for _, redirect_uri in ipairs(client_metadata.redirect_uris) do
+		if not redirect_uri_allowed(redirect_uri, client_uri, client_metadata.application_type) then
+			return nil, oauth_error("invalid_redirect_uri", "Invalid, insecure or inappropriate redirect URI.");
+		end
+	end
+
+	for field, prop_schema in pairs(registration_schema.properties) do
+		if field ~= "client_uri" and prop_schema.format == "uri" and client_metadata[field] then
+			if not redirect_uri_allowed(client_metadata[field], client_uri, "web") then
+				return nil, oauth_error("invalid_client_metadata", "Invalid, insecure or inappropriate informative URI");
+			end
+		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);
+
+	if grant_types:contains("authorization_code") and not response_types:contains("code") then
+		return nil, oauth_error("invalid_client_metadata", "Inconsistency between 'grant_types' and 'response_types'");
+	elseif grant_types:contains("implicit") and not response_types:contains("token") then
+		return nil, oauth_error("invalid_client_metadata", "Inconsistency between 'grant_types' and 'response_types'");
+	end
+
+	if set.intersection(grant_types, allowed_grant_type_handlers):empty() then
+		return nil, oauth_error("invalid_client_metadata", "No allowed 'grant_types' specified");
+	elseif set.intersection(response_types, allowed_response_type_handlers):empty() then
+		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);
+
+	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);
+		client_metadata.client_secret = client_secret;
+		client_metadata.client_secret_expires_at = 0;
+
+		if not registration_options.accept_expired then
+			client_metadata.client_secret_expires_at = client_metadata.client_id_issued_at + (registration_options.default_ttl or 3600);
+		end
+	end
+
+	return client_metadata;
+end
+
+local function handle_register_request(event)
+	local request = event.request;
+	local client_metadata, err = json.decode(request.body);
+	if err then
+		return oauth_error("invalid_request", "Invalid JSON");
+	end
+
+	local response, err = create_client(client_metadata);
+	if err then return err end
+
+	return {
+		status_code = 201;
+		headers = { content_type = "application/json" };
+		body = json.encode(response);
+	};
+end
+
+if not registration_key then
+	module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled")
+	handle_authorization_request = nil
+	handle_register_request = nil
+end
+
+local function handle_userinfo_request(event)
+	local request = event.request;
+	local credentials = get_request_credentials(request);
+	if not credentials or not credentials.bearer_token then
+		module:log("debug", "Missing credentials for UserInfo endpoint: %q", credentials)
+		return 401;
+	end
+	local token_info,err = tokens.get_token_info(credentials.bearer_token);
+	if not token_info then
+		module:log("debug", "UserInfo query failed token validation: %s", err)
+		return 403;
+	end
+	local scopes = set.new()
+	if type(token_info.grant.data) == "table" and type(token_info.grant.data.oauth2_scopes) == "string" then
+		scopes:add_list(parse_scopes(token_info.grant.data.oauth2_scopes));
+	else
+		module:log("debug", "token_info = %q", token_info)
+	end
+
+	if not scopes:contains("openid") then
+		module:log("debug", "Missing the 'openid' scope in %q", scopes)
+		-- The 'openid' scope is required for access to this endpoint.
+		return 403;
+	end
+
+	local user_info = {
+		iss = get_issuer();
+		sub = url.build({ scheme = "xmpp"; path = token_info.jid });
+	}
+
+	local token_claims = set.intersection(openid_claims, scopes);
+	token_claims:remove("openid"); -- that's "iss" and "sub" above
+	if not token_claims:empty() then
+		-- Another module can do that
+		module:fire_event("token/userinfo", {
+			token = token_info;
+			claims = token_claims;
+			username = jid.split(token_info.jid);
+			userinfo = user_info;
+		});
+	end
+
+	return {
+		status_code = 200;
+		headers = { content_type = "application/json" };
+		body = json.encode(user_info);
+	};
+end
+
 module:depends("http");
 module:provides("http", {
 	route = {
-		["POST /token"] = handle_token_grant;
+		-- OAuth 2.0 in 5 simple steps!
+		-- This is the normal 'authorization_code' flow.
+
+		-- Step 1. Create OAuth client
+		["POST /register"] = handle_register_request;
+
+		-- Step 2. User-facing login and consent view
 		["GET /authorize"] = handle_authorization_request;
+		["POST /authorize"] = handle_authorization_request;
+
+		-- Step 3. User is redirected to the 'redirect_uri' along with an
+		-- authorization code.  In the insecure 'implicit' flow, the access token
+		-- is delivered here.
+
+		-- Step 4. Retrieve access token using the code.
+		["POST /token"] = handle_token_grant;
+
+		-- Step 4 is later repeated using the refresh token to get new access tokens.
+
+		-- Step 5. Revoke token (access or refresh)
 		["POST /revoke"] = handle_revocation_request;
+
+		-- OpenID
+		["GET /userinfo"] = handle_userinfo_request;
+
+		-- Optional static content for templates
+		["GET /style.css"] = templates.css and {
+			headers = {
+				["Content-Type"] = "text/css";
+			};
+			body = _render_html(templates.css, module:get_option("oauth2_template_style"));
+		} or nil;
+		["GET /script.js"] = templates.js and {
+			headers = {
+				["Content-Type"] = "text/javascript";
+			};
+			body = templates.js;
+		} or nil;
+
+		-- Some convenient fallback handlers
+		["GET /register"] = { headers = { content_type = "application/schema+json" }; body = json.encode(registration_schema) };
+		["GET /token"] = function() return 405; end;
+		["GET /revoke"] = function() return 405; end;
 	};
 });
 
@@ -310,3 +999,41 @@
 	event.response.status_code = event.error.code or 400;
 	return json.encode(oauth2_response);
 end, 5);
+
+-- OIDC Discovery
+
+module:provides("http", {
+	name = "oauth2-discovery";
+	default_path = "/.well-known/oauth-authorization-server";
+	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" };
+			};
+		};
+	};
+});
+
+module:shared("tokenauth/oauthbearer_config").oidc_discovery_url = module:http_url("oauth2-discovery", "/.well-known/oauth-authorization-server");
--- a/mod_inotify_reload/mod_inotify_reload.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_inotify_reload/mod_inotify_reload.lua	Sat May 06 19:40:23 2023 -0500
@@ -12,31 +12,19 @@
 local watches = {};
 local watch_ids = {};
 
--- Fake socket object around inotify
-local inh_conn = {
-	getfd = function () return inh:fileno(); end;
-	dirty = function (self) return false; end;
-	settimeout = function () end;
-	send = function (_, d) return #d, 0; end;
-	close = function () end;
-	receive = function ()
-		local events = inh:read();
-		for _, event in ipairs(events) do
-			local mod = watches[watch_ids[event.wd]];
-			if mod then
-				local host, name = mod.host, mod.name;
-				module:log("debug", "Reloading changed module mod_%s on %s", name, host);
-				modulemanager.reload(host, name);
-			else
-				module:log("warn", "no watch for %d", event.wd);
-			end
+require"net.server".watchfd(inh:fileno(), function()
+	local events = inh:read();
+	for _, event in ipairs(events) do
+		local mod = watches[watch_ids[event.wd]];
+		if mod then
+			local host, name = mod.host, mod.name;
+			module:log("debug", "Reloading changed module mod_%s on %s", name, host);
+			modulemanager.reload(host, name);
+		else
+			module:log("warn", "no watch for %d", event.wd);
 		end
-		return "";
 	end
-};
-require "net.server".wrapclient(inh_conn, "inotify", inh:fileno(), {
-	onincoming = function () end, ondisconnect = function () end
-}, "*a");
+end);
 
 function watch_module(name, host, path)
 	local id, err = inh:addwatch(path, inotify.IN_CLOSE_WRITE);
--- a/mod_isolate_host/mod_isolate_host.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_isolate_host/mod_isolate_host.lua	Sat May 06 19:40:23 2023 -0500
@@ -22,6 +22,14 @@
 			except_domains:add(to_host);
 			return;
 		end
+		if origin.type == "local" then
+			-- this is code-generated, which means that set_session_isolation_flag has never triggered.
+			-- we need to check explicitly.
+			if not is_jid_isolated(jid_bare(event.stanza.attr.from)) then
+				module:log("debug", "server-generated stanza from %s is allowed, as the jid is not isolated", event.stanza.attr.from);
+				return;
+			end
+		end
 		module:log("warn", "Forbidding stanza from %s to %s", stanza.attr.from or origin.full_jid, stanza.attr.to);
 		origin.send(st.error_reply(stanza, "auth", "forbidden", "Communication with "..to_host.." is not available"));
 		return true;
@@ -36,13 +44,21 @@
 
 module:default_permission("prosody:admin", "xmpp:federate");
 
-function check_user_isolated(event)
+function is_jid_isolated(bare_jid)
+	if except_users:contains(bare_jid) or module:may("xmpp:federate", bare_jid) then
+		return false;
+	else
+		return true;
+	end
+end
+
+function set_session_isolation_flag(event)
 	local session = event.session;
 	local bare_jid = jid_bare(session.full_jid);
-	if module:may("xmpp:federate", event) or except_users:contains(bare_jid) then
+	if not is_jid_isolated(bare_jid) then
 		session.no_host_isolation = true;
 	end
 	module:log("debug", "%s is %sisolated", session.full_jid or "[?]", session.no_host_isolation and "" or "not ");
 end
 
-module:hook("resource-bind", check_user_isolated);
+module:hook("resource-bind", set_session_isolation_flag);
--- a/mod_listusers/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_listusers/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -1,3 +1,13 @@
+---
+labels:
+- 'Stage-Obsolete'
+---
+
+::: {.alert .alert-warning}
+As of Prosody 0.12.x, `prosodyctl shell user list HOST` can be used
+instead of this module.
+:::
+
 This module adds a command to `prosodyctl` for listing users.
 
 ``` sh
--- a/mod_log_ringbuffer/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_log_ringbuffer/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -34,7 +34,7 @@
     error = "/var/log/prosody/prosody.err";
 
     -- Log debug and higher to a 2MB buffer
-    { level = "debug", to = "ringbuffer", size = 1024*1024*2, filename = "debug-logs-{pid}-{count}.log", signal = "SIGUSR2" };
+    { to = "ringbuffer", size = 1024*1024*2, filename_template = "debug-logs-{pid}-{count}.log", signal = "SIGUSR2" };
 }
 ```
 
@@ -43,8 +43,8 @@
 `to`
 :   Set this to `"ringbuffer"`.
 
-`level`
-:   The minimum log level to capture, e.g. `"debug"`.
+`levels`
+:   The log levels to capture, e.g. `{ min = "debug" }`. By default, all levels are captured.
 
 `size`
 :   The size, in bytes, of the buffer. When the buffer fills,
@@ -109,7 +109,6 @@
 
 	{
 		to = "ringbuffer";
-		level = "debug";
 		filename_template = "{paths.data}/traceback-{pid}-{count}.log";
 		event = "debug_traceback/triggered";
 	};
--- a/mod_muc_http_defaults/mod_muc_http_defaults.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_muc_http_defaults/mod_muc_http_defaults.lua	Sat May 06 19:40:23 2023 -0500
@@ -89,7 +89,7 @@
 		if type(config.description) == "string" then room:set_description(config.description); end
 		if type(config.language) == "string" then room:set_language(config.language); end
 		if type(config.password) == "string" then room:set_password(config.password); end
-		if type(config.subject) == "string" then room:set_subject(config.subject); end
+		if type(config.subject) == "string" then room:set_subject(room.jid, config.subject); end
 
 		if type(config.public) == "boolean" then room:set_public(config.public); end
 		if type(config.members_only) == "boolean" then room:set_members_only(config.members_only); end
--- a/mod_muc_rtbl/mod_muc_rtbl.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_muc_rtbl/mod_muc_rtbl.lua	Sat May 06 19:40:23 2023 -0500
@@ -38,6 +38,13 @@
 		module:log("debug", "Retracted hash: %s", hash);
 		banned_hashes[hash] = nil;
 	end;
+
+	purge = function()
+		module:log("debug", "Purge all hashes");
+		for hash in pairs(banned_hashes) do
+			banned_hashes[hash] = nil;
+		end
+	end;
 });
 
 function request_list()
@@ -146,7 +153,7 @@
 
 module:hook("muc-private-message", function(event)
 	local occupant = event.room:get_occupant_by_nick(event.stanza.attr.from);
-	local affiliation = event.room:get_affiliation(event.occupant.bare_jid);
+	local affiliation = event.room:get_affiliation(occupant.bare_jid);
 	if affiliation and affiliation ~= "none" then
 		-- Skip check for affiliated users
 		return;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_oidc_userinfo_vcard4/README.md	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,19 @@
+---
+summary: OIDC UserInfo profile details from vcard4
+labels:
+- Stage-Alpha
+rockspec:
+  dependencies:
+  - mod_http_oauth2
+---
+
+This module extracts profile details from the user's [vcard4][XEP-0292]
+and provides them in the [UserInfo] endpoint of [mod_http_oauth2] to
+clients the user grants authorization.
+
+Whether this is really needed is unclear at this point. When logging in
+with an XMPP client, it could fetch the actual vcard4 to retrieve these
+details, so the UserInfo details would probably primarily be useful to
+other OAuth 2 and OIDC clients.
+
+[UserInfo]: https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,81 @@
+-- Provide OpenID UserInfo data to mod_http_oauth2
+-- Alternatively, separate module for the whole HTTP endpoint?
+--
+local nodeprep = require "util.encodings".stringprep.nodeprep;
+
+local mod_pep = module:depends "pep";
+
+local gender_map = { M = "male"; F = "female"; O = "other"; N = "nnot applicable"; U = "unknown" }
+
+module:hook("token/userinfo", function(event)
+	local pep_service = mod_pep.get_pep_service(event.username);
+
+	local vcard4 = select(3, pep_service:get_last_item("urn:xmpp:vcard4", true));
+
+	local userinfo = event.userinfo;
+	vcard4 = vcard4 and vcard4:get_child("vcard", "urn:ietf:params:xml:ns:vcard-4.0");
+	if vcard4 and event.claims:contains("profile") then
+		userinfo.name = vcard4:find("fn/text#");
+		userinfo.family_name = vcard4:find("n/surname#");
+		userinfo.given_name = vcard4:find("n/given#");
+		userinfo.middle_name = vcard4:find("n/additional#");
+
+		userinfo.nickname = vcard4:find("nickname/text#");
+		if not userinfo.nickname then
+			local ok, _, nick_item = pep_service:get_last_item("http://jabber.org/protocol/nick", true);
+			if ok and nick_item then
+				userinfo.nickname = nick_item:get_child_text("nick", "http://jabber.org/protocol/nick");
+			end
+		end
+
+		userinfo.preferred_username = event.username;
+
+		-- profile -- page? not their website
+		-- picture -- mod_http_pep_avatar?
+		userinfo.website = vcard4:find("url/uri#");
+		userinfo.birthdate = vcard4:find("bday/date#");
+		userinfo.zoneinfo = vcard4:find("tz/text#");
+		userinfo.locale = vcard4:find("lang/language-tag#");
+
+		userinfo.gender = gender_map[vcard4:find("gender/sex#")] or vcard4:find("gender/text#");
+
+		-- updated_at -- we don't keep a vcard change timestamp?
+	end
+
+	if not userinfo.nickname and event.claims:contains("profile") then
+		local ok, _, nick_item = pep_service:get_last_item("http://jabber.org/protocol/nick", true);
+		if ok and nick_item then
+			userinfo.nickname = nick_item:get_child_text("nick", "http://jabber.org/protocol/nick");
+		end
+	end
+
+	if vcard4 and event.claims:contains("email") then
+		userinfo.email = vcard4:find("email/text#")
+		if userinfo.email then
+			userinfo.email_verified = false;
+		end
+	end
+
+	if vcard4 and event.claims:contains("address") then
+		local adr = vcard4:get_child("adr");
+		if adr then
+			userinfo.address = {
+				formatted = nil;
+				street_address = adr:get_child_text("street");
+				locality = adr:get_child_text("locality");
+				region = adr:get_child_text("region");
+				postal_code = adr:get_child_text("code");
+				country = adr:get_child_text("country");
+			}
+		end
+	end
+
+	if vcard4 and event.claims:contains("phone") then
+		userinfo.phone = vcard4:find("tel/text#")
+		if userinfo.phone then
+			userinfo.phone_number_verified = false;
+		end
+	end
+
+
+end, 10);
--- a/mod_prometheus/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_prometheus/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -15,10 +15,12 @@
 
 [prometheusconf]: https://prometheus.io/docs/instrumenting/exposition_formats/
 
+::: {.alert .alert-info}
 **Note:** For use with Prosody trunk (0.12) we recommend the bundled
 [mod_http_openmetrics](https://prosody.im/doc/modules/mod_http_openmetrics)
 instead. This module (mod_prometheus) will continue to be available in the
 community repository for use with older Prosody versions.
+:::
 
 Configuration
 =============
--- a/mod_pubsub_feeds/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_pubsub_feeds/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -3,7 +3,7 @@
 rockspec:
   build:
     modules:
-      pubsub_feeds.feeds: feeds.lib.lua
+      mod_pubsub_feeds.feeds: feeds.lib.lua
 ---
 
 # Introduction
--- a/mod_register_apps/mod_register_apps.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_register_apps/mod_register_apps.lua	Sat May 06 19:40:23 2023 -0500
@@ -1,7 +1,7 @@
 -- luacheck: ignore 631
 module:depends("http");
 local http_files
-if prosody.process_type == "prosody" then
+if prosody.process_type then
 	-- Prosody >= 0.12
 	http_files = require "net.http.files";
 else
--- a/mod_remote_roster/mod_remote_roster.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_remote_roster/mod_remote_roster.lua	Sat May 06 19:40:23 2023 -0500
@@ -19,6 +19,7 @@
 local rm_roster_push = require "core.rostermanager".roster_push;
 local user_exists = require "core.usermanager".user_exists;
 local add_task = require "util.timer".add_task;
+local new_id = require "util.id".short;
 
 module:hook("iq-get/bare/jabber:iq:roster:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
@@ -138,7 +139,7 @@
 	if roster then
 		local item = roster[jid];
 		local contact_node, contact_host = jid_split(jid);
-		local stanza = st.iq({ type="set", from=node.."@"..host, to=contact_host }):query("jabber:iq:roster");
+		local stanza = st.iq({ type="set", from=node.."@"..host, to=contact_host, id = new_id() }):query("jabber:iq:roster");
 		if item then
 			stanza:tag("item", { jid = jid, subscription = item.subscription, name = item.name, ask = item.ask });
 			for group in pairs(item.groups) do
--- a/mod_rest/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_rest/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -38,9 +38,9 @@
 
 ## OAuth2
 
-[mod_http_oauth2] can be used to grant bearer tokens which are
-accepted by mod_rest.  Tokens can be passed to `curl` like
-`--oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K` as in some examples below.
+[mod_http_oauth2] can be used to grant bearer tokens which are accepted
+by mod_rest.  Tokens can be passed to `curl` like `--oauth2-bearer
+dmVyeSBzZWNyZXQgdG9rZW4K` instead of using `--user`.
 
 ## Sending stanzas
 
@@ -62,7 +62,7 @@
 
 ``` {.sh}
 curl https://prosody.example:5281/rest \
-    --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \
+    --user username \
     -H 'Content-Type: application/json' \
     --data-binary '{
            "body" : "Hello!",
@@ -81,7 +81,7 @@
 
 ```
 curl https://prosody.example:5281/rest/message/chat/john@example.com \
-    --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \
+    --user username \
     -H 'Content-Type: text/plain' \
     --data-binary 'Hello John!'
 ```
@@ -93,7 +93,7 @@
 
 ``` {.sh}
 curl https://prosody.example:5281/rest \
-    --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \
+    --user username \
     -H 'Content-Type: application/xmpp+xml' \
     --data-binary '<iq type="get" to="example.net">
             <ping xmlns="urn:xmpp:ping"/>
@@ -111,7 +111,7 @@
 
 ```
 curl https://prosody.example:5281/rest/version/example.com \
-    --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \
+    --user username \
     -H 'Accept: application/json'
 ```
 
--- a/mod_rest/apidemo.lib.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_rest/apidemo.lib.lua	Sat May 06 19:40:23 2023 -0500
@@ -27,11 +27,13 @@
 
 do
 	local f = module:load_resource("res/openapi.yaml");
+	local openapi = f:read("*a");
+	openapi = openapi:gsub("https://example%.com/oauth2", module:http_url("oauth2"));
 	_M.schema = {
 		headers = {
 			content_type = "text/x-yaml";
 		};
-		body = f:read("*a");
+		body = openapi;
 	}
 	f:close();
 end
--- a/mod_rest/example/prosody_oauth.py	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_rest/example/prosody_oauth.py	Sat May 06 19:40:23 2023 -0500
@@ -1,27 +1,45 @@
-from oauthlib.oauth2 import LegacyApplicationClient
 from requests_oauthlib import OAuth2Session
-
-
-class ProsodyRestClient(LegacyApplicationClient):
-    pass
+import requests
 
 
 class ProsodyRestSession(OAuth2Session):
-    def __init__(self, base_url=None, token_url=None, rest_url=None, *args, **kwargs):
-        if base_url and not token_url:
-            token_url = base_url + "/oauth2/token"
-        if base_url and not rest_url:
-            rest_url = base_url + "/rest"
-        self._prosody_rest_url = rest_url
-        self._prosody_token_url = token_url
+    def __init__(
+        self, base_url, client_name, client_uri, redirect_uri, *args, **kwargs
+    ):
+        self.base_url = base_url
+        discovery_url = base_url + "/.well-known/oauth-authorization-server"
 
-        super().__init__(client=ProsodyRestClient(*args, **kwargs))
+        meta = requests.get(discovery_url).json()
+        reg = requests.post(
+            meta["registration_endpoint"],
+            json={
+                "client_name": client_name,
+                "client_uri": client_uri,
+                "redirect_uris": [redirect_uri],
+            },
+        ).json()
+
+        super().__init__(client_id=reg["client_id"], *args, **kwargs)
+
+        self.meta = meta
+        self.client_secret = reg["client_secret"]
+        self.client_id = reg["client_id"]
+
+    def authorization_url(self, *args, **kwargs):
+        return super().authorization_url(
+            self.meta["authorization_endpoint"], *args, **kwargs
+        )
 
     def fetch_token(self, *args, **kwargs):
-        return super().fetch_token(token_url=self._prosody_token_url, *args, **kwargs)
+        return super().fetch_token(
+            token_url=self.meta["token_endpoint"],
+            client_secret=self.client_secret,
+            *args,
+            **kwargs
+        )
 
     def xmpp(self, json=None, *args, **kwargs):
-        return self.post(self._prosody_rest_url, json=json, *args, **kwargs)
+        return self.post(self.base_url + "/rest", json=json, *args, **kwargs)
 
 
 if __name__ == "__main__":
@@ -30,8 +48,16 @@
     # from prosody_oauth import ProsodyRestSession
     from getpass import getpass
 
-    p = ProsodyRestSession(base_url=input("Base URL: "), client_id="app")
-    
-    p.fetch_token(username=input("XMPP Address: "), password=getpass("Password: "))
+    p = ProsodyRestSession(
+        input("Base URL: "),
+        "Prosody mod_rest OAuth 2 example",
+        "https://modules.prosody.im/mod_rest",
+        "urn:ietf:wg:oauth:2.0:oob",
+    )
+
+    print("Open the following URL in a browser and login:")
+    print(p.authorization_url()[0])
+
+    p.fetch_token(code=getpass("Paste Authorization code: "))
 
     print(p.xmpp(json={"disco": True, "to": "jabber.org"}).json())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_rest/example/rest.sh	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,130 @@
+#!/bin/bash -eu
+
+# Copyright (c) Kim Alvefur
+# This file is MIT/X11 licensed.
+
+# Dependencies:
+# - https://httpie.io/
+# - https://github.com/stedolan/jq
+# - some sort of XDG 'open' command
+
+# 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"
+fi
+	
+if [[ $# == 0 ]]; then
+	echo "${0##*/} [-h HOST] [-u USER|--login] [/path] kind=(message|presence|iq) ...."
+	# Last arguments are handed to HTTPie, so refer to its docs for further details
+	exit 0
+fi
+
+if [[ "$1" == "-h" ]]; then
+	HOST="$2"
+	shift 2
+elif [ -z "${HOST:-}" ]; then
+	HOST="$(hostname)"
+fi
+
+if [[ "$HOST" != *.* ]]; then
+	# Assumes subdomain of your DOMAIN
+	if [ -z "${DOMAIN:-}" ]; then
+		DOMAIN="$(hostname -d)"
+	fi
+	if [[ "$HOST" == *:* ]]; then
+		HOST="${HOST%:*}.$DOMAIN:${HOST#*:}"
+	else
+		HOST="$HOST.$DOMAIN"
+	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=""
+if [[ "$1" == /* ]]; then
+	GET_PATH="$1"
+	shift 1
+fi
+
+http --check-status -p b "--$AUTH_METHOD" "$AUTH_ID" "https://$HOST/rest$GET_PATH" "$@"
--- a/mod_rest/mod_rest.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_rest/mod_rest.lua	Sat May 06 19:40:23 2023 -0500
@@ -390,7 +390,10 @@
 			module:hook(archive_event_name, archive_handler, 1);
 		end
 
-		local p = module:send_iq(payload, origin):next(
+		local iq_timeout = tonumber(request.headers.prosody_rest_timeout) or module:get_option_number("rest_iq_timeout", 60*2);
+		iq_timeout = math.min(iq_timeout, module:get_option_number("rest_iq_max_timeout", 300));
+
+		local p = module:send_iq(payload, origin, iq_timeout):next(
 			function (result)
 				module:log("debug", "Sending[rest]: %s", result.stanza:top_tag());
 				response.headers.content_type = send_type;
--- a/mod_rest/res/openapi.yaml	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_rest/res/openapi.yaml	Sat May 06 19:40:23 2023 -0500
@@ -21,6 +21,7 @@
       security:
         - basic: []
         - token: []
+        - oauth2: []
       requestBody:
         $ref: '#/components/requestBodies/common'
       responses:
@@ -37,6 +38,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/kind'
       - $ref: '#/components/parameters/type'
@@ -55,6 +57,7 @@
       security:
         - basic: []
         - token: []
+        - oauth2: []
       requestBody:
         $ref: '#/components/requestBodies/common'
       responses:
@@ -69,6 +72,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       responses:
@@ -91,6 +95,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       responses:
@@ -112,6 +117,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       responses:
@@ -126,6 +132,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       responses:
@@ -140,6 +147,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       - name: type
@@ -160,6 +168,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       - name: with
@@ -211,6 +220,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       responses:
@@ -225,6 +235,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       responses:
@@ -239,6 +250,7 @@
       security:
       - basic: []
       - token: []
+      - oauth2: []
       parameters:
       - $ref: '#/components/parameters/to'
       responses:
@@ -1411,6 +1423,18 @@
       description: Use JID as username.
       scheme: Basic
       type: http
+    oauth2:
+      description: Needs mod_http_oauth2
+      type: oauth2
+      flows:
+        authorizationCode:
+          authorizationUrl: https://example.com/oauth2/authorize
+          tokenUrl: https://example.com/oauth2/token
+          scopes:
+            prosody:restricted: Restricted account
+            prosody:user: Regular user privileges
+            prosody:admin: Administrator privileges
+            prosody:operator: Server operator privileges
 
   requestBodies:
     common:
--- a/mod_s2s_blacklist/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_s2s_blacklist/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -2,6 +2,11 @@
 level.
 
 ``` {.lua}
+modules_enabled = {
+    -- other modules --
+    "s2s_blacklist",
+
+}
 s2s_blacklist = {
     "proxy.eu.jabber.org",
 }
--- a/mod_s2s_whitelist/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_s2s_whitelist/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -2,6 +2,11 @@
 whitelist.
 
 ``` {.lua}
+modules_enabled = {
+    -- other modules --
+    "s2s_whitelist",
+
+}
 s2s_whitelist = {
     "example.org",
 }
--- a/mod_sasl2/mod_sasl2.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_sasl2/mod_sasl2.lua	Sat May 06 19:40:23 2023 -0500
@@ -121,6 +121,7 @@
 end
 
 module:hook("sasl2/c2s/failure", function (event)
+	module:fire_event("authentication-failure", event);
 	local session, condition, text = event.session, event.message, event.error_text;
 	local failure = st.stanza("failure", { xmlns = xmlns_sasl2 })
 		:tag(condition, { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl" }):up();
@@ -165,6 +166,7 @@
 end, -1000);
 
 module:hook("sasl2/c2s/success", function (event)
+	module:fire_event("authentication-success", event);
 	local session = event.session;
 	local features = st.stanza("stream:features");
 	module:fire_event("stream-features", { origin = session, features = features });
@@ -206,6 +208,10 @@
 	local user_agent = auth:get_child("user-agent");
 	if user_agent then
 		session.client_id = user_agent.attr.id;
+		sasl_handler.user_agent = {
+			software = user_agent:get_child_text("software");
+			device = user_agent:get_child_text("device");
+		};
 	end
 	local initial = auth:get_child_text("initial-response");
 	return process_cdata(session, initial);
--- a/mod_sasl2_bind2/mod_sasl2_bind2.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_sasl2_bind2/mod_sasl2_bind2.lua	Sat May 06 19:40:23 2023 -0500
@@ -24,13 +24,15 @@
 -- Helper to actually bind a resource to a session
 
 local function do_bind(session, bind_request)
-	local resource;
+	local resource = session.sasl_handler.resource;
 
-	local client_name_tag = bind_request:get_child_text("tag");
-	if client_name_tag then
-		local client_id = session.client_id;
-		local tag_suffix = client_id and base64.encode(sha1(client_id):sub(1, 9)) or id.medium();
-		resource = ("%s~%s"):format(client_name_tag, tag_suffix);
+	if not resource then
+		local client_name_tag = bind_request:get_child_text("tag");
+		if client_name_tag then
+			local client_id = session.client_id;
+			local tag_suffix = client_id and base64.encode(sha1(client_id):sub(1, 9)) or id.medium();
+			resource = ("%s~%s"):format(client_name_tag, tag_suffix);
+		end
 	end
 
 	local success, err_type, err, err_msg = sm_bind_resource(session, resource);
--- a/mod_sasl2_fast/mod_sasl2_fast.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_sasl2_fast/mod_sasl2_fast.lua	Sat May 06 19:40:23 2023 -0500
@@ -1,3 +1,5 @@
+local usermanager = require "core.usermanager";
+
 local sasl = require "util.sasl";
 local dt = require "util.datetime";
 local id = require "util.id";
@@ -38,6 +40,8 @@
 
 local function new_token_tester(hmac_f)
 	return function (mechanism, username, client_id, token_hash, cb_data, invalidate)
+		local account_info = usermanager.get_account_info(username, module.host);
+		local last_password_change = account_info and account_info.password_updated;
 		local tried_current_token = false;
 		local key = hash.sha256(client_id, true).."-new";
 		local token;
@@ -52,12 +56,18 @@
 						log("debug", "Token found, but it has expired (%ds ago). Cleaning up...", current_time - token.expires_at);
 						token_store:set(username, key, nil);
 						return nil, "credentials-expired";
+					elseif last_password_change and token.issued_at < last_password_change then
+						log("debug", "Token found, but issued prior to password change (%ds ago). Cleaning up...",
+							current_time - last_password_change
+						);
+						token_store:set(username, key, nil);
+						return nil, "credentials-expired";
 					end
 					if not tried_current_token and not invalidate then
 						-- The new token is becoming the current token
 						token_store:set_keys(username, {
 							[key] = token_store.remove;
-							[key:sub(1, -4).."-cur"] = token;
+							[key:sub(1, -5).."-cur"] = token;
 						});
 					end
 					local rotation_needed;
@@ -74,7 +84,7 @@
 				log("debug", "Trying next token...");
 				-- Try again with the current token instead
 				tried_current_token = true;
-				key = key:sub(1, -4).."-cur";
+				key = key:sub(1, -5).."-cur";
 			else
 				log("debug", "No matching %s token found for %s/%s", mechanism, username, key);
 				return nil;
@@ -102,6 +112,7 @@
 	end
 	local sasl_handler = get_sasl_handler(username);
 	if not sasl_handler then return; end
+	sasl_handler.fast_auth = true; -- For informational purposes
 	-- Copy channel binding info from primary SASL handler
 	sasl_handler.profile.cb = session.sasl_handler.profile.cb;
 	sasl_handler.userdata = session.sasl_handler.userdata;
@@ -217,3 +228,27 @@
 register_ht_mechanism("HT-SHA-256-UNIQ", "ht_sha_256", "tls-unique");
 register_ht_mechanism("HT-SHA-256-ENDP", "ht_sha_256", "tls-server-end-point");
 register_ht_mechanism("HT-SHA-256-EXPR", "ht_sha_256", "tls-exporter");
+
+-- Public API
+
+--luacheck: ignore 131
+function is_client_fast(username, client_id, last_password_change)
+	local client_id_hash = hash.sha256(client_id, true);
+	local curr_time = now();
+	local cur = token_store:get(username, client_id_hash.."-cur");
+	if cur and cur.expires_at >= curr_time and (not last_password_change or last_password_change < cur.issued_at) then
+		return true;
+	end
+	local new = token_store:get(username, client_id_hash.."-new");
+	if new and new.expires_at >= curr_time and (not last_password_change or last_password_change < new.issued_at) then
+		return true;
+	end
+	return false;
+end
+
+function revoke_fast_tokens(username, client_id)
+	local client_id_hash = hash.sha256(client_id, true);
+	local cur_ok = token_store:set(username, client_id_hash.."-cur", nil);
+	local new_ok = token_store:set(username, client_id_hash.."-new", nil);
+	return cur_ok and new_ok;
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_service_outage_status/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,19 @@
+This module allows advertising a machine-readable document were outages,
+planned or otherwise, may be reported.
+
+See [XEP-0455: Service Outage Status] for further details, including
+the format of the outage status document.
+
+```lua
+modules_enabled = {
+    -- other modules
+    "service_outage_status",
+}
+
+outage_status_urls = {
+    "https://uptime.example.net/status.json",
+}
+```
+
+The outage status document should be hosted on a separate server to
+ensure availability even if the XMPP server is unreachable.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_service_outage_status/mod_service_outage_status.lua	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,9 @@
+local dataforms = require "util.dataforms";
+
+local form_layout = dataforms.new({
+	{ type = "hidden"; var = "FORM_TYPE"; value = "urn:xmpp:sos:0" };
+	{ type = "list-multi"; name = "addrs"; var = "external-status-addresses" };
+});
+
+local addresses = module:get_option_array("outage_status_urls");
+module:add_extension(form_layout:form({ addrs = addresses }, "result"));
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_spam_report_forwarder/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,48 @@
+---
+labels:
+- 'Stage-Beta'
+summary: 'Forward spam/abuse reports to a JID'
+---
+
+This module forwards spam/abuse reports (e.g. those submitted by users via
+XEP-0377 via mod_spam_reporting) to one or more JIDs.
+
+## Configuration
+
+Install and enable the module the same as any other.
+
+There is a single option, `spam_report_destinations` which accepts a list of
+JIDs to send reports to.
+
+For example:
+
+```lua
+modules_enabled = {
+    ---
+    "spam_reporting";
+    "spam_report_forwarder";
+    ---
+}
+
+spam_report_destinations = { "antispam.example.com" }
+```
+
+## Protocol
+
+This section is intended for developers.
+
+XEP-0377 assumes the report is embedded within another protocol such as
+XEP-0191, and doesn't specify a format for communicating "standalone" reports.
+This module transmits them inside a `<message>` stanza, and adds a `<jid/>`
+element (borrowed from XEP-0268):
+
+```xml
+<message from="prosody.example" to="destination.example">
+    <report xmlns="urn:xmpp:reporting:1" reason="urn:xmpp:reporting:spam">
+        <jid xmlns="urn:xmpp:jid:0">spammer@bad.example</jid>
+        <text>
+          Never came trouble to my house like this.
+        </text>
+    </report>
+</message>
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_spam_report_forwarder/mod_spam_report_forwarder.lua	Sat May 06 19:40:23 2023 -0500
@@ -0,0 +1,21 @@
+local st = require "util.stanza";
+
+local destinations = module:get_option_set("spam_report_destinations", {});
+
+function forward_report(event)
+	local report = st.clone(event.report);
+	report:text_tag("jid", event.jid, { xmlns = "urn:xmpp:jid:0" });
+
+	local message = st.message({ from = module.host })
+		:add_child(report);
+
+	for destination in destinations do
+		local m = st.clone(message);
+		m.attr.to = destination;
+		module:send(m);
+	end
+end
+
+module:hook("spam_reporting/abuse-report", forward_report, -1);
+module:hook("spam_reporting/spam-report", forward_report, -1);
+module:hook("spam_reporting/unknown-report", forward_report, -1);
--- a/mod_strict_https/README.markdown	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_strict_https/README.markdown	Sat May 06 19:40:23 2023 -0500
@@ -1,33 +1,37 @@
 ---
-labels:
 summary: HTTP Strict Transport Security
-...
+---
 
-Introduction
-============
+# Introduction
 
-This module implements [HTTP Strict Transport
-Security](https://tools.ietf.org/html/rfc6797) and responds to all
-non-HTTPS requests with a `301 Moved Permanently` redirect to the HTTPS
-equivalent of the path.
+This module implements [RFC 6797: HTTP Strict Transport Security] and
+responds to all non-HTTPS requests with a `301 Moved Permanently`
+redirect to the HTTPS equivalent of the path.
 
-Configuration
-=============
+# Configuration
 
 Add the module to the `modules_enabled` list and optionally configure
 the specific header sent.
 
-    modules_enabled = {
-      ...
-          "strict_https";
-    }
-    hsts_header = "max-age=31556952"
+``` lua
+modules_enabled = {
+  ...
+      "strict_https";
+}
+hsts_header = "max-age=31556952"
+```
+
+If the redirect from `http://` to `https://` causes trouble with
+internal use of HTTP APIs it can be disabled:
 
-Compatibility
-=============
+``` lua
+hsts_redirect = false
+```
+
+# Compatibility
 
-  ------- --------------
-  trunk   Works
-  0.9     Works
-  0.8     Doesn't work
-  ------- --------------
+  ------- -------------
+  trunk   Should work
+  0.12    Should work
+  0.11    Should work
+  ------- -------------
--- a/mod_strict_https/mod_strict_https.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_strict_https/mod_strict_https.lua	Sat May 06 19:40:23 2023 -0500
@@ -1,44 +1,23 @@
 -- HTTP Strict Transport Security
--- https://tools.ietf.org/html/rfc6797
+-- https://www.rfc-editor.org/info/rfc6797
 
 module:set_global();
 
 local http_server = require "net.http.server";
 
 local hsts_header = module:get_option_string("hsts_header", "max-age=31556952"); -- This means "Don't even try to access without HTTPS for a year"
-
-local _old_send_response;
-local _old_fire_event;
-
-local modules = {};
+local redirect = module:get_option_boolean("hsts_redirect", true);
 
-function module.load()
-	_old_send_response = http_server.send_response;
-	function http_server.send_response(response, body)
-		response.headers.strict_transport_security = hsts_header;
-		return _old_send_response(response, body);
-	end
-
-	_old_fire_event = http_server._events.fire_event;
-	function http_server._events.fire_event(event, payload)
-		local request = payload.request;
-		local host = event:match("^[A-Z]+ ([^/]+)");
-		local module = modules[host];
-		if module and not request.secure then
-			payload.response.headers.location = module:http_url(request.path);
+module:wrap_object_event(http_server._events, false, function(handlers, event_name, event_data)
+	local request, response = event_data.request, event_data.response;
+	if request and response then
+		if request.secure then
+			response.headers.strict_transport_security = hsts_header;
+		elseif redirect then
+			-- This won't get the port number right
+			response.headers.location = "https://" .. request.host .. request.path .. (request.query and "?" .. request.query or "");
 			return 301;
 		end
-		return _old_fire_event(event, payload);
 	end
-end
-function module.unload()
-	http_server.send_response = _old_send_response;
-	http_server._events.fire_event = _old_fire_event;
-end
-function module.add_host(module)
-	local http_host = module:get_option_string("http_host", module.host);
-	modules[http_host] = module;
-	function module.unload()
-		modules[http_host] = nil;
-	end
-end
+	return handlers(event_name, event_data);
+end);
--- a/mod_vcard_muc/mod_vcard_muc.lua	Wed Feb 22 22:47:45 2023 -0500
+++ b/mod_vcard_muc/mod_vcard_muc.lua	Sat May 06 19:40:23 2023 -0500
@@ -76,7 +76,7 @@
 			session.send(st.error_reply(stanza, "cancel", "item-not-found"));
 		end
 	else
-		if from_affiliation == "owner" then
+		if from_affiliation == "owner" or (module.may and module:may("muc:automatic-ownership", from)) then
 			if vcards:set(room_node, st.preserialize(stanza.tags[1])) then
 				session.send(st.reply(stanza):tag("vCard", { xmlns = "vcard-temp" }));
 				broadcast_presence(room, nil)