rodauth-oauth 0.0.4 → 0.3.0

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