rodauth-oauth 0.0.3 → 0.2.0

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