Skip to content

Commit

Permalink
Implements basic claims request parameter (Closes #11)
Browse files Browse the repository at this point in the history
  • Loading branch information
zedtux committed Jul 28, 2023
1 parent 0f9e409 commit 8627007
Show file tree
Hide file tree
Showing 16 changed files with 454 additions and 75 deletions.
10 changes: 10 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
AllCops:
NewCops: enable
TargetRubyVersion: 3.0

Naming/RescuedExceptionsVariableName:
Enabled: Yes
PreferredName: error

Style/Documentation:
Enabled: No
65 changes: 56 additions & 9 deletions app/controllers/oidc_provider/authorizations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
# frozen_string_literal: true

module OIDCProvider
class AuthorizationsController < ApplicationController
include Concerns::ConnectEndpoint

before_action :require_oauth_request
before_action :require_response_type_code
before_action :ensure_claims_is_valid
before_action :require_client
before_action :require_authentication
before_action :print_scopes_and_claims

def create
puts "scopes: #{requested_scopes}"
authorization = Authorization.create(
client_id: @client.identifier,
nonce: oauth_request.nonce,
scopes: requested_scopes,
account: oidc_current_account
)
authorization = build_authorization

oauth_response.code = authorization.code
oauth_response.redirect_uri = @redirect_uri
Expand All @@ -27,6 +25,41 @@ def create

private

def build_authorization
authorization = Authorization.new(
client_id: @client.identifier,
nonce: oauth_request.nonce,
scopes: requested_scopes,
account: oidc_current_account
)

oauth_request.claims && authorization.claims = JSON.parse(oauth_request.claims)

authorization.save
authorization
end

def ensure_claims_is_valid
return true unless oauth_request.claims

validate_json_is_a_hash!(parse_claims_as_json!)
rescue Errors::InvalidClaimsFormatError => error
Rails.logger.error "Invalid claims passed: #{error.message}"
oauth_request.invalid_request! 'invalid claims format'
end

def parse_claims_as_json!
JSON.parse(oauth_request.claims)
rescue JSON::ParserError => error
Rails.logger.error "Invalid claims passed: #{error.message}"
oauth_request.invalid_request! 'claims just be a JSON'
end

def print_scopes_and_claims
Rails.logger.info "scopes: #{requested_scopes}"
Rails.logger.info "claims: #{oauth_request.claims}"
end

def require_client
@client = ClientStore.new.find_by(identifier: oauth_request.client_id) or oauth_request.invalid_request! 'not a valid client'
@redirect_uri = oauth_request.verify_redirect_uri! [oauth_request.redirect_uri, @client.redirect_uri]
Expand All @@ -42,6 +75,20 @@ def require_response_type_code
oauth_request.unsupported_response_type!
end
end
end

end
# Recursive method validating the given `json` is a hash of hashes
def validate_json_is_a_hash!(json)
# When reaching the end of the json/hash path, we're getting a `nil`, or
# a String (hard coded value) or the `essential` boolean value (not yet
# implemented).
#
# For example, when the previous call of this method received
# `{ email: nil }`, the current call of this method receives `nil`.
return if json.nil? || json.is_a?(String)

raise Errors::InvalidClaimsFormatError unless json.is_a?(Hash)

json.each_key { |key| validate_json_is_a_hash!(json[key]) }
end
end
end
26 changes: 14 additions & 12 deletions app/controllers/oidc_provider/discovery_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

module OIDCProvider
class DiscoveryController < ApplicationController
def show
Expand All @@ -7,7 +9,7 @@ def show
when 'openid-configuration'
openid_configuration
else
render plain: "Not found", status: :not_found
render plain: 'Not found', status: :not_found
end
end

Expand All @@ -21,27 +23,27 @@ def webfinger_discovery
}]
}
jrd[:subject] = params[:resource] if params[:resource].present?
render json: jrd, content_type: "application/jrd+json"
render json: jrd, content_type: 'application/jrd+json'
end

def openid_configuration
config = OpenIDConnect::Discovery::Provider::Config::Response.new(
issuer: OIDCProvider.issuer,
authorization_endpoint: authorizations_url(host: OIDCProvider.issuer),
token_endpoint: tokens_url(host: OIDCProvider.issuer),
userinfo_endpoint: user_info_url(host: OIDCProvider.issuer),
claims_parameter_supported: true,
claims_supported: OIDCProvider.supported_claims,
end_session_endpoint: end_session_url(host: OIDCProvider.issuer),
grant_types_supported: [:authorization_code],
id_token_signing_alg_values_supported: [:RS256],
issuer: OIDCProvider.issuer,
jwks_uri: jwks_url(host: OIDCProvider.issuer),
scopes_supported: ["openid"] + OIDCProvider.supported_scopes.map(&:name),
response_types_supported: [:code],
grant_types_supported: [:authorization_code],
scopes_supported: OIDCProvider.supported_scopes.map(&:name),
subject_types_supported: [:public],
id_token_signing_alg_values_supported: [:RS256],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
claims_supported: ['sub', 'iss', 'name', 'email']
token_endpoint: tokens_url(host: OIDCProvider.issuer),
token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post],
userinfo_endpoint: user_info_url(host: OIDCProvider.issuer)
)
render json: config
end
end

end
end
13 changes: 11 additions & 2 deletions app/controllers/oidc_provider/user_infos_controller.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
# frozen_string_literal: true

module OIDCProvider
class UserInfosController < ApplicationController
before_action :require_access_token

def show
render json: AccountToUserInfo.new.(current_token.authorization.account, current_token.authorization.scopes)
render json: user_info
end

private

def user_info
AccountToUserInfo.new(current_token.authorization.user_info_scopes)
.call(current_token.authorization.account)
end
end
end
end
107 changes: 101 additions & 6 deletions app/models/oidc_provider/authorization.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

module OIDCProvider
class Authorization < ApplicationRecord
belongs_to :account, class_name: OIDCProvider.account_class
Expand All @@ -10,25 +12,72 @@ 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
super || generate_id_token!
end

def user_info_scopes
scopes - ['openid']
end

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!
token = create_access_token!
token
create_access_token!
end

def generate_id_token!
Expand All @@ -37,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
60 changes: 52 additions & 8 deletions app/models/oidc_provider/id_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,7 @@ class IdToken < ApplicationRecord
delegate :account, to: :authorization

def to_response_object
OpenIDConnect::ResponseObject::IdToken.new(
iss: OIDCProvider.issuer,
sub: account.send(OIDCProvider.account_identifier),
aud: authorization.client_id,
nonce: nonce,
exp: expires_at.to_i,
iat: created_at.to_i
)
OpenIDConnect::ResponseObject::IdToken.new(id_token_attributes)
end

def to_jwt
Expand All @@ -27,6 +20,57 @@ def to_jwt

private

# Return a Struct accepting all the possible attributes from an instance of
# the OpenIDConnect::ResponseObject::IdToken class used to collect the scope
# values and populate the OpenIDConnect::ResponseObject::IdToken instance
# that will be returned by the above `to_response_object`.
#
# At first I used an OpenStruct but since it has been officially discouraged
# for performance, version compatibility, and potential security issues,
# a `Struct` with predefined attributes is used instead.
# See https://docs.ruby-lang.org/en/3.0/OpenStruct.html#class-OpenStruct-label-Caveats
def build_id_token_struct
Struct.new(*OpenIDConnect::ResponseObject::IdToken.all_attributes)
end

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

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

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

def scope_configs
authorization.scope_configs_for(:id_token)
end

class << self
def config
{
Expand Down
Loading

0 comments on commit 8627007

Please sign in to comment.