rodauth-oauth 0.0.5 → 0.4.0

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