rodauth-oauth 0.0.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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