lib/rodauth/features/oauth.rb in rodauth-oauth-0.0.1 vs lib/rodauth/features/oauth.rb in rodauth-oauth-0.0.2

- old
+ new

@@ -12,10 +12,27 @@ end end using(RegexpExtensions) end + unless String.method_defined?(:delete_suffix!) + module SuffixExtensions + refine(String) do + def delete_suffix!(suffix) + suffix = suffix.to_s + chomp! if frozen? + len = suffix.length + return unless len.positive? && index(suffix, -len) + + self[-len..-1] = "" + self + end + end + end + using(SuffixExtensions) + end + SCOPES = %w[profile.read].freeze depends :login before "authorize" @@ -49,26 +66,30 @@ auth_value_method :oauth_grant_expires_in, 60 * 5 # 5 minutes auth_value_method :oauth_token_expires_in, 60 * 60 # 60 minutes auth_value_method :use_oauth_implicit_grant_type, false + auth_value_method :oauth_require_pkce, false + auth_value_method :oauth_pkce_challenge_method, "S256" + # URL PARAMS # Authorize / token %w[ - grant_type code refresh_token client_id scope + grant_type code refresh_token client_id client_secret scope state redirect_uri scopes token_type_hint token - access_type response_type + access_type approval_prompt response_type + code_challenge code_challenge_method code_verifier ].each do |param| auth_value_method :"#{param}_param", param end # Application - APPLICATION_REQUIRED_PARAMS = %w[name description scopes homepage_url redirect_uri].freeze + APPLICATION_REQUIRED_PARAMS = %w[name description scopes homepage_url redirect_uri client_secret].freeze auth_value_method :oauth_application_required_params, APPLICATION_REQUIRED_PARAMS - (APPLICATION_REQUIRED_PARAMS + %w[client_id client_secret]).each do |param| + (APPLICATION_REQUIRED_PARAMS + %w[client_id]).each do |param| auth_value_method :"oauth_application_#{param}_param", param end # OAuth Token auth_value_method :oauth_tokens_path, "oauth-tokens" @@ -81,17 +102,22 @@ expires_in revoked_at ].each do |column| auth_value_method :"oauth_tokens_#{column}_column", column end + # Oauth Token Hash + auth_value_method :oauth_tokens_token_hash_column, nil + auth_value_method :oauth_tokens_refresh_token_hash_column, nil + # OAuth Grants auth_value_method :oauth_grants_table, :oauth_grants auth_value_method :oauth_grants_id_column, :id %i[ account_id oauth_application_id redirect_uri code scopes access_type expires_in revoked_at + code_challenge code_challenge_method ].each do |column| auth_value_method :"oauth_grants_#{column}_column", column end auth_value_method :authorization_required_error_status, 401 @@ -128,12 +154,20 @@ auth_value_method :unsupported_token_type_message, "Invalid token type hint" auth_value_method :unique_error_message, "is already in use" auth_value_method :null_error_message, "is not filled" + # PKCE + auth_value_method :code_challenge_required_error_code, "invalid_request" + auth_value_method :code_challenge_required_message, "code challenge required" + auth_value_method :unsupported_transform_algorithm_error_code, "invalid_request" + auth_value_method :unsupported_transform_algorithm_message, "transform algorithm not supported" + auth_value_methods( - :oauth_unique_id_generator + :oauth_unique_id_generator, + :secret_matches?, + :secret_hash ) redirect(:oauth_application) do |id| "/#{oauth_applications_path}/#{id}" end @@ -176,55 +210,35 @@ def initialize(scope) @scope = scope end def state - state = param(state_param) - - return unless state && !state.empty? - - state + param_or_nil(state_param) end def scopes - scopes = param(scopes_param) - - return [oauth_application_default_scope] unless scopes && !scopes.empty? - - scopes.split(" ") + (param_or_nil(scopes_param) || oauth_application_default_scope).split(" ") end def client_id - client_id = param(client_id_param) + param_or_nil(client_id_param) + end - return unless client_id && !client_id.empty? - - client_id + def client_secret + param_or_nil(client_secret_param) end def redirect_uri - redirect_uri = param(redirect_uri_param) - - return oauth_application[oauth_applications_redirect_uri_column] unless redirect_uri && !redirect_uri.empty? - - redirect_uri + param_or_nil(redirect_uri_param) || oauth_application[oauth_applications_redirect_uri_column] end def token_type_hint - token_type_hint = param(token_type_hint_param) - - return "access_token" unless token_type_hint && !token_type_hint.empty? - - token_type_hint + param_or_nil(token_type_hint_param) || "access_token" end def token - token = param(token_param) - - return unless token && !token.empty? - - token + param_or_nil(token_param) end def oauth_application return @oauth_application if defined?(@oauth_application) @@ -248,14 +262,13 @@ return unless scheme == "Bearer" # check if there is a token # check if token has not expired # check if token has been revoked - db[oauth_tokens_table].where(oauth_tokens_token_column => token) - .where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP) - .where(oauth_tokens_revoked_at_column => nil) - .first + oauth_token_by_token(token).where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP) + .where(oauth_tokens_revoked_at_column => nil) + .first end end def require_oauth_authorization(*scopes) authorization_required unless authorization_token @@ -315,14 +328,106 @@ end end private + def secret_matches?(oauth_application, secret) + BCrypt::Password.new(oauth_application[oauth_applications_client_secret_column]) == secret + end + + def secret_hash(secret) + password_hash(secret) + end + def oauth_unique_id_generator - SecureRandom.uuid + SecureRandom.hex(32) end + def generate_token_hash(token) + Base64.urlsafe_encode64(Digest::SHA256.digest(token)) + end + + unless method_defined?(:password_hash) + # From login_requirements_base feature + if ENV["RACK_ENV"] == "test" + def password_hash_cost + BCrypt::Engine::MIN_COST + end + else + # :nocov: + def password_hash_cost + BCrypt::Engine::DEFAULT_COST + end + # :nocov: + end + + def password_hash(password) + BCrypt::Password.create(password, cost: password_hash_cost) + end + end + + def generate_oauth_token(params = {}, should_generate_refresh_token = true) + create_params = { + oauth_grants_expires_in_column => Time.now + oauth_token_expires_in + }.merge(params) + + token = oauth_unique_id_generator + refresh_token = nil + + if oauth_tokens_token_hash_column + create_params[oauth_tokens_token_hash_column] = generate_token_hash(token) + else + create_params[oauth_tokens_token_column] = token + end + + if should_generate_refresh_token + refresh_token = oauth_unique_id_generator + + if oauth_tokens_refresh_token_hash_column + create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token) + else + create_params[oauth_tokens_refresh_token_column] = refresh_token + end + end + oauth_token = _generate_oauth_token(create_params) + + oauth_token[oauth_tokens_token_column] = token + oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token + oauth_token + end + + def _generate_oauth_token(params = {}) + ds = db[oauth_tokens_table] + + begin + if ds.supports_returning?(:insert) + ds.returning.insert(params) + else + id = ds.insert(params) + ds.where(oauth_tokens_id_column => id).first + end + rescue Sequel::UniqueConstraintViolation + retry + end + end + + def oauth_token_by_token(token) + if oauth_tokens_token_hash_column + db[oauth_tokens_table].where(oauth_tokens_token_hash_column => generate_token_hash(token)) + else + db[oauth_tokens_table].where(oauth_tokens_token_column => token) + end + end + + def oauth_token_by_refresh_token(token) + if oauth_tokens_refresh_token_hash_column + db[oauth_tokens_table].where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token)) + else + db[oauth_tokens_table].where(oauth_tokens_refresh_token_column => token) + end + end + # Oauth Application def oauth_application_params @oauth_application_params ||= oauth_application_required_params.each_with_object({}) do |param, params| value = request.params[__send__(:"oauth_application_#{param}_param")] @@ -361,13 +466,15 @@ oauth_applications_homepage_url_column => oauth_application_params[oauth_application_homepage_url_param], oauth_applications_redirect_uri_column => oauth_application_params[oauth_application_redirect_uri_param] } # set client ID/secret pairs + create_params.merge! \ oauth_applications_client_id_column => oauth_unique_id_generator, - oauth_applications_client_secret_column => oauth_unique_id_generator + oauth_applications_client_secret_column => \ + secret_hash(oauth_application_params[oauth_application_client_secret_param]) create_params[oauth_applications_scopes_column] = if create_params[oauth_applications_scopes_column] create_params[oauth_applications_scopes_column].join(",") else oauth_application_default_scope @@ -402,30 +509,61 @@ end # Authorize def validate_oauth_grant_params - unless oauth_application && check_valid_redirect_uri? && check_valid_access_type? + unless oauth_application && check_valid_redirect_uri? && check_valid_access_type? && + check_valid_approval_prompt? && check_valid_response_type? redirect_response_error("invalid_request") end redirect_response_error("invalid_scope") unless check_valid_scopes? + + validate_pkce_challenge_params end + def try_approval_prompt + approval_prompt = param_or_nil(approval_prompt_param) + + return unless approval_prompt && approval_prompt == "auto" + + return if db[oauth_grants_table].where( + oauth_grants_account_id_column => account_id, + oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], + oauth_grants_redirect_uri_column => redirect_uri, + oauth_grants_scopes_column => scopes.join(","), + oauth_grants_access_type_column => "online" + ).count.zero? + + # if there's a previous oauth grant for the params combo, it means that this user has approved before. + + request.env["REQUEST_METHOD"] = "POST" + end + def create_oauth_grant create_params = { oauth_grants_account_id_column => account_id, oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], oauth_grants_redirect_uri_column => redirect_uri, oauth_grants_code_column => oauth_unique_id_generator, oauth_grants_expires_in_column => Time.now + oauth_grant_expires_in, oauth_grants_scopes_column => scopes.join(",") } - unless (access_type = param("access_type")).empty? + if (access_type = param_or_nil(access_type_param)) create_params[oauth_grants_access_type_column] = access_type end + # PKCE flow + if (code_challenge = param_or_nil(code_challenge_param)) + code_challenge_method = param_or_nil(code_challenge_method_param) + + create_params[oauth_grants_code_challenge_column] = code_challenge + create_params[oauth_grants_code_challenge_method_column] = code_challenge_method + elsif oauth_require_pkce + redirect_response_error("code_challenge_required") + end + ds = db[oauth_grants_table] begin if ds.supports_returning?(:insert) ds.returning(authorize_code_column).insert(create_params) @@ -439,108 +577,116 @@ end # Access Tokens def validate_oauth_token_params - redirect_response_error("invalid_request") unless param(client_id_param) + redirect_response_error("invalid_request") unless param_or_nil(client_id_param) - unless (grant_type = param(grant_type_param)) + unless param_or_nil(client_secret_param) + redirect_response_error("invalid_request") unless param_or_nil(code_verifier_param) + end + + unless (grant_type = param_or_nil(grant_type_param)) redirect_response_error("invalid_request") end case grant_type when "authorization_code" - redirect_response_error("invalid_request") unless param(code_param) + redirect_response_error("invalid_request") unless param_or_nil(code_param) when "refresh_token" - redirect_response_error("invalid_request") unless param(refresh_token_param) + redirect_response_error("invalid_request") unless param_or_nil(refresh_token_param) else redirect_response_error("invalid_request") end end - def generate_oauth_token(params = {}) - create_params = { - oauth_grants_expires_in_column => Time.now + oauth_token_expires_in, - oauth_tokens_token_column => oauth_unique_id_generator - }.merge(params) + def create_oauth_token + oauth_application = db[oauth_applications_table].where( + oauth_applications_client_id_column => param(client_id_param) + ).first - ds = db[oauth_tokens_table] + redirect_response_error("invalid_request") unless oauth_application - begin - if ds.supports_returning?(:insert) - ds.returning.insert(create_params) - else - id = ds.insert(create_params) - ds.where(oauth_tokens_id_column => id).first - end - rescue Sequel::UniqueConstraintViolation - retry + if (client_secret = param_or_nil(client_secret_param)) + redirect_response_error("invalid_request") unless secret_matches?(oauth_application, client_secret) end - end - def create_oauth_token case param(grant_type_param) when "authorization_code" + # fetch oauth grant oauth_grant = db[oauth_grants_table].where( oauth_grants_code_column => param(code_param), oauth_grants_redirect_uri_column => param(redirect_uri_param), - oauth_grants_oauth_application_id_column => db[oauth_applications_table].where( - oauth_applications_client_id_column => param(client_id_param), - oauth_applications_account_id_column => oauth_applications_account_id_column - ).select(oauth_applications_id_column) + oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], + oauth_grants_revoked_at_column => nil ).where(Sequel[oauth_grants_expires_in_column] >= Sequel::CURRENT_TIMESTAMP) - .where(oauth_grants_revoked_at_column => nil) .first redirect_response_error("invalid_grant") unless oauth_grant + # PKCE + if oauth_grant[oauth_grants_code_challenge_column] + code_verifier = param_or_nil(code_verifier_param) + + unless code_verifier && check_valid_grant_challenge?(oauth_grant, code_verifier) + redirect_response_error("invalid_request") + end + elsif oauth_require_pkce + redirect_response_error("code_challenge_required") + end + create_params = { oauth_tokens_account_id_column => oauth_grant[oauth_grants_account_id_column], oauth_tokens_oauth_application_id_column => oauth_grant[oauth_grants_oauth_application_id_column], oauth_tokens_oauth_grant_id_column => oauth_grant[oauth_grants_id_column], oauth_tokens_scopes_column => oauth_grant[oauth_grants_scopes_column] } - if oauth_grant[oauth_grants_access_type_column] == "offline" - create_params[oauth_tokens_refresh_token_column] = oauth_unique_id_generator - end # revoke oauth grant db[oauth_grants_table].where(oauth_grants_id_column => oauth_grant[oauth_grants_id_column]) .update(oauth_grants_revoked_at_column => Sequel::CURRENT_TIMESTAMP) - generate_oauth_token(create_params) + generate_oauth_token(create_params, oauth_grant[oauth_grants_access_type_column] == "offline") + when "refresh_token" - # fetch oauth grant - oauth_token = db[oauth_tokens_table].where( - oauth_tokens_refresh_token_column => param(refresh_token_param), - oauth_tokens_oauth_application_id_column => db[oauth_applications_table].where( - oauth_applications_client_id_column => param(client_id_param), - oauth_applications_account_id_column => account_id - ).select(oauth_applications_id_column) + # fetch oauth token + oauth_token = oauth_token_by_refresh_token(param(refresh_token_param)).where( + oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column] ).where(oauth_grants_revoked_at_column => nil).first redirect_response_error("invalid_grant") unless oauth_token + token = oauth_unique_id_generator + update_params = { oauth_tokens_oauth_application_id_column => oauth_token[oauth_grants_oauth_application_id_column], - oauth_tokens_expires_in_column => Time.now + oauth_token_expires_in, - oauth_tokens_token_column => oauth_unique_id_generator + oauth_tokens_expires_in_column => Time.now + oauth_token_expires_in } + if oauth_tokens_token_hash_column + update_params[oauth_tokens_token_hash_column] = generate_token_hash(token) + else + update_params[oauth_tokens_token_column] = token + end + ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) - begin + + oauth_token = begin if ds.supports_returning?(:update) ds.returning.update(update_params) else ds.update(update_params) ds.first end - rescue Sequel::UniqueConstraintViolation - retry + rescue Sequel::UniqueConstraintViolation + retry end + + oauth_token[oauth_tokens_token_column] = token + oauth_token else redirect_response_error("invalid_grant") end end @@ -554,44 +700,45 @@ redirect_response_error("invalid_request") unless param(token_param) end def revoke_oauth_token - # one can only revoke tokens which haven't been revoked before, and which are - # either our tokens, or tokens from applications we own. - ds = db[oauth_tokens_table] - .where(oauth_tokens_revoked_at_column => nil) - .where( - Sequel.or( - oauth_tokens_account_id_column => account_id, - oauth_tokens_oauth_application_id_column => db[oauth_applications_table].where( - oauth_applications_client_id_column => param(client_id_param), - oauth_applications_account_id_column => account_id - ).select(oauth_applications_id_column) - ) - ) ds = case token_type_hint when "access_token" - ds.where(oauth_tokens_token_column => token) + oauth_token_by_token(token) when "refresh_token" - ds.where(oauth_tokens_refresh_token_column => token) + oauth_token_by_refresh_token(token) end + # one can only revoke tokens which haven't been revoked before, and which are + # either our tokens, or tokens from applications we own. + oauth_token = ds.where(oauth_tokens_revoked_at_column => nil) + .where( + Sequel.or( + oauth_tokens_account_id_column => account_id, + oauth_tokens_oauth_application_id_column => db[oauth_applications_table].where( + oauth_applications_client_id_column => param(client_id_param), + oauth_applications_account_id_column => account_id + ).select(oauth_applications_id_column) + ) + ).first - oauth_token = ds.first redirect_response_error("invalid_request") unless oauth_token update_params = { oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP } ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) - if ds.supports_returning?(:update) - ds.returning.update(update_params) - else - ds.update(update_params) - ds.first - end + oauth_token = if ds.supports_returning?(:update) + ds.returning.update(update_params) + else + ds.update(update_params) + ds.first + end + oauth_token[oauth_tokens_token_column] = token + oauth_token + # If the particular # token is a refresh token and the authorization server supports the # revocation of access tokens, then the authorization server SHOULD # also invalidate all access tokens based on the same authorization # grant @@ -604,36 +751,55 @@ def redirect_response_error(error_code, redirect_url = request.referer || default_redirect) if json_request? throw_json_response_error(invalid_oauth_response_status, error_code) else redirect_url = URI.parse(redirect_url) - query_params = ["error=#{error_code}"] + query_params = [] + + query_params << if respond_to?(:"#{error_code}_error_code") + "error=#{send(:"#{error_code}_error_code")}" + else + "error=#{error_code}" + end + if respond_to?(:"#{error_code}_message") message = send(:"#{error_code}_message") query_params << ["error_description=#{CGI.escape(message)}"] end + query_params << redirect_url.query if redirect_url.query redirect_url.query = query_params.join("&") redirect(redirect_url.to_s) end end def throw_json_response_error(status, error_code) set_response_error_status(status) - payload = { "error" => error_code } + code = if respond_to?(:"#{error_code}_error_code") + send(:"#{error_code}_error_code") + else + error_code + end + payload = { "error" => code } payload["error_description"] = send(:"#{error_code}_message") if respond_to?(:"#{error_code}_message") - json_payload = if request.respond_to?(:convert_to_json) - request.send(:convert_to_json, payload) - else - JSON.dump(payload) - end + json_payload = _json_response_body(payload) response["Content-Type"] ||= json_response_content_type response["WWW-Authenticate"] = "Bearer" if status == 401 response.write(json_payload) request.halt end + unless method_defined?(:_json_response_body) + def _json_response_body(hash) + if request.respond_to?(:convert_to_json) + request.send(:convert_to_json, hash) + else + JSON.dump(hash) + end + end + end + def authorization_required if json_request? throw_json_response_error(authorization_required_error_status, "invalid_client") else set_redirect_error_flash(require_authorization_error_flash) @@ -652,29 +818,63 @@ end ACCESS_TYPES = %w[offline online].freeze def check_valid_access_type? - access_type = param("access_type") - access_type.empty? || ACCESS_TYPES.include?(access_type) + access_type = param_or_nil(access_type_param) + !access_type || ACCESS_TYPES.include?(access_type) end + APPROVAL_PROMPTS = %w[force auto].freeze + + def check_valid_approval_prompt? + approval_prompt = param_or_nil(approval_prompt_param) + !approval_prompt || APPROVAL_PROMPTS.include?(approval_prompt) + end + def check_valid_response_type? - response_type = param("response_type") + response_type = param_or_nil(response_type_param) - return true if response_type.empty? || response_type == "code" + return true if response_type.nil? || response_type == "code" return use_oauth_implicit_grant_type if response_type == "token" false end + # PKCE + + def validate_pkce_challenge_params + if param_or_nil(code_challenge_param) + + challenge_method = param_or_nil(code_challenge_method_param) + redirect_response_error("code_challenge_required") unless oauth_pkce_challenge_method == challenge_method + else + return unless oauth_require_pkce + + redirect_response_error("code_challenge_required") + end + end + + def check_valid_grant_challenge?(grant, verifier) + challenge = grant[oauth_grants_code_challenge_column] + + case grant[oauth_grants_code_challenge_method_column] + when "plain" + challenge == verifier + when "S256" + generated_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier)) + generated_challenge.delete_suffix!("=") while generated_challenge.end_with?("=") + + challenge == generated_challenge + else + redirect_response_error("unsupported_transform_algorithm") + end + end + # /oauth-token route(:oauth_token) do |r| - throw_json_response_error(authorization_required_error_status, "invalid_client") unless logged_in? - - # access-token r.post do catch_error do validate_oauth_token_params oauth_token = nil @@ -685,22 +885,18 @@ end response.status = 200 response["Content-Type"] ||= json_response_content_type json_response = { - "token" => oauth_token[:token], + "token" => oauth_token[oauth_tokens_token_column], "token_type" => oauth_token_type, "expires_in" => oauth_token_expires_in } - json_response["refresh_token"] = oauth_token[:refresh_token] if oauth_token[:refresh_token] + json_response["refresh_token"] = oauth_token[oauth_tokens_refresh_token_column] if oauth_token[:refresh_token] - json_payload = if request.respond_to?(:convert_to_json) - request.send(:convert_to_json, json_response) - else - JSON.dump(json_response) - end + json_payload = _json_response_body(json_response) response.write(json_payload) request.halt end throw_json_response_error(invalid_oauth_response_status, "invalid_request") @@ -725,19 +921,15 @@ if json_request? response.status = 200 response["Content-Type"] ||= json_response_content_type json_response = { - "token" => oauth_token[:token], - "refresh_token" => oauth_token[:refresh_token], - "revoked_at" => oauth_token[:revoked_at] + "token" => oauth_token[oauth_tokens_token_column], + "refresh_token" => oauth_token[oauth_tokens_refresh_token_column], + "revoked_at" => oauth_token[oauth_tokens_revoked_at_column] } - json_payload = if request.respond_to?(:convert_to_json) - request.send(:convert_to_json, json_response) - else - JSON.dump(json_response) - end + json_payload = _json_response_body(json_response) response.write(json_payload) request.halt else set_notice_flash revoke_oauth_token_notice_flash redirect request.referer || "/" @@ -749,19 +941,18 @@ end # /oauth-authorize route(:oauth_authorize) do |r| require_account + validate_oauth_grant_params + try_approval_prompt if request.get? r.get do - validate_oauth_grant_params authorize_view end r.post do - validate_oauth_grant_params - code = nil query_params = [] fragment_params = [] transaction do @@ -773,12 +964,12 @@ create_params = { oauth_tokens_account_id_column => account_id, oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column], oauth_tokens_scopes_column => scopes } - oauth_token = generate_oauth_token(create_params) + oauth_token = generate_oauth_token(create_params, false) - fragment_params << ["access_token=#{oauth_token[:token]}"] + fragment_params << ["access_token=#{oauth_token[oauth_tokens_token_column]}"] fragment_params << ["token_type=#{oauth_token_type}"] fragment_params << ["expires_in=#{oauth_token_expires_in}"] when "code", "", nil code = create_oauth_grant query_params << ["code=#{code}"]