From 6c06b021b37a0d2be936fd902dbd71eb1a3114a6 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 9 Oct 2024 09:58:22 -0400 Subject: [PATCH] Add support for dynamic scopes This commit adds support for dynamic scopes, which are disabled by default. As discussed in https://github.com/keycloak/keycloak/discussions/8486, a dynamic scope notation is in the form: : The objective of this feature is to have a static part of the scope that represents an entity and a variable part that identifies the entity. For example, a scope of `user:1` could be interpreted as allowing access to perform actions of user 1. A wildcard (`*`) is allowed in the variable part, such as `user:*`. This scope allows the request to perform actions as any users. Dynamic scopes can be enabled via: ```ruby Doorkeeper.configure do enable_dynamic_scopes end ``` A custom delimiter can also be configured: ```ruby Doorkeeper.configure do enable_dynamic_scopes(delimiter: '-') end ``` Relates to https://github.com/doorkeeper-gem/doorkeeper/issues/431 --- lib/doorkeeper/config.rb | 18 +++++++ lib/doorkeeper/oauth/scopes.rb | 38 +++++++++++++- spec/lib/config_spec.rb | 32 ++++++++++++ spec/lib/oauth/scopes_spec.rb | 90 ++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 1 deletion(-) diff --git a/lib/doorkeeper/config.rb b/lib/doorkeeper/config.rb index eaa226c75..c464f1803 100644 --- a/lib/doorkeeper/config.rb +++ b/lib/doorkeeper/config.rb @@ -31,6 +31,16 @@ def confirm_application_owner @config.instance_variable_set(:@confirm_application_owner, true) end + # Provide support for dynamic scopes (e.g. user:*) (disabled by default) + # Optional parameter delimiter (default ":") if you want to customize + # the delimiter separating the scope name and matching value. + # + # @param opts [Hash] the options to configure dynamic scopes + def enable_dynamic_scopes(opts = {}) + @config.instance_variable_set(:@enable_dynamic_scopes, true) + @config.instance_variable_set(:@dynamic_scopes_delimiter, opts[:delimiter] || ':') + end + # Define default access token scopes for your provider # # @param scopes [Array] Default set of access (OAuth::Scopes.new) @@ -511,6 +521,14 @@ def enable_application_owner? option_set? :enable_application_owner end + def enable_dynamic_scopes? + option_set? :enable_dynamic_scopes + end + + def dynamic_scopes_delimiter + @dynamic_scopes_delimiter + end + def polymorphic_resource_owner? option_set? :polymorphic_resource_owner end diff --git a/lib/doorkeeper/oauth/scopes.rb b/lib/doorkeeper/oauth/scopes.rb index c5b10cf18..21a6f1a91 100644 --- a/lib/doorkeeper/oauth/scopes.rb +++ b/lib/doorkeeper/oauth/scopes.rb @@ -6,6 +6,8 @@ class Scopes include Enumerable include Comparable + DYNAMIC_SCOPE_WILDCARD = "*" + def self.from_string(string) string ||= "" new.tap do |scope| @@ -26,7 +28,15 @@ def initialize end def exists?(scope) - @scopes.include? scope.to_s + scope = scope.to_s + + @scopes.any? do |allowed_scope| + if dynamic_scopes_enabled? && dynamic_scopes_present?(allowed_scope, scope) + dynamic_scope_match?(allowed_scope, scope) + else + allowed_scope == scope + end + end end def add(*scopes) @@ -66,6 +76,32 @@ def &(other) private + def dynamic_scopes_enabled? + Doorkeeper.config.enable_dynamic_scopes? + end + + def dynamic_scope_delimiter + return unless dynamic_scopes_enabled? + + @dynamic_scope_delimiter ||= Doorkeeper.config.dynamic_scopes_delimiter + end + + def dynamic_scopes_present?(allowed, requested) + allowed.include?(dynamic_scope_delimiter) && requested.include?(dynamic_scope_delimiter) + end + + def dynamic_scope_match?(allowed, requested) + allowed_pattern = allowed.split(dynamic_scope_delimiter, 2) + request_pattern = requested.split(dynamic_scope_delimiter, 2) + + return false if allowed_pattern[0] != request_pattern[0] + return false if allowed_pattern[1].blank? + return false if request_pattern[1].blank? + return true if allowed_pattern[1] == DYNAMIC_SCOPE_WILDCARD && allowed_pattern[1].present? + + allowed_pattern[1] == request_pattern[1] + end + def to_array(other) case other when Scopes diff --git a/spec/lib/config_spec.rb b/spec/lib/config_spec.rb index e88216250..83970ec89 100644 --- a/spec/lib/config_spec.rb +++ b/spec/lib/config_spec.rb @@ -355,6 +355,38 @@ end end + describe "enable_dynamic_scopes" do + it "is disabled by default" do + expect(Doorkeeper.config.enable_dynamic_scopes?).not_to be(true) + end + + context "when enabled with default delimiter" do + before do + Doorkeeper.configure do + enable_dynamic_scopes + end + end + + it 'returns true' do + expect(Doorkeeper.config.enable_dynamic_scopes?).to be(true) + expect(Doorkeeper.config.dynamic_scopes_delimiter).to eq(":") + end + end + + context "when enabled with custom delimiter" do + before do + Doorkeeper.configure do + enable_dynamic_scopes(delimiter: "-") + end + end + + it 'returns true' do + expect(Doorkeeper.config.enable_dynamic_scopes?).to be(true) + expect(Doorkeeper.config.dynamic_scopes_delimiter).to eq("-") + end + end + end + describe "enable_application_owner" do it "is disabled by default" do expect(Doorkeeper.config.enable_application_owner?).not_to be(true) diff --git a/spec/lib/oauth/scopes_spec.rb b/spec/lib/oauth/scopes_spec.rb index f9042e8c2..6f1fc3aba 100644 --- a/spec/lib/oauth/scopes_spec.rb +++ b/spec/lib/oauth/scopes_spec.rb @@ -144,5 +144,95 @@ it "is false if no scopes are included even for existing ones" do expect(scopes).not_to have_scopes(described_class.from_string("public admin notexistent")) end + + context "with dynamic scopes disabled" do + context "with wildcard dynamic scope" do + before do + scopes.add "user:*" + end + + it "returns false with specific user" do + expect(scopes).not_to have_scopes(described_class.from_string("public user:1")) + end + + it "returns true with wildcard user" do + expect(scopes).to have_scopes(described_class.from_string("public user:*")) + end + + it "returns false if requested scope missing parameter" do + expect(scopes).not_to have_scopes(described_class.from_string("public user:")) + end + end + end + + context "with dynamic scopes enabled" do + before do + Doorkeeper.configure do + enable_dynamic_scopes + end + end + + context "with wildcard dynamic scope" do + before do + scopes.add "user:*" + end + + it "returns true with specific user" do + expect(scopes).to have_scopes(described_class.from_string("public user:1")) + end + + it "returns true with wildcard user" do + expect(scopes).to have_scopes(described_class.from_string("public user:*")) + end + + it "returns false if requested scope missing parameter" do + expect(scopes).not_to have_scopes(described_class.from_string("public user:")) + end + + it "returns false if dynamic scope does not match" do + expect(scopes).not_to have_scopes(described_class.from_string("public userA:1")) + end + end + + context "with specific dynamic scope" do + before do + scopes.add "user:1" + end + + it "returns true with specific user" do + expect(scopes).to have_scopes(described_class.from_string("public user:1")) + end + + it "returns false with wildcard user" do + expect(scopes).not_to have_scopes(described_class.from_string("public user:*")) + end + + it "returns false for disallowed user" do + expect(scopes).not_to have_scopes(described_class.from_string("public user:2")) + end + + context "with custom delimiter" do + before do + Doorkeeper.configure do + enable_dynamic_scopes(delimiter: "-") + end + + scopes.add "user-1" + end + + it "returns true with specific user" do + expect(scopes).to have_scopes(described_class.from_string("public user-1")) + end + + it "returns false with wildcard user" do + expect(scopes).not_to have_scopes(described_class.from_string("public user-*")) + end + + it "returns false for disallowed user" do + expect(scopes).not_to have_scopes(described_class.from_string("public user-2")) + end + end + end + end end end