Software / code / prosody-modules
Diff
mod_http_oauth2/mod_http_oauth2.lua @ 6309:342f88e8d522 draft
Merge update
| author | Trần H. Trung <xmpp:trần.h.trung@trung.fun> |
|---|---|
| date | Sun, 15 Jun 2025 01:08:46 +0700 |
| parent | 6273:8ceedc336d0d |
| parent | 6308:e1c54de06905 |
| child | 6344:eb834f754f57 |
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua Sun Jun 01 21:32:49 2025 +0700 +++ b/mod_http_oauth2/mod_http_oauth2.lua Sun Jun 15 01:08:46 2025 +0700 @@ -410,7 +410,10 @@ local request_username if expect_username_jid then - local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)")); + local request_jid = params.username; + if not request_jid then + return oauth_error("invalid_request", "missing 'username' (JID)"); + end local _request_username, request_host = jid.prepped_split(request_jid); if not (_request_username and request_host) or request_host ~= module.host then @@ -419,15 +422,36 @@ request_username = _request_username else - request_username = assert(params.username, oauth_error("invalid_request", "missing 'username'")); + request_username = params.username; + if not request_username then + return oauth_error("invalid_request", "missing 'username'"); + end + end + + local request_password = params.password; + if not request_password then + return oauth_error("invalid_request", "missing 'password'"); end - local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'")); + local auth_event = { + session = { + type = "oauth2"; + ip = "::"; + username = request_username; + host = module.host; + log = module._log; + sasl_handler = { username = request_username; selected = "x-oauth2-password" }; + client_id = client.client_name; + }; + }; if not usermanager.test_password(request_username, module.host, request_password) then + module:fire_event("authentication-failure", auth_event); return oauth_error("invalid_grant", "incorrect credentials"); end + module:fire_event("authentication-success", auth_event); + local granted_jid = jid.join(request_username, module.host); local granted_scopes, granted_role = filter_scopes(request_username, params.scope); return json.encode(new_access_token(granted_jid, granted_role, granted_scopes, client)); @@ -552,21 +576,9 @@ return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token)); end -function grant_type_handlers.refresh_token(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 +function grant_type_handlers.refresh_token(params, client) if not params.refresh_token then return oauth_error("invalid_request", "missing 'refresh_token'"); 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 refresh_token_info = tokens.get_token_info(params.refresh_token); if not refresh_token_info or refresh_token_info.purpose ~= "oauth2-refresh" then return oauth_error("invalid_grant", "invalid refresh token"); @@ -598,21 +610,9 @@ 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 +grant_type_handlers[device_uri] = function(params, client) 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("device_code:" .. params.client_id .. "#" .. params.device_code); if type(code) ~= "table" or code_expired(code) then return oauth_error("expired_token"); @@ -747,8 +747,14 @@ local component_secret = assert(module:get_option_string("component_secret"), "'component_secret' is a required setting when loaded on a Component"); 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'")); + local request_jid = params.username; + if not request_jid then + return oauth_error("invalid_request", "missing 'username' (JID)"); + end + local request_password = params.password + if not request_password then + return oauth_error("invalid_request", "missing 'password'"); + end local request_username, request_host, request_resource = jid.prepped_split(request_jid); if params.scope then -- TODO shouldn't we support scopes / roles here? @@ -781,15 +787,28 @@ -- the redirect_uri is missing or invalid. In those cases, we render an -- error directly to the user-agent. local function error_response(request, redirect_uri, err) - if not redirect_uri or redirect_uri == oob_uri or redirect_uri == device_uri then + if not redirect_uri or redirect_uri == oob_uri then return render_error(err); end - local q = strict_formdecode(request.url.query); + local params = strict_formdecode(request.url.query); + 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("device_code:" .. params.client_id .. "#" .. device_code); + if type(code) == "table" then + code.error = err; + code.expires = os.time() + 60; + codes:set("device_code:" .. params.client_id .. "#" .. device_code, code); + end + end + return render_error(err); + end local redirect_query = url.parse(redirect_uri); local sep = redirect_query.query and "&" or "?"; redirect_uri = redirect_uri .. sep .. http.formencode(err.extra.oauth2_response) - .. "&" .. http.formencode({ state = q.state, iss = get_issuer() }); + .. "&" .. http.formencode({ state = params.state, iss = get_issuer() }); module:log("debug", "Sending error response to client via redirect to %s", redirect_uri); return { status_code = 303; @@ -842,6 +861,15 @@ end end +local function array_contains(haystack, needle) + for _, item in ipairs(haystack) do + if item == needle then + return true + end + end + return false +end + function handle_token_grant(event) local credentials = get_request_credentials(event.request); @@ -874,10 +902,15 @@ local grant_type = params.grant_type + if not array_contains(client.grant_types or { "authorization_code" }, grant_type) then + return oauth_error("invalid_request", "'grant_type' not registered"); + end + local grant_handler = grant_type_handlers[grant_type]; if not grant_handler then - return oauth_error("invalid_request", "No such grant type."); + return oauth_error("invalid_request", "'grant_type' not available"); end + return grant_handler(params, client); end @@ -909,10 +942,16 @@ end -- From this point we know that redirect_uri is safe to use - local client_response_types = set.new(array(client.response_types or { "code" })); - client_response_types = set.intersection(client_response_types, allowed_response_type_handlers); - if not client_response_types:contains(params.response_type) then - return error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not allowed")); + local response_type = params.response_type; + if not array_contains(client.response_types or { "code" }, response_type) then + return error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not registered")); + end + if not allowed_response_type_handlers:contains(response_type) then + return error_response(request, redirect_uri, oauth_error("unsupported_response_type", "'response_type' not allowed")); + end + local response_handler = response_type_handlers[response_type]; + if not response_handler then + return error_response(request, redirect_uri, oauth_error("unsupported_response_type")); end local requested_scopes = parse_scopes(params.scope or ""); @@ -976,16 +1015,6 @@ end 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("device_code:" .. params.client_id .. "#" .. device_code); - code.error = oauth_error("access_denied"); - code.expires = os.time() + 60; - codes:set("device_code:" .. params.client_id .. "#" .. device_code, code); - end - end return error_response(request, redirect_uri, oauth_error("access_denied")); end -- else auth_state.consent == true @@ -1009,13 +1038,8 @@ aud = params.client_id; auth_time = auth_state.user.iat; nonce = params.nonce; - amr = auth_state.user.amr; + amr = auth_state.user.amr; -- RFC 8176: Authentication Method Reference Values }); - local response_type = params.response_type; - local response_handler = response_type_handlers[response_type]; - if not response_handler then - return error_response(request, redirect_uri, oauth_error("unsupported_response_type")); - end local ret = response_handler(client, params, user_jid, id_token); if errors.is_err(ret) then return error_response(request, redirect_uri, ret); @@ -1307,7 +1331,6 @@ response_types = { title = "Response Types"; type = "array"; - minItems = 1; uniqueItems = true; items = { type = "string"; enum = { "code"; "token" } }; default = { "code" }; @@ -1471,18 +1494,18 @@ local grant_types = set.new(client_metadata.grant_types); local response_types = set.new(client_metadata.response_types); + if not (grant_types - allowed_grant_type_handlers):empty() then + return nil, oauth_error("invalid_client_metadata", "Disallowed 'grant_types' specified"); + elseif not (response_types - allowed_response_type_handlers):empty() then + return nil, oauth_error("invalid_client_metadata", "Disallowed 'response_types' specified"); + end + if grant_types:contains("authorization_code") and not response_types:contains("code") then return nil, oauth_error("invalid_client_metadata", "Inconsistency between 'grant_types' and 'response_types'"); elseif grant_types:contains("implicit") and not response_types:contains("token") then return nil, oauth_error("invalid_client_metadata", "Inconsistency between 'grant_types' and 'response_types'"); end - if set.intersection(grant_types, allowed_grant_type_handlers):empty() then - return nil, oauth_error("invalid_client_metadata", "No allowed 'grant_types' specified"); - elseif set.intersection(response_types, allowed_response_type_handlers):empty() then - return nil, oauth_error("invalid_client_metadata", "No allowed 'response_types' specified"); - end - if client_metadata.token_endpoint_auth_method ~= "none" then -- Ensure that each client_id JWT with a client_secret is unique. -- A short ID along with the issued at timestamp should be sufficient to @@ -1669,28 +1692,35 @@ issuer = get_issuer(); authorization_endpoint = handle_authorization_request and module:http_url() .. "/authorize" or nil; token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil; + jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil; scopes_supported = usermanager.get_all_roles and array(it.keys(usermanager.get_all_roles(module.host))):push("xmpp"):append(array(openid_claims:items())); response_types_supported = array(it.keys(response_type_handlers)); - token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" }); + response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" }); + grant_types_supported = array(it.keys(grant_type_handlers)); + token_endpoint_auth_methods_supported = array({ "client_secret_basic"; "client_secret_post"; "none" }); + token_endpoint_auth_signing_alg_values_supported = nil; + service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html"); + ui_locales_supported = allowed_locales[1] and allowed_locales; op_policy_uri = module:get_option_string("oauth2_policy_url", nil); 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"; + revocation_endpoint_auth_methods_supported = array({ "client_secret_basic"; "client_secret_post"; "none" }); + revocation_endpoint_auth_signing_alg_values_supported = nil; introspection_endpoint = handle_introspection_request and module:http_url() .. "/introspect"; introspection_endpoint_auth_methods_supported = nil; + introspection_endpoint_auth_signing_alg_values_supported = nil; code_challenge_methods_supported = array(it.keys(verifier_transforms)); - grant_types_supported = array(it.keys(grant_type_handlers)); - response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" }); + + -- RFC 8628: OAuth 2.0 Device Authorization Grant + device_authorization_endpoint = handle_device_authorization_request and module:http_url() .. "/device"; + + -- RFC 9207: OAuth 2.0 Authorization Server Issuer Identification authorization_response_iss_parameter_supported = true; - service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html"); - ui_locales_supported = allowed_locales[1] and allowed_locales; - -- OpenID + -- OpenID Connect Discovery 1.0 userinfo_endpoint = handle_userinfo_request and module:http_url() .. "/userinfo" or nil; - jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata id_token_signing_alg_values_supported = { "HS256" }; -- The algorithm RS256 MUST be included, but we use HS256 and client_secret as shared key. } return authorization_server_metadata;