rodauth-oauth 0.0.2 → 0.0.3
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 +34 -2
- data/LICENSE.txt +191 -0
- data/README.md +145 -13
- data/lib/generators/roda/oauth/install_generator.rb +1 -1
- data/lib/generators/roda/oauth/views_generator.rb +1 -6
- data/lib/rodauth/features/oauth.rb +148 -96
- data/lib/rodauth/features/oauth_http_mac.rb +111 -0
- data/lib/rodauth/features/oauth_jwt.rb +228 -0
- data/lib/rodauth/oauth/version.rb +1 -1
- metadata +6 -2
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
module Rodauth
|
4
|
+
Feature.define(:oauth_http_mac) do
|
5
|
+
unless String.method_defined?(:delete_prefix)
|
6
|
+
module PrefixExtensions
|
7
|
+
refine(String) do
|
8
|
+
def delete_suffix(suffix)
|
9
|
+
suffix = suffix.to_s
|
10
|
+
len = suffix.length
|
11
|
+
if len.positive? && index(suffix, -len)
|
12
|
+
self[0...-len]
|
13
|
+
else
|
14
|
+
dup
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def delete_prefix(prefix)
|
19
|
+
prefix = prefix.to_s
|
20
|
+
if rindex(prefix, 0)
|
21
|
+
self[prefix.length..-1]
|
22
|
+
else
|
23
|
+
dup
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
using(PrefixExtensions)
|
29
|
+
end
|
30
|
+
|
31
|
+
depends :oauth
|
32
|
+
|
33
|
+
auth_value_method :oauth_token_type, "mac"
|
34
|
+
auth_value_method :oauth_mac_algorithm, "hmac-sha-256" # hmac-sha-256, hmac-sha-1
|
35
|
+
auth_value_method :oauth_tokens_mac_key_column, :mac_key
|
36
|
+
|
37
|
+
def authorization_token
|
38
|
+
return @authorization_token if defined?(@authorization_token)
|
39
|
+
|
40
|
+
@authorization_token = begin
|
41
|
+
value = request.get_header("HTTP_AUTHORIZATION").to_s
|
42
|
+
|
43
|
+
scheme, token = value.split(/ +/, 2)
|
44
|
+
|
45
|
+
return unless scheme == "MAC"
|
46
|
+
|
47
|
+
mac_attributes = parse_mac_authorization_header_props(token)
|
48
|
+
|
49
|
+
oauth_token = oauth_token_by_token(mac_attributes["id"])
|
50
|
+
.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
|
51
|
+
.where(oauth_tokens_revoked_at_column => nil)
|
52
|
+
.first
|
53
|
+
|
54
|
+
return unless oauth_token && mac_signature_matches?(oauth_token, mac_attributes)
|
55
|
+
|
56
|
+
oauth_token
|
57
|
+
|
58
|
+
# TODO: set new MAC-KEY for the next request
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def generate_oauth_token(params = {}, *args)
|
65
|
+
super({ oauth_tokens_mac_key_column => oauth_unique_id_generator }.merge(params), *args)
|
66
|
+
end
|
67
|
+
|
68
|
+
def json_access_token_payload(oauth_token)
|
69
|
+
payload = super
|
70
|
+
|
71
|
+
payload["mac_key"] = oauth_token[oauth_tokens_mac_key_column]
|
72
|
+
payload["mac_algorithm"] = oauth_mac_algorithm
|
73
|
+
|
74
|
+
payload
|
75
|
+
end
|
76
|
+
|
77
|
+
def mac_signature_matches?(oauth_token, mac_attributes)
|
78
|
+
nonce = mac_attributes["nonce"]
|
79
|
+
uri = URI(request.url)
|
80
|
+
|
81
|
+
request_signature = [
|
82
|
+
nonce,
|
83
|
+
request.request_method,
|
84
|
+
uri.request_uri,
|
85
|
+
uri.host,
|
86
|
+
uri.port
|
87
|
+
].join("\n") + ("\n" * 3)
|
88
|
+
|
89
|
+
mac_algorithm = case oauth_mac_algorithm
|
90
|
+
when "hmac-sha-256"
|
91
|
+
OpenSSL::Digest::SHA256
|
92
|
+
when "hmac-sha-1"
|
93
|
+
OpenSSL::Digest::SHA1
|
94
|
+
else
|
95
|
+
raise ArgumentError, "Unsupported algorithm"
|
96
|
+
end
|
97
|
+
|
98
|
+
mac_signature = Base64.strict_encode64 \
|
99
|
+
OpenSSL::HMAC.digest(mac_algorithm.new, oauth_token[oauth_tokens_mac_key_column], request_signature)
|
100
|
+
|
101
|
+
mac_signature == mac_attributes["mac"]
|
102
|
+
end
|
103
|
+
|
104
|
+
def parse_mac_authorization_header_props(token)
|
105
|
+
@mac_authorization_header_props = token.split(/ *, */).each_with_object({}) do |prop, props|
|
106
|
+
field, value = prop.split(/ *= */, 2)
|
107
|
+
props[field] = value.delete_prefix("\"").delete_suffix("\"")
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,228 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
module Rodauth
|
4
|
+
Feature.define(:oauth_jwt) do
|
5
|
+
depends :oauth
|
6
|
+
|
7
|
+
auth_value_method :oauth_jwt_token_issuer, "Example"
|
8
|
+
|
9
|
+
auth_value_method :oauth_jwt_key, nil
|
10
|
+
auth_value_method :oauth_jwt_public_key, nil
|
11
|
+
auth_value_method :oauth_jwt_algorithm, "HS256"
|
12
|
+
|
13
|
+
auth_value_method :oauth_jwt_jwk_key, nil
|
14
|
+
auth_value_method :oauth_jwt_jwk_public_key, nil
|
15
|
+
auth_value_method :oauth_jwt_jwk_algorithm, "RS256"
|
16
|
+
|
17
|
+
auth_value_method :oauth_jwt_jwe_key, nil
|
18
|
+
auth_value_method :oauth_jwt_jwe_public_key, nil
|
19
|
+
auth_value_method :oauth_jwt_jwe_algorithm, nil
|
20
|
+
auth_value_method :oauth_jwt_jwe_encryption_method, nil
|
21
|
+
|
22
|
+
auth_value_method :oauth_jwt_jwe_copyright, nil
|
23
|
+
auth_value_method :oauth_jwt_audience, nil
|
24
|
+
|
25
|
+
auth_value_methods(
|
26
|
+
:generate_jti,
|
27
|
+
:jwt_encode,
|
28
|
+
:jwt_decode
|
29
|
+
)
|
30
|
+
|
31
|
+
def require_oauth_authorization(*scopes)
|
32
|
+
authorization_required unless authorization_token
|
33
|
+
|
34
|
+
scopes << oauth_application_default_scope if scopes.empty?
|
35
|
+
|
36
|
+
token_scopes = authorization_token["scopes"].split(",")
|
37
|
+
|
38
|
+
authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def authorization_token
|
44
|
+
return @authorization_token if defined?(@authorization_token)
|
45
|
+
|
46
|
+
@authorization_token = begin
|
47
|
+
value = request.get_header("HTTP_AUTHORIZATION").to_s
|
48
|
+
|
49
|
+
scheme, token = value.split(/ +/, 2)
|
50
|
+
|
51
|
+
return unless scheme == "Bearer"
|
52
|
+
|
53
|
+
jwt_decode(token)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def generate_oauth_token(params = {}, should_generate_refresh_token = true)
|
58
|
+
create_params = {
|
59
|
+
oauth_grants_expires_in_column => Time.now + oauth_token_expires_in
|
60
|
+
}.merge(params)
|
61
|
+
|
62
|
+
if should_generate_refresh_token
|
63
|
+
refresh_token = oauth_unique_id_generator
|
64
|
+
|
65
|
+
if oauth_tokens_refresh_token_hash_column
|
66
|
+
create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
|
67
|
+
else
|
68
|
+
create_params[oauth_tokens_refresh_token_column] = refresh_token
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
oauth_token = _generate_oauth_token(create_params)
|
73
|
+
|
74
|
+
issued_at = Time.current.utc.to_i
|
75
|
+
|
76
|
+
payload = {
|
77
|
+
sub: oauth_token[oauth_tokens_account_id_column],
|
78
|
+
iss: oauth_jwt_token_issuer, # issuer
|
79
|
+
iat: issued_at, # issued at
|
80
|
+
#
|
81
|
+
# sub REQUIRED - as defined in section 4.1.2 of [RFC7519]. In case of
|
82
|
+
# access tokens obtained through grants where a resource owner is
|
83
|
+
# involved, such as the authorization code grant, the value of "sub"
|
84
|
+
# SHOULD correspond to the subject identifier of the resource owner.
|
85
|
+
# In case of access tokens obtained through grants where no resource
|
86
|
+
# owner is involved, such as the client credentials grant, the value
|
87
|
+
# of "sub" SHOULD correspond to an identifier the authorization
|
88
|
+
# server uses to indicate the client application.
|
89
|
+
client_id: oauth_token[oauth_tokens_oauth_application_id_column],
|
90
|
+
|
91
|
+
exp: issued_at + oauth_token_expires_in,
|
92
|
+
aud: oauth_jwt_audience,
|
93
|
+
|
94
|
+
# one of the points of using jwt is avoiding database lookups, so we put here all relevant
|
95
|
+
# token data.
|
96
|
+
scopes: oauth_token[oauth_tokens_scopes_column]
|
97
|
+
}
|
98
|
+
|
99
|
+
token = jwt_encode(payload)
|
100
|
+
|
101
|
+
oauth_token[oauth_tokens_token_column] = token
|
102
|
+
oauth_token
|
103
|
+
end
|
104
|
+
|
105
|
+
def _jwt_key
|
106
|
+
@_jwt_key ||= oauth_jwt_key || oauth_application[oauth_applications_client_secret_column]
|
107
|
+
end
|
108
|
+
|
109
|
+
if defined?(JSON::JWT)
|
110
|
+
# :nocov:
|
111
|
+
|
112
|
+
# json-jwt
|
113
|
+
def jwt_encode(payload)
|
114
|
+
jwt = JSON::JWT.new(payload)
|
115
|
+
|
116
|
+
jwt = if oauth_jwt_jwk_key
|
117
|
+
jwk = JSON::JWK.new(oauth_jwt_jwk_key)
|
118
|
+
jwt.kid = jwk.thumbprint
|
119
|
+
jwt.sign(oauth_jwt_jwk_key, oauth_jwt_jwk_algorithm)
|
120
|
+
else
|
121
|
+
jwt.sign(_jwt_key, oauth_jwt_algorithm)
|
122
|
+
end
|
123
|
+
if oauth_jwt_jwe_key
|
124
|
+
algorithm = oauth_jwt_jwe_algorithm.to_sym if oauth_jwt_jwe_algorithm
|
125
|
+
jwt = jwt.encrypt(oauth_jwt_jwe_public_key || oauth_jwt_jwe_key,
|
126
|
+
algorithm,
|
127
|
+
oauth_jwt_jwe_encryption_method.to_sym)
|
128
|
+
end
|
129
|
+
jwt.to_s
|
130
|
+
end
|
131
|
+
|
132
|
+
def jwt_decode(token)
|
133
|
+
token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
|
134
|
+
if oauth_jwt_jwk_key
|
135
|
+
jwk = JSON::JWK.new(oauth_jwt_jwk_key)
|
136
|
+
JSON::JWT.decode(token, jwk)
|
137
|
+
else
|
138
|
+
JSON::JWT.decode(token, oauth_jwt_public_key || _jwt_key)
|
139
|
+
end
|
140
|
+
rescue JSON::JWT::Exception
|
141
|
+
nil
|
142
|
+
end
|
143
|
+
# :nocov:
|
144
|
+
elsif defined?(JWT)
|
145
|
+
|
146
|
+
# ruby-jwt
|
147
|
+
|
148
|
+
def jwt_encode(payload)
|
149
|
+
headers = {}
|
150
|
+
|
151
|
+
key, algorithm = if oauth_jwt_jwk_key
|
152
|
+
jwk_key = JWT::JWK.new(oauth_jwt_jwk_key)
|
153
|
+
# JWK
|
154
|
+
# Currently only supports RSA public keys.
|
155
|
+
headers[:kid] = jwk_key.kid
|
156
|
+
|
157
|
+
[jwk_key.keypair, oauth_jwt_jwk_algorithm]
|
158
|
+
else
|
159
|
+
# JWS
|
160
|
+
|
161
|
+
[_jwt_key, oauth_jwt_algorithm]
|
162
|
+
end
|
163
|
+
|
164
|
+
# Use the key and iat to create a unique key per request to prevent replay attacks
|
165
|
+
jti_raw = [key, payload[:iat]].join(":").to_s
|
166
|
+
jti = Digest::MD5.hexdigest(jti_raw)
|
167
|
+
|
168
|
+
# @see JWT reserved claims - https://tools.ietf.org/html/draft-jones-json-web-token-07#page-7
|
169
|
+
payload[:jti] = jti
|
170
|
+
token = JWT.encode(payload, key, algorithm, headers)
|
171
|
+
|
172
|
+
if oauth_jwt_jwe_key
|
173
|
+
params = {
|
174
|
+
zip: "DEF",
|
175
|
+
copyright: oauth_jwt_jwe_copyright
|
176
|
+
}
|
177
|
+
params[:enc] = oauth_jwt_jwe_encryption_method if oauth_jwt_jwe_encryption_method
|
178
|
+
params[:alg] = oauth_jwt_jwe_algorithm if oauth_jwt_jwe_algorithm
|
179
|
+
token = JWE.encrypt(token, oauth_jwt_jwe_public_key || oauth_jwt_jwe_key, **params)
|
180
|
+
end
|
181
|
+
|
182
|
+
token
|
183
|
+
end
|
184
|
+
|
185
|
+
def jwt_decode(token)
|
186
|
+
# decrypt jwe
|
187
|
+
token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
|
188
|
+
|
189
|
+
# decode jwt
|
190
|
+
headers = { algorithms: [oauth_jwt_algorithm] }
|
191
|
+
|
192
|
+
key = if oauth_jwt_jwk_key
|
193
|
+
jwk_key = JWT::JWK.new(oauth_jwt_jwk_key)
|
194
|
+
# JWK
|
195
|
+
# The jwk loader would fetch the set of JWKs from a trusted source
|
196
|
+
jwk_loader = lambda do |options|
|
197
|
+
@cached_keys = nil if options[:invalidate] # need to reload the keys
|
198
|
+
@cached_keys ||= { keys: [jwk_key.export] }
|
199
|
+
end
|
200
|
+
|
201
|
+
headers[:algorithms] = [oauth_jwt_jwk_algorithm]
|
202
|
+
headers[:jwks] = jwk_loader
|
203
|
+
|
204
|
+
nil
|
205
|
+
else
|
206
|
+
# JWS
|
207
|
+
# worst case scenario, the key is the application key
|
208
|
+
oauth_jwt_public_key || _jwt_key
|
209
|
+
end
|
210
|
+
token, = JWT.decode(token, key, true, headers)
|
211
|
+
token
|
212
|
+
rescue JWT::DecodeError
|
213
|
+
nil
|
214
|
+
end
|
215
|
+
|
216
|
+
else
|
217
|
+
# :nocov:
|
218
|
+
def jwt_encode(_token)
|
219
|
+
raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
|
220
|
+
end
|
221
|
+
|
222
|
+
def jwt_decode(_token)
|
223
|
+
raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
|
224
|
+
end
|
225
|
+
# :nocov:
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rodauth-oauth
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.3
|
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-05
|
11
|
+
date: 2020-06-05 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:
|
@@ -16,10 +16,12 @@ email:
|
|
16
16
|
executables: []
|
17
17
|
extensions: []
|
18
18
|
extra_rdoc_files:
|
19
|
+
- LICENSE.txt
|
19
20
|
- README.md
|
20
21
|
- CHANGELOG.md
|
21
22
|
files:
|
22
23
|
- CHANGELOG.md
|
24
|
+
- LICENSE.txt
|
23
25
|
- README.md
|
24
26
|
- lib/generators/roda/oauth/install_generator.rb
|
25
27
|
- lib/generators/roda/oauth/templates/app/models/oauth_application.rb
|
@@ -28,6 +30,8 @@ files:
|
|
28
30
|
- lib/generators/roda/oauth/templates/db/migrate/create_rodauth_oauth.rb
|
29
31
|
- lib/generators/roda/oauth/views_generator.rb
|
30
32
|
- lib/rodauth/features/oauth.rb
|
33
|
+
- lib/rodauth/features/oauth_http_mac.rb
|
34
|
+
- lib/rodauth/features/oauth_jwt.rb
|
31
35
|
- lib/rodauth/oauth.rb
|
32
36
|
- lib/rodauth/oauth/railtie.rb
|
33
37
|
- lib/rodauth/oauth/version.rb
|