Software /
code /
prosody-modules
Diff
mod_http_oauth2/mod_http_oauth2.lua @ 5593:04f36a470dca
Update from upstream
author | Trần H. Trung <xmpp:trần.h.trung@trung.fun> |
---|---|
date | Sun, 09 Jul 2023 01:31:29 +0700 |
parent | 5580:feadbd481285 |
child | 5596:7040d0772758 |
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua Fri May 26 02:15:45 2023 +0700 +++ b/mod_http_oauth2/mod_http_oauth2.lua Sun Jul 09 01:31:29 2023 +0700 @@ -1,22 +1,23 @@ -local hashes = require "util.hashes"; +local usermanager = require "core.usermanager"; +local url = require "socket.url"; +local array = require "util.array"; local cache = require "util.cache"; +local encodings = require "util.encodings"; +local errors = require "util.error"; +local hashes = require "util.hashes"; local http = require "util.http"; +local id = require "util.id"; +local it = require "util.iterators"; local jid = require "util.jid"; local json = require "util.json"; -local usermanager = require "core.usermanager"; -local errors = require "util.error"; -local url = require "socket.url"; -local id = require "util.id"; -local encodings = require "util.encodings"; -local base64 = encodings.base64; +local schema = require "util.jsonschema"; +local jwt = require "util.jwt"; local random = require "util.random"; -local schema = require "util.jsonschema"; local set = require "util.set"; -local jwt = require"util.jwt"; -local it = require "util.iterators"; -local array = require "util.array"; local st = require "util.stanza"; +local base64 = encodings.base64; + local function b64url(s) return (base64.encode(s):gsub("[+/=]", { ["+"] = "-", ["/"] = "_", ["="] = "" })) end @@ -27,6 +28,24 @@ end end +local function strict_formdecode(query) + if not query then + return nil; + end + local params = http.formdecode(query); + if type(params) ~= "table" then + return nil, "no-pairs"; + end + local dups = {}; + for _, pair in ipairs(params) do + if dups[pair.name] then + return nil, "duplicate"; + end + dups[pair.name] = true; + end + return params; +end + local function read_file(base_path, fn, required) local f, err = io.open(base_path .. "/" .. fn); if not f then @@ -41,10 +60,14 @@ return data; end +local allowed_locales = module:get_option_array("allowed_oauth2_locales", {}); +-- TODO Allow translations or per-locale templates somehow. + local template_path = module:get_option_path("oauth2_template_path", "html"); local templates = { login = read_file(template_path, "login.html", true); consent = read_file(template_path, "consent.html", true); + oob = read_file(template_path, "oob.html", true); error = read_file(template_path, "error.html", true); css = read_file(template_path, "style.css"); js = read_file(template_path, "script.js"); @@ -52,7 +75,9 @@ local site_name = module:get_option_string("site_name", module.host); -local _render_html = require"util.interpolation".new("%b{}", st.xml_escape); +local security_policy = module:get_option_string("oauth2_security_policy", "default-src 'self'"); + +local render_html = require"util.interpolation".new("%b{}", st.xml_escape); local function render_page(template, data, sensitive) data = data or {}; data.site_name = site_name; @@ -60,16 +85,19 @@ status_code = data.error and data.error.code or 200; headers = { ["Content-Type"] = "text/html; charset=utf-8"; - ["Content-Security-Policy"] = "default-src 'self'"; + ["Content-Security-Policy"] = security_policy; ["Referrer-Policy"] = "no-referrer"; ["X-Frame-Options"] = "DENY"; ["Cache-Control"] = (sensitive and "no-store" or "no-cache")..", private"; + ["Pragma"] = "no-cache"; }; - body = _render_html(template, data); + body = render_html(template, data); }; return resp; end +local authorization_server_metadata = nil; + local tokens = module:depends("tokenauth"); local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 86400); @@ -92,6 +120,21 @@ sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options); end +-- verify and prepare client structure +local function check_client(client_id) + if not verify_client then + return nil, "client-registration-not-enabled"; + end + + local ok, client = verify_client(client_id); + if not ok then + return ok, client; + end + + client.client_hash = b64url(hashes.sha256(client_id)); + return client; +end + -- scope : string | array | set -- -- at each step, allow the same or a subset of scopes @@ -103,7 +146,16 @@ return array(scope_string:gmatch("%S+")); end -local openid_claims = set.new({ "openid"; "profile"; "email"; "address"; "phone" }); +local openid_claims = set.new(); +module:add_item("openid-claim", "openid"); + +module:handle_items("openid-claim", function(event) + authorization_server_metadata = nil; + openid_claims:add(event.item); +end, function() + authorization_server_metadata = nil; + openid_claims = set.new(module:get_host_items("openid-claim")); +end, true); -- array -> array, array, array local function split_scopes(scope_list) @@ -196,7 +248,13 @@ -- properties that are deemed useful e.g. in case tokens issued to a certain -- client needs to be revoked local function client_subset(client) - return { name = client.client_name; uri = client.client_uri; id = client.software_id; version = client.software_version }; + return { + name = client.client_name; + uri = client.client_uri; + id = client.software_id; + version = client.software_version; + hash = client.client_hash; + }; end local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info) @@ -328,24 +386,14 @@ local redirect_uri = get_redirect_uri(client, params.redirect_uri); if redirect_uri == oob_uri then - -- TODO some nicer template page - -- mod_http_errors will set content-type to text/html if it catches this - -- event, if not text/plain is kept for the fallback text. - local response = { status_code = 200; headers = { content_type = "text/plain" } } - response.body = module:context("*"):fire_event("http-message", { - response = response; - title = "Your authorization code"; - message = "Here's your authorization code, copy and paste it into " .. (client.client_name or "your client"); - extra = code; - }) or ("Here's your authorization code:\n%s\n"):format(code); - return response; + return render_page(templates.oob, { client = client; authorization_code = code }, true); elseif not redirect_uri then return oauth_error("invalid_redirect_uri"); end local redirect = url.parse(redirect_uri); - local query = http.formdecode(redirect.query or ""); + local query = strict_formdecode(redirect.query); if type(query) ~= "table" then query = {}; end table.insert(query, { name = "code", value = code }); table.insert(query, { name = "iss", value = get_issuer() }); @@ -357,6 +405,8 @@ return { status_code = 303; headers = { + cache_control = "no-store"; + pragma = "no-cache"; location = url.build(redirect); }; } @@ -379,6 +429,8 @@ return { status_code = 303; headers = { + cache_control = "no-store"; + pragma = "no-cache"; location = url.build(redirect); }; } @@ -401,8 +453,8 @@ return oauth_error("invalid_scope", "unknown scope requested"); end - local client_ok, client = verify_client(params.client_id); - if not client_ok then + local client = check_client(params.client_id); + if not client then return oauth_error("invalid_client", "incorrect credentials"); end @@ -414,6 +466,7 @@ if err then error(err); end -- MUST NOT use the authorization code more than once, so remove it to -- prevent a second attempted use + -- TODO if a second attempt *is* made, revoke any tokens issued codes:set(params.client_id .. "#" .. params.code, nil); if not code or type(code) ~= "table" or code_expired(code) then module:log("debug", "authorization_code invalid or expired: %q", code); @@ -436,8 +489,8 @@ if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end if not params.refresh_token then return oauth_error("invalid_request", "missing 'refresh_token'"); end - local client_ok, client = verify_client(params.client_id); - if not client_ok then + local client = check_client(params.client_id); + if not client then return oauth_error("invalid_client", "incorrect credentials"); end @@ -451,6 +504,13 @@ return oauth_error("invalid_grant", "invalid refresh token"); end + local refresh_token_client = refresh_token_info.grant.data.oauth2_client; + if not refresh_token_client.hash or refresh_token_client.hash ~= client.client_hash then + module:log("warn", "OAuth client %q (%s) tried to use refresh token belonging to %q (%s)", client.client_name, client.client_hash, + refresh_token_client.name, refresh_token_client.hash); + return oauth_error("unauthorized_client", "incorrect credentials"); + end + local refresh_scopes = refresh_token_info.grant.data.oauth2_scopes; if params.scope then @@ -514,7 +574,7 @@ user = { username = username; host = module.host; - token = new_user_token({ username = username, host = module.host }); + token = new_user_token({ username = username; host = module.host; auth_time = os.time() }); }; }; elseif form.user_token and form.consent then @@ -607,7 +667,7 @@ if not redirect_uri or redirect_uri == oob_uri then return render_error(err); end - local q = request.url.query and http.formdecode(request.url.query); + local q = strict_formdecode(request.url.query); local redirect_query = url.parse(redirect_uri); local sep = redirect_query.query and "&" or "?"; redirect_uri = redirect_uri @@ -617,12 +677,18 @@ return { status_code = 303; headers = { + cache_control = "no-store"; + pragma = "no-cache"; location = redirect_uri; }; }; end -local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password", "refresh_token"}) +local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", { + "authorization_code"; + "password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used. + "refresh_token"; +}) for handler_type in pairs(grant_type_handlers) do if not allowed_grant_type_handlers:contains(handler_type) then module:log("debug", "Grant type %q disabled", handler_type); @@ -657,7 +723,9 @@ local credentials = get_request_credentials(event.request); event.response.headers.content_type = "application/json"; - local params = http.formdecode(event.request.body); + event.response.headers.cache_control = "no-store"; + event.response.headers.pragma = "no-cache"; + local params = strict_formdecode(event.request.body); if not params then return oauth_error("invalid_request"); end @@ -683,7 +751,7 @@ if not request.url.query then return render_error(oauth_error("invalid_request", "Missing query parameters")); end - local params = http.formdecode(request.url.query); + local params = strict_formdecode(request.url.query); if not params then return render_error(oauth_error("invalid_request", "Invalid query parameters")); end @@ -692,9 +760,9 @@ return render_error(oauth_error("invalid_request", "Missing 'client_id' parameter")); end - local ok, client = verify_client(params.client_id); + local client = check_client(params.client_id); - if not ok then + if not client then return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter")); end @@ -718,13 +786,31 @@ end); end + -- The 'prompt' parameter from OpenID Core + local prompt = set.new(parse_scopes(params.prompt or "select_account login consent")); + if prompt:contains("none") then + -- Client wants no interaction, only confirmation of prior login and + -- consent, but this is not implemented. + return error_response(request, redirect_uri, oauth_error("interaction_required")); + elseif not prompt:contains("select_account") then + -- TODO If the login page is split into account selection followed by login + -- (e.g. password), and then the account selection could be skipped iff the + -- 'login_hint' parameter is present. + return error_response(request, redirect_uri, oauth_error("account_selection_required")); + elseif not prompt:contains("login") then + -- Currently no cookies or such are used, so login is required every time. + return error_response(request, redirect_uri, oauth_error("login_required")); + elseif not prompt:contains("consent") then + -- Are there any circumstances when consent would be implied or assumed? + return error_response(request, redirect_uri, oauth_error("consent_required")); + end + local auth_state = get_auth_state(request); if not auth_state.user then -- Render login page local extra = {}; if params.login_hint then extra.username_hint = (jid.prepped_split(params.login_hint)); - extra.no_username_hint = not extra.username_hint; end return render_page(templates.login, { state = auth_state; client = client; extra = extra }); elseif auth_state.consent == nil then @@ -755,6 +841,7 @@ iss = get_issuer(); sub = url.build({ scheme = "xmpp"; path = user_jid }); aud = params.client_id; + auth_time = auth_state.user.auth_time; nonce = params.nonce; }); local response_type = params.response_type; @@ -771,6 +858,8 @@ local function handle_revocation_request(event) local request, response = event.request, event.response; + response.headers.cache_control = "no-store"; + response.headers.pragma = "no-cache"; if request.headers.authorization then local credentials = get_request_credentials(request); if not credentials or credentials.type ~= "basic" then @@ -783,7 +872,7 @@ end end - local form_data = http.formdecode(event.request.body or ""); + local form_data = strict_formdecode(event.request.body); if not form_data or not form_data.token then response.headers.accept = "application/x-www-form-urlencoded"; return 415; @@ -839,24 +928,31 @@ default = { "code" }; }; client_name = { type = "string" }; - client_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - logo_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; + client_uri = { type = "string"; format = "uri"; pattern = "^https:" }; + logo_uri = { type = "string"; format = "uri"; pattern = "^https:" }; scope = { type = "string" }; contacts = { type = "array"; minItems = 1; items = { type = "string"; format = "email" } }; - tos_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - policy_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - jwks_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; - jwks = { type = "object"; description = "JSON Web Key Set, RFC 7517" }; + tos_uri = { type = "string"; format = "uri"; pattern = "^https:" }; + policy_uri = { type = "string"; format = "uri"; pattern = "^https:" }; software_id = { type = "string"; format = "uuid" }; software_version = { type = "string" }; }; - luaPatternProperties = { - -- Localized versions of descriptive properties and URIs - ["^client_name#"] = { description = "Localized version of 'client_name'"; type = "string" }; - ["^[a-z_]+_uri#"] = { type = "string"; format = "uri"; luaPattern = "^https:" }; - }; } +-- Limit per-locale fields to allowed locales, partly to keep size of client_id +-- down, partly because we don't yet use them for anything. +-- Only relevant for user-visible strings and URIs. +if allowed_locales[1] then + local props = registration_schema.properties; + for _, locale in ipairs(allowed_locales) do + props["client_name#" .. locale] = props["client_name"]; + props["client_uri#" .. locale] = props["client_uri"]; + props["logo_uri#" .. locale] = props["logo_uri"]; + props["tos_uri#" .. locale] = props["tos_uri"]; + props["policy_uri#" .. locale] = props["policy_uri"]; + end +end + local function redirect_uri_allowed(redirect_uri, client_uri, app_type) local uri = url.parse(redirect_uri); if not uri.scheme then @@ -881,6 +977,13 @@ end end + -- MUST ignore any metadata that it does not understand + for propname in pairs(client_metadata) do + if not registration_schema.properties[propname] then + client_metadata[propname] = nil; + end + end + local client_uri = url.parse(client_metadata.client_uri); if not client_uri or client_uri.scheme ~= "https" or loopbacks:contains(client_uri.host) then return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri"); @@ -900,19 +1003,6 @@ end end - for k, v in pairs(client_metadata) do - local base_k = k:match"^([^#]+)#" or k; - if not registration_schema.properties[base_k] or k:find"^client_uri#" then - -- Ignore and strip unknown extra properties - client_metadata[k] = nil; - elseif k:find"_uri#" then - -- Localized URIs should be secure too - if not redirect_uri_allowed(v, client_uri, "web") then - return nil, oauth_error("invalid_client_metadata", "Invalid, insecure or inappropriate informative URI"); - end - end - end - local grant_types = set.new(client_metadata.grant_types); local response_types = set.new(client_metadata.response_types); @@ -928,10 +1018,6 @@ return nil, oauth_error("invalid_client_metadata", "No allowed 'response_types' specified"); end - -- Ensure each signed client_id JWT is unique, short ID and issued at - -- timestamp should be sufficient to rule out brute force attacks - client_metadata.nonce = id.short(); - -- Do we want to keep everything? local client_id = sign_client(client_metadata); @@ -939,7 +1025,14 @@ client_metadata.client_id_issued_at = os.time(); if client_metadata.token_endpoint_auth_method ~= "none" then - local client_secret = make_client_secret(client_id); + -- 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 + -- rule out brute force attacks. + -- Not needed for public clients without a secret, but those are expected + -- to be uncommon since they can only do the insecure implicit flow. + client_metadata.nonce = id.short(); + + local client_secret = make_client_secret(client_id, client_metadata); client_metadata.client_secret = client_secret; client_metadata.client_secret_expires_at = 0; @@ -963,7 +1056,11 @@ return { status_code = 201; - headers = { content_type = "application/json" }; + headers = { + cache_control = "no-store"; + pragma = "no-cache"; + content_type = "application/json"; + }; body = json.encode(response); }; end @@ -1036,6 +1133,7 @@ -- Step 2. User-facing login and consent view ["GET /authorize"] = handle_authorization_request; ["POST /authorize"] = handle_authorization_request; + ["OPTIONS /authorize"] = { status_code = 403; body = "" }; -- Step 3. User is redirected to the 'redirect_uri' along with an -- authorization code. In the insecure 'implicit' flow, the access token @@ -1057,7 +1155,7 @@ headers = { ["Content-Type"] = "text/css"; }; - body = _render_html(templates.css, module:get_option("oauth2_template_style")); + body = render_html(templates.css, module:get_option("oauth2_template_style")); } or nil; ["GET /script.js"] = templates.js and { headers = { @@ -1087,39 +1185,53 @@ -- OIDC Discovery +function get_authorization_server_metadata() + if authorization_server_metadata then + return authorization_server_metadata; + end + authorization_server_metadata = { + -- RFC 8414: OAuth 2.0 Authorization Server Metadata + 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; + 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" }); + 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" }); + 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"; + }); + 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"); + ui_locales_supported = allowed_locales[1] and allowed_locales; + + -- OpenID + userinfo_endpoint = handle_register_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; +end + module:provides("http", { name = "oauth2-discovery"; default_path = "/.well-known/oauth-authorization-server"; cors = { enabled = true }; route = { - ["GET"] = { - headers = { content_type = "application/json" }; - body = json.encode { - -- RFC 8414: OAuth 2.0 Authorization Server Metadata - 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; - 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" }); - 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" }); - 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" }); - 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"); - - -- OpenID - userinfo_endpoint = handle_register_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. - }; - }; + ["GET"] = function() + return { + headers = { content_type = "application/json" }; + body = json.encode(get_authorization_server_metadata()); + } + end }; });