-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add Client Authentication Methods Registry #1772
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3fbe2f8
7fdad73
c1e95e9
93b2d41
42a9d10
fafad38
1595444
5ed4bc4
4e37539
f2fe3f9
8f47f71
e63e18c
12b6110
3084791
d302bb5
26a1d8a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| --colour | ||
| --colour --format documentation | ||
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,8 +70,30 @@ def scopes_by_grant_type(hash = {}) | |
| # `params` object. | ||
| # | ||
| # @param methods [Array] Define client credentials | ||
| # @deprecated | ||
| def client_credentials(*methods) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is pretty messy because of all the different ways We could move the deprecated line to the |
||
| @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. | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if we could/should do this in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| client_authentication | ||
| end | ||
|
|
||
| @client_authentication_methods = methods.map do |name| | ||
| Doorkeeper::ClientAuthentication.get(name) | ||
| end.compact | ||
| end | ||
|
|
||
| def access_token_methods | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -16,6 +17,16 @@ def validate! | |
|
|
||
| private | ||
|
|
||
| def validate_client_authentication_value | ||
| return if client_authentication.is_a?(Array) | ||
|
|
||
| ::Rails.logger.warn( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
endWhen the So if I used a plugin that implemented Doorkeeper.config do
client_authentication [:private_key_jwt, :client_secret_basic, :client_secret_post, :none]
endMaybe we should have |
||
| "[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. | ||
| # | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we could arguably change the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
|
|
||
This file was deleted.
| 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? | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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., 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've asked about this here: https://mailarchive.ietf.org/arch/msg/oauth/QGJfkCMqN2kVMRMDwvCiF3mZ6r4/
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| Doorkeeper::ClientAuthentication::Credentials.new(client_id, client_secret) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| 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) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could also call it
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question. Maybe something related to what it actually returns (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
| 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 | ||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
ThisIsMissEm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| end | ||
|
|
||
| def authorization_strategy(response_type) | ||
| grant_flow = authorization_flows.detect do |flow| | ||
| flow.matches_response_type?(response_type) | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.