rodauth-oauth 0.0.1 → 0.0.6

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.
@@ -0,0 +1,108 @@
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
+
51
+ return unless oauth_token && mac_signature_matches?(oauth_token, mac_attributes)
52
+
53
+ oauth_token
54
+
55
+ # TODO: set new MAC-KEY for the next request
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def generate_oauth_token(params = {}, *args)
62
+ super({ oauth_tokens_mac_key_column => oauth_unique_id_generator }.merge(params), *args)
63
+ end
64
+
65
+ def json_access_token_payload(oauth_token)
66
+ payload = super
67
+
68
+ payload["mac_key"] = oauth_token[oauth_tokens_mac_key_column]
69
+ payload["mac_algorithm"] = oauth_mac_algorithm
70
+
71
+ payload
72
+ end
73
+
74
+ def mac_signature_matches?(oauth_token, mac_attributes)
75
+ nonce = mac_attributes["nonce"]
76
+ uri = URI(request.url)
77
+
78
+ request_signature = [
79
+ nonce,
80
+ request.request_method,
81
+ uri.request_uri,
82
+ uri.host,
83
+ uri.port
84
+ ].join("\n") + ("\n" * 3)
85
+
86
+ mac_algorithm = case oauth_mac_algorithm
87
+ when "hmac-sha-256"
88
+ OpenSSL::Digest::SHA256
89
+ when "hmac-sha-1"
90
+ OpenSSL::Digest::SHA1
91
+ else
92
+ raise ArgumentError, "Unsupported algorithm"
93
+ end
94
+
95
+ mac_signature = Base64.strict_encode64 \
96
+ OpenSSL::HMAC.digest(mac_algorithm.new, oauth_token[oauth_tokens_mac_key_column], request_signature)
97
+
98
+ mac_signature == mac_attributes["mac"]
99
+ end
100
+
101
+ def parse_mac_authorization_header_props(token)
102
+ @mac_authorization_header_props = token.split(/ *, */).each_with_object({}) do |prop, props|
103
+ field, value = prop.split(/ *= */, 2)
104
+ props[field] = value.delete_prefix("\"").delete_suffix("\"")
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,421 @@
1
+ # frozen-string-literal: true
2
+
3
+ require "rodauth/oauth/ttl_store"
4
+
5
+ module Rodauth
6
+ Feature.define(:oauth_jwt) do
7
+ depends :oauth
8
+
9
+ auth_value_method :oauth_jwt_token_issuer, "Example"
10
+
11
+ auth_value_method :oauth_application_jws_jwk_column, nil
12
+
13
+ auth_value_method :oauth_jwt_key, nil
14
+ auth_value_method :oauth_jwt_public_key, nil
15
+ auth_value_method :oauth_jwt_algorithm, "HS256"
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_method :request_uri_not_supported_message, "request uri is unsupported"
26
+ auth_value_method :invalid_request_object_message, "request object is invalid"
27
+
28
+ auth_value_methods(
29
+ :jwt_encode,
30
+ :jwt_decode,
31
+ :jwks_set
32
+ )
33
+
34
+ JWKS = OAuth::TtlStore.new
35
+
36
+ def require_oauth_authorization(*scopes)
37
+ authorization_required unless authorization_token
38
+
39
+ scopes << oauth_application_default_scope if scopes.empty?
40
+
41
+ token_scopes = authorization_token["scope"].split(" ")
42
+
43
+ authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
44
+ end
45
+
46
+ private
47
+
48
+ def authorization_token
49
+ return @authorization_token if defined?(@authorization_token)
50
+
51
+ @authorization_token = begin
52
+ bearer_token = fetch_access_token
53
+
54
+ return unless bearer_token
55
+
56
+ jwt_token = jwt_decode(bearer_token)
57
+
58
+ return unless jwt_token
59
+
60
+ return if jwt_token["iss"] != oauth_jwt_token_issuer ||
61
+ jwt_token["aud"] != oauth_jwt_audience ||
62
+ !jwt_token["sub"]
63
+
64
+ jwt_token
65
+ end
66
+ end
67
+
68
+ # /authorize
69
+
70
+ def validate_oauth_grant_params
71
+ # TODO: add support for requst_uri
72
+ redirect_response_error("request_uri_not_supported") if param_or_nil("request_uri")
73
+
74
+ request_object = param_or_nil("request")
75
+
76
+ return super unless request_object && oauth_application
77
+
78
+ jws_jwk = if oauth_application[oauth_application_jws_jwk_column]
79
+ jwk = oauth_application[oauth_application_jws_jwk_column]
80
+
81
+ if jwk
82
+ jwk = JSON.parse(jwk, symbolize_names: true) if jwk.is_a?(String)
83
+ end
84
+ else
85
+ redirect_response_error("invalid_request_object")
86
+ end
87
+
88
+ claims = jwt_decode(request_object, jws_key: jwk_import(jws_jwk), jws_algorithm: jwk[:alg])
89
+
90
+ redirect_response_error("invalid_request_object") unless claims
91
+
92
+ # If signed, the Authorization Request
93
+ # Object SHOULD contain the Claims "iss" (issuer) and "aud" (audience)
94
+ # as members, with their semantics being the same as defined in the JWT
95
+ # [RFC7519] specification. The value of "aud" should be the value of
96
+ # the Authorization Server (AS) "issuer" as defined in RFC8414
97
+ # [RFC8414].
98
+ claims.delete(:iss)
99
+ audience = claims.delete(:aud)
100
+
101
+ redirect_response_error("invalid_request_object") if audience && audience != authorization_server_url
102
+
103
+ claims.each do |k, v|
104
+ request.params[k.to_s] = v
105
+ end
106
+
107
+ super
108
+ end
109
+
110
+ # /token
111
+
112
+ def before_token
113
+ # requset authentication optional for assertions
114
+ return if param("grant_type") == "urn:ietf:params:oauth:grant-type:jwt-bearer"
115
+
116
+ super
117
+ end
118
+
119
+ def validate_oauth_token_params
120
+ if param("grant_type") == "urn:ietf:params:oauth:grant-type:jwt-bearer"
121
+ redirect_response_error("invalid_client") unless param_or_nil("assertion")
122
+ else
123
+ super
124
+ end
125
+ end
126
+
127
+ def create_oauth_token
128
+ if param("grant_type") == "urn:ietf:params:oauth:grant-type:jwt-bearer"
129
+ create_oauth_token_from_assertion
130
+ else
131
+ super
132
+ end
133
+ end
134
+
135
+ def create_oauth_token_from_assertion
136
+ claims = jwt_decode(param("assertion"))
137
+
138
+ redirect_response_error("invalid_grant") unless claims
139
+
140
+ @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
141
+
142
+ account = account_ds(claims["sub"]).first
143
+
144
+ redirect_response_error("invalid_client") unless oauth_application && account
145
+
146
+ create_params = {
147
+ oauth_tokens_account_id_column => claims["sub"],
148
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
149
+ oauth_tokens_scopes_column => claims["scope"]
150
+ }
151
+
152
+ generate_oauth_token(create_params, false)
153
+ end
154
+
155
+ def generate_oauth_token(params = {}, should_generate_refresh_token = true)
156
+ create_params = {
157
+ oauth_grants_expires_in_column => Time.now + oauth_token_expires_in
158
+ }.merge(params)
159
+
160
+ if should_generate_refresh_token
161
+ refresh_token = oauth_unique_id_generator
162
+
163
+ if oauth_tokens_refresh_token_hash_column
164
+ create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
165
+ else
166
+ create_params[oauth_tokens_refresh_token_column] = refresh_token
167
+ end
168
+ end
169
+
170
+ oauth_token = _generate_oauth_token(create_params)
171
+
172
+ issued_at = Time.now.utc.to_i
173
+
174
+ payload = {
175
+ sub: oauth_token[oauth_tokens_account_id_column],
176
+ iss: oauth_jwt_token_issuer, # issuer
177
+ iat: issued_at, # issued at
178
+ #
179
+ # sub REQUIRED - as defined in section 4.1.2 of [RFC7519]. In case of
180
+ # access tokens obtained through grants where a resource owner is
181
+ # involved, such as the authorization code grant, the value of "sub"
182
+ # SHOULD correspond to the subject identifier of the resource owner.
183
+ # In case of access tokens obtained through grants where no resource
184
+ # owner is involved, such as the client credentials grant, the value
185
+ # of "sub" SHOULD correspond to an identifier the authorization
186
+ # server uses to indicate the client application.
187
+ client_id: oauth_application[oauth_applications_client_id_column],
188
+
189
+ exp: issued_at + oauth_token_expires_in,
190
+ aud: oauth_jwt_audience,
191
+
192
+ # one of the points of using jwt is avoiding database lookups, so we put here all relevant
193
+ # token data.
194
+ scope: oauth_token[oauth_tokens_scopes_column]
195
+ }
196
+
197
+ token = jwt_encode(payload)
198
+
199
+ oauth_token[oauth_tokens_token_column] = token
200
+ oauth_token
201
+ end
202
+
203
+ def oauth_token_by_token(token, *)
204
+ jwt_decode(token)
205
+ end
206
+
207
+ def json_token_introspect_payload(oauth_token)
208
+ return { active: false } unless oauth_token
209
+
210
+ return super unless oauth_token["sub"] # naive check on whether it's a jwt token
211
+
212
+ {
213
+ active: true,
214
+ scope: oauth_token["scope"],
215
+ client_id: oauth_token["client_id"],
216
+ # username
217
+ token_type: "access_token",
218
+ exp: oauth_token["exp"],
219
+ iat: oauth_token["iat"],
220
+ nbf: oauth_token["nbf"],
221
+ sub: oauth_token["sub"],
222
+ aud: oauth_token["aud"],
223
+ iss: oauth_token["iss"],
224
+ jti: oauth_token["jti"]
225
+ }
226
+ end
227
+
228
+ def oauth_server_metadata_body(path)
229
+ metadata = super
230
+ metadata.merge! \
231
+ jwks_uri: oauth_jwks_url,
232
+ token_endpoint_auth_signing_alg_values_supported: [oauth_jwt_algorithm]
233
+ metadata
234
+ end
235
+
236
+ def token_from_application?(oauth_token, oauth_application)
237
+ return super unless oauth_token["sub"] # naive check on whether it's a jwt token
238
+
239
+ oauth_token["client_id"] == oauth_application[oauth_applications_client_id_column]
240
+ end
241
+
242
+ def _jwt_key
243
+ @_jwt_key ||= oauth_jwt_key || (oauth_application[oauth_applications_client_secret_column] if oauth_application)
244
+ end
245
+
246
+ # Resource Server only!
247
+ #
248
+ # returns the jwks set from the authorization server.
249
+ def auth_server_jwks_set
250
+ metadata = authorization_server_metadata
251
+
252
+ return unless metadata && (jwks_uri = metadata[:jwks_uri])
253
+
254
+ jwks_uri = URI(jwks_uri)
255
+
256
+ jwks = JWKS[jwks_uri]
257
+
258
+ return jwks if jwks
259
+
260
+ JWKS.set(jwks_uri) do
261
+ http = Net::HTTP.new(jwks_uri.host, jwks_uri.port)
262
+ http.use_ssl = jwks_uri.scheme == "https"
263
+
264
+ request = Net::HTTP::Get.new(jwks_uri.request_uri)
265
+ request["accept"] = json_response_content_type
266
+ response = http.request(request)
267
+ authorization_required unless response.code.to_i == 200
268
+
269
+ # time-to-live
270
+ ttl = if response.key?("cache-control")
271
+ cache_control = response["cache_control"]
272
+ cache_control[/max-age=(\d+)/, 1]
273
+ elsif response.key?("expires")
274
+ Time.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
275
+ end
276
+
277
+ [JSON.parse(response.body, symbolize_names: true), ttl]
278
+ end
279
+ end
280
+
281
+ if defined?(JSON::JWT)
282
+ # :nocov:
283
+
284
+ def jwk_import(data)
285
+ JSON::JWK.new(data)
286
+ end
287
+
288
+ # json-jwt
289
+ def jwt_encode(payload)
290
+ jwt = JSON::JWT.new(payload)
291
+ jwk = JSON::JWK.new(_jwt_key)
292
+
293
+ jwt = jwt.sign(jwk, oauth_jwt_algorithm)
294
+ jwt.kid = jwk.thumbprint
295
+
296
+ if oauth_jwt_jwe_key
297
+ algorithm = oauth_jwt_jwe_algorithm.to_sym if oauth_jwt_jwe_algorithm
298
+ jwt = jwt.encrypt(oauth_jwt_jwe_public_key || oauth_jwt_jwe_key,
299
+ algorithm,
300
+ oauth_jwt_jwe_encryption_method.to_sym)
301
+ end
302
+ jwt.to_s
303
+ end
304
+
305
+ def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, **)
306
+ token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
307
+
308
+ @jwt_token = if jws_key
309
+ JSON::JWT.decode(token, jws_key)
310
+ elsif !is_authorization_server? && auth_server_jwks_set
311
+ JSON::JWT.decode(token, JSON::JWK::Set.new(auth_server_jwks_set))
312
+ end
313
+ rescue JSON::JWT::Exception
314
+ nil
315
+ end
316
+
317
+ def jwks_set
318
+ [
319
+ (JSON::JWK.new(oauth_jwt_public_key).merge(use: "sig", alg: oauth_jwt_algorithm) if oauth_jwt_public_key),
320
+ (JSON::JWK.new(oauth_jwt_jwe_public_key).merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
321
+ ].compact
322
+ end
323
+
324
+ # :nocov:
325
+ elsif defined?(JWT)
326
+
327
+ # ruby-jwt
328
+
329
+ def jwk_import(data)
330
+ JWT::JWK.import(data).keypair
331
+ end
332
+
333
+ def jwt_encode(payload)
334
+ headers = {}
335
+
336
+ key = _jwt_key
337
+
338
+ if key.is_a?(OpenSSL::PKey::RSA)
339
+ jwk = JWT::JWK.new(_jwt_key)
340
+ headers[:kid] = jwk.kid
341
+
342
+ key = jwk.keypair
343
+ end
344
+
345
+ # Use the key and iat to create a unique key per request to prevent replay attacks
346
+ jti_raw = [key, payload[:iat]].join(":").to_s
347
+ jti = Digest::SHA256.hexdigest(jti_raw)
348
+
349
+ # @see JWT reserved claims - https://tools.ietf.org/html/draft-jones-json-web-token-07#page-7
350
+ payload[:jti] = jti
351
+ token = JWT.encode(payload, key, oauth_jwt_algorithm, headers)
352
+
353
+ if oauth_jwt_jwe_key
354
+ params = {
355
+ zip: "DEF",
356
+ copyright: oauth_jwt_jwe_copyright
357
+ }
358
+ params[:enc] = oauth_jwt_jwe_encryption_method if oauth_jwt_jwe_encryption_method
359
+ params[:alg] = oauth_jwt_jwe_algorithm if oauth_jwt_jwe_algorithm
360
+ token = JWE.encrypt(token, oauth_jwt_jwe_public_key || oauth_jwt_jwe_key, **params)
361
+ end
362
+
363
+ token
364
+ end
365
+
366
+ def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, jws_algorithm: oauth_jwt_algorithm)
367
+ # decrypt jwe
368
+ token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
369
+
370
+ # decode jwt
371
+ @jwt_token = if jws_key
372
+ JWT.decode(token, jws_key, true, algorithms: [jws_algorithm]).first
373
+ elsif !is_authorization_server? && auth_server_jwks_set
374
+ algorithms = auth_server_jwks_set[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
375
+ JWT.decode(token, nil, true, jwks: auth_server_jwks_set, algorithms: algorithms).first
376
+ end
377
+ rescue JWT::DecodeError, JWT::JWKError
378
+ nil
379
+ end
380
+
381
+ def jwks_set
382
+ [
383
+ (JWT::JWK.new(oauth_jwt_public_key).export.merge(use: "sig", alg: oauth_jwt_algorithm) if oauth_jwt_public_key),
384
+ (JWT::JWK.new(oauth_jwt_jwe_public_key).export.merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
385
+ ].compact
386
+ end
387
+ else
388
+ # :nocov:
389
+ def jwk_import(_data)
390
+ raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
391
+ end
392
+
393
+ def jwt_encode(_token)
394
+ raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
395
+ end
396
+
397
+ def jwt_decode(_token, **)
398
+ raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
399
+ end
400
+
401
+ def jwks_set
402
+ raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
403
+ end
404
+ # :nocov:
405
+ end
406
+
407
+ def validate_oauth_revoke_params
408
+ token_hint = param_or_nil("token_type_hint")
409
+
410
+ throw(:rodauth_error) if !token_hint || token_hint == "access_token"
411
+
412
+ super
413
+ end
414
+
415
+ route(:oauth_jwks) do |r|
416
+ r.get do
417
+ json_response_success({ keys: jwks_set })
418
+ end
419
+ end
420
+ end
421
+ end