rodauth-oauth 0.0.6 → 0.4.1

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,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