diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..452c19c --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,10 @@ +AllCops: + NewCops: enable + TargetRubyVersion: 3.0 + +Naming/RescuedExceptionsVariableName: + Enabled: Yes + PreferredName: error + +Style/Documentation: + Enabled: No diff --git a/app/controllers/oidc_provider/authorizations_controller.rb b/app/controllers/oidc_provider/authorizations_controller.rb index 35fdca3..d925931 100644 --- a/app/controllers/oidc_provider/authorizations_controller.rb +++ b/app/controllers/oidc_provider/authorizations_controller.rb @@ -6,13 +6,13 @@ class AuthorizationsController < ApplicationController 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 - Rails.logger.info "scopes: #{requested_scopes}" - - authorization = build_authorization_with(requested_scopes) + authorization = build_authorization oauth_response.code = authorization.code oauth_response.redirect_uri = @redirect_uri @@ -25,13 +25,39 @@ def create private - def build_authorization_with(scopes) - Authorization.create( + def build_authorization + authorization = Authorization.new( client_id: @client.identifier, nonce: oauth_request.nonce, - scopes: scopes, + 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 @@ -49,5 +75,20 @@ def require_response_type_code oauth_request.unsupported_response_type! 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 diff --git a/app/controllers/oidc_provider/discovery_controller.rb b/app/controllers/oidc_provider/discovery_controller.rb index 7ace84e..3e43966 100644 --- a/app/controllers/oidc_provider/discovery_controller.rb +++ b/app/controllers/oidc_provider/discovery_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module OIDCProvider class DiscoveryController < ApplicationController def show @@ -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 @@ -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 \ No newline at end of file +end diff --git a/app/controllers/oidc_provider/user_infos_controller.rb b/app/controllers/oidc_provider/user_infos_controller.rb index 30908bd..09628ce 100644 --- a/app/controllers/oidc_provider/user_infos_controller.rb +++ b/app/controllers/oidc_provider/user_infos_controller.rb @@ -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 \ No newline at end of file +end diff --git a/app/models/oidc_provider/authorization.rb b/app/models/oidc_provider/authorization.rb index e98bccf..6509e09 100644 --- a/app/models/oidc_provider/authorization.rb +++ b/app/models/oidc_provider/authorization.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module OIDCProvider class Authorization < ApplicationRecord belongs_to :account, class_name: OIDCProvider.account_class @@ -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! @@ -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 diff --git a/app/models/oidc_provider/id_token.rb b/app/models/oidc_provider/id_token.rb index a690354..2425792 100644 --- a/app/models/oidc_provider/id_token.rb +++ b/app/models/oidc_provider/id_token.rb @@ -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 @@ -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 { diff --git a/db/migrate/20170928211111_create_oidc_provider_authorizations.rb b/db/migrate/20170928211111_create_oidc_provider_authorizations.rb index 105a069..76f0d74 100644 --- a/db/migrate/20170928211111_create_oidc_provider_authorizations.rb +++ b/db/migrate/20170928211111_create_oidc_provider_authorizations.rb @@ -1,11 +1,14 @@ +# frozen_string_literal: true + class CreateOIDCProviderAuthorizations < ActiveRecord::Migration[5.1] def change create_table :oidc_provider_authorizations do |t| - t.references :account, foreign_key: {to_table: OIDCProvider.account_class.tableize}, null: false + t.references :account, foreign_key: { to_table: OIDCProvider.account_class.tableize }, null: false t.string :client_id, null: false t.string :nonce t.string :code, null: false t.text :scopes, null: false + t.text :claims t.datetime :expires_at, null: false t.timestamps diff --git a/db/migrate/20230721112432_add_claims_to_oidc_provider_authorization_if_missing.rb b/db/migrate/20230721112432_add_claims_to_oidc_provider_authorization_if_missing.rb new file mode 100644 index 0000000..f4ec4df --- /dev/null +++ b/db/migrate/20230721112432_add_claims_to_oidc_provider_authorization_if_missing.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddClaimsToOIDCProviderAuthorizationIfMissing < ActiveRecord::Migration[5.1] + def up + return if ActiveRecord::Base.connection.column_exists?(:oidc_provider_authorizations, :claims) + + add_column :oidc_provider_authorizations, :claims, :text + end + + def down + remove_column :oidc_provider_authorizations, :claims + end +end diff --git a/lib/oidc_provider.rb b/lib/oidc_provider.rb index 66cddf6..412822c 100644 --- a/lib/oidc_provider.rb +++ b/lib/oidc_provider.rb @@ -1,23 +1,28 @@ # frozen_string_literal: true -require "openid_connect" -require "oidc_provider/engine" +require 'openid_connect' +require 'oidc_provider/engine' +require 'oidc_provider/errors' module OIDCProvider - + # rubocop:disable Naming/ConstantName module Scopes - OpenID = "openid" - Profile = "profile" - Email = "email" - Address = "address" + OpenID = 'openid' + Profile = 'profile' + Email = 'email' + Address = 'address' end + # rubocop:enable Naming/ConstantName - autoload :TokenEndpoint, 'oidc_provider/token_endpoint' - autoload :ClientStore, 'oidc_provider/client_store' - autoload :Client, 'oidc_provider/client' autoload :AccountToUserInfo, 'oidc_provider/account_to_user_info' + autoload :Client, 'oidc_provider/client' + autoload :ClientStore, 'oidc_provider/client_store' + autoload :IdTokenBuilder, 'oidc_provider/id_token_builder' + autoload :ResponseObjectBuilder, 'oidc_provider/response_object_builder' autoload :Scope, 'oidc_provider/scope' - autoload :UserInfoBuilder, 'oidc_provider/user_info_builder' + autoload :ScopeAttributesCollector, 'oidc_provider/scope_attributes_collector' + autoload :ScopeConfig, 'oidc_provider/scope_config' + autoload :TokenEndpoint, 'oidc_provider/token_endpoint' mattr_accessor :issuer @@ -28,7 +33,7 @@ module Scopes @@clients = [] mattr_accessor :account_class - @@account_class = "User" + @@account_class = 'User' mattr_accessor :current_account_method @@current_account_method = :current_user @@ -53,6 +58,41 @@ def self.add_scope(name, &block) end def self.configure + @@clients = [] + + @@supported_scopes = [open_id_scope] + 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) + collector = ScopeAttributesCollector.new + collector.run(&scope.work) + collector.collecteds + end + + def self.open_id_scope # rubocop:disable Metrics/AbcSize + Scope.new(OIDCProvider::Scopes::OpenID) do |id_token| + iss OIDCProvider.issuer + sub id_token.account.send(OIDCProvider.account_identifier) + aud id_token.authorization.client_id + nonce id_token.nonce + exp id_token.expires_at.to_i + iat id_token.created_at.to_i + end + end + + # Returns all the claims from all the `@@supported_scopes` + def self.supported_claims + @@supported_scopes.flat_map(&:claims) + end end diff --git a/lib/oidc_provider/account_to_user_info.rb b/lib/oidc_provider/account_to_user_info.rb index 627d63a..f077249 100644 --- a/lib/oidc_provider/account_to_user_info.rb +++ b/lib/oidc_provider/account_to_user_info.rb @@ -1,12 +1,25 @@ +# frozen_string_literal: true + module OIDCProvider class AccountToUserInfo - def call(account, scope_names) - scopes = scope_names.map { |name| OIDCProvider.supported_scopes.detect { |scope| scope.name == name } }.compact - OpenIDConnect::ResponseObject::UserInfo.new(sub: account.send(OIDCProvider.account_identifier)).tap do |user_info| + def initialize(scope_names) + @scope_names = scope_names + end + + def call(account) + OpenIDConnect::ResponseObject::UserInfo.new( + sub: account.send(OIDCProvider.account_identifier) + ).tap do |user_info| scopes.each do |scope| - UserInfoBuilder.new(user_info, account).run(&scope.work) + ResponseObjectBuilder.new(user_info, account).run(&scope.work) end end end + + private + + def scopes + @scope_names.map { |name| OIDCProvider.find_scope(name) }.compact + end end -end \ No newline at end of file +end diff --git a/lib/oidc_provider/errors.rb b/lib/oidc_provider/errors.rb new file mode 100644 index 0000000..db66d30 --- /dev/null +++ b/lib/oidc_provider/errors.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module OIDCProvider + module Errors + # Allows one to catch all OIDCProvider errors + class OIDCProviderError < StandardError; end + + # Raised when passed claims is not a Hash of hashes only. + class InvalidClaimsFormatError < OIDCProviderError; end + end +end diff --git a/lib/oidc_provider/response_object_builder.rb b/lib/oidc_provider/response_object_builder.rb new file mode 100644 index 0000000..9842af4 --- /dev/null +++ b/lib/oidc_provider/response_object_builder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module OIDCProvider + class ResponseObjectBuilder + attr_reader :response_object + + 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) # rubocop:disable Style/MissingRespondToMissing + return if @filter_claims.present? && @filter_claims.include?(sym) == false + + @response_object.send("#{sym}=", *args) + end + end +end diff --git a/lib/oidc_provider/scope.rb b/lib/oidc_provider/scope.rb index d73a120..6e51d46 100644 --- a/lib/oidc_provider/scope.rb +++ b/lib/oidc_provider/scope.rb @@ -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 \ No newline at end of file +end diff --git a/lib/oidc_provider/scope_attributes_collector.rb b/lib/oidc_provider/scope_attributes_collector.rb new file mode 100644 index 0000000..d6becc9 --- /dev/null +++ b/lib/oidc_provider/scope_attributes_collector.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# The aim of this class is to collect the method names from a given scope block +# added to the configuration from `add_scope` method. +class ScopeAttributesCollector + # This class is used in order to create a context which is always happy about + # the requested method so that this `ScopeAttributesCollector` never reach an + # `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 = HappyWorld.new + @collecteds = [] + end + + def run(&block) + # Redirects all method calls to this class so that all the method_missing + # are forwarded bellow in this class. + instance_exec(@source, &block) + end + + def method_missing(sym, *_) # rubocop:disable Style/MissingRespondToMissing + @collecteds |= [sym] + end +end diff --git a/lib/oidc_provider/scope_config.rb b/lib/oidc_provider/scope_config.rb new file mode 100644 index 0000000..be5008d --- /dev/null +++ b/lib/oidc_provider/scope_config.rb @@ -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 diff --git a/lib/oidc_provider/user_info_builder.rb b/lib/oidc_provider/user_info_builder.rb deleted file mode 100644 index 128fe3e..0000000 --- a/lib/oidc_provider/user_info_builder.rb +++ /dev/null @@ -1,18 +0,0 @@ -module OIDCProvider - class UserInfoBuilder - attr_reader :user_info - - def initialize(user_info, account) - @user_info = user_info - @account = account - end - - def run(&block) - instance_exec(@account, &block) - end - - def method_missing(sym, *args) - @user_info.send("#{sym}=", *args) - end - end -end \ No newline at end of file