Skip to content

Commit

Permalink
Implements basic claims request parameter (Closes brandnewbox#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
zedtux committed Jul 26, 2023
1 parent 7116590 commit 93f948c
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 29 deletions.
98 changes: 94 additions & 4 deletions app/models/oidc_provider/authorization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,24 @@ class Authorization < ApplicationRecord
attribute :expires_at, :datetime, default: -> { 5.minutes.from_now }

serialize :scopes, JSON
serialize :claims, JSON

def access_token
super || (expire! && generate_access_token!)
end

def scope_configs_for(type)
if claims_request_for?(type)
build_scope_configs_from_claims_request_for(type)
else
type == :id_token ? [open_id_scope_config] : user_info_scope_configs
end
end

def expire!
self.expires_at = Time.now
self.save!
end

def access_token
super || expire! && generate_access_token!
save!
end

def id_token
Expand All @@ -32,6 +42,40 @@ def user_info_scopes

private

def build_scope_config_for(scope, type, key)
ScopeConfig.new(scope, [key.to_sym]).tap do |scope_config|
scope_config.add_force_claim(key.to_sym => claims[type.to_s][key])
end
end

def build_scope_configs_from_claims_request_for(type)
# No matter the `claims` config, when we are about to create an IdToken
# response, we need the OpenID scope claims since there's mandatory ones
scope_configs = type == :id_token ? [open_id_scope_config] : []

claims[type.to_s].each_key do |key|
scopes_with_claim = OIDCProvider.find_all_scopes_with_claim(key)

next unless scope_found?(scopes_with_claim, key)

warn_when_many_scopes_found_in(scopes_with_claim, key, type)

scope = scopes_with_claim.first

next unless scope_has_been_requested?(scope)

scope_configs << build_scope_config_for(scope, type, key)
end

scope_configs
end

def claims_request_for?(type)
return false unless claims

claims.keys.include?(type.to_s)
end

def generate_access_token!
create_access_token!
end
Expand All @@ -42,5 +86,51 @@ def generate_id_token!
token.save!
token
end

def open_id_scope_config
scope = OIDCProvider.find_scope(OIDCProvider::Scopes::OpenID)

ScopeConfig.new(scope, scope.claims)
end

def scope_found?(scopes_with_claim, key)
return true unless scopes_with_claim.empty?

Rails.logger.warn(
"WARNING: No scope found providing the '#{key}' claim. " \
'OIDCProvider will skip it.'
)

false
end

def scope_has_been_requested?(scope)
return true if scopes.include?(scope.name)

Rails.logger.warn(
"WARNING: The scope #{scope.name} has not being requested " \
'on authorization creation, there fore OIDCProvider will skip it.'
)

false
end

def user_info_scope_configs
user_info_scopes.map do |scope_name|
scope = OIDCProvider.find_scope(scope_name)

ScopeConfig.new(scope, scope.claims)
end
end

def warn_when_many_scopes_found_in(scopes_with_claim, key, type)
return unless scopes_with_claim.size > 1

Rails.logger.warn(
"WARNING: Scopes #{scopes_with_claim.map(&:name).to_sentence} " \
"have the #{key} claim declared. OIDCProvider will use the first " \
"one to populate the #{type} response."
)
end
end
end
50 changes: 39 additions & 11 deletions app/models/oidc_provider/id_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ class IdToken < ApplicationRecord
delegate :account, to: :authorization

def to_response_object
# TODO : Merge UserInfo with IdToken
OpenIDConnect::ResponseObject::IdToken.new(
build_values_from_scope(OIDCProvider::Scopes::OpenID)
)
OpenIDConnect::ResponseObject::IdToken.new(id_token_attributes)
end

def to_jwt
Expand All @@ -34,20 +31,51 @@ def build_id_token_struct
Struct.new(*OpenIDConnect::ResponseObject::IdToken.all_attributes)
end

def build_values_from_scope(scope_name)
id_token_attributes = build_id_token_struct.new
def build_user_info_struct
Struct.new(*OpenIDConnect::ResponseObject::UserInfo.all_attributes)
end

def build_values_from_scope(scope_config)
attributes, context = prepare_response_object_builder_from(scope_config)

ResponseObjectBuilder.new(attributes, context, scope_config.requested_claims)
.run(&scope_config.scope.work)

response_attributes = attributes.to_h.compact

scope_config.force_claim.each do |key, value|
response_attributes[key] = value
end

response_attributes
end

scope = OIDCProvider.find_scope(scope_name)
raise "No scope #{scope_name} found" unless scope
def id_token_attributes
scope_configs.each_with_object({}) do |scope_config, memo|
output = build_values_from_scope(scope_config)
memo.merge!(output)
end
end

ResponseObjectBuilder.new(id_token_attributes, self).run(&scope.work)
def prepare_response_object_builder_from(scope_config)
if scope_config.name == OIDCProvider::Scopes::OpenID
[build_id_token_struct.new, self]
else
[build_user_info_struct.new, account]
end
end

id_token_attributes
def scope_configs
authorization.scope_configs_for(:id_token)
end

class << self
def oidc_provider_key_path
Rails.root.join("lib/oidc_provider_key.pem")
end

def key_pair
@key_pair ||= OpenSSL::PKey::RSA.new(File.read(Rails.root.join("lib/oidc_provider_key.pem")), ENV["OIDC_PROVIDER_KEY_PASSPHRASE"])
@key_pair ||= OpenSSL::PKey::RSA.new(File.read(oidc_provider_key_path), ENV["OIDC_PROVIDER_KEY_PASSPHRASE"])
end

def private_jwk
Expand Down
15 changes: 8 additions & 7 deletions lib/oidc_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module Scopes
autoload :ResponseObjectBuilder, 'oidc_provider/response_object_builder'
autoload :Scope, 'oidc_provider/scope'
autoload :ScopeAttributesCollector, 'oidc_provider/scope_attributes_collector'
autoload :ScopeConfig, 'oidc_provider/scope_config'
autoload :TokenEndpoint, 'oidc_provider/token_endpoint'

mattr_accessor :issuer
Expand Down Expand Up @@ -63,13 +64,17 @@ def self.configure
yield self
end

def self.find_all_scopes_with_claim(name)
@@supported_scopes.select { |scope| scope.claims.include?(name.to_sym) }
end

def self.find_scope(name)
@@supported_scopes.detect { |scope| scope.name == name }
end

# Returns the claims from a given scope
def self.claims_from_scope(scope, source)
collector = ScopeAttributesCollector.new(source)
def self.claims_from_scope(scope)
collector = ScopeAttributesCollector.new
collector.run(&scope.work)
collector.collecteds
end
Expand All @@ -87,10 +92,6 @@ def self.open_id_scope # rubocop:disable Metrics/AbcSize

# Returns all the claims from all the `@@supported_scopes`
def self.supported_claims
collector = ScopeAttributesCollector.new

@@supported_scopes.each { |scope| collector.run(&scope.work) }

collector.collecteds
@@supported_scopes.flat_map(&:claims)
end
end
7 changes: 5 additions & 2 deletions lib/oidc_provider/response_object_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ module OIDCProvider
class ResponseObjectBuilder
attr_reader :response_object

def initialize(response_object, context)
def initialize(response_object, context, filter_claims = nil)
@context = context
@filter_claims = filter_claims
@response_object = response_object
end

def run(&block)
instance_exec(@context, &block)
end

def method_missing(sym, *args)
def method_missing(sym, *args) # rubocop:disable Style/MissingRespondToMissing
return if @filter_claims.present? && @filter_claims.include?(sym) == false

@response_object.send("#{sym}=", *args)
end
end
Expand Down
27 changes: 25 additions & 2 deletions lib/oidc_provider/scope.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
# frozen_string_literal: true

module OIDCProvider
class Scope
attr_accessor :name, :work
attr_accessor :claims, :name, :work

def initialize(name, &block)
@name = name
@work = block

@claims = OIDCProvider.claims_from_scope(self)
inject_claims_to_id_token_if_needed!
end

private

# Since the openid_connect gem is limiting the allowed attributes on the
# response classes, this gem declares more optional attributes on the
# OpenIDConnect::ResponseObject::IdToken class.
#
# NOTE : This is done when adding a scope to this gem, so only once at the
# app's boot time when initializing this gem.
# NOTE : Ideally the openid_connect gem should be patched in order to allow
# more claims without this hack.
def inject_claims_to_id_token_if_needed!
missings = @claims - OpenIDConnect::ResponseObject::IdToken.all_attributes

return if missings.empty?

OpenIDConnect::ResponseObject::IdToken.attr_optional(*missings)
end
end
end
end
13 changes: 10 additions & 3 deletions lib/oidc_provider/scope_attributes_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,22 @@ class ScopeAttributesCollector
# `undefined method` error and such and collect happilly all the block
# method names.
class HappyWorld
# When using something like [user.first_name, user.last_name].join(' ') in a
# scope, the `to_str` method is called and must return a string.
# Since we are in an Happy World, let's say it!
def to_str
'HappyWorld'
end

def method_missing(*_) # rubocop:disable Style/MissingRespondToMissing
HappyWorld.new
end
end

attr_reader :collecteds

def initialize(source = nil)
@source = source || HappyWorld.new
def initialize
@source = HappyWorld.new
@collecteds = []
end

Expand All @@ -27,6 +34,6 @@ def run(&block)
end

def method_missing(sym, *_) # rubocop:disable Style/MissingRespondToMissing
@collecteds |= [sym.to_s]
@collecteds |= [sym]
end
end
25 changes: 25 additions & 0 deletions lib/oidc_provider/scope_config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module OIDCProvider
class ScopeConfig
attr_accessor :force_claim, :requested_claims, :scope

def initialize(scope, requested_claims)
@force_claim = {}
@requested_claims = requested_claims
@scope = scope
end

def add_force_claim(key_value)
raise ArgumentError unless key_value.is_a?(Hash)

# Only stores keys where the value is not `nil` thanks to the `.compact`
# method.
@force_claim.merge!(key_value.compact)
end

def name
@scope.name
end
end
end

0 comments on commit 93f948c

Please sign in to comment.