Diff

mod_http_oauth2/mod_http_oauth2.lua @ 6309:342f88e8d522 draft

Merge update
author Trần H. Trung <xmpp:trần.h.trung@trung.fun>
date Sun, 15 Jun 2025 01:08:46 +0700
parent 6273:8ceedc336d0d
parent 6308:e1c54de06905
child 6344:eb834f754f57
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua	Sun Jun 01 21:32:49 2025 +0700
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Sun Jun 15 01:08:46 2025 +0700
@@ -410,7 +410,10 @@
 	local request_username
 
 	if expect_username_jid then
-		local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)"));
+		local request_jid = params.username;
+		if not request_jid then
+			return oauth_error("invalid_request", "missing 'username' (JID)");
+		end
 		local _request_username, request_host = jid.prepped_split(request_jid);
 
 		if not (_request_username and request_host) or request_host ~= module.host then
@@ -419,15 +422,36 @@
 
 		request_username = _request_username
 	else
-		request_username = assert(params.username, oauth_error("invalid_request", "missing 'username'"));
+		request_username = params.username;
+		if not request_username then
+			return oauth_error("invalid_request", "missing 'username'");
+		end
+	end
+
+	local request_password = params.password;
+	if not request_password then
+		return oauth_error("invalid_request", "missing 'password'");
 	end
 
-	local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'"));
+	local auth_event = {
+		session = {
+			type = "oauth2";
+			ip = "::";
+			username = request_username;
+			host = module.host;
+			log = module._log;
+			sasl_handler = { username = request_username; selected = "x-oauth2-password" };
+			client_id = client.client_name;
+		};
+	};
 
 	if not usermanager.test_password(request_username, module.host, request_password) then
+		module:fire_event("authentication-failure", auth_event);
 		return oauth_error("invalid_grant", "incorrect credentials");
 	end
 
+	module:fire_event("authentication-success", auth_event);
+
 	local granted_jid = jid.join(request_username, module.host);
 	local granted_scopes, granted_role = filter_scopes(request_username, params.scope);
 	return json.encode(new_access_token(granted_jid, granted_role, granted_scopes, client));
@@ -552,21 +576,9 @@
 	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
+function grant_type_handlers.refresh_token(params, client)
 	if not params.refresh_token then return oauth_error("invalid_request", "missing 'refresh_token'"); 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 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");
@@ -598,21 +610,9 @@
 	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
+grant_type_handlers[device_uri] = function(params, client)
 	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");
@@ -747,8 +747,14 @@
 	local component_secret = assert(module:get_option_string("component_secret"), "'component_secret' is a required setting when loaded on a Component");
 
 	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'"));
+		local request_jid = params.username;
+		if not request_jid then
+			return oauth_error("invalid_request", "missing 'username' (JID)");
+		end
+		local request_password = params.password
+		if not request_password then
+			return oauth_error("invalid_request", "missing 'password'");
+		end
 		local request_username, request_host, request_resource = jid.prepped_split(request_jid);
 		if params.scope then
 			-- TODO shouldn't we support scopes / roles here?
@@ -781,15 +787,28 @@
 -- the redirect_uri is missing or invalid. In those cases, we render an
 -- error directly to the user-agent.
 local function error_response(request, redirect_uri, err)
-	if not redirect_uri or redirect_uri == oob_uri or redirect_uri == device_uri then
+	if not redirect_uri or redirect_uri == oob_uri then
 		return render_error(err);
 	end
-	local q = strict_formdecode(request.url.query);
+	local params = strict_formdecode(request.url.query);
+	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);
+			if type(code) == "table" then
+				code.error = err;
+				code.expires = os.time() + 60;
+				codes:set("device_code:" .. params.client_id .. "#" .. device_code, code);
+			end
+		end
+		return render_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() });
+		.. "&" .. http.formencode({ state = params.state, iss = get_issuer() });
 	module:log("debug", "Sending error response to client via redirect to %s", redirect_uri);
 	return {
 		status_code = 303;
@@ -842,6 +861,15 @@
 	end
 end
 
+local function array_contains(haystack, needle)
+	for _, item in ipairs(haystack) do
+		if item == needle then
+			return true
+		end
+	end
+	return false
+end
+
 function handle_token_grant(event)
 	local credentials = get_request_credentials(event.request);
 
@@ -874,10 +902,15 @@
 
 
 	local grant_type = params.grant_type
+	if not array_contains(client.grant_types or { "authorization_code" }, grant_type) then
+		return oauth_error("invalid_request", "'grant_type' not registered");
+	end
+
 	local grant_handler = grant_type_handlers[grant_type];
 	if not grant_handler then
-		return oauth_error("invalid_request", "No such grant type.");
+		return oauth_error("invalid_request", "'grant_type' not available");
 	end
+
 	return grant_handler(params, client);
 end
 
@@ -909,10 +942,16 @@
 	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 error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not allowed"));
+	local response_type = params.response_type;
+	if not array_contains(client.response_types or { "code" }, response_type) then
+		return error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not registered"));
+	end
+	if not allowed_response_type_handlers:contains(response_type) then
+		return error_response(request, redirect_uri, oauth_error("unsupported_response_type", "'response_type' not allowed"));
+	end
+	local response_handler = response_type_handlers[response_type];
+	if not response_handler then
+		return error_response(request, redirect_uri, oauth_error("unsupported_response_type"));
 	end
 
 	local requested_scopes = parse_scopes(params.scope or "");
@@ -976,16 +1015,6 @@
 		end
 	elseif not auth_state.consent then
 		-- Notify client of rejection
-		if redirect_uri == device_uri then
-			local is_device, device_state = verify_device_token(params.state);
-			if is_device then
-				local device_code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code));
-				local code = codes:get("device_code:" .. params.client_id .. "#" .. device_code);
-				code.error = oauth_error("access_denied");
-				code.expires = os.time() + 60;
-				codes:set("device_code:" .. params.client_id .. "#" .. device_code, code);
-			end
-		end
 		return error_response(request, redirect_uri, oauth_error("access_denied"));
 	end
 	-- else auth_state.consent == true
@@ -1009,13 +1038,8 @@
 		aud = params.client_id;
 		auth_time = auth_state.user.iat;
 		nonce = params.nonce;
-		amr = auth_state.user.amr;
+		amr = auth_state.user.amr; -- RFC 8176: Authentication Method Reference Values
 	});
-	local response_type = params.response_type;
-	local response_handler = response_type_handlers[response_type];
-	if not response_handler then
-		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);
@@ -1307,7 +1331,6 @@
 		response_types = {
 			title = "Response Types";
 			type = "array";
-			minItems = 1;
 			uniqueItems = true;
 			items = { type = "string"; enum = { "code"; "token" } };
 			default = { "code" };
@@ -1471,18 +1494,18 @@
 	local grant_types = set.new(client_metadata.grant_types);
 	local response_types = set.new(client_metadata.response_types);
 
+	if not (grant_types - allowed_grant_type_handlers):empty() then
+		return nil, oauth_error("invalid_client_metadata", "Disallowed 'grant_types' specified");
+	elseif not (response_types - allowed_response_type_handlers):empty() then
+		return nil, oauth_error("invalid_client_metadata", "Disallowed 'response_types' specified");
+	end
+
 	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
-
 	if client_metadata.token_endpoint_auth_method ~= "none" then
 		-- 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
@@ -1669,28 +1692,35 @@
 		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; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata
 		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" });
+		response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" });
+		grant_types_supported = array(it.keys(grant_type_handlers));
+		token_endpoint_auth_methods_supported = array({ "client_secret_basic"; "client_secret_post"; "none" });
+		token_endpoint_auth_signing_alg_values_supported = nil;
+		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;
 		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";
+		revocation_endpoint_auth_methods_supported = array({ "client_secret_basic"; "client_secret_post"; "none" });
+		revocation_endpoint_auth_signing_alg_values_supported = nil;
 		introspection_endpoint = handle_introspection_request and module:http_url() .. "/introspect";
 		introspection_endpoint_auth_methods_supported = nil;
+		introspection_endpoint_auth_signing_alg_values_supported = nil;
 		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" });
+
+		-- RFC 8628: OAuth 2.0 Device Authorization Grant
+		device_authorization_endpoint = handle_device_authorization_request and module:http_url() .. "/device";
+
+		-- RFC 9207: OAuth 2.0 Authorization Server Issuer Identification
 		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
+		-- OpenID Connect Discovery 1.0
 		userinfo_endpoint = handle_userinfo_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;