lib/rodauth/features/oauth.rb in rodauth-oauth-0.1.0 vs lib/rodauth/features/oauth.rb in rodauth-oauth-0.2.0

- old
+ new

@@ -1,26 +1,32 @@ # frozen-string-literal: true +require "time" require "base64" require "securerandom" require "net/http" require "rodauth/oauth/ttl_store" +require "rodauth/oauth/database_extensions" module Rodauth Feature.define(:oauth) do # RUBY EXTENSIONS - # :nocov: unless Regexp.method_defined?(:match?) + # If you wonder why this is there: the oauth feature uses a refinement to enhance the + # Regexp class locally with #match? , but this is never tested, because ActiveSupport + # monkey-patches the same method... Please ActiveSupport, stop being so intrusive! + # :nocov: module RegexpExtensions refine(Regexp) do def match?(*args) !match(*args).nil? end end end using(RegexpExtensions) + # :nocov: end unless String.method_defined?(:delete_suffix!) module SuffixExtensions refine(String) do @@ -35,11 +41,10 @@ end end end using(SuffixExtensions) end - # :nocov: SCOPES = %w[profile.read].freeze before "authorize" after "authorize" @@ -108,10 +113,12 @@ # Oauth Token Hash auth_value_method :oauth_tokens_token_hash_column, nil auth_value_method :oauth_tokens_refresh_token_hash_column, nil + # Access Token reuse + auth_value_method :oauth_reuse_access_token, false # OAuth Grants auth_value_method :oauth_grants_table, :oauth_grants auth_value_method :oauth_grants_id_column, :id %i[ account_id oauth_application_id @@ -122,10 +129,11 @@ auth_value_method :"oauth_grants_#{column}_column", column end auth_value_method :authorization_required_error_status, 401 auth_value_method :invalid_oauth_response_status, 400 + auth_value_method :already_in_use_response_status, 409 # OAuth Applications auth_value_method :oauth_applications_path, "oauth-applications" auth_value_method :oauth_applications_table, :oauth_applications @@ -153,10 +161,12 @@ auth_value_method :invalid_url_message, "Invalid URL" 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" + auth_value_method :already_in_use_message, "error generating unique token" + auth_value_method :already_in_use_error_code, "invalid_request" # 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" @@ -170,18 +180,22 @@ # Resource Server params # Only required to use if the plugin is to be used in a resource server auth_value_method :is_authorization_server?, true + auth_value_method :oauth_unique_id_generation_retries, 3 + auth_value_methods( :fetch_access_token, :oauth_unique_id_generator, :secret_matches?, :secret_hash, :generate_token_hash, :authorization_server_url, - :before_introspection_request + :before_introspection_request, + :require_authorizable_account, + :oauth_tokens_unique_columns ) auth_value_methods(:only_json?) auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i @@ -211,18 +225,16 @@ (accept = request.env["HTTP_ACCEPT"]) && accept =~ json_request_regexp end unless method_defined?(:json_request?) - # :nocov: # copied from the jwt feature def json_request? return @json_request if defined?(@json_request) @json_request = request.content_type =~ json_request_regexp end - # :nocov: end def initialize(scope) @scope = scope end @@ -341,11 +353,11 @@ end end end request.get do - scope.instance_variable_set(:@oauth_applications, db[:oauth_applications]) + scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table]) oauth_applications_view end request.post do catch_error do @@ -373,12 +385,46 @@ end end end end + def post_configure + super + self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db)) + + # Check whether we can reutilize db entries for the same account / application pair + one_oauth_token_per_account = begin + db.indexes(oauth_tokens_table).values.any? do |definition| + definition[:unique] && + definition[:columns] == oauth_tokens_unique_columns + end + end + self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account } + end + private + def rescue_from_uniqueness_error(&block) + retries = oauth_unique_id_generation_retries + begin + transaction(savepoint: :only, &block) + rescue Sequel::UniqueConstraintViolation + redirect_response_error("already_in_use") if retries.zero? + retries -= 1 + retry + end + end + + # OAuth Token Unique/Reuse + def oauth_tokens_unique_columns + [ + oauth_tokens_oauth_application_id_column, + oauth_tokens_account_id_column, + oauth_tokens_scopes_column + ] + end + def authorization_server_url base_url end def authorization_server_metadata @@ -397,14 +443,14 @@ response = http.request(request) authorization_required unless response.code.to_i == 200 # time-to-live ttl = if response.key?("cache-control") - cache_control = response["cache_control"] + cache_control = response["cache-control"] cache_control[/max-age=(\d+)/, 1] elsif response.key?("expires") - Time.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i + DateTime.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i end [JSON.parse(response.body, symbolize_names: true), ttl] end end @@ -480,70 +526,76 @@ def token_from_application?(oauth_token, oauth_application) oauth_token[oauth_tokens_oauth_application_id_column] == oauth_application[oauth_applications_id_column] end unless method_defined?(:password_hash) - # :nocov: # From login_requirements_base feature - if ENV["RACK_ENV"] == "test" - def password_hash_cost - BCrypt::Engine::MIN_COST - end - else - def password_hash_cost - BCrypt::Engine::DEFAULT_COST - end - end def password_hash(password) - BCrypt::Password.create(password, cost: password_hash_cost) + BCrypt::Password.create(password, cost: BCrypt::Engine::DEFAULT_COST) end - # :nocov: 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 + rescue_from_uniqueness_error do + token = oauth_unique_id_generator - 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 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 + refresh_token = nil + 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 + 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 - 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).first - else - id = ds.insert(params) - ds.where(oauth_tokens_id_column => id).first + if __one_oauth_token_per_account + + token = __insert_or_update_and_return__( + ds, + oauth_tokens_id_column, + oauth_tokens_unique_columns, + params, + Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP, + ([oauth_tokens_token_column, oauth_tokens_refresh_token_column] if oauth_reuse_access_token) + ) + + # if the previous operation didn't return a row, it means that the conditions + # invalidated the update, and the existing token is still valid. + token || ds.where( + oauth_tokens_account_id_column => params[oauth_tokens_account_id_column], + oauth_tokens_oauth_application_id_column => params[oauth_tokens_oauth_application_id_column] + ).first + else + if oauth_reuse_access_token + unique_conds = Hash[oauth_tokens_unique_columns.map { |column| [column, params[column]] }] + valid_token = ds.where(Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP) + .where(unique_conds).first + return valid_token if valid_token end - rescue Sequel::UniqueConstraintViolation - retry + __insert_and_return__(ds, oauth_tokens_id_column, params) end end def oauth_token_by_token(token, dataset = db[oauth_tokens_table]) ds = if oauth_tokens_token_hash_column @@ -631,43 +683,27 @@ redirect_uris = redirect_uris.to_a.reject(&:empty?).join(" ") if redirect_uris.respond_to?(:each) create_params[oauth_applications_redirect_uri_column] = redirect_uris unless redirect_uris.empty? # set client ID/secret pairs create_params.merge! \ - oauth_applications_client_id_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(oauth_scope_separator) else oauth_application_default_scope end - id = nil - raised = begin - id = db[oauth_applications_table].insert(create_params) - false - rescue Sequel::ConstraintViolation => e - e - end - - if raised - field = raised.message[/\.(.*)$/, 1] - case raised - when Sequel::UniqueConstraintViolation - throw_error(field, unique_error_message) - when Sequel::NotNullConstraintViolation - throw_error(field, null_error_message) - end + rescue_from_uniqueness_error do + create_params[oauth_applications_client_id_column] = oauth_unique_id_generator + db[oauth_applications_table].insert(create_params) end - - !raised && id end # Authorize - def before_authorize + def require_authorizable_account require_account end def validate_oauth_grant_params redirect_response_error("invalid_request", request.referer || default_redirect) unless oauth_application && check_valid_redirect_uri? @@ -707,39 +743,29 @@ oauth_grants_expires_in_column => Time.now + oauth_grant_expires_in, oauth_grants_scopes_column => scopes.join(oauth_scope_separator) ) # Access Type flow - if use_oauth_access_type? - if (access_type = param_or_nil("access_type")) - create_params[oauth_grants_access_type_column] = access_type - end + if use_oauth_access_type? && (access_type = param_or_nil("access_type")) + create_params[oauth_grants_access_type_column] = access_type end # PKCE flow - if use_oauth_pkce? + if use_oauth_pkce? && (code_challenge = param_or_nil("code_challenge")) + code_challenge_method = param_or_nil("code_challenge_method") - if (code_challenge = param_or_nil("code_challenge")) - code_challenge_method = param_or_nil("code_challenge_method") - - 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 + create_params[oauth_grants_code_challenge_column] = code_challenge + create_params[oauth_grants_code_challenge_method_column] = code_challenge_method end ds = db[oauth_grants_table] - begin - authorization_code = oauth_unique_id_generator - create_params[oauth_grants_code_column] = authorization_code - ds.insert(create_params) - authorization_code - rescue Sequel::UniqueConstraintViolation - retry + rescue_from_uniqueness_error do + create_params[oauth_grants_code_column] = oauth_unique_id_generator + __insert_and_return__(ds, oauth_grants_id_column, create_params) end + create_params[oauth_grants_code_column] end def do_authorize(redirect_url, query_params = [], fragment_params = []) case param("response_type") when "token" @@ -779,14 +805,10 @@ json_access_token_payload(oauth_token) end # Access Tokens - def before_token - require_oauth_application - end - def validate_oauth_token_params unless (grant_type = param_or_nil("grant_type")) redirect_response_error("invalid_request") end @@ -832,12 +854,10 @@ 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 } create_oauth_token_from_token(oauth_token, update_params) - else - redirect_response_error("invalid_grant") end end def create_oauth_token_from_authorization_code(oauth_grant, create_params) # PKCE @@ -862,43 +882,35 @@ end def create_oauth_token_from_token(oauth_token, update_params) redirect_response_error("invalid_grant") unless token_from_application?(oauth_token, oauth_application) - token = oauth_unique_id_generator + rescue_from_uniqueness_error do + token = oauth_unique_id_generator - 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]) - - oauth_token = begin - if ds.supports_returning?(:update) - ds.returning.update(update_params).first + if oauth_tokens_token_hash_column + update_params[oauth_tokens_token_hash_column] = generate_token_hash(token) else - ds.update(update_params) - ds.first + update_params[oauth_tokens_token_column] = token end - rescue Sequel::UniqueConstraintViolation - retry - end - oauth_token[oauth_tokens_token_column] = token - oauth_token + ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) + + oauth_token = __update_and_return__(ds, update_params) + oauth_token[oauth_tokens_token_column] = token + oauth_token + end end TOKEN_HINT_TYPES = %w[access_token refresh_token].freeze # Token introspect def validate_oauth_introspect_params # check if valid token hint type - if param_or_nil("token_type_hint") - redirect_response_error("unsupported_token_type") unless TOKEN_HINT_TYPES.include?(param("token_type_hint")) + if param_or_nil("token_type_hint") && !TOKEN_HINT_TYPES.include?(param("token_type_hint")) + redirect_response_error("unsupported_token_type") end redirect_response_error("invalid_request") unless param_or_nil("token") end @@ -912,22 +924,16 @@ # username token_type: oauth_token_type } end - def before_introspect; end - # Token revocation - def before_revoke - require_oauth_application - end - def validate_oauth_revoke_params # check if valid token hint type - if param_or_nil("token_type_hint") - redirect_response_error("unsupported_token_type") unless TOKEN_HINT_TYPES.include?(param("token_type_hint")) + if param_or_nil("token_type_hint") && !TOKEN_HINT_TYPES.include?(param("token_type_hint")) + redirect_response_error("unsupported_token_type") end redirect_response_error("invalid_request") unless param_or_nil("token") end @@ -940,27 +946,17 @@ oauth_token_by_token(token) end redirect_response_error("invalid_request") unless oauth_token - if oauth_application - redirect_response_error("invalid_request") unless token_from_application?(oauth_token, oauth_application) - else - @oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => - oauth_token[oauth_tokens_oauth_application_id_column]).first - end + redirect_response_error("invalid_request") unless token_from_application?(oauth_token, oauth_application) 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]) - oauth_token = if ds.supports_returning?(:update) - ds.returning.update(update_params).first - else - ds.update(update_params) - ds.first - end + oauth_token = __update_and_return__(ds, update_params) oauth_token[oauth_tokens_token_column] = token oauth_token # If the particular @@ -974,11 +970,17 @@ # Response helpers def redirect_response_error(error_code, redirect_url = redirect_uri || request.referer || default_redirect) if accepts_json? - throw_json_response_error(invalid_oauth_response_status, error_code) + status_code = if respond_to?(:"#{error_code}_response_status") + send(:"#{error_code}_response_status") + else + invalid_oauth_response_status + end + + throw_json_response_error(status_code, error_code) else redirect_url = URI.parse(redirect_url) query_params = [] query_params << if respond_to?(:"#{error_code}_error_code") @@ -1021,19 +1023,17 @@ response.write(json_payload) request.halt end unless method_defined?(:_json_response_body) - # :nocov: def _json_response_body(hash) if request.respond_to?(:convert_to_json) request.send(:convert_to_json, hash) else JSON.dump(hash) end end - # :nocov: end def authorization_required if accepts_json? throw_json_response_error(authorization_required_error_status, "invalid_client") @@ -1154,18 +1154,20 @@ # /token route(:token) do |r| next unless is_authorization_server? - before_token + before_token_route + require_oauth_application r.post do catch_error do validate_oauth_token_params oauth_token = nil transaction do + before_token oauth_token = create_oauth_token end json_response_success(json_access_token_payload(oauth_token)) end @@ -1176,16 +1178,17 @@ # /introspect route(:introspect) do |r| next unless is_authorization_server? - before_introspect + before_introspect_route r.post do catch_error do validate_oauth_introspect_params + before_introspect oauth_token = case param("token_type_hint") when "access_token" oauth_token_by_token(param("token")) when "refresh_token" oauth_token_by_refresh_token(param("token")) @@ -1209,18 +1212,20 @@ # /revoke route(:revoke) do |r| next unless is_authorization_server? - before_revoke + before_revoke_route + require_oauth_application r.post do catch_error do validate_oauth_revoke_params oauth_token = nil transaction do + before_revoke oauth_token = revoke_oauth_token after_revoke end if accepts_json? @@ -1240,23 +1245,24 @@ # /authorize route(:authorize) do |r| next unless is_authorization_server? - require_account + before_authorize_route + require_authorizable_account + validate_oauth_grant_params try_approval_prompt if use_oauth_access_type? && request.get? - before_authorize - r.get do authorize_view end r.post do redirect_url = URI.parse(redirect_uri) transaction do + before_authorize do_authorize(redirect_url) end redirect(redirect_url.to_s) end end