candy_check 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +5 -0
  3. data/.rubocop.yml +6 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +16 -0
  6. data/Gemfile +4 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +157 -0
  9. data/Rakefile +15 -0
  10. data/bin/cc_appstore +90 -0
  11. data/bin/cc_playstore +119 -0
  12. data/candy_check.gemspec +31 -0
  13. data/lib/candy_check/app_store/client.rb +57 -0
  14. data/lib/candy_check/app_store/config.rb +30 -0
  15. data/lib/candy_check/app_store/receipt.rb +83 -0
  16. data/lib/candy_check/app_store/verification.rb +49 -0
  17. data/lib/candy_check/app_store/verification_failure.rb +60 -0
  18. data/lib/candy_check/app_store/verifier.rb +69 -0
  19. data/lib/candy_check/app_store.rb +12 -0
  20. data/lib/candy_check/play_store/client.rb +102 -0
  21. data/lib/candy_check/play_store/config.rb +51 -0
  22. data/lib/candy_check/play_store/discovery_repository.rb +33 -0
  23. data/lib/candy_check/play_store/receipt.rb +81 -0
  24. data/lib/candy_check/play_store/verification.rb +46 -0
  25. data/lib/candy_check/play_store/verification_failure.rb +30 -0
  26. data/lib/candy_check/play_store/verifier.rb +52 -0
  27. data/lib/candy_check/play_store.rb +15 -0
  28. data/lib/candy_check/utils/attribute_reader.rb +30 -0
  29. data/lib/candy_check/utils/config.rb +40 -0
  30. data/lib/candy_check/utils.rb +2 -0
  31. data/lib/candy_check/version.rb +4 -0
  32. data/lib/candy_check.rb +8 -0
  33. data/spec/app_store/client_spec.rb +55 -0
  34. data/spec/app_store/config_spec.rb +41 -0
  35. data/spec/app_store/receipt_spec.rb +92 -0
  36. data/spec/app_store/verifcation_failure_spec.rb +28 -0
  37. data/spec/app_store/verification_spec.rb +66 -0
  38. data/spec/app_store/verifier_spec.rb +110 -0
  39. data/spec/candy_check_spec.rb +9 -0
  40. data/spec/fixtures/api_cache.dump +1 -0
  41. data/spec/fixtures/play_store/api_cache.dump +1 -0
  42. data/spec/fixtures/play_store/auth_failure.txt +18 -0
  43. data/spec/fixtures/play_store/auth_success.txt +20 -0
  44. data/spec/fixtures/play_store/discovery.txt +2841 -0
  45. data/spec/fixtures/play_store/dummy.p12 +0 -0
  46. data/spec/fixtures/play_store/empty.txt +17 -0
  47. data/spec/fixtures/play_store/products_failure.txt +29 -0
  48. data/spec/fixtures/play_store/products_success.txt +22 -0
  49. data/spec/play_store/client_spec.rb +104 -0
  50. data/spec/play_store/config_spec.rb +96 -0
  51. data/spec/play_store/discovery_respository_spec.rb +31 -0
  52. data/spec/play_store/receipt_spec.rb +88 -0
  53. data/spec/play_store/verification_failure_spec.rb +35 -0
  54. data/spec/play_store/verification_spec.rb +80 -0
  55. data/spec/play_store/verifier_spec.rb +95 -0
  56. data/spec/spec_helper.rb +35 -0
  57. data/spec/support/with_fixtures.rb +9 -0
  58. data/spec/support/with_temp_file.rb +23 -0
  59. metadata +270 -0
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'candy_check/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'candy_check'
8
+ spec.version = CandyCheck::VERSION
9
+ spec.authors = ['Jonas Thiel']
10
+ spec.email = ['jonas@thiel.io']
11
+ spec.summary = 'Check and verify in-app receipts'
12
+ spec.homepage = 'https://github.com/jnbt/candy_check'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(/^(test|spec|features)\//)
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_dependency 'multi_json', '~> 1.10'
21
+ spec.add_dependency 'google-api-client', '~> 0.8'
22
+
23
+ spec.add_development_dependency 'rubocop', '~> 0.28'
24
+ spec.add_development_dependency 'inch', '~> 0.5'
25
+ spec.add_development_dependency 'bundler', '~> 1.7'
26
+ spec.add_development_dependency 'rake', '~> 10.0'
27
+ spec.add_development_dependency 'coveralls', '~> 0.7'
28
+ spec.add_development_dependency 'minitest', '~> 5.5'
29
+ spec.add_development_dependency 'minitest-around', '~> 0.3'
30
+ spec.add_development_dependency 'webmock', '~> 1.20'
31
+ end
@@ -0,0 +1,57 @@
1
+ require 'multi_json'
2
+
3
+ module CandyCheck
4
+ module AppStore
5
+ # Simple HTTP client to load the receipt's data from Apple's verification
6
+ # servers (either sandbox or production).
7
+ class Client
8
+ # Mimetype for JSON objects
9
+ JSON_MIME_TYPE = 'application/json'
10
+
11
+ # Initialize a new client bound to an endpoint
12
+ # @param endpoint_url [String]
13
+ def initialize(endpoint_url)
14
+ @uri = URI(endpoint_url)
15
+ end
16
+
17
+ # Contacts the configured endpoint and requests the receipt's data
18
+ # @param receipt_data [String] base64 encoded data string from the app
19
+ # @param secret [String] the password for auto-renewable subscriptions
20
+ # @return [Hash]
21
+ def verify(receipt_data, secret = nil)
22
+ request = build_request(build_request_parameters(receipt_data, secret))
23
+ response = perform_request(request)
24
+ MultiJson.load(response.body)
25
+ end
26
+
27
+ private
28
+
29
+ def perform_request(request)
30
+ build_http_connector.request(request)
31
+ end
32
+
33
+ def build_http_connector
34
+ Net::HTTP.new(@uri.host, @uri.port).tap do |net|
35
+ net.use_ssl = true
36
+ net.verify_mode = OpenSSL::SSL::VERIFY_PEER
37
+ end
38
+ end
39
+
40
+ def build_request(parameters)
41
+ Net::HTTP::Post.new(@uri.request_uri).tap do |post|
42
+ post['Accept'] = JSON_MIME_TYPE
43
+ post['Content-Type'] = JSON_MIME_TYPE
44
+ post.body = MultiJson.dump(parameters)
45
+ end
46
+ end
47
+
48
+ def build_request_parameters(receipt_data, secret)
49
+ {
50
+ 'receipt-data' => receipt_data
51
+ }.tap do |h|
52
+ h['password'] = secret if secret
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,30 @@
1
+ module CandyCheck
2
+ module AppStore
3
+ # Configure the verifier
4
+ class Config < Utils::Config
5
+ # @return [Symbol] the used environment
6
+ attr_reader :environment
7
+
8
+ # Initializes a new configuration from a hash
9
+ # @param attributes [Hash]
10
+ # @example
11
+ # Config.new(
12
+ # environment: :production # or :sandbox
13
+ # )
14
+ def initialize(attributes)
15
+ super
16
+ end
17
+
18
+ # @return [Boolean] if it is production environment
19
+ def production?
20
+ environment == :production
21
+ end
22
+
23
+ private
24
+
25
+ def validate!
26
+ validates_inclusion(:environment, :production, :sandbox)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,83 @@
1
+ module CandyCheck
2
+ module AppStore
3
+ # Describes a successful response from the AppStore verification server
4
+ class Receipt
5
+ include Utils::AttributeReader
6
+
7
+ # @return [Hash] the raw attributes returned from the server
8
+ attr_reader :attributes
9
+
10
+ # Initializes a new instance which bases on a JSON result
11
+ # from Apple's verification server
12
+ # @param attributes [Hash]
13
+ def initialize(attributes)
14
+ @attributes = attributes
15
+ end
16
+
17
+ # In most cases a receipt is a valid transaction except when the
18
+ # transaction was canceled.
19
+ # @return [Boolean]
20
+ def valid?
21
+ !has?('cancellation_date')
22
+ end
23
+
24
+ # The receipt's transaction id
25
+ # @return [String]
26
+ def transaction_id
27
+ read('transaction_id')
28
+ end
29
+
30
+ # The receipt's original transaction id which might differ from
31
+ # the transaction id for restored products
32
+ # @return [String]
33
+ def original_transaction_id
34
+ read('original_transaction_id')
35
+ end
36
+
37
+ # The version number for the app
38
+ # @return [String]
39
+ def app_version
40
+ read('bvrs')
41
+ end
42
+
43
+ # The app's bundle identifier
44
+ # @return [String]
45
+ def bundle_identifier
46
+ read('bid')
47
+ end
48
+
49
+ # The app's item id of the product
50
+ # @return [String]
51
+ def item_id
52
+ read('item_id')
53
+ end
54
+
55
+ # The quantity of the product
56
+ # @return [Fixnum]
57
+ def quantity
58
+ read_integer('quantity')
59
+ end
60
+
61
+ # The purchase date
62
+ # @return [DateTime]
63
+ def purchase_date
64
+ read_datetime_from_string('purchase_date')
65
+ end
66
+
67
+ # The original purchase date which might differ from the
68
+ # actual purchase date for restored products
69
+ # @return [DateTime]
70
+ def original_purchase_date
71
+ read_datetime_from_string('original_purchase_date')
72
+ end
73
+
74
+ # The date of when Apple has canceled this transaction.
75
+ # From Apple's documentation: "Treat a canceled receipt
76
+ # the same as if no purchase had ever been made."
77
+ # @return [DateTime]
78
+ def cancellation_date
79
+ read_datetime_from_string('cancellation_date')
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,49 @@
1
+ module CandyCheck
2
+ module AppStore
3
+ # Verifies a receipt block against a verification server.
4
+ # The call return either an {Receipt} or a {VerificationFailure}
5
+ class Verification
6
+ # @return [String] the verification URL to use
7
+ attr_reader :endpoint_url
8
+ # @return [String] the raw data to be verified
9
+ attr_reader :receipt_data
10
+ # @return [String] the optional shared secret
11
+ attr_reader :secret
12
+
13
+ # Constant for successful responses
14
+ STATUS_OK = 0
15
+
16
+ # Builds a fresh verification run
17
+ # @param endpoint_url [String] the verification URL to use
18
+ # @param receipt_data [String] the raw data to be verified
19
+ # @param secret [String] the optional shared secret
20
+ def initialize(endpoint_url, receipt_data, secret = nil)
21
+ @endpoint_url, @receipt_data = endpoint_url, receipt_data
22
+ @secret = secret
23
+ end
24
+
25
+ # Performs the verification against the remote server
26
+ # @return [Receipt] if successful
27
+ # @return [VerificationFailure] otherwise
28
+ def call!
29
+ verify!
30
+ if valid?
31
+ Receipt.new(@response['receipt'])
32
+ else
33
+ VerificationFailure.fetch(@response['status'])
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def valid?
40
+ @response && @response['status'] == STATUS_OK && @response['receipt']
41
+ end
42
+
43
+ def verify!
44
+ client = Client.new(endpoint_url)
45
+ @response = client.verify(receipt_data, secret)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,60 @@
1
+ module CandyCheck
2
+ module AppStore
3
+ # Represents a failing call against the verification server
4
+ class VerificationFailure < Struct.new(:code, :message)
5
+ # @!attribute code
6
+ # @return [Fixnum] the code of the failure
7
+ # @!attribute message
8
+ # @return [String] the message of the failure
9
+
10
+ class << self
11
+ # Gets a known failure or build an unknown failure
12
+ # without description
13
+ # @param code [Fixnum]
14
+ # @return [VerificationFailure]
15
+ def fetch(code)
16
+ known.fetch(code) do
17
+ fallback(code)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def fallback(code)
24
+ new(code || -1, 'Unknown error')
25
+ end
26
+
27
+ def known
28
+ @known ||= {}
29
+ end
30
+
31
+ def add(code, name)
32
+ known[code] = new(code, name)
33
+ end
34
+
35
+ def freeze!
36
+ known.freeze
37
+ end
38
+ end
39
+
40
+ add 21_000, 'The App Store could not read the JSON object you provided.'
41
+ add 21_002, 'The data in the receipt-data property was malformed' \
42
+ ' or missing.'
43
+ add 21_003, 'The receipt could not be authenticated.'
44
+ add 21_004, 'The shared secret you provided does not match the shared' \
45
+ ' secret on file for your account.'
46
+ add 21_005, 'The receipt server is not currently available.'
47
+ add 21_006, 'This receipt is valid but the subscription has expired.' \
48
+ ' When this status code is returned to your server, the' \
49
+ ' receipt data is also decoded and returned as part of'\
50
+ ' the response.'
51
+ add 21_007, 'This receipt is from the test environment, but it was' \
52
+ ' sent to the production environment for verification.' \
53
+ ' Send it to the test environment instead.'
54
+ add 21_008, 'This receipt is from the production environment, but it' \
55
+ ' was sent to the test environment for verification.' \
56
+ ' Send it to the production environment instead.'
57
+ freeze!
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,69 @@
1
+ module CandyCheck
2
+ module AppStore
3
+ # Verifies receipts against the verification servers.
4
+ # The call return either an {Receipt} or a {VerificationFailure}
5
+ class Verifier
6
+ # HTTPS endpoint for production receipts
7
+ PRODUCTION_ENDPOINT = 'https://buy.itunes.apple.com/verifyReceipt'
8
+ # HTTPS endpoint for sandbox receipts
9
+ SANDBOX_ENDPOINT = 'https://sandbox.itunes.apple.com/verifyReceipt'
10
+ # Status code from production endpoint when receiving a sandbox
11
+ # receipt which occurs during the app's review process
12
+ REDIRECT_TO_SANDBOX_CODE = 21_007
13
+ # Status code from the sandbox endpoint when receiving a production
14
+ # receipt
15
+ REDIRECT_TO_PRODUCTION_CODE = 21_008
16
+
17
+ # @return [Config] the current configuration
18
+ attr_reader :config
19
+
20
+ # Initializes a new verifier for the application which is bound
21
+ # to a configuration
22
+ # @param config [Config]
23
+ def initialize(config)
24
+ @config = config
25
+ end
26
+
27
+ # Calls a verification for the given input
28
+ # @param receipt_data [String] the raw data to be verified
29
+ # @param secret [String] the optional shared secret
30
+ # @return [Receipt] if successful
31
+ # @return [VerificationFailure] otherwise
32
+ def verify(receipt_data, secret = nil)
33
+ default_endpoint, opposite_endpoint = endpoints
34
+ result = call_for(default_endpoint, receipt_data, secret)
35
+ if should_retry?(result)
36
+ return call_for(opposite_endpoint, receipt_data, secret)
37
+ end
38
+ result
39
+ end
40
+
41
+ private
42
+
43
+ def call_for(endpoint_url, receipt_data, secret)
44
+ Verification.new(endpoint_url, receipt_data, secret).call!
45
+ end
46
+
47
+ def should_retry?(result)
48
+ result.is_a?(VerificationFailure) && redirect?(result)
49
+ end
50
+
51
+ def endpoints
52
+ if config.production?
53
+ [PRODUCTION_ENDPOINT, SANDBOX_ENDPOINT]
54
+ else
55
+ [SANDBOX_ENDPOINT, PRODUCTION_ENDPOINT]
56
+ end
57
+ end
58
+
59
+ def redirect_code
60
+ config.production? ? REDIRECT_TO_SANDBOX_CODE :
61
+ REDIRECT_TO_PRODUCTION_CODE
62
+ end
63
+
64
+ def redirect?(failure)
65
+ failure.code == redirect_code
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,12 @@
1
+ require 'candy_check/app_store/client'
2
+ require 'candy_check/app_store/config'
3
+ require 'candy_check/app_store/receipt'
4
+ require 'candy_check/app_store/verification'
5
+ require 'candy_check/app_store/verification_failure'
6
+ require 'candy_check/app_store/verifier'
7
+
8
+ module CandyCheck
9
+ # Module to request and verify a AppStore receipt
10
+ module AppStore
11
+ end
12
+ end
@@ -0,0 +1,102 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ # A client which uses the official Google API SDK to authenticate
4
+ # and request product information from Google's API.
5
+ #
6
+ # @example Usage
7
+ # config = ClientConfig.new({...})
8
+ # client = Client.new(config)
9
+ # client.boot! # a single time
10
+ # client.verify('my.bundle', 'product_1', 'a-very-long-secure-token')
11
+ # # ... multiple calls from now on
12
+ # client.verify('my.bundle', 'product_1', 'another-long-token')
13
+ class Client
14
+ # Error thrown if the discovery of the API wasn't successful
15
+ class DiscoveryError < RuntimeError; end
16
+
17
+ # API endpoint
18
+ API_URL = 'https://accounts.google.com/o/oauth2/token'
19
+ # API scope for Android services
20
+ API_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'
21
+ # API discovery namespace
22
+ API_DISCOVER = 'androidpublisher'
23
+ # API version
24
+ API_VERSION = 'v2'
25
+
26
+ # Initializes a client using a configuration.
27
+ # @param config [ClientConfig]
28
+ def initialize(config)
29
+ self.config = config
30
+ end
31
+
32
+ # Boots a client by discovering the API's services and then authorizes
33
+ # by fetching an access token.
34
+ # If the config has a cache_file the client tries to load discovery
35
+ def boot!
36
+ self.api_client = Google::APIClient.new(
37
+ application_name: config.application_name,
38
+ application_version: config.application_version
39
+ )
40
+ discover!
41
+ authorize!
42
+ end
43
+
44
+ # Calls the remote API to load the product information for a specific
45
+ # combination of parameter which should be loaded from the client.
46
+ # @param package [String] the app's package name
47
+ # @param product_id [String] the app's item id
48
+ # @param token [String] the purchase token
49
+ # @return [Hash] result of the API call
50
+ def verify(package, product_id, token)
51
+ api_client.execute(
52
+ api_method: rpc.purchases.products.get,
53
+ parameters: {
54
+ 'packageName' => package,
55
+ 'productId' => product_id,
56
+ 'token' => token
57
+ }
58
+ ).data.to_hash
59
+ end
60
+
61
+ private
62
+
63
+ attr_accessor :config, :api_client, :rpc
64
+
65
+ def discover!
66
+ self.rpc = load_discover_dump || request_discover
67
+ validate_rpc!
68
+ write_discover_dump
69
+ end
70
+
71
+ def request_discover
72
+ api_client.discovered_api(API_DISCOVER, API_VERSION)
73
+ end
74
+
75
+ def authorize!
76
+ api_client.authorization = Signet::OAuth2::Client.new(
77
+ token_credential_uri: API_URL,
78
+ audience: API_URL,
79
+ scope: API_SCOPE,
80
+ issuer: config.issuer,
81
+ signing_key: config.api_key
82
+ )
83
+ api_client.authorization.fetch_access_token!
84
+ end
85
+
86
+ def validate_rpc!
87
+ return if rpc.purchases.products.get
88
+ fail DiscoveryError, 'Unable to get the API discovery'
89
+ rescue NoMethodError
90
+ raise DiscoveryError, 'Unable to get the API discovery'
91
+ end
92
+
93
+ def load_discover_dump
94
+ DiscoveryRepository.new(config.cache_file).load
95
+ end
96
+
97
+ def write_discover_dump
98
+ DiscoveryRepository.new(config.cache_file).save(rpc)
99
+ end
100
+ end
101
+ end
102
+ end