changeset 5589:7040d0772758

mod_http_oauth2: Implement RFC 8628 Device Authorization Grant Meant for devices without easy access to a web browser, such as refrigerators and toasters, which definitely need to be running OAuth-enabled XMPP clients! Could be used for CLI tools that might have trouble running a http server needed for the authorization code flow.
author Kim Alvefur <zash@zash.se>
date Mon, 10 Jul 2023 07:16:54 +0200
parents 59acf7f540c1
children b681948a01f1
files mod_http_oauth2/README.markdown mod_http_oauth2/html/device.html mod_http_oauth2/mod_http_oauth2.lua
diffstat 3 files changed, 211 insertions(+), 6 deletions(-) [+]
line wrap: on
line diff
--- a/mod_http_oauth2/README.markdown	Fri Jul 07 19:45:48 2023 +0200
+++ b/mod_http_oauth2/README.markdown	Mon Jul 10 07:16:54 2023 +0200
@@ -51,6 +51,7 @@
 - [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html)
 - [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)
+- [RFC 8628: OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628)
 - [RFC 9207: OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html)
 - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
 - [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) (_partial, e.g. missing JWKS_)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/device.html	Mon Jul 10 07:16:54 2023 +0200
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>{site_name} - Authorize{client&d} Device</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+	<main>
+	<h1>{site_name}</h1>
+	<fieldset>
+	<legend>Device Authorization</legend>
+{client&
+	<p>Authorization completed. You can go back to
+	<em>{client.client_name}</em>.</p>}
+{client~
+	<p>Enter the code to continue.</p>
+	<form method="get">
+		<input type="text" name="user_code" placeholder="XXXX-XXXX" aria-label="user-code" required >
+		<input type="submit" value="Continue">
+	</form>}
+	</fieldset>
+	</main>
+</body>
+</html>
+
--- a/mod_http_oauth2/mod_http_oauth2.lua	Fri Jul 07 19:45:48 2023 +0200
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Mon Jul 10 07:16:54 2023 +0200
@@ -68,6 +68,7 @@
 	login = read_file(template_path, "login.html", true);
 	consent = read_file(template_path, "consent.html", true);
 	oob = read_file(template_path, "oob.html", true);
+	device = read_file(template_path, "device.html", true);
 	error = read_file(template_path, "error.html", true);
 	css = read_file(template_path, "style.css");
 	js = read_file(template_path, "script.js");
@@ -120,6 +121,8 @@
 	sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options);
 end
 
+local new_device_token, verify_device_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 });
+
 -- verify and prepare client structure
 local function check_client(client_id)
 	if not verify_client then
@@ -231,6 +234,7 @@
 -- code to the user for them to copy-paste into the client, which can then
 -- continue as if it received it via redirect.
 local oob_uri = "urn:ietf:wg:oauth:2.0:oob";
+local device_uri = "urn:ietf:params:oauth:grant-type:device_code";
 
 local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" });
 
@@ -317,6 +321,15 @@
 		-- When only a single URI is registered, that's the default
 		return client.redirect_uris[1];
 	end
+	if query_redirect_uri == device_uri and client.grant_types then
+		for _, grant_type in ipairs(client.grant_types) do
+			if grant_type == device_uri then
+				return query_redirect_uri;
+			end
+		end
+		-- Tried to use device authorization flow without registering it.
+		return;
+	end
 	-- Verify the client-provided URI matches one previously registered
 	for _, redirect_uri in ipairs(client.redirect_uris) do
 		if query_redirect_uri == redirect_uri then
@@ -342,6 +355,13 @@
 local response_type_handlers = {};
 local verifier_transforms = {};
 
+function grant_type_handlers.implicit()
+	-- Placeholder to make discovery work correctly.
+	-- Access tokens are delivered via redirect when using the implict flow, not
+	-- via the token endpoint, so how did you get here?
+	return oauth_error("invalid_request");
+end
+
 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'"));
@@ -371,6 +391,13 @@
 	end
 
 	local code = id.medium();
+	if params.redirect_uri == device_uri then
+		local is_device, device_state = verify_device_token(params.state);
+		if is_device then
+			-- reconstruct the device_code
+			code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code));
+		end
+	end
 	local ok = codes:set(params.client_id .. "#" .. code, {
 		expires = os.time() + 600;
 		granted_jid = granted_jid;
@@ -387,6 +414,8 @@
 	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
 	if redirect_uri == oob_uri then
 		return render_page(templates.oob, { client = client; authorization_code = code }, true);
+	elseif redirect_uri == device_uri then
+		return render_page(templates.device, { client = client }, true);
 	elseif not redirect_uri then
 		return oauth_error("invalid_redirect_uri");
 	end
@@ -530,6 +559,34 @@
 	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
+	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(params.client_id .. "#" .. params.device_code);
+	if type(code) ~= "table" or code_expired(code) then
+		return oauth_error("expired_token");
+	elseif code.error then
+		return code.error;
+	elseif not code.granted_jid then
+		return oauth_error("authorization_pending");
+	end
+	codes:set(client.client_hash .. "#" .. params.device_code, nil);
+
+	return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token));
+end
+
 -- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients
 
 function verifier_transforms.plain(code_verifier)
@@ -688,6 +745,7 @@
 	"authorization_code";
 	"password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used.
 	"refresh_token";
+	device_uri;
 })
 for handler_type in pairs(grant_type_handlers) do
 	if not allowed_grant_type_handlers:contains(handler_type) then
@@ -727,7 +785,7 @@
 	event.response.headers.pragma = "no-cache";
 	local params = strict_formdecode(event.request.body);
 	if not params then
-		return oauth_error("invalid_request");
+		return oauth_error("invalid_request", "Could not parse request body as 'application/x-www-form-urlencoded'");
 	end
 
 	if credentials and credentials.type == "basic" then
@@ -739,7 +797,7 @@
 	local grant_type = params.grant_type
 	local grant_handler = grant_type_handlers[grant_type];
 	if not grant_handler then
-		return oauth_error("invalid_request");
+		return oauth_error("invalid_request", "No such grant type.");
 	end
 	return grant_handler(params);
 end
@@ -820,6 +878,16 @@
 		return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true);
 	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(params.client_id .. "#" .. device_code);
+				code.error = oauth_error("access_denied");
+				code.expires = os.time() + 60;
+				codes:set(params.client_id .. "#" .. device_code, code);
+			end
+		end
 		return error_response(request, redirect_uri, oauth_error("access_denied"));
 	end
 	-- else auth_state.consent == true
@@ -856,6 +924,110 @@
 	return ret;
 end
 
+local function handle_device_authorization_request(event)
+	local request = event.request;
+
+	local credentials = get_request_credentials(request);
+
+	local params = strict_formdecode(request.body);
+	if not params then
+		return render_error(oauth_error("invalid_request", "Invalid query parameters"));
+	end
+
+	if credentials and credentials.type == "basic" then
+		-- client_secret_basic converted internally to client_secret_post
+		params.client_id = http.urldecode(credentials.username);
+		local client_secret = http.urldecode(credentials.password);
+
+		if not verify_client_secret(params.client_id, client_secret) then
+			module:log("debug", "client_secret mismatch");
+			return oauth_error("invalid_client", "incorrect credentials");
+		end
+	else
+		return 401;
+	end
+
+	local client = check_client(params.client_id);
+
+	if not client then
+		return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter"));
+	end
+
+	if not set.new(client.grant_types):contains(device_uri) then
+		return render_error(oauth_error("invalid_client", "Client not registered for device authorization grant"));
+	end
+
+	local requested_scopes = parse_scopes(params.scope or "");
+	if client.scope then
+		local client_scopes = set.new(parse_scopes(client.scope));
+		requested_scopes:filter(function(scope)
+			return client_scopes:contains(scope);
+		end);
+	end
+
+	-- TODO better code generator, this one should be easy to type from a
+	-- screen onto a phone
+	local user_code = (id.tiny() .. "-" .. id.tiny()):upper();
+	local collisions = 0;
+	while codes:get(user_code) do
+		collisions = collisions + 1;
+		if collisions > 10 then
+			return oauth_error("temporarily_unavailable");
+		end
+		user_code = (id.tiny() .. "-" .. id.tiny()):upper();
+	end
+	-- device code should be derivable after consent but not guessable by the user
+	local device_code = b64url(hashes.hmac_sha256(verification_key, user_code));
+	local verification_uri = module:http_url() .. "/device";
+	local verification_uri_complete = verification_uri .. "?" .. http.formencode({ user_code = user_code });
+
+	local dc_ok = codes:set(params.client_id .. "#" .. device_code, { expires = os.time() + 1200 });
+	local uc_ok = codes:set(user_code,
+		{ user_code = user_code; expires = os.time() + 600; client_id = params.client_id;
+    scope = requested_scopes:concat(" ") });
+	if not dc_ok or not uc_ok then
+		return oauth_error("temporarily_unavailable");
+	end
+
+	return {
+		headers = { content_type = "application/json"; cache_control = "no-store"; pragma = "no-cache" };
+		body = json.encode {
+			device_code = device_code;
+			user_code = user_code;
+			verification_uri = verification_uri;
+			verification_uri_complete = verification_uri_complete;
+			expires_in = 600;
+			interval = 5;
+		};
+	}
+end
+
+local function handle_device_verification_request(event)
+	local request = event.request;
+	local params = strict_formdecode(request.url.query);
+	if not params or not params.user_code then
+		return render_page(templates.device, { client = false });
+	end
+
+	local device_info = codes:get(params.user_code);
+	if not device_info or code_expired(device_info) or not codes:set(params.user_code, nil) then
+		return render_error(oauth_error("expired_token", "Incorrect or expired code"));
+	end
+
+	return {
+		status_code = 303;
+		headers = {
+			location = module:http_url() .. "/authorize" .. "?" .. http.formencode({
+				client_id = device_info.client_id;
+				redirect_uri = device_uri;
+				response_type = "code";
+				scope = device_info.scope;
+				state = new_device_token({ user_code = params.user_code });
+			});
+		};
+	}
+end
+
 local function handle_revocation_request(event)
 	local request, response = event.request, event.response;
 	response.headers.cache_control = "no-store";
@@ -915,6 +1087,7 @@
 					"refresh_token";
 					"urn:ietf:params:oauth:grant-type:jwt-bearer";
 					"urn:ietf:params:oauth:grant-type:saml2-bearer";
+					device_uri;
 				};
 			};
 			default = { "authorization_code" };
@@ -1069,6 +1242,8 @@
 	module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled")
 	handle_authorization_request = nil
 	handle_register_request = nil
+	handle_device_authorization_request = nil
+	handle_device_verification_request = nil
 end
 
 local function handle_userinfo_request(event)
@@ -1130,6 +1305,10 @@
 		-- Step 1. Create OAuth client
 		["POST /register"] = handle_register_request;
 
+		-- Device flow
+		["POST /device"] = handle_device_authorization_request;
+		["GET /device"] = handle_device_verification_request;
+
 		-- Step 2. User-facing login and consent view
 		["GET /authorize"] = handle_authorization_request;
 		["POST /authorize"] = handle_authorization_request;
@@ -1203,11 +1382,9 @@
 		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";
 		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";
-		});
+		grant_types_supported = array(it.keys(grant_type_handlers));
 		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");