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

Implements basic claims request parameter (Closes #11) #13

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
Binary file added .DS_Store
Binary file not shown.
47 changes: 41 additions & 6 deletions app/controllers/oidc_provider/authorizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +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 :reset_login_if_necessary
before_action :require_authentication

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
Expand All @@ -26,13 +25,34 @@ 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
)

authorization.claims = JSON.parse(oauth_request.claims) if 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 require_client
Expand Down Expand Up @@ -60,5 +80,20 @@ def reset_login_if_necessary
redirect_to url_for(request.query_parameters.except(:prompt))
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
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to happen in a new migration, which it does. So we should remove it here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely agreed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, sorry, checking my code again and I see that here it will apply for new projects, while the new migration script db/migrate/20230721112432_add_claims_to_oidc_provider_authorization_if_missing.rb will add that column for existing projects when the column doesn't exist already, so that it works in both cases.

We could keep it like this, but if you really want it I can remove it.

t.datetime :expires_at, null: false

t.timestamps
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading