Software /
code /
prosody-modules
Changeset
5193:2bb29ece216b
mod_http_oauth2: Implement stateless dynamic client registration
Replaces previous explicit registration that required either the
additional module mod_adhoc_oauth2_client or manually editing the
database. That method was enough to have something to test with, but
would not probably not scale easily.
Dynamic client registration allows creating clients on the fly, which
may be even easier in theory.
In order to not allow basically unauthenticated writes to the database,
we implement a stateless model here.
per_host_key := HMAC(config -> oauth2_registration_key, hostname)
client_id := JWT { client metadata } signed with per_host_key
client_secret := HMAC(per_host_key, client_id)
This should ensure everything we need to know is part of the client_id,
allowing redirects etc to be validated, and the client_secret can be
validated with only the client_id and the per_host_key.
A nonce injected into the client_id JWT should ensure nobody can submit
the same client metadata and retrieve the same client_secret
author | Kim Alvefur <zash@zash.se> |
---|---|
date | Fri, 03 Mar 2023 21:14:19 +0100 |
parents | 5192:03aa9baa9ac3 |
children | 5194:25041e15994e |
files | mod_http_oauth2/mod_http_oauth2.lua |
diffstat | 1 files changed, 114 insertions(+), 28 deletions(-) [+] |
line wrap: on
line diff
--- a/mod_http_oauth2/mod_http_oauth2.lua Fri Mar 03 19:21:38 2023 +0000 +++ b/mod_http_oauth2/mod_http_oauth2.lua Fri Mar 03 21:14:19 2023 +0100 @@ -9,10 +9,24 @@ local uuid = require "util.uuid"; local encodings = require "util.encodings"; local base64 = encodings.base64; +local schema = require "util.jsonschema"; +local jwt = require"util.jwt"; local tokens = module:depends("tokenauth"); -local clients = module:open_store("oauth2_clients", "map"); +-- 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_options = module:get_option("oauth2_registration_options", { default_ttl = 60 * 60 * 24 * 90 }); + +local jwt_sign, jwt_verify; +if not registration_key then + module:log("error", "Missing required 'oauth2_registration_key', generate a strong key and configure it") +else + -- Tie it to the host if global + registration_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 filter_scopes(username, host, requested_scope_string) if host ~= module.host then @@ -72,6 +86,14 @@ }; end +local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string + for _, redirect_uri in ipairs(client.redirect_uris) do + if query_redirect_uri == nil or query_redirect_uri == redirect_uri then + return redirect_uri + end + end +end + local grant_type_handlers = {}; local response_type_handlers = {}; @@ -97,13 +119,9 @@ function response_type_handlers.code(params, granted_jid) if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end - 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 + local ok, client = jwt_verify(params.client_id); + + if not ok then return oauth_error("invalid_client", "incorrect credentials"); end @@ -120,7 +138,7 @@ return {status_code = 429}; end - local redirect_uri = params.redirect_uri or client.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 local response = { status_code = 200; headers = { content_type = "text/plain" } } @@ -156,20 +174,17 @@ function response_type_handlers.token(params, granted_jid) if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end - 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 + local client = jwt_verify(params.client_id); + if not client then return oauth_error("invalid_client", "incorrect credentials"); end - local granted_scopes = filter_scopes(client_owner, client_host, params.scope); + local request_username, request_host = jid.split(granted_jid); + local granted_scopes = filter_scopes(request_username, request_host, params.scope); local token_info = new_access_token(granted_jid, granted_scopes, nil); - local redirect = url.parse(client.redirect_uri); + local redirect = url.parse(get_redirect_uri(client, params.redirect_uri)); token_info.state = params.state; redirect.fragment = http.formencode(token_info); @@ -181,10 +196,12 @@ } end -local pepper = module:get_option_string("oauth2_client_pepper", ""); +local function make_secret(client_id) --> client_secret + return hashes.hmac_sha256(registration_key, client_id, true); +end -local function verify_secret(stored, salt, i, secret) - return base64.decode(stored) == hashes.pbkdf2_hmac_sha256(secret, salt .. pepper, i); +local function verify_secret(client_id, client_secret) + return hashes.equals(make_secret(client_id), client_secret); end function grant_type_handlers.authorization_code(params) @@ -195,14 +212,12 @@ 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 = jwt_verify(params.client_id); + if not client 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_secret(params.client_id, params.client_secret) then module:log("debug", "client_secret mismatch"); return oauth_error("invalid_client", "incorrect credentials"); end @@ -212,7 +227,6 @@ module:log("debug", "authorization_code invalid or expired: %q", code); return oauth_error("invalid_client", "incorrect credentials"); end - assert(codes:set(client_id .. "#" .. params.code, nil)); return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil)); end @@ -352,12 +366,84 @@ return 200; end +local registration_schema = { + type = "object"; + required = { "client_name"; "redirect_uris" }; + properties = { + redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } }; + token_endpoint_auth_method = { enum = { "none"; "client_secret_post"; "client_secret_basic" }; type = "string" }; + grant_types = { + items = { + 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"; + }; + type = "string"; + }; + type = "array"; + }; + response_types = { items = { enum = { "code"; "token" }; type = "string" }; type = "array" }; + client_name = { type = "string" }; + client_uri = { type = "string"; format = "uri" }; + logo_uri = { type = "string"; format = "uri" }; + scope = { type = "string" }; + contacts = { items = { type = "string" }; type = "array" }; + tos_uri = { type = "string" }; + policy_uri = { type = "string"; format = "uri" }; + jwks_uri = { type = "string"; format = "uri" }; + jwks = { type = "object"; description = "JSON Web Key Set, RFC 7517" }; + software_id = { type = "string"; format = "uuid" }; + software_version = { type = "string" }; + }; +} + +local function handle_register_request(event) + local request = event.request; + local client_metadata = json.decode(request.body); + + if not schema.validate(registration_schema, client_metadata) then + return oauth_error("invalid_request", "Failed schema validation."); + end + + -- Ensure each signed client_id JWT is unique + client_metadata.nonce = uuid.generate(); + + -- Do we want to keep everything? + local client_id = jwt_sign(client_metadata); + local client_secret = make_secret(client_id); + + local client_desc = { + client_id = client_id; + client_secret = client_secret; + client_id_issued_at = os.time(); + client_secret_expires_at = 0; + } + + return { + status_code = 201; + headers = { content_type = "application/json" }; + body = json.encode(client_desc); + }; +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 + module:depends("http"); module:provides("http", { route = { ["POST /token"] = handle_token_grant; ["GET /authorize"] = handle_authorization_request; ["POST /revoke"] = handle_revocation_request; + ["POST /register"] = handle_register_request; }; }); @@ -386,7 +472,7 @@ authorization_endpoint = module:http_url() .. "/authorize"; token_endpoint = module:http_url() .. "/token"; jwks_uri = nil; -- TODO? - registration_endpoint = nil; -- TODO + registration_endpoint = module:http_url() .. "/register"; scopes_supported = { "prosody:restricted"; "prosody:user"; "prosody:admin"; "prosody:operator" }; response_types_supported = { "code"; "token" }; authorization_response_iss_parameter_supported = true;