Skip to content

Commit

Permalink
Add support for dynamic scopes
Browse files Browse the repository at this point in the history
This commit adds support for dynamic scopes, which are disabled by
default.

As discussed in keycloak/keycloak#8486,
a dynamic scope notation is in the form:

<static-part>:<variable-part>

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 #431
  • Loading branch information
stanhu committed Oct 9, 2024
1 parent a02cd99 commit 7426d07
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 1 deletion.
18 changes: 18 additions & 0 deletions lib/doorkeeper/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion lib/doorkeeper/oauth/scopes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class Scopes
include Enumerable
include Comparable

DYNAMIC_SCOPE_WILDCARD = "*"

def self.from_string(string)
string ||= ""
new.tap do |scope|
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions spec/lib/config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions spec/lib/oauth/scopes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 7426d07

Please sign in to comment.