rodauth-oauth 0.4.3 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96756ac8a30c904c5b832b64c47a00af9524810561d58c909b6f322da7348e8c
4
- data.tar.gz: 965f6ff260bd86c2fcb7bbd2ba2bd131b453f04a39b73f99ef0860d2bc95b0e0
3
+ metadata.gz: 7324d08b229d4bfdea92df95c769539570565e161d758a38d95bea50f78fda96
4
+ data.tar.gz: c421e4886baf39eb9ebe04e6919c6ab292d3c85dbc32bc78e5d8992c17a40816
5
5
  SHA512:
6
- metadata.gz: e7e257a12204599a27d0917f2b31c32906f0d4c566d51ee6d4fde146e2340e36afb9a932cff8bf37872d59259f4d43d423d1c1266f3066063c70aa334f83e119
7
- data.tar.gz: 07c0e564e7636893f736f6e05f634684cd7bc28e9d0acfb53ba518357fab198bc878792a68bde6b988b8c8ddf2d3e2bb4d4ecebcd9c4bf68d85f75178cdd0fdf
6
+ metadata.gz: 4ba7e8a7975260618034285b77d4489416fe368c500b31516e7dd69d89e75863e18bebdb4868a72caa61fa2ed93541f1da6ea1c9c9d4cf15072bf4008209a1a9
7
+ data.tar.gz: 87e9f031183ede6af4f338f60ff5ae5f7a8581412068abb484b5312de4a6962fcaa68cb95eae8aab7c755adc4e1c0ada6e3e19b009ece35d4902cc84d1ab1e01
data/CHANGELOG.md CHANGED
@@ -2,40 +2,52 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ### 0.5.0 (08/02/2021)
6
+
7
+ #### RP-Initiated Logout
8
+
9
+ The `:oidc` plugin can now do [RP-Initiated Logout](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/wikis/RP-Initiated-Logout). It's disabled by default, so read the docs to learn how to enable it.
10
+
11
+ #### Security
12
+
13
+ The `:oauth_jwt` (and by association, `:oidc`) plugin(s) verifies the claims of used JWT tokens. This is a **very important security fix**, as without it, there is no protection against replay attacks and other types of misuse of the JWT token.
14
+
15
+ A new auth method, `generate_jti(claims)`, was [added to the list of oauth_jwt plugin options](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/wikis/JWT-Access-Tokens#rodauth-options). By default, it'll hash the `aud` and `iat` claims together, but you can overwrite how this is done.
16
+
5
17
  ### 0.4.3 (09/12/2020)
6
18
 
7
19
  * Introspection requests made to an Authorization Server in "resource server" mode are not correctly encoding the body using the "application/x-www-form-urlencoded" format.
8
20
 
9
21
  ### 0.4.2 (24/11/2020)
10
22
 
11
- ### Bugfixes
23
+ #### Bugfixes
12
24
 
13
25
  * database extensions were being run in resource server mode, when it's not expected that the oauth db tables are around.
14
26
 
15
27
  ### 0.4.1 (24/11/2020)
16
28
 
17
- ### Improvements
29
+ #### Improvements
18
30
 
19
31
  When in "Resource Server" mode, calling `rodauth.authorization_token` will now return an hash of the JSON payload that the Authorization Server responds, and which was already previously used to authorize access to protected resources.
20
32
 
21
- ### Bugfixes
33
+ #### Bugfixes
22
34
 
23
35
  * An error occurred if the client passed an empty authorization header (`Authorization: ` or `Authorization: Bearer `), causing an unexpected error; It now responds with the proper `401 Unauthorized` status code.
24
36
 
25
37
  ### 0.4.0 (13/11/2020)
26
38
 
27
- ### Features
39
+ #### Features
28
40
 
29
41
  * A new method, `get_additional_param(account, claim)`, is now exposed; this method will be called whenever non-OIDC scopes are requested in the emission of the ID token.
30
42
 
31
43
  * The `form_post` response is now supported, either by passing the `response_mode=form_post` request param in the authorization URL, or by setting `oauth_response_mode "form_post"` option. This improves the overall security of an Authorization server even more, as authorization codes are sent to client applications via a POST request to the redirect URI.
32
44
 
33
45
 
34
- ### Improvements
46
+ #### Improvements
35
47
 
36
48
  * For the OIDC `address` scope, proper claims are now emitted as per the standard, i.e. the "formatted", "street_address", "locality", "region", "postal_code", "country". These will be the ones referenced in the `get_oidc_param` method.
37
49
 
38
- ### Bugfixes
50
+ #### Bugfixes
39
51
 
40
52
  * The rails templates were missing declarations from a few params, which made some of the flows (the PKCE for example) not work out-of-the box;
41
53
  * rails tests were silently not running in CI;
data/README.md CHANGED
@@ -25,7 +25,12 @@ This gem implements the following RFCs and features of OAuth:
25
25
  * [JWT Secured Authorization Requests](https://tools.ietf.org/html/draft-ietf-oauth-jwsreq-20);
26
26
  * OAuth application and token management dashboards;
27
27
 
28
- It also implements the [OpenID Connect layer](https://openid.net/connect/) on top of the OAuth features it provides.
28
+ It also implements the [OpenID Connect layer](https://openid.net/connect/) on top of the OAuth features it provides, including:
29
+
30
+ * [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html);
31
+ * [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0-29.html);
32
+ * [OpenID Multiple Response Types](https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html);
33
+ * [RP Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html);
29
34
 
30
35
  This gem supports also rails (through [rodauth-rails]((https://github.com/janko/rodauth-rails))).
31
36
 
@@ -104,7 +109,7 @@ For OpenID, it's very similar to the example above:
104
109
  ```ruby
105
110
  plugin :rodauth do
106
111
  # enable it in the plugin
107
- enable :login, :openid
112
+ enable :login, :oidc
108
113
  oauth_application_default_scope %w[openid]
109
114
  oauth_application_scopes %w[openid email profile]
110
115
  end
@@ -8,6 +8,8 @@ module Rodauth
8
8
 
9
9
  JWKS = OAuth::TtlStore.new
10
10
 
11
+ # Recommended to have hmac_secret as well
12
+
11
13
  auth_value_method :oauth_jwt_subject_type, "public" # public, pairwise
12
14
  auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation
13
15
 
@@ -38,7 +40,8 @@ module Rodauth
38
40
  :jwt_encode,
39
41
  :jwt_decode,
40
42
  :jwks_set,
41
- :last_account_login_at
43
+ :last_account_login_at,
44
+ :generate_jti
42
45
  )
43
46
 
44
47
  route(:jwks) do |r|
@@ -67,6 +70,10 @@ module Rodauth
67
70
  end
68
71
  end
69
72
 
73
+ def issuer
74
+ @issuer ||= oauth_jwt_token_issuer || authorization_server_url
75
+ end
76
+
70
77
  def authorization_token
71
78
  return @authorization_token if defined?(@authorization_token)
72
79
 
@@ -79,7 +86,7 @@ module Rodauth
79
86
 
80
87
  return unless jwt_token
81
88
 
82
- return if jwt_token["iss"] != (oauth_jwt_token_issuer || authorization_server_url) ||
89
+ return if jwt_token["iss"] != issuer ||
83
90
  (oauth_jwt_audience && jwt_token["aud"] != oauth_jwt_audience) ||
84
91
  !jwt_token["sub"]
85
92
 
@@ -105,7 +112,7 @@ module Rodauth
105
112
  redirect_response_error("invalid_request_object")
106
113
  end
107
114
 
108
- claims = jwt_decode(request_object, jws_key: jwk_import(jws_jwk), jws_algorithm: jwk[:alg])
115
+ claims = jwt_decode(request_object, jws_key: jwk_import(jws_jwk), jws_algorithm: jwk[:alg], verify_jti: false)
109
116
 
110
117
  redirect_response_error("invalid_request_object") unless claims
111
118
 
@@ -118,7 +125,7 @@ module Rodauth
118
125
  claims.delete("iss")
119
126
  audience = claims.delete("aud")
120
127
 
121
- redirect_response_error("invalid_request_object") if audience && audience != authorization_server_url
128
+ redirect_response_error("invalid_request_object") if audience && audience != issuer
122
129
 
123
130
  claims.each do |k, v|
124
131
  request.params[k.to_s] = v
@@ -209,7 +216,7 @@ module Rodauth
209
216
  issued_at = Time.now.to_i
210
217
 
211
218
  claims = {
212
- iss: (oauth_jwt_token_issuer || authorization_server_url), # issuer
219
+ iss: issuer, # issuer
213
220
  iat: issued_at, # issued at
214
221
  #
215
222
  # sub REQUIRED - as defined in section 4.1.2 of [RFC7519]. In case of
@@ -317,6 +324,23 @@ module Rodauth
317
324
  end
318
325
  end
319
326
 
327
+ def generate_jti(payload)
328
+ # Use the key and iat to create a unique key per request to prevent replay attacks
329
+ jti_raw = [
330
+ payload[:aud] || payload["aud"],
331
+ payload[:iat] || payload["iat"]
332
+ ].join(":").to_s
333
+ Digest::SHA256.hexdigest(jti_raw)
334
+ end
335
+
336
+ def verify_jti(jti, claims)
337
+ generate_jti(claims) == jti
338
+ end
339
+
340
+ def verify_aud(aud, claims)
341
+ aud == (oauth_jwt_audience || claims["client_id"])
342
+ end
343
+
320
344
  if defined?(JSON::JWT)
321
345
 
322
346
  def jwk_import(data)
@@ -325,6 +349,7 @@ module Rodauth
325
349
 
326
350
  # json-jwt
327
351
  def jwt_encode(payload)
352
+ payload[:jti] = generate_jti(payload)
328
353
  jwt = JSON::JWT.new(payload)
329
354
  jwk = JSON::JWK.new(_jwt_key)
330
355
 
@@ -340,18 +365,34 @@ module Rodauth
340
365
  jwt.to_s
341
366
  end
342
367
 
343
- def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, **)
368
+ def jwt_decode(
369
+ token,
370
+ jws_key: oauth_jwt_public_key || _jwt_key,
371
+ verify_claims: true,
372
+ verify_jti: true,
373
+ **
374
+ )
344
375
  token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
345
376
 
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))
377
+ claims = if is_authorization_server?
378
+ if oauth_jwt_legacy_public_key
379
+ JSON::JWT.decode(token, JSON::JWK::Set.new({ keys: jwks_set }))
380
+ elsif jws_key
381
+ JSON::JWT.decode(token, jws_key)
382
+ end
383
+ elsif (jwks = auth_server_jwks_set)
384
+ JSON::JWT.decode(token, JSON::JWK::Set.new(jwks))
385
+ end
386
+
387
+ if verify_claims && !(claims[:iss] == issuer &&
388
+ verify_aud(claims[:aud], claims) &&
389
+ (!claims[:iat] || Time.at(claims[:iat]) > (Time.now - oauth_token_expires_in)) &&
390
+ (!claims[:exp] || Time.at(claims[:exp]) > Time.now) &&
391
+ (!verify_jti || verify_jti(claims[:jti], claims)))
392
+ return
354
393
  end
394
+
395
+ claims
355
396
  rescue JSON::JWT::Exception
356
397
  nil
357
398
  end
@@ -384,12 +425,8 @@ module Rodauth
384
425
  key = jwk.keypair
385
426
  end
386
427
 
387
- # Use the key and iat to create a unique key per request to prevent replay attacks
388
- jti_raw = [key, payload[:iat]].join(":").to_s
389
- jti = Digest::SHA256.hexdigest(jti_raw)
390
-
391
428
  # @see JWT reserved claims - https://tools.ietf.org/html/draft-jones-json-web-token-07#page-7
392
- payload[:jti] = jti
429
+ payload[:jti] = generate_jti(payload)
393
430
  token = JWT.encode(payload, key, oauth_jwt_algorithm, headers)
394
431
 
395
432
  if oauth_jwt_jwe_key
@@ -405,21 +442,54 @@ module Rodauth
405
442
  token
406
443
  end
407
444
 
408
- def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, jws_algorithm: oauth_jwt_algorithm)
445
+ def jwt_decode(
446
+ token,
447
+ jws_key: oauth_jwt_public_key || _jwt_key,
448
+ jws_algorithm: oauth_jwt_algorithm,
449
+ verify_claims: true,
450
+ verify_jti: true
451
+ )
409
452
  # decrypt jwe
410
453
  token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
454
+
455
+ # verifying the JWT implies verifying:
456
+ #
457
+ # issuer: check that server generated the token
458
+ # aud: check the audience field (client is who he says he is)
459
+ # iat: check that the token didn't expire
460
+ #
461
+ # subject can't be verified automatically without having access to the account id,
462
+ # which we don't because that's the whole point.
463
+ #
464
+ verify_claims_params = if verify_claims
465
+ {
466
+ verify_iss: true,
467
+ iss: issuer,
468
+ # can't use stock aud verification, as it's dependent on the client application id
469
+ verify_aud: false,
470
+ verify_jti: (verify_jti ? method(:verify_jti) : false),
471
+ verify_iat: true
472
+ }
473
+ else
474
+ {}
475
+ end
476
+
411
477
  # decode jwt
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
478
+ claims = if is_authorization_server?
479
+ if oauth_jwt_legacy_public_key
480
+ algorithms = jwks_set.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
481
+ JWT.decode(token, nil, true, jwks: { keys: jwks_set }, algorithms: algorithms, **verify_claims_params).first
482
+ elsif jws_key
483
+ JWT.decode(token, jws_key, true, algorithms: [jws_algorithm], **verify_claims_params).first
484
+ end
485
+ elsif (jwks = auth_server_jwks_set)
486
+ algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
487
+ JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms, **verify_claims_params).first
488
+ end
489
+
490
+ return if verify_claims && !verify_aud(claims["aud"], claims)
491
+
492
+ claims
423
493
  rescue JWT::DecodeError, JWT::JWKError
424
494
  nil
425
495
  end
@@ -14,6 +14,7 @@ module Rodauth
14
14
  VALID_METADATA_KEYS = %i[
15
15
  issuer
16
16
  authorization_endpoint
17
+ end_session_endpoint
17
18
  token_endpoint
18
19
  userinfo_endpoint
19
20
  jwks_uri
@@ -75,6 +76,10 @@ module Rodauth
75
76
  auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
76
77
  auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
77
78
 
79
+ # logout
80
+ auth_value_method :oauth_applications_post_logout_redirect_uri_column, :post_logout_redirect_uri
81
+ auth_value_method :use_rp_initiated_logout?, false
82
+
78
83
  auth_value_methods(:get_oidc_param, :get_additional_param)
79
84
 
80
85
  # /userinfo
@@ -108,10 +113,81 @@ module Rodauth
108
113
  end
109
114
  end
110
115
 
111
- def openid_configuration(issuer = nil)
116
+ # /oidc-logout
117
+ route(:oidc_logout) do |r|
118
+ next unless use_rp_initiated_logout?
119
+
120
+ before_oidc_logout_route
121
+ require_authorizable_account
122
+
123
+ # OpenID Providers MUST support the use of the HTTP GET and POST methods
124
+ r.on method: %i[get post] do
125
+ catch_error do
126
+ validate_oidc_logout_params
127
+
128
+ #
129
+ # why this is done:
130
+ #
131
+ # we need to decode the id token in order to get the application, because, if the
132
+ # signing key is application-specific, we don't know how to verify the signature
133
+ # beforehand. Hence, we have to do it twice: decode-and-do-not-verify, initialize
134
+ # the @oauth_application, and then decode-and-verify.
135
+ #
136
+ oauth_token = jwt_decode(param("id_token_hint"), verify_claims: false)
137
+ oauth_application_id = oauth_token["client_id"]
138
+
139
+ # check whether ID token belongs to currently logged-in user
140
+ redirect_response_error("invalid_request") unless oauth_token["sub"] == jwt_subject(
141
+ oauth_tokens_account_id_column => account_id,
142
+ oauth_tokens_oauth_application_id_column => oauth_application_id
143
+ )
144
+
145
+ # When an id_token_hint parameter is present, the OP MUST validate that it was the issuer of the ID Token.
146
+ redirect_response_error("invalid_request") unless oauth_token && oauth_token["iss"] == issuer
147
+
148
+ # now let's logout from IdP
149
+ transaction do
150
+ before_logout
151
+ logout
152
+ after_logout
153
+ end
154
+
155
+ if (post_logout_redirect_uri = param_or_nil("post_logout_redirect_uri"))
156
+ catch(:default_logout_redirect) do
157
+ oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => oauth_token["client_id"]).first
158
+
159
+ throw(:default_logout_redirect) unless oauth_application
160
+
161
+ post_logout_redirect_uris = oauth_application[oauth_applications_post_logout_redirect_uri_column].split(" ")
162
+
163
+ throw(:default_logout_redirect) unless post_logout_redirect_uris.include?(post_logout_redirect_uri)
164
+
165
+ if (state = param_or_nil("state"))
166
+ post_logout_redirect_uri = URI(post_logout_redirect_uri)
167
+ params = ["state=#{state}"]
168
+ params << post_logout_redirect_uri.query if post_logout_redirect_uri.query
169
+ post_logout_redirect_uri.query = params.join("&")
170
+ post_logout_redirect_uri = post_logout_redirect_uri.to_s
171
+ end
172
+
173
+ redirect(post_logout_redirect_uri)
174
+ end
175
+
176
+ end
177
+
178
+ # regular logout procedure
179
+ set_notice_flash(logout_notice_flash)
180
+ redirect(logout_redirect)
181
+ end
182
+
183
+ redirect_response_error("invalid_request")
184
+ end
185
+ end
186
+
187
+ def openid_configuration(alt_issuer = nil)
112
188
  request.on(".well-known/openid-configuration") do
113
189
  request.get do
114
- json_response_success(openid_configuration_body(issuer), cache: true)
190
+ json_response_success(openid_configuration_body(alt_issuer), cache: true)
115
191
  end
116
192
  end
117
193
  end
@@ -342,6 +418,18 @@ module Rodauth
342
418
  params
343
419
  end
344
420
 
421
+ # Logout
422
+
423
+ def validate_oidc_logout_params
424
+ redirect_response_error("invalid_request") unless param_or_nil("id_token_hint")
425
+ # check if valid token hint type
426
+ return unless (redirect_uri = param_or_nil("post_logout_redirect_uri"))
427
+
428
+ return if check_valid_uri?(redirect_uri)
429
+
430
+ redirect_response_error("invalid_request")
431
+ end
432
+
345
433
  # Metadata
346
434
 
347
435
  def openid_configuration_body(path)
@@ -368,6 +456,7 @@ module Rodauth
368
456
 
369
457
  metadata.merge(
370
458
  userinfo_endpoint: userinfo_url,
459
+ end_session_endpoint: (oidc_logout_url if use_rp_initiated_logout?),
371
460
  response_types_supported: response_types_supported,
372
461
  subject_types_supported: [oauth_jwt_subject_type],
373
462
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "0.4.3"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rodauth-oauth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tiago Cardoso
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-10 00:00:00.000000000 Z
11
+ date: 2021-02-08 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Implementation of the OAuth 2.0 protocol on top of rodauth.
14
14
  email:
@@ -71,7 +71,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
71
71
  - !ruby/object:Gem::Version
72
72
  version: '0'
73
73
  requirements: []
74
- rubygems_version: 3.1.4
74
+ rubygems_version: 3.2.3
75
75
  signing_key:
76
76
  specification_version: 4
77
77
  summary: Implementation of the OAuth 2.0 protocol on top of rodauth.