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