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