Skip to content

Commit 814b2f6

Browse files
committed
Implements basic claims request parameter (Closes #11)
1 parent ef6a62b commit 814b2f6

16 files changed

+445
-72
lines changed

.rubocop.yml

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
AllCops:
2+
NewCops: enable
3+
TargetRubyVersion: 3.0
4+
5+
Naming/RescuedExceptionsVariableName:
6+
Enabled: Yes
7+
PreferredName: error
8+
9+
Style/Documentation:
10+
Enabled: No

app/controllers/oidc_provider/authorizations_controller.rb

+47-6
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ class AuthorizationsController < ApplicationController
66

77
before_action :require_oauth_request
88
before_action :require_response_type_code
9+
before_action :ensure_claims_is_valid
910
before_action :require_client
1011
before_action :require_authentication
12+
before_action :print_scopes_and_claims
1113

1214
def create
13-
Rails.logger.info "scopes: #{requested_scopes}"
14-
15-
authorization = build_authorization_with(requested_scopes)
15+
authorization = build_authorization
1616

1717
oauth_response.code = authorization.code
1818
oauth_response.redirect_uri = @redirect_uri
@@ -25,13 +25,39 @@ def create
2525

2626
private
2727

28-
def build_authorization_with(scopes)
29-
Authorization.create(
28+
def build_authorization
29+
authorization = Authorization.new(
3030
client_id: @client.identifier,
3131
nonce: oauth_request.nonce,
32-
scopes: scopes,
32+
scopes: requested_scopes,
3333
account: oidc_current_account
3434
)
35+
36+
oauth_request.claims && authorization.claims = JSON.parse(oauth_request.claims)
37+
38+
authorization.save
39+
authorization
40+
end
41+
42+
def ensure_claims_is_valid
43+
return true unless oauth_request.claims
44+
45+
validate_json_is_a_hash!(parse_claims_as_json!)
46+
rescue Errors::InvalidClaimsFormatError => error
47+
Rails.logger.error "Invalid claims passed: #{error.message}"
48+
oauth_request.invalid_request! 'invalid claims format'
49+
end
50+
51+
def parse_claims_as_json!
52+
JSON.parse(oauth_request.claims)
53+
rescue JSON::ParserError => error
54+
Rails.logger.error "Invalid claims passed: #{error.message}"
55+
oauth_request.invalid_request! 'claims just be a JSON'
56+
end
57+
58+
def print_scopes_and_claims
59+
Rails.logger.info "scopes: #{requested_scopes}"
60+
Rails.logger.info "claims: #{oauth_request.claims}"
3561
end
3662

3763
def require_client
@@ -49,5 +75,20 @@ def require_response_type_code
4975

5076
oauth_request.unsupported_response_type!
5177
end
78+
79+
# Recursive method validating the given `json` is a hash of hashes
80+
def validate_json_is_a_hash!(json)
81+
# When reaching the end of the json/hash path, we're getting a `nil`, or
82+
# a String (hard coded value) or the `essential` boolean value (not yet
83+
# implemented).
84+
#
85+
# For example, when the previous call of this method received
86+
# `{ email: nil }`, the current call of this method receives `nil`.
87+
return if json.nil? || json.is_a?(String)
88+
89+
raise Errors::InvalidClaimsFormatError unless json.is_a?(Hash)
90+
91+
json.each_key { |key| validate_json_is_a_hash!(json[key]) }
92+
end
5293
end
5394
end
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
module OIDCProvider
24
class DiscoveryController < ApplicationController
35
def show
@@ -7,7 +9,7 @@ def show
79
when 'openid-configuration'
810
openid_configuration
911
else
10-
render plain: "Not found", status: :not_found
12+
render plain: 'Not found', status: :not_found
1113
end
1214
end
1315

@@ -21,27 +23,27 @@ def webfinger_discovery
2123
}]
2224
}
2325
jrd[:subject] = params[:resource] if params[:resource].present?
24-
render json: jrd, content_type: "application/jrd+json"
26+
render json: jrd, content_type: 'application/jrd+json'
2527
end
2628

2729
def openid_configuration
2830
config = OpenIDConnect::Discovery::Provider::Config::Response.new(
29-
issuer: OIDCProvider.issuer,
3031
authorization_endpoint: authorizations_url(host: OIDCProvider.issuer),
31-
token_endpoint: tokens_url(host: OIDCProvider.issuer),
32-
userinfo_endpoint: user_info_url(host: OIDCProvider.issuer),
32+
claims_parameter_supported: true,
33+
claims_supported: OIDCProvider.supported_claims,
3334
end_session_endpoint: end_session_url(host: OIDCProvider.issuer),
35+
grant_types_supported: [:authorization_code],
36+
id_token_signing_alg_values_supported: [:RS256],
37+
issuer: OIDCProvider.issuer,
3438
jwks_uri: jwks_url(host: OIDCProvider.issuer),
35-
scopes_supported: ["openid"] + OIDCProvider.supported_scopes.map(&:name),
3639
response_types_supported: [:code],
37-
grant_types_supported: [:authorization_code],
40+
scopes_supported: OIDCProvider.supported_scopes.map(&:name),
3841
subject_types_supported: [:public],
39-
id_token_signing_alg_values_supported: [:RS256],
40-
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
41-
claims_supported: ['sub', 'iss', 'name', 'email']
42+
token_endpoint: tokens_url(host: OIDCProvider.issuer),
43+
token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post],
44+
userinfo_endpoint: user_info_url(host: OIDCProvider.issuer)
4245
)
4346
render json: config
4447
end
4548
end
46-
47-
end
49+
end
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1+
# frozen_string_literal: true
2+
13
module OIDCProvider
24
class UserInfosController < ApplicationController
35
before_action :require_access_token
46

57
def show
6-
render json: AccountToUserInfo.new.(current_token.authorization.account, current_token.authorization.scopes)
8+
render json: user_info
9+
end
10+
11+
private
12+
13+
def user_info
14+
AccountToUserInfo.new(current_token.authorization.user_info_scopes)
15+
.call(current_token.authorization.account)
716
end
817
end
9-
end
18+
end
+101-6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
module OIDCProvider
24
class Authorization < ApplicationRecord
35
belongs_to :account, class_name: OIDCProvider.account_class
@@ -10,25 +12,72 @@ class Authorization < ApplicationRecord
1012
attribute :expires_at, :datetime, default: -> { 5.minutes.from_now }
1113

1214
serialize :scopes, JSON
15+
serialize :claims, JSON
16+
17+
def access_token
18+
super || (expire! && generate_access_token!)
19+
end
20+
21+
def scope_configs_for(type)
22+
if claims_request_for?(type)
23+
build_scope_configs_from_claims_request_for(type)
24+
else
25+
type == :id_token ? [open_id_scope_config] : user_info_scope_configs
26+
end
27+
end
1328

1429
def expire!
1530
self.expires_at = Time.now
16-
self.save!
17-
end
1831

19-
def access_token
20-
super || expire! && generate_access_token!
32+
save!
2133
end
2234

2335
def id_token
2436
super || generate_id_token!
2537
end
2638

39+
def user_info_scopes
40+
scopes - ['openid']
41+
end
42+
2743
private
2844

45+
def build_scope_config_for(scope, type, key)
46+
ScopeConfig.new(scope, [key.to_sym]).tap do |scope_config|
47+
scope_config.add_force_claim(key.to_sym => claims[type.to_s][key])
48+
end
49+
end
50+
51+
def build_scope_configs_from_claims_request_for(type)
52+
# No matter the `claims` config, when we are about to create an IdToken
53+
# response, we need the OpenID scope claims since there's mandatory ones
54+
scope_configs = type == :id_token ? [open_id_scope_config] : []
55+
56+
claims[type.to_s].each_key do |key|
57+
scopes_with_claim = OIDCProvider.find_all_scopes_with_claim(key)
58+
59+
next unless scope_found?(scopes_with_claim, key)
60+
61+
warn_when_many_scopes_found_in(scopes_with_claim, key, type)
62+
63+
scope = scopes_with_claim.first
64+
65+
next unless scope_has_been_requested?(scope)
66+
67+
scope_configs << build_scope_config_for(scope, type, key)
68+
end
69+
70+
scope_configs
71+
end
72+
73+
def claims_request_for?(type)
74+
return false unless claims
75+
76+
claims.keys.include?(type.to_s)
77+
end
78+
2979
def generate_access_token!
30-
token = create_access_token!
31-
token
80+
create_access_token!
3281
end
3382

3483
def generate_id_token!
@@ -37,5 +86,51 @@ def generate_id_token!
3786
token.save!
3887
token
3988
end
89+
90+
def open_id_scope_config
91+
scope = OIDCProvider.find_scope(OIDCProvider::Scopes::OpenID)
92+
93+
ScopeConfig.new(scope, scope.claims)
94+
end
95+
96+
def scope_found?(scopes_with_claim, key)
97+
return true unless scopes_with_claim.empty?
98+
99+
Rails.logger.warn(
100+
"WARNING: No scope found providing the '#{key}' claim. " \
101+
'OIDCProvider will skip it.'
102+
)
103+
104+
false
105+
end
106+
107+
def scope_has_been_requested?(scope)
108+
return true if scopes.include?(scope.name)
109+
110+
Rails.logger.warn(
111+
"WARNING: The scope #{scope.name} has not being requested " \
112+
'on authorization creation, there fore OIDCProvider will skip it.'
113+
)
114+
115+
false
116+
end
117+
118+
def user_info_scope_configs
119+
user_info_scopes.map do |scope_name|
120+
scope = OIDCProvider.find_scope(scope_name)
121+
122+
ScopeConfig.new(scope, scope.claims)
123+
end
124+
end
125+
126+
def warn_when_many_scopes_found_in(scopes_with_claim, key, type)
127+
return unless scopes_with_claim.size > 1
128+
129+
Rails.logger.warn(
130+
"WARNING: Scopes #{scopes_with_claim.map(&:name).to_sentence} " \
131+
"have the #{key} claim declared. OIDCProvider will use the first " \
132+
"one to populate the #{type} response."
133+
)
134+
end
40135
end
41136
end

app/models/oidc_provider/id_token.rb

+52-8
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,7 @@ class IdToken < ApplicationRecord
1111
delegate :account, to: :authorization
1212

1313
def to_response_object
14-
OpenIDConnect::ResponseObject::IdToken.new(
15-
iss: OIDCProvider.issuer,
16-
sub: account.send(OIDCProvider.account_identifier),
17-
aud: authorization.client_id,
18-
nonce: nonce,
19-
exp: expires_at.to_i,
20-
iat: created_at.to_i
21-
)
14+
OpenIDConnect::ResponseObject::IdToken.new(id_token_attributes)
2215
end
2316

2417
def to_jwt
@@ -27,6 +20,57 @@ def to_jwt
2720

2821
private
2922

23+
# Return a Struct accepting all the possible attributes from an instance of
24+
# the OpenIDConnect::ResponseObject::IdToken class used to collect the scope
25+
# values and populate the OpenIDConnect::ResponseObject::IdToken instance
26+
# that will be returned by the above `to_response_object`.
27+
#
28+
# At first I used an OpenStruct but since it has been officially discouraged
29+
# for performance, version compatibility, and potential security issues,
30+
# a `Struct` with predefined attributes is used instead.
31+
# See https://docs.ruby-lang.org/en/3.0/OpenStruct.html#class-OpenStruct-label-Caveats
32+
def build_id_token_struct
33+
Struct.new(*OpenIDConnect::ResponseObject::IdToken.all_attributes)
34+
end
35+
36+
def build_user_info_struct
37+
Struct.new(*OpenIDConnect::ResponseObject::UserInfo.all_attributes)
38+
end
39+
40+
def build_values_from_scope(scope_config)
41+
attributes, context = prepare_response_object_builder_from(scope_config)
42+
43+
ResponseObjectBuilder.new(attributes, context, scope_config.requested_claims)
44+
.run(&scope_config.scope.work)
45+
46+
response_attributes = attributes.to_h.compact
47+
48+
scope_config.force_claim.each do |key, value|
49+
response_attributes[key] = value
50+
end
51+
52+
response_attributes
53+
end
54+
55+
def id_token_attributes
56+
scope_configs.each_with_object({}) do |scope_config, memo|
57+
output = build_values_from_scope(scope_config)
58+
memo.merge!(output)
59+
end
60+
end
61+
62+
def prepare_response_object_builder_from(scope_config)
63+
if scope_config.name == OIDCProvider::Scopes::OpenID
64+
[build_id_token_struct.new, self]
65+
else
66+
[build_user_info_struct.new, account]
67+
end
68+
end
69+
70+
def scope_configs
71+
authorization.scope_configs_for(:id_token)
72+
end
73+
3074
class << self
3175
def config
3276
{

db/migrate/20170928211111_create_oidc_provider_authorizations.rb

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
# frozen_string_literal: true
2+
13
class CreateOIDCProviderAuthorizations < ActiveRecord::Migration[5.1]
24
def change
35
create_table :oidc_provider_authorizations do |t|
4-
t.references :account, foreign_key: {to_table: OIDCProvider.account_class.tableize}, null: false
6+
t.references :account, foreign_key: { to_table: OIDCProvider.account_class.tableize }, null: false
57
t.string :client_id, null: false
68
t.string :nonce
79
t.string :code, null: false
810
t.text :scopes, null: false
11+
t.text :claims
912
t.datetime :expires_at, null: false
1013

1114
t.timestamps

0 commit comments

Comments
 (0)