├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── .yardopts ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── simple_oauth.rb └── simple_oauth │ ├── header.rb │ └── version.rb ├── simple_oauth.gemspec └── spec ├── helper.rb ├── simple_oauth └── header_spec.rb └── support ├── fixtures └── rsa-private-key └── rsa.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - "3.0" 18 | - "3.1" 19 | - "3.2" 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - name: Run the default task 29 | run: bundle exec rake 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --order random 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - standard 3 | - standard-performance 4 | - rubocop-rspec 5 | - rubocop-performance 6 | - rubocop-rake 7 | 8 | AllCops: 9 | NewCops: enable 10 | TargetRubyVersion: 3.0 11 | 12 | Layout/ArgumentAlignment: 13 | Enabled: true 14 | EnforcedStyle: with_fixed_indentation 15 | 16 | Layout/ArrayAlignment: 17 | Enabled: true 18 | EnforcedStyle: with_fixed_indentation 19 | 20 | Layout/EndAlignment: 21 | Enabled: true 22 | EnforcedStyleAlignWith: variable 23 | 24 | Layout/HashAlignment: 25 | Enabled: true 26 | EnforcedHashRocketStyle: key 27 | EnforcedColonStyle: key 28 | EnforcedLastArgumentHashStyle: always_inspect 29 | 30 | Layout/LineLength: 31 | Enabled: false 32 | 33 | Layout/ParameterAlignment: 34 | Enabled: true 35 | EnforcedStyle: with_fixed_indentation 36 | IndentationWidth: ~ 37 | 38 | Layout/SpaceInsideHashLiteralBraces: 39 | Enabled: false 40 | 41 | Metrics/ParameterLists: 42 | CountKeywordArgs: false 43 | 44 | RSpec/MultipleExpectations: 45 | Enabled: false 46 | 47 | RSpec/ExampleLength: 48 | Enabled: false 49 | 50 | RSpec/MessageSpies: 51 | Enabled: false 52 | 53 | RSpec/PendingWithoutReason: 54 | Enabled: false 55 | 56 | RSpec/FilePath: 57 | Enabled: false 58 | 59 | Style/Alias: 60 | Enabled: true 61 | EnforcedStyle: prefer_alias_method 62 | 63 | Style/FrozenStringLiteralComment: 64 | Enabled: false 65 | 66 | Style/StringLiterals: 67 | Enabled: true 68 | EnforcedStyle: double_quotes 69 | 70 | Style/StringLiteralsInInterpolation: 71 | Enabled: true 72 | EnforcedStyle: double_quotes 73 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | env: 3 | global: 4 | - JRUBY_OPTS="$JRUBY_OPTS --debug" 5 | rvm: 6 | - 1.8.7 7 | - 1.9.3 8 | - 2.0.0 9 | - 2.1 10 | - jruby-18mode 11 | - jruby-19mode 12 | - jruby-head 13 | - rbx-2 14 | - ruby-head 15 | matrix: 16 | allow_failures: 17 | - rvm: jruby-head 18 | - rvm: rbx-2 19 | - rvm: ruby-head 20 | fast_finish: true 21 | sudo: false 22 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | - 3 | CONTRIBUTING.md 4 | LICENSE.md 5 | README.md 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.4.0] - 2023-08-10 4 | 5 | - Update 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 1. Fork the project. 3 | 2. Create a topic branch. 4 | 3. Add failing tests. 5 | 4. Add code to pass the failing tests. 6 | 5. Run `bundle exec rake`. If failing, repeat step 4. 7 | 6. Commit and push your changes. 8 | 7. Submit a pull request. Please do not include changes to the gemspec. 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in simple_oauth.gemspec 4 | gemspec 5 | 6 | gem "rake", ">= 13.0.6" 7 | gem "rspec", ">= 3.12" 8 | gem "rubocop", ">= 1.21" 9 | gem "rubocop-performance", ">= 1.18" 10 | gem "rubocop-rake", ">= 0.6" 11 | gem "rubocop-rspec", ">= 0.31" 12 | gem "simplecov", ">= 0.22" 13 | gem "standard", ">= 1.30.1" 14 | gem "webmock", ">= 3.18.1" 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2010-2023 Steve Richert, Erik Michaels-Ober 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simple_oauth 2 | 3 | Simply builds and verifies OAuth headers 4 | 5 | ## Installation 6 | 7 | Install the gem and add to the application's Gemfile by executing: 8 | 9 | $ bundle add simple_oauth 10 | 11 | If bundler is not being used to manage dependencies, install the gem by executing: 12 | 13 | $ gem install simple_oauth 14 | 15 | 16 | ## Contributing 17 | 18 | Bug reports and pull requests are welcome on GitHub at https://github.com/laserlemon/simple_oauth. 19 | 20 | This project conforms to [Standard Ruby](https://github.com/standardrb/standard). Patches that don’t maintain that standard will not be accepted. 21 | 22 | ## License 23 | 24 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 25 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require "rubocop/rake_task" 4 | require "standard/rake" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | RuboCop::RakeTask.new 9 | 10 | task default: %i[spec rubocop standard] 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "simple_oauth" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require "irb" 10 | IRB.start(__FILE__) 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/simple_oauth.rb: -------------------------------------------------------------------------------- 1 | require_relative "simple_oauth/header" 2 | require_relative "simple_oauth/version" 3 | -------------------------------------------------------------------------------- /lib/simple_oauth/header.rb: -------------------------------------------------------------------------------- 1 | require "openssl" 2 | require "uri" 3 | require "base64" 4 | require "cgi" 5 | 6 | module SimpleOAuth 7 | # Generates OAuth header for HTTP request 8 | class Header 9 | ATTRIBUTE_KEYS = %i[callback consumer_key nonce signature_method timestamp token verifier version].freeze unless defined? ::SimpleOAuth::Header::ATTRIBUTE_KEYS 10 | 11 | IGNORED_KEYS = %i[consumer_secret token_secret signature].freeze unless defined? ::SimpleOAuth::Header::IGNORED_KEYS 12 | 13 | attr_reader :method, :params, :options 14 | 15 | class << self 16 | def default_options 17 | { 18 | nonce: OpenSSL::Random.random_bytes(16).unpack1("H*"), 19 | signature_method: "HMAC-SHA1", 20 | timestamp: Time.now.to_i.to_s, 21 | version: "1.0" 22 | } 23 | end 24 | 25 | def parse(header) 26 | header.to_s.sub(/^OAuth\s/, "").split(/,\s*/).inject({}) do |attributes, pair| 27 | match = pair.match(/^(\w+)="([^"]*)"$/) 28 | attributes.merge(match[1].sub(/^oauth_/, "").to_sym => unescape(match[2])) 29 | end 30 | end 31 | 32 | def escape(value) 33 | uri_parser.escape(value.to_s, /[^a-z0-9\-._~]/i) 34 | end 35 | alias_method :encode, :escape 36 | 37 | def unescape(value) 38 | uri_parser.unescape(value.to_s) 39 | end 40 | alias_method :decode, :unescape 41 | 42 | private 43 | 44 | def uri_parser 45 | @uri_parser ||= URI.const_defined?(:Parser) ? URI::DEFAULT_PARSER : URI 46 | end 47 | end 48 | 49 | def initialize(method, url, params, oauth = {}) 50 | @method = method.to_s.upcase 51 | @uri = URI.parse(url.to_s) 52 | @uri.scheme = @uri.scheme.downcase 53 | @uri.normalize! 54 | @uri.fragment = nil 55 | @params = params 56 | @options = oauth.is_a?(Hash) ? self.class.default_options.merge(oauth) : self.class.parse(oauth) 57 | end 58 | 59 | def url 60 | uri = @uri.dup 61 | uri.query = nil 62 | uri.to_s 63 | end 64 | 65 | def to_s 66 | "OAuth #{normalized_attributes}" 67 | end 68 | 69 | def valid?(secrets = {}) 70 | original_options = options.dup 71 | options.merge!(secrets) 72 | valid = options[:signature] == signature 73 | options.replace(original_options) 74 | valid 75 | end 76 | 77 | def signed_attributes 78 | attributes.merge(oauth_signature: signature) 79 | end 80 | 81 | private 82 | 83 | def normalized_attributes 84 | signed_attributes.sort_by { |k, _| k.to_s }.collect { |k, v| %(#{k}="#{self.class.escape(v)}") }.join(", ") 85 | end 86 | 87 | def attributes 88 | matching_keys, extra_keys = options.keys.partition { |key| ATTRIBUTE_KEYS.include?(key) } 89 | extra_keys -= IGNORED_KEYS 90 | raise "SimpleOAuth: Found extra option keys not matching ATTRIBUTE_KEYS:\n [#{extra_keys.collect(&:inspect).join(", ")}]" unless options[:ignore_extra_keys] || extra_keys.empty? 91 | 92 | options.select { |key, _| matching_keys.include?(key) }.transform_keys { |key| :"oauth_#{key}" } 93 | end 94 | 95 | def signature 96 | send("#{options[:signature_method].downcase.tr("-", "_")}_signature") 97 | end 98 | 99 | def hmac_sha1_signature 100 | Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA1"), secret, signature_base)).chomp.delete("\n") 101 | end 102 | 103 | def secret 104 | options.values_at(:consumer_secret, :token_secret).collect { |v| self.class.escape(v) }.join("&") 105 | end 106 | alias_method :plaintext_signature, :secret 107 | 108 | def signature_base 109 | [method, url, normalized_params].collect { |v| self.class.escape(v) }.join("&") 110 | end 111 | 112 | def normalized_params 113 | signature_params.collect { |p| p.collect { |v| self.class.escape(v) } }.sort.collect { |p| p.join("=") }.join("&") 114 | end 115 | 116 | def signature_params 117 | attributes.to_a + params.to_a + url_params 118 | end 119 | 120 | def url_params 121 | CGI.parse(@uri.query || "").inject([]) { |p, (k, vs)| p + vs.sort.collect { |v| [k, v] } } 122 | end 123 | 124 | def rsa_sha1_signature 125 | Base64.encode64(private_key.sign(OpenSSL::Digest.new("SHA1"), signature_base)).chomp.delete("\n") 126 | end 127 | 128 | def private_key 129 | OpenSSL::PKey::RSA.new(options[:consumer_secret]) 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/simple_oauth/version.rb: -------------------------------------------------------------------------------- 1 | module SimpleOauth 2 | VERSION = "0.1.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /simple_oauth.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/simple_oauth/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "simple_oauth" 5 | spec.version = SimpleOauth::VERSION 6 | spec.authors = ["Steve Richert", "Erik Berlin"] 7 | spec.email = ["steve.richert@gmail.com", "sferik@gmail.com"] 8 | 9 | spec.summary = "Simply builds and verifies OAuth headers" 10 | spec.description = spec.summary 11 | spec.homepage = "https://github.com/laserlemon/simple_oauth" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = ">= 3.0" 14 | 15 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = "https://github.com/laserlemon/simple_oauth" 19 | spec.metadata["changelog_uri"] = "https://github.com/laserlemon/simple_oauth/blob/master/CHANGELOG.md" 20 | 21 | # Specify which files should be added to the gem when it is released. 22 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 23 | spec.files = Dir.chdir(__dir__) do 24 | `git ls-files -z`.split("\x0").reject do |f| 25 | (File.expand_path(f) == __FILE__) || 26 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]) 27 | end 28 | end 29 | spec.bindir = "exe" 30 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 31 | spec.require_paths = ["lib"] 32 | spec.metadata["rubygems_mfa_required"] = "true" 33 | end 34 | -------------------------------------------------------------------------------- /spec/helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | 3 | require "simplecov" 4 | 5 | SimpleCov.start do 6 | add_filter "/spec/" 7 | minimum_coverage(100) 8 | end 9 | 10 | require "rspec" 11 | require "simple_oauth" 12 | 13 | def uri_parser 14 | @uri_parser ||= URI.const_defined?(:Parser) ? URI::DEFAULT_PARSER : URI 15 | end 16 | 17 | RSpec.configure do |config| 18 | config.expect_with :rspec do |c| 19 | c.syntax = :expect 20 | end 21 | end 22 | 23 | Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require f } 24 | -------------------------------------------------------------------------------- /spec/simple_oauth/header_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe SimpleOAuth::Header do 4 | describe ".default_options" do 5 | let(:default_options) { described_class.default_options } 6 | 7 | it "is different every time" do 8 | expect(described_class.default_options).not_to eq default_options 9 | end 10 | 11 | it "is used for new headers" do 12 | allow(described_class).to receive(:default_options).and_return(default_options) 13 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friendships.json", {}) 14 | expect(header.options).to eq default_options 15 | end 16 | 17 | it "includes a signature method and an OAuth version" do 18 | expect(default_options[:signature_method]).not_to be_nil 19 | expect(default_options[:version]).not_to be_nil 20 | end 21 | end 22 | 23 | describe ".escape" do 24 | it "escapes (most) non-word characters" do 25 | [" ", "!", "@", "#", "$", "%", "^", "&"].each do |character| 26 | escaped = described_class.escape(character) 27 | expect(escaped).not_to eq character 28 | expect(escaped).to eq uri_parser.escape(character, /.*/) 29 | end 30 | end 31 | 32 | it "does not escape - . or ~" do 33 | ["-", ".", "~"].each do |character| 34 | escaped = described_class.escape(character) 35 | expect(escaped).to eq character 36 | end 37 | end 38 | 39 | it "escapes non-ASCII characters" do 40 | expect(described_class.escape("é")).to eq "%C3%A9" 41 | end 42 | 43 | it "escapes multibyte characters" do 44 | expect(described_class.escape("あ")).to eq "%E3%81%82" 45 | end 46 | end 47 | 48 | describe ".unescape" do 49 | pending 50 | end 51 | 52 | describe ".parse" do 53 | let(:header) { described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}) } 54 | let(:parsed_options) { described_class.parse(header) } 55 | 56 | it "returns a hash" do 57 | expect(parsed_options).to be_a(Hash) 58 | end 59 | 60 | it "includes the options used to build the header" do 61 | expect(parsed_options.except(:signature)).to eq header.options 62 | end 63 | 64 | it "includes a signature" do 65 | expect(header.options).not_to have_key(:signature) 66 | expect(parsed_options).to have_key(:signature) 67 | expect(parsed_options[:signature]).not_to be_nil 68 | end 69 | 70 | it "handles optional 'linear white space'" do 71 | parsed_header_with_spaces = described_class.parse 'OAuth oauth_consumer_key="abcd", oauth_nonce="oLKtec51GQy", oauth_signature="efgh%26mnop", oauth_signature_method="PLAINTEXT", oauth_timestamp="1286977095", oauth_token="ijkl", oauth_version="1.0"' 72 | expect(parsed_header_with_spaces).to be_a(Hash) 73 | expect(parsed_header_with_spaces.keys.size).to eq 7 74 | 75 | parsed_header_with_tabs = described_class.parse 'OAuth oauth_consumer_key="abcd", oauth_nonce="oLKtec51GQy", oauth_signature="efgh%26mnop", oauth_signature_method="PLAINTEXT", oauth_timestamp="1286977095", oauth_token="ijkl", oauth_version="1.0"' 76 | expect(parsed_header_with_tabs).to be_a(Hash) 77 | expect(parsed_header_with_tabs.keys.size).to eq 7 78 | 79 | parsed_header_with_spaces_and_tabs = described_class.parse 'OAuth oauth_consumer_key="abcd", oauth_nonce="oLKtec51GQy", oauth_signature="efgh%26mnop", oauth_signature_method="PLAINTEXT", oauth_timestamp="1286977095", oauth_token="ijkl", oauth_version="1.0"' 80 | expect(parsed_header_with_spaces_and_tabs).to be_a(Hash) 81 | expect(parsed_header_with_spaces_and_tabs.keys.size).to eq 7 82 | 83 | parsed_header_without_spaces = described_class.parse 'OAuth oauth_consumer_key="abcd",oauth_nonce="oLKtec51GQy",oauth_signature="efgh%26mnop",oauth_signature_method="PLAINTEXT",oauth_timestamp="1286977095",oauth_token="ijkl",oauth_version="1.0"' 84 | expect(parsed_header_without_spaces).to be_a(Hash) 85 | expect(parsed_header_without_spaces.keys.size).to eq 7 86 | end 87 | end 88 | 89 | describe "#initialize" do 90 | let(:header) do 91 | described_class.new(:get, "HTTPS://api.TWITTER.com:443/1/statuses/friendships.json?foo=bar#anchor", {}) 92 | end 93 | 94 | it "stringifies and uppercases the request method" do 95 | expect(header.method).to eq "GET" 96 | end 97 | 98 | it "downcases the scheme and authority" do 99 | expect(header.url).to match %r{^https://api\.twitter\.com/} 100 | end 101 | 102 | it "ignores the query and fragment" do 103 | expect(header.url).to match %r{/1/statuses/friendships\.json$} 104 | end 105 | end 106 | 107 | describe "#valid?" do 108 | context "when using the HMAC-SHA1 signature method" do 109 | it "requires consumer and token secrets" do 110 | secrets = {consumer_secret: "CONSUMER_SECRET", token_secret: "TOKEN_SECRET"} 111 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, secrets) 112 | parsed_header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, header) 113 | expect(parsed_header).not_to be_valid 114 | expect(parsed_header).to be_valid(secrets) 115 | end 116 | end 117 | 118 | context "when using the RSA-SHA1 signature method" do 119 | it "requires an identical private key" do 120 | secrets = {consumer_secret: rsa_private_key} 121 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, 122 | secrets.merge(signature_method: "RSA-SHA1")) 123 | parsed_header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, header) 124 | expect { parsed_header.valid? }.to raise_error(TypeError) 125 | expect(parsed_header).to be_valid(secrets) 126 | end 127 | end 128 | 129 | context "when using the PLAINTEXT signature method" do 130 | it "requires consumer and token secrets" do 131 | secrets = {consumer_secret: "CONSUMER_SECRET", token_secret: "TOKEN_SECRET"} 132 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, 133 | secrets.merge(signature_method: "PLAINTEXT")) 134 | parsed_header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, header) 135 | expect(parsed_header).not_to be_valid 136 | expect(parsed_header).to be_valid(secrets) 137 | end 138 | end 139 | end 140 | 141 | describe "#normalized_attributes" do 142 | let(:header) { described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}) } 143 | let(:normalized_attributes) { header.send(:normalized_attributes) } 144 | 145 | it "returns a sorted-key, quoted-value and comma-separated list" do 146 | allow(header).to receive(:signed_attributes).and_return(d: 1, c: 2, b: 3, a: 4) 147 | expect(normalized_attributes).to eq 'a="4", b="3", c="2", d="1"' 148 | end 149 | 150 | it "URI encodes its values" do 151 | allow(header).to receive(:signed_attributes).and_return(1 => "!", 2 => "@", 3 => "#", 4 => "$") 152 | expect(normalized_attributes).to eq '1="%21", 2="%40", 3="%23", 4="%24"' 153 | end 154 | end 155 | 156 | describe "#signed_attributes" do 157 | it "includes the OAuth signature" do 158 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}) 159 | expect(header.send(:signed_attributes)).to have_key(:oauth_signature) 160 | end 161 | end 162 | 163 | describe "#attributes" do 164 | let(:header) do 165 | options = {} 166 | SimpleOAuth::Header::ATTRIBUTE_KEYS.each { |k| options[k] = k.to_s.upcase } 167 | options[:other] = "OTHER" 168 | described_class.new(:get, "https://api.twitter.com/1/statuses/friendships.json", {}, options) 169 | end 170 | 171 | it "prepends keys with 'oauth_'" do 172 | header.options[:ignore_extra_keys] = true 173 | expect(header.send(:attributes).keys).to(be_all { |k| k.to_s =~ /^oauth_/ }) 174 | end 175 | 176 | it "excludes keys not included in the list of valid attributes" do 177 | header.options[:ignore_extra_keys] = true 178 | expect(header.send(:attributes).keys).to(be_all { |k| k.is_a?(Symbol) }) 179 | expect(header.send(:attributes)).not_to have_key(:oauth_other) 180 | end 181 | 182 | it "preserves values for valid keys" do 183 | header.options[:ignore_extra_keys] = true 184 | expect(header.send(:attributes).size).to eq SimpleOAuth::Header::ATTRIBUTE_KEYS.size 185 | expect(header.send(:attributes)).to(be_all { |k, v| k.to_s == "oauth_#{v.downcase}" }) 186 | end 187 | 188 | it "raises exception for extra keys" do 189 | expect do 190 | header.send(:attributes) 191 | end.to raise_error(RuntimeError, 192 | "SimpleOAuth: Found extra option keys not matching ATTRIBUTE_KEYS:\n [:other]") 193 | end 194 | end 195 | 196 | describe "#signature" do 197 | specify "when using HMAC-SHA1" do 198 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, signature_method: "HMAC-SHA1") 199 | expect(header).to receive(:hmac_sha1_signature).once.and_return("HMAC_SHA1_SIGNATURE") 200 | expect(header.send(:signature)).to eq "HMAC_SHA1_SIGNATURE" 201 | end 202 | 203 | specify "when using RSA-SHA1" do 204 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, signature_method: "RSA-SHA1") 205 | expect(header).to receive(:rsa_sha1_signature).once.and_return("RSA_SHA1_SIGNATURE") 206 | expect(header.send(:signature)).to eq "RSA_SHA1_SIGNATURE" 207 | end 208 | 209 | specify "when using PLAINTEXT" do 210 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, signature_method: "PLAINTEXT") 211 | expect(header).to receive(:plaintext_signature).once.and_return("PLAINTEXT_SIGNATURE") 212 | expect(header.send(:signature)).to eq "PLAINTEXT_SIGNATURE" 213 | end 214 | end 215 | 216 | describe "#hmac_sha1_signature" do 217 | it "reproduces a successful Twitter GET" do 218 | options = { 219 | consumer_key: "8karQBlMg6gFOwcf8kcoYw", 220 | consumer_secret: "3d0vcHyUiiqADpWxolW8nlDIpSWMlyK7YNgc5Qna2M", 221 | nonce: "547fed103e122eecf84c080843eedfe6", 222 | signature_method: "HMAC-SHA1", 223 | timestamp: "1286830180", 224 | token: "201425800-Sv4sTcgoffmHGkTCue0JnURT8vrm4DiFAkeFNDkh", 225 | token_secret: "T5qa1tF57tfDzKmpM89DHsNuhgOY4NT6DlNLsTFcuQ" 226 | } 227 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friends.json", {}, options) 228 | expect(header.to_s).to eq 'OAuth oauth_consumer_key="8karQBlMg6gFOwcf8kcoYw", oauth_nonce="547fed103e122eecf84c080843eedfe6", oauth_signature="i9CT6ahDRAlfGX3hKYf78QzXsaw%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1286830180", oauth_token="201425800-Sv4sTcgoffmHGkTCue0JnURT8vrm4DiFAkeFNDkh", oauth_version="1.0"' 229 | end 230 | 231 | it "reproduces a successful Twitter POST" do 232 | options = { 233 | consumer_key: "8karQBlMg6gFOwcf8kcoYw", 234 | consumer_secret: "3d0vcHyUiiqADpWxolW8nlDIpSWMlyK7YNgc5Qna2M", 235 | nonce: "b40a3e0f18590ecdcc0e273f7d7c82f8", 236 | signature_method: "HMAC-SHA1", 237 | timestamp: "1286830181", 238 | token: "201425800-Sv4sTcgoffmHGkTCue0JnURT8vrm4DiFAkeFNDkh", 239 | token_secret: "T5qa1tF57tfDzKmpM89DHsNuhgOY4NT6DlNLsTFcuQ" 240 | } 241 | header = described_class.new(:post, "https://api.twitter.com/1/statuses/update.json", 242 | {status: "hi, again"}, options) 243 | expect(header.to_s).to eq 'OAuth oauth_consumer_key="8karQBlMg6gFOwcf8kcoYw", oauth_nonce="b40a3e0f18590ecdcc0e273f7d7c82f8", oauth_signature="mPqSFKejrWWk3ZT9bTQjhO5b2xI%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1286830181", oauth_token="201425800-Sv4sTcgoffmHGkTCue0JnURT8vrm4DiFAkeFNDkh", oauth_version="1.0"' 244 | end 245 | end 246 | 247 | describe "#secret" do 248 | let(:header) { described_class.new(:get, "https://api.twitter.com/1/statuses/friendships.json", {}) } 249 | let(:secret) { header.send(:secret) } 250 | 251 | it "combines the consumer and token secrets with an ampersand" do 252 | allow(header).to receive(:options).and_return(consumer_secret: "CONSUMER_SECRET", 253 | token_secret: "TOKEN_SECRET") 254 | expect(secret).to eq "CONSUMER_SECRET&TOKEN_SECRET" 255 | end 256 | 257 | it "URI encodes each secret value before combination" do 258 | allow(header).to receive(:options).and_return(consumer_secret: "CONSUM#R_SECRET", 259 | token_secret: "TOKEN_S#CRET") 260 | expect(secret).to eq "CONSUM%23R_SECRET&TOKEN_S%23CRET" 261 | end 262 | end 263 | 264 | describe "#signature_base" do 265 | let(:header) { described_class.new(:get, "https://api.twitter.com/1/statuses/friendships.json", {}) } 266 | let(:signature_base) { header.send(:signature_base) } 267 | 268 | it "combines the request method, URL and normalized parameters using ampersands" do 269 | allow(header).to receive_messages(method: "METHOD", url: "URL", normalized_params: "NORMALIZED_PARAMS") 270 | expect(signature_base).to eq "METHOD&URL&NORMALIZED_PARAMS" 271 | end 272 | 273 | it "URI encodes each value before combination" do 274 | allow(header).to receive_messages(method: "ME#HOD", url: "U#L", normalized_params: "NORMAL#ZED_PARAMS") 275 | expect(signature_base).to eq "ME%23HOD&U%23L&NORMAL%23ZED_PARAMS" 276 | end 277 | end 278 | 279 | describe "#normalized_params" do 280 | let(:header) do 281 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friendships.json", {}) 282 | allow(header).to receive(:signature_params).and_return([%w[A 4], %w[B 3], %w[B 2], %w[C 1], ["D[]", "0 "]]) 283 | header 284 | end 285 | let(:signature_params) { header.send(:signature_params) } 286 | let(:normalized_params) { header.send(:normalized_params) } 287 | 288 | it "joins key/value pairs with equal signs and ampersands" do 289 | expect(normalized_params).to be_a(String) 290 | parts = normalized_params.split("&") 291 | expect(parts.size).to eq signature_params.size 292 | pairs = parts.collect { |p| p.split("=") } 293 | expect(pairs).to(be_all { |p| p.size == 2 }) 294 | end 295 | end 296 | 297 | describe "#signature_params" do 298 | let(:header) { described_class.new(:get, "https://api.twitter.com/1/statuses/friendships.json", {}) } 299 | let(:signature_params) { header.send(:signature_params) } 300 | 301 | it "combines OAuth header attributes, body parameters and URL parameters into an flattened array of key/value pairs" do 302 | allow(header).to receive_messages(attributes: {attribute: "ATTRIBUTE"}, params: {"param" => "PARAM"}, 303 | url_params: [%w[url_param 1], %w[url_param 2]]) 304 | expect(signature_params).to eq [ 305 | [:attribute, "ATTRIBUTE"], 306 | %w[param PARAM], 307 | %w[url_param 1], 308 | %w[url_param 2] 309 | ] 310 | end 311 | end 312 | 313 | describe "#url_params" do 314 | it "returns an empty array when the URL has no query parameters" do 315 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friendships.json", {}) 316 | expect(header.send(:url_params)).to eq [] 317 | end 318 | 319 | it "returns an array of key/value pairs for each query parameter" do 320 | header = described_class.new(:get, "https://api.twitter.com/1/statuses/friendships.json?test=TEST", {}) 321 | expect(header.send(:url_params)).to eq [%w[test TEST]] 322 | end 323 | 324 | it "sorts values for repeated keys" do 325 | header = described_class.new(:get, 326 | "https://api.twitter.com/1/statuses/friendships.json?test=3&test=1&test=2", {}) 327 | expect(header.send(:url_params)).to eq [%w[test 1], %w[test 2], %w[test 3]] 328 | end 329 | end 330 | 331 | describe "#rsa_sha1_signature" do 332 | it "reproduces a successful OAuth example GET" do 333 | options = { 334 | consumer_key: "dpf43f3p2l4k3l03", 335 | consumer_secret: rsa_private_key, 336 | nonce: "13917289812797014437", 337 | signature_method: "RSA-SHA1", 338 | timestamp: "1196666512" 339 | } 340 | header = described_class.new(:get, "http://photos.example.net/photos", 341 | {file: "vacaction.jpg", size: "original"}, options) 342 | expect(header.to_s).to eq 'OAuth oauth_consumer_key="dpf43f3p2l4k3l03", oauth_nonce="13917289812797014437", oauth_signature="jvTp%2FwX1TYtByB1m%2BPbyo0lnCOLIsyGCH7wke8AUs3BpnwZJtAuEJkvQL2%2F9n4s5wUmUl4aCI4BwpraNx4RtEXMe5qg5T1LVTGliMRpKasKsW%2F%2Fe%2BRinhejgCuzoH26dyF8iY2ZZ%2F5D1ilgeijhV%2FvBka5twt399mXwaYdCwFYE%3D", oauth_signature_method="RSA-SHA1", oauth_timestamp="1196666512", oauth_version="1.0"' 343 | end 344 | end 345 | 346 | describe "#private_key" do 347 | pending 348 | end 349 | 350 | describe "#plaintext_signature" do 351 | it "reproduces a successful OAuth example GET" do 352 | options = { 353 | consumer_key: "abcd", 354 | consumer_secret: "efgh", 355 | nonce: "oLKtec51GQy", 356 | signature_method: "PLAINTEXT", 357 | timestamp: "1286977095", 358 | token: "ijkl", 359 | token_secret: "mnop" 360 | } 361 | header = described_class.new(:get, "http://host.net/resource?name=value", {name: "value"}, options) 362 | expect(header.to_s).to eq 'OAuth oauth_consumer_key="abcd", oauth_nonce="oLKtec51GQy", oauth_signature="efgh%26mnop", oauth_signature_method="PLAINTEXT", oauth_timestamp="1286977095", oauth_token="ijkl", oauth_version="1.0"' 363 | end 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /spec/support/fixtures/rsa-private-key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALRiMLAh9iimur8V 3 | A7qVvdqxevEuUkW4K+2KdMXmnQbG9Aa7k7eBjK1S+0LYmVjPKlJGNXHDGuy5Fw/d 4 | 7rjVJ0BLB+ubPK8iA/Tw3hLQgXMRRGRXXCn8ikfuQfjUS1uZSatdLB81mydBETlJ 5 | hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtleUzavkbrPjy0T5FMou8H 6 | X9u2AC2ry8vD/l7cqedtwMPp9k7TubgNFo+NGvKsl2ynyprOZR1xjQ7WgrgVB+mm 7 | uScOM/5HVceFuGRDhYTCObE+y1kxRloNYXnx3ei1zbeYLPCHdhxRYW7T0qcynNmw 8 | rn05/KO2RLjgQNalsQJBANeA3Q4Nugqy4QBUCEC09SqylT2K9FrrItqL2QKc9v0Z 9 | zO2uwllCbg0dwpVuYPYXYvikNHHg+aCWF+VXsb9rpPsCQQDWR9TT4ORdzoj+Nccn 10 | qkMsDmzt0EfNaAOwHOmVJ2RVBspPcxt5iN4HI7HNeG6U5YsFBb+/GZbgfBT3kpNG 11 | WPTpAkBI+gFhjfJvRw38n3g/+UeAkwMI2TJQS4n8+hid0uus3/zOjDySH3XHCUno 12 | cn1xOJAyZODBo47E+67R4jV1/gzbAkEAklJaspRPXP877NssM5nAZMU0/O/NGCZ+ 13 | 3jPgDUno6WbJn5cqm8MqWhW1xGkImgRk+fkDBquiq4gPiT898jusgQJAd5Zrr6Q8 14 | AO/0isr/3aa6O6NLQxISLKcPDk2NOccAfS/xOtfOz4sJYM3+Bs4Io9+dZGSDCA54 15 | Lw03eHTNQghS0A== 16 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /spec/support/rsa.rb: -------------------------------------------------------------------------------- 1 | module RSAHelpers 2 | PRIVATE_KEY_PATH = File.expand_path("fixtures/rsa-private-key", __dir__) 3 | 4 | def rsa_private_key 5 | @rsa_private_key ||= File.read(PRIVATE_KEY_PATH) 6 | end 7 | end 8 | 9 | RSpec.configure do |config| 10 | config.include RSAHelpers 11 | end 12 | --------------------------------------------------------------------------------