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.
@@ -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,7 +6,12 @@ module Rodauth
6
6
  Feature.define(:oauth_jwt) do
7
7
  depends :oauth
8
8
 
9
- auth_value_method :oauth_jwt_token_issuer, "Example"
9
+ JWKS = OAuth::TtlStore.new
10
+
11
+ auth_value_method :oauth_jwt_subject_type, "public" # public, pairwise
12
+ auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation
13
+
14
+ auth_value_method :oauth_jwt_token_issuer, nil
10
15
 
11
16
  auth_value_method :oauth_application_jws_jwk_column, nil
12
17
 
@@ -19,6 +24,10 @@ module Rodauth
19
24
  auth_value_method :oauth_jwt_jwe_algorithm, nil
20
25
  auth_value_method :oauth_jwt_jwe_encryption_method, nil
21
26
 
27
+ # values used for rotating keys
28
+ auth_value_method :oauth_jwt_legacy_public_key, nil
29
+ auth_value_method :oauth_jwt_legacy_algorithm, nil
30
+
22
31
  auth_value_method :oauth_jwt_jwe_copyright, nil
23
32
  auth_value_method :oauth_jwt_audience, nil
24
33
 
@@ -28,10 +37,17 @@ module Rodauth
28
37
  auth_value_methods(
29
38
  :jwt_encode,
30
39
  :jwt_decode,
31
- :jwks_set
40
+ :jwks_set,
41
+ :last_account_login_at
32
42
  )
33
43
 
34
- 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
35
51
 
36
52
  def require_oauth_authorization(*scopes)
37
53
  authorization_required unless authorization_token
@@ -45,6 +61,12 @@ module Rodauth
45
61
 
46
62
  private
47
63
 
64
+ unless method_defined?(:last_account_login_at)
65
+ def last_account_login_at
66
+ nil
67
+ end
68
+ end
69
+
48
70
  def authorization_token
49
71
  return @authorization_token if defined?(@authorization_token)
50
72
 
@@ -57,8 +79,8 @@ module Rodauth
57
79
 
58
80
  return unless jwt_token
59
81
 
60
- return if jwt_token["iss"] != oauth_jwt_token_issuer ||
61
- jwt_token["aud"] != oauth_jwt_audience ||
82
+ return if jwt_token["iss"] != (oauth_jwt_token_issuer || authorization_server_url) ||
83
+ (oauth_jwt_audience && jwt_token["aud"] != oauth_jwt_audience) ||
62
84
  !jwt_token["sub"]
63
85
 
64
86
  jwt_token
@@ -78,9 +100,7 @@ module Rodauth
78
100
  jws_jwk = if oauth_application[oauth_application_jws_jwk_column]
79
101
  jwk = oauth_application[oauth_application_jws_jwk_column]
80
102
 
81
- if jwk
82
- jwk = JSON.parse(jwk, symbolize_names: true) if jwk.is_a?(String)
83
- end
103
+ jwk = JSON.parse(jwk, symbolize_names: true) if jwk && jwk.is_a?(String)
84
104
  else
85
105
  redirect_response_error("invalid_request_object")
86
106
  end
@@ -95,8 +115,8 @@ module Rodauth
95
115
  # [RFC7519] specification. The value of "aud" should be the value of
96
116
  # the Authorization Server (AS) "issuer" as defined in RFC8414
97
117
  # [RFC8414].
98
- claims.delete(:iss)
99
- audience = claims.delete(:aud)
118
+ claims.delete("iss")
119
+ audience = claims.delete("aud")
100
120
 
101
121
  redirect_response_error("invalid_request_object") if audience && audience != authorization_server_url
102
122
 
@@ -109,11 +129,17 @@ module Rodauth
109
129
 
110
130
  # /token
111
131
 
112
- def before_token
132
+ def require_oauth_application
113
133
  # requset authentication optional for assertions
114
- return if param("grant_type") == "urn:ietf:params:oauth:grant-type:jwt-bearer"
134
+ return super unless param("grant_type") == "urn:ietf:params:oauth:grant-type:jwt-bearer"
115
135
 
116
- super
136
+ claims = jwt_decode(param("assertion"))
137
+
138
+ redirect_response_error("invalid_grant") unless claims
139
+
140
+ @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
141
+
142
+ authorization_required unless @oauth_application
117
143
  end
118
144
 
119
145
  def validate_oauth_token_params
@@ -135,10 +161,6 @@ module Rodauth
135
161
  def create_oauth_token_from_assertion
136
162
  claims = jwt_decode(param("assertion"))
137
163
 
138
- redirect_response_error("invalid_grant") unless claims
139
-
140
- @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
141
-
142
164
  account = account_ds(claims["sub"]).first
143
165
 
144
166
  redirect_response_error("invalid_client") unless oauth_application && account
@@ -154,26 +176,40 @@ module Rodauth
154
176
 
155
177
  def generate_oauth_token(params = {}, should_generate_refresh_token = true)
156
178
  create_params = {
157
- 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)
158
180
  }.merge(params)
159
181
 
160
- if should_generate_refresh_token
161
- refresh_token = oauth_unique_id_generator
182
+ oauth_token = rescue_from_uniqueness_error do
183
+ if should_generate_refresh_token
184
+ refresh_token = oauth_unique_id_generator
162
185
 
163
- if oauth_tokens_refresh_token_hash_column
164
- create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
165
- else
166
- create_params[oauth_tokens_refresh_token_column] = refresh_token
186
+ if oauth_tokens_refresh_token_hash_column
187
+ create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
188
+ else
189
+ create_params[oauth_tokens_refresh_token_column] = refresh_token
190
+ end
167
191
  end
192
+
193
+ _generate_oauth_token(create_params)
168
194
  end
169
195
 
170
- oauth_token = _generate_oauth_token(create_params)
196
+ claims = jwt_claims(oauth_token)
197
+
198
+ # one of the points of using jwt is avoiding database lookups, so we put here all relevant
199
+ # token data.
200
+ claims[:scope] = oauth_token[oauth_tokens_scopes_column]
171
201
 
172
- issued_at = Time.now.utc.to_i
202
+ token = jwt_encode(claims)
173
203
 
174
- payload = {
175
- sub: oauth_token[oauth_tokens_account_id_column],
176
- iss: oauth_jwt_token_issuer, # issuer
204
+ oauth_token[oauth_tokens_token_column] = token
205
+ oauth_token
206
+ end
207
+
208
+ def jwt_claims(oauth_token)
209
+ issued_at = Time.now.to_i
210
+
211
+ claims = {
212
+ iss: (oauth_jwt_token_issuer || authorization_server_url), # issuer
177
213
  iat: issued_at, # issued at
178
214
  #
179
215
  # sub REQUIRED - as defined in section 4.1.2 of [RFC7519]. In case of
@@ -184,23 +220,32 @@ module Rodauth
184
220
  # owner is involved, such as the client credentials grant, the value
185
221
  # of "sub" SHOULD correspond to an identifier the authorization
186
222
  # server uses to indicate the client application.
223
+ sub: jwt_subject(oauth_token),
187
224
  client_id: oauth_application[oauth_applications_client_id_column],
188
225
 
189
226
  exp: issued_at + oauth_token_expires_in,
190
- aud: oauth_jwt_audience,
191
-
192
- # one of the points of using jwt is avoiding database lookups, so we put here all relevant
193
- # token data.
194
- scope: oauth_token[oauth_tokens_scopes_column]
227
+ aud: (oauth_jwt_audience || oauth_application[oauth_applications_client_id_column])
195
228
  }
196
229
 
197
- token = jwt_encode(payload)
230
+ claims[:auth_time] = last_account_login_at.to_i if last_account_login_at
198
231
 
199
- oauth_token[oauth_tokens_token_column] = token
200
- oauth_token
232
+ claims
233
+ end
234
+
235
+ def jwt_subject(oauth_token)
236
+ case oauth_jwt_subject_type
237
+ when "public"
238
+ oauth_token[oauth_tokens_account_id_column]
239
+ when "pairwise"
240
+ id = oauth_token[oauth_tokens_account_id_column]
241
+ application_id = oauth_token[oauth_tokens_oauth_application_id_column]
242
+ Digest::SHA256.hexdigest("#{id}#{application_id}#{oauth_jwt_subject_secret}")
243
+ else
244
+ raise StandardError, "unexpected subject (#{oauth_jwt_subject_type})"
245
+ end
201
246
  end
202
247
 
203
- def oauth_token_by_token(token, *)
248
+ def oauth_token_by_token(token)
204
249
  jwt_decode(token)
205
250
  end
206
251
 
@@ -228,17 +273,11 @@ module Rodauth
228
273
  def oauth_server_metadata_body(path)
229
274
  metadata = super
230
275
  metadata.merge! \
231
- jwks_uri: oauth_jwks_url,
276
+ jwks_uri: jwks_url,
232
277
  token_endpoint_auth_signing_alg_values_supported: [oauth_jwt_algorithm]
233
278
  metadata
234
279
  end
235
280
 
236
- def token_from_application?(oauth_token, oauth_application)
237
- return super unless oauth_token["sub"] # naive check on whether it's a jwt token
238
-
239
- oauth_token["client_id"] == oauth_application[oauth_applications_client_id_column]
240
- end
241
-
242
281
  def _jwt_key
243
282
  @_jwt_key ||= oauth_jwt_key || (oauth_application[oauth_applications_client_secret_column] if oauth_application)
244
283
  end
@@ -268,10 +307,10 @@ module Rodauth
268
307
 
269
308
  # time-to-live
270
309
  ttl = if response.key?("cache-control")
271
- cache_control = response["cache_control"]
272
- cache_control[/max-age=(\d+)/, 1]
310
+ cache_control = response["cache-control"]
311
+ cache_control[/max-age=(\d+)/, 1].to_i
273
312
  elsif response.key?("expires")
274
- Time.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
313
+ Time.parse(response["expires"]).to_i - Time.now.to_i
275
314
  end
276
315
 
277
316
  [JSON.parse(response.body, symbolize_names: true), ttl]
@@ -279,7 +318,6 @@ module Rodauth
279
318
  end
280
319
 
281
320
  if defined?(JSON::JWT)
282
- # :nocov:
283
321
 
284
322
  def jwk_import(data)
285
323
  JSON::JWK.new(data)
@@ -305,23 +343,27 @@ module Rodauth
305
343
  def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, **)
306
344
  token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
307
345
 
308
- @jwt_token = if jws_key
309
- JSON::JWT.decode(token, jws_key)
310
- elsif !is_authorization_server? && auth_server_jwks_set
311
- JSON::JWT.decode(token, JSON::JWK::Set.new(auth_server_jwks_set))
312
- end
346
+ if is_authorization_server?
347
+ if oauth_jwt_legacy_public_key
348
+ JSON::JWT.decode(token, JSON::JWK::Set.new({ keys: jwks_set }))
349
+ elsif jws_key
350
+ JSON::JWT.decode(token, jws_key)
351
+ end
352
+ elsif (jwks = auth_server_jwks_set)
353
+ JSON::JWT.decode(token, JSON::JWK::Set.new(jwks))
354
+ end
313
355
  rescue JSON::JWT::Exception
314
356
  nil
315
357
  end
316
358
 
317
359
  def jwks_set
318
- [
360
+ @jwks_set ||= [
319
361
  (JSON::JWK.new(oauth_jwt_public_key).merge(use: "sig", alg: oauth_jwt_algorithm) if oauth_jwt_public_key),
362
+ (JSON::JWK.new(oauth_jwt_legacy_public_key).merge(use: "sig", alg: oauth_jwt_legacy_algorithm) if oauth_jwt_legacy_public_key),
320
363
  (JSON::JWK.new(oauth_jwt_jwe_public_key).merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
321
364
  ].compact
322
365
  end
323
366
 
324
- # :nocov:
325
367
  elsif defined?(JWT)
326
368
 
327
369
  # ruby-jwt
@@ -366,21 +408,30 @@ module Rodauth
366
408
  def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, jws_algorithm: oauth_jwt_algorithm)
367
409
  # decrypt jwe
368
410
  token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
369
-
370
411
  # decode jwt
371
- @jwt_token = if jws_key
372
- JWT.decode(token, jws_key, true, algorithms: [jws_algorithm]).first
373
- elsif !is_authorization_server? && auth_server_jwks_set
374
- algorithms = auth_server_jwks_set[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
375
- JWT.decode(token, nil, true, jwks: auth_server_jwks_set, algorithms: algorithms).first
376
- end
412
+ if is_authorization_server?
413
+ if oauth_jwt_legacy_public_key
414
+ algorithms = jwks_set.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
415
+ JWT.decode(token, nil, true, jwks: { keys: jwks_set }, algorithms: algorithms).first
416
+ elsif jws_key
417
+ JWT.decode(token, jws_key, true, algorithms: [jws_algorithm]).first
418
+ end
419
+ elsif (jwks = auth_server_jwks_set)
420
+ algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
421
+ JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms).first
422
+ end
377
423
  rescue JWT::DecodeError, JWT::JWKError
378
424
  nil
379
425
  end
380
426
 
381
427
  def jwks_set
382
- [
428
+ @jwks_set ||= [
383
429
  (JWT::JWK.new(oauth_jwt_public_key).export.merge(use: "sig", alg: oauth_jwt_algorithm) if oauth_jwt_public_key),
430
+ (
431
+ if oauth_jwt_legacy_public_key
432
+ JWT::JWK.new(oauth_jwt_legacy_public_key).export.merge(use: "sig", alg: oauth_jwt_legacy_algorithm)
433
+ end
434
+ ),
384
435
  (JWT::JWK.new(oauth_jwt_jwe_public_key).export.merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
385
436
  ].compact
386
437
  end
@@ -411,11 +462,5 @@ module Rodauth
411
462
 
412
463
  super
413
464
  end
414
-
415
- route(:oauth_jwks) do |r|
416
- r.get do
417
- json_response_success({ keys: jwks_set })
418
- end
419
- end
420
465
  end
421
466
  end
@@ -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