candy_check 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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