Software /
code /
prosody-modules
Diff
mod_http_oauth2/mod_http_oauth2.lua @ 5856:75dee6127829
Merge upstream
author | Trần H. Trung <xmpp:trần.h.trung@trung.fun> |
---|---|
date | Tue, 06 Feb 2024 18:32:01 +0700 |
parent | 5853:b109773ce6fe |
child | 5882:761142ee0ff2 |
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua Tue Aug 29 23:51:17 2023 +0700 +++ b/mod_http_oauth2/mod_http_oauth2.lua Tue Feb 06 18:32:01 2024 +0700 @@ -111,7 +111,8 @@ local registration_options = module:get_option("oauth2_registration_options", { default_ttl = registration_ttl; accept_expired = not registration_ttl }); -local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", false); +local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", true); +local respect_prompt = module:get_option_boolean("oauth2_respect_oidc_prompt", false); local verification_key; local sign_client, verify_client; @@ -138,6 +139,8 @@ return client; end +local purpose_map = { ["oauth2-refresh"] = "refresh_token"; ["oauth"] = "access_token" }; + -- scope : string | array | set -- -- at each step, allow the same or a subset of scopes @@ -212,12 +215,19 @@ return code_expires_in(code) < 0; end +-- LRU cache for short-term storage of authorization codes and device codes local codes = cache.new(10000, function (_, code) + -- If the cache is full and the oldest item hasn't expired yet then we + -- might be under some kind of DoS attack, so might as well reject further + -- entries for a bit. return code_expired(code) end); -- Clear out unredeemed codes so they don't linger in memory. module:daily("Clear expired authorization codes", function() + -- The tail should be the least recently touched item, and most likely to + -- have expired already, so check and remove that one until encountering + -- one that has not expired. local k, code = codes:tail(); while code and code_expired(code) do codes:set(k, nil); @@ -242,7 +252,7 @@ type = "modify"; condition = "bad-request"; code = err_name == "invalid_client" and 401 or 400; - text = err_desc and (err_name..": "..err_desc) or err_name; + text = err_desc or err_name:gsub("^.", string.upper):gsub("_", " "); extra = { oauth2_response = { error = err_name, error_description = err_desc } }; }); end @@ -272,7 +282,7 @@ local grant = refresh_token_info and refresh_token_info.grant; if not grant then -- No existing grant, create one - grant = tokens.create_grant(token_jid, token_jid, default_refresh_ttl, token_data); + grant = tokens.create_grant(token_jid, token_jid, nil, token_data); end if refresh_token_info then @@ -284,7 +294,7 @@ end end -- in with the new refresh token - local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, nil, "oauth2-refresh"); + local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh"); if role == "xmpp" then -- Special scope meaning the users default role. @@ -390,13 +400,15 @@ end local granted_scopes, granted_role = filter_scopes(request_username, params.scope); - if pkce_required and not params.code_challenge then + local redirect_uri = get_redirect_uri(client, params.redirect_uri); + + if pkce_required and not params.code_challenge and redirect_uri ~= device_uri and redirect_uri ~= oob_uri then return oauth_error("invalid_request", "PKCE required"); end local prefix = "authorization_code:"; local code = id.medium(); - if params.redirect_uri == device_uri then + if redirect_uri == device_uri then local is_device, device_state = verify_device_token(params.state); if is_device then -- reconstruct the device_code @@ -419,7 +431,6 @@ return oauth_error("temporarily_unavailable"); end - local redirect_uri = get_redirect_uri(client, params.redirect_uri); if redirect_uri == oob_uri then return render_page(templates.oob, { client = client; authorization_code = code }, true); elseif redirect_uri == device_uri then @@ -630,16 +641,32 @@ -- First step: login local username = encodings.stringprep.nodeprep(form.username); local password = encodings.stringprep.saslprep(form.password); + -- Many things hooked to authentication-{success,failure} don't expect + -- non-XMPP sessions so here's something close enough... + local auth_event = { + session = { + type = "http"; + ip = request.ip; + conn = request.conn; + username = username; + host = module.host; + log = request.log; + sasl_handler = { username = username; selected = "x-www-form" }; + client_id = request.headers.user_agent; + }; + }; if not (username and password) or not usermanager.test_password(username, module.host, password) then + module:fire_event("authentication-failure", auth_event); return { error = "Invalid username/password"; }; end + module:fire_event("authentication-success", auth_event); return { user = { username = username; host = module.host; - token = new_user_token({ username = username; host = module.host; auth_time = os.time() }); + token = new_user_token({ username = username; host = module.host; amr = { "pwd" } }); }; }; elseif form.user_token and form.consent then @@ -729,7 +756,7 @@ -- 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 then + if not redirect_uri or redirect_uri == oob_uri or redirect_uri == device_uri then return render_error(err); end local q = strict_formdecode(request.url.query); @@ -738,7 +765,7 @@ redirect_uri = redirect_uri .. sep .. http.formencode(err.extra.oauth2_response) .. "&" .. http.formencode({ state = q.state, iss = get_issuer() }); - module:log("warn", "Sending error response to client via redirect to %s", redirect_uri); + module:log("debug", "Sending error response to client via redirect to %s", redirect_uri); return { status_code = 303; headers = { @@ -751,7 +778,6 @@ 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"; device_uri; }) @@ -781,7 +807,7 @@ end end -local allowed_challenge_methods = module:get_option_set("allowed_oauth2_code_challenge_methods", { "plain"; "S256" }) +local allowed_challenge_methods = module:get_option_set("allowed_oauth2_code_challenge_methods", { "S256" }) for handler_type in pairs(verifier_transforms) do if not allowed_challenge_methods:contains(handler_type) then module:log("debug", "Challenge method %q disabled", handler_type); @@ -859,37 +885,56 @@ 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 prompt = set.new(parse_scopes(respect_prompt and params.prompt or "select_account login consent")); local auth_state = get_auth_state(request); if not auth_state.user then + if 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")); + end + -- Render login page local extra = {}; if params.login_hint then - extra.username_hint = (jid.prepped_split(params.login_hint)); + extra.username_hint = (jid.prepped_split(params.login_hint) or encodings.stringprep.nodeprep(params.login_hint)); + 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")); end return render_page(templates.login, { state = auth_state; client = client; extra = extra }); elseif auth_state.consent == nil then - -- Render consent page local scopes, roles = split_scopes(requested_scopes); roles = user_assumable_roles(auth_state.user.username, roles); - return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true); + + if not prompt:contains("consent") then + local grants = tokens.get_user_grants(auth_state.user.username); + local matching_grant; + if grants then + for grant_id, grant in pairs(grants) do + if grant.data and grant.data.oauth2_client and grant.data.oauth2_client.hash == client.client_hash then + if set.new(parse_scopes(grant.data.oauth2_scopes)) == set.new(scopes+roles) then + matching_grant = grant_id; + break + end + end + end + end + + if not matching_grant then + return error_response(request, redirect_uri, oauth_error("consent_required")); + else + -- Consent for these scopes already granted to this exact client, continue + auth_state.scopes = scopes + roles; + auth_state.consent = "granted"; + end + + else + -- Render consent page + return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true); + end elseif not auth_state.consent then -- Notify client of rejection if redirect_uri == device_uri then @@ -923,8 +968,9 @@ iss = get_issuer(); sub = url.build({ scheme = "xmpp"; path = user_jid }); aud = params.client_id; - auth_time = auth_state.user.auth_time; + auth_time = auth_state.user.iat; nonce = params.nonce; + amr = auth_state.user.amr; }); local response_type = params.response_type; local response_handler = response_type_handlers[response_type]; @@ -1046,15 +1092,67 @@ } end +local function handle_introspection_request(event) + local request = event.request; + local credentials = get_request_credentials(request); + if not credentials or credentials.type ~= "basic" then + event.response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name); + return 401; + end + -- OAuth "client" credentials + if not verify_client_secret(credentials.username, credentials.password) then + return 401; + end + + local client = check_client(credentials.username); + if not client then + return 401; + end + + local form_data = http.formdecode(request.body or "="); + local token = form_data.token; + if not token then + return 400; + end + + local token_info = tokens.get_token_info(form_data.token); + if not token_info then + return { headers = { content_type = "application/json" }; body = json.encode { active = false } }; + end + local token_client = token_info.grant.data.oauth2_client; + if not token_client or token_client.hash ~= client.client_hash then + return 403; + end + + return { + headers = { content_type = "application/json" }; + body = json.encode { + active = true; + client_id = credentials.username; -- We don't really know for sure + username = jid.node(token_info.jid); + scope = token_info.grant.data.oauth2_scopes; + token_type = purpose_map[token_info.purpose]; + exp = token.expires; + iat = token.created; + sub = url.build({ scheme = "xmpp"; path = token_info.jid }); + aud = credentials.username; + iss = get_issuer(); + jti = token_info.id; + }; + }; +end + +-- RFC 7009 says that the authorization server should validate that only the client that a token was issued to should be able to revoke it. However +-- this would prevent someone who comes across a leaked token from doing the responsible thing and revoking it, so this is not enforced by default. local strict_auth_revoke = module:get_option_boolean("oauth2_require_auth_revoke", false); 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 + local credentials = get_request_credentials(request); + if credentials then + if credentials.type ~= "basic" then response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name); return 401; end @@ -1074,6 +1172,22 @@ response.headers.accept = "application/x-www-form-urlencoded"; return 415; end + + if credentials then + local client = check_client(credentials.username); + if not client then + return 401; + end + local token_info = tokens.get_token_info(form_data.token); + if not token_info then + return 404; + end + local token_client = token_info.grant.data.oauth2_client; + if not token_client or token_client.hash ~= client.client_hash then + return 403; + end + end + local ok, err = tokens.revoke_token(form_data.token); if not ok then module:log("warn", "Unable to revoke token: %s", tostring(err)); @@ -1084,6 +1198,7 @@ local registration_schema = { title = "OAuth 2.0 Dynamic Client Registration Protocol"; + description = "This endpoint allows dynamically registering an OAuth 2.0 client."; type = "object"; required = { -- These are shown to users in the template @@ -1098,16 +1213,30 @@ type = "array"; minItems = 1; uniqueItems = true; - items = { title = "Redirect URI"; type = "string"; format = "uri" }; + items = { + title = "Redirect URI"; + type = "string"; + format = "uri"; + examples = { + "https://app.example.com/redirect"; + "http://localhost:8080/redirect"; + "com.example.app:/redirect"; + oob_uri; + device_uri; + }; + }; }; token_endpoint_auth_method = { title = "Token Endpoint Authentication Method"; + description = "Authentication method the client intends to use. Recommended is `client_secret_basic`. \z + `none` is only allowed for use with the insecure Implicit flow."; type = "string"; enum = { "none"; "client_secret_post"; "client_secret_basic" }; default = "client_secret_basic"; }; grant_types = { title = "Grant Types"; + description = "List of grant types the client intends to use."; type = "array"; minItems = 1; uniqueItems = true; @@ -1129,8 +1258,9 @@ application_type = { title = "Application Type"; description = "Determines which kinds of redirect URIs the client may register. \z - The value 'web' limits the client to https:// URLs with the same hostname as in 'client_uri' \z - while the value 'native' allows either loopback http:// URLs or application specific URIs."; + The value `web` limits the client to `https://` URLs with the same hostname as \z + in `client_uri` while the value `native` allows either loopback URLs like \z + `http://localhost:8080/` or application specific URIs like `com.example.app:/redirect`."; type = "string"; enum = { "native"; "web" }; default = "web"; @@ -1150,10 +1280,12 @@ }; client_uri = { title = "Client URL"; - description = "Should be an link to a page with information about the client."; + description = "Should be an link to a page with information about the client. \z + The hostname in this URL must be the same as in every other '_uri' property."; type = "string"; format = "uri"; pattern = "^https:"; + examples = { "https://app.example.com/" }; }; logo_uri = { title = "Logo URL"; @@ -1161,11 +1293,13 @@ type = "string"; format = "uri"; pattern = "^https:"; + examples = { "https://app.example.com/appicon.png" }; }; scope = { title = "Scopes"; description = "Space-separated list of scopes the client promises to restrict itself to."; type = "string"; + examples = { "openid xmpp" }; }; contacts = { title = "Contact Addresses"; @@ -1177,17 +1311,19 @@ tos_uri = { title = "Terms of Service URL"; description = "Link to Terms of Service for the client, presented to the user in the consent dialog. \z - MUST be a https:// URL with hostname matching that of 'client_uri'."; + MUST be a `https://` URL with hostname matching that of `client_uri`."; type = "string"; format = "uri"; pattern = "^https:"; + examples = { "https://app.example.com/tos.html" }; }; policy_uri = { title = "Privacy Policy URL"; - description = "Link to a Privacy Policy for the client. MUST be a https:// URL with hostname matching that of 'client_uri'."; + description = "Link to a Privacy Policy for the client. MUST be a `https://` URL with hostname matching that of `client_uri`."; type = "string"; format = "uri"; pattern = "^https:"; + examples = { "https://app.example.com/policy.pdf" }; }; software_id = { title = "Software ID"; @@ -1200,7 +1336,7 @@ description = "Version of the client software being registered. \z E.g. to allow revoking all related tokens in the event of a security incident."; type = "string"; - example = "2.3.1"; + examples = { "2.3.1" }; }; }; } @@ -1221,6 +1357,9 @@ local function redirect_uri_allowed(redirect_uri, client_uri, app_type) local uri = url.parse(redirect_uri); + if not uri then + return false; + end if not uri.scheme then return false; -- no relative URLs end @@ -1232,8 +1371,24 @@ end function create_client(client_metadata) - if not schema.validate(registration_schema, client_metadata) then - return nil, oauth_error("invalid_request", "Failed schema validation."); + local valid, validation_errors = schema.validate(registration_schema, client_metadata); + if not valid then + return nil, errors.new({ + type = "modify"; + condition = "bad-request"; + code = 400; + text = "Failed schema validation."; + extra = { + oauth2_response = { + error = "invalid_request"; + error_description = "Client registration data failed schema validation."; -- TODO Generate from validation_errors? + -- JSON Schema Output Format + -- https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01#name-basic + valid = false; + errors = validation_errors; + }; + }; + }); end local client_uri = url.parse(client_metadata.client_uri); @@ -1289,6 +1444,15 @@ 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 + -- 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(); + end + -- Do we want to keep everything? local client_id = sign_client(client_metadata); @@ -1296,14 +1460,7 @@ client_metadata.client_id_issued_at = os.time(); 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 - -- 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); + local client_secret = make_client_secret(client_id); client_metadata.client_secret = client_secret; client_metadata.client_secret_expires_at = 0; @@ -1424,6 +1581,9 @@ -- Step 5. Revoke token (access or refresh) ["POST /revoke"] = handle_revocation_request; + -- Get info about a token + ["POST /introspect"] = handle_introspection_request; + -- OpenID ["GET /userinfo"] = handle_userinfo_request; @@ -1432,7 +1592,7 @@ headers = { ["Content-Type"] = "text/css"; }; - body = render_html(templates.css, module:get_option("oauth2_template_style")); + body = templates.css; } or nil; ["GET /script.js"] = templates.js and { headers = { @@ -1445,6 +1605,7 @@ ["GET /register"] = { headers = { content_type = "application/schema+json" }; body = json.encode(registration_schema) }; ["GET /token"] = function() return 405; end; ["GET /revoke"] = function() return 405; end; + ["GET /introspect"] = function() return 405; end; }; }); @@ -1481,6 +1642,8 @@ 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"; + introspection_endpoint = handle_introspection_request and module:http_url() .. "/introspect"; + introspection_endpoint_auth_methods_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" });