Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request signing beta #1210

Merged
merged 15 commits into from
Apr 14, 2023
3 changes: 3 additions & 0 deletions lib/stripe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
require "socket"
require "uri"
require "forwardable"
require "base64"

# Version
require "stripe/api_version"
Expand Down Expand Up @@ -44,6 +45,7 @@
require "stripe/singleton_api_resource"
require "stripe/webhook"
require "stripe/stripe_configuration"
require "stripe/request_signing_authenticator"

# Named API resources
require "stripe/resources"
Expand All @@ -70,6 +72,7 @@ class << self

# User configurable options
def_delegators :@config, :api_key, :api_key=
def_delegators :@config, :authenticator, :authenticator=
def_delegators :@config, :api_version, :api_version=
def_delegators :@config, :stripe_account, :stripe_account=
def_delegators :@config, :api_base, :api_base=
Expand Down
79 changes: 79 additions & 0 deletions lib/stripe/request_signing_authenticator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

module Stripe
class RequestSigningAuthenticator
attr_reader :auth_token

def initialize(auth_token)
@auth_token = case auth_token
when String
auth_token
else
raise ArgumentError, "auth_token must be a string"
end
end

def authenticate(method, headers, body)
authorization_header_name = "Authorization"
content_type_header_name = "Content-Type"
stripe_context_header_name = "Stripe-Context"
stripe_account_header_name = "Stripe-Account"
content_digest_header_name = "Content-Digest"
signature_input_header_name = "Signature-Input"
signature_header_name = "Signature"

covered_headers = [stripe_context_header_name,
stripe_account_header_name,
authorization_header_name,]

headers[authorization_header_name] = "STRIPE-V2-SIG #{auth_token}"

if method != :get
covered_headers += [content_type_header_name,
content_digest_header_name,]
content = body || ""
headers[content_digest_header_name] =
%(sha-256=:#{content_digest(content)}:)
end

covered_headers_formatted = covered_headers
.map { |string| %("#{string.downcase}") }
.join(" ")

signature_input = "(#{covered_headers_formatted});created=#{created_time}"

inputs = covered_headers
.map { |header| %("#{header.downcase}": #{headers[header]}) }
.join("\n")

signature_base = %(#{inputs}\n"@signature-params": #{signature_input})
.encode(Encoding::UTF_8)

headers[signature_input_header_name] = "sig1=#{signature_input}"

headers[signature_header_name] =
"sig1=:#{encoded_signature(signature_base)}:"
end

# To be overriden by the user with their own signing implementation
private def sign(_signature_base)
raise NoMethodError, "`sign()` not implemented. Please override " \
"the `sign` method on Stripe::RequestSigningAuthenticator with your " \
"custom signing implementation."
end

private def encoded_signature(signature_base)
Base64.strict_encode64(sign(signature_base))
rescue StandardError
raise AuthenticationError, "Error calculating request signature."
end

private def content_digest(content)
Base64.strict_encode64(OpenSSL::Digest.new("SHA256").digest(content))
end

private def created_time
Time.now.to_i
end
end
end
19 changes: 16 additions & 3 deletions lib/stripe/stripe_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,10 @@ def self.maybe_gc_connection_managers

api_base ||= config.api_base
api_key ||= config.api_key
authenticator ||= config.authenticator
params = Util.objects_to_ids(params)

check_api_key!(api_key)
check_keys!(api_key, authenticator)

body_params = nil
query_params = nil
Expand All @@ -469,11 +470,14 @@ def self.maybe_gc_connection_managers
body, body_log =
body_params ? encode_body(body_params, headers) : [nil, nil]

authenticator.authenticate(method, headers, body) unless api_key

# stores information on the request we're about to make so that we don't
# have to pass as many parameters around for logging.
context = RequestLogContext.new
context.account = headers["Stripe-Account"]
context.api_key = api_key
context.authenticator = authenticator
context.api_version = headers["Stripe-Version"]
context.body = body_log
context.idempotency_key = headers["Idempotency-Key"]
Expand Down Expand Up @@ -512,8 +516,16 @@ def self.maybe_gc_connection_managers
(api_base || config.api_base) + url
end

private def check_api_key!(api_key)
unless api_key
private def check_keys!(api_key, authenticator)
if api_key && authenticator
raise AuthenticationError, "Can't specify both API key " \
"and authenticator. Either set your API key" \
'using "Stripe.api_key = <API-KEY>", or set your authenticator ' \
'using "Stripe.authenticator = <AUTHENTICATOR>"' \
end

unless api_key || authenticator
# Default to missing API key error message for general users.
raise AuthenticationError, "No API key provided. " \
'Set your API key using "Stripe.api_key = <API-KEY>". ' \
"You can generate API keys from the Stripe web interface. " \
Expand Down Expand Up @@ -966,6 +978,7 @@ class RequestLogContext
attr_accessor :body
attr_accessor :account
attr_accessor :api_key
attr_accessor :authenticator
attr_accessor :api_version
attr_accessor :idempotency_key
attr_accessor :method
Expand Down
1 change: 1 addition & 0 deletions lib/stripe/stripe_configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ module Stripe
class StripeConfiguration
attr_accessor :api_key
attr_accessor :api_version
attr_accessor :authenticator
attr_accessor :client_id
attr_accessor :enable_telemetry
attr_accessor :logger
Expand Down
9 changes: 8 additions & 1 deletion lib/stripe/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module Util
# Options that a user is allowed to specify.
OPTS_USER_SPECIFIED = Set[
:api_key,
:authenticator,
:idempotency_key,
:stripe_account,
:stripe_version
Expand Down Expand Up @@ -281,7 +282,13 @@ def self.normalize_opts(opts)
when String
{ api_key: opts }
when Hash
check_api_key!(opts.fetch(:api_key)) if opts.key?(:api_key)
# If the user is using request signing for authentication,
# no need to check the api_key per request.
if !(opts.key?(:client) &&
opts.fetch(:client).config.authenticator) &&
opts.key?(:api_key)
check_api_key!(opts.fetch(:api_key))
end
# Explicitly use dup here instead of clone to avoid preserving freeze
# state on input params.
opts.dup
Expand Down
13 changes: 13 additions & 0 deletions test/stripe/api_resource_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ class NestedTestAPIResource < APIResource

should "not specifying api credentials should raise an exception" do
Stripe.api_key = nil
Stripe.authenticator = nil
assert_raises Stripe::AuthenticationError do
Stripe::Customer.new("cus_123").refresh
end
end

should "specifying both api_key and authenticator should raise an exception" do
Stripe.api_key = "sk_123"

def no_op; end

Stripe.authenticator = method(:no_op)

assert_raises Stripe::AuthenticationError do
Stripe::Customer.new("cus_123").refresh
end
Expand Down
49 changes: 49 additions & 0 deletions test/stripe/stripe_client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,55 @@ class StripeClientTest < Test::Unit::TestCase
end
end

context "signing headers" do
setup do
RequestSigningAuthenticator.any_instance.stubs(:content_digest).returns("digest")
RequestSigningAuthenticator.any_instance.stubs(:created_time).returns(1_234_567_890)
RequestSigningAuthenticator.any_instance.stubs(:encoded_signature).returns("signature")

Stripe.api_key = nil
Stripe.authenticator = RequestSigningAuthenticator.new("keyinfo_test_123")
end

should "apply valid signing headers for get requests" do
stub_request(:get, "#{Stripe.api_base}/v1/charges/ch_123")
.to_return(body: JSON.generate(object: "charge"))

client = StripeClient.new
client.send(request_method, :get, "/v1/charges/ch_123",
&@read_body_chunk_block)
assert_requested(
:get,
"#{Stripe.api_base}/v1/charges/ch_123",
headers: {
"Authorization" => "STRIPE-V2-SIG keyinfo_test_123",
"Signature" => "sig1=:signature:",
"Signature-Input" => 'sig1=("stripe-context" "stripe-account" "authorization");created=1234567890',
}
)
end

should "apply valid signing headers for post requests" do
stub_request(:post, "#{Stripe.api_base}/v1/charges")
.to_return(body: JSON.generate(object: "charge"))

client = StripeClient.new
client.send(request_method, :post, "/v1/charges",
&@read_body_chunk_block)

assert_requested(
:post,
"#{Stripe.api_base}/v1/charges",
headers: {
"Authorization" => "STRIPE-V2-SIG keyinfo_test_123",
"Content-Digest" => "sha-256=:digest:",
"Signature" => "sig1=:signature:",
"Signature-Input" => 'sig1=("stripe-context" "stripe-account" "authorization" "content-type" "content-digest");created=1234567890',
}
)
end
end

context "logging" do
setup do
# Freeze time for the purposes of the `elapsed` parameter that we
Expand Down
2 changes: 2 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class TestCase
Stripe.api_key = "sk_test_123"
Stripe.api_base = "http://localhost:#{MOCK_PORT}"

Stripe.authenticator = nil

stub_connect
end

Expand Down