Software /
code /
prosody-modules
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 }; |