diff --git a/README.md b/README.md index e59b6c4f..899bc2b7 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ end | jwt_secret_base64 | For HMAC with SHA2 (e.g. HS256) signing algorithms, specify the base64-encoded secret used to sign the JWT token. Defaults to the OAuth2 client secret if not specified. | no | client_options.secret | "bXlzZWNyZXQ=\n" | | logout_path | The log out is only triggered when the request path ends on this path | no | '/logout' | '/sign_out' | | acr_values | Authentication Class Reference (ACR) values to be passed to the authorize_uri to enforce a specific level, see [RFC9470](https://www.rfc-editor.org/rfc/rfc9470.html) | no | nil | "c1 c2" | +| id_token_encryption_alg | Key-wrapping algorithm used by the provider to encrypt the ID token as a JWE. When set, the token is decrypted before verification. See [JWE support](#jwe-support) below. | no | nil | "RSA-OAEP", "RSA-OAEP-256", "dir" | +| id_token_encryption_key | Decryption key matching `id_token_encryption_alg`. PEM-encoded private key for RSA algorithms; base64url-encoded bytes for `dir`. Mutually exclusive with `id_token_encryption_key_file`. | no | nil | | +| id_token_encryption_key_file | Path to a file containing the decryption key. Same format as `id_token_encryption_key` (PEM for RSA; base64url for `dir`). Useful when loading config from JSON or when keeping secrets out of application config. | no | nil | | ### Client Config Options @@ -148,6 +151,42 @@ These are the configuration options for the client_options hash of the configura property can be used to add the attribute to the token request. Initial value is `true`, which means that the scope attribute is included by default. +### JWE Support + +Some OpenID Connect providers (e.g. the Belgian [It's Me](https://www.itsme-id.com/) identity provider) encrypt the ID token as a [JWE](https://www.rfc-editor.org/rfc/rfc7516) before returning it. Set `id_token_encryption_alg` and `id_token_encryption_key` (or `id_token_encryption_key_file`) to have the token transparently decrypted before verification: + +```ruby +# Inline key value +provider :openid_connect, { + # ... standard options ... + id_token_encryption_alg: "RSA-OAEP-256", + id_token_encryption_key: File.read("private_key.pem"), +} + +# Or point to a key file (useful when loading config from JSON or environment variables) +provider :openid_connect, { + # ... standard options ... + id_token_encryption_alg: "RSA-OAEP-256", + id_token_encryption_key_file: "/path/to/private_key.pem", +} +``` + +Supported key-wrapping algorithms and their required key type: + +| `id_token_encryption_alg` | `id_token_encryption_key` | Notes | +|---------------------------|--------------------------------------------|--------------------------------| +| `RSA-OAEP` | PEM-encoded RSA private key | | +| `RSA-OAEP-256` | PEM-encoded RSA private key | Requires OpenSSL >= 3.0 | +| `dir` | Base64url-encoded symmetric key bytes | See key-length note below | + +All four JWE content encryption algorithms are supported: `A128GCM`, `A256GCM`, `A128CBC-HS256`, `A256CBC-HS512`. + +**Key lengths for `dir`:** The symmetric key must be exactly the right length for the chosen `enc` algorithm - `A128GCM`: 16 bytes, `A256GCM`: 32 bytes, `A128CBC-HS256`: 32 bytes (16 MAC + 16 ENC), `A256CBC-HS512`: 64 bytes (32 MAC + 32 ENC). The key must be base64url-encoded when passed as `id_token_encryption_key` or stored in an `id_token_encryption_key_file`. + +#### Encrypted userinfo endpoint + +When `id_token_encryption_alg` is set, the userinfo endpoint response is also handled as a potentially encrypted JWT/JWE. The same `id_token_encryption_alg` and `id_token_encryption_key` values are used for both the ID token and the userinfo response - configure these to match whatever your provider uses for the userinfo endpoint. If your provider uses different keys or algorithms for the two, this integration is not currently supported. + ## Additional notes * In some cases, you may want to go straight to the callback phase - e.g. when requested by a stateless client, like a mobile app. In such example, the session is empty, so you have to forward certain parameters received from the client. diff --git a/lib/omniauth/strategies/openid_connect.rb b/lib/omniauth/strategies/openid_connect.rb index 73dd0fe0..ed2dd338 100644 --- a/lib/omniauth/strategies/openid_connect.rb +++ b/lib/omniauth/strategies/openid_connect.rb @@ -14,6 +14,17 @@ class OpenIDConnect # rubocop:disable Metrics/ClassLength include OmniAuth::Strategy extend Forwardable + JWE_SEGMENT_COUNT = 5 + + # Required key byte-lengths for each `enc` algorithm used with `dir`. + # CBC modes need mac-key + enc-key concatenated; GCM modes use the key directly. + DIR_ENC_KEY_LENGTHS = { + 'A128CBC-HS256' => 32, + 'A256CBC-HS512' => 64, + 'A128GCM' => 16, + 'A256GCM' => 32, + }.freeze + RESPONSE_TYPE_EXCEPTIONS = { 'id_token' => { exception_class: OmniAuth::OpenIDConnect::MissingIdTokenError, key: :missing_id_token }.freeze, 'code' => { exception_class: OmniAuth::OpenIDConnect::MissingCodeError, key: :missing_code }.freeze, @@ -71,6 +82,9 @@ class OpenIDConnect # rubocop:disable Metrics/ClassLength } option :logout_path, '/logout' + option :id_token_encryption_alg, nil # e.g. 'RSA-OAEP', 'RSA-OAEP-256', 'dir' + option :id_token_encryption_key, nil # PEM string for RSA algorithms; base64url-encoded bytes for 'dir' + option :id_token_encryption_key_file, nil # path to a file containing the key (PEM or base64url for 'dir') def uid user_info.raw_attributes[options.uid_field.to_sym] || user_info.sub @@ -266,12 +280,48 @@ def user_info if access_token.id_token decoded = decode_id_token(access_token.id_token).raw_attributes - @user_info = ::OpenIDConnect::ResponseObject::UserInfo.new access_token.userinfo!.raw_attributes.merge(decoded) + if options.id_token_encryption_alg.present? + # When JWE encryption is configured, the userinfo endpoint may also return an + # encrypted response (e.g. with Content-Type: application/jwt). The openid_connect + # gem calls res.body.with_indifferent_access which fails on a JWE string, so we + # fetch and decrypt the userinfo ourselves. + userinfo = fetch_userinfo_attributes + # OIDC Core ยง5.3.2: the sub in the UserInfo response MUST exactly match the sub + # in the ID Token; if they differ, the UserInfo response MUST NOT be used. + if userinfo.present? && userinfo[:sub].to_s != decoded[:sub].to_s + raise CallbackError, error: :userinfo_sub_mismatch, + reason: "UserInfo sub (#{userinfo[:sub].inspect}) does not match ID token sub (#{decoded[:sub].inspect})" + end + @user_info = ::OpenIDConnect::ResponseObject::UserInfo.new(userinfo.merge(decoded)) + else + @user_info = ::OpenIDConnect::ResponseObject::UserInfo.new access_token.userinfo!.raw_attributes.merge(decoded) + end else @user_info = access_token.userinfo! end end + # Fetches userinfo directly and decrypts if the response is a JWE/JWT string. + # Falls back to an empty hash on decryption/parsing failures so that decoded + # ID token attributes are still used. Network and other unexpected errors propagate. + def fetch_userinfo_attributes + response = access_token.http_client.get(client.userinfo_uri) + body = response.body + return body if body.is_a?(Hash) + + jwt_string = jwe?(body) ? decrypt_jwe(body) : body + # NOTE: The userinfo JWT signature is not verified here. JWE encryption provides + # confidentiality but not authenticity - it does not prove the claims came from the + # legitimate provider. Full verification would require a JWKS lookup (as the + # openid_connect gem does for the ID token). In practice, TLS transport and the + # requirement for the attacker to also possess the decryption key make active + # forgery very difficult, but this is a known limitation. + JSON::JWT.decode(jwt_string, :skip_verification).to_h.with_indifferent_access + rescue CallbackError, JSON::JWT::Exception => e + OmniAuth.logger.warn "[OIDC] Failed to decrypt userinfo response: #{e.class}: #{e.message}" + {} + end + def access_token return @access_token if @access_token @@ -297,6 +347,7 @@ def access_token # limitation in the openid_connect gem: # https://github.com/nov/openid_connect/issues/61 def decode_id_token(id_token) + id_token = decrypt_jwe(id_token) if jwe?(id_token) decoded = JSON::JWT.decode(id_token, :skip_verification) algorithm = decoded.algorithm.to_sym @@ -468,6 +519,143 @@ def configured_response_type @configured_response_type ||= options.response_type.to_s end + def jwe?(token) + options.id_token_encryption_alg.to_s != '' && token.to_s.count('.') + 1 == JWE_SEGMENT_COUNT + end + + def decrypt_jwe(jwe_token) + alg = options.id_token_encryption_alg.to_s + key = resolve_encryption_key + + case alg + when 'RSA-OAEP' + raise_missing_key(:id_token_encryption_key) if key.nil? || key.to_s.strip.empty? + JSON::JWE.decode_compact_serialized(jwe_token, OpenSSL::PKey.read(key)).plain_text + when 'RSA-OAEP-256' + raise_missing_key(:id_token_encryption_key) if key.nil? || key.to_s.strip.empty? + decrypt_rsa_oaep_256(jwe_token, OpenSSL::PKey.read(key)) + when 'dir' + raise_missing_key(:id_token_encryption_key) if key.nil? || key.to_s.empty? + decrypt_dir(jwe_token, key) + else + raise CallbackError, error: :jwe_decryption_failed, + reason: "Unknown id_token_encryption_alg: #{alg.inspect}" + end + rescue JSON::JWE::DecryptionFailed, JSON::JWE::InvalidFormat, JSON::JWE::UnexpectedAlgorithm, + OpenSSL::PKey::PKeyError, OpenSSL::Cipher::CipherError, + JSON::ParserError, ArgumentError, NoMethodError => e + raise CallbackError, error: :jwe_decryption_failed, reason: "JWE decryption failed: #{e.message}" + end + + # Decrypts a JWE token using RSA-OAEP-256 (SHA-256 for OAEP hash and MGF1). + # The json-jwt gem does not support RSA-OAEP-256 natively, so this uses + # OpenSSL directly. Requires OpenSSL >= 3.0. + def decrypt_rsa_oaep_256(jwe_token, rsa_key) # rubocop:disable Naming/VariableNumber + if OpenSSL::VERSION.split('.').first.to_i < 3 + raise CallbackError, error: :jwe_decryption_failed, + reason: 'RSA-OAEP-256 requires OpenSSL >= 3.0' + end + + protected_b64, encrypted_cek_b64, iv_b64, ciphertext_b64, auth_tag_b64 = jwe_token.split('.') + + header = JSON.parse(jwe_b64_decode(protected_b64)) + enc = header['enc'] + cek = rsa_key.decrypt( + jwe_b64_decode(encrypted_cek_b64), + rsa_padding_mode: 'oaep', + rsa_oaep_md: 'SHA256', + rsa_mgf1_md: 'SHA256' + ) + init_vec = jwe_b64_decode(iv_b64) + ciphertext = jwe_b64_decode(ciphertext_b64) + auth_tag = jwe_b64_decode(auth_tag_b64) + + case enc + when 'A128CBC-HS256' then decrypt_aes_cbc(cek, init_vec, ciphertext, auth_tag, protected_b64, 'aes-128-cbc', 16) + when 'A256CBC-HS512' then decrypt_aes_cbc(cek, init_vec, ciphertext, auth_tag, protected_b64, 'aes-256-cbc', 32) + when 'A128GCM' then decrypt_aes_gcm(cek, init_vec, ciphertext, auth_tag, protected_b64, 'aes-128-gcm') + when 'A256GCM' then decrypt_aes_gcm(cek, init_vec, ciphertext, auth_tag, protected_b64, 'aes-256-gcm') + else raise JSON::JWE::UnexpectedAlgorithm, "Unsupported enc: #{enc}" + end + end + + # Decrypts a JWE token using the dir (direct key agreement) algorithm. + # The json-jwt gem has issues decrypting dir tokens when the key is a raw string, + # so we bypass it and use our own AES implementation instead. + # The symmetric_key must be base64url-encoded (with or without padding). + def decrypt_dir(jwe_token, symmetric_key) + protected_b64, _encrypted_cek_b64, iv_b64, ciphertext_b64, auth_tag_b64 = jwe_token.split('.') + + header = JSON.parse(jwe_b64_decode(protected_b64)) + enc = header['enc'] + cek = jwe_b64_decode(symmetric_key) + + expected_key_length = DIR_ENC_KEY_LENGTHS[enc] + if expected_key_length && cek.bytesize != expected_key_length + raise ArgumentError, + "dir key must be #{expected_key_length} bytes for #{enc} (got #{cek.bytesize})" + end + + init_vec = jwe_b64_decode(iv_b64) + ciphertext = jwe_b64_decode(ciphertext_b64) + auth_tag = jwe_b64_decode(auth_tag_b64) + + case enc + when 'A128CBC-HS256' then decrypt_aes_cbc(cek, init_vec, ciphertext, auth_tag, protected_b64, 'aes-128-cbc', 16) + when 'A256CBC-HS512' then decrypt_aes_cbc(cek, init_vec, ciphertext, auth_tag, protected_b64, 'aes-256-cbc', 32) + when 'A128GCM' then decrypt_aes_gcm(cek, init_vec, ciphertext, auth_tag, protected_b64, 'aes-128-gcm') + when 'A256GCM' then decrypt_aes_gcm(cek, init_vec, ciphertext, auth_tag, protected_b64, 'aes-256-gcm') + else raise JSON::JWE::UnexpectedAlgorithm, "Unsupported enc: #{enc}" + end + end + + # base64 gem 0.3.0 requires proper padding for urlsafe_decode64. + # JWE compact serialization uses unpadded base64url, so we add it explicitly. + def jwe_b64_decode(str) + Base64.urlsafe_decode64(str + ('=' * ((4 - (str.length % 4)) % 4))) + end + + # rubocop:disable Metrics/ParameterLists + def decrypt_aes_cbc(cek, init_vec, ciphertext, auth_tag, auth_data, cipher_name, key_half) + mac_key = cek[0, key_half] + enc_key = cek[key_half, key_half] + al = [auth_data.bytesize * 8].pack('Q>') + hmac_input = auth_data.b + init_vec + ciphertext + al + digest = key_half == 16 ? OpenSSL::Digest.new('SHA256') : OpenSSL::Digest.new('SHA512') + expected_tag = OpenSSL::HMAC.digest(digest, mac_key, hmac_input)[0, key_half] + raise JSON::JWE::DecryptionFailed unless OpenSSL.fixed_length_secure_compare(expected_tag, auth_tag[0, key_half]) + + cipher = OpenSSL::Cipher.new(cipher_name) + cipher.decrypt + cipher.key = enc_key + cipher.iv = init_vec + cipher.update(ciphertext) + cipher.final + end + + def decrypt_aes_gcm(cek, init_vec, ciphertext, auth_tag, auth_data, cipher_name) + cipher = OpenSSL::Cipher.new(cipher_name) + cipher.decrypt + cipher.key = cek + cipher.iv = init_vec + cipher.auth_tag = auth_tag + cipher.auth_data = auth_data.b + cipher.update(ciphertext) + cipher.final + end + # rubocop:enable Metrics/ParameterLists + + def resolve_encryption_key + if options.id_token_encryption_key_file.present? + File.read(options.id_token_encryption_key_file) + else + options.id_token_encryption_key + end + end + + def raise_missing_key(option_name) + raise CallbackError, error: :jwe_decryption_failed, + reason: "#{option_name} is required for #{options.id_token_encryption_alg} decryption" + end + def verify_id_token!(id_token) return unless id_token diff --git a/test/lib/omniauth/strategies/openid_connect_jwe_test.rb b/test/lib/omniauth/strategies/openid_connect_jwe_test.rb new file mode 100644 index 00000000..b808fe26 --- /dev/null +++ b/test/lib/omniauth/strategies/openid_connect_jwe_test.rb @@ -0,0 +1,490 @@ +# frozen_string_literal: true + +require 'test_helper' + +class OpenIDConnectJweTest < StrategyTestCase + # Builds a minimal RSA-OAEP-256 JWE token for round-trip testing. + def build_rsa_oaep_256_jwe(plaintext, rsa_public_key, enc:) + header = Base64.urlsafe_encode64({ alg: 'RSA-OAEP-256', enc: enc }.to_json, padding: false) + + cek_size = { 'A128GCM' => 16, 'A256GCM' => 32, 'A128CBC-HS256' => 32, 'A256CBC-HS512' => 64 } + cek = SecureRandom.bytes(cek_size.fetch(enc)) + + encrypted_cek = rsa_public_key.encrypt( + cek, + rsa_padding_mode: 'oaep', + rsa_oaep_md: 'SHA256', + rsa_mgf1_md: 'SHA256' + ) + + iv, ciphertext, auth_tag = encrypt_content(enc, cek, plaintext, header) + + [header, + Base64.urlsafe_encode64(encrypted_cek, padding: false), + Base64.urlsafe_encode64(iv, padding: false), + Base64.urlsafe_encode64(ciphertext, padding: false), + Base64.urlsafe_encode64(auth_tag, padding: false)].join('.') + end + + def encrypt_content(enc, cek, plaintext, header) + case enc + when 'A128GCM', 'A256GCM' + iv = SecureRandom.bytes(12) + cipher = OpenSSL::Cipher.new(enc == 'A128GCM' ? 'aes-128-gcm' : 'aes-256-gcm') + cipher.encrypt + cipher.key = cek + cipher.iv = iv + cipher.auth_data = header.b + ciphertext = cipher.update(plaintext) + cipher.final + [iv, ciphertext, cipher.auth_tag] + when 'A128CBC-HS256', 'A256CBC-HS512' + key_half = enc == 'A128CBC-HS256' ? 16 : 32 + mac_key = cek[0, key_half] + enc_key = cek[key_half, key_half] + iv = SecureRandom.bytes(16) + cipher = OpenSSL::Cipher.new(enc == 'A128CBC-HS256' ? 'aes-128-cbc' : 'aes-256-cbc') + cipher.encrypt + cipher.key = enc_key + cipher.iv = iv + ciphertext = cipher.update(plaintext) + cipher.final + al = [header.bytesize * 8].pack('Q>') + digest = OpenSSL::Digest.new(enc == 'A128CBC-HS256' ? 'SHA256' : 'SHA512') + auth_tag = OpenSSL::HMAC.digest(digest, mac_key, header.b + iv + ciphertext + al)[0, key_half] + [iv, ciphertext, auth_tag] + end + end + + # --------------------------------------------------------------------------- + # #jwe? + # --------------------------------------------------------------------------- + + def test_jwe_returns_false_when_alg_not_configured + strategy.options.id_token_encryption_alg = nil + refute strategy.send(:jwe?, 'a.b.c.d.e') + refute strategy.send(:jwe?, 'a.b.c') + end + + def test_jwe_returns_false_for_3_segment_token_even_with_alg_configured + strategy.options.id_token_encryption_alg = 'RSA-OAEP' + refute strategy.send(:jwe?, 'a.b.c') + end + + def test_jwe_returns_true_for_5_segment_token_with_alg_configured + strategy.options.id_token_encryption_alg = 'RSA-OAEP' + assert strategy.send(:jwe?, 'a.b.c.d.e') + end + + # --------------------------------------------------------------------------- + # #decrypt_jwe - RSA-OAEP + # --------------------------------------------------------------------------- + + def test_decrypt_jwe_rsa_oaep_raises_without_key + strategy.options.id_token_encryption_alg = 'RSA-OAEP' + strategy.options.id_token_encryption_key = nil + error = assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + assert_includes error.error_reason, 'id_token_encryption_key' + end + + def test_decrypt_jwe_rsa_oaep_delegates_to_json_jwe + rsa_key = OpenSSL::PKey::RSA.generate(2048) + strategy.options.id_token_encryption_alg = 'RSA-OAEP' + strategy.options.id_token_encryption_key = rsa_key.to_pem + + mock_jwe = mock + mock_jwe.stubs(:plain_text).returns('decrypted.jws.token') + JSON::JWE.expects(:decode_compact_serialized) + .with('a.b.c.d.e', instance_of(OpenSSL::PKey::RSA)) + .returns(mock_jwe) + + result = strategy.send(:decrypt_jwe, 'a.b.c.d.e') + assert_equal 'decrypted.jws.token', result + end + + # --------------------------------------------------------------------------- + # #decrypt_jwe - RSA-OAEP-256 + # --------------------------------------------------------------------------- + + def test_decrypt_jwe_rsa_oaep_256_raises_without_key + strategy.options.id_token_encryption_alg = 'RSA-OAEP-256' + strategy.options.id_token_encryption_key = nil + assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + end + + def test_decrypt_jwe_rsa_oaep_256_raises_for_malformed_pem + strategy.options.id_token_encryption_alg = 'RSA-OAEP-256' + strategy.options.id_token_encryption_key = 'not-a-valid-pem' + assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + end + + def test_decrypt_jwe_rsa_oaep_256_round_trip_a128gcm + skip 'Requires OpenSSL >= 3.0' if OpenSSL::VERSION.split('.').first.to_i < 3 + + rsa_key = OpenSSL::PKey::RSA.generate(2048) + plaintext = 'test.jws.payload' + jwe_token = build_rsa_oaep_256_jwe(plaintext, rsa_key.public_key, enc: 'A128GCM') + + strategy.options.id_token_encryption_alg = 'RSA-OAEP-256' + strategy.options.id_token_encryption_key = rsa_key.to_pem + + assert_equal plaintext, strategy.send(:decrypt_jwe, jwe_token) + end + + def test_decrypt_jwe_rsa_oaep_256_round_trip_a128cbc_hs256 + skip 'Requires OpenSSL >= 3.0' if OpenSSL::VERSION.split('.').first.to_i < 3 + + rsa_key = OpenSSL::PKey::RSA.generate(2048) + plaintext = 'test.jws.payload' + jwe_token = build_rsa_oaep_256_jwe(plaintext, rsa_key.public_key, enc: 'A128CBC-HS256') + + strategy.options.id_token_encryption_alg = 'RSA-OAEP-256' + strategy.options.id_token_encryption_key = rsa_key.to_pem + + assert_equal plaintext, strategy.send(:decrypt_jwe, jwe_token) + end + + # --------------------------------------------------------------------------- + # #decrypt_jwe - dir + # --------------------------------------------------------------------------- + + def test_decrypt_jwe_dir_raises_without_key + strategy.options.id_token_encryption_alg = 'dir' + strategy.options.id_token_encryption_key = nil + error = assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + assert_includes error.error_reason, 'id_token_encryption_key' + end + + def test_decrypt_jwe_dir_round_trip_a128cbc_hs256 + # A128CBC-HS256 requires a 32-byte CEK (16 mac + 16 enc) + raw_key = SecureRandom.bytes(32) + plaintext = 'test.jws.payload' + header = Base64.urlsafe_encode64({ alg: 'dir', enc: 'A128CBC-HS256' }.to_json, padding: false) + iv, ciphertext, auth_tag = encrypt_content('A128CBC-HS256', raw_key, plaintext, header) + jwe_token = [ + header, '', + Base64.urlsafe_encode64(iv, padding: false), + Base64.urlsafe_encode64(ciphertext, padding: false), + Base64.urlsafe_encode64(auth_tag, padding: false) + ].join('.') + + strategy.options.id_token_encryption_alg = 'dir' + strategy.options.id_token_encryption_key = Base64.urlsafe_encode64(raw_key, padding: false) + + assert_equal plaintext, strategy.send(:decrypt_jwe, jwe_token) + end + + def test_decrypt_jwe_dir_round_trip_a128gcm + raw_key = SecureRandom.bytes(16) + plaintext = 'test.jws.payload' + header = Base64.urlsafe_encode64({ alg: 'dir', enc: 'A128GCM' }.to_json, padding: false) + iv, ciphertext, auth_tag = encrypt_content('A128GCM', raw_key, plaintext, header) + jwe_token = [ + header, '', + Base64.urlsafe_encode64(iv, padding: false), + Base64.urlsafe_encode64(ciphertext, padding: false), + Base64.urlsafe_encode64(auth_tag, padding: false) + ].join('.') + + strategy.options.id_token_encryption_alg = 'dir' + strategy.options.id_token_encryption_key = Base64.urlsafe_encode64(raw_key, padding: false) + + assert_equal plaintext, strategy.send(:decrypt_jwe, jwe_token) + end + + def test_decrypt_jwe_dir_round_trip_using_key_file + raw_key = SecureRandom.bytes(16) + plaintext = 'test.jws.payload' + header = Base64.urlsafe_encode64({ alg: 'dir', enc: 'A128GCM' }.to_json, padding: false) + iv, ciphertext, auth_tag = encrypt_content('A128GCM', raw_key, plaintext, header) + jwe_token = [ + header, '', + Base64.urlsafe_encode64(iv, padding: false), + Base64.urlsafe_encode64(ciphertext, padding: false), + Base64.urlsafe_encode64(auth_tag, padding: false) + ].join('.') + + Tempfile.create('jwe_key') do |f| + f.write(Base64.urlsafe_encode64(raw_key, padding: false)) + f.flush + + strategy.options.id_token_encryption_alg = 'dir' + strategy.options.id_token_encryption_key_file = f.path + + assert_equal plaintext, strategy.send(:decrypt_jwe, jwe_token) + end + end + + def test_decrypt_jwe_rsa_oaep_round_trip_using_key_file + rsa_key = OpenSSL::PKey::RSA.generate(2048) + mock_jwe = mock + mock_jwe.stubs(:plain_text).returns('decrypted.jws.token') + JSON::JWE.expects(:decode_compact_serialized) + .with('a.b.c.d.e', instance_of(OpenSSL::PKey::RSA)) + .returns(mock_jwe) + + Tempfile.create(['jwe_key', '.pem']) do |f| + f.write(rsa_key.to_pem) + f.flush + + strategy.options.id_token_encryption_alg = 'RSA-OAEP' + strategy.options.id_token_encryption_key_file = f.path + + assert_equal 'decrypted.jws.token', strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + end + + # --------------------------------------------------------------------------- + # #decrypt_jwe - unknown alg + # --------------------------------------------------------------------------- + + def test_decrypt_jwe_unknown_alg_raises_callback_error + strategy.options.id_token_encryption_alg = 'unsupported-alg' + strategy.options.id_token_encryption_key = 'any' + error = assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + assert_includes error.error_reason, 'unsupported-alg' + end + + # --------------------------------------------------------------------------- + # #decrypt_jwe - error wrapping + # --------------------------------------------------------------------------- + + def test_decrypt_jwe_wraps_decryption_failed + rsa_key = OpenSSL::PKey::RSA.generate(2048) + strategy.options.id_token_encryption_alg = 'RSA-OAEP' + strategy.options.id_token_encryption_key = rsa_key.to_pem + JSON::JWE.stubs(:decode_compact_serialized).raises(JSON::JWE::DecryptionFailed) + assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + end + + def test_decrypt_jwe_dir_wraps_malformed_token + strategy.options.id_token_encryption_alg = 'dir' + strategy.options.id_token_encryption_key = 'key' + # Passing a malformed JWE to decrypt_dir triggers a JSON::ParserError on the header, + # which is rescued and wrapped in a CallbackError. + assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + end + + def test_decrypt_jwe_wraps_argument_error + rsa_key = OpenSSL::PKey::RSA.generate(2048) + strategy.options.id_token_encryption_alg = 'RSA-OAEP' + strategy.options.id_token_encryption_key = rsa_key.to_pem + JSON::JWE.stubs(:decode_compact_serialized).raises(ArgumentError, 'invalid base64') + assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, 'a.b.c.d.e') + end + end + + # --------------------------------------------------------------------------- + # #decode_id_token integration + # --------------------------------------------------------------------------- + + def test_decode_id_token_does_not_call_decrypt_jwe_for_3_segment_token + strategy.expects(:decrypt_jwe).never + strategy.send(:decode_id_token, 'header.payload.sig') + rescue StandardError + nil # super will fail without a full OIDC setup; we only care decrypt_jwe was not called + end + + def test_decode_id_token_calls_decrypt_jwe_for_5_segment_token + strategy.options.id_token_encryption_alg = 'dir' + strategy.options.id_token_encryption_key = 'key' + strategy.expects(:decrypt_jwe).with('h.e.i.c.t').returns('header.payload.sig') + strategy.send(:decode_id_token, 'h.e.i.c.t') + rescue StandardError + nil # super will fail without a full OIDC setup; we only care decrypt_jwe was called + end + + # --------------------------------------------------------------------------- + # #user_info and #fetch_userinfo_attributes - encrypted userinfo endpoint + # --------------------------------------------------------------------------- + + # A strategy instance without the default user_info stub, for testing user_info directly. + def jwe_strategy + @jwe_strategy ||= OmniAuth::Strategies::OpenIDConnect.new(DummyApp.new).tap do |s| + s.options.client_options.identifier = @identifier + s.options.client_options.secret = @secret + s.stubs(:request).returns(request) + s.stubs(:script_name).returns('') + end + end + + def test_user_info_uses_fetch_userinfo_attributes_when_encryption_configured + rsa_key = OpenSSL::PKey::RSA.generate(2048) + id_token_claims = { sub: 'user123', email: 'user@example.com', given_name: 'Ada' } + id_token_jws = JSON::JWT.new(id_token_claims).sign(rsa_key, :RS256).to_s + + mock_access_token = stub(id_token: id_token_jws, http_client: stub) + decoded = stub(raw_attributes: id_token_claims) + + jwe_strategy.options.id_token_encryption_alg = 'RSA-OAEP' + jwe_strategy.stubs(:access_token).returns(mock_access_token) + jwe_strategy.stubs(:decode_id_token).with(id_token_jws).returns(decoded) + jwe_strategy.stubs(:fetch_userinfo_attributes).returns({ sub: 'user123', phone_number: '+32499000000' }) + + result = jwe_strategy.send(:user_info) + assert_equal 'user123', result.sub + assert_equal 'Ada', result.given_name + assert_equal '+32499000000', result.phone_number + end + + def test_user_info_raises_on_sub_mismatch + rsa_key = OpenSSL::PKey::RSA.generate(2048) + id_token_claims = { sub: 'user123' } + id_token_jws = JSON::JWT.new(id_token_claims).sign(rsa_key, :RS256).to_s + + mock_access_token = stub(id_token: id_token_jws) + decoded = stub(raw_attributes: id_token_claims) + + jwe_strategy.options.id_token_encryption_alg = 'RSA-OAEP' + jwe_strategy.stubs(:access_token).returns(mock_access_token) + jwe_strategy.stubs(:decode_id_token).with(id_token_jws).returns(decoded) + jwe_strategy.stubs(:fetch_userinfo_attributes).returns({ sub: 'attacker', email: 'evil@example.com' }) + + assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + jwe_strategy.send(:user_info) + end + end + + def test_user_info_falls_back_to_standard_path_when_no_encryption + jwe_strategy.options.id_token_encryption_alg = nil + mock_access_token = stub(id_token: nil) + expected_userinfo = OpenIDConnect::ResponseObject::UserInfo.new(sub: 'user456') + + jwe_strategy.stubs(:access_token).returns(mock_access_token) + mock_access_token.stubs(:userinfo!).returns(expected_userinfo) + + result = jwe_strategy.send(:user_info) + assert_equal 'user456', result.sub + end + + def test_fetch_userinfo_attributes_returns_hash_body_unchanged + mock_http_client = stub + mock_access_token = stub(http_client: mock_http_client) + mock_client = stub(userinfo_uri: 'https://oidc.example.com/userinfo') + body = { sub: 'user123', email: 'user@example.com' } + + jwe_strategy.options.id_token_encryption_alg = 'RSA-OAEP' + jwe_strategy.stubs(:access_token).returns(mock_access_token) + jwe_strategy.stubs(:client).returns(mock_client) + mock_http_client.stubs(:get).returns(stub(body: body)) + + result = jwe_strategy.send(:fetch_userinfo_attributes) + assert_equal body, result + end + + def test_fetch_userinfo_attributes_decodes_plain_jwt_string_body + rsa_key = OpenSSL::PKey::RSA.generate(2048) + claims = { sub: 'user123', phone_number: '+32499000000' } + jws = JSON::JWT.new(claims).sign(rsa_key, :RS256).to_s + + mock_http_client = stub + mock_access_token = stub(http_client: mock_http_client) + mock_client = stub(userinfo_uri: 'https://oidc.example.com/userinfo') + + jwe_strategy.options.id_token_encryption_alg = 'RSA-OAEP' + jwe_strategy.stubs(:access_token).returns(mock_access_token) + jwe_strategy.stubs(:client).returns(mock_client) + mock_http_client.stubs(:get).returns(stub(body: jws)) + + result = jwe_strategy.send(:fetch_userinfo_attributes) + assert_equal 'user123', result[:sub] + assert_equal '+32499000000', result[:phone_number] + end + + def test_fetch_userinfo_attributes_decrypts_jwe_string_body + raw_key = SecureRandom.bytes(32) + plaintext_claims = { sub: 'user123', phone_number: '+32499000000' } + # Build a dir JWE wrapping a signed JWT + rsa_key = OpenSSL::PKey::RSA.generate(2048) + inner_jwt = JSON::JWT.new(plaintext_claims).sign(rsa_key, :RS256).to_s + header = Base64.urlsafe_encode64({ alg: 'dir', enc: 'A128CBC-HS256' }.to_json, padding: false) + iv, ciphertext, auth_tag = encrypt_content('A128CBC-HS256', raw_key, inner_jwt, header) + jwe_body = [ + header, '', + Base64.urlsafe_encode64(iv, padding: false), + Base64.urlsafe_encode64(ciphertext, padding: false), + Base64.urlsafe_encode64(auth_tag, padding: false) + ].join('.') + + mock_http_client = stub + mock_access_token = stub(http_client: mock_http_client) + mock_client = stub(userinfo_uri: 'https://oidc.example.com/userinfo') + + jwe_strategy.options.id_token_encryption_alg = 'dir' + jwe_strategy.options.id_token_encryption_key = Base64.urlsafe_encode64(raw_key, padding: false) + jwe_strategy.stubs(:access_token).returns(mock_access_token) + jwe_strategy.stubs(:client).returns(mock_client) + mock_http_client.stubs(:get).returns(stub(body: jwe_body)) + + result = jwe_strategy.send(:fetch_userinfo_attributes) + assert_equal 'user123', result[:sub] + assert_equal '+32499000000', result[:phone_number] + end + + def test_fetch_userinfo_attributes_returns_empty_hash_on_decryption_error + mock_http_client = stub + mock_access_token = stub(http_client: mock_http_client) + mock_client = stub(userinfo_uri: 'https://oidc.example.com/userinfo') + + jwe_strategy.options.id_token_encryption_alg = 'RSA-OAEP' + jwe_strategy.stubs(:access_token).returns(mock_access_token) + jwe_strategy.stubs(:client).returns(mock_client) + # A JWE-shaped body triggers decrypt_jwe which raises CallbackError on failure + jwe_strategy.stubs(:jwe?).returns(true) + jwe_strategy.stubs(:decrypt_jwe).raises( + OmniAuth::Strategies::OpenIDConnect::CallbackError.new(error: :jwe_decryption_failed, reason: 'bad key') + ) + mock_http_client.stubs(:get).returns(stub(body: 'a.b.c.d.e')) + + result = jwe_strategy.send(:fetch_userinfo_attributes) + assert_equal({}, result) + end + + def test_fetch_userinfo_attributes_propagates_network_errors + mock_http_client = stub + mock_access_token = stub(http_client: mock_http_client) + mock_client = stub(userinfo_uri: 'https://oidc.example.com/userinfo') + + jwe_strategy.options.id_token_encryption_alg = 'RSA-OAEP' + jwe_strategy.stubs(:access_token).returns(mock_access_token) + jwe_strategy.stubs(:client).returns(mock_client) + mock_http_client.stubs(:get).raises(StandardError, 'connection failed') + + assert_raises(StandardError) do + jwe_strategy.send(:fetch_userinfo_attributes) + end + end + + def test_decrypt_dir_raises_on_wrong_key_length + strategy.options.id_token_encryption_alg = 'dir' + strategy.options.id_token_encryption_key = 'tooshort' + + symmetric_key = SecureRandom.bytes(32) + header = Base64.urlsafe_encode64({ alg: 'dir', enc: 'A128CBC-HS256' }.to_json, padding: false) + iv, ciphertext, auth_tag = encrypt_content('A128CBC-HS256', symmetric_key.b, 'payload', header) + jwe_token = [ + header, '', + Base64.urlsafe_encode64(iv, padding: false), + Base64.urlsafe_encode64(ciphertext, padding: false), + Base64.urlsafe_encode64(auth_tag, padding: false) + ].join('.') + + error = assert_raises(OmniAuth::Strategies::OpenIDConnect::CallbackError) do + strategy.send(:decrypt_jwe, jwe_token) + end + assert_includes error.error_reason, 'dir key must be 32 bytes' + end +end