mod_http_oauth2: Implement RFC 7628 Proof Key for Code Exchange
authorKim Alvefur <zash@zash.se>
Sat, 29 Apr 2023 13:09:46 +0200
changeset 5387 df11a2cbc7b7
parent 5386 12498c0d705f
child 5388 b40f29ec391a
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.
mod_http_oauth2/README.markdown
mod_http_oauth2/mod_http_oauth2.lua
--- 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