Mercurial > prosody-modules
diff mod_http_oauth2/mod_http_oauth2.lua @ 5650:0eb2d5ea2428
merge
author | Stephen Paul Weber <singpolyma@singpolyma.net> |
---|---|
date | Sat, 06 May 2023 19:40:23 -0500 |
parents | aa068449b0b6 |
children | 5b2352dda31f |
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_http_oauth2/mod_http_oauth2.lua Sat May 06 19:40:23 2023 -0500 @@ -6,51 +6,175 @@ local usermanager = require "core.usermanager"; local errors = require "util.error"; local url = require "socket.url"; -local uuid = require "util.uuid"; +local id = require "util.id"; local encodings = require "util.encodings"; local base64 = encodings.base64; +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 function b64url(s) + return (base64.encode(s):gsub("[+/=]", { ["+"] = "-", ["/"] = "_", ["="] = "" })) +end + +local function tmap(t) + return function(k) + return t[k]; + end +end + +local function read_file(base_path, fn, required) + local f, err = io.open(base_path .. "/" .. fn); + if not f then + module:log(required and "error" or "debug", "Unable to load template file: %s", err); + if required then + return error("Failed to load templates"); + end + return nil; + end + local data = assert(f:read("*a")); + assert(f:close()); + return data; +end + +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); + error = read_file(template_path, "error.html", true); + css = read_file(template_path, "style.css"); + js = read_file(template_path, "script.js"); +}; + +local site_name = module:get_option_string("site_name", module.host); + +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; + local resp = { + status_code = 200; + headers = { + ["Content-Type"] = "text/html; charset=utf-8"; + ["Content-Security-Policy"] = "default-src 'self'"; + ["X-Frame-Options"] = "DENY"; + ["Cache-Control"] = (sensitive and "no-store" or "no-cache")..", private"; + }; + body = _render_html(template, data); + }; + return resp; +end local tokens = module:depends("tokenauth"); -local clients = module:open_store("oauth2_clients", "map"); +local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 86400); +local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", nil); + +-- Used to derive client_secret from client_id, set to enable stateless dynamic registration. +local registration_key = module:get_option_string("oauth2_registration_key"); +local registration_algo = module:get_option_string("oauth2_registration_algorithm", "HS256"); +local registration_ttl = module:get_option("oauth2_registration_ttl", nil); +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 function filter_scopes(username, host, requested_scope_string) - if host ~= module.host then - return usermanager.get_jid_role(username.."@"..host, module.host).name; - end +local verification_key; +local jwt_sign, jwt_verify; +if registration_key then + -- Tie it to the host if global + verification_key = hashes.hmac_sha256(registration_key, module.host); + jwt_sign, jwt_verify = jwt.init(registration_algo, registration_key, registration_key, registration_options); +end + +local function parse_scopes(scope_string) + return array(scope_string:gmatch("%S+")); +end - if requested_scope_string then -- Specific role requested - -- TODO: The requested scope string is technically a space-delimited list - -- of scopes, but for simplicity we're mapping this slot to role names. - if usermanager.user_can_assume_role(username, module.host, requested_scope_string) then - return requested_scope_string; +local openid_claims = set.new({ "openid", "profile"; "email"; "address"; "phone" }); + +local function split_scopes(scope_list) + local claims, roles, unknown = array(), array(), array(); + local all_roles = usermanager.get_all_roles(module.host); + for _, scope in ipairs(scope_list) do + if openid_claims:contains(scope) then + claims:push(scope); + elseif all_roles[scope] then + roles:push(scope); + else + unknown:push(scope); end end + return claims, roles, unknown; +end +local function can_assume_role(username, requested_role) + return usermanager.user_can_assume_role(username, module.host, requested_role); +end + +local function select_role(username, requested_roles) + if requested_roles then + for _, requested_role in ipairs(requested_roles) do + if can_assume_role(username, requested_role) then + return requested_role; + end + end + end + -- otherwise the default role return usermanager.get_user_role(username, module.host).name; end -local function code_expires_in(code) - return os.difftime(os.time(), code.issued); +local function filter_scopes(username, requested_scope_string) + local granted_scopes, requested_roles; + + if requested_scope_string then -- Specific role(s) requested + granted_scopes, requested_roles = split_scopes(parse_scopes(requested_scope_string)); + else + granted_scopes = array(); + end + + local selected_role = select_role(username, requested_roles); + granted_scopes:push(selected_role); + + return granted_scopes:concat(" "), selected_role; end -local function code_expired(code) - return code_expires_in(code) > 120; +local function code_expires_in(code) --> number, seconds until code expires + return os.difftime(code.expires, os.time()); +end + +local function code_expired(code) --> boolean, true: has expired, false: still valid + return code_expires_in(code) < 0; end local codes = cache.new(10000, function (_, code) return code_expired(code) end); -module:add_timer(900, function() +-- Periodically clear out unredeemed codes. Does not need to be exact, expired +-- codes are rejected if tried. Mostly just to keep memory usage in check. +module:hourly("Clear expired authorization codes", function() local k, code = codes:tail(); while code and code_expired(code) do codes:set(k, nil); k, code = codes:tail(); end - return code and code_expires_in(code) + 1 or 900; end) +local function get_issuer() + return (module:http_url(nil, "/"):gsub("/$", "")); +end + +local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" }); +local function is_secure_redirect(uri) + local u = url.parse(uri); + return u.scheme ~= "http" or loopbacks:contains(u.host); +end + local function oauth_error(err_name, err_desc) return errors.new({ type = "modify"; @@ -61,19 +185,69 @@ }); end -local function new_access_token(token_jid, scope, ttl) - local token = tokens.create_jid_token(token_jid, token_jid, scope, ttl); +-- client_id / client_metadata are pretty large, filter out a subset of +-- 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 }; +end + +local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info) + local token_data = { oauth2_scopes = scope_string, oauth2_client = nil }; + if client then + token_data.oauth2_client = client_subset(client); + end + if next(token_data) == nil then + token_data = nil; + end + + local refresh_token; + 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); + -- Create refresh token for the grant if desired + refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant, nil, nil, "oauth2-refresh"); + else + -- Grant exists, reuse existing refresh token + refresh_token = refresh_token_info.token; + + refresh_token_info.grant = nil; -- Prevent reference loop + end + + local access_token, access_token_info = tokens.create_token(token_jid, grant, role, default_access_ttl, "oauth2"); + + local expires_at = access_token_info.expires; return { token_type = "bearer"; - access_token = token; - expires_in = ttl; - scope = scope; - -- TODO: include refresh_token when implemented + access_token = access_token; + expires_in = expires_at and (expires_at - os.time()) or nil; + scope = scope_string; + id_token = id_token; + refresh_token = refresh_token or nil; }; end +local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string + if not query_redirect_uri then + if #client.redirect_uris ~= 1 then + -- Client registered multiple URIs, it needs specify which one to use + return; + end + -- When only a single URI is registered, that's the default + return client.redirect_uris[1]; + end + -- Verify the client-provided URI matches one previously registered + for _, redirect_uri in ipairs(client.redirect_uris) do + if query_redirect_uri == redirect_uri then + return redirect_uri + end + end +end + local grant_type_handlers = {}; local response_type_handlers = {}; +local verifier_transforms = {}; function grant_type_handlers.password(params) local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)")); @@ -88,57 +262,99 @@ end local granted_jid = jid.join(request_username, request_host, request_resource); - local granted_scopes = filter_scopes(request_username, request_host, params.scope); - return json.encode(new_access_token(granted_jid, granted_scopes, nil)); + local granted_scopes, granted_role = filter_scopes(request_username, params.scope); + return json.encode(new_access_token(granted_jid, granted_role, granted_scopes, nil)); end -function response_type_handlers.code(params, granted_jid) - if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end - if not params.redirect_uri then return oauth_error("invalid_request", "missing 'redirect_uri'"); end +function response_type_handlers.code(client, params, granted_jid, id_token) + local request_username, request_host = jid.split(granted_jid); + if not request_host or request_host ~= module.host then + return oauth_error("invalid_request", "invalid JID"); + end + local granted_scopes, granted_role = filter_scopes(request_username, params.scope); - local client_owner, client_host, client_id = jid.prepped_split(params.client_id); - if client_host ~= module.host then - return oauth_error("invalid_client", "incorrect credentials"); - end - local client, err = clients:get(client_owner, client_id); - if err then error(err); end - if not client then - return oauth_error("invalid_client", "incorrect credentials"); + if pkce_required and not params.code_challenge then + return oauth_error("invalid_request", "PKCE required"); end - local granted_scopes = filter_scopes(client_owner, client_host, params.scope); - - local code = uuid.generate(); + local code = id.medium(); local ok = codes:set(params.client_id .. "#" .. code, { - issued = os.time(); + expires = os.time() + 600; granted_jid = granted_jid; granted_scopes = granted_scopes; + granted_role = granted_role; + challenge = params.code_challenge; + challenge_method = params.code_challenge_method; + id_token = id_token; }); if not ok then return {status_code = 429}; end - local redirect = url.parse(params.redirect_uri); + local redirect_uri = get_redirect_uri(client, params.redirect_uri); + if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" 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; + elseif not redirect_uri then + return 400; + end + + local redirect = url.parse(redirect_uri); + local query = http.formdecode(redirect.query or ""); if type(query) ~= "table" then query = {}; end - table.insert(query, { name = "code", value = code }) + table.insert(query, { name = "code", value = code }); + table.insert(query, { name = "iss", value = get_issuer() }); if params.state then table.insert(query, { name = "state", value = params.state }); end redirect.query = http.formencode(query); return { - status_code = 302; + status_code = 303; headers = { location = url.build(redirect); }; } end -local pepper = module:get_option_string("oauth2_client_pepper", ""); +-- Implicit flow +function response_type_handlers.token(client, params, granted_jid) + local request_username, request_host = jid.split(granted_jid); + if not request_host or request_host ~= module.host then + return oauth_error("invalid_request", "invalid JID"); + end + local granted_scopes, granted_role = filter_scopes(request_username, params.scope); + local token_info = new_access_token(granted_jid, granted_role, granted_scopes, client, nil); + + local redirect = url.parse(get_redirect_uri(client, params.redirect_uri)); + if not redirect then return 400; end + token_info.state = params.state; + redirect.fragment = http.formencode(token_info); -local function verify_secret(stored, salt, i, secret) - return base64.decode(stored) == hashes.pbkdf2_hmac_sha256(secret, salt .. pepper, i); + return { + status_code = 303; + headers = { + location = url.build(redirect); + }; + } +end + +local function make_client_secret(client_id) --> client_secret + return hashes.hmac_sha256(verification_key, client_id, true); +end + +local function verify_client_secret(client_id, client_secret) + return hashes.equals(make_client_secret(client_id), client_secret); end function grant_type_handlers.authorization_code(params) @@ -149,49 +365,157 @@ return oauth_error("invalid_scope", "unknown scope requested"); end - local client_owner, client_host, client_id = jid.prepped_split(params.client_id); - if client_host ~= module.host then - module:log("debug", "%q ~= %q", client_host, module.host); + local client_ok, client = jwt_verify(params.client_id); + if not client_ok then return oauth_error("invalid_client", "incorrect credentials"); end - local client, err = clients:get(client_owner, client_id); - if err then error(err); end - if not client or not verify_secret(client.secret_hash, client.salt, client.iteration_count, params.client_secret) then + + 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, err = codes:get(params.client_id .. "#" .. params.code); if err then error(err); end + -- MUST NOT use the authorization code more than once, so remove it to + -- prevent a second attempted use + 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); return oauth_error("invalid_client", "incorrect credentials"); end - assert(codes:set(client_owner, client_id .. "#" .. params.code, nil)); + + -- TODO Decide if the code should be removed or not when PKCE fails + local transform = verifier_transforms[code.challenge_method or "plain"]; + if not transform then + return oauth_error("invalid_request", "unknown challenge transform method"); + elseif transform(params.code_verifier) ~= code.challenge then + return oauth_error("invalid_grant", "incorrect credentials"); + end + + 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 + if not params.refresh_token then return oauth_error("invalid_request", "missing 'refresh_token'"); end + + local client_ok, client = jwt_verify(params.client_id); + if not client_ok then + return oauth_error("invalid_client", "incorrect credentials"); + end - return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil)); + 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"); + end + + -- new_access_token() requires the actual token + refresh_token_info.token = params.refresh_token; + + return json.encode(new_access_token( + refresh_token_info.jid, refresh_token_info.role, refresh_token_info.grant.data.oauth2_scopes, client, nil, refresh_token_info + )); +end + +-- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients + +function verifier_transforms.plain(code_verifier) + -- code_challenge = code_verifier + return code_verifier; +end + +function verifier_transforms.S256(code_verifier) + -- code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + return code_verifier and b64url(hashes.sha256(code_verifier)); end -local function check_credentials(request, allow_token) +-- Used to issue/verify short-lived tokens for the authorization process below +local new_user_token, verify_user_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 }); + +-- From the given request, figure out if the user is authenticated and has granted consent yet +-- As this requires multiple steps (seek credentials, seek consent), we have a lot of state to +-- carry around across requests. We also need to protect against CSRF and session mix-up attacks +-- (e.g. the user may have multiple concurrent flows in progress, session cookies aren't unique +-- to one of them). +-- Our strategy here is to preserve the original query string (containing the authz request), and +-- encode the rest of the flow in form POSTs. +local function get_auth_state(request) + local form = request.method == "POST" + and request.body + and request.body ~= "" + and request.headers.content_type == "application/x-www-form-urlencoded" + and http.formdecode(request.body); + + if type(form) ~= "table" then return {}; end + + if not form.user_token then + -- First step: login + local username = encodings.stringprep.nodeprep(form.username); + local password = encodings.stringprep.saslprep(form.password); + if not (username and password) or not usermanager.test_password(username, module.host, password) then + return { + error = "Invalid username/password"; + }; + end + return { + user = { + username = username; + host = module.host; + token = new_user_token({ username = username, host = module.host }); + }; + }; + elseif form.user_token and form.consent then + -- Second step: consent + local ok, user = verify_user_token(form.user_token); + if not ok then + return { + error = user == "token-expired" and "Session expired - try again" or nil; + }; + end + + local scope = array():append(form):filter(function(field) + return field.name == "scope" or field.name == "role"; + end):pluck("value"):concat(" "); + + user.token = form.user_token; + return { + user = user; + scope = scope; + consent = form.consent == "granted"; + }; + end + + return {}; +end + +local function get_request_credentials(request) + if not request.headers.authorization then return; end + local auth_type, auth_data = string.match(request.headers.authorization, "^(%S+)%s(.+)$"); if auth_type == "Basic" then local creds = base64.decode(auth_data); - if not creds then return false; end + if not creds then return; end local username, password = string.match(creds, "^([^:]+):(.*)$"); - if not username then return false; end - username, password = encodings.stringprep.nodeprep(username), encodings.stringprep.saslprep(password); - if not username then return false; end - if not usermanager.test_password(username, module.host, password) then - return false; - end - return username; - elseif auth_type == "Bearer" and allow_token then - local token_info = tokens.get_token_info(auth_data); - if not token_info or not token_info.session or token_info.session.host ~= module.host then - return false; - end - return token_info.session.username; + if not username then return; end + return { + type = "basic"; + username = username; + password = password; + }; + elseif auth_type == "Bearer" then + return { + type = "bearer"; + bearer_token = auth_data; + }; end + return nil; end @@ -210,7 +534,7 @@ end if request_password == component_secret then local granted_jid = jid.join(request_username, request_host, request_resource); - return json.encode(new_access_token(granted_jid, nil, nil)); + return json.encode(new_access_token(granted_jid, nil, nil, nil)); end return oauth_error("invalid_grant", "incorrect credentials"); end @@ -218,69 +542,178 @@ -- TODO How would this make sense with components? -- Have an admin authenticate maybe? response_type_handlers.code = nil; + response_type_handlers.token = nil; grant_type_handlers.authorization_code = nil; - check_credentials = function () return false end +end + +-- OAuth errors should be returned to the client if possible, i.e. by +-- appending the error information to the redirect_uri and sending the +-- redirect to the user-agent. In some cases we can't do this, e.g. if +-- the redirect_uri is missing or invalid. In those cases, we render an +-- error directly to the user-agent. +local function error_response(request, err) + local q = request.url.query and http.formdecode(request.url.query); + local redirect_uri = q and q.redirect_uri; + if not redirect_uri or not is_secure_redirect(redirect_uri) then + module:log("warn", "Missing or invalid redirect_uri <%s>, rendering error to user-agent", redirect_uri or ""); + return render_page(templates.error, { 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() }); + module:log("warn", "Sending error response to client via redirect to %s", redirect_uri); + return { + status_code = 303; + headers = { + location = redirect_uri; + }; + }; +end + +local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password", "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); + grant_type_handlers[handler_type] = nil; + else + module:log("debug", "Grant type %q enabled", handler_type); + end +end + +-- "token" aka implicit flow is considered insecure +local allowed_response_type_handlers = module:get_option_set("allowed_oauth2_response_types", {"code"}) +for handler_type in pairs(response_type_handlers) do + if not allowed_response_type_handlers:contains(handler_type) then + module:log("debug", "Response type %q disabled", handler_type); + response_type_handlers[handler_type] = nil; + else + module:log("debug", "Response type %q enabled", handler_type); + end +end + +local allowed_challenge_methods = module:get_option_set("allowed_oauth2_code_challenge_methods", { "plain"; "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); + verifier_transforms[handler_type] = nil; + else + module:log("debug", "Challenge method %q enabled", handler_type); + end end function handle_token_grant(event) + local credentials = get_request_credentials(event.request); + event.response.headers.content_type = "application/json"; local params = http.formdecode(event.request.body); if not params then - return oauth_error("invalid_request"); + return error_response(event.request, oauth_error("invalid_request")); end + + if credentials and credentials.type == "basic" then + -- client_secret_basic converted internally to client_secret_post + params.client_id = http.urldecode(credentials.username); + params.client_secret = http.urldecode(credentials.password); + end + local grant_type = params.grant_type local grant_handler = grant_type_handlers[grant_type]; if not grant_handler then - return oauth_error("unsupported_grant_type"); + return error_response(event.request, oauth_error("unsupported_grant_type")); end return grant_handler(params); end local function handle_authorization_request(event) - local request, response = event.request, event.response; - if not request.headers.authorization then - response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name); - return 401; - end - local user = check_credentials(request); - if not user then - return 401; - end - -- TODO ask user for consent here + local request = event.request; + if not request.url.query then - response.headers.content_type = "application/json"; - return oauth_error("invalid_request"); + return error_response(request, oauth_error("invalid_request")); end local params = http.formdecode(request.url.query); if not params then - return oauth_error("invalid_request"); + return error_response(request, oauth_error("invalid_request")); + end + + if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end + + local ok, client = jwt_verify(params.client_id); + + if not ok then + return oauth_error("invalid_client", "incorrect credentials"); + end + + 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 oauth_error("invalid_client", "response_type not allowed"); end + + local auth_state = get_auth_state(request); + if not auth_state.user then + -- Render login page + return render_page(templates.login, { state = auth_state, client = client }); + elseif auth_state.consent == nil then + -- Render consent page + local scopes, requested_roles = split_scopes(parse_scopes(params.scope or "")); + local default_role = select_role(auth_state.user.username, requested_roles); + local roles = array(it.values(usermanager.get_all_roles(module.host))):filter(function(role) + return can_assume_role(auth_state.user.username, role.name); + end):sort(function(a, b) + return (a.priority or 0) < (b.priority or 0) + end):map(function(role) + return { name = role.name; selected = role.name == default_role }; + end); + if not roles[2] then + -- Only one role to choose from, might as well skip the selector + roles = nil; + end + return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes; roles = roles }, true); + elseif not auth_state.consent then + -- Notify client of rejection + return error_response(request, oauth_error("access_denied")); + end + -- else auth_state.consent == true + + params.scope = auth_state.scope; + + local user_jid = jid.join(auth_state.user.username, module.host); + local client_secret = make_client_secret(params.client_id); + local id_token_signer = jwt.new_signer("HS256", client_secret); + local id_token = id_token_signer({ + iss = get_issuer(); + sub = url.build({ scheme = "xmpp"; path = user_jid }); + aud = params.client_id; + nonce = params.nonce; + }); local response_type = params.response_type; local response_handler = response_type_handlers[response_type]; if not response_handler then - response.headers.content_type = "application/json"; - return oauth_error("unsupported_response_type"); + return error_response(request, oauth_error("unsupported_response_type")); end - return response_handler(params, jid.join(user, module.host)); + return response_handler(client, params, user_jid, id_token); end local function handle_revocation_request(event) local request, response = event.request, event.response; - if not request.headers.authorization then - response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name); - return 401; - elseif request.headers.content_type ~= "application/x-www-form-urlencoded" - or not request.body or request.body == "" then - return 400; - end - local user = check_credentials(request, true); - if not user then - return 401; + if request.headers.authorization then + local credentials = get_request_credentials(request); + if not credentials or credentials.type ~= "basic" then + 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 end - local form_data = http.formdecode(event.request.body); + local form_data = http.formdecode(event.request.body or ""); if not form_data or not form_data.token then - return 400; + response.headers.accept = "application/x-www-form-urlencoded"; + return 415; end local ok, err = tokens.revoke_token(form_data.token); if not ok then @@ -290,12 +723,268 @@ return 200; end +local registration_schema = { + type = "object"; + required = { + -- These are shown to users in the template + "client_name"; + "client_uri"; + -- We need at least one redirect URI for things to work + "redirect_uris"; + }; + properties = { + redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } }; + token_endpoint_auth_method = { + type = "string"; + enum = { "none"; "client_secret_post"; "client_secret_basic" }; + default = "client_secret_basic"; + }; + grant_types = { + type = "array"; + items = { + type = "string"; + enum = { + "authorization_code"; + "implicit"; + "password"; + "client_credentials"; + "refresh_token"; + "urn:ietf:params:oauth:grant-type:jwt-bearer"; + "urn:ietf:params:oauth:grant-type:saml2-bearer"; + }; + }; + default = { "authorization_code" }; + }; + application_type = { type = "string"; enum = { "native"; "web" }; default = "web" }; + response_types = { type = "array"; items = { type = "string"; enum = { "code"; "token" } }; default = { "code" } }; + client_name = { type = "string" }; + client_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; + logo_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; + scope = { type = "string" }; + contacts = { type = "array"; 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" }; + 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:" }; + }; +} + +local function redirect_uri_allowed(redirect_uri, client_uri, app_type) + local uri = url.parse(redirect_uri); + if app_type == "native" then + return uri.scheme == "http" and loopbacks:contains(uri.host) or uri.scheme ~= "https"; + elseif app_type == "web" then + return uri.scheme == "https" and uri.host == client_uri.host; + end +end + +function create_client(client_metadata) + if not schema.validate(registration_schema, client_metadata) then + return nil, oauth_error("invalid_request", "Failed schema validation."); + end + + -- Fill in default values + for propname, propspec in pairs(registration_schema.properties) do + if client_metadata[propname] == nil and type(propspec) == "table" and propspec.default ~= nil then + client_metadata[propname] = propspec.default; + 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"); + end + + for _, redirect_uri in ipairs(client_metadata.redirect_uris) do + if not redirect_uri_allowed(redirect_uri, client_uri, client_metadata.application_type) then + return nil, oauth_error("invalid_redirect_uri", "Invalid, insecure or inappropriate redirect URI."); + end + end + + for field, prop_schema in pairs(registration_schema.properties) do + if field ~= "client_uri" and prop_schema.format == "uri" and client_metadata[field] then + if not redirect_uri_allowed(client_metadata[field], client_uri, "web") then + return nil, oauth_error("invalid_client_metadata", "Invalid, insecure or inappropriate informative URI"); + end + 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); + + 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 + + -- 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 = jwt_sign(client_metadata); + + client_metadata.client_id = client_id; + 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); + client_metadata.client_secret = client_secret; + client_metadata.client_secret_expires_at = 0; + + if not registration_options.accept_expired then + client_metadata.client_secret_expires_at = client_metadata.client_id_issued_at + (registration_options.default_ttl or 3600); + end + end + + return client_metadata; +end + +local function handle_register_request(event) + local request = event.request; + local client_metadata, err = json.decode(request.body); + if err then + return oauth_error("invalid_request", "Invalid JSON"); + end + + local response, err = create_client(client_metadata); + if err then return err end + + return { + status_code = 201; + headers = { content_type = "application/json" }; + body = json.encode(response); + }; +end + +if not registration_key then + module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled") + handle_authorization_request = nil + handle_register_request = nil +end + +local function handle_userinfo_request(event) + local request = event.request; + local credentials = get_request_credentials(request); + if not credentials or not credentials.bearer_token then + module:log("debug", "Missing credentials for UserInfo endpoint: %q", credentials) + return 401; + end + local token_info,err = tokens.get_token_info(credentials.bearer_token); + if not token_info then + module:log("debug", "UserInfo query failed token validation: %s", err) + return 403; + end + local scopes = set.new() + if type(token_info.grant.data) == "table" and type(token_info.grant.data.oauth2_scopes) == "string" then + scopes:add_list(parse_scopes(token_info.grant.data.oauth2_scopes)); + else + module:log("debug", "token_info = %q", token_info) + end + + if not scopes:contains("openid") then + module:log("debug", "Missing the 'openid' scope in %q", scopes) + -- The 'openid' scope is required for access to this endpoint. + return 403; + end + + local user_info = { + iss = get_issuer(); + sub = url.build({ scheme = "xmpp"; path = token_info.jid }); + } + + local token_claims = set.intersection(openid_claims, scopes); + token_claims:remove("openid"); -- that's "iss" and "sub" above + if not token_claims:empty() then + -- Another module can do that + module:fire_event("token/userinfo", { + token = token_info; + claims = token_claims; + username = jid.split(token_info.jid); + userinfo = user_info; + }); + end + + return { + status_code = 200; + headers = { content_type = "application/json" }; + body = json.encode(user_info); + }; +end + module:depends("http"); module:provides("http", { route = { - ["POST /token"] = handle_token_grant; + -- OAuth 2.0 in 5 simple steps! + -- This is the normal 'authorization_code' flow. + + -- Step 1. Create OAuth client + ["POST /register"] = handle_register_request; + + -- Step 2. User-facing login and consent view ["GET /authorize"] = handle_authorization_request; + ["POST /authorize"] = handle_authorization_request; + + -- Step 3. User is redirected to the 'redirect_uri' along with an + -- authorization code. In the insecure 'implicit' flow, the access token + -- is delivered here. + + -- Step 4. Retrieve access token using the code. + ["POST /token"] = handle_token_grant; + + -- Step 4 is later repeated using the refresh token to get new access tokens. + + -- Step 5. Revoke token (access or refresh) ["POST /revoke"] = handle_revocation_request; + + -- OpenID + ["GET /userinfo"] = handle_userinfo_request; + + -- Optional static content for templates + ["GET /style.css"] = templates.css and { + headers = { + ["Content-Type"] = "text/css"; + }; + body = _render_html(templates.css, module:get_option("oauth2_template_style")); + } or nil; + ["GET /script.js"] = templates.js and { + headers = { + ["Content-Type"] = "text/javascript"; + }; + body = templates.js; + } or nil; + + -- Some convenient fallback handlers + ["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; }; }); @@ -310,3 +999,41 @@ event.response.status_code = event.error.code or 400; return json.encode(oauth2_response); end, 5); + +-- OIDC Discovery + +module:provides("http", { + name = "oauth2-discovery"; + default_path = "/.well-known/oauth-authorization-server"; + 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; + jwks_uri = nil; -- TODO? + 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))):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; + id_token_signing_alg_values_supported = { "HS256" }; + }; + }; + }; +}); + +module:shared("tokenauth/oauthbearer_config").oidc_discovery_url = module:http_url("oauth2-discovery", "/.well-known/oauth-authorization-server");