rodauth-oauth 0.0.5 → 0.4.0

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,14 @@ 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
15
+
16
+ auth_value_method :oauth_application_jws_jwk_column, nil
10
17
 
11
18
  auth_value_method :oauth_jwt_key, nil
12
19
  auth_value_method :oauth_jwt_public_key, nil
@@ -17,16 +24,30 @@ module Rodauth
17
24
  auth_value_method :oauth_jwt_jwe_algorithm, nil
18
25
  auth_value_method :oauth_jwt_jwe_encryption_method, nil
19
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
+
20
31
  auth_value_method :oauth_jwt_jwe_copyright, nil
21
32
  auth_value_method :oauth_jwt_audience, nil
22
33
 
34
+ auth_value_method :request_uri_not_supported_message, "request uri is unsupported"
35
+ auth_value_method :invalid_request_object_message, "request object is invalid"
36
+
23
37
  auth_value_methods(
24
38
  :jwt_encode,
25
39
  :jwt_decode,
26
- :jwks_set
40
+ :jwks_set,
41
+ :last_account_login_at
27
42
  )
28
43
 
29
- 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
30
51
 
31
52
  def require_oauth_authorization(*scopes)
32
53
  authorization_required unless authorization_token
@@ -40,6 +61,12 @@ module Rodauth
40
61
 
41
62
  private
42
63
 
64
+ unless method_defined?(:last_account_login_at)
65
+ def last_account_login_at
66
+ nil
67
+ end
68
+ end
69
+
43
70
  def authorization_token
44
71
  return @authorization_token if defined?(@authorization_token)
45
72
 
@@ -52,21 +79,67 @@ module Rodauth
52
79
 
53
80
  return unless jwt_token
54
81
 
55
- return if jwt_token["iss"] != oauth_jwt_token_issuer ||
56
- 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) ||
57
84
  !jwt_token["sub"]
58
85
 
59
86
  jwt_token
60
87
  end
61
88
  end
62
89
 
90
+ # /authorize
91
+
92
+ def validate_oauth_grant_params
93
+ # TODO: add support for requst_uri
94
+ redirect_response_error("request_uri_not_supported") if param_or_nil("request_uri")
95
+
96
+ request_object = param_or_nil("request")
97
+
98
+ return super unless request_object && oauth_application
99
+
100
+ jws_jwk = if oauth_application[oauth_application_jws_jwk_column]
101
+ jwk = oauth_application[oauth_application_jws_jwk_column]
102
+
103
+ jwk = JSON.parse(jwk, symbolize_names: true) if jwk && jwk.is_a?(String)
104
+ else
105
+ redirect_response_error("invalid_request_object")
106
+ end
107
+
108
+ claims = jwt_decode(request_object, jws_key: jwk_import(jws_jwk), jws_algorithm: jwk[:alg])
109
+
110
+ redirect_response_error("invalid_request_object") unless claims
111
+
112
+ # If signed, the Authorization Request
113
+ # Object SHOULD contain the Claims "iss" (issuer) and "aud" (audience)
114
+ # as members, with their semantics being the same as defined in the JWT
115
+ # [RFC7519] specification. The value of "aud" should be the value of
116
+ # the Authorization Server (AS) "issuer" as defined in RFC8414
117
+ # [RFC8414].
118
+ claims.delete("iss")
119
+ audience = claims.delete("aud")
120
+
121
+ redirect_response_error("invalid_request_object") if audience && audience != authorization_server_url
122
+
123
+ claims.each do |k, v|
124
+ request.params[k.to_s] = v
125
+ end
126
+
127
+ super
128
+ end
129
+
63
130
  # /token
64
131
 
65
- def before_token
132
+ def require_oauth_application
66
133
  # requset authentication optional for assertions
67
- 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"
68
135
 
69
- 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
70
143
  end
71
144
 
72
145
  def validate_oauth_token_params
@@ -88,10 +161,6 @@ module Rodauth
88
161
  def create_oauth_token_from_assertion
89
162
  claims = jwt_decode(param("assertion"))
90
163
 
91
- redirect_response_error("invalid_grant") unless claims
92
-
93
- @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
94
-
95
164
  account = account_ds(claims["sub"]).first
96
165
 
97
166
  redirect_response_error("invalid_client") unless oauth_application && account
@@ -107,26 +176,40 @@ module Rodauth
107
176
 
108
177
  def generate_oauth_token(params = {}, should_generate_refresh_token = true)
109
178
  create_params = {
110
- 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)
111
180
  }.merge(params)
112
181
 
113
- if should_generate_refresh_token
114
- 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
115
185
 
116
- if oauth_tokens_refresh_token_hash_column
117
- create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
118
- else
119
- 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
120
191
  end
192
+
193
+ _generate_oauth_token(create_params)
121
194
  end
122
195
 
123
- 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]
201
+
202
+ token = jwt_encode(claims)
203
+
204
+ oauth_token[oauth_tokens_token_column] = token
205
+ oauth_token
206
+ end
124
207
 
125
- issued_at = Time.now.utc.to_i
208
+ def jwt_claims(oauth_token)
209
+ issued_at = Time.now.to_i
126
210
 
127
- payload = {
128
- sub: oauth_token[oauth_tokens_account_id_column],
129
- iss: oauth_jwt_token_issuer, # issuer
211
+ claims = {
212
+ iss: (oauth_jwt_token_issuer || authorization_server_url), # issuer
130
213
  iat: issued_at, # issued at
131
214
  #
132
215
  # sub REQUIRED - as defined in section 4.1.2 of [RFC7519]. In case of
@@ -137,23 +220,32 @@ module Rodauth
137
220
  # owner is involved, such as the client credentials grant, the value
138
221
  # of "sub" SHOULD correspond to an identifier the authorization
139
222
  # server uses to indicate the client application.
223
+ sub: jwt_subject(oauth_token),
140
224
  client_id: oauth_application[oauth_applications_client_id_column],
141
225
 
142
226
  exp: issued_at + oauth_token_expires_in,
143
- aud: oauth_jwt_audience,
144
-
145
- # one of the points of using jwt is avoiding database lookups, so we put here all relevant
146
- # token data.
147
- scope: oauth_token[oauth_tokens_scopes_column]
227
+ aud: (oauth_jwt_audience || oauth_application[oauth_applications_client_id_column])
148
228
  }
149
229
 
150
- token = jwt_encode(payload)
230
+ claims[:auth_time] = last_account_login_at.to_i if last_account_login_at
151
231
 
152
- oauth_token[oauth_tokens_token_column] = token
153
- 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
154
246
  end
155
247
 
156
- def oauth_token_by_token(token, *)
248
+ def oauth_token_by_token(token)
157
249
  jwt_decode(token)
158
250
  end
159
251
 
@@ -181,17 +273,11 @@ module Rodauth
181
273
  def oauth_server_metadata_body(path)
182
274
  metadata = super
183
275
  metadata.merge! \
184
- jwks_uri: oauth_jwks_url,
276
+ jwks_uri: jwks_url,
185
277
  token_endpoint_auth_signing_alg_values_supported: [oauth_jwt_algorithm]
186
278
  metadata
187
279
  end
188
280
 
189
- def token_from_application?(oauth_token, oauth_application)
190
- return super unless oauth_token["sub"] # naive check on whether it's a jwt token
191
-
192
- oauth_token["client_id"] == oauth_application[oauth_applications_client_id_column]
193
- end
194
-
195
281
  def _jwt_key
196
282
  @_jwt_key ||= oauth_jwt_key || (oauth_application[oauth_applications_client_secret_column] if oauth_application)
197
283
  end
@@ -221,10 +307,10 @@ module Rodauth
221
307
 
222
308
  # time-to-live
223
309
  ttl = if response.key?("cache-control")
224
- cache_control = response["cache_control"]
225
- cache_control[/max-age=(\d+)/, 1]
310
+ cache_control = response["cache-control"]
311
+ cache_control[/max-age=(\d+)/, 1].to_i
226
312
  elsif response.key?("expires")
227
- Time.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
313
+ Time.parse(response["expires"]).to_i - Time.now.to_i
228
314
  end
229
315
 
230
316
  [JSON.parse(response.body, symbolize_names: true), ttl]
@@ -232,7 +318,10 @@ module Rodauth
232
318
  end
233
319
 
234
320
  if defined?(JSON::JWT)
235
- # :nocov:
321
+
322
+ def jwk_import(data)
323
+ JSON::JWK.new(data)
324
+ end
236
325
 
237
326
  # json-jwt
238
327
  def jwt_encode(payload)
@@ -251,34 +340,38 @@ module Rodauth
251
340
  jwt.to_s
252
341
  end
253
342
 
254
- def jwt_decode(token)
255
- return @jwt_token if defined?(@jwt_token)
256
-
343
+ def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, **)
257
344
  token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
258
345
 
259
- jwk = oauth_jwt_public_key || _jwt_key
260
-
261
- @jwt_token = if jwk
262
- JSON::JWT.decode(token, jwk)
263
- elsif !is_authorization_server? && auth_server_jwks_set
264
- JSON::JWT.decode(token, JSON::JWK::Set.new(auth_server_jwks_set))
265
- 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
266
355
  rescue JSON::JWT::Exception
267
356
  nil
268
357
  end
269
358
 
270
359
  def jwks_set
271
- [
360
+ @jwks_set ||= [
272
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),
273
363
  (JSON::JWK.new(oauth_jwt_jwe_public_key).merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
274
364
  ].compact
275
365
  end
276
366
 
277
- # :nocov:
278
367
  elsif defined?(JWT)
279
368
 
280
369
  # ruby-jwt
281
370
 
371
+ def jwk_import(data)
372
+ JWT::JWK.import(data).keypair
373
+ end
374
+
282
375
  def jwt_encode(payload)
283
376
  headers = {}
284
377
 
@@ -312,38 +405,47 @@ module Rodauth
312
405
  token
313
406
  end
314
407
 
315
- def jwt_decode(token)
316
- return @jwt_token if defined?(@jwt_token)
317
-
408
+ def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, jws_algorithm: oauth_jwt_algorithm)
318
409
  # decrypt jwe
319
410
  token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
320
-
321
411
  # decode jwt
322
- key = oauth_jwt_public_key || _jwt_key
323
-
324
- @jwt_token = if key
325
- JWT.decode(token, key, true, algorithms: [oauth_jwt_algorithm]).first
326
- elsif !is_authorization_server? && auth_server_jwks_set
327
- algorithms = auth_server_jwks_set[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
328
- JWT.decode(token, nil, true, jwks: auth_server_jwks_set, algorithms: algorithms).first
329
- 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
330
423
  rescue JWT::DecodeError, JWT::JWKError
331
424
  nil
332
425
  end
333
426
 
334
427
  def jwks_set
335
- [
428
+ @jwks_set ||= [
336
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
+ ),
337
435
  (JWT::JWK.new(oauth_jwt_jwe_public_key).export.merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
338
436
  ].compact
339
437
  end
340
438
  else
341
439
  # :nocov:
440
+ def jwk_import(_data)
441
+ raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
442
+ end
443
+
342
444
  def jwt_encode(_token)
343
445
  raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
344
446
  end
345
447
 
346
- def jwt_decode(_token)
448
+ def jwt_decode(_token, **)
347
449
  raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
348
450
  end
349
451
 
@@ -353,10 +455,12 @@ module Rodauth
353
455
  # :nocov:
354
456
  end
355
457
 
356
- route(:oauth_jwks) do |r|
357
- r.get do
358
- json_response_success({ keys: jwks_set })
359
- end
458
+ def validate_oauth_revoke_params
459
+ token_hint = param_or_nil("token_type_hint")
460
+
461
+ throw(:rodauth_error) if !token_hint || token_hint == "access_token"
462
+
463
+ super
360
464
  end
361
465
  end
362
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