Mercurial > prosody-modules
comparison mod_http_oauth2/mod_http_oauth2.lua @ 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 | feadbd481285 |
children | b496ebc12aed |
comparison
equal
deleted
inserted
replaced
5588:59acf7f540c1 | 5589:7040d0772758 |
---|---|
66 local template_path = module:get_option_path("oauth2_template_path", "html"); | 66 local template_path = module:get_option_path("oauth2_template_path", "html"); |
67 local templates = { | 67 local templates = { |
68 login = read_file(template_path, "login.html", true); | 68 login = read_file(template_path, "login.html", true); |
69 consent = read_file(template_path, "consent.html", true); | 69 consent = read_file(template_path, "consent.html", true); |
70 oob = read_file(template_path, "oob.html", true); | 70 oob = read_file(template_path, "oob.html", true); |
71 device = read_file(template_path, "device.html", true); | |
71 error = read_file(template_path, "error.html", true); | 72 error = read_file(template_path, "error.html", true); |
72 css = read_file(template_path, "style.css"); | 73 css = read_file(template_path, "style.css"); |
73 js = read_file(template_path, "script.js"); | 74 js = read_file(template_path, "script.js"); |
74 }; | 75 }; |
75 | 76 |
118 -- Tie it to the host if global | 119 -- Tie it to the host if global |
119 verification_key = hashes.hmac_sha256(registration_key, module.host); | 120 verification_key = hashes.hmac_sha256(registration_key, module.host); |
120 sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options); | 121 sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options); |
121 end | 122 end |
122 | 123 |
124 local new_device_token, verify_device_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 }); | |
125 | |
123 -- verify and prepare client structure | 126 -- verify and prepare client structure |
124 local function check_client(client_id) | 127 local function check_client(client_id) |
125 if not verify_client then | 128 if not verify_client then |
126 return nil, "client-registration-not-enabled"; | 129 return nil, "client-registration-not-enabled"; |
127 end | 130 end |
229 | 232 |
230 -- Non-standard special redirect URI that has the AS show the authorization | 233 -- Non-standard special redirect URI that has the AS show the authorization |
231 -- code to the user for them to copy-paste into the client, which can then | 234 -- code to the user for them to copy-paste into the client, which can then |
232 -- continue as if it received it via redirect. | 235 -- continue as if it received it via redirect. |
233 local oob_uri = "urn:ietf:wg:oauth:2.0:oob"; | 236 local oob_uri = "urn:ietf:wg:oauth:2.0:oob"; |
237 local device_uri = "urn:ietf:params:oauth:grant-type:device_code"; | |
234 | 238 |
235 local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" }); | 239 local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" }); |
236 | 240 |
237 local function oauth_error(err_name, err_desc) | 241 local function oauth_error(err_name, err_desc) |
238 return errors.new({ | 242 return errors.new({ |
315 return; | 319 return; |
316 end | 320 end |
317 -- When only a single URI is registered, that's the default | 321 -- When only a single URI is registered, that's the default |
318 return client.redirect_uris[1]; | 322 return client.redirect_uris[1]; |
319 end | 323 end |
324 if query_redirect_uri == device_uri and client.grant_types then | |
325 for _, grant_type in ipairs(client.grant_types) do | |
326 if grant_type == device_uri then | |
327 return query_redirect_uri; | |
328 end | |
329 end | |
330 -- Tried to use device authorization flow without registering it. | |
331 return; | |
332 end | |
320 -- Verify the client-provided URI matches one previously registered | 333 -- Verify the client-provided URI matches one previously registered |
321 for _, redirect_uri in ipairs(client.redirect_uris) do | 334 for _, redirect_uri in ipairs(client.redirect_uris) do |
322 if query_redirect_uri == redirect_uri then | 335 if query_redirect_uri == redirect_uri then |
323 return redirect_uri | 336 return redirect_uri |
324 end | 337 end |
340 | 353 |
341 local grant_type_handlers = {}; | 354 local grant_type_handlers = {}; |
342 local response_type_handlers = {}; | 355 local response_type_handlers = {}; |
343 local verifier_transforms = {}; | 356 local verifier_transforms = {}; |
344 | 357 |
358 function grant_type_handlers.implicit() | |
359 -- Placeholder to make discovery work correctly. | |
360 -- Access tokens are delivered via redirect when using the implict flow, not | |
361 -- via the token endpoint, so how did you get here? | |
362 return oauth_error("invalid_request"); | |
363 end | |
364 | |
345 function grant_type_handlers.password(params) | 365 function grant_type_handlers.password(params) |
346 local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)")); | 366 local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)")); |
347 local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'")); | 367 local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'")); |
348 local request_username, request_host, request_resource = jid.prepped_split(request_jid); | 368 local request_username, request_host, request_resource = jid.prepped_split(request_jid); |
349 | 369 |
369 if pkce_required and not params.code_challenge then | 389 if pkce_required and not params.code_challenge then |
370 return oauth_error("invalid_request", "PKCE required"); | 390 return oauth_error("invalid_request", "PKCE required"); |
371 end | 391 end |
372 | 392 |
373 local code = id.medium(); | 393 local code = id.medium(); |
394 if params.redirect_uri == device_uri then | |
395 local is_device, device_state = verify_device_token(params.state); | |
396 if is_device then | |
397 -- reconstruct the device_code | |
398 code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code)); | |
399 end | |
400 end | |
374 local ok = codes:set(params.client_id .. "#" .. code, { | 401 local ok = codes:set(params.client_id .. "#" .. code, { |
375 expires = os.time() + 600; | 402 expires = os.time() + 600; |
376 granted_jid = granted_jid; | 403 granted_jid = granted_jid; |
377 granted_scopes = granted_scopes; | 404 granted_scopes = granted_scopes; |
378 granted_role = granted_role; | 405 granted_role = granted_role; |
385 end | 412 end |
386 | 413 |
387 local redirect_uri = get_redirect_uri(client, params.redirect_uri); | 414 local redirect_uri = get_redirect_uri(client, params.redirect_uri); |
388 if redirect_uri == oob_uri then | 415 if redirect_uri == oob_uri then |
389 return render_page(templates.oob, { client = client; authorization_code = code }, true); | 416 return render_page(templates.oob, { client = client; authorization_code = code }, true); |
417 elseif redirect_uri == device_uri then | |
418 return render_page(templates.device, { client = client }, true); | |
390 elseif not redirect_uri then | 419 elseif not redirect_uri then |
391 return oauth_error("invalid_redirect_uri"); | 420 return oauth_error("invalid_redirect_uri"); |
392 end | 421 end |
393 | 422 |
394 local redirect = url.parse(redirect_uri); | 423 local redirect = url.parse(redirect_uri); |
526 | 555 |
527 -- new_access_token() requires the actual token | 556 -- new_access_token() requires the actual token |
528 refresh_token_info.token = params.refresh_token; | 557 refresh_token_info.token = params.refresh_token; |
529 | 558 |
530 return json.encode(new_access_token(refresh_token_info.jid, role, new_scopes, client, nil, refresh_token_info)); | 559 return json.encode(new_access_token(refresh_token_info.jid, role, new_scopes, client, nil, refresh_token_info)); |
560 end | |
561 | |
562 grant_type_handlers[device_uri] = function(params) | |
563 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end | |
564 if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end | |
565 if not params.device_code then return oauth_error("invalid_request", "missing 'device_code'"); end | |
566 | |
567 local client = check_client(params.client_id); | |
568 if not client then | |
569 return oauth_error("invalid_client", "incorrect credentials"); | |
570 end | |
571 | |
572 if not verify_client_secret(params.client_id, params.client_secret) then | |
573 module:log("debug", "client_secret mismatch"); | |
574 return oauth_error("invalid_client", "incorrect credentials"); | |
575 end | |
576 | |
577 local code = codes:get(params.client_id .. "#" .. params.device_code); | |
578 if type(code) ~= "table" or code_expired(code) then | |
579 return oauth_error("expired_token"); | |
580 elseif code.error then | |
581 return code.error; | |
582 elseif not code.granted_jid then | |
583 return oauth_error("authorization_pending"); | |
584 end | |
585 codes:set(client.client_hash .. "#" .. params.device_code, nil); | |
586 | |
587 return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token)); | |
531 end | 588 end |
532 | 589 |
533 -- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients | 590 -- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients |
534 | 591 |
535 function verifier_transforms.plain(code_verifier) | 592 function verifier_transforms.plain(code_verifier) |
686 | 743 |
687 local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", { | 744 local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", { |
688 "authorization_code"; | 745 "authorization_code"; |
689 "password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used. | 746 "password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used. |
690 "refresh_token"; | 747 "refresh_token"; |
748 device_uri; | |
691 }) | 749 }) |
692 for handler_type in pairs(grant_type_handlers) do | 750 for handler_type in pairs(grant_type_handlers) do |
693 if not allowed_grant_type_handlers:contains(handler_type) then | 751 if not allowed_grant_type_handlers:contains(handler_type) then |
694 module:log("debug", "Grant type %q disabled", handler_type); | 752 module:log("debug", "Grant type %q disabled", handler_type); |
695 grant_type_handlers[handler_type] = nil; | 753 grant_type_handlers[handler_type] = nil; |
725 event.response.headers.content_type = "application/json"; | 783 event.response.headers.content_type = "application/json"; |
726 event.response.headers.cache_control = "no-store"; | 784 event.response.headers.cache_control = "no-store"; |
727 event.response.headers.pragma = "no-cache"; | 785 event.response.headers.pragma = "no-cache"; |
728 local params = strict_formdecode(event.request.body); | 786 local params = strict_formdecode(event.request.body); |
729 if not params then | 787 if not params then |
730 return oauth_error("invalid_request"); | 788 return oauth_error("invalid_request", "Could not parse request body as 'application/x-www-form-urlencoded'"); |
731 end | 789 end |
732 | 790 |
733 if credentials and credentials.type == "basic" then | 791 if credentials and credentials.type == "basic" then |
734 -- client_secret_basic converted internally to client_secret_post | 792 -- client_secret_basic converted internally to client_secret_post |
735 params.client_id = http.urldecode(credentials.username); | 793 params.client_id = http.urldecode(credentials.username); |
737 end | 795 end |
738 | 796 |
739 local grant_type = params.grant_type | 797 local grant_type = params.grant_type |
740 local grant_handler = grant_type_handlers[grant_type]; | 798 local grant_handler = grant_type_handlers[grant_type]; |
741 if not grant_handler then | 799 if not grant_handler then |
742 return oauth_error("invalid_request"); | 800 return oauth_error("invalid_request", "No such grant type."); |
743 end | 801 end |
744 return grant_handler(params); | 802 return grant_handler(params); |
745 end | 803 end |
746 | 804 |
747 local function handle_authorization_request(event) | 805 local function handle_authorization_request(event) |
818 local scopes, roles = split_scopes(requested_scopes); | 876 local scopes, roles = split_scopes(requested_scopes); |
819 roles = user_assumable_roles(auth_state.user.username, roles); | 877 roles = user_assumable_roles(auth_state.user.username, roles); |
820 return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true); | 878 return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true); |
821 elseif not auth_state.consent then | 879 elseif not auth_state.consent then |
822 -- Notify client of rejection | 880 -- Notify client of rejection |
881 if redirect_uri == device_uri then | |
882 local is_device, device_state = verify_device_token(params.state); | |
883 if is_device then | |
884 local device_code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code)); | |
885 local code = codes:get(params.client_id .. "#" .. device_code); | |
886 code.error = oauth_error("access_denied"); | |
887 code.expires = os.time() + 60; | |
888 codes:set(params.client_id .. "#" .. device_code, code); | |
889 end | |
890 end | |
823 return error_response(request, redirect_uri, oauth_error("access_denied")); | 891 return error_response(request, redirect_uri, oauth_error("access_denied")); |
824 end | 892 end |
825 -- else auth_state.consent == true | 893 -- else auth_state.consent == true |
826 | 894 |
827 local granted_scopes = auth_state.scopes | 895 local granted_scopes = auth_state.scopes |
852 local ret = response_handler(client, params, user_jid, id_token); | 920 local ret = response_handler(client, params, user_jid, id_token); |
853 if errors.is_err(ret) then | 921 if errors.is_err(ret) then |
854 return error_response(request, redirect_uri, ret); | 922 return error_response(request, redirect_uri, ret); |
855 end | 923 end |
856 return ret; | 924 return ret; |
925 end | |
926 | |
927 local function handle_device_authorization_request(event) | |
928 local request = event.request; | |
929 | |
930 local credentials = get_request_credentials(request); | |
931 | |
932 local params = strict_formdecode(request.body); | |
933 if not params then | |
934 return render_error(oauth_error("invalid_request", "Invalid query parameters")); | |
935 end | |
936 | |
937 if credentials and credentials.type == "basic" then | |
938 -- client_secret_basic converted internally to client_secret_post | |
939 params.client_id = http.urldecode(credentials.username); | |
940 local client_secret = http.urldecode(credentials.password); | |
941 | |
942 if not verify_client_secret(params.client_id, client_secret) then | |
943 module:log("debug", "client_secret mismatch"); | |
944 return oauth_error("invalid_client", "incorrect credentials"); | |
945 end | |
946 else | |
947 return 401; | |
948 end | |
949 | |
950 local client = check_client(params.client_id); | |
951 | |
952 if not client then | |
953 return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter")); | |
954 end | |
955 | |
956 if not set.new(client.grant_types):contains(device_uri) then | |
957 return render_error(oauth_error("invalid_client", "Client not registered for device authorization grant")); | |
958 end | |
959 | |
960 local requested_scopes = parse_scopes(params.scope or ""); | |
961 if client.scope then | |
962 local client_scopes = set.new(parse_scopes(client.scope)); | |
963 requested_scopes:filter(function(scope) | |
964 return client_scopes:contains(scope); | |
965 end); | |
966 end | |
967 | |
968 -- TODO better code generator, this one should be easy to type from a | |
969 -- screen onto a phone | |
970 local user_code = (id.tiny() .. "-" .. id.tiny()):upper(); | |
971 local collisions = 0; | |
972 while codes:get(user_code) do | |
973 collisions = collisions + 1; | |
974 if collisions > 10 then | |
975 return oauth_error("temporarily_unavailable"); | |
976 end | |
977 user_code = (id.tiny() .. "-" .. id.tiny()):upper(); | |
978 end | |
979 -- device code should be derivable after consent but not guessable by the user | |
980 local device_code = b64url(hashes.hmac_sha256(verification_key, user_code)); | |
981 local verification_uri = module:http_url() .. "/device"; | |
982 local verification_uri_complete = verification_uri .. "?" .. http.formencode({ user_code = user_code }); | |
983 | |
984 local dc_ok = codes:set(params.client_id .. "#" .. device_code, { expires = os.time() + 1200 }); | |
985 local uc_ok = codes:set(user_code, | |
986 { user_code = user_code; expires = os.time() + 600; client_id = params.client_id; | |
987 scope = requested_scopes:concat(" ") }); | |
988 if not dc_ok or not uc_ok then | |
989 return oauth_error("temporarily_unavailable"); | |
990 end | |
991 | |
992 return { | |
993 headers = { content_type = "application/json"; cache_control = "no-store"; pragma = "no-cache" }; | |
994 body = json.encode { | |
995 device_code = device_code; | |
996 user_code = user_code; | |
997 verification_uri = verification_uri; | |
998 verification_uri_complete = verification_uri_complete; | |
999 expires_in = 600; | |
1000 interval = 5; | |
1001 }; | |
1002 } | |
1003 end | |
1004 | |
1005 local function handle_device_verification_request(event) | |
1006 local request = event.request; | |
1007 local params = strict_formdecode(request.url.query); | |
1008 if not params or not params.user_code then | |
1009 return render_page(templates.device, { client = false }); | |
1010 end | |
1011 | |
1012 local device_info = codes:get(params.user_code); | |
1013 if not device_info or code_expired(device_info) or not codes:set(params.user_code, nil) then | |
1014 return render_error(oauth_error("expired_token", "Incorrect or expired code")); | |
1015 end | |
1016 | |
1017 return { | |
1018 status_code = 303; | |
1019 headers = { | |
1020 location = module:http_url() .. "/authorize" .. "?" .. http.formencode({ | |
1021 client_id = device_info.client_id; | |
1022 redirect_uri = device_uri; | |
1023 response_type = "code"; | |
1024 scope = device_info.scope; | |
1025 state = new_device_token({ user_code = params.user_code }); | |
1026 }); | |
1027 }; | |
1028 } | |
857 end | 1029 end |
858 | 1030 |
859 local function handle_revocation_request(event) | 1031 local function handle_revocation_request(event) |
860 local request, response = event.request, event.response; | 1032 local request, response = event.request, event.response; |
861 response.headers.cache_control = "no-store"; | 1033 response.headers.cache_control = "no-store"; |
913 "password"; | 1085 "password"; |
914 "client_credentials"; | 1086 "client_credentials"; |
915 "refresh_token"; | 1087 "refresh_token"; |
916 "urn:ietf:params:oauth:grant-type:jwt-bearer"; | 1088 "urn:ietf:params:oauth:grant-type:jwt-bearer"; |
917 "urn:ietf:params:oauth:grant-type:saml2-bearer"; | 1089 "urn:ietf:params:oauth:grant-type:saml2-bearer"; |
1090 device_uri; | |
918 }; | 1091 }; |
919 }; | 1092 }; |
920 default = { "authorization_code" }; | 1093 default = { "authorization_code" }; |
921 }; | 1094 }; |
922 application_type = { type = "string"; enum = { "native"; "web" }; default = "web" }; | 1095 application_type = { type = "string"; enum = { "native"; "web" }; default = "web" }; |
1067 | 1240 |
1068 if not registration_key then | 1241 if not registration_key then |
1069 module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled") | 1242 module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled") |
1070 handle_authorization_request = nil | 1243 handle_authorization_request = nil |
1071 handle_register_request = nil | 1244 handle_register_request = nil |
1245 handle_device_authorization_request = nil | |
1246 handle_device_verification_request = nil | |
1072 end | 1247 end |
1073 | 1248 |
1074 local function handle_userinfo_request(event) | 1249 local function handle_userinfo_request(event) |
1075 local request = event.request; | 1250 local request = event.request; |
1076 local credentials = get_request_credentials(request); | 1251 local credentials = get_request_credentials(request); |
1127 -- OAuth 2.0 in 5 simple steps! | 1302 -- OAuth 2.0 in 5 simple steps! |
1128 -- This is the normal 'authorization_code' flow. | 1303 -- This is the normal 'authorization_code' flow. |
1129 | 1304 |
1130 -- Step 1. Create OAuth client | 1305 -- Step 1. Create OAuth client |
1131 ["POST /register"] = handle_register_request; | 1306 ["POST /register"] = handle_register_request; |
1307 | |
1308 -- Device flow | |
1309 ["POST /device"] = handle_device_authorization_request; | |
1310 ["GET /device"] = handle_device_verification_request; | |
1132 | 1311 |
1133 -- Step 2. User-facing login and consent view | 1312 -- Step 2. User-facing login and consent view |
1134 ["GET /authorize"] = handle_authorization_request; | 1313 ["GET /authorize"] = handle_authorization_request; |
1135 ["POST /authorize"] = handle_authorization_request; | 1314 ["POST /authorize"] = handle_authorization_request; |
1136 ["OPTIONS /authorize"] = { status_code = 403; body = "" }; | 1315 ["OPTIONS /authorize"] = { status_code = 403; body = "" }; |
1201 token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" }); | 1380 token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" }); |
1202 op_policy_uri = module:get_option_string("oauth2_policy_url", nil); | 1381 op_policy_uri = module:get_option_string("oauth2_policy_url", nil); |
1203 op_tos_uri = module:get_option_string("oauth2_terms_url", nil); | 1382 op_tos_uri = module:get_option_string("oauth2_terms_url", nil); |
1204 revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil; | 1383 revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil; |
1205 revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" }); | 1384 revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" }); |
1385 device_authorization_endpoint = handle_device_authorization_request and module:http_url() .. "/device"; | |
1206 code_challenge_methods_supported = array(it.keys(verifier_transforms)); | 1386 code_challenge_methods_supported = array(it.keys(verifier_transforms)); |
1207 grant_types_supported = array(it.keys(response_type_handlers)):map(tmap { | 1387 grant_types_supported = array(it.keys(grant_type_handlers)); |
1208 token = "implicit"; | |
1209 code = "authorization_code"; | |
1210 }); | |
1211 response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" }); | 1388 response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" }); |
1212 authorization_response_iss_parameter_supported = true; | 1389 authorization_response_iss_parameter_supported = true; |
1213 service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html"); | 1390 service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html"); |
1214 ui_locales_supported = allowed_locales[1] and allowed_locales; | 1391 ui_locales_supported = allowed_locales[1] and allowed_locales; |
1215 | 1392 |