lib/rodauth/features/oauth_jwt.rb in rodauth-oauth-0.8.0 vs lib/rodauth/features/oauth_jwt.rb in rodauth-oauth-0.9.0
- old
+ new
@@ -8,26 +8,42 @@
JWKS = OAuth::TtlStore.new
# Recommended to have hmac_secret as well
- auth_value_method :oauth_jwt_subject_type, "public" # public, pairwise
+ auth_value_method :oauth_jwt_subject_type, "public" # fallback subject type: public, pairwise
auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation
auth_value_method :oauth_jwt_token_issuer, nil
- auth_value_method :oauth_applications_jws_jwk_column, :jws_jwk
+ configuration_module_eval do
+ define_method :oauth_applications_jws_jwk_column do
+ warn "#{__method__} is deprecated, switch to `oauth_applications_jwks_column`"
+ oauth_applications_jwks_column
+ end
+ define_method :oauth_applications_jws_jwk_label do
+ warn "#{__method__} is deprecated, switch to `oauth_applications_jwks_label`"
+ oauth_applications_jws_jwk_label
+ end
+ define_method :oauth_application_jws_jwk_param do
+ warn "#{__method__} is deprecated, switch to `oauth_applications_jwks_param`"
+ oauth_applications_jwks_param
+ end
+ end
+
+ auth_value_method :oauth_applications_subject_type_column, :subject_type
auth_value_method :oauth_applications_jwt_public_key_column, :jwt_public_key
+ auth_value_method :oauth_applications_request_object_signing_alg_column, :request_object_signing_alg
+ auth_value_method :oauth_applications_request_object_encryption_alg_column, :request_object_encryption_alg
+ auth_value_method :oauth_applications_request_object_encryption_enc_column, :request_object_encryption_enc
- translatable_method :oauth_applications_jws_jwk_label, "JSON Web Keys"
translatable_method :oauth_applications_jwt_public_key_label, "Public key"
- auth_value_method :oauth_application_jws_jwk_param, :jws_jwk
- auth_value_method :oauth_application_jwt_public_key_param, :jwt_public_key
+ auth_value_method :oauth_jwt_keys, {}
auth_value_method :oauth_jwt_key, nil
auth_value_method :oauth_jwt_public_key, nil
- auth_value_method :oauth_jwt_algorithm, "HS256"
+ auth_value_method :oauth_jwt_algorithm, "RS256"
auth_value_method :oauth_jwt_jwe_key, nil
auth_value_method :oauth_jwt_jwe_public_key, nil
auth_value_method :oauth_jwt_jwe_algorithm, nil
auth_value_method :oauth_jwt_jwe_encryption_method, nil
@@ -117,18 +133,24 @@
request_object = param_or_nil("request")
return super unless request_object && oauth_application
- jws_jwk = if (jwk = oauth_application[oauth_applications_jws_jwk_column])
- jwk = JSON.parse(jwk, symbolize_names: true) if jwk && jwk.is_a?(String)
- else
- redirect_response_error("invalid_request_object")
- end
+ if (jwks = oauth_application_jwks)
+ jwks = JSON.parse(jwks, symbolize_names: true) if jwks.is_a?(String)
+ else
+ redirect_response_error("invalid_request_object")
+ end
- claims = jwt_decode(request_object, jws_key: jwk_import(jws_jwk), jws_algorithm: jwk[:alg], verify_jti: false)
+ request_sig_enc_opts = {
+ jws_algorithm: oauth_application[oauth_applications_request_object_signing_alg_column],
+ jws_encryption_algorithm: oauth_application[oauth_applications_request_object_encryption_alg_column],
+ jws_encryption_method: oauth_application[oauth_applications_request_object_encryption_enc_column]
+ }.compact
+ claims = jwt_decode(request_object, jwks: jwks, verify_jti: false, **request_sig_enc_opts)
+
redirect_response_error("invalid_request_object") unless claims
# If signed, the Authorization Request
# Object SHOULD contain the Claims "iss" (issuer) and "aud" (audience)
# as members, with their semantics being the same as defined in the JWT
@@ -206,19 +228,24 @@
claims
end
def jwt_subject(oauth_token)
- case oauth_jwt_subject_type
+ subject_type = if oauth_application
+ oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
+ else
+ oauth_jwt_subject_type
+ end
+ case subject_type
when "public"
oauth_token[oauth_tokens_account_id_column]
when "pairwise"
id = oauth_token[oauth_tokens_account_id_column]
application_id = oauth_token[oauth_tokens_oauth_application_id_column]
Digest::SHA256.hexdigest("#{id}#{application_id}#{oauth_jwt_subject_secret}")
else
- raise StandardError, "unexpected subject (#{oauth_jwt_subject_type})"
+ raise StandardError, "unexpected subject (#{subject_type})"
end
end
def oauth_token_by_token(token)
jwt_decode(token)
@@ -243,32 +270,42 @@
iss: oauth_token["iss"],
jti: oauth_token["jti"]
}
end
- def oauth_server_metadata_body(path)
+ def oauth_server_metadata_body(path = nil)
metadata = super
metadata.merge! \
jwks_uri: jwks_url,
- token_endpoint_auth_signing_alg_values_supported: [oauth_jwt_algorithm]
+ token_endpoint_auth_signing_alg_values_supported: (oauth_jwt_keys.keys + [oauth_jwt_algorithm]).uniq
metadata
end
def _jwt_key
@_jwt_key ||= oauth_jwt_key || begin
if oauth_application
- if (jwk = oauth_application[oauth_applications_jws_jwk_column])
- jwk = JSON.parse(jwk, symbolize_names: true) if jwk && jwk.is_a?(String)
- jwk
+ if (jwks = oauth_application_jwks)
+ jwks = JSON.parse(jwks, symbolize_names: true) if jwks && jwks.is_a?(String)
+ jwks
else
oauth_application[oauth_applications_jwt_public_key_column]
end
end
end
end
+ def _jwt_public_key
+ @_jwt_public_key ||= oauth_jwt_public_key || begin
+ if oauth_application
+ jwks || oauth_application[oauth_applications_jwt_public_key_column]
+ else
+ _jwt_key
+ end
+ end
+ end
+
# Resource Server only!
#
# returns the jwks set from the authorization server.
def auth_server_jwks_set
metadata = authorization_server_metadata
@@ -317,48 +354,125 @@
def verify_aud(expected_aud, aud)
expected_aud == aud
end
+ def oauth_application_jwks
+ jwks = oauth_application[oauth_applications_jwks_column]
+
+ if jwks
+ jwks = JSON.parse(jwks, symbolize_names: true) if jwks.is_a?(String)
+ return jwks
+ end
+
+ jwks_uri = oauth_application[oauth_applications_jwks_uri_column]
+
+ return unless jwks_uri
+
+ jwks_uri = URI(jwks_uri)
+
+ jwks = JWKS[jwks_uri]
+
+ return jwks if jwks
+
+ JWKS.set(jwks_uri) do
+ http = Net::HTTP.new(jwks_uri.host, jwks_uri.port)
+ http.use_ssl = jwks_uri.scheme == "https"
+
+ request = Net::HTTP::Get.new(jwks_uri.request_uri)
+ request["accept"] = json_response_content_type
+ response = http.request(request)
+ return unless response.code.to_i == 200
+
+ # time-to-live
+ ttl = if response.key?("cache-control")
+ cache_control = response["cache-control"]
+ cache_control[/max-age=(\d+)/, 1].to_i
+ elsif response.key?("expires")
+ Time.parse(response["expires"]).to_i - Time.now.to_i
+ end
+
+ [JSON.parse(response.body, symbolize_names: true), ttl]
+ end
+ end
+
if defined?(JSON::JWT)
+ # json-jwt
+ auth_value_method :oauth_jwt_algorithms_supported, %w[
+ HS256 HS384 HS512
+ RS256 RS384 RS512
+ PS256 PS384 PS512
+ ES256 ES384 ES512 ES256K
+ ]
+ auth_value_method :oauth_jwt_jwe_algorithms_supported, %w[
+ RSA1_5 RSA-OAEP dir A128KW A256KW
+ ]
+ auth_value_method :oauth_jwt_jwe_encryption_methods_supported, %w[
+ A128GCM A256GCM A128CBC-HS256 A256CBC-HS512
+ ]
+
def jwk_import(data)
JSON::JWK.new(data)
end
- # json-jwt
- def jwt_encode(payload)
+ def jwt_encode(payload,
+ jwks: nil,
+ jwe_key: oauth_jwt_jwe_public_key || oauth_jwt_jwe_key,
+ signing_algorithm: oauth_jwt_algorithm,
+ encryption_algorithm: oauth_jwt_jwe_algorithm,
+ encryption_method: oauth_jwt_jwe_encryption_method)
payload[:jti] = generate_jti(payload)
jwt = JSON::JWT.new(payload)
- jwk = JSON::JWK.new(_jwt_key)
- jwt = jwt.sign(jwk, oauth_jwt_algorithm)
+ key = oauth_jwt_keys[signing_algorithm] || _jwt_key
+ key = key.first if key.is_a?(Array)
+
+ jwk = JSON::JWK.new(key || "")
+
+ jwt = jwt.sign(jwk, signing_algorithm)
jwt.kid = jwk.thumbprint
- if oauth_jwt_jwe_key
- algorithm = oauth_jwt_jwe_algorithm.to_sym if oauth_jwt_jwe_algorithm
- jwt = jwt.encrypt(oauth_jwt_jwe_public_key || oauth_jwt_jwe_key,
- algorithm,
- oauth_jwt_jwe_encryption_method.to_sym)
+ if jwks && (jwk = jwks.find { |k| k[:use] == "enc" && k[:alg] == encryption_algorithm && k[:enc] == encryption_method })
+ jwk = JSON::JWK.new(jwk)
+ jwe = jwt.encrypt(jwk, encryption_algorithm.to_sym, encryption_method.to_sym)
+ jwe.to_s
+ elsif jwe_key
+ algorithm = encryption_algorithm.to_sym if encryption_algorithm
+ meth = encryption_method.to_sym if encryption_method
+ jwt.encrypt(jwe_key, algorithm, meth)
+ else
+ jwt.to_s
end
- jwt.to_s
end
def jwt_decode(
token,
+ jwks: nil,
jws_key: oauth_jwt_public_key || _jwt_key,
+ jws_algorithm: oauth_jwt_algorithm,
+ jwe_key: oauth_jwt_jwe_key,
+ jws_encryption_algorithm: oauth_jwt_jwe_algorithm,
+ jws_encryption_method: oauth_jwt_jwe_encryption_method,
verify_claims: true,
verify_jti: true,
verify_iss: true,
verify_aud: false,
**
)
- token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
+ token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if jwe_key
claims = if is_authorization_server?
if oauth_jwt_legacy_public_key
JSON::JWT.decode(token, JSON::JWK::Set.new({ keys: jwks_set }))
+ elsif jwks
+ enc_algs = [jws_encryption_algorithm].compact
+ enc_meths = [jws_encryption_method].compact
+ sig_algs = [jws_algorithm].compact.map(&:to_sym)
+ jws = JSON::JWT.decode(token, JSON::JWK::Set.new({ keys: jwks }), enc_algs + sig_algs, enc_meths)
+ jws = JSON::JWT.decode(jws.plain_text, JSON::JWK::Set.new({ keys: jwks }), sig_algs) if jws.is_a?(JSON::JWE)
+ jws
elsif jws_key
JSON::JWT.decode(token, jws_key)
end
elsif (jwks = auth_server_jwks_set)
JSON::JWT.decode(token, JSON::JWK::Set.new(jwks))
@@ -388,58 +502,99 @@
(JSON::JWK.new(oauth_jwt_jwe_public_key).merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
].compact
end
elsif defined?(JWT)
-
# ruby-jwt
+ require "rodauth/oauth/jwe_extensions" if defined?(JWE)
+ auth_value_method :oauth_jwt_algorithms_supported, %w[
+ HS256 HS384 HS512 HS512256
+ RS256 RS384 RS512
+ ED25519
+ ES256 ES384 ES512
+ PS256 PS384 PS512
+ ]
+
+ auth_value_methods(
+ :oauth_jwt_jwe_algorithms_supported,
+ :oauth_jwt_jwe_encryption_methods_supported
+ )
+
+ def oauth_jwt_jwe_algorithms_supported
+ JWE::VALID_ALG
+ end
+
+ def oauth_jwt_jwe_encryption_methods_supported
+ JWE::VALID_ENC
+ end
+
def jwk_import(data)
JWT::JWK.import(data).keypair
end
- def jwt_encode(payload)
+ def jwt_encode(payload, signing_algorithm: oauth_jwt_algorithm)
headers = {}
- key = _jwt_key
+ key = oauth_jwt_keys[signing_algorithm] || _jwt_key
+ key = key.first if key.is_a?(Array)
- if key.is_a?(OpenSSL::PKey::RSA)
- jwk = JWT::JWK.new(_jwt_key)
+ case key
+ when OpenSSL::PKey::PKey
+ jwk = JWT::JWK.new(key)
headers[:kid] = jwk.kid
key = jwk.keypair
end
# @see JWT reserved claims - https://tools.ietf.org/html/draft-jones-json-web-token-07#page-7
payload[:jti] = generate_jti(payload)
- token = JWT.encode(payload, key, oauth_jwt_algorithm, headers)
+ JWT.encode(payload, key, signing_algorithm, headers)
+ end
- if oauth_jwt_jwe_key
- params = {
- zip: "DEF",
- copyright: oauth_jwt_jwe_copyright
- }
- params[:enc] = oauth_jwt_jwe_encryption_method if oauth_jwt_jwe_encryption_method
- params[:alg] = oauth_jwt_jwe_algorithm if oauth_jwt_jwe_algorithm
- token = JWE.encrypt(token, oauth_jwt_jwe_public_key || oauth_jwt_jwe_key, **params)
+ if defined?(JWE)
+ def jwt_encode_with_jwe(
+ payload,
+ jwks: nil,
+ jwe_key: oauth_jwt_jwe_public_key || oauth_jwt_jwe_key,
+ encryption_algorithm: oauth_jwt_jwe_algorithm,
+ encryption_method: oauth_jwt_jwe_encryption_method, **args
+ )
+
+ token = jwt_encode_without_jwe(payload, **args)
+
+ return token unless encryption_algorithm && encryption_method
+
+ if jwks && jwks.any? { |k| k[:use] == "enc" }
+ JWE.__rodauth_oauth_encrypt_from_jwks(token, jwks, alg: encryption_algorithm, enc: encryption_method)
+ elsif jwe_key
+ params = {
+ zip: "DEF",
+ copyright: oauth_jwt_jwe_copyright
+ }
+ params[:enc] = encryption_method if encryption_method
+ params[:alg] = encryption_algorithm if encryption_algorithm
+ JWE.encrypt(token, jwe_key, **params)
+ else
+ token
+ end
end
- token
+ alias_method :jwt_encode_without_jwe, :jwt_encode
+ alias_method :jwt_encode, :jwt_encode_with_jwe
end
def jwt_decode(
token,
+ jwks: nil,
jws_key: oauth_jwt_public_key || _jwt_key,
jws_algorithm: oauth_jwt_algorithm,
verify_claims: true,
verify_jti: true,
verify_iss: true,
verify_aud: false
)
- # decrypt jwe
- token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
-
# verifying the JWT implies verifying:
#
# issuer: check that server generated the token
# aud: check the audience field (client is who he says he is)
# iat: check that the token didn't expire
@@ -463,10 +618,12 @@
# decode jwt
claims = if is_authorization_server?
if oauth_jwt_legacy_public_key
algorithms = jwks_set.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
JWT.decode(token, nil, true, jwks: { keys: jwks_set }, algorithms: algorithms, **verify_claims_params).first
+ elsif jwks
+ JWT.decode(token, nil, true, algorithms: [jws_algorithm], jwks: { keys: jwks }, **verify_claims_params).first
elsif jws_key
JWT.decode(token, jws_key, true, algorithms: [jws_algorithm], **verify_claims_params).first
end
elsif (jwks = auth_server_jwks_set)
algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
@@ -478,10 +635,37 @@
claims
rescue JWT::DecodeError, JWT::JWKError
nil
end
+ if defined?(JWE)
+ def jwt_decode_with_jwe(
+ token,
+ jwks: nil,
+ jwe_key: oauth_jwt_jwe_key,
+ jws_encryption_algorithm: oauth_jwt_jwe_algorithm,
+ jws_encryption_method: oauth_jwt_jwe_encryption_method,
+ **args
+ )
+
+ token = if jwks && jwks.any? { |k| k[:use] == "enc" }
+ JWE.__rodauth_oauth_decrypt_from_jwks(token, jwks, alg: jws_encryption_algorithm, enc: jws_encryption_method)
+ elsif jwe_key
+ JWE.decrypt(token, jwe_key)
+ else
+ token
+ end
+
+ jwt_decode_without_jwe(token, jwks: jwks, **args)
+ rescue JWE::DecodeError => e
+ jwt_decode_without_jwe(token, jwks: jwks, **args) if e.message.include?("Not enough or too many segments")
+ end
+
+ alias_method :jwt_decode_without_jwe, :jwt_decode
+ alias_method :jwt_decode, :jwt_decode_with_jwe
+ end
+
def jwks_set
@jwks_set ||= [
(JWT::JWK.new(oauth_jwt_public_key).export.merge(use: "sig", alg: oauth_jwt_algorithm) if oauth_jwt_public_key),
(
if oauth_jwt_legacy_public_key
@@ -515,8 +699,23 @@
token_hint = param_or_nil("token_type_hint")
throw(:rodauth_error) if !token_hint || token_hint == "access_token"
super
+ end
+
+ def jwt_response_success(jwt, cache = false)
+ response.status = 200
+ response["Content-Type"] ||= "application/jwt"
+ if cache
+ # defaulting to 1-day for everyone, for now at least
+ max_age = 60 * 60 * 24
+ response["Cache-Control"] = "private, max-age=#{max_age}"
+ else
+ response["Cache-Control"] = "no-store"
+ response["Pragma"] = "no-cache"
+ end
+ response.write(jwt)
+ request.halt
end
end
end