changeset 5383:df11a2cbc7b7

mod_http_oauth2: Implement RFC 7628 Proof Key for Code Exchange Likely to become mandatory in OAuth 2.1. Backwards compatible since the default 'plain' verifier would compare nil with nil if the relevant parameters are left out.
author Kim Alvefur <zash@zash.se>
date Sat, 29 Apr 2023 13:09:46 +0200
parents 12498c0d705f
children b40f29ec391a
files mod_http_oauth2/README.markdown mod_http_oauth2/mod_http_oauth2.lua
diffstat 2 files changed, 42 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- a/mod_http_oauth2/README.markdown	Sat Apr 29 11:26:04 2023 +0200
+++ b/mod_http_oauth2/README.markdown	Sat Apr 29 13:09:46 2023 +0200
@@ -46,6 +46,7 @@
 
 - [RFC 6749: The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749)
 - [RFC 7628: A Set of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth](https://www.rfc-editor.org/rfc/rfc7628)
+- [RFC 7636: Proof Key for Code Exchange by OAuth Public Clients](https://www.rfc-editor.org/rfc/rfc7636)
 - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
 - [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) & [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html)
 - [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
@@ -121,6 +122,13 @@
 }
 ```
 
+The [Proof Key for Code Exchange][RFC 7636] mitigation method can be
+made required:
+
+```lua
+oauth2_require_code_challenge = true
+```
+
 ## Deployment notes
 
 ### Access management
--- a/mod_http_oauth2/mod_http_oauth2.lua	Sat Apr 29 11:26:04 2023 +0200
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Sat Apr 29 13:09:46 2023 +0200
@@ -17,6 +17,10 @@
 local array = require "util.array";
 local st = require "util.stanza";
 
+local function b64url(s)
+	return (s:gsub("[+/=]", { ["+"] = "-", ["/"] = "_", ["="] = "" }))
+end
+
 local function read_file(base_path, fn, required)
 	local f, err = io.open(base_path .. "/" .. fn);
 	if not f then
@@ -69,6 +73,8 @@
 local registration_algo = module:get_option_string("oauth2_registration_algorithm", "HS256");
 local registration_options = module:get_option("oauth2_registration_options", { default_ttl = 60 * 60 * 24 * 90 });
 
+local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", false);
+
 local verification_key;
 local jwt_sign, jwt_verify;
 if registration_key then
@@ -211,6 +217,7 @@
 
 local grant_type_handlers = {};
 local response_type_handlers = {};
+local verifier_transforms = {};
 
 function grant_type_handlers.password(params)
 	local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)"));
@@ -236,12 +243,18 @@
 	end
 	local granted_scopes, granted_role = filter_scopes(request_username, params.scope);
 
+	if pkce_required and not params.code_challenge then
+		return oauth_error("invalid_request", "PKCE required");
+	end
+
 	local code = id.medium();
 	local ok = codes:set(params.client_id .. "#" .. code, {
 		expires = os.time() + 600;
 		granted_jid = granted_jid;
 		granted_scopes = granted_scopes;
 		granted_role = granted_role;
+		challenge = params.code_challenge;
+		challenge_method = params.code_challenge_method;
 		id_token = id_token;
 	});
 	if not ok then
@@ -340,6 +353,14 @@
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
 
+	-- TODO Decide if the code should be removed or not when PKCE fails
+	local transform = verifier_transforms[code.challenge_method or "plain"];
+	if not transform then
+		return oauth_error("invalid_request", "unknown challenge transform method");
+	elseif transform(params.code_verifier) ~= code.challenge then
+		return oauth_error("invalid_grant", "incorrect credentials");
+	end
+
 	return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token));
 end
 
@@ -371,6 +392,18 @@
 	));
 end
 
+-- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients
+
+function verifier_transforms.plain(code_verifier)
+	-- code_challenge = code_verifier
+	return code_verifier;
+end
+
+function verifier_transforms.S256(code_verifier)
+	-- code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
+	return code_verifier and b64url(hashes.SHA256(code_verifier));
+end
+
 -- Used to issue/verify short-lived tokens for the authorization process below
 local new_user_token, verify_user_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 });
 
@@ -903,6 +936,7 @@
 				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));
+				code_challenge_methods_supported = array(it.keys(verifier_transforms));
 				authorization_response_iss_parameter_supported = true;
 
 				-- OpenID