Comparison

mod_http_oauth2/mod_http_oauth2.lua @ 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
parent 5192:03aa9baa9ac3
child 5194:25041e15994e
comparison
equal deleted inserted replaced
5192:03aa9baa9ac3 5193:2bb29ece216b
7 local errors = require "util.error"; 7 local errors = require "util.error";
8 local url = require "socket.url"; 8 local url = require "socket.url";
9 local uuid = require "util.uuid"; 9 local uuid = require "util.uuid";
10 local encodings = require "util.encodings"; 10 local encodings = require "util.encodings";
11 local base64 = encodings.base64; 11 local base64 = encodings.base64;
12 local schema = require "util.jsonschema";
13 local jwt = require"util.jwt";
12 14
13 local tokens = module:depends("tokenauth"); 15 local tokens = module:depends("tokenauth");
14 16
15 local clients = module:open_store("oauth2_clients", "map"); 17 -- Used to derive client_secret from client_id, set to enable stateless dynamic registration.
18 local registration_key = module:get_option_string("oauth2_registration_key");
19 local registration_algo = module:get_option_string("oauth2_registration_algorithm", "HS256");
20 local registration_options = module:get_option("oauth2_registration_options", { default_ttl = 60 * 60 * 24 * 90 });
21
22 local jwt_sign, jwt_verify;
23 if not registration_key then
24 module:log("error", "Missing required 'oauth2_registration_key', generate a strong key and configure it")
25 else
26 -- Tie it to the host if global
27 registration_key = hashes.hmac_sha256(registration_key, module.host);
28 jwt_sign, jwt_verify = jwt.init(registration_algo, registration_key, registration_key, registration_options);
29 end
16 30
17 local function filter_scopes(username, host, requested_scope_string) 31 local function filter_scopes(username, host, requested_scope_string)
18 if host ~= module.host then 32 if host ~= module.host then
19 return usermanager.get_jid_role(username.."@"..host, module.host).name; 33 return usermanager.get_jid_role(username.."@"..host, module.host).name;
20 end 34 end
70 scope = scope; 84 scope = scope;
71 -- TODO: include refresh_token when implemented 85 -- TODO: include refresh_token when implemented
72 }; 86 };
73 end 87 end
74 88
89 local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string
90 for _, redirect_uri in ipairs(client.redirect_uris) do
91 if query_redirect_uri == nil or query_redirect_uri == redirect_uri then
92 return redirect_uri
93 end
94 end
95 end
96
75 local grant_type_handlers = {}; 97 local grant_type_handlers = {};
76 local response_type_handlers = {}; 98 local response_type_handlers = {};
77 99
78 function grant_type_handlers.password(params) 100 function grant_type_handlers.password(params)
79 local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)")); 101 local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)"));
95 -- TODO response_type_handlers have some common boilerplate code, refactor? 117 -- TODO response_type_handlers have some common boilerplate code, refactor?
96 118
97 function response_type_handlers.code(params, granted_jid) 119 function response_type_handlers.code(params, granted_jid)
98 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end 120 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
99 121
100 local client_owner, client_host, client_id = jid.prepped_split(params.client_id); 122 local ok, client = jwt_verify(params.client_id);
101 if client_host ~= module.host then 123
102 return oauth_error("invalid_client", "incorrect credentials"); 124 if not ok then
103 end
104 local client, err = clients:get(client_owner, client_id);
105 if err then error(err); end
106 if not client then
107 return oauth_error("invalid_client", "incorrect credentials"); 125 return oauth_error("invalid_client", "incorrect credentials");
108 end 126 end
109 127
110 local request_username, request_host = jid.split(granted_jid); 128 local request_username, request_host = jid.split(granted_jid);
111 local granted_scopes = filter_scopes(request_username, request_host, params.scope); 129 local granted_scopes = filter_scopes(request_username, request_host, params.scope);
118 }); 136 });
119 if not ok then 137 if not ok then
120 return {status_code = 429}; 138 return {status_code = 429};
121 end 139 end
122 140
123 local redirect_uri = params.redirect_uri or client.redirect_uri; 141 local redirect_uri = get_redirect_uri(client, params.redirect_uri);
124 if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" then 142 if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" then
125 -- TODO some nicer template page 143 -- TODO some nicer template page
126 local response = { status_code = 200; headers = { content_type = "text/plain" } } 144 local response = { status_code = 200; headers = { content_type = "text/plain" } }
127 response.body = module:context("*"):fire_event("http-message", { 145 response.body = module:context("*"):fire_event("http-message", {
128 response = response; 146 response = response;
154 172
155 -- Implicit flow 173 -- Implicit flow
156 function response_type_handlers.token(params, granted_jid) 174 function response_type_handlers.token(params, granted_jid)
157 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end 175 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
158 176
159 local client_owner, client_host, client_id = jid.prepped_split(params.client_id); 177 local client = jwt_verify(params.client_id);
160 if client_host ~= module.host then 178
161 return oauth_error("invalid_client", "incorrect credentials");
162 end
163 local client, err = clients:get(client_owner, client_id);
164 if err then error(err); end
165 if not client then 179 if not client then
166 return oauth_error("invalid_client", "incorrect credentials"); 180 return oauth_error("invalid_client", "incorrect credentials");
167 end 181 end
168 182
169 local granted_scopes = filter_scopes(client_owner, client_host, params.scope); 183 local request_username, request_host = jid.split(granted_jid);
184 local granted_scopes = filter_scopes(request_username, request_host, params.scope);
170 local token_info = new_access_token(granted_jid, granted_scopes, nil); 185 local token_info = new_access_token(granted_jid, granted_scopes, nil);
171 186
172 local redirect = url.parse(client.redirect_uri); 187 local redirect = url.parse(get_redirect_uri(client, params.redirect_uri));
173 token_info.state = params.state; 188 token_info.state = params.state;
174 redirect.fragment = http.formencode(token_info); 189 redirect.fragment = http.formencode(token_info);
175 190
176 return { 191 return {
177 status_code = 302; 192 status_code = 302;
179 location = url.build(redirect); 194 location = url.build(redirect);
180 }; 195 };
181 } 196 }
182 end 197 end
183 198
184 local pepper = module:get_option_string("oauth2_client_pepper", ""); 199 local function make_secret(client_id) --> client_secret
185 200 return hashes.hmac_sha256(registration_key, client_id, true);
186 local function verify_secret(stored, salt, i, secret) 201 end
187 return base64.decode(stored) == hashes.pbkdf2_hmac_sha256(secret, salt .. pepper, i); 202
203 local function verify_secret(client_id, client_secret)
204 return hashes.equals(make_secret(client_id), client_secret);
188 end 205 end
189 206
190 function grant_type_handlers.authorization_code(params) 207 function grant_type_handlers.authorization_code(params)
191 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end 208 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
192 if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end 209 if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end
193 if not params.code then return oauth_error("invalid_request", "missing 'code'"); end 210 if not params.code then return oauth_error("invalid_request", "missing 'code'"); end
194 if params.scope and params.scope ~= "" then 211 if params.scope and params.scope ~= "" then
195 return oauth_error("invalid_scope", "unknown scope requested"); 212 return oauth_error("invalid_scope", "unknown scope requested");
196 end 213 end
197 214
198 local client_owner, client_host, client_id = jid.prepped_split(params.client_id); 215 local client = jwt_verify(params.client_id);
199 if client_host ~= module.host then 216 if not client then
200 module:log("debug", "%q ~= %q", client_host, module.host);
201 return oauth_error("invalid_client", "incorrect credentials"); 217 return oauth_error("invalid_client", "incorrect credentials");
202 end 218 end
203 local client, err = clients:get(client_owner, client_id); 219
204 if err then error(err); end 220 if not verify_secret(params.client_id, params.client_secret) then
205 if not client or not verify_secret(client.secret_hash, client.salt, client.iteration_count, params.client_secret) then
206 module:log("debug", "client_secret mismatch"); 221 module:log("debug", "client_secret mismatch");
207 return oauth_error("invalid_client", "incorrect credentials"); 222 return oauth_error("invalid_client", "incorrect credentials");
208 end 223 end
209 local code, err = codes:get(params.client_id .. "#" .. params.code); 224 local code, err = codes:get(params.client_id .. "#" .. params.code);
210 if err then error(err); end 225 if err then error(err); end
211 if not code or type(code) ~= "table" or code_expired(code) then 226 if not code or type(code) ~= "table" or code_expired(code) then
212 module:log("debug", "authorization_code invalid or expired: %q", code); 227 module:log("debug", "authorization_code invalid or expired: %q", code);
213 return oauth_error("invalid_client", "incorrect credentials"); 228 return oauth_error("invalid_client", "incorrect credentials");
214 end 229 end
215 assert(codes:set(client_id .. "#" .. params.code, nil));
216 230
217 return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil)); 231 return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil));
218 end 232 end
219 233
220 local function check_credentials(request, allow_token) 234 local function check_credentials(request, allow_token)
350 return 500; 364 return 500;
351 end 365 end
352 return 200; 366 return 200;
353 end 367 end
354 368
369 local registration_schema = {
370 type = "object";
371 required = { "client_name"; "redirect_uris" };
372 properties = {
373 redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } };
374 token_endpoint_auth_method = { enum = { "none"; "client_secret_post"; "client_secret_basic" }; type = "string" };
375 grant_types = {
376 items = {
377 enum = {
378 "authorization_code";
379 "implicit";
380 "password";
381 "client_credentials";
382 "refresh_token";
383 "urn:ietf:params:oauth:grant-type:jwt-bearer";
384 "urn:ietf:params:oauth:grant-type:saml2-bearer";
385 };
386 type = "string";
387 };
388 type = "array";
389 };
390 response_types = { items = { enum = { "code"; "token" }; type = "string" }; type = "array" };
391 client_name = { type = "string" };
392 client_uri = { type = "string"; format = "uri" };
393 logo_uri = { type = "string"; format = "uri" };
394 scope = { type = "string" };
395 contacts = { items = { type = "string" }; type = "array" };
396 tos_uri = { type = "string" };
397 policy_uri = { type = "string"; format = "uri" };
398 jwks_uri = { type = "string"; format = "uri" };
399 jwks = { type = "object"; description = "JSON Web Key Set, RFC 7517" };
400 software_id = { type = "string"; format = "uuid" };
401 software_version = { type = "string" };
402 };
403 }
404
405 local function handle_register_request(event)
406 local request = event.request;
407 local client_metadata = json.decode(request.body);
408
409 if not schema.validate(registration_schema, client_metadata) then
410 return oauth_error("invalid_request", "Failed schema validation.");
411 end
412
413 -- Ensure each signed client_id JWT is unique
414 client_metadata.nonce = uuid.generate();
415
416 -- Do we want to keep everything?
417 local client_id = jwt_sign(client_metadata);
418 local client_secret = make_secret(client_id);
419
420 local client_desc = {
421 client_id = client_id;
422 client_secret = client_secret;
423 client_id_issued_at = os.time();
424 client_secret_expires_at = 0;
425 }
426
427 return {
428 status_code = 201;
429 headers = { content_type = "application/json" };
430 body = json.encode(client_desc);
431 };
432 end
433
434 if not registration_key then
435 module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled")
436 handle_authorization_request = nil
437 handle_register_request = nil
438 end
439
355 module:depends("http"); 440 module:depends("http");
356 module:provides("http", { 441 module:provides("http", {
357 route = { 442 route = {
358 ["POST /token"] = handle_token_grant; 443 ["POST /token"] = handle_token_grant;
359 ["GET /authorize"] = handle_authorization_request; 444 ["GET /authorize"] = handle_authorization_request;
360 ["POST /revoke"] = handle_revocation_request; 445 ["POST /revoke"] = handle_revocation_request;
446 ["POST /register"] = handle_register_request;
361 }; 447 };
362 }); 448 });
363 449
364 local http_server = require "net.http.server"; 450 local http_server = require "net.http.server";
365 451
384 body = json.encode { 470 body = json.encode {
385 issuer = module:http_url(nil, "/"); 471 issuer = module:http_url(nil, "/");
386 authorization_endpoint = module:http_url() .. "/authorize"; 472 authorization_endpoint = module:http_url() .. "/authorize";
387 token_endpoint = module:http_url() .. "/token"; 473 token_endpoint = module:http_url() .. "/token";
388 jwks_uri = nil; -- TODO? 474 jwks_uri = nil; -- TODO?
389 registration_endpoint = nil; -- TODO 475 registration_endpoint = module:http_url() .. "/register";
390 scopes_supported = { "prosody:restricted"; "prosody:user"; "prosody:admin"; "prosody:operator" }; 476 scopes_supported = { "prosody:restricted"; "prosody:user"; "prosody:admin"; "prosody:operator" };
391 response_types_supported = { "code"; "token" }; 477 response_types_supported = { "code"; "token" };
392 authorization_response_iss_parameter_supported = true; 478 authorization_response_iss_parameter_supported = true;
393 }; 479 };
394 }; 480 };