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