Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rspec
Original file line number Diff line number Diff line change
@@ -1 +1 @@
--colour
--colour --format documentation
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is temporary, but was necessary to help debug the tests — I'm wondering if we can set this based on environment?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By environment you mean what exactly? I personally also prefer documentation format, but sometimes it creates a noisy output and harder for debugging (in CI for example, a lot to scroll and read)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in the CI workflow specify options optimized for CI, but for developers specify options that help them more.

12 changes: 7 additions & 5 deletions lib/doorkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#
module Doorkeeper
autoload :Errors, "doorkeeper/errors"
autoload :ClientAuthentication, "doorkeeper/client_authentication"
autoload :GrantFlow, "doorkeeper/grant_flow"
autoload :OAuth, "doorkeeper/oauth"
autoload :Rake, "doorkeeper/rake"
Expand All @@ -33,7 +34,6 @@ module Request
autoload :RefreshToken, "doorkeeper/request/refresh_token"
autoload :Token, "doorkeeper/request/token"
end

module RevocableTokens
autoload :RevocableAccessToken, "doorkeeper/revocable_tokens/revocable_access_token"
autoload :RevocableRefreshToken, "doorkeeper/revocable_tokens/revocable_refresh_token"
Expand Down Expand Up @@ -62,17 +62,19 @@ module OAuth
autoload :TokenRequest, "doorkeeper/oauth/token_request"
autoload :TokenResponse, "doorkeeper/oauth/token_response"

module ClientAuthentication
autoload :None, "doorkeeper/oauth/client_authentication/none"
autoload :ClientSecretBasic, "doorkeeper/oauth/client_authentication/client_secret_basic"
autoload :ClientSecretPost, "doorkeeper/oauth/client_authentication/client_secret_post"
end

module Authorization
autoload :Code, "doorkeeper/oauth/authorization/code"
autoload :Context, "doorkeeper/oauth/authorization/context"
autoload :Token, "doorkeeper/oauth/authorization/token"
autoload :URIBuilder, "doorkeeper/oauth/authorization/uri_builder"
end

class Client
autoload :Credentials, "doorkeeper/oauth/client/credentials"
end

module ClientCredentials
autoload :Validator, "doorkeeper/oauth/client_credentials/validator"
autoload :Creator, "doorkeeper/oauth/client_credentials/creator"
Expand Down
27 changes: 27 additions & 0 deletions lib/doorkeeper/client_authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require "doorkeeper/client_authentication/credentials"
require "doorkeeper/client_authentication/fallback_method"
require "doorkeeper/client_authentication/method"
require "doorkeeper/client_authentication/registry"

module Doorkeeper
module ClientAuthentication
extend Registry

register(
:none,
Doorkeeper::OAuth::ClientAuthentication::None,
)

register(
:client_secret_post,
Doorkeeper::OAuth::ClientAuthentication::ClientSecretPost,
)

register(
:client_secret_basic,
Doorkeeper::OAuth::ClientAuthentication::ClientSecretBasic,
)
end
end
11 changes: 11 additions & 0 deletions lib/doorkeeper/client_authentication/credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Doorkeeper
module ClientAuthentication
Credentials = Struct.new(:uid, :secret) do
# Public clients may have their secret blank, but "credentials" are
# still present
delegate :blank?, to: :uid
end
end
end
15 changes: 15 additions & 0 deletions lib/doorkeeper/client_authentication/fallback_method.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module Doorkeeper
module ClientAuthentication
class FallbackMethod
def self.matches_request?(request)
true
end

def self.authenticate(request)
nil
end
end
end
end
15 changes: 15 additions & 0 deletions lib/doorkeeper/client_authentication/method.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module Doorkeeper
module ClientAuthentication
class Method
attr_reader :name, :method
delegate :matches_request?, to: :method

def initialize(name, method)
@name = name
@method = method
end
end
end
end
31 changes: 31 additions & 0 deletions lib/doorkeeper/client_authentication/registry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module Doorkeeper
module ClientAuthentication
module Registry
mattr_accessor :methods
self.methods = {}

# Allows to register custom OAuth client authentication method so that
# Doorkeeper could recognize and process it.
#
def register(name, method)
name_key = name.to_sym

if methods.key?(name_key)
::Kernel.warn <<~WARNING
[DOORKEEPER] '#{name_key}' client authentication strategy is already registered and will be overridden
in #{caller(1..1).first}
WARNING
end

methods[name_key] = Doorkeeper::ClientAuthentication::Method.new(name, method)
end

# [NOTE]: make it to use #fetch after removing fallbacks
def get(name)
methods[name.to_sym]
end
end
end
end
52 changes: 49 additions & 3 deletions lib/doorkeeper/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,30 @@ def scopes_by_grant_type(hash = {})
# `params` object.
#
# @param methods [Array] Define client credentials
# @deprecated
def client_credentials(*methods)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty messy because of all the different ways client_credentials could be set, and we can only convert known methods across automatically.

We could move the deprecated line to the else at the end, and basically say: "here's what you need instead: client_authentication [...]"

@config.instance_variable_set(:@client_credentials_methods, methods)
deprecated("client_credentials", "Use the client_authentication option instead. Automatically converting to client_authentication")

client_authentication = methods.map {|method|
case method
when :from_basic
:client_secret_basic
when :from_params
:client_secret_post
else
if method.respond_to? :call
Kernel.warn("[DOORKEEPER] Unknown client_credentials method detected, received callable block")
else
Kernel.warn("[DOORKEEPER] Unknown client_credentials method detected: #{method}")
end
end
}.reject(&:nil?)

if client_authentication.empty?
Kernel.warn("[DOORKEEPER] No known client_credentials method detected, cannot automatically convert to client_authentication option")
else
@config.instance_variable_set(:@client_credentials_methods, client_authentication.concat([:none]))
end
end

# Change the way access token is authenticated from the request object.
Expand Down Expand Up @@ -186,6 +208,13 @@ def hash_application_secrets(using: nil, fallback: nil)

private

def deprecated(name, message=nil)
warning = "[DOORKEEPER] #{name} has been deprecated and will soon be removed"
warning = "#{warning}\n#{message}" if message.present?

Kernel.warn(warning)
end

# Configure the secret storing functionality
def configure_secrets_for(type, using:, fallback:)
raise ArgumentError, "Invalid type #{type}" if %i[application token].exclude?(type)
Expand Down Expand Up @@ -253,9 +282,11 @@ def configure_secrets_for(type, using:, fallback:)
option :orm, default: :active_record
option :native_redirect_uri, default: "urn:ietf:wg:oauth:2.0:oob", deprecated: true
option :grant_flows, default: %w[authorization_code client_credentials]
option :client_authentication, default: %i[client_secret_basic client_secret_post none]
option :pkce_code_challenge_methods, default: %w[plain S256]
option :handle_auth_errors, default: :render
option :token_lookup_batch_size, default: 10_000

# Sets the token_reuse_limit
# It will be used only when reuse_access_token option in enabled
# By default it will be 100
Expand Down Expand Up @@ -579,8 +610,23 @@ def pkce_code_challenge_methods_supported
pkce_code_challenge_methods
end

def client_credentials_methods
@client_credentials_methods ||= %i[from_basic from_params]
def client_authentication_methods
return @client_authentication_methods if defined?(@client_authentication_methods)

methods = if instance_variable_defined?("@client_credentials_methods")
if instance_variable_defined?("@client_authentication")
Kernel.warn("[DOORKEEPER] Both client_credentials and client_authentication are set, using client_authentication")
client_authentication
else
instance_variable_get("@client_credentials_methods")
end
else
Comment on lines +616 to +623
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we could/should do this in def client_credentials above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would depend on the order in which these were registered — we really more want a hook that's like "validations to run after configuration", which may be the Doorkeeper::Config::Validations class.

client_authentication
end

@client_authentication_methods = methods.map do |name|
Doorkeeper::ClientAuthentication.get(name)
end.compact
end

def access_token_methods
Expand Down
11 changes: 11 additions & 0 deletions lib/doorkeeper/config/validations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module Validations
# Validates configuration options to be set properly.
#
def validate!
validate_client_authentication_value
validate_reuse_access_token_value
validate_token_reuse_limit
validate_secret_strategies
Expand All @@ -16,6 +17,16 @@ def validate!

private

def validate_client_authentication_value
return if client_authentication.is_a?(Array)

::Rails.logger.warn(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't figure out a way to test this, but this is where supporting a list of arguments for option would be a good idea.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set an option in the config_spec.rb ? I mean what exactly is the problem with testing it? any blockers which don't come to my mind?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I want to avoid a misconfiguration like:

Doorkeeper.config do
  client_authentication :client_secret_post
end

When the client_authentication method expects an array as the value — I guess we could automatically take a single value and convert it to an array, but the reason it's a misconfiguration is because client_authentication isn't additive to the defaults, it replaces.

So if I used a plugin that implemented :private_key_jwt, then I'd need to add it with:

Doorkeeper.config do
  client_authentication [:private_key_jwt, :client_secret_basic, :client_secret_post, :none]
end

Maybe we should have prepend_client_authentication :method and append_client_authentication :method to be additive? And perhaps disable_client_authentication :method to remove a method?

"[DOORKEEPER] You have configured client_authentication as a non-array value. Using default value"
)

@client_authentication = [:client_secret_basic, :client_secret_post, :none]
end

# Determine whether +reuse_access_token+ and a non-restorable
# +token_secret_strategy+ have both been activated.
#
Expand Down
2 changes: 2 additions & 0 deletions lib/doorkeeper/oauth/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ def self.find(uid, method = Doorkeeper.config.application_model.method(:by_uid))
new(application)
end

# TODO: Figure out a way to have this just get the client but not assert
# authentication if not secret
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could arguably change the self.authenticate to be an instance method instead, so you find the client first, then authenticate it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid of breaking changes TBH with changing public API behavior. We'll need to push as a major version updated I believe and check for compatibility as least with some known extensions like openid_connect and similar

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we could do it in a non-breaking way? I'm not sure.

For openid_connect, I'd expect it'd need to write their own client authentication methods, which they currently don't have. (i.e., they haven't been able to implement private_key_jwt or similar for client authentication — they just do stuff with ID tokens and amending responses to use JWTs

def self.authenticate(credentials, method = Doorkeeper.config.application_model.method(:by_uid_and_secret))
return if credentials.blank?
return unless (application = method.call(credentials.uid, credentials.secret))
Expand Down
34 changes: 0 additions & 34 deletions lib/doorkeeper/oauth/client/credentials.rb

This file was deleted.

22 changes: 22 additions & 0 deletions lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Doorkeeper
module OAuth
module ClientAuthentication
class ClientSecretBasic
def self.matches_request?(request)
request.authorization.present? && request.authorization.downcase.start_with?('basic')
end

def self.authenticate(request)
value = request.authorization.to_s.split(" ", 2).second
client_id, client_secret = Base64.decode64(value).split(':', 2)

return unless client_id.present? && client_secret.present?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure if you can do client authentication with just the username, e.g.,

Authorization: Basic Base64Encode(pPqlv1hayMY3NXSMnikiqMLw3G2tyibuRex2HHo3aVE:)

My reading of the spec is that without the password this mechanism wouldn't match? Though, previously the code would have accepted that as a value authentication, and it'd return credentials without the client_secret, so maybe just the client_id (username) is necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consensus in the mailing list seems to imply that these methods only support POST requests, and require the client_secret to be present.


Doorkeeper::ClientAuthentication::Credentials.new(client_id, client_secret)
end
end
end
end
end
23 changes: 23 additions & 0 deletions lib/doorkeeper/oauth/client_authentication/client_secret_post.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module Doorkeeper
module OAuth
module ClientAuthentication
class ClientSecretPost
def self.matches_request?(request)
request.method.upcase === "POST" && request.request_parameters[:client_id].present? && request.request_parameters[:client_secret].present?
end

def self.authenticate(request)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if authenticate is really the method name that's correct here.. maybe more credentials or parse or something? Maybe call or handle even?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also call it from_request or something? Given how it's called?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Maybe something related to what it actually returns (oorkeeper::ClientAuthentication::Credentials )? credentials (or smth like build_credentials) sounds better to me as its more clean and meaningful

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think this actually needs to be changed, since the specification actually requires parsing out all present authentication mechanisms, and then failing if more than one matches, per the last line in https://datatracker.ietf.org/doc/html/rfc6749#section-2.3

I think we actually need these to have a match & extract phase, and then an authenticate phase.

This is also interesting from section 2.3.2:

When using other authentication methods, the authorization server MUST define a mapping between the client identifier (registration record) and authentication scheme.

client_id = request.request_parameters[:client_id]
client_secret = request.request_parameters[:client_secret]

Doorkeeper::ClientAuthentication::Credentials.new(
client_id,
client_secret
)
end
end
end
end
end
19 changes: 19 additions & 0 deletions lib/doorkeeper/oauth/client_authentication/none.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module Doorkeeper
module OAuth
module ClientAuthentication
class None
def self.matches_request?(request)
!request.authorization && request.request_parameters[:client_id] && !request.request_parameters[:client_secret]
end

def self.authenticate(request)
Doorkeeper::ClientAuthentication::Credentials.new(
request.request_parameters[:client_id], nil
)
end
end
end
end
end
19 changes: 19 additions & 0 deletions lib/doorkeeper/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
module Doorkeeper
module Request
class << self
def client_authentication_method(request)
# TODO: Should we support theoretically more than one method matching a
# request and then check each for authentication? Currently we only
# check the first that matches the request
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently we only return the first matching method for a request, not all potentially matching methods. I'm not sure if we should support that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is actually a bug. We need to collect an array of potential client credentials, and then check if we:

I discovered this whilst implementing client authentication mechanisms in Hollo (an activitypub server)

strategy = client_authentication_methods.detect do |strategy|
strategy.matches_request?(request)
end

if strategy
strategy.method
else
Doorkeeper::ClientAuthentication::FallbackMethod
end
end

def authorization_strategy(response_type)
grant_flow = authorization_flows.detect do |flow|
flow.matches_response_type?(response_type)
Expand Down Expand Up @@ -40,6 +55,10 @@ def token_strategy(grant_type)

private

def client_authentication_methods
Doorkeeper.configuration.client_authentication_methods
end

def authorization_flows
Doorkeeper.configuration.authorization_response_flows
end
Expand Down
Loading