rodauth-oauth 0.4.3 → 0.5.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.
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.