rodauth-oauth 0.0.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,104 @@
1
+ # frozen-string-literal: true
2
+
3
+ require "onelogin/ruby-saml"
4
+
5
+ module Rodauth
6
+ Feature.define(:oauth_saml) do
7
+ depends :oauth
8
+
9
+ auth_value_method :oauth_saml_cert_fingerprint, "9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D"
10
+ auth_value_method :oauth_saml_cert_fingerprint_algorithm, nil
11
+ auth_value_method :oauth_saml_name_identifier_format, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
12
+
13
+ auth_value_method :oauth_saml_security_authn_requests_signed, false
14
+ auth_value_method :oauth_saml_security_metadata_signed, false
15
+ auth_value_method :oauth_saml_security_digest_method, XMLSecurity::Document::SHA1
16
+ auth_value_method :oauth_saml_security_signature_method, XMLSecurity::Document::RSA_SHA1
17
+
18
+ SAML_GRANT_TYPE = "http://oauth.net/grant_type/assertion/saml/2.0/bearer"
19
+
20
+ # /token
21
+
22
+ def require_oauth_application
23
+ # requset authentication optional for assertions
24
+ return super unless param("grant_type") == SAML_GRANT_TYPE && !param_or_nil("client_id")
25
+
26
+ # TODO: invalid grant
27
+ authorization_required unless saml_assertion
28
+
29
+ redirect_uri = saml_assertion.destination
30
+
31
+ @oauth_application = db[oauth_applications_table].where(
32
+ oauth_applications_homepage_url_column => saml_assertion.audiences,
33
+ oauth_applications_redirect_uri_column => redirect_uri
34
+ ).first
35
+
36
+ # The Assertion's <Issuer> element MUST contain a unique identifier
37
+ # for the entity that issued the Assertion.
38
+ authorization_required unless saml_assertion.issuers.all? do |issuer|
39
+ issuer.start_with?(@oauth_application[oauth_applications_homepage_url_column])
40
+ end
41
+
42
+ authorization_required unless @oauth_application
43
+ end
44
+
45
+ private
46
+
47
+ def secret_matches?(oauth_application, secret)
48
+ return super unless param_or_nil("assertion")
49
+
50
+ true
51
+ end
52
+
53
+ def saml_assertion
54
+ return @saml_assertion if defined?(@saml_assertion)
55
+
56
+ @saml_assertion = begin
57
+ settings = OneLogin::RubySaml::Settings.new
58
+ settings.idp_cert_fingerprint = oauth_saml_cert_fingerprint
59
+ settings.idp_cert_fingerprint_algorithm = oauth_saml_cert_fingerprint_algorithm
60
+ settings.name_identifier_format = oauth_saml_name_identifier_format
61
+ settings.security[:authn_requests_signed] = oauth_saml_security_authn_requests_signed
62
+ settings.security[:metadata_signed] = oauth_saml_security_metadata_signed
63
+ settings.security[:digest_method] = oauth_saml_security_digest_method
64
+ settings.security[:signature_method] = oauth_saml_security_signature_method
65
+
66
+ response = OneLogin::RubySaml::Response.new(param("assertion"), settings: settings, skip_recipient_check: true)
67
+
68
+ return unless response.is_valid?
69
+
70
+ response
71
+ end
72
+ end
73
+
74
+ def validate_oauth_token_params
75
+ return super unless param("grant_type") == SAML_GRANT_TYPE
76
+
77
+ redirect_response_error("invalid_client") unless param_or_nil("assertion")
78
+
79
+ redirect_response_error("invalid_scope") unless check_valid_scopes?
80
+ end
81
+
82
+ def create_oauth_token
83
+ if param("grant_type") == SAML_GRANT_TYPE
84
+ create_oauth_token_from_saml_assertion
85
+ else
86
+ super
87
+ end
88
+ end
89
+
90
+ def create_oauth_token_from_saml_assertion
91
+ account = db[accounts_table].where(login_column => saml_assertion.nameid).first
92
+
93
+ redirect_response_error("invalid_client") unless oauth_application && account
94
+
95
+ create_params = {
96
+ oauth_tokens_account_id_column => account[account_id_column],
97
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
98
+ oauth_tokens_scopes_column => (param_or_nil("scope") || oauth_application[oauth_applications_scopes_column])
99
+ }
100
+
101
+ generate_oauth_token(create_params, false)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,267 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ Feature.define(:oidc) do
5
+ OIDC_SCOPES_MAP = {
6
+ "profile" => %i[name family_name given_name middle_name nickname preferred_username
7
+ profile picture website gender birthdate zoneinfo locale updated_at].freeze,
8
+ "email" => %i[email email_verified].freeze,
9
+ "address" => %i[address].freeze,
10
+ "phone" => %i[phone_number phone_number_verified].freeze
11
+ }.freeze
12
+
13
+ depends :oauth_jwt
14
+
15
+ auth_value_method :oauth_application_default_scope, "openid"
16
+ auth_value_method :oauth_application_scopes, %w[openid]
17
+
18
+ auth_value_method :oauth_grants_nonce_column, :nonce
19
+ auth_value_method :oauth_tokens_nonce_column, :nonce
20
+
21
+ auth_value_method :invalid_scope_message, "The Access Token expired"
22
+
23
+ auth_value_method :webfinger_relation, "http://openid.net/specs/connect/1.0/issuer"
24
+
25
+ auth_value_methods(:get_oidc_param)
26
+
27
+ def openid_configuration(issuer = nil)
28
+ request.on(".well-known/openid-configuration") do
29
+ request.get do
30
+ json_response_success(openid_configuration_body(issuer))
31
+ end
32
+ end
33
+ end
34
+
35
+ def webfinger
36
+ request.on(".well-known/webfinger") do
37
+ request.get do
38
+ resource = param_or_nil("resource")
39
+
40
+ throw_json_response_error(400, "invalid_request") unless resource
41
+
42
+ response.status = 200
43
+ response["Content-Type"] ||= "application/jrd+json"
44
+
45
+ json_payload = JSON.dump({
46
+ subject: resource,
47
+ links: [{
48
+ rel: webfinger_relation,
49
+ href: authorization_server_url
50
+ }]
51
+ })
52
+ response.write(json_payload)
53
+ request.halt
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def create_oauth_grant(create_params = {})
61
+ return super unless (nonce = param_or_nil("nonce"))
62
+
63
+ super(oauth_grants_nonce_column => nonce)
64
+ end
65
+
66
+ def create_oauth_token_from_authorization_code(oauth_grant, create_params)
67
+ return super unless oauth_grant[oauth_grants_nonce_column]
68
+
69
+ super(oauth_grant, create_params.merge(oauth_tokens_nonce_column => oauth_grant[oauth_grants_nonce_column]))
70
+ end
71
+
72
+ def create_oauth_token
73
+ oauth_token = super
74
+ generate_id_token(oauth_token)
75
+ oauth_token
76
+ end
77
+
78
+ def generate_id_token(oauth_token)
79
+ oauth_scopes = oauth_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
80
+
81
+ return unless oauth_scopes.include?("openid")
82
+
83
+ id_token_claims = jwt_claims(oauth_token)
84
+ id_token_claims[:nonce] = oauth_token[oauth_tokens_nonce_column] if oauth_token[oauth_tokens_nonce_column]
85
+
86
+ # Time when the End-User authentication occurred.
87
+ #
88
+ # Sounds like the same as issued at claim.
89
+ id_token_claims[:auth_time] = id_token_claims[:iat]
90
+
91
+ account = db[accounts_table].where(account_id_column => oauth_token[oauth_tokens_account_id_column]).first
92
+
93
+ # this should never happen!
94
+ # a newly minted oauth token from a grant should have been assigned to an account
95
+ # who just authorized its generation.
96
+ return unless account
97
+
98
+ fill_with_account_claims(id_token_claims, account, oauth_scopes)
99
+
100
+ oauth_token[:id_token] = jwt_encode(id_token_claims)
101
+ end
102
+
103
+ def fill_with_account_claims(claims, account, scopes)
104
+ scopes_by_oidc = scopes.each_with_object({}) do |scope, by_oidc|
105
+ oidc, param = scope.split(".", 2)
106
+
107
+ by_oidc[oidc] ||= []
108
+
109
+ by_oidc[oidc] << param.to_sym if param
110
+ end
111
+
112
+ oidc_scopes = (OIDC_SCOPES_MAP.keys & scopes_by_oidc.keys)
113
+
114
+ return if oidc_scopes.empty?
115
+
116
+ if respond_to?(:get_oidc_param)
117
+ oidc_scopes.each do |scope|
118
+ params = scopes_by_oidc[scope]
119
+ params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
120
+
121
+ params.each do |param|
122
+ claims[param] = __send__(:get_oidc_param, account, param)
123
+ end
124
+ end
125
+ else
126
+ warn "`get_oidc_param(token, param)` must be implemented to use oidc scopes."
127
+ end
128
+ end
129
+
130
+ def json_access_token_payload(oauth_token)
131
+ payload = super
132
+ payload["id_token"] = oauth_token[:id_token] if oauth_token[:id_token]
133
+ payload
134
+ end
135
+
136
+ # Authorize
137
+
138
+ def check_valid_response_type?
139
+ case param_or_nil("response_type")
140
+ when "none", "id_token",
141
+ "code token", "code id_token", "id_token token", "code id_token token" # multiple
142
+ true
143
+ else
144
+ super
145
+ end
146
+ end
147
+
148
+ def do_authorize(redirect_url, query_params = [], fragment_params = [])
149
+ return super unless use_oauth_implicit_grant_type?
150
+
151
+ case param("response_type")
152
+ when "id_token"
153
+ fragment_params.replace(_do_authorize_id_token.map { |k, v| "#{k}=#{v}" })
154
+ when "code token"
155
+ redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
156
+
157
+ params = _do_authorize_code.merge(_do_authorize_token)
158
+
159
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
160
+ when "code id_token"
161
+ params = _do_authorize_code.merge(_do_authorize_id_token)
162
+
163
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
164
+ when "id_token token"
165
+ params = _do_authorize_id_token.merge(_do_authorize_token)
166
+
167
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
168
+ when "code id_token token"
169
+ params = _do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token)
170
+
171
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
172
+ end
173
+
174
+ super(redirect_url, query_params, fragment_params)
175
+ end
176
+
177
+ def _do_authorize_id_token
178
+ create_params = {
179
+ oauth_tokens_account_id_column => account_id,
180
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
181
+ oauth_tokens_scopes_column => scopes
182
+ }
183
+ oauth_token = generate_oauth_token(create_params, false)
184
+ generate_id_token(oauth_token)
185
+ params = json_access_token_payload(oauth_token)
186
+ params.delete("access_token")
187
+ params
188
+ end
189
+
190
+ # Metadata
191
+
192
+ def openid_configuration_body(path)
193
+ metadata = oauth_server_metadata_body(path)
194
+
195
+ scope_claims = oauth_application_scopes.each_with_object([]) do |scope, claims|
196
+ oidc, param = scope.split(".", 2)
197
+ if param
198
+ claims << param
199
+ else
200
+ oidc_claims = OIDC_SCOPES_MAP[oidc]
201
+ claims.concat(oidc_claims) if oidc_claims
202
+ end
203
+ end
204
+
205
+ scope_claims.unshift("auth_time") if last_account_login_at
206
+
207
+ metadata.merge({
208
+ userinfo_endpoint: userinfo_url,
209
+ response_types_supported: metadata[:response_types_supported] +
210
+ ["none", "id_token", %w[code token], %w[code id_token], %w[id_token token], %w[code id_token token]],
211
+ response_modes_supported: %w[query fragment],
212
+ grant_types_supported: %w[authorization_code implicit],
213
+
214
+ subject_types_supported: [oauth_jwt_subject_type],
215
+
216
+ id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
217
+ id_token_encryption_alg_values_supported: [oauth_jwt_jwe_algorithm].compact,
218
+ id_token_encryption_enc_values_supported: [oauth_jwt_jwe_encryption_method].compact,
219
+
220
+ userinfo_signing_alg_values_supported: [],
221
+ userinfo_encryption_alg_values_supported: [],
222
+ userinfo_encryption_enc_values_supported: [],
223
+
224
+ request_object_signing_alg_values_supported: [],
225
+ request_object_encryption_alg_values_supported: [],
226
+ request_object_encryption_enc_values_supported: [],
227
+
228
+ # These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core].
229
+ # Values defined by this specification are normal, aggregated, and distributed.
230
+ # If omitted, the implementation supports only normal Claims.
231
+ claim_types_supported: %w[normal],
232
+ claims_supported: %w[sub iss iat exp aud] | scope_claims
233
+ })
234
+ end
235
+
236
+ # /userinfo
237
+ route(:userinfo) do |r|
238
+ next unless is_authorization_server?
239
+
240
+ r.on method: %i[get post] do
241
+ catch_error do
242
+ oauth_token = authorization_token
243
+
244
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_token
245
+
246
+ oauth_scopes = oauth_token["scope"].split(" ")
247
+
248
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
249
+
250
+ account = db[accounts_table].where(account_id_column => oauth_token["sub"]).first
251
+
252
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless account
253
+
254
+ oauth_scopes.delete("openid")
255
+
256
+ oidc_claims = { "sub" => oauth_token["sub"] }
257
+
258
+ fill_with_account_claims(oidc_claims, account, oauth_scopes)
259
+
260
+ json_response_success(oidc_claims)
261
+ end
262
+
263
+ throw_json_response_error(authorization_required_error_status, "invalid_token")
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodauth
4
+ module OAuth
5
+ # rubocop:disable Naming/MethodName, Metrics/ParameterLists
6
+ def self.ExtendDatabase(db)
7
+ Module.new do
8
+ dataset = db.dataset
9
+
10
+ if dataset.supports_returning?(:insert)
11
+ def __insert_and_return__(dataset, _pkey, params)
12
+ dataset.returning.insert(params).first
13
+ end
14
+ else
15
+ def __insert_and_return__(dataset, pkey, params)
16
+ id = dataset.insert(params)
17
+ dataset.where(pkey => id).first
18
+ end
19
+ end
20
+
21
+ if dataset.supports_returning?(:update)
22
+ def __update_and_return__(dataset, params)
23
+ dataset.returning.update(params).first
24
+ end
25
+ else
26
+ def __update_and_return__(dataset, params)
27
+ dataset.update(params)
28
+ dataset.first
29
+ end
30
+ end
31
+
32
+ if dataset.respond_to?(:supports_insert_conflict?) && dataset.supports_insert_conflict?
33
+ def __insert_or_update_and_return__(dataset, pkey, unique_columns, params, conds = nil, exclude_on_update = nil)
34
+ to_update = params.keys - unique_columns
35
+ to_update -= exclude_on_update if exclude_on_update
36
+
37
+ dataset = dataset.insert_conflict(
38
+ target: unique_columns,
39
+ update: Hash[ to_update.map { |attribute| [attribute, Sequel[:excluded][attribute]] } ],
40
+ update_where: conds
41
+ )
42
+
43
+ __insert_and_return__(dataset, pkey, params)
44
+ end
45
+ else
46
+ def __insert_or_update_and_return__(dataset, pkey, unique_columns, params, conds = nil, exclude_on_update = nil)
47
+ find_params, update_params = params.partition { |key, _| unique_columns.include?(key) }.map { |h| Hash[h] }
48
+
49
+ dataset_where = dataset.where(find_params)
50
+ record = if conds
51
+ dataset_where_conds = dataset_where.where(conds)
52
+
53
+ # this means that there's still a valid entry there, so return early
54
+ return if dataset_where.count != dataset_where_conds.count
55
+
56
+ dataset_where_conds.first
57
+ else
58
+ dataset_where.first
59
+ end
60
+
61
+ if record
62
+ update_params.reject! { |k, _v| exclude_on_update.include?(k) } if exclude_on_update
63
+ __update_and_return__(dataset_where, update_params)
64
+ else
65
+ __insert_and_return__(dataset, pkey, params)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ # rubocop:enable Naming/MethodName, Metrics/ParameterLists
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # The TTL store is a data structure which keeps data by a key, and with a time-to-live.
5
+ # It is specifically designed for data which is static, i.e. for a certain key in a
6
+ # sufficiently large span, the value will be the same.
7
+ #
8
+ # Because of that, synchronizations around reads do not exist, while write synchronizations
9
+ # will be short-circuited by a read.
10
+ #
11
+ class Rodauth::OAuth::TtlStore
12
+ DEFAULT_TTL = 60 * 60 * 24 # default TTL is one day
13
+
14
+ def initialize
15
+ @store_mutex = Mutex.new
16
+ @store = Hash.new {}
17
+ end
18
+
19
+ def [](key)
20
+ lookup(key, now)
21
+ end
22
+
23
+ def set(key, &block)
24
+ @store_mutex.synchronize do
25
+ # short circuit
26
+ return @store[key][:payload] if @store[key] && @store[key][:ttl] < now
27
+
28
+ payload, ttl = block.call
29
+ @store[key] = { payload: payload, ttl: (ttl || (now + DEFAULT_TTL)) }
30
+
31
+ @store[key][:payload]
32
+ end
33
+ end
34
+
35
+ def uncache(key)
36
+ @store_mutex.synchronize do
37
+ @store.delete(key)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def now
44
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ end
46
+
47
+ # do not use directly!
48
+ def lookup(key, ttl)
49
+ return unless @store.key?(key)
50
+
51
+ value = @store[key]
52
+
53
+ return if value.empty?
54
+
55
+ return unless value[:ttl] > ttl
56
+
57
+ value[:payload]
58
+ end
59
+ end