rodauth-oauth 0.0.2 → 0.1.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.
@@ -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