rodauth-oauth 0.3.0 → 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: 24f3563d467a065dc119cbe53c0f14c8f28654d6512de0b7fd09a99e3f4aabdb
4
- data.tar.gz: 8abc6dc62b463885f7198cafec57fe707f99885e67754be1d7cefbbefa31a834
3
+ metadata.gz: 7324d08b229d4bfdea92df95c769539570565e161d758a38d95bea50f78fda96
4
+ data.tar.gz: c421e4886baf39eb9ebe04e6919c6ab292d3c85dbc32bc78e5d8992c17a40816
5
5
  SHA512:
6
- metadata.gz: 9fd172c9930f1cf88239a8f5ba7d5c93dc9c92b05a601c6f909b5ad12e4319ce0aa093831b93dad93542fdda0cdc1694b001ca78922239e80a2370472373b9a5
7
- data.tar.gz: 26e8e9c619425213f2cd5f21e7710f9561c0b0395bd8928656584c88586f4197bf80a765d9a90baea604ef9fdaff5c27f4b8447adf3fa617463e26b7b0a08470
6
+ metadata.gz: 4ba7e8a7975260618034285b77d4489416fe368c500b31516e7dd69d89e75863e18bebdb4868a72caa61fa2ed93541f1da6ea1c9c9d4cf15072bf4008209a1a9
7
+ data.tar.gz: 87e9f031183ede6af4f338f60ff5ae5f7a8581412068abb484b5312de4a6962fcaa68cb95eae8aab7c755adc4e1c0ada6e3e19b009ece35d4902cc84d1ab1e01
data/CHANGELOG.md CHANGED
@@ -2,18 +2,69 @@
2
2
 
3
3
  ## master
4
4
 
5
- ### 0.3.0
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
+
17
+ ### 0.4.3 (09/12/2020)
18
+
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.
20
+
21
+ ### 0.4.2 (24/11/2020)
22
+
23
+ #### Bugfixes
24
+
25
+ * database extensions were being run in resource server mode, when it's not expected that the oauth db tables are around.
26
+
27
+ ### 0.4.1 (24/11/2020)
28
+
29
+ #### Improvements
30
+
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.
32
+
33
+ #### Bugfixes
34
+
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.
36
+
37
+ ### 0.4.0 (13/11/2020)
6
38
 
7
39
  #### Features
8
40
 
9
- * `oauth_refresh_token_protection_policy` is a new option, which can be used to set a protection policy around usage of refresh tokens. By default it's `none`, for backwards-compatibility. However, when set to `rotation`, refresh tokens will be "use-once", i.e. a token refresh request will generate a new refresh token. Also, refresh token requests doen with already-used refresh tokens will be interpreted as a security breach, i.e. all tokens linked to the compromised refresh token will be revoked.
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.
42
+
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.
44
+
45
+
46
+ #### Improvements
47
+
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.
49
+
50
+ #### Bugfixes
51
+
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;
53
+ * rails tests were silently not running in CI;
54
+ * The CI suite was revamped, so that all Oauth tests would be run under rails as well. All versions from rails equal or above 5.0 are now targeted;
55
+
56
+ ### 0.3.0 (8/10/2020)
57
+
58
+ #### Features
59
+
60
+ * `oauth_refresh_token_protection_policy` is a new option, which can be used to set a protection policy around usage of refresh tokens. By default it's `none`, for backwards-compatibility. However, when set to `rotation`, refresh tokens will be "use-once", i.e. a token refresh request will generate a new refresh token. Also, refresh token requests performed with already-used refresh tokens will be interpreted as a security breach, i.e. all tokens linked to the compromised refresh token will be revoked.
10
61
 
11
62
  #### Improvements
12
63
 
13
64
 
14
65
  * Support for the OIDC authorize [`prompt` parameter](https://openid.net/specs/openid-connect-core-1_0.html) (sectionn 3.1.2.1). It supports the `none`, `login` and `consent` out-of-the-box, while providing support for `select-account` when paired with [rodauth-select-account, a rodauth feature to handle multiple accounts in the same session](https://gitlab.com/honeyryderchuck/rodauth-select-account).
15
66
 
16
- * Refresh Tokens are now expired. The refresh token expiration period is governed by the `oauth_refresh_token_expires_in` option (default: 1 year), and is the period for which a refresh token can be used after its respective token expired.
67
+ * Refresh Tokens are now expirable. The refresh token expiration period is governed by the `oauth_refresh_token_expires_in` option (default: 1 year), and is the period for which a refresh token can be used after its respective access token expired.
17
68
 
18
69
  #### Bugfixes
19
70
 
@@ -31,7 +82,7 @@ Use `rodauth.convert_timestamp` in the templates, whenever dates are displayed.
31
82
 
32
83
  Set HTTP Cache headers for metadata responses, such as `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration`, so they can be stored at the edge. The cache will be valid for 1 day (this value isn't set by an option yet).
33
84
 
34
- ### 0.2.0
85
+ ### 0.2.0 (9/9/2020)
35
86
 
36
87
  #### Features
37
88
 
@@ -75,9 +126,7 @@ Fixed some mishandling of HTTP headers when in in resource-server mode.
75
126
  * 97.7% test coverage;
76
127
  * `rodauth-oauth` CI tests run against sqlite, postgresql and mysql.
77
128
 
78
- ### 0.1.0
79
-
80
- (31/7/2020)
129
+ ### 0.1.0 (31/7/2020)
81
130
 
82
131
  #### Features
83
132
 
@@ -123,9 +172,7 @@ URI schemes for client applications redirect URIs have to be `https`. In order t
123
172
  * fixed trailing "/" in the "issuer" value in server metadata (`https://server.com/` -> `https://server.com`).
124
173
 
125
174
 
126
- ### 0.0.6
127
-
128
- (6/7/2020)
175
+ ### 0.0.6 (6/7/2020)
129
176
 
130
177
  #### Features
131
178
 
@@ -148,9 +195,7 @@ The `oauth_jwt` feature now supports JWT Secured Authorization Request (JAR) (se
148
195
  Removed React Javascript from example applications.
149
196
 
150
197
 
151
- ### 0.0.5
152
-
153
- (26/6/2020)
198
+ ### 0.0.5 (26/6/2020)
154
199
 
155
200
  #### Features
156
201
 
@@ -187,9 +232,7 @@ It **requires** the authorization to implement the server metadata endpoint (`/.
187
232
  * option `scopes_param` renamed to `scope_param`;
188
233
  *
189
234
 
190
- ## 0.0.4
191
-
192
- (13/6/2020)
235
+ ## 0.0.4 (13/6/2020)
193
236
 
194
237
  ### Features
195
238
 
@@ -226,9 +269,7 @@ The `oauth_jwt` feature now allows the usage of access tokens to authorize the g
226
269
 
227
270
  * Fixed scope claim of JWT ("scopes" -> "scope");
228
271
 
229
- ## 0.0.3
230
-
231
- (5/6/2020)
272
+ ## 0.0.3 (5/6/2020)
232
273
 
233
274
  ### Features
234
275
 
@@ -260,9 +301,7 @@ end
260
301
  * renamed the existing `use_oauth_implicit_grant_type` to `use_oauth_implicit_grant_type?`;
261
302
  * It's now usable as JSON API (small caveat: POST authorize will still redirect on success...);
262
303
 
263
- ## 0.0.2
264
-
265
- (29/5/2020)
304
+ ## 0.0.2 (29/5/2020)
266
305
 
267
306
  ### Features
268
307
 
@@ -278,8 +317,6 @@ end
278
317
 
279
318
  * usage of client secret for authorizing the generation of tokens, as the spec mandates (and refraining from them when doing PKCE).
280
319
 
281
- ## 0.0.1
282
-
283
- (14/5/2020)
320
+ ## 0.0.1 (14/5/2020)
284
321
 
285
322
  Initial implementation of the Oauth 2.0 framework, with an example app done using roda.
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
@@ -84,6 +84,7 @@ module Rodauth
84
84
 
85
85
  auth_value_method :oauth_require_pkce, false
86
86
  auth_value_method :oauth_pkce_challenge_method, "S256"
87
+ auth_value_method :oauth_response_mode, "query"
87
88
 
88
89
  auth_value_method :oauth_valid_uri_schemes, %w[https]
89
90
 
@@ -100,6 +101,7 @@ module Rodauth
100
101
  button "Register", "oauth_application"
101
102
  button "Authorize", "oauth_authorize"
102
103
  button "Revoke", "oauth_token_revoke"
104
+ button "Back to Client Application", "oauth_authorize_post"
103
105
 
104
106
  # OAuth Token
105
107
  auth_value_method :oauth_tokens_path, "oauth-tokens"
@@ -313,11 +315,41 @@ module Rodauth
313
315
  r.post do
314
316
  redirect_url = URI.parse(redirect_uri)
315
317
 
316
- transaction do
318
+ params, mode = transaction do
317
319
  before_authorize
318
- do_authorize(redirect_url)
320
+ do_authorize
321
+ end
322
+
323
+ case mode
324
+ when "query"
325
+ params = params.map { |k, v| "#{k}=#{v}" }
326
+ params << redirect_url.query if redirect_url.query
327
+ redirect_url.query = params.join("&")
328
+ redirect(redirect_url.to_s)
329
+ when "fragment"
330
+ params = params.map { |k, v| "#{k}=#{v}" }
331
+ params << redirect_url.query if redirect_url.query
332
+ redirect_url.fragment = params.join("&")
333
+ redirect(redirect_url.to_s)
334
+ when "form_post"
335
+ scope.view layout: false, inline: <<-FORM
336
+ <html>
337
+ <head><title>Authorized</title></head>
338
+ <body onload="javascript:document.forms[0].submit()">
339
+ <form method="post" action="#{redirect_uri}">
340
+ #{
341
+ params.map do |name, value|
342
+ "<input type=\"hidden\" name=\"#{name}\" value=\"#{scope.h(value)}\" />"
343
+ end.join
344
+ }
345
+ <input type="submit" class="btn btn-outline-primary" value="#{scope.h(oauth_authorize_post_button)}"/>
346
+ </form>
347
+ </body>
348
+ </html>
349
+ FORM
350
+ when "none"
351
+ redirect(redirect_url.to_s)
319
352
  end
320
- redirect(redirect_url.to_s)
321
353
  end
322
354
  end
323
355
 
@@ -457,13 +489,13 @@ module Rodauth
457
489
  def fetch_access_token
458
490
  value = request.env["HTTP_AUTHORIZATION"]
459
491
 
460
- return unless value
492
+ return unless value && !value.empty?
461
493
 
462
494
  scheme, token = value.split(" ", 2)
463
495
 
464
496
  return unless scheme.downcase == oauth_token_type
465
497
 
466
- return if token.empty?
498
+ return if token.nil? || token.empty?
467
499
 
468
500
  token
469
501
  end
@@ -476,31 +508,34 @@ module Rodauth
476
508
 
477
509
  return unless bearer_token
478
510
 
479
- # check if token has not expired
480
- # check if token has been revoked
481
- @authorization_token = oauth_token_by_token(bearer_token)
511
+ @authorization_token = if is_authorization_server?
512
+ # check if token has not expired
513
+ # check if token has been revoked
514
+ oauth_token_by_token(bearer_token)
515
+ else
516
+ # where in resource server, NOT the authorization server.
517
+ payload = introspection_request("access_token", bearer_token)
518
+
519
+ return unless payload["active"]
520
+
521
+ payload
522
+ end
482
523
  end
483
524
 
484
525
  def require_oauth_authorization(*scopes)
485
- token_scopes = if is_authorization_server?
486
- authorization_required unless authorization_token
526
+ authorization_required unless authorization_token
487
527
 
488
- scopes << oauth_application_default_scope if scopes.empty?
528
+ scopes << oauth_application_default_scope if scopes.empty?
489
529
 
530
+ token_scopes = if is_authorization_server?
490
531
  authorization_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
491
532
  else
492
- bearer_token = fetch_access_token
493
-
494
- authorization_required unless bearer_token
495
-
496
- scopes << oauth_application_default_scope if scopes.empty?
497
-
498
- # where in resource server, NOT the authorization server.
499
- payload = introspection_request("access_token", bearer_token)
500
-
501
- authorization_required unless payload["active"]
502
-
503
- payload["scope"].split(oauth_scope_separator)
533
+ aux_scopes = authorization_token["scope"]
534
+ if aux_scopes
535
+ aux_scopes.split(oauth_scope_separator)
536
+ else
537
+ []
538
+ end
504
539
  end
505
540
 
506
541
  authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
@@ -508,6 +543,11 @@ module Rodauth
508
543
 
509
544
  def post_configure
510
545
  super
546
+
547
+ # all of the extensions below involve DB changes. Resource server mode doesn't use
548
+ # database functions for OAuth though.
549
+ return unless is_authorization_server?
550
+
511
551
  self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db))
512
552
 
513
553
  # Check whether we can reutilize db entries for the same account / application pair
@@ -584,9 +624,9 @@ module Rodauth
584
624
  http.use_ssl = auth_url.scheme == "https"
585
625
 
586
626
  request = Net::HTTP::Post.new(introspect_path)
587
- request["content-type"] = json_response_content_type
627
+ request["content-type"] = "application/x-www-form-urlencoded"
588
628
  request["accept"] = json_response_content_type
589
- request.body = JSON.dump({ "token_type_hint" => token_type_hint, "token" => token })
629
+ request.set_form_data({ "token_type_hint" => token_type_hint, "token" => token })
590
630
 
591
631
  before_introspection_request(request)
592
632
  response = http.request(request)
@@ -848,6 +888,9 @@ module Rodauth
848
888
  end
849
889
  redirect_response_error("invalid_scope") unless check_valid_scopes?
850
890
 
891
+ if (response_mode = param_or_nil("response_mode")) && response_mode != "form_post"
892
+ redirect_response_error("invalid_request")
893
+ end
851
894
  validate_pkce_challenge_params if use_oauth_pkce?
852
895
  end
853
896
 
@@ -899,28 +942,26 @@ module Rodauth
899
942
  create_params[oauth_grants_code_column]
900
943
  end
901
944
 
902
- def do_authorize(redirect_url, query_params = [], fragment_params = [])
945
+ def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
903
946
  case param("response_type")
904
947
  when "token"
905
948
  redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
906
949
 
907
- fragment_params.replace(_do_authorize_token.map { |k, v| "#{k}=#{v}" })
908
- when "code", "", nil
909
- query_params.replace(_do_authorize_code.map { |k, v| "#{k}=#{v}" })
910
- end
911
-
912
- if param_or_nil("state")
913
- if !fragment_params.empty?
914
- fragment_params << "state=#{param('state')}"
915
- else
916
- query_params << "state=#{param('state')}"
917
- end
950
+ response_mode ||= "fragment"
951
+ response_params.replace(_do_authorize_token)
952
+ when "code"
953
+ response_mode ||= "query"
954
+ response_params.replace(_do_authorize_code)
955
+ when "none"
956
+ response_mode ||= "none"
957
+ when "", nil
958
+ response_mode ||= oauth_response_mode
959
+ response_params.replace(_do_authorize_code)
918
960
  end
919
961
 
920
- query_params << redirect_url.query if redirect_url.query
962
+ response_params["state"] = param("state") if param_or_nil("state")
921
963
 
922
- redirect_url.query = query_params.join("&") unless query_params.empty?
923
- redirect_url.fragment = fragment_params.join("&") unless fragment_params.empty?
964
+ [response_params, response_mode]
924
965
  end
925
966
 
926
967
  def _do_authorize_code
@@ -1296,7 +1337,7 @@ module Rodauth
1296
1337
  issuer += "/#{path}" if path
1297
1338
 
1298
1339
  responses_supported = %w[code]
1299
- response_modes_supported = %w[query]
1340
+ response_modes_supported = %w[query form_post]
1300
1341
  grant_types_supported = %w[authorization_code]
1301
1342
 
1302
1343
  if use_oauth_implicit_grant_type?
@@ -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
@@ -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
@@ -2,17 +2,19 @@
2
2
 
3
3
  module Rodauth
4
4
  Feature.define(:oidc) do
5
+ # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
5
6
  OIDC_SCOPES_MAP = {
6
7
  "profile" => %i[name family_name given_name middle_name nickname preferred_username
7
8
  profile picture website gender birthdate zoneinfo locale updated_at].freeze,
8
9
  "email" => %i[email email_verified].freeze,
9
- "address" => %i[address].freeze,
10
+ "address" => %i[formatted street_address locality region postal_code country].freeze,
10
11
  "phone" => %i[phone_number phone_number_verified].freeze
11
12
  }.freeze
12
13
 
13
14
  VALID_METADATA_KEYS = %i[
14
15
  issuer
15
16
  authorization_endpoint
17
+ end_session_endpoint
16
18
  token_endpoint
17
19
  userinfo_endpoint
18
20
  jwks_uri
@@ -74,7 +76,11 @@ module Rodauth
74
76
  auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
75
77
  auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
76
78
 
77
- auth_value_methods(:get_oidc_param)
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
+
83
+ auth_value_methods(:get_oidc_param, :get_additional_param)
78
84
 
79
85
  # /userinfo
80
86
  route(:userinfo) do |r|
@@ -107,10 +113,81 @@ module Rodauth
107
113
  end
108
114
  end
109
115
 
110
- 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)
111
188
  request.on(".well-known/openid-configuration") do
112
189
  request.get do
113
- json_response_success(openid_configuration_body(issuer), cache: true)
190
+ json_response_success(openid_configuration_body(alt_issuer), cache: true)
114
191
  end
115
192
  end
116
193
  end
@@ -245,8 +322,11 @@ module Rodauth
245
322
  oauth_token[:id_token] = jwt_encode(id_token_claims)
246
323
  end
247
324
 
325
+ # aka fill_with_standard_claims
248
326
  def fill_with_account_claims(claims, account, scopes)
249
- scopes_by_oidc = scopes.each_with_object({}) do |scope, by_oidc|
327
+ scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc|
328
+ next if scope == "openid"
329
+
250
330
  oidc, param = scope.split(".", 2)
251
331
 
252
332
  by_oidc[oidc] ||= []
@@ -254,21 +334,33 @@ module Rodauth
254
334
  by_oidc[oidc] << param.to_sym if param
255
335
  end
256
336
 
257
- oidc_scopes = (OIDC_SCOPES_MAP.keys & scopes_by_oidc.keys)
258
-
259
- return if oidc_scopes.empty?
337
+ oidc_scopes, additional_scopes = scopes_by_claim.keys.partition { |key| OIDC_SCOPES_MAP.key?(key) }
260
338
 
261
- if respond_to?(:get_oidc_param)
262
- oidc_scopes.each do |scope|
263
- params = scopes_by_oidc[scope]
264
- params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
339
+ unless oidc_scopes.empty?
340
+ if respond_to?(:get_oidc_param)
341
+ oidc_scopes.each do |scope|
342
+ scope_claims = claims
343
+ params = scopes_by_claim[scope]
344
+ params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
265
345
 
266
- params.each do |param|
267
- claims[param] = __send__(:get_oidc_param, account, param)
346
+ scope_claims = (claims["address"] = {}) if scope == "address"
347
+ params.each do |param|
348
+ scope_claims[param] = __send__(:get_oidc_param, account, param)
349
+ end
268
350
  end
351
+ else
352
+ warn "`get_oidc_param(account, claim)` must be implemented to use oidc scopes."
353
+ end
354
+ end
355
+
356
+ return if additional_scopes.empty?
357
+
358
+ if respond_to?(:get_additional_param)
359
+ additional_scopes.each do |scope|
360
+ claims[scope] = __send__(:get_additional_param, account, scope.to_sym)
269
361
  end
270
362
  else
271
- warn "`get_oidc_param(token, param)` must be implemented to use oidc scopes."
363
+ warn "`get_additional_param(account, claim)` must be implemented to use oidc scopes."
272
364
  end
273
365
  end
274
366
 
@@ -290,33 +382,27 @@ module Rodauth
290
382
  end
291
383
  end
292
384
 
293
- def do_authorize(redirect_url, query_params = [], fragment_params = [])
385
+ def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
294
386
  return super unless use_oauth_implicit_grant_type?
295
387
 
296
388
  case param("response_type")
297
389
  when "id_token"
298
- fragment_params.replace(_do_authorize_id_token.map { |k, v| "#{k}=#{v}" })
390
+ response_params.replace(_do_authorize_id_token)
299
391
  when "code token"
300
392
  redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
301
393
 
302
- params = _do_authorize_code.merge(_do_authorize_token)
303
-
304
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
394
+ response_params.replace(_do_authorize_code.merge(_do_authorize_token))
305
395
  when "code id_token"
306
- params = _do_authorize_code.merge(_do_authorize_id_token)
307
-
308
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
396
+ response_params.replace(_do_authorize_code.merge(_do_authorize_id_token))
309
397
  when "id_token token"
310
- params = _do_authorize_id_token.merge(_do_authorize_token)
311
-
312
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
398
+ response_params.replace(_do_authorize_id_token.merge(_do_authorize_token))
313
399
  when "code id_token token"
314
- params = _do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token)
315
400
 
316
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
401
+ response_params.replace(_do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token))
317
402
  end
403
+ response_mode ||= "fragment" unless response_params.empty?
318
404
 
319
- super(redirect_url, query_params, fragment_params)
405
+ super(response_params, response_mode)
320
406
  end
321
407
 
322
408
  def _do_authorize_id_token
@@ -332,6 +418,18 @@ module Rodauth
332
418
  params
333
419
  end
334
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
+
335
433
  # Metadata
336
434
 
337
435
  def openid_configuration_body(path)
@@ -351,13 +449,15 @@ module Rodauth
351
449
 
352
450
  scope_claims.unshift("auth_time") if last_account_login_at
353
451
 
452
+ response_types_supported = metadata[:response_types_supported]
453
+ if use_oauth_implicit_grant_type?
454
+ response_types_supported += ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"]
455
+ end
456
+
354
457
  metadata.merge(
355
458
  userinfo_endpoint: userinfo_url,
356
- response_types_supported: metadata[:response_types_supported] +
357
- ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"],
358
- response_modes_supported: %w[query fragment],
359
- grant_types_supported: %w[authorization_code implicit],
360
-
459
+ end_session_endpoint: (oidc_logout_url if use_rp_initiated_logout?),
460
+ response_types_supported: response_types_supported,
361
461
  subject_types_supported: [oauth_jwt_subject_type],
362
462
 
363
463
  id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
@@ -13,7 +13,7 @@ class Rodauth::OAuth::TtlStore
13
13
 
14
14
  def initialize
15
15
  @store_mutex = Mutex.new
16
- @store = Hash.new {}
16
+ @store = {}
17
17
  end
18
18
 
19
19
  def [](key)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -20,6 +20,7 @@
20
20
 
21
21
  #{"<input type=\"hidden\" name=\"access_type\" value=\"#{rodauth.param("access_type")}\"/>" if rodauth.param_or_nil("access_type")}
22
22
  #{"<input type=\"hidden\" name=\"response_type\" value=\"#{rodauth.param("response_type")}\"/>" if rodauth.param_or_nil("response_type")}
23
+ #{"<input type=\"hidden\" name=\"response_mode\" value=\"#{rodauth.param("response_mode")}\"/>" if rodauth.param_or_nil("response_mode")}
23
24
  #{"<input type=\"hidden\" name=\"state\" value=\"#{rodauth.param("state")}\"/>" if rodauth.param_or_nil("state")}
24
25
  #{"<input type=\"hidden\" name=\"nonce\" value=\"#{rodauth.param("nonce")}\"/>" if rodauth.param_or_nil("nonce")}
25
26
  #{"<input type=\"hidden\" name=\"redirect_uri\" value=\"#{rodauth.redirect_uri}\"/>" if rodauth.param_or_nil("redirect_uri")}
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.3.0
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-10-08 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.2
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.