rodauth-oauth 0.2.0 → 0.4.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -8,20 +8,16 @@ module Rodauth
8
8
  def delete_suffix(suffix)
9
9
  suffix = suffix.to_s
10
10
  len = suffix.length
11
- if len.positive? && index(suffix, -len)
12
- self[0...-len]
13
- else
14
- dup
15
- end
11
+ return dup unless len.positive? && index(suffix, -len)
12
+
13
+ self[0...-len]
16
14
  end
17
15
 
18
16
  def delete_prefix(prefix)
19
17
  prefix = prefix.to_s
20
- if rindex(prefix, 0)
21
- self[prefix.length..-1]
22
- else
23
- dup
24
- end
18
+ return dup unless rindex(prefix, 0)
19
+
20
+ self[prefix.length..-1]
25
21
  end
26
22
  end
27
23
  end
@@ -6,6 +6,8 @@ module Rodauth
6
6
  Feature.define(:oauth_jwt) do
7
7
  depends :oauth
8
8
 
9
+ JWKS = OAuth::TtlStore.new
10
+
9
11
  auth_value_method :oauth_jwt_subject_type, "public" # public, pairwise
10
12
  auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation
11
13
 
@@ -39,7 +41,13 @@ module Rodauth
39
41
  :last_account_login_at
40
42
  )
41
43
 
42
- JWKS = OAuth::TtlStore.new
44
+ route(:jwks) do |r|
45
+ next unless is_authorization_server?
46
+
47
+ r.get do
48
+ json_response_success({ keys: jwks_set }, true)
49
+ end
50
+ end
43
51
 
44
52
  def require_oauth_authorization(*scopes)
45
53
  authorization_required unless authorization_token
@@ -168,7 +176,7 @@ module Rodauth
168
176
 
169
177
  def generate_oauth_token(params = {}, should_generate_refresh_token = true)
170
178
  create_params = {
171
- oauth_grants_expires_in_column => Time.now + oauth_token_expires_in
179
+ oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in)
172
180
  }.merge(params)
173
181
 
174
182
  oauth_token = rescue_from_uniqueness_error do
@@ -198,7 +206,7 @@ module Rodauth
198
206
  end
199
207
 
200
208
  def jwt_claims(oauth_token)
201
- issued_at = Time.now.utc.to_i
209
+ issued_at = Time.now.to_i
202
210
 
203
211
  claims = {
204
212
  iss: (oauth_jwt_token_issuer || authorization_server_url), # issuer
@@ -219,7 +227,7 @@ module Rodauth
219
227
  aud: (oauth_jwt_audience || oauth_application[oauth_applications_client_id_column])
220
228
  }
221
229
 
222
- claims[:auth_time] = last_account_login_at.utc.to_i if last_account_login_at
230
+ claims[:auth_time] = last_account_login_at.to_i if last_account_login_at
223
231
 
224
232
  claims
225
233
  end
@@ -237,7 +245,7 @@ module Rodauth
237
245
  end
238
246
  end
239
247
 
240
- def oauth_token_by_token(token, *)
248
+ def oauth_token_by_token(token)
241
249
  jwt_decode(token)
242
250
  end
243
251
 
@@ -300,9 +308,9 @@ module Rodauth
300
308
  # time-to-live
301
309
  ttl = if response.key?("cache-control")
302
310
  cache_control = response["cache-control"]
303
- cache_control[/max-age=(\d+)/, 1]
311
+ cache_control[/max-age=(\d+)/, 1].to_i
304
312
  elsif response.key?("expires")
305
- DateTime.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
313
+ Time.parse(response["expires"]).to_i - Time.now.to_i
306
314
  end
307
315
 
308
316
  [JSON.parse(response.body, symbolize_names: true), ttl]
@@ -454,13 +462,5 @@ module Rodauth
454
462
 
455
463
  super
456
464
  end
457
-
458
- route(:jwks) do |r|
459
- next unless is_authorization_server?
460
-
461
- r.get do
462
- json_response_success({ keys: jwks_set })
463
- end
464
- end
465
465
  end
466
466
  end
@@ -2,14 +2,63 @@
2
2
 
3
3
  module Rodauth
4
4
  Feature.define(:oidc) do
5
+ # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
5
6
  OIDC_SCOPES_MAP = {
6
7
  "profile" => %i[name family_name given_name middle_name nickname preferred_username
7
8
  profile picture website gender birthdate zoneinfo locale updated_at].freeze,
8
9
  "email" => %i[email email_verified].freeze,
9
- "address" => %i[address].freeze,
10
+ "address" => %i[formatted street_address locality region postal_code country].freeze,
10
11
  "phone" => %i[phone_number phone_number_verified].freeze
11
12
  }.freeze
12
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
+
13
62
  depends :oauth_jwt
14
63
 
15
64
  auth_value_method :oauth_application_default_scope, "openid"
@@ -22,12 +71,47 @@ module Rodauth
22
71
 
23
72
  auth_value_method :webfinger_relation, "http://openid.net/specs/connect/1.0/issuer"
24
73
 
25
- auth_value_methods(:get_oidc_param)
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
26
110
 
27
111
  def openid_configuration(issuer = nil)
28
112
  request.on(".well-known/openid-configuration") do
29
113
  request.get do
30
- json_response_success(openid_configuration_body(issuer))
114
+ json_response_success(openid_configuration_body(issuer), cache: true)
31
115
  end
32
116
  end
33
117
  end
@@ -57,6 +141,68 @@ module Rodauth
57
141
 
58
142
  private
59
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
+
60
206
  def create_oauth_grant(create_params = {})
61
207
  return super unless (nonce = param_or_nil("nonce"))
62
208
 
@@ -100,8 +246,11 @@ module Rodauth
100
246
  oauth_token[:id_token] = jwt_encode(id_token_claims)
101
247
  end
102
248
 
249
+ # aka fill_with_standard_claims
103
250
  def fill_with_account_claims(claims, account, scopes)
104
- scopes_by_oidc = scopes.each_with_object({}) do |scope, by_oidc|
251
+ scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc|
252
+ next if scope == "openid"
253
+
105
254
  oidc, param = scope.split(".", 2)
106
255
 
107
256
  by_oidc[oidc] ||= []
@@ -109,21 +258,33 @@ module Rodauth
109
258
  by_oidc[oidc] << param.to_sym if param
110
259
  end
111
260
 
112
- oidc_scopes = (OIDC_SCOPES_MAP.keys & scopes_by_oidc.keys)
113
-
114
- return if oidc_scopes.empty?
261
+ oidc_scopes, additional_scopes = scopes_by_claim.keys.partition { |key| OIDC_SCOPES_MAP.key?(key) }
115
262
 
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)
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)
120
269
 
121
- params.each do |param|
122
- claims[param] = __send__(:get_oidc_param, account, param)
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
123
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)
124
285
  end
125
286
  else
126
- warn "`get_oidc_param(token, param)` must be implemented to use oidc scopes."
287
+ warn "`get_additional_param(account, claim)` must be implemented to use oidc scopes."
127
288
  end
128
289
  end
129
290
 
@@ -145,33 +306,27 @@ module Rodauth
145
306
  end
146
307
  end
147
308
 
148
- def do_authorize(redirect_url, query_params = [], fragment_params = [])
309
+ def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
149
310
  return super unless use_oauth_implicit_grant_type?
150
311
 
151
312
  case param("response_type")
152
313
  when "id_token"
153
- fragment_params.replace(_do_authorize_id_token.map { |k, v| "#{k}=#{v}" })
314
+ response_params.replace(_do_authorize_id_token)
154
315
  when "code token"
155
316
  redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
156
317
 
157
- params = _do_authorize_code.merge(_do_authorize_token)
158
-
159
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
318
+ response_params.replace(_do_authorize_code.merge(_do_authorize_token))
160
319
  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}" })
320
+ response_params.replace(_do_authorize_code.merge(_do_authorize_id_token))
164
321
  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}" })
322
+ response_params.replace(_do_authorize_id_token.merge(_do_authorize_token))
168
323
  when "code id_token token"
169
- params = _do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token)
170
324
 
171
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
325
+ response_params.replace(_do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token))
172
326
  end
327
+ response_mode ||= "fragment" unless response_params.empty?
173
328
 
174
- super(redirect_url, query_params, fragment_params)
329
+ super(response_params, response_mode)
175
330
  end
176
331
 
177
332
  def _do_authorize_id_token
@@ -190,7 +345,9 @@ module Rodauth
190
345
  # Metadata
191
346
 
192
347
  def openid_configuration_body(path)
193
- metadata = oauth_server_metadata_body(path)
348
+ metadata = oauth_server_metadata_body(path).select do |k, _|
349
+ VALID_METADATA_KEYS.include?(k)
350
+ end
194
351
 
195
352
  scope_claims = oauth_application_scopes.each_with_object([]) do |scope, claims|
196
353
  oidc, param = scope.split(".", 2)
@@ -204,63 +361,38 @@ module Rodauth
204
361
 
205
362
  scope_claims.unshift("auth_time") if last_account_login_at
206
363
 
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
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
262
368
 
263
- throw_json_response_error(authorization_required_error_status, "invalid_token")
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?)
264
396
  end
265
397
  end
266
398
  end