rodauth-oauth 0.0.5 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,399 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ Feature.define(:oidc) do
5
+ # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
6
+ OIDC_SCOPES_MAP = {
7
+ "profile" => %i[name family_name given_name middle_name nickname preferred_username
8
+ profile picture website gender birthdate zoneinfo locale updated_at].freeze,
9
+ "email" => %i[email email_verified].freeze,
10
+ "address" => %i[formatted street_address locality region postal_code country].freeze,
11
+ "phone" => %i[phone_number phone_number_verified].freeze
12
+ }.freeze
13
+
14
+ VALID_METADATA_KEYS = %i[
15
+ issuer
16
+ authorization_endpoint
17
+ token_endpoint
18
+ userinfo_endpoint
19
+ jwks_uri
20
+ registration_endpoint
21
+ scopes_supported
22
+ response_types_supported
23
+ response_modes_supported
24
+ grant_types_supported
25
+ acr_values_supported
26
+ subject_types_supported
27
+ id_token_signing_alg_values_supported
28
+ id_token_encryption_alg_values_supported
29
+ id_token_encryption_enc_values_supported
30
+ userinfo_signing_alg_values_supported
31
+ userinfo_encryption_alg_values_supported
32
+ userinfo_encryption_enc_values_supported
33
+ request_object_signing_alg_values_supported
34
+ request_object_encryption_alg_values_supported
35
+ request_object_encryption_enc_values_supported
36
+ token_endpoint_auth_methods_supported
37
+ token_endpoint_auth_signing_alg_values_supported
38
+ display_values_supported
39
+ claim_types_supported
40
+ claims_supported
41
+ service_documentation
42
+ claims_locales_supported
43
+ ui_locales_supported
44
+ claims_parameter_supported
45
+ request_parameter_supported
46
+ request_uri_parameter_supported
47
+ require_request_uri_registration
48
+ op_policy_uri
49
+ op_tos_uri
50
+ ].freeze
51
+
52
+ REQUIRED_METADATA_KEYS = %i[
53
+ issuer
54
+ authorization_endpoint
55
+ token_endpoint
56
+ jwks_uri
57
+ response_types_supported
58
+ subject_types_supported
59
+ id_token_signing_alg_values_supported
60
+ ].freeze
61
+
62
+ depends :oauth_jwt
63
+
64
+ auth_value_method :oauth_application_default_scope, "openid"
65
+ auth_value_method :oauth_application_scopes, %w[openid]
66
+
67
+ auth_value_method :oauth_grants_nonce_column, :nonce
68
+ auth_value_method :oauth_tokens_nonce_column, :nonce
69
+
70
+ auth_value_method :invalid_scope_message, "The Access Token expired"
71
+
72
+ auth_value_method :webfinger_relation, "http://openid.net/specs/connect/1.0/issuer"
73
+
74
+ auth_value_method :oauth_prompt_login_cookie_key, "_rodauth_oauth_prompt_login"
75
+ auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
76
+ auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
77
+
78
+ auth_value_methods(:get_oidc_param, :get_additional_param)
79
+
80
+ # /userinfo
81
+ route(:userinfo) do |r|
82
+ next unless is_authorization_server?
83
+
84
+ r.on method: %i[get post] do
85
+ catch_error do
86
+ oauth_token = authorization_token
87
+
88
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_token
89
+
90
+ oauth_scopes = oauth_token["scope"].split(" ")
91
+
92
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
93
+
94
+ account = db[accounts_table].where(account_id_column => oauth_token["sub"]).first
95
+
96
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless account
97
+
98
+ oauth_scopes.delete("openid")
99
+
100
+ oidc_claims = { "sub" => oauth_token["sub"] }
101
+
102
+ fill_with_account_claims(oidc_claims, account, oauth_scopes)
103
+
104
+ json_response_success(oidc_claims)
105
+ end
106
+
107
+ throw_json_response_error(authorization_required_error_status, "invalid_token")
108
+ end
109
+ end
110
+
111
+ def openid_configuration(issuer = nil)
112
+ request.on(".well-known/openid-configuration") do
113
+ request.get do
114
+ json_response_success(openid_configuration_body(issuer), cache: true)
115
+ end
116
+ end
117
+ end
118
+
119
+ def webfinger
120
+ request.on(".well-known/webfinger") do
121
+ request.get do
122
+ resource = param_or_nil("resource")
123
+
124
+ throw_json_response_error(400, "invalid_request") unless resource
125
+
126
+ response.status = 200
127
+ response["Content-Type"] ||= "application/jrd+json"
128
+
129
+ json_payload = JSON.dump({
130
+ subject: resource,
131
+ links: [{
132
+ rel: webfinger_relation,
133
+ href: authorization_server_url
134
+ }]
135
+ })
136
+ response.write(json_payload)
137
+ request.halt
138
+ end
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def require_authorizable_account
145
+ try_prompt if param_or_nil("prompt")
146
+ super
147
+ end
148
+
149
+ # this executes before checking for a logged in account
150
+ def try_prompt
151
+ prompt = param_or_nil("prompt")
152
+
153
+ case prompt
154
+ when "none"
155
+ redirect_response_error("login_required") unless logged_in?
156
+
157
+ require_account
158
+
159
+ if db[oauth_grants_table].where(
160
+ oauth_grants_account_id_column => account_id,
161
+ oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
162
+ oauth_grants_redirect_uri_column => redirect_uri,
163
+ oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
164
+ oauth_grants_access_type_column => "online"
165
+ ).count.zero?
166
+ redirect_response_error("consent_required")
167
+ end
168
+
169
+ request.env["REQUEST_METHOD"] = "POST"
170
+ when "login"
171
+ if logged_in? && request.cookies[oauth_prompt_login_cookie_key] == "login"
172
+ ::Rack::Utils.delete_cookie_header!(response.headers, oauth_prompt_login_cookie_key, oauth_prompt_login_cookie_options)
173
+ return
174
+ end
175
+
176
+ # logging out
177
+ clear_session
178
+ set_session_value(login_redirect_session_key, request.fullpath)
179
+
180
+ login_cookie_opts = Hash[oauth_prompt_login_cookie_options]
181
+ login_cookie_opts[:value] = "login"
182
+ login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes
183
+ ::Rack::Utils.set_cookie_header!(response.headers, oauth_prompt_login_cookie_key, login_cookie_opts)
184
+
185
+ redirect require_login_redirect
186
+ when "consent"
187
+ require_account
188
+
189
+ if db[oauth_grants_table].where(
190
+ oauth_grants_account_id_column => account_id,
191
+ oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
192
+ oauth_grants_redirect_uri_column => redirect_uri,
193
+ oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
194
+ oauth_grants_access_type_column => "online"
195
+ ).count.zero?
196
+ redirect_response_error("consent_required")
197
+ end
198
+ when "select-account"
199
+ # obly works if select_account plugin is available
200
+ require_select_account if respond_to?(:require_select_account)
201
+ else
202
+ redirect_response_error("invalid_request")
203
+ end
204
+ end
205
+
206
+ def create_oauth_grant(create_params = {})
207
+ return super unless (nonce = param_or_nil("nonce"))
208
+
209
+ super(oauth_grants_nonce_column => nonce)
210
+ end
211
+
212
+ def create_oauth_token_from_authorization_code(oauth_grant, create_params)
213
+ return super unless oauth_grant[oauth_grants_nonce_column]
214
+
215
+ super(oauth_grant, create_params.merge(oauth_tokens_nonce_column => oauth_grant[oauth_grants_nonce_column]))
216
+ end
217
+
218
+ def create_oauth_token
219
+ oauth_token = super
220
+ generate_id_token(oauth_token)
221
+ oauth_token
222
+ end
223
+
224
+ def generate_id_token(oauth_token)
225
+ oauth_scopes = oauth_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
226
+
227
+ return unless oauth_scopes.include?("openid")
228
+
229
+ id_token_claims = jwt_claims(oauth_token)
230
+ id_token_claims[:nonce] = oauth_token[oauth_tokens_nonce_column] if oauth_token[oauth_tokens_nonce_column]
231
+
232
+ # Time when the End-User authentication occurred.
233
+ #
234
+ # Sounds like the same as issued at claim.
235
+ id_token_claims[:auth_time] = id_token_claims[:iat]
236
+
237
+ account = db[accounts_table].where(account_id_column => oauth_token[oauth_tokens_account_id_column]).first
238
+
239
+ # this should never happen!
240
+ # a newly minted oauth token from a grant should have been assigned to an account
241
+ # who just authorized its generation.
242
+ return unless account
243
+
244
+ fill_with_account_claims(id_token_claims, account, oauth_scopes)
245
+
246
+ oauth_token[:id_token] = jwt_encode(id_token_claims)
247
+ end
248
+
249
+ # aka fill_with_standard_claims
250
+ def fill_with_account_claims(claims, account, scopes)
251
+ scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc|
252
+ next if scope == "openid"
253
+
254
+ oidc, param = scope.split(".", 2)
255
+
256
+ by_oidc[oidc] ||= []
257
+
258
+ by_oidc[oidc] << param.to_sym if param
259
+ end
260
+
261
+ oidc_scopes, additional_scopes = scopes_by_claim.keys.partition { |key| OIDC_SCOPES_MAP.key?(key) }
262
+
263
+ unless oidc_scopes.empty?
264
+ if respond_to?(:get_oidc_param)
265
+ oidc_scopes.each do |scope|
266
+ scope_claims = claims
267
+ params = scopes_by_claim[scope]
268
+ params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
269
+
270
+ scope_claims = (claims["address"] = {}) if scope == "address"
271
+ params.each do |param|
272
+ scope_claims[param] = __send__(:get_oidc_param, account, param)
273
+ end
274
+ end
275
+ else
276
+ warn "`get_oidc_param(account, claim)` must be implemented to use oidc scopes."
277
+ end
278
+ end
279
+
280
+ return if additional_scopes.empty?
281
+
282
+ if respond_to?(:get_additional_param)
283
+ additional_scopes.each do |scope|
284
+ claims[scope] = __send__(:get_additional_param, account, scope.to_sym)
285
+ end
286
+ else
287
+ warn "`get_additional_param(account, claim)` must be implemented to use oidc scopes."
288
+ end
289
+ end
290
+
291
+ def json_access_token_payload(oauth_token)
292
+ payload = super
293
+ payload["id_token"] = oauth_token[:id_token] if oauth_token[:id_token]
294
+ payload
295
+ end
296
+
297
+ # Authorize
298
+
299
+ def check_valid_response_type?
300
+ case param_or_nil("response_type")
301
+ when "none", "id_token",
302
+ "code token", "code id_token", "id_token token", "code id_token token" # multiple
303
+ true
304
+ else
305
+ super
306
+ end
307
+ end
308
+
309
+ def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
310
+ return super unless use_oauth_implicit_grant_type?
311
+
312
+ case param("response_type")
313
+ when "id_token"
314
+ response_params.replace(_do_authorize_id_token)
315
+ when "code token"
316
+ redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
317
+
318
+ response_params.replace(_do_authorize_code.merge(_do_authorize_token))
319
+ when "code id_token"
320
+ response_params.replace(_do_authorize_code.merge(_do_authorize_id_token))
321
+ when "id_token token"
322
+ response_params.replace(_do_authorize_id_token.merge(_do_authorize_token))
323
+ when "code id_token token"
324
+
325
+ response_params.replace(_do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token))
326
+ end
327
+ response_mode ||= "fragment" unless response_params.empty?
328
+
329
+ super(response_params, response_mode)
330
+ end
331
+
332
+ def _do_authorize_id_token
333
+ create_params = {
334
+ oauth_tokens_account_id_column => account_id,
335
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
336
+ oauth_tokens_scopes_column => scopes
337
+ }
338
+ oauth_token = generate_oauth_token(create_params, false)
339
+ generate_id_token(oauth_token)
340
+ params = json_access_token_payload(oauth_token)
341
+ params.delete("access_token")
342
+ params
343
+ end
344
+
345
+ # Metadata
346
+
347
+ def openid_configuration_body(path)
348
+ metadata = oauth_server_metadata_body(path).select do |k, _|
349
+ VALID_METADATA_KEYS.include?(k)
350
+ end
351
+
352
+ scope_claims = oauth_application_scopes.each_with_object([]) do |scope, claims|
353
+ oidc, param = scope.split(".", 2)
354
+ if param
355
+ claims << param
356
+ else
357
+ oidc_claims = OIDC_SCOPES_MAP[oidc]
358
+ claims.concat(oidc_claims) if oidc_claims
359
+ end
360
+ end
361
+
362
+ scope_claims.unshift("auth_time") if last_account_login_at
363
+
364
+ response_types_supported = metadata[:response_types_supported]
365
+ if use_oauth_implicit_grant_type?
366
+ response_types_supported += ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"]
367
+ end
368
+
369
+ metadata.merge(
370
+ userinfo_endpoint: userinfo_url,
371
+ response_types_supported: response_types_supported,
372
+ subject_types_supported: [oauth_jwt_subject_type],
373
+
374
+ id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
375
+ id_token_encryption_alg_values_supported: [oauth_jwt_jwe_algorithm].compact,
376
+ id_token_encryption_enc_values_supported: [oauth_jwt_jwe_encryption_method].compact,
377
+
378
+ userinfo_signing_alg_values_supported: [],
379
+ userinfo_encryption_alg_values_supported: [],
380
+ userinfo_encryption_enc_values_supported: [],
381
+
382
+ request_object_signing_alg_values_supported: [],
383
+ request_object_encryption_alg_values_supported: [],
384
+ request_object_encryption_enc_values_supported: [],
385
+
386
+ # These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core].
387
+ # Values defined by this specification are normal, aggregated, and distributed.
388
+ # If omitted, the implementation supports only normal Claims.
389
+ claim_types_supported: %w[normal],
390
+ claims_supported: %w[sub iss iat exp aud] | scope_claims
391
+ ).reject do |key, val|
392
+ # Filter null values in optional items
393
+ (!REQUIRED_METADATA_KEYS.include?(key.to_sym) && val.nil?) ||
394
+ # Claims with zero elements MUST be omitted from the response
395
+ (val.respond_to?(:empty?) && val.empty?)
396
+ end
397
+ end
398
+ end
399
+ 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