diff --git a/lib/jwt/jwk.rb b/lib/jwt/jwk.rb index 7bef7430..04aee444 100644 --- a/lib/jwt/jwk.rb +++ b/lib/jwt/jwk.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true +require_relative 'jwk/key_abstract' require_relative 'jwk/rsa' +require_relative 'jwk/hmac' require_relative 'jwk/key_finder' module JWT module JWK MAPPINGS = { 'RSA' => ::JWT::JWK::RSA, - OpenSSL::PKey::RSA => ::JWT::JWK::RSA + OpenSSL::PKey::RSA => ::JWT::JWK::RSA, + 'oct' => ::JWT::JWK::HMAC, + String => ::JWT::JWK::HMAC }.freeze class << self diff --git a/lib/jwt/jwk/hmac.rb b/lib/jwt/jwk/hmac.rb new file mode 100644 index 00000000..8ba66e1a --- /dev/null +++ b/lib/jwt/jwk/hmac.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module JWT + module JWK + class HMAC < KeyAbstract + KTY = 'oct'.freeze + + def initialize(keypair, kid = nil) + raise ArgumentError, 'keypair must be of type String' unless keypair.is_a?(String) + + super + @kid = kid || generate_kid(@keypair) + end + + def private? + true + end + + def public_key + nil + end + + # See https://tools.ietf.org/html/rfc7517#appendix-A.3 + def export(options = {}) + ret = { + kty: KTY, + kid: kid + } + + return ret unless private? && options[:include_private] == true + + ret.merge( + k: keypair + ) + end + + private + + def generate_kid(hmac_key) + sequence = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::UTF8String.new(hmac_key), + OpenSSL::ASN1::UTF8String.new(KTY)]) + OpenSSL::Digest::SHA256.hexdigest(sequence.to_der) + end + + class << self + def import(jwk_data) + jwk_k = jwk_data[:k] || jwk_data['k'] + jwk_kid = jwk_data[:kid] || jwk_data['kid'] + + raise JWT::JWKError, 'Key format is invalid for HMAC' unless jwk_k + + self.new(jwk_k, jwk_kid) + end + end + end + end +end diff --git a/lib/jwt/jwk/key_abstract.rb b/lib/jwt/jwk/key_abstract.rb new file mode 100644 index 00000000..b9271469 --- /dev/null +++ b/lib/jwt/jwk/key_abstract.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module JWT + module JWK + class KeyAbstract + attr_reader :keypair, :kid + + def initialize(keypair, kid = nil) + @keypair = keypair + @kid = kid + end + + def private? + raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" + end + + def public_key + raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" + end + + def export(_options = {}) + raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" + end + + class << self + def import(_jwk_data) + raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" + end + end + end + end +end diff --git a/spec/jwk/hmac_spec.rb b/spec/jwk/hmac_spec.rb new file mode 100644 index 00000000..31ad4ee0 --- /dev/null +++ b/spec/jwk/hmac_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' +require 'jwt' + +describe JWT::JWK::HMAC do + let(:hmac_key) { 'secret-key' } + + describe '.new' do + subject { described_class.new(key) } + + context 'when a secret key given' do + let(:key) { hmac_key } + it 'creates an instance of the class' do + expect(subject).to be_a described_class + expect(subject.private?).to eq true + end + end + end + + describe '#export' do + let(:kid) { nil } + + context 'when key is exported' do + let(:key) { hmac_key } + subject { described_class.new(key, kid).export } + it 'returns a hash with the key' do + expect(subject).to be_a Hash + expect(subject).to include(:kty, :kid) + end + end + + context 'when key is exported with private key' do + let(:key) { hmac_key } + subject { described_class.new(key, kid).export(include_private: true) } + it 'returns a hash with the key' do + expect(subject).to be_a Hash + expect(subject).to include(:kty, :kid, :k) + end + end + end + + describe '.import' do + subject { described_class.import(params) } + let(:exported_key) { described_class.new(key).export(include_private: true) } + + context 'when secret key is given' do + let(:key) { hmac_key } + let(:params) { exported_key } + + it 'returns a key' do + expect(subject).to be_a described_class + expect(subject.export(include_private: true)).to eq(exported_key) + end + + context 'with a custom "kid" value' do + let(:exported_key) { + super().merge(kid: 'custom_key_identifier') + } + it 'imports that "kid" value' do + expect(subject.kid).to eq('custom_key_identifier') + end + end + end + end +end diff --git a/spec/jwk_spec.rb b/spec/jwk_spec.rb index 3ab5374f..12d85f89 100644 --- a/spec/jwk_spec.rb +++ b/spec/jwk_spec.rb @@ -43,11 +43,9 @@ it { is_expected.to be_a ::JWT::JWK::RSA } end - context 'when unsupported key is given' do - let(:keypair) { 'key' } - it 'raises an error' do - expect { subject }.to raise_error(::JWT::JWKError, 'Cannot create JWK from a String') - end + context 'when secret key is given' do + let(:keypair) { 'secret-key' } + it { is_expected.to be_a ::JWT::JWK::HMAC } end end end