Skip to content
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

Support wildcard scopes in M2M auth #127

Merged
merged 3 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
require:
- rubocop-rspec
- rubocop-rspec

AllCops:
NewCops: disable
Expand Down
7 changes: 7 additions & 0 deletions lib/stytch/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,11 @@ def initialize(request)
super(msg)
end
end

class M2MPermissionError < StandardError
def initialize(has_scopes, required_scopes)
msg = "Missing at least one required scope from #{required_scopes} for M2M request with scopes #{has_scopes}"
super(msg)
end
end
end
53 changes: 49 additions & 4 deletions lib/stytch/m2m.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ def token(client_id:, client_secret:, scopes: nil)
# max_token_age::
# The maximum possible lifetime in seconds for the token to be valid.
# The type of this field is nilable +Integer+.
# scope_authorization_func::
# A function to check if the token has the required scopes. This defaults to a function that assumes
# scopes are either direct string matches or written in the form "action:resource". See the
# documentation for +perform_authorization_check+ for more information.
# == Returns:
# +nil+ if the token could not be validated, or an object with the following fields:
# scopes::
Expand All @@ -107,7 +111,12 @@ def token(client_id:, client_secret:, scopes: nil)
# custom_claims::
# A map of custom claims present in the token.
# The type of this field is +object+.
def authenticate_token(access_token:, required_scopes: nil, max_token_age: nil)
def authenticate_token(
access_token:,
required_scopes: nil,
max_token_age: nil,
scope_authorization_func: method(:perform_authorization_check)
)
# Intentionally allow this to re-raise if authentication fails
decoded_jwt = authenticate_token_local(access_token)

Expand All @@ -119,14 +128,50 @@ def authenticate_token(access_token:, required_scopes: nil, max_token_age: nil)
resp = marshal_jwt_into_response(decoded_jwt)

unless required_scopes.nil?
for scope in required_scopes
raise TokenMissingScopeError, scope unless resp['scopes'].include?(scope)
end
is_authorized = scope_authorization_func.call(
has_scopes: resp['scopes'],
required_scopes: required_scopes
)
raise M2MPermissionError.new(resp['scopes'], required_scopes) unless is_authorized
end

resp
end

# Performs an authorization check against an M2M client and a set of required
# scopes. Returns true if the client has all the required scopes, false otherwise.
# A scope can match if the client has a wildcard resource or the specific resource.
# This function assumes that scopes are of the form "action:resource" or just
# "specific_scope". It is _also_ possible to represent scopes as "resource:action",
# but it is ultimately up to the developer to ensure consistency in the scopes format.
# Note that a scope of "*" will only match another literal "*" because wildcards are
# *not* supported in the prefix piece of a scope.
def perform_authorization_check(
has_scopes:,
required_scopes:
)
client_scopes = Hash.new { |hash, key| hash[key] = Set.new }
has_scopes.each do |scope|
action = scope
resource = '-'
action, resource = scope.split(':') if scope.include?(':')
client_scopes[action].add(resource)
end

required_scopes.each do |required_scope|
required_action = required_scope
required_resource = '-'
required_action, required_resource = required_scope.split(':') if required_scope.include?(':')
return false unless client_scopes.key?(required_action)

resources = client_scopes[required_action]
# The client can either have a wildcard resource or the specific resource
return false unless resources.include?('*') || resources.include?(required_resource)
end

true
end

# Parse a M2M token and verify the signature locally (without calling /authenticate in the API)
def authenticate_token_local(jwt)
issuer = 'stytch.com/' + @project_id
Expand Down
2 changes: 1 addition & 1 deletion lib/stytch/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Stytch
VERSION = '9.0.0'
VERSION = '9.1.0'
end
69 changes: 69 additions & 0 deletions spec/stytch/m2m_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

RSpec.describe Stytch::M2M do
let(:m2m) { Stytch::M2M.new(nil, '') }

it 'handles basic m2m auth' do
has = ['read:user', 'write:user']
needs = ['read:user']

res = m2m.perform_authorization_check(has_scopes: has, required_scopes: needs)
expect(res).to eq(true)
end

it 'handles multiple required scopes' do
has = ['read:users', 'write:users', 'read:books']
needs = ['read:users', 'read:books']

res = m2m.perform_authorization_check(has_scopes: has, required_scopes: needs)
expect(res).to eq(true)
end

it 'handles simple scopes' do
has = %w[read_users write_users]
needs = ['read_users']

res = m2m.perform_authorization_check(has_scopes: has, required_scopes: needs)
expect(res).to eq(true)
end

it 'handles wildcard resources' do
has = ['read:*', 'write:*']
needs = ['read:users']

res = m2m.perform_authorization_check(has_scopes: has, required_scopes: needs)
expect(res).to eq(true)
end

it 'raises an exception for missing scopes' do
has = ['read:users']
needs = ['write:users']

res = m2m.perform_authorization_check(has_scopes: has, required_scopes: needs)
expect(res).to eq(false)
end

it 'raises an exception for missing scopes with wildcards' do
has = ['read:users', 'write:*']
needs = ['delete:books']

res = m2m.perform_authorization_check(has_scopes: has, required_scopes: needs)
expect(res).to eq(false)
end

it 'has simple scope and wants specific scope' do
has = ['read']
needs = ['read:users']

res = m2m.perform_authorization_check(has_scopes: has, required_scopes: needs)
expect(res).to eq(false)
end

it 'has specific scope and wants simple scope' do
has = ['read:users']
needs = ['read']

res = m2m.perform_authorization_check(has_scopes: has, required_scopes: needs)
expect(res).to eq(false)
end
end
2 changes: 1 addition & 1 deletion spec/stytch/sessions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def jwt_claims(project_id, iat)
'sub' => 'user-live-fde03dd1-fff7-4b3c-9b31-ead3fbc224de',
'iat' => now.to_time.to_i,
'nbf' => now.to_time.to_i,
'exp' => now.to_time.to_i + 5 * 60, # five minutes
'exp' => now.to_time.to_i + 5 * 60, # five minutes
'iss' => 'stytch.com/' + project_id,
'aud' => [project_id]
}
Expand Down
2 changes: 1 addition & 1 deletion stytch.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ Gem::Specification.new do |spec|
spec.add_dependency 'jwt', '>= 2.3.0'

spec.add_development_dependency 'rspec', '~> 3.11.0'
spec.add_development_dependency 'rubocop', '1.56.3'
spec.add_development_dependency 'rubocop', '1.64.1'
spec.add_development_dependency 'rubocop-rspec', '2.24.0'
end
Loading