rodauth-oauth 0.0.2 → 0.1.0

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