Diff

mod_http_oauth2/mod_http_oauth2.lua @ 5593:04f36a470dca

Update from upstream
author Trần H. Trung <xmpp:trần.h.trung@trung.fun>
date Sun, 09 Jul 2023 01:31:29 +0700
parent 5580:feadbd481285
child 5596:7040d0772758
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -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,14 @@
 	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);
 	error = read_file(template_path, "error.html", true);
 	css = read_file(template_path, "style.css");
 	js = read_file(template_path, "script.js");
@@ -52,7 +75,9 @@
 
 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;
@@ -60,16 +85,19 @@
 		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);
@@ -92,6 +120,21 @@
 	sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options);
 end
 
+-- 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
@@ -103,7 +146,16 @@
 	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)
@@ -196,7 +248,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)
@@ -328,24 +386,14 @@
 
 	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
 	if redirect_uri == oob_uri 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;
+		return render_page(templates.oob, { client = client; authorization_code = code }, true);
 	elseif not redirect_uri then
 		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() });
@@ -357,6 +405,8 @@
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = url.build(redirect);
 		};
 	}
@@ -379,6 +429,8 @@
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = url.build(redirect);
 		};
 	}
@@ -401,8 +453,8 @@
 		return oauth_error("invalid_scope", "unknown scope requested");
 	end
 
-	local client_ok, client = verify_client(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
 
@@ -414,6 +466,7 @@
 	if err then error(err); end
 	-- MUST NOT use the authorization code more than once, so remove it to
 	-- prevent a second attempted use
+	-- TODO if a second attempt *is* made, revoke any tokens issued
 	codes:set(params.client_id .. "#" .. params.code, nil);
 	if not code or type(code) ~= "table" or code_expired(code) then
 		module:log("debug", "authorization_code invalid or expired: %q", code);
@@ -436,8 +489,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 = verify_client(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
 
@@ -451,6 +504,13 @@
 		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
@@ -514,7 +574,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
@@ -607,7 +667,7 @@
 	if not redirect_uri or redirect_uri == oob_uri then
 		return render_error(err);
 	end
-	local q = request.url.query and http.formdecode(request.url.query);
+	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
@@ -617,12 +677,18 @@
 	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";
+})
 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);
@@ -657,7 +723,9 @@
 	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 oauth_error("invalid_request");
 	end
@@ -683,7 +751,7 @@
 	if not request.url.query then
 		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 render_error(oauth_error("invalid_request", "Invalid query parameters"));
 	end
@@ -692,9 +760,9 @@
 		return render_error(oauth_error("invalid_request", "Missing 'client_id' parameter"));
 	end
 
-	local ok, client = verify_client(params.client_id);
+	local client = check_client(params.client_id);
 
-	if not ok then
+	if not client then
 		return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter"));
 	end
 
@@ -718,13 +786,31 @@
 		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") 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
 		local extra = {};
 		if params.login_hint then
 			extra.username_hint = (jid.prepped_split(params.login_hint));
-			extra.no_username_hint = not extra.username_hint;
 		end
 		return render_page(templates.login, { state = auth_state; client = client; extra = extra });
 	elseif auth_state.consent == nil then
@@ -755,6 +841,7 @@
 		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;
@@ -771,6 +858,8 @@
 
 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
@@ -783,7 +872,7 @@
 		end
 	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;
@@ -839,24 +928,31 @@
 			default = { "code" };
 		};
 		client_name = { type = "string" };
-		client_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		logo_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
+		client_uri = { type = "string"; format = "uri"; pattern = "^https:" };
+		logo_uri = { type = "string"; format = "uri"; pattern = "^https:" };
 		scope = { type = "string" };
 		contacts = { type = "array"; minItems = 1; items = { type = "string"; format = "email" } };
-		tos_uri = { type = "string"; format = "uri"; 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" };
+		tos_uri = { type = "string"; format = "uri"; pattern = "^https:" };
+		policy_uri = { type = "string"; format = "uri"; pattern = "^https:" };
 		software_id = { type = "string"; format = "uuid" };
 		software_version = { type = "string" };
 	};
-	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:" };
-	};
 }
 
+-- 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
@@ -881,6 +977,13 @@
 		end
 	end
 
+	-- 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
+
 	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");
@@ -900,19 +1003,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);
 
@@ -928,10 +1018,6 @@
 		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 = sign_client(client_metadata);
 
@@ -939,7 +1025,14 @@
 	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;
 
@@ -963,7 +1056,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
@@ -1036,6 +1133,7 @@
 		-- 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
@@ -1057,7 +1155,7 @@
 			headers = {
 				["Content-Type"] = "text/css";
 			};
-			body = _render_html(templates.css, module:get_option("oauth2_template_style"));
+			body = render_html(templates.css, module:get_option("oauth2_template_style"));
 		} or nil;
 		["GET /script.js"] = templates.js and {
 			headers = {
@@ -1087,39 +1185,53 @@
 
 -- 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" });
+		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");
+		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;
-				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" });
-				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;
-				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.
-			};
-		};
+		["GET"] = function()
+			return {
+				headers = { content_type = "application/json" };
+				body = json.encode(get_authorization_server_metadata());
+			}
+		end
 	};
 });