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 +4 -4
- data/CHANGELOG.md +18 -6
- data/README.md +7 -2
- data/lib/rodauth/features/oauth_jwt.rb +101 -31
- data/lib/rodauth/features/oidc.rb +91 -2
- data/lib/rodauth/oauth/version.rb +1 -1
- 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,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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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, :
|
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"] !=
|
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
|
@@ -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
|
-
|
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(
|
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
|
|
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.
|