changeset 5193:2bb29ece216b

mod_http_oauth2: Implement stateless dynamic client registration Replaces previous explicit registration that required either the additional module mod_adhoc_oauth2_client or manually editing the database. That method was enough to have something to test with, but would not probably not scale easily. Dynamic client registration allows creating clients on the fly, which may be even easier in theory. In order to not allow basically unauthenticated writes to the database, we implement a stateless model here. per_host_key := HMAC(config -> oauth2_registration_key, hostname) client_id := JWT { client metadata } signed with per_host_key client_secret := HMAC(per_host_key, client_id) This should ensure everything we need to know is part of the client_id, allowing redirects etc to be validated, and the client_secret can be validated with only the client_id and the per_host_key. A nonce injected into the client_id JWT should ensure nobody can submit the same client metadata and retrieve the same client_secret
author Kim Alvefur <zash@zash.se>
date Fri, 03 Mar 2023 21:14:19 +0100
parents 03aa9baa9ac3
children 25041e15994e
files mod_http_oauth2/mod_http_oauth2.lua
diffstat 1 files changed, 114 insertions(+), 28 deletions(-) [+]
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua	Fri Mar 03 19:21:38 2023 +0000
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Fri Mar 03 21:14:19 2023 +0100
@@ -9,10 +9,24 @@
 local uuid = require "util.uuid";
 local encodings = require "util.encodings";
 local base64 = encodings.base64;
+local schema = require "util.jsonschema";
+local jwt = require"util.jwt";
 
 local tokens = module:depends("tokenauth");
 
-local clients = module:open_store("oauth2_clients", "map");
+-- Used to derive client_secret from client_id, set to enable stateless dynamic registration.
+local registration_key = module:get_option_string("oauth2_registration_key");
+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 jwt_sign, jwt_verify;
+if not registration_key then
+	module:log("error", "Missing required 'oauth2_registration_key', generate a strong key and configure it")
+else
+	-- Tie it to the host if global
+	registration_key = hashes.hmac_sha256(registration_key, module.host);
+	jwt_sign, jwt_verify = jwt.init(registration_algo, registration_key, registration_key, registration_options);
+end
 
 local function filter_scopes(username, host, requested_scope_string)
 	if host ~= module.host then
@@ -72,6 +86,14 @@
 	};
 end
 
+local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string
+	for _, redirect_uri in ipairs(client.redirect_uris) do
+		if query_redirect_uri == nil or query_redirect_uri == redirect_uri then
+			return redirect_uri
+		end
+	end
+end
+
 local grant_type_handlers = {};
 local response_type_handlers = {};
 
@@ -97,13 +119,9 @@
 function response_type_handlers.code(params, granted_jid)
 	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
 
-	local client_owner, client_host, client_id = jid.prepped_split(params.client_id);
-	if client_host ~= module.host then
-		return oauth_error("invalid_client", "incorrect credentials");
-	end
-	local client, err = clients:get(client_owner, client_id);
-	if err then error(err); end
-	if not client then
+	local ok, client = jwt_verify(params.client_id);
+
+	if not ok then
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
 
@@ -120,7 +138,7 @@
 		return {status_code = 429};
 	end
 
-	local redirect_uri = params.redirect_uri or client.redirect_uri;
+	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
 		local response = { status_code = 200; headers = { content_type = "text/plain" } }
@@ -156,20 +174,17 @@
 function response_type_handlers.token(params, granted_jid)
 	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
 
-	local client_owner, client_host, client_id = jid.prepped_split(params.client_id);
-	if client_host ~= module.host then
-		return oauth_error("invalid_client", "incorrect credentials");
-	end
-	local client, err = clients:get(client_owner, client_id);
-	if err then error(err); end
+	local client = jwt_verify(params.client_id);
+
 	if not client then
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
 
-	local granted_scopes = filter_scopes(client_owner, client_host, params.scope);
+	local request_username, request_host = jid.split(granted_jid);
+	local granted_scopes = filter_scopes(request_username, request_host, params.scope);
 	local token_info = new_access_token(granted_jid, granted_scopes, nil);
 
-	local redirect = url.parse(client.redirect_uri);
+	local redirect = url.parse(get_redirect_uri(client, params.redirect_uri));
 	token_info.state = params.state;
 	redirect.fragment = http.formencode(token_info);
 
@@ -181,10 +196,12 @@
 	}
 end
 
-local pepper = module:get_option_string("oauth2_client_pepper", "");
+local function make_secret(client_id) --> client_secret
+	return hashes.hmac_sha256(registration_key, client_id, true);
+end
 
-local function verify_secret(stored, salt, i, secret)
-	return base64.decode(stored) == hashes.pbkdf2_hmac_sha256(secret, salt .. pepper, i);
+local function verify_secret(client_id, client_secret)
+	return hashes.equals(make_secret(client_id), client_secret);
 end
 
 function grant_type_handlers.authorization_code(params)
@@ -195,14 +212,12 @@
 		return oauth_error("invalid_scope", "unknown scope requested");
 	end
 
-	local client_owner, client_host, client_id = jid.prepped_split(params.client_id);
-	if client_host ~= module.host then
-		module:log("debug", "%q ~= %q", client_host, module.host);
+	local client = jwt_verify(params.client_id);
+	if not client then
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
-	local client, err = clients:get(client_owner, client_id);
-	if err then error(err); end
-	if not client or not verify_secret(client.secret_hash, client.salt, client.iteration_count, params.client_secret) then
+
+	if not verify_secret(params.client_id, params.client_secret) then
 		module:log("debug", "client_secret mismatch");
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
@@ -212,7 +227,6 @@
 		module:log("debug", "authorization_code invalid or expired: %q", code);
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
-	assert(codes:set(client_id .. "#" .. params.code, nil));
 
 	return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil));
 end
@@ -352,12 +366,84 @@
 	return 200;
 end
 
+local registration_schema = {
+	type = "object";
+	required = { "client_name"; "redirect_uris" };
+	properties = {
+		redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } };
+		token_endpoint_auth_method = { enum = { "none"; "client_secret_post"; "client_secret_basic" }; type = "string" };
+		grant_types = {
+			items = {
+				enum = {
+					"authorization_code";
+					"implicit";
+					"password";
+					"client_credentials";
+					"refresh_token";
+					"urn:ietf:params:oauth:grant-type:jwt-bearer";
+					"urn:ietf:params:oauth:grant-type:saml2-bearer";
+				};
+				type = "string";
+			};
+			type = "array";
+		};
+		response_types = { items = { enum = { "code"; "token" }; type = "string" }; type = "array" };
+		client_name = { type = "string" };
+		client_uri = { type = "string"; format = "uri" };
+		logo_uri = { type = "string"; format = "uri" };
+		scope = { type = "string" };
+		contacts = { items = { type = "string" }; type = "array" };
+		tos_uri = { type = "string" };
+		policy_uri = { type = "string"; format = "uri" };
+		jwks_uri = { type = "string"; format = "uri" };
+		jwks = { type = "object"; description = "JSON Web Key Set, RFC 7517" };
+		software_id = { type = "string"; format = "uuid" };
+		software_version = { type = "string" };
+	};
+}
+
+local function handle_register_request(event)
+	local request = event.request;
+	local client_metadata = json.decode(request.body);
+
+	if not schema.validate(registration_schema, client_metadata) then
+		return oauth_error("invalid_request", "Failed schema validation.");
+	end
+
+	-- Ensure each signed client_id JWT is unique
+	client_metadata.nonce = uuid.generate();
+
+	-- Do we want to keep everything?
+	local client_id = jwt_sign(client_metadata);
+	local client_secret = make_secret(client_id);
+
+	local client_desc = {
+		client_id = client_id;
+		client_secret = client_secret;
+		client_id_issued_at = os.time();
+		client_secret_expires_at = 0;
+	}
+
+	return {
+		status_code = 201;
+		headers = { content_type = "application/json" };
+		body = json.encode(client_desc);
+	};
+end
+
+if not registration_key then
+	module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled")
+	handle_authorization_request = nil
+	handle_register_request = nil
+end
+
 module:depends("http");
 module:provides("http", {
 	route = {
 		["POST /token"] = handle_token_grant;
 		["GET /authorize"] = handle_authorization_request;
 		["POST /revoke"] = handle_revocation_request;
+		["POST /register"] = handle_register_request;
 	};
 });
 
@@ -386,7 +472,7 @@
 				authorization_endpoint = module:http_url() .. "/authorize";
 				token_endpoint = module:http_url() .. "/token";
 				jwks_uri = nil; -- TODO?
-				registration_endpoint = nil; -- TODO
+				registration_endpoint = module:http_url() .. "/register";
 				scopes_supported = { "prosody:restricted"; "prosody:user"; "prosody:admin"; "prosody:operator" };
 				response_types_supported = { "code"; "token" };
 				authorization_response_iss_parameter_supported = true;