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 +4 -4
- data/CHANGELOG.md +62 -25
- data/README.md +7 -2
- data/lib/rodauth/features/oauth.rb +82 -41
- data/lib/rodauth/features/oauth_http_mac.rb +6 -10
- data/lib/rodauth/features/oauth_jwt.rb +101 -31
- data/lib/rodauth/features/oidc.rb +134 -34
- data/lib/rodauth/oauth/ttl_store.rb +1 -1
- data/lib/rodauth/oauth/version.rb +1 -1
- data/templates/authorize.str +1 -0
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7324d08b229d4bfdea92df95c769539570565e161d758a38d95bea50f78fda96
|
4
|
+
data.tar.gz: c421e4886baf39eb9ebe04e6919c6ab292d3c85dbc32bc78e5d8992c17a40816
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
-
*
|
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
|
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, :
|
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
|
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
|
-
|
480
|
-
|
481
|
-
|
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
|
-
|
486
|
-
authorization_required unless authorization_token
|
526
|
+
authorization_required unless authorization_token
|
487
527
|
|
488
|
-
|
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
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
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"] =
|
627
|
+
request["content-type"] = "application/x-www-form-urlencoded"
|
588
628
|
request["accept"] = json_response_content_type
|
589
|
-
request.
|
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(
|
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
|
-
|
908
|
-
|
909
|
-
|
910
|
-
|
911
|
-
|
912
|
-
|
913
|
-
|
914
|
-
|
915
|
-
|
916
|
-
|
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
|
-
|
962
|
+
response_params["state"] = param("state") if param_or_nil("state")
|
921
963
|
|
922
|
-
|
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
|
-
|
12
|
-
|
13
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
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"] !=
|
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 !=
|
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:
|
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(
|
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
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
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] =
|
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(
|
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
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
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[
|
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
|
-
|
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
|
-
|
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(
|
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
|
-
|
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 =
|
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
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
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
|
-
|
267
|
-
|
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 "`
|
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(
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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(
|
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
|
-
|
357
|
-
|
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],
|
data/templates/authorize.str
CHANGED
@@ -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.
|
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:
|
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.
|
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.
|