diff mod_http_oauth2/mod_http_oauth2.lua @ 5653:62c6e17a5e9d

Merge
author Stephen Paul Weber <singpolyma@singpolyma.net>
date Mon, 18 Sep 2023 08:24:19 -0500
parents d67980d9e12d
children bbde136a4c29
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -1,22 +1,23 @@
-local hashes = require "util.hashes";
+local usermanager = require "core.usermanager";
+local url = require "socket.url";
+local array = require "util.array";
 local cache = require "util.cache";
+local encodings = require "util.encodings";
+local errors = require "util.error";
+local hashes = require "util.hashes";
 local http = require "util.http";
+local id = require "util.id";
+local it = require "util.iterators";
 local jid = require "util.jid";
 local json = require "util.json";
-local usermanager = require "core.usermanager";
-local errors = require "util.error";
-local url = require "socket.url";
-local id = require "util.id";
-local encodings = require "util.encodings";
-local base64 = encodings.base64;
+local schema = require "util.jsonschema";
+local jwt = require "util.jwt";
 local random = require "util.random";
-local schema = require "util.jsonschema";
 local set = require "util.set";
-local jwt = require"util.jwt";
-local it = require "util.iterators";
-local array = require "util.array";
 local st = require "util.stanza";
 
+local base64 = encodings.base64;
+
 local function b64url(s)
 	return (base64.encode(s):gsub("[+/=]", { ["+"] = "-", ["/"] = "_", ["="] = "" }))
 end
@@ -27,6 +28,24 @@
 	end
 end
 
+local function strict_formdecode(query)
+	if not query then
+		return nil;
+	end
+	local params = http.formdecode(query);
+	if type(params) ~= "table" then
+		return nil, "no-pairs";
+	end
+	local dups = {};
+	for _, pair in ipairs(params) do
+		if dups[pair.name] then
+			return nil, "duplicate";
+		end
+		dups[pair.name] = true;
+	end
+	return params;
+end
+
 local function read_file(base_path, fn, required)
 	local f, err = io.open(base_path .. "/" .. fn);
 	if not f then
@@ -41,10 +60,15 @@
 	return data;
 end
 
+local allowed_locales = module:get_option_array("allowed_oauth2_locales", {});
+-- TODO Allow translations or per-locale templates somehow.
+
 local template_path = module:get_option_path("oauth2_template_path", "html");
 local templates = {
 	login = read_file(template_path, "login.html", true);
 	consent = read_file(template_path, "consent.html", true);
+	oob = read_file(template_path, "oob.html", true);
+	device = read_file(template_path, "device.html", true);
 	error = read_file(template_path, "error.html", true);
 	css = read_file(template_path, "style.css");
 	js = read_file(template_path, "script.js");
@@ -52,27 +76,33 @@
 
 local site_name = module:get_option_string("site_name", module.host);
 
-local _render_html = require"util.interpolation".new("%b{}", st.xml_escape);
+local security_policy = module:get_option_string("oauth2_security_policy", "default-src 'self'");
+
+local render_html = require"util.interpolation".new("%b{}", st.xml_escape);
 local function render_page(template, data, sensitive)
 	data = data or {};
 	data.site_name = site_name;
 	local resp = {
-		status_code = 200;
+		status_code = data.error and data.error.code or 200;
 		headers = {
 			["Content-Type"] = "text/html; charset=utf-8";
-			["Content-Security-Policy"] = "default-src 'self'";
+			["Content-Security-Policy"] = security_policy;
+			["Referrer-Policy"] = "no-referrer";
 			["X-Frame-Options"] = "DENY";
 			["Cache-Control"] = (sensitive and "no-store" or "no-cache")..", private";
+			["Pragma"] = "no-cache";
 		};
-		body = _render_html(template, data);
+		body = render_html(template, data);
 	};
 	return resp;
 end
 
+local authorization_server_metadata = nil;
+
 local tokens = module:depends("tokenauth");
 
-local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 86400);
-local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", nil);
+local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 3600);
+local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", 604800);
 
 -- Used to derive client_secret from client_id, set to enable stateless dynamic registration.
 local registration_key = module:get_option_string("oauth2_registration_key");
@@ -84,26 +114,60 @@
 local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", false);
 
 local verification_key;
-local jwt_sign, jwt_verify;
+local sign_client, verify_client;
 if registration_key then
 	-- Tie it to the host if global
 	verification_key = hashes.hmac_sha256(registration_key, module.host);
-	jwt_sign, jwt_verify = jwt.init(registration_algo, registration_key, registration_key, registration_options);
+	sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options);
 end
 
+local new_device_token, verify_device_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 });
+
+-- verify and prepare client structure
+local function check_client(client_id)
+	if not verify_client then
+		return nil, "client-registration-not-enabled";
+	end
+
+	local ok, client = verify_client(client_id);
+	if not ok then
+		return ok, client;
+	end
+
+	client.client_hash = b64url(hashes.sha256(client_id));
+	return client;
+end
+
+-- scope : string | array | set
+--
+-- at each step, allow the same or a subset of scopes
+-- (all ( client ( grant ( token ) ) ))
+-- preserve order since it determines role if more than one granted
+
+-- string -> array
 local function parse_scopes(scope_string)
 	return array(scope_string:gmatch("%S+"));
 end
 
-local openid_claims = set.new({ "openid", "profile"; "email"; "address"; "phone" });
+local openid_claims = set.new();
+module:add_item("openid-claim", "openid");
 
+module:handle_items("openid-claim", function(event)
+	authorization_server_metadata = nil;
+	openid_claims:add(event.item);
+end, function()
+	authorization_server_metadata = nil;
+	openid_claims = set.new(module:get_host_items("openid-claim"));
+end, true);
+
+-- array -> array, array, array
 local function split_scopes(scope_list)
 	local claims, roles, unknown = array(), array(), array();
 	local all_roles = usermanager.get_all_roles(module.host);
 	for _, scope in ipairs(scope_list) do
 		if openid_claims:contains(scope) then
 			claims:push(scope);
-		elseif all_roles[scope] then
+		elseif scope == "xmpp" or all_roles[scope] then
 			roles:push(scope);
 		else
 			unknown:push(scope);
@@ -113,32 +177,29 @@
 end
 
 local function can_assume_role(username, requested_role)
-	return usermanager.user_can_assume_role(username, module.host, requested_role);
+	return requested_role == "xmpp" or usermanager.user_can_assume_role(username, module.host, requested_role);
+end
+
+-- function (string) : function(string) : boolean
+local function role_assumable_by(username)
+	return function(role)
+		return can_assume_role(username, role);
+	end
 end
 
-local function select_role(username, requested_roles)
-	if requested_roles then
-		for _, requested_role in ipairs(requested_roles) do
-			if can_assume_role(username, requested_role) then
-				return requested_role;
-			end
-		end
-	end
-	-- otherwise the default role
-	return usermanager.get_user_role(username, module.host).name;
+-- string, array --> array
+local function user_assumable_roles(username, requested_roles)
+	return array.filter(requested_roles, role_assumable_by(username));
 end
 
+-- string, string|nil --> string, string
 local function filter_scopes(username, requested_scope_string)
-	local granted_scopes, requested_roles;
+	local requested_scopes, requested_roles = split_scopes(parse_scopes(requested_scope_string or ""));
 
-	if requested_scope_string then -- Specific role(s) requested
-		granted_scopes, requested_roles = split_scopes(parse_scopes(requested_scope_string));
-	else
-		granted_scopes = array();
-	end
+	local granted_roles = user_assumable_roles(username, requested_roles);
+	local granted_scopes = requested_scopes + granted_roles;
 
-	local selected_role = select_role(username, requested_roles);
-	granted_scopes:push(selected_role);
+	local selected_role = granted_roles[1];
 
 	return granted_scopes:concat(" "), selected_role;
 end
@@ -155,9 +216,8 @@
 	return code_expired(code)
 end);
 
--- Periodically clear out unredeemed codes.  Does not need to be exact, expired
--- codes are rejected if tried. Mostly just to keep memory usage in check.
-module:hourly("Clear expired authorization codes", function()
+-- Clear out unredeemed codes so they don't linger in memory.
+module:daily("Clear expired authorization codes", function()
 	local k, code = codes:tail();
 	while code and code_expired(code) do
 		codes:set(k, nil);
@@ -169,11 +229,13 @@
 	return (module:http_url(nil, "/"):gsub("/$", ""));
 end
 
+-- Non-standard special redirect URI that has the AS show the authorization
+-- code to the user for them to copy-paste into the client, which can then
+-- continue as if it received it via redirect.
+local oob_uri = "urn:ietf:wg:oauth:2.0:oob";
+local device_uri = "urn:ietf:params:oauth:grant-type:device_code";
+
 local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" });
-local function is_secure_redirect(uri)
-	local u = url.parse(uri);
-	return u.scheme ~= "http" or loopbacks:contains(u.host);
-end
 
 local function oauth_error(err_name, err_desc)
 	return errors.new({
@@ -189,7 +251,13 @@
 -- properties that are deemed useful e.g. in case tokens issued to a certain
 -- client needs to be revoked
 local function client_subset(client)
-	return { name = client.client_name; uri = client.client_uri; id = client.software_id; version = client.software_version };
+	return {
+		name = client.client_name;
+		uri = client.client_uri;
+		id = client.software_id;
+		version = client.software_version;
+		hash = client.client_hash;
+	};
 end
 
 local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info)
@@ -201,21 +269,30 @@
 		token_data = nil;
 	end
 
-	local refresh_token;
 	local grant = refresh_token_info and refresh_token_info.grant;
 	if not grant then
 		-- No existing grant, create one
-		grant = tokens.create_grant(token_jid, token_jid, default_refresh_ttl, token_data);
-		-- Create refresh token for the grant if desired
-		refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant, nil, nil, "oauth2-refresh");
-	else
-		-- Grant exists, reuse existing refresh token
-		refresh_token = refresh_token_info.token;
-
-		refresh_token_info.grant = nil; -- Prevent reference loop
+		grant = tokens.create_grant(token_jid, token_jid, nil, token_data);
 	end
 
-	local access_token, access_token_info = tokens.create_token(token_jid, grant, role, default_access_ttl, "oauth2");
+	if refresh_token_info then
+		-- out with the old refresh tokens
+		local ok, err = tokens.revoke_token(refresh_token_info.token);
+		if not ok then
+			module:log("error", "Could not revoke refresh token: %s", err);
+			return 500;
+		end
+	end
+	-- in with the new refresh token
+	local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh");
+
+	if role == "xmpp" then
+		-- Special scope meaning the users default role.
+		local user_default_role = usermanager.get_user_role(jid.node(token_jid), module.host);
+		role = user_default_role and user_default_role.name;
+	end
+
+	local access_token, access_token_info = tokens.create_token(token_jid, grant.id, role, default_access_ttl, "oauth2");
 
 	local expires_at = access_token_info.expires;
 	return {
@@ -228,6 +305,17 @@
 	};
 end
 
+local function normalize_loopback(uri)
+	local u = url.parse(uri);
+	if u.scheme == "http" and loopbacks:contains(u.host) then
+		u.authority = nil;
+		u.host = "::1";
+		u.port = nil;
+		return url.build(u);
+	end
+	-- else, not a valid loopback uri
+end
+
 local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string
 	if not query_redirect_uri then
 		if #client.redirect_uris ~= 1 then
@@ -237,18 +325,47 @@
 		-- When only a single URI is registered, that's the default
 		return client.redirect_uris[1];
 	end
+	if query_redirect_uri == device_uri and client.grant_types then
+		for _, grant_type in ipairs(client.grant_types) do
+			if grant_type == device_uri then
+				return query_redirect_uri;
+			end
+		end
+		-- Tried to use device authorization flow without registering it.
+		return;
+	end
 	-- Verify the client-provided URI matches one previously registered
 	for _, redirect_uri in ipairs(client.redirect_uris) do
 		if query_redirect_uri == redirect_uri then
 			return redirect_uri
 		end
 	end
+	-- The authorization server MUST allow any port to be specified at the time
+	-- of the request for loopback IP redirect URIs, to accommodate clients that
+	-- obtain an available ephemeral port from the operating system at the time
+	-- of the request.
+	-- https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-08.html#section-8.4.2
+	local loopback_redirect_uri = normalize_loopback(query_redirect_uri);
+	if loopback_redirect_uri then
+		for _, redirect_uri in ipairs(client.redirect_uris) do
+			if loopback_redirect_uri == normalize_loopback(redirect_uri) then
+				return query_redirect_uri;
+			end
+		end
+	end
 end
 
 local grant_type_handlers = {};
 local response_type_handlers = {};
 local verifier_transforms = {};
 
+function grant_type_handlers.implicit()
+	-- Placeholder to make discovery work correctly.
+	-- Access tokens are delivered via redirect when using the implict flow, not
+	-- via the token endpoint, so how did you get here?
+	return oauth_error("invalid_request");
+end
+
 function grant_type_handlers.password(params)
 	local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)"));
 	local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'"));
@@ -277,8 +394,19 @@
 		return oauth_error("invalid_request", "PKCE required");
 	end
 
+	local prefix = "authorization_code:";
 	local code = id.medium();
-	local ok = codes:set(params.client_id .. "#" .. code, {
+	if params.redirect_uri == device_uri then
+		local is_device, device_state = verify_device_token(params.state);
+		if is_device then
+			-- reconstruct the device_code
+			prefix = "device_code:";
+			code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code));
+		else
+			return oauth_error("invalid_request");
+		end
+	end
+	local ok = codes:set(prefix.. params.client_id .. "#" .. code, {
 		expires = os.time() + 600;
 		granted_jid = granted_jid;
 		granted_scopes = granted_scopes;
@@ -288,29 +416,21 @@
 		id_token = id_token;
 	});
 	if not ok then
-		return {status_code = 429};
+		return oauth_error("temporarily_unavailable");
 	end
 
 	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
-	if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" then
-		-- TODO some nicer template page
-		-- mod_http_errors will set content-type to text/html if it catches this
-		-- event, if not text/plain is kept for the fallback text.
-		local response = { status_code = 200; headers = { content_type = "text/plain" } }
-		response.body = module:context("*"):fire_event("http-message", {
-			response = response;
-			title = "Your authorization code";
-			message = "Here's your authorization code, copy and paste it into " .. (client.client_name or "your client");
-			extra = code;
-		}) or ("Here's your authorization code:\n%s\n"):format(code);
-		return response;
+	if redirect_uri == oob_uri then
+		return render_page(templates.oob, { client = client; authorization_code = code }, true);
+	elseif redirect_uri == device_uri then
+		return render_page(templates.device, { client = client }, true);
 	elseif not redirect_uri then
-		return 400;
+		return oauth_error("invalid_redirect_uri");
 	end
 
 	local redirect = url.parse(redirect_uri);
 
-	local query = http.formdecode(redirect.query or "");
+	local query = strict_formdecode(redirect.query);
 	if type(query) ~= "table" then query = {}; end
 	table.insert(query, { name = "code", value = code });
 	table.insert(query, { name = "iss", value = get_issuer() });
@@ -322,6 +442,8 @@
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = url.build(redirect);
 		};
 	}
@@ -337,13 +459,15 @@
 	local token_info = new_access_token(granted_jid, granted_role, granted_scopes, client, nil);
 
 	local redirect = url.parse(get_redirect_uri(client, params.redirect_uri));
-	if not redirect then return 400; end
+	if not redirect then return oauth_error("invalid_redirect_uri"); end
 	token_info.state = params.state;
 	redirect.fragment = http.formencode(token_info);
 
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = url.build(redirect);
 		};
 	}
@@ -362,11 +486,12 @@
 	if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end
 	if not params.code then return oauth_error("invalid_request", "missing 'code'"); end
 	if params.scope and params.scope ~= "" then
+		-- FIXME allow a subset of granted scopes
 		return oauth_error("invalid_scope", "unknown scope requested");
 	end
 
-	local client_ok, client = jwt_verify(params.client_id);
-	if not client_ok then
+	local client = check_client(params.client_id);
+	if not client then
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
 
@@ -374,11 +499,12 @@
 		module:log("debug", "client_secret mismatch");
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
-	local code, err = codes:get(params.client_id .. "#" .. params.code);
+	local code, err = codes:get("authorization_code:" .. params.client_id .. "#" .. params.code);
 	if err then error(err); end
 	-- MUST NOT use the authorization code more than once, so remove it to
 	-- prevent a second attempted use
-	codes:set(params.client_id .. "#" .. params.code, nil);
+	-- TODO if a second attempt *is* made, revoke any tokens issued
+	codes:set("authorization_code:" .. params.client_id .. "#" .. params.code, nil);
 	if not code or type(code) ~= "table" or code_expired(code) then
 		module:log("debug", "authorization_code invalid or expired: %q", code);
 		return oauth_error("invalid_client", "incorrect credentials");
@@ -400,8 +526,8 @@
 	if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end
 	if not params.refresh_token then return oauth_error("invalid_request", "missing 'refresh_token'"); end
 
-	local client_ok, client = jwt_verify(params.client_id);
-	if not client_ok then
+	local client = check_client(params.client_id);
+	if not client then
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
 
@@ -415,12 +541,58 @@
 		return oauth_error("invalid_grant", "invalid refresh token");
 	end
 
+	local refresh_token_client = refresh_token_info.grant.data.oauth2_client;
+	if not refresh_token_client.hash or refresh_token_client.hash ~= client.client_hash then
+		module:log("warn", "OAuth client %q (%s) tried to use refresh token belonging to %q (%s)", client.client_name, client.client_hash,
+			refresh_token_client.name, refresh_token_client.hash);
+		return oauth_error("unauthorized_client", "incorrect credentials");
+	end
+
+	local refresh_scopes = refresh_token_info.grant.data.oauth2_scopes;
+
+	if params.scope then
+		local granted_scopes = set.new(parse_scopes(refresh_scopes));
+		local requested_scopes = parse_scopes(params.scope);
+		refresh_scopes = array.filter(requested_scopes, function(scope)
+			return granted_scopes:contains(scope);
+		end):concat(" ");
+	end
+
+	local username = jid.split(refresh_token_info.jid);
+	local new_scopes, role = filter_scopes(username, refresh_scopes);
+
 	-- new_access_token() requires the actual token
 	refresh_token_info.token = params.refresh_token;
 
-	return json.encode(new_access_token(
-		refresh_token_info.jid, refresh_token_info.role, refresh_token_info.grant.data.oauth2_scopes, client, nil, refresh_token_info
-	));
+	return json.encode(new_access_token(refresh_token_info.jid, role, new_scopes, client, nil, refresh_token_info));
+end
+
+grant_type_handlers[device_uri] = function(params)
+	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
+	if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end
+	if not params.device_code then return oauth_error("invalid_request", "missing 'device_code'"); end
+
+	local client = check_client(params.client_id);
+	if not client then
+		return oauth_error("invalid_client", "incorrect credentials");
+	end
+
+	if not verify_client_secret(params.client_id, params.client_secret) then
+		module:log("debug", "client_secret mismatch");
+		return oauth_error("invalid_client", "incorrect credentials");
+	end
+
+	local code = codes:get("device_code:" .. params.client_id .. "#" .. params.device_code);
+	if type(code) ~= "table" or code_expired(code) then
+		return oauth_error("expired_token");
+	elseif code.error then
+		return code.error;
+	elseif not code.granted_jid then
+		return oauth_error("authorization_pending");
+	end
+	codes:set("device_code:" .. params.client_id .. "#" .. params.device_code, nil);
+
+	return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token));
 end
 
 -- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients
@@ -467,7 +639,7 @@
 			user = {
 				username = username;
 				host = module.host;
-				token = new_user_token({ username = username, host = module.host });
+				token = new_user_token({ username = username; host = module.host; auth_time = os.time() });
 			};
 		};
 	elseif form.user_token and form.consent then
@@ -479,14 +651,14 @@
 			};
 		end
 
-		local scope = array():append(form):filter(function(field)
-			return field.name == "scope" or field.name == "role";
-		end):pluck("value"):concat(" ");
+		local scopes = array():append(form):filter(function(field)
+			return field.name == "scope";
+		end):pluck("value");
 
 		user.token = form.user_token;
 		return {
 			user = user;
-			scope = scope;
+			scopes = scopes;
 			consent = form.consent == "granted";
 		};
 	end
@@ -527,6 +699,7 @@
 		local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'"));
 		local request_username, request_host, request_resource = jid.prepped_split(request_jid);
 		if params.scope then
+			-- TODO shouldn't we support scopes / roles here?
 			return oauth_error("invalid_scope", "unknown scope requested");
 		end
 		if not request_host or request_host ~= module.host then
@@ -546,18 +719,20 @@
 	grant_type_handlers.authorization_code = nil;
 end
 
+local function render_error(err)
+	return render_page(templates.error, { error = err });
+end
+
 -- OAuth errors should be returned to the client if possible, i.e. by
 -- appending the error information to the redirect_uri and sending the
 -- redirect to the user-agent. In some cases we can't do this, e.g. if
 -- the redirect_uri is missing or invalid. In those cases, we render an
 -- error directly to the user-agent.
-local function error_response(request, err)
-	local q = request.url.query and http.formdecode(request.url.query);
-	local redirect_uri = q and q.redirect_uri;
-	if not redirect_uri or not is_secure_redirect(redirect_uri) then
-		module:log("warn", "Missing or invalid redirect_uri <%s>, rendering error to user-agent", redirect_uri or "");
-		return render_page(templates.error, { error = err });
+local function error_response(request, redirect_uri, err)
+	if not redirect_uri or redirect_uri == oob_uri then
+		return render_error(err);
 	end
+	local q = strict_formdecode(request.url.query);
 	local redirect_query = url.parse(redirect_uri);
 	local sep = redirect_query.query and "&" or "?";
 	redirect_uri = redirect_uri
@@ -567,12 +742,25 @@
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = redirect_uri;
 		};
 	};
 end
 
-local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password", "refresh_token"})
+local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {
+	"authorization_code";
+	"password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used.
+	"refresh_token";
+	device_uri;
+})
+if allowed_grant_type_handlers:contains("device_code") then
+	-- expand short form because that URI is long
+	module:log("debug", "Expanding %q to %q in '%s'", "device_code", device_uri, "allowed_oauth2_grant_types");
+	allowed_grant_type_handlers:remove("device_code");
+	allowed_grant_type_handlers:add(device_uri);
+end
 for handler_type in pairs(grant_type_handlers) do
 	if not allowed_grant_type_handlers:contains(handler_type) then
 		module:log("debug", "Grant type %q disabled", handler_type);
@@ -607,9 +795,11 @@
 	local credentials = get_request_credentials(event.request);
 
 	event.response.headers.content_type = "application/json";
-	local params = http.formdecode(event.request.body);
+	event.response.headers.cache_control = "no-store";
+	event.response.headers.pragma = "no-cache";
+	local params = strict_formdecode(event.request.body);
 	if not params then
-		return error_response(event.request, oauth_error("invalid_request"));
+		return oauth_error("invalid_request", "Could not parse request body as 'application/x-www-form-urlencoded'");
 	end
 
 	if credentials and credentials.type == "basic" then
@@ -621,7 +811,7 @@
 	local grant_type = params.grant_type
 	local grant_handler = grant_type_handlers[grant_type];
 	if not grant_handler then
-		return error_response(event.request, oauth_error("unsupported_grant_type"));
+		return oauth_error("invalid_request", "No such grant type.");
 	end
 	return grant_handler(params);
 end
@@ -629,55 +819,102 @@
 local function handle_authorization_request(event)
 	local request = event.request;
 
+	-- Directly returning errors to the user before we have a validated client object
 	if not request.url.query then
-		return error_response(request, oauth_error("invalid_request"));
+		return render_error(oauth_error("invalid_request", "Missing query parameters"));
 	end
-	local params = http.formdecode(request.url.query);
+	local params = strict_formdecode(request.url.query);
 	if not params then
-		return error_response(request, oauth_error("invalid_request"));
+		return render_error(oauth_error("invalid_request", "Invalid query parameters"));
 	end
 
-	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
+	if not params.client_id then
+		return render_error(oauth_error("invalid_request", "Missing 'client_id' parameter"));
+	end
 
-	local ok, client = jwt_verify(params.client_id);
+	local client = check_client(params.client_id);
 
-	if not ok then
-		return oauth_error("invalid_client", "incorrect credentials");
+	if not client then
+		return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter"));
 	end
 
+	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
+	if not redirect_uri then
+		return render_error(oauth_error("invalid_request", "Invalid 'redirect_uri' parameter"));
+	end
+	-- From this point we know that redirect_uri is safe to use
+
 	local client_response_types = set.new(array(client.response_types or { "code" }));
 	client_response_types = set.intersection(client_response_types, allowed_response_type_handlers);
 	if not client_response_types:contains(params.response_type) then
-		return oauth_error("invalid_client", "response_type not allowed");
+		return error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not allowed"));
+	end
+
+	local requested_scopes = parse_scopes(params.scope or "");
+	if client.scope then
+		local client_scopes = set.new(parse_scopes(client.scope));
+		requested_scopes:filter(function(scope)
+			return client_scopes:contains(scope);
+		end);
+	end
+
+	-- The 'prompt' parameter from OpenID Core
+	local prompt = set.new(parse_scopes(params.prompt or "select_account login consent"));
+	if prompt:contains("none") then
+		-- Client wants no interaction, only confirmation of prior login and
+		-- consent, but this is not implemented.
+		return error_response(request, redirect_uri, oauth_error("interaction_required"));
+	elseif not prompt:contains("select_account") and not params.login_hint then
+		-- TODO If the login page is split into account selection followed by login
+		-- (e.g. password), and then the account selection could be skipped iff the
+		-- 'login_hint' parameter is present.
+		return error_response(request, redirect_uri, oauth_error("account_selection_required"));
+	elseif not prompt:contains("login") then
+		-- Currently no cookies or such are used, so login is required every time.
+		return error_response(request, redirect_uri, oauth_error("login_required"));
+	elseif not prompt:contains("consent") then
+		-- Are there any circumstances when consent would be implied or assumed?
+		return error_response(request, redirect_uri, oauth_error("consent_required"));
 	end
 
 	local auth_state = get_auth_state(request);
 	if not auth_state.user then
 		-- Render login page
-		return render_page(templates.login, { state = auth_state, client = client });
+		local extra = {};
+		if params.login_hint then
+			extra.username_hint = (jid.prepped_split(params.login_hint));
+		end
+		return render_page(templates.login, { state = auth_state; client = client; extra = extra });
 	elseif auth_state.consent == nil then
 		-- Render consent page
-		local scopes, requested_roles = split_scopes(parse_scopes(params.scope or ""));
-		local default_role = select_role(auth_state.user.username, requested_roles);
-		local roles = array(it.values(usermanager.get_all_roles(module.host))):filter(function(role)
-			return can_assume_role(auth_state.user.username, role.name);
-		end):sort(function(a, b)
-			return (a.priority or 0) < (b.priority or 0)
-		end):map(function(role)
-			return { name = role.name; selected = role.name == default_role };
-		end);
-		if not roles[2] then
-			-- Only one role to choose from, might as well skip the selector
-			roles = nil;
-		end
-		return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes; roles = roles }, true);
+		local scopes, roles = split_scopes(requested_scopes);
+		roles = user_assumable_roles(auth_state.user.username, roles);
+		return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true);
 	elseif not auth_state.consent then
 		-- Notify client of rejection
-		return error_response(request, oauth_error("access_denied"));
+		if redirect_uri == device_uri then
+			local is_device, device_state = verify_device_token(params.state);
+			if is_device then
+				local device_code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code));
+				local code = codes:get("device_code:" .. params.client_id .. "#" .. device_code);
+				code.error = oauth_error("access_denied");
+				code.expires = os.time() + 60;
+				codes:set("device_code:" .. params.client_id .. "#" .. device_code, code);
+			end
+		end
+		return error_response(request, redirect_uri, oauth_error("access_denied"));
 	end
 	-- else auth_state.consent == true
 
-	params.scope = auth_state.scope;
+	local granted_scopes = auth_state.scopes
+	if client.scope then
+		local client_scopes = set.new(parse_scopes(client.scope));
+		granted_scopes:filter(function(scope)
+			return client_scopes:contains(scope);
+		end);
+	end
+
+	params.scope = granted_scopes:concat(" ");
 
 	local user_jid = jid.join(auth_state.user.username, module.host);
 	local client_secret = make_client_secret(params.client_id);
@@ -686,18 +923,135 @@
 		iss = get_issuer();
 		sub = url.build({ scheme = "xmpp"; path = user_jid });
 		aud = params.client_id;
+		auth_time = auth_state.user.auth_time;
 		nonce = params.nonce;
 	});
 	local response_type = params.response_type;
 	local response_handler = response_type_handlers[response_type];
 	if not response_handler then
-		return error_response(request, oauth_error("unsupported_response_type"));
+		return error_response(request, redirect_uri, oauth_error("unsupported_response_type"));
+	end
+	local ret = response_handler(client, params, user_jid, id_token);
+	if errors.is_err(ret) then
+		return error_response(request, redirect_uri, ret);
+	end
+	return ret;
+end
+
+local function handle_device_authorization_request(event)
+	local request = event.request;
+
+	local credentials = get_request_credentials(request);
+
+	local params = strict_formdecode(request.body);
+	if not params then
+		return render_error(oauth_error("invalid_request", "Invalid query parameters"));
+	end
+
+	if credentials and credentials.type == "basic" then
+		-- client_secret_basic converted internally to client_secret_post
+		params.client_id = http.urldecode(credentials.username);
+		local client_secret = http.urldecode(credentials.password);
+
+		if not verify_client_secret(params.client_id, client_secret) then
+			module:log("debug", "client_secret mismatch");
+			return oauth_error("invalid_client", "incorrect credentials");
+		end
+	else
+		return 401;
+	end
+
+	local client = check_client(params.client_id);
+
+	if not client then
+		return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter"));
+	end
+
+	if not set.new(client.grant_types):contains(device_uri) then
+		return render_error(oauth_error("invalid_client", "Client not registered for device authorization grant"));
+	end
+
+	local requested_scopes = parse_scopes(params.scope or "");
+	if client.scope then
+		local client_scopes = set.new(parse_scopes(client.scope));
+		requested_scopes:filter(function(scope)
+			return client_scopes:contains(scope);
+		end);
 	end
-	return response_handler(client, params, user_jid, id_token);
+
+	-- TODO better code generator, this one should be easy to type from a
+	-- screen onto a phone
+	local user_code = (id.tiny() .. "-" .. id.tiny()):upper();
+	local collisions = 0;
+	while codes:get("authorization_code:" .. device_uri .. "#" .. user_code) do
+		collisions = collisions + 1;
+		if collisions > 10 then
+			return oauth_error("temporarily_unavailable");
+		end
+		user_code = (id.tiny() .. "-" .. id.tiny()):upper();
+	end
+	-- device code should be derivable after consent but not guessable by the user
+	local device_code = b64url(hashes.hmac_sha256(verification_key, user_code));
+	local verification_uri = module:http_url() .. "/device";
+	local verification_uri_complete = verification_uri .. "?" .. http.formencode({ user_code = user_code });
+
+	local expires = os.time() + 600;
+	local dc_ok = codes:set("device_code:" .. params.client_id .. "#" .. device_code, { expires = expires });
+	local uc_ok = codes:set("user_code:" .. user_code,
+		{ user_code = user_code; expires = expires; client_id = params.client_id;
+    scope = requested_scopes:concat(" ") });
+	if not dc_ok or not uc_ok then
+		return oauth_error("temporarily_unavailable");
+	end
+
+	return {
+		headers = { content_type = "application/json"; cache_control = "no-store"; pragma = "no-cache" };
+		body = json.encode {
+			device_code = device_code;
+			user_code = user_code;
+			verification_uri = verification_uri;
+			verification_uri_complete = verification_uri_complete;
+			expires_in = 600;
+			interval = 5;
+		};
+	}
 end
 
+local function handle_device_verification_request(event)
+	local request = event.request;
+	local params = strict_formdecode(request.url.query);
+	if not params or not params.user_code then
+		return render_page(templates.device, { client = false });
+	end
+
+	local device_info = codes:get("user_code:" .. params.user_code);
+	if not device_info or code_expired(device_info) or not codes:set("user_code:" .. params.user_code, nil) then
+		return render_page(templates.device, {
+			client = false;
+			error = oauth_error("expired_token", "Incorrect or expired code");
+		});
+	end
+
+	return {
+		status_code = 303;
+		headers = {
+			location = module:http_url() .. "/authorize" .. "?" .. http.formencode({
+				client_id = device_info.client_id;
+				redirect_uri = device_uri;
+				response_type = "code";
+				scope = device_info.scope;
+				state = new_device_token({ user_code = params.user_code });
+			});
+		};
+	}
+end
+
+local strict_auth_revoke = module:get_option_boolean("oauth2_require_auth_revoke", false);
+
 local function handle_revocation_request(event)
 	local request, response = event.request, event.response;
+	response.headers.cache_control = "no-store";
+	response.headers.pragma = "no-cache";
 	if request.headers.authorization then
 		local credentials = get_request_credentials(request);
 		if not credentials or credentials.type ~= "basic" then
@@ -708,9 +1062,14 @@
 		if not verify_client_secret(credentials.username, credentials.password) then
 			return 401;
 		end
+		-- TODO check that it's their token I guess?
+	elseif strict_auth_revoke then
+		-- Why require auth to revoke a leaked token?
+		response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name);
+		return 401;
 	end
 
-	local form_data = http.formdecode(event.request.body or "");
+	local form_data = strict_formdecode(event.request.body);
 	if not form_data or not form_data.token then
 		response.headers.accept = "application/x-www-form-urlencoded";
 		return 415;
@@ -724,6 +1083,7 @@
 end
 
 local registration_schema = {
+	title = "OAuth 2.0 Dynamic Client Registration Protocol";
 	type = "object";
 	required = {
 		-- These are shown to users in the template
@@ -733,14 +1093,24 @@
 		"redirect_uris";
 	};
 	properties = {
-		redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } };
+		redirect_uris = {
+			title = "List of Redirect URIs";
+			type = "array";
+			minItems = 1;
+			uniqueItems = true;
+			items = { title = "Redirect URI"; type = "string"; format = "uri" };
+		};
 		token_endpoint_auth_method = {
+			title = "Token Endpoint Authentication Method";
 			type = "string";
 			enum = { "none"; "client_secret_post"; "client_secret_basic" };
 			default = "client_secret_basic";
 		};
 		grant_types = {
+			title = "Grant Types";
 			type = "array";
+			minItems = 1;
+			uniqueItems = true;
 			items = {
 				type = "string";
 				enum = {
@@ -751,35 +1121,111 @@
 					"refresh_token";
 					"urn:ietf:params:oauth:grant-type:jwt-bearer";
 					"urn:ietf:params:oauth:grant-type:saml2-bearer";
+					device_uri;
 				};
 			};
 			default = { "authorization_code" };
 		};
-		application_type = { type = "string"; enum = { "native"; "web" }; default = "web" };
-		response_types = { type = "array"; items = { type = "string"; enum = { "code"; "token" } }; default = { "code" } };
-		client_name = { type = "string" };
-		client_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		logo_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		scope = { type = "string" };
-		contacts = { type = "array"; items = { type = "string"; format = "email" } };
-		tos_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		policy_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		jwks_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		jwks = { type = "object"; description = "JSON Web Key Set, RFC 7517" };
-		software_id = { type = "string"; format = "uuid" };
-		software_version = { type = "string" };
-	};
-	luaPatternProperties = {
-		-- Localized versions of descriptive properties and URIs
-		["^client_name#"] = { description = "Localized version of 'client_name'"; type = "string" };
-		["^[a-z_]+_uri#"] = { type = "string"; format = "uri"; luaPattern = "^https:" };
+		application_type = {
+			title = "Application Type";
+			description = "Determines which kinds of redirect URIs the client may register. \z
+			The value 'web' limits the client to https:// URLs with the same hostname as in 'client_uri' \z
+			while the value 'native' allows either loopback http:// URLs or application specific URIs.";
+			type = "string";
+			enum = { "native"; "web" };
+			default = "web";
+		};
+		response_types = {
+			title = "Response Types";
+			type = "array";
+			minItems = 1;
+			uniqueItems = true;
+			items = { type = "string"; enum = { "code"; "token" } };
+			default = { "code" };
+		};
+		client_name = {
+			title = "Client Name";
+			description = "Human-readable name of the client, presented to the user in the consent dialog.";
+			type = "string";
+		};
+		client_uri = {
+			title = "Client URL";
+			description = "Should be an link to a page with information about the client.";
+			type = "string";
+			format = "uri";
+			pattern = "^https:";
+		};
+		logo_uri = {
+			title = "Logo URL";
+			description = "URL to the clients logotype (not currently used).";
+			type = "string";
+			format = "uri";
+			pattern = "^https:";
+		};
+		scope = {
+			title = "Scopes";
+			description = "Space-separated list of scopes the client promises to restrict itself to.";
+			type = "string";
+		};
+		contacts = {
+			title = "Contact Addresses";
+			description = "Addresses, typically email or URLs where the client developers can be contacted.";
+			type = "array";
+			minItems = 1;
+			items = { type = "string"; format = "email" };
+		};
+		tos_uri = {
+			title = "Terms of Service URL";
+			description = "Link to Terms of Service for the client, presented to the user in the consent dialog. \z
+			MUST be a https:// URL with hostname matching that of 'client_uri'.";
+			type = "string";
+			format = "uri";
+			pattern = "^https:";
+		};
+		policy_uri = {
+			title = "Privacy Policy URL";
+			description = "Link to a Privacy Policy for the client. MUST be a https:// URL with hostname matching that of 'client_uri'.";
+			type = "string";
+			format = "uri";
+			pattern = "^https:";
+		};
+		software_id = {
+			title = "Software ID";
+			description = "Unique identifier for the client software, common for all instances. Typically an UUID.";
+			type = "string";
+			format = "uuid";
+		};
+		software_version = {
+			title = "Software Version";
+			description = "Version of the client software being registered. \z
+			E.g. to allow revoking all related tokens in the event of a security incident.";
+			type = "string";
+			example = "2.3.1";
+		};
 	};
 }
 
+-- Limit per-locale fields to allowed locales, partly to keep size of client_id
+-- down, partly because we don't yet use them for anything.
+-- Only relevant for user-visible strings and URIs.
+if allowed_locales[1] then
+	local props = registration_schema.properties;
+	for _, locale in ipairs(allowed_locales) do
+		props["client_name#" .. locale] = props["client_name"];
+		props["client_uri#" .. locale] = props["client_uri"];
+		props["logo_uri#" .. locale] = props["logo_uri"];
+		props["tos_uri#" .. locale] = props["tos_uri"];
+		props["policy_uri#" .. locale] = props["policy_uri"];
+	end
+end
+
 local function redirect_uri_allowed(redirect_uri, client_uri, app_type)
 	local uri = url.parse(redirect_uri);
+	if not uri.scheme then
+		return false; -- no relative URLs
+	end
 	if app_type == "native" then
-		return uri.scheme == "http" and loopbacks:contains(uri.host) or uri.scheme ~= "https";
+		return uri.scheme == "http" and loopbacks:contains(uri.host) or redirect_uri == oob_uri or uri.scheme:find(".", 1, true) ~= nil;
 	elseif app_type == "web" then
 		return uri.scheme == "https" and uri.host == client_uri.host;
 	end
@@ -790,6 +1236,16 @@
 		return nil, oauth_error("invalid_request", "Failed schema validation.");
 	end
 
+	local client_uri = url.parse(client_metadata.client_uri);
+	if not client_uri or client_uri.scheme ~= "https" or loopbacks:contains(client_uri.host) then
+		return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri");
+	end
+
+	if not client_metadata.application_type and redirect_uri_allowed(client_metadata.redirect_uris[1], client_uri, "native") then
+		client_metadata.application_type = "native";
+		-- else defaults to "web"
+	end
+
 	-- Fill in default values
 	for propname, propspec in pairs(registration_schema.properties) do
 		if client_metadata[propname] == nil and type(propspec) == "table" and propspec.default ~= nil then
@@ -797,9 +1253,11 @@
 		end
 	end
 
-	local client_uri = url.parse(client_metadata.client_uri);
-	if not client_uri or client_uri.scheme ~= "https" or loopbacks:contains(client_uri.host) then
-		return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri");
+	-- MUST ignore any metadata that it does not understand
+	for propname in pairs(client_metadata) do
+		if not registration_schema.properties[propname] then
+			client_metadata[propname] = nil;
+		end
 	end
 
 	for _, redirect_uri in ipairs(client_metadata.redirect_uris) do
@@ -816,19 +1274,6 @@
 		end
 	end
 
-	for k, v in pairs(client_metadata) do
-		local base_k = k:match"^([^#]+)#" or k;
-		if not registration_schema.properties[base_k] or k:find"^client_uri#" then
-			-- Ignore and strip unknown extra properties
-			client_metadata[k] = nil;
-		elseif k:find"_uri#" then
-			-- Localized URIs should be secure too
-			if not redirect_uri_allowed(v, client_uri, "web") then
-				return nil, oauth_error("invalid_client_metadata", "Invalid, insecure or inappropriate informative URI");
-			end
-		end
-	end
-
 	local grant_types = set.new(client_metadata.grant_types);
 	local response_types = set.new(client_metadata.response_types);
 
@@ -844,18 +1289,21 @@
 		return nil, oauth_error("invalid_client_metadata", "No allowed 'response_types' specified");
 	end
 
-	-- Ensure each signed client_id JWT is unique, short ID and issued at
-	-- timestamp should be sufficient to rule out brute force attacks
-	client_metadata.nonce = id.short();
-
 	-- Do we want to keep everything?
-	local client_id = jwt_sign(client_metadata);
+	local client_id = sign_client(client_metadata);
 
 	client_metadata.client_id = client_id;
 	client_metadata.client_id_issued_at = os.time();
 
 	if client_metadata.token_endpoint_auth_method ~= "none" then
-		local client_secret = make_client_secret(client_id);
+		-- Ensure that each client_id JWT with a client_secret is unique.
+		-- A short ID along with the issued at timestamp should be sufficient to
+		-- rule out brute force attacks.
+		-- Not needed for public clients without a secret, but those are expected
+		-- to be uncommon since they can only do the insecure implicit flow.
+		client_metadata.nonce = id.short();
+
+		local client_secret = make_client_secret(client_id, client_metadata);
 		client_metadata.client_secret = client_secret;
 		client_metadata.client_secret_expires_at = 0;
 
@@ -879,7 +1327,11 @@
 
 	return {
 		status_code = 201;
-		headers = { content_type = "application/json" };
+		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
+			content_type = "application/json";
+		};
 		body = json.encode(response);
 	};
 end
@@ -888,6 +1340,8 @@
 	module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled")
 	handle_authorization_request = nil
 	handle_register_request = nil
+	handle_device_authorization_request = nil
+	handle_device_verification_request = nil
 end
 
 local function handle_userinfo_request(event)
@@ -941,6 +1395,7 @@
 
 module:depends("http");
 module:provides("http", {
+	cors = { enabled = true; credentials = true };
 	route = {
 		-- OAuth 2.0 in 5 simple steps!
 		-- This is the normal 'authorization_code' flow.
@@ -948,9 +1403,14 @@
 		-- Step 1. Create OAuth client
 		["POST /register"] = handle_register_request;
 
+		-- Device flow
+		["POST /device"] = handle_device_authorization_request;
+		["GET /device"] = handle_device_verification_request;
+
 		-- Step 2. User-facing login and consent view
 		["GET /authorize"] = handle_authorization_request;
 		["POST /authorize"] = handle_authorization_request;
+		["OPTIONS /authorize"] = { status_code = 403; body = "" };
 
 		-- Step 3. User is redirected to the 'redirect_uri' along with an
 		-- authorization code.  In the insecure 'implicit' flow, the access token
@@ -972,7 +1432,7 @@
 			headers = {
 				["Content-Type"] = "text/css";
 			};
-			body = _render_html(templates.css, module:get_option("oauth2_template_style"));
+			body = templates.css;
 		} or nil;
 		["GET /script.js"] = templates.js and {
 			headers = {
@@ -1002,37 +1462,51 @@
 
 -- OIDC Discovery
 
+function get_authorization_server_metadata()
+	if authorization_server_metadata then
+		return authorization_server_metadata;
+	end
+	authorization_server_metadata = {
+		-- RFC 8414: OAuth 2.0 Authorization Server Metadata
+		issuer = get_issuer();
+		authorization_endpoint = handle_authorization_request and module:http_url() .. "/authorize" or nil;
+		token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil;
+		registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil;
+		scopes_supported = usermanager.get_all_roles
+			and array(it.keys(usermanager.get_all_roles(module.host))):push("xmpp"):append(array(openid_claims:items()));
+		response_types_supported = array(it.keys(response_type_handlers));
+		token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" });
+		op_policy_uri = module:get_option_string("oauth2_policy_url", nil);
+		op_tos_uri = module:get_option_string("oauth2_terms_url", nil);
+		revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil;
+		revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" });
+		device_authorization_endpoint = handle_device_authorization_request and module:http_url() .. "/device";
+		code_challenge_methods_supported = array(it.keys(verifier_transforms));
+		grant_types_supported = array(it.keys(grant_type_handlers));
+		response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" });
+		authorization_response_iss_parameter_supported = true;
+		service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html");
+		ui_locales_supported = allowed_locales[1] and allowed_locales;
+
+		-- OpenID
+		userinfo_endpoint = handle_register_request and module:http_url() .. "/userinfo" or nil;
+		jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata
+		id_token_signing_alg_values_supported = { "HS256" }; -- The algorithm RS256 MUST be included, but we use HS256 and client_secret as shared key.
+	}
+	return authorization_server_metadata;
+end
+
 module:provides("http", {
 	name = "oauth2-discovery";
 	default_path = "/.well-known/oauth-authorization-server";
+	cors = { enabled = true };
 	route = {
-		["GET"] = {
-			headers = { content_type = "application/json" };
-			body = json.encode {
-				-- RFC 8414: OAuth 2.0 Authorization Server Metadata
-				issuer = get_issuer();
-				authorization_endpoint = handle_authorization_request and module:http_url() .. "/authorize" or nil;
-				token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil;
-				jwks_uri = nil; -- TODO?
-				registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil;
-				scopes_supported = usermanager.get_all_roles and array(it.keys(usermanager.get_all_roles(module.host))):append(array(openid_claims:items()));
-				response_types_supported = array(it.keys(response_type_handlers));
-				token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" });
-				op_policy_uri = module:get_option_string("oauth2_policy_url", nil);
-				op_tos_uri = module:get_option_string("oauth2_terms_url", nil);
-				revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil;
-				revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" });
-				code_challenge_methods_supported = array(it.keys(verifier_transforms));
-				grant_types_supported = array(it.keys(response_type_handlers)):map(tmap { token = "implicit"; code = "authorization_code" });
-				response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" });
-				authorization_response_iss_parameter_supported = true;
-				service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html");
-
-				-- OpenID
-				userinfo_endpoint = handle_register_request and module:http_url() .. "/userinfo" or nil;
-				id_token_signing_alg_values_supported = { "HS256" };
-			};
-		};
+		["GET"] = function()
+			return {
+				headers = { content_type = "application/json" };
+				body = json.encode(get_authorization_server_metadata());
+			}
+		end
 	};
 });