rodauth-oauth 0.0.1 → 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -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