simple_oauth2 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +25 -0
  3. data/.coveralls.yml +1 -0
  4. data/.gitignore +26 -0
  5. data/.hound.yml +4 -0
  6. data/.rubocop.yml +5 -0
  7. data/.rubocop_todo.yml +12 -0
  8. data/.travis.yml +31 -0
  9. data/Gemfile +18 -0
  10. data/LICENSE +21 -0
  11. data/README.md +11 -0
  12. data/Rakefile +11 -0
  13. data/gemfiles/nobrainer.rb +15 -0
  14. data/lib/simple_oauth2/configuration/class_accessors.rb +36 -0
  15. data/lib/simple_oauth2/configuration/constants.rb +36 -0
  16. data/lib/simple_oauth2/configuration.rb +169 -0
  17. data/lib/simple_oauth2/generators/authorization.rb +64 -0
  18. data/lib/simple_oauth2/generators/base.rb +31 -0
  19. data/lib/simple_oauth2/generators/token.rb +71 -0
  20. data/lib/simple_oauth2/helpers.rb +40 -0
  21. data/lib/simple_oauth2/mixins/nobrainer/access_grant.rb +62 -0
  22. data/lib/simple_oauth2/mixins/nobrainer/access_token.rb +98 -0
  23. data/lib/simple_oauth2/mixins/nobrainer/client.rb +43 -0
  24. data/lib/simple_oauth2/resource/bearer.rb +20 -0
  25. data/lib/simple_oauth2/responses.rb +62 -0
  26. data/lib/simple_oauth2/scopes.rb +59 -0
  27. data/lib/simple_oauth2/strategies/authorization_code.rb +22 -0
  28. data/lib/simple_oauth2/strategies/base.rb +61 -0
  29. data/lib/simple_oauth2/strategies/client_credentials.rb +21 -0
  30. data/lib/simple_oauth2/strategies/code.rb +25 -0
  31. data/lib/simple_oauth2/strategies/password.rb +21 -0
  32. data/lib/simple_oauth2/strategies/refresh_token.rb +53 -0
  33. data/lib/simple_oauth2/strategies/token.rb +24 -0
  34. data/lib/simple_oauth2/uniq_token.rb +20 -0
  35. data/lib/simple_oauth2/version.rb +26 -0
  36. data/lib/simple_oauth2.rb +62 -0
  37. data/logo.png +0 -0
  38. data/simple_oauth2.gemspec +22 -0
  39. data/spec/configuration/config_spec.rb +181 -0
  40. data/spec/configuration/version_spec.rb +11 -0
  41. data/spec/dummy/endpoints/authorization.rb +15 -0
  42. data/spec/dummy/endpoints/custom_authorization.rb +21 -0
  43. data/spec/dummy/endpoints/custom_token.rb +21 -0
  44. data/spec/dummy/endpoints/status.rb +51 -0
  45. data/spec/dummy/endpoints/token.rb +22 -0
  46. data/spec/dummy/orm/nobrainer/app/config/db.rb +8 -0
  47. data/spec/dummy/orm/nobrainer/app/models/access_grant.rb +3 -0
  48. data/spec/dummy/orm/nobrainer/app/models/access_token.rb +3 -0
  49. data/spec/dummy/orm/nobrainer/app/models/client.rb +3 -0
  50. data/spec/dummy/orm/nobrainer/app/models/user.rb +11 -0
  51. data/spec/dummy/orm/nobrainer/app/twitter.rb +51 -0
  52. data/spec/dummy/orm/nobrainer/config.ru +37 -0
  53. data/spec/dummy/simple_oauth2_config.rb +7 -0
  54. data/spec/requests/flows/authorization_code_spec.rb +177 -0
  55. data/spec/requests/flows/client_credentials_spec.rb +163 -0
  56. data/spec/requests/flows/code_spec.rb +98 -0
  57. data/spec/requests/flows/password_spec.rb +183 -0
  58. data/spec/requests/flows/refresh_token_spec.rb +282 -0
  59. data/spec/requests/flows/token_spec.rb +113 -0
  60. data/spec/requests/protected_resources_spec.rb +65 -0
  61. data/spec/requests/revoke_token_spec.rb +90 -0
  62. data/spec/spec_helper.rb +51 -0
  63. data/spec/support/helper.rb +11 -0
  64. metadata +125 -0
@@ -0,0 +1,62 @@
1
+ module Simple
2
+ module OAuth2
3
+ module NoBrainer
4
+ # Includes all the required API, associations, validations and callbacks
5
+ module AccessGrant
6
+ extend ActiveSupport::Concern
7
+
8
+ included do # rubocop:disable Metrics/BlockLength
9
+ include ::NoBrainer::Document
10
+ include ::NoBrainer::Document::Timestamps
11
+
12
+ belongs_to :client, class_name: Simple::OAuth2.config.client_class_name,
13
+ foreign_key: :client_id, primary_key: :id
14
+ belongs_to :resource_owner, class_name: Simple::OAuth2.config.resource_owner_class_name,
15
+ foreign_key: :resource_owner_id, primary_key: :id
16
+
17
+ before_save { self.updated_at = Time.now }
18
+ before_validation :setup_expiration, if: :new_record?
19
+
20
+ field :resource_owner_id, type: String, index: true, required: true
21
+ field :client_id, type: String, index: true, required: true
22
+
23
+ field :token,
24
+ type: String,
25
+ required: true,
26
+ uniq: true,
27
+ index: true,
28
+ default: -> { Simple::OAuth2.config.token_generator.generate }
29
+
30
+ field :redirect_uri, type: String, required: true
31
+ field :scopes, type: String
32
+
33
+ field :revoked_at, type: Time
34
+ field :expires_at, type: Time, required: true
35
+ field :created_at, type: Time, required: true, default: -> { Time.now }
36
+ field :updated_at, type: Time, required: true, default: -> { Time.now }
37
+
38
+ class << self
39
+ def create_for(client, resource_owner, redirect_uri, scopes = nil)
40
+ create(
41
+ client_id: client.id,
42
+ resource_owner_id: resource_owner.id,
43
+ redirect_uri: redirect_uri,
44
+ scopes: scopes
45
+ )
46
+ end
47
+
48
+ def authenticate(token)
49
+ where(token: token.to_s).first
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def setup_expiration
56
+ self.expires_at = Time.now.utc + Simple::OAuth2.config.authorization_code_lifetime if expires_at.nil?
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,98 @@
1
+ module Simple
2
+ module OAuth2
3
+ module NoBrainer
4
+ # Includes all the required API, associations, validations and callbacks
5
+ module AccessToken
6
+ extend ActiveSupport::Concern
7
+
8
+ included do # rubocop:disable Metrics/BlockLength
9
+ include ::NoBrainer::Document
10
+ include ::NoBrainer::Document::Timestamps
11
+
12
+ before_save { self.updated_at = Time.now }
13
+ before_validation :setup_expiration, if: :new_record?
14
+
15
+ belongs_to :client, class_name: Simple::OAuth2.config.client_class_name,
16
+ foreign_key: :client_id, primary_key: :id
17
+ belongs_to :resource_owner, class_name: Simple::OAuth2.config.resource_owner_class_name,
18
+ foreign_key: :resource_owner_id, primary_key: :id
19
+
20
+ field :resource_owner_id, type: String, index: true, required: true
21
+ field :client_id, type: String, index: true, required: true
22
+ field :token,
23
+ type: String,
24
+ index: true,
25
+ required: true,
26
+ uniq: true,
27
+ default: -> { Simple::OAuth2.config.token_generator.generate }
28
+ field :refresh_token,
29
+ type: String,
30
+ index: true,
31
+ uniq: true,
32
+ default: -> do
33
+ if Simple::OAuth2.config.issue_refresh_token
34
+ Simple::OAuth2.config.token_generator.generate
35
+ else
36
+ ''
37
+ end
38
+ end
39
+
40
+ field :scopes, type: String
41
+
42
+ field :revoked_at, type: Time
43
+ field :expires_at, type: Time, required: true
44
+ field :created_at, type: Time, required: true, default: -> { Time.now }
45
+ field :updated_at, type: Time, required: true, default: -> { Time.now }
46
+
47
+ class << self
48
+ def create_for(client, resource_owner, scopes = nil)
49
+ create(
50
+ client_id: client.id,
51
+ resource_owner_id: resource_owner.id,
52
+ scopes: scopes
53
+ )
54
+ end
55
+
56
+ def authenticate(token, token_type_hint = nil)
57
+ return if token.blank?
58
+
59
+ if token_type_hint == 'refresh_token'
60
+ where(refresh_token: token).first
61
+ else
62
+ where(token: token).first
63
+ end
64
+ end
65
+ end
66
+
67
+ def expired?
68
+ expires_at && Time.now.utc > expires_at
69
+ end
70
+
71
+ def revoked?
72
+ revoked_at && revoked_at <= Time.now.utc
73
+ end
74
+
75
+ def revoke!(revoked_at = Time.now.utc)
76
+ update!(revoked_at: revoked_at)
77
+ end
78
+
79
+ def to_bearer_token
80
+ {
81
+ access_token: token,
82
+ expires_in: expires_at && Simple::OAuth2.config.access_token_lifetime.to_i,
83
+ refresh_token: refresh_token,
84
+ scope: scopes
85
+ }
86
+ end
87
+
88
+ private
89
+
90
+ def setup_expiration
91
+ expires_in = Simple::OAuth2.config.access_token_lifetime.to_i
92
+ self.expires_at = Time.now.utc + expires_in if expires_at.nil? && !expires_in.nil?
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,43 @@
1
+ module Simple
2
+ module OAuth2
3
+ module NoBrainer
4
+ # Includes all the required API, associations, validations and callbacks
5
+ module Client
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include ::NoBrainer::Document
10
+ include ::NoBrainer::Document::Timestamps
11
+
12
+ before_save { self.updated_at = Time.now }
13
+
14
+ has_many :access_tokens, class_name: Simple::OAuth2.config.access_token_class_name, foreign_key: :client_id
15
+ has_many :access_grants, class_name: Simple::OAuth2.config.access_grant_class_name, foreign_key: :client_id
16
+
17
+ field :name, type: String, required: true
18
+ field :redirect_uri, type: String, required: true
19
+
20
+ field :key,
21
+ type: String,
22
+ required: true,
23
+ index: true,
24
+ uniq: true,
25
+ default: -> { Simple::OAuth2.config.token_generator.generate }
26
+ field :secret,
27
+ type: String,
28
+ required: true,
29
+ index: true,
30
+ uniq: true,
31
+ default: -> { Simple::OAuth2.config.token_generator.generate }
32
+
33
+ field :created_at, type: Time, required: true, default: -> { Time.now }
34
+ field :updated_at, type: Time, required: true, default: -> { Time.now }
35
+
36
+ def self.authenticate(key)
37
+ where(key: key.to_s).first
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Resource
4
+ # OAuth2 middleware Protected Resource Endpoint
5
+ class Bearer
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ # See https://github.com/nov/rack-oauth2/wiki/Server-Resource-Endpoint
11
+ def call(env)
12
+ app = Rack::OAuth2::Server::Resource::Bearer.new(@app, Simple::OAuth2.config.realm) do |req|
13
+ Simple::OAuth2.config.token_authenticator.call(req)
14
+ end
15
+ app.call(env)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,62 @@
1
+ module Simple
2
+ module OAuth2
3
+ # Processes Rack Responses and contains helper methods
4
+ #
5
+ # @return [Object] Rack response
6
+ #
7
+ # @example
8
+ # rack_response = [
9
+ # 200,
10
+ # { 'Content-Type' => 'application/json' },
11
+ # Rack::BodyProxy.new(Rack::Response.new('200'.to_json))
12
+ # ]
13
+ # response = Simple::OAuth2::Responses.new(rack_response)
14
+ #
15
+ # response.status #=> 200
16
+ # response.headers #=> {}
17
+ # response.body #=> '200'
18
+ # response #=> <Simple::OAuth2::Responses:0x007fc9f32080b8 @response=[
19
+ # 200,
20
+ # {},
21
+ # <Rack::BodyProxy:0x007fc9f3208108
22
+ # @block=nil,
23
+ # @body= <Rack::Response:0x007fc9f3208388
24
+ # @block=nil,
25
+ # @body=["\"200\""],
26
+ # @header={"Content-Length"=>"5"},
27
+ # @length=5,
28
+ # @status=200
29
+ # >,
30
+ # @closed=false
31
+ # >
32
+ # ]
33
+ #
34
+ class Responses
35
+ # Simple::OAuth2 response class
36
+ #
37
+ # @param response [Array] raw Rack::Response object
38
+ #
39
+ def initialize(response)
40
+ @response = response
41
+ end
42
+
43
+ # Response status
44
+ def status
45
+ @response[0]
46
+ end
47
+
48
+ # Response headers
49
+ def headers
50
+ @response[1]
51
+ end
52
+
53
+ # Response JSON-parsed body
54
+ def body
55
+ response_body = @response[2].body.first
56
+ return {} if response_body.nil? || response_body.empty?
57
+
58
+ JSON.parse(response_body)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,59 @@
1
+ module Simple
2
+ module OAuth2
3
+ # Scopes helper for scopes validation
4
+ class Scopes
5
+ # Checks if requested scopes are valid
6
+ #
7
+ # @param access_scopes [Array] scopes of AccessToken class
8
+ # @param scopes [Array<String, Symbol>] array, symbol, string of any object that responds to `to_a`
9
+ #
10
+ def self.valid?(access_scopes, scopes)
11
+ new(access_scopes, scopes).valid?
12
+ end
13
+
14
+ # Helper class initializer
15
+ #
16
+ # @param access_scopes [Array] scopes of AccessToken class
17
+ # @param scopes [Array<String, Symbol>] array, symbol, string of any object that responds to `to_a`
18
+ #
19
+ def initialize(access_scopes, scopes = [])
20
+ @scopes = to_array(scopes)
21
+ @access_scopes = to_array(access_scopes)
22
+ end
23
+
24
+ # Checks if requested scopes (passed and processed on initialization) are presented in the AccessToken
25
+ #
26
+ # @return [Boolean] true if requested scopes are empty or present in access_scopes
27
+ #
28
+ def valid?
29
+ @scopes.empty? || present_in_access_token?
30
+ end
31
+
32
+ private
33
+
34
+ # Checks if scopes present in access_scopes
35
+ #
36
+ # @return [Boolean] true if requested scopes present in access_scopes
37
+ #
38
+ def present_in_access_token?
39
+ Set.new(@access_scopes) >= Set.new(@scopes)
40
+ end
41
+
42
+ # Converts scopes set to the array
43
+ #
44
+ # @param scopes [Array<String, Symbol>, #to_a]
45
+ # string, symbol, array or object that responds to `to_a`
46
+ # @return [Array<String>] array of scopes
47
+ #
48
+ def to_array(scopes)
49
+ collection = if scopes.is_a?(Array) || scopes.respond_to?(:to_a)
50
+ scopes.to_a
51
+ elsif scopes.is_a?(String) || scopes.is_a?(Symbol)
52
+ scopes.split(',')
53
+ end
54
+
55
+ collection.map(&:to_s)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Authorization Code strategy class
5
+ # Processes request and respond with Access Token
6
+ class AuthorizationCode < Base
7
+ class << self
8
+ # Processes Authorization Code request
9
+ def process(request)
10
+ client = token_verify_client!(request)
11
+
12
+ code = authenticate_access_grant(request) || request.invalid_grant!
13
+ code.redirect_uri == request.redirect_uri || request.invalid_grant!
14
+
15
+ token = config.access_token_class.create_for(client, code.resource_owner, code.scopes)
16
+ expose_to_bearer_token(token)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,61 @@
1
+ module Simple
2
+ module OAuth2
3
+ # Simple::OAuth2 strategies namespace
4
+ module Strategies
5
+ # Base Strategies class.
6
+ # Contains common functionality for all the descendants
7
+ class Base
8
+ class << self
9
+ # Authenticates Client from the request
10
+ def authenticate_client(request)
11
+ config.client_class.authenticate(request.client_id)
12
+ end
13
+
14
+ # Authenticates Resource Owner from the request
15
+ def authenticate_resource_owner(client, request)
16
+ config.resource_owner_class.oauth_authenticate(
17
+ client,
18
+ request.params['username'],
19
+ request.params['password']
20
+ )
21
+ end
22
+
23
+ # Authenticates Access Grant from the request
24
+ def authenticate_access_grant(request)
25
+ config.access_grant_class.authenticate(request.code)
26
+ end
27
+
28
+ # Exposes token object to Bearer token.
29
+ #
30
+ # @param token [AccessToken] any object that responds to `to_bearer_token`
31
+ # @return [Rack::OAuth2::AccessToken::Bearer] bearer token instance
32
+ #
33
+ def expose_to_bearer_token(token)
34
+ Rack::OAuth2::AccessToken::Bearer.new(token.to_bearer_token)
35
+ end
36
+
37
+ # Token endpoint, check client for exact matching verifier
38
+ def token_verify_client!(request)
39
+ client = authenticate_client(request) || request.invalid_client!
40
+ client.secret == request.client_secret || request.invalid_client!
41
+ client
42
+ end
43
+
44
+ # Authorization endpoint, check client and redirect_uri for exact matching verifier
45
+ def authorization_verify_client!(request, response)
46
+ client = authenticate_client(request) || request.bad_request!
47
+ response.redirect_uri = request.verify_redirect_uri!(client.redirect_uri)
48
+ client
49
+ end
50
+
51
+ private
52
+
53
+ # Short getter for Simple::OAuth2 configuration.
54
+ def config
55
+ Simple::OAuth2.config
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,21 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # ClientCredentials strategy class.
5
+ # Processes request and respond with Access Token
6
+ class ClientCredentials < Base
7
+ class << self
8
+ # Processes ClientCredentials request
9
+ def process(request)
10
+ client = authenticate_client(request) || request.invalid_client!
11
+
12
+ resource_owner = authenticate_resource_owner(client, request) || request.invalid_grant!
13
+
14
+ token = config.access_token_class.create_for(client, resource_owner, request.scope.join(','))
15
+ expose_to_bearer_token(token)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Code strategy class.
5
+ # Processes request and respond with Code
6
+ class Code < Base
7
+ class << self
8
+ # Processes Code request
9
+ def process(request, response)
10
+ client = authorization_verify_client!(request, response)
11
+
12
+ authorization_code = config.access_grant_class.create_for(
13
+ client,
14
+ config.resource_owner_authenticator.call(request),
15
+ response.redirect_uri,
16
+ request.scope.join(',')
17
+ )
18
+
19
+ response.code = authorization_code.token
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Resource Owner Password Credentials strategy class
5
+ # Processes request and respond with Access Token
6
+ class Password < Base
7
+ class << self
8
+ # Processes Password request
9
+ def process(request)
10
+ client = token_verify_client!(request)
11
+
12
+ resource_owner = authenticate_resource_owner(client, request) || request.invalid_grant!
13
+
14
+ token = config.access_token_class.create_for(client, resource_owner, request.scope.join(','))
15
+ expose_to_bearer_token(token)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,53 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Refresh Token strategy class
5
+ # Processes request and respond with Access Token
6
+ class RefreshToken < Base
7
+ class << self
8
+ # Processes Refresh Token request
9
+ def process(request)
10
+ client = token_verify_client!(request)
11
+ refresh_token = verify_refresh_token!(request, client.id)
12
+
13
+ token = config.access_token_class.create_for(
14
+ client, refresh_token.resource_owner, request.scope.join(',')
15
+ )
16
+ run_callback_on_refresh_token(refresh_token) if config.on_refresh_runnable?
17
+
18
+ expose_to_bearer_token(token)
19
+ end
20
+
21
+ private
22
+
23
+ # Check refresh token and client id for exact matching verifier
24
+ def verify_refresh_token!(request, client_id)
25
+ refresh_token = config.access_token_class.authenticate(request.refresh_token, 'refresh_token')
26
+ refresh_token || request.invalid_grant!
27
+ refresh_token.client_id == client_id || request.unauthorized_client!
28
+
29
+ refresh_token
30
+ end
31
+
32
+ # Invokes custom callback on Access Token refresh.
33
+ # If callback is a proc, then call it with token.
34
+ # If access token responds to callback value (symbol for example), then call it from the token.
35
+ #
36
+ # @param access_token [Object] Access Token instance
37
+ #
38
+ def run_callback_on_refresh_token(access_token)
39
+ callback = config.on_refresh
40
+
41
+ if callback.respond_to?(:call)
42
+ callback.call(access_token)
43
+ elsif access_token.respond_to?(callback)
44
+ access_token.send(callback)
45
+ else
46
+ raise(ArgumentError, ":on_refresh is not a block and Access Token class doesn't respond to #{callback}!")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Token strategy class
5
+ # Processes request and respond with Access Token
6
+ class Token < Base
7
+ class << self
8
+ # Processes Token request
9
+ def process(request, response)
10
+ client = authorization_verify_client!(request, response)
11
+
12
+ access_token = config.access_token_class.create_for(
13
+ client,
14
+ config.resource_owner_authenticator.call(request),
15
+ request.scope.join(',')
16
+ )
17
+
18
+ response.access_token = expose_to_bearer_token(access_token)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ module Simple
2
+ module OAuth2
3
+ # OAuth2 helper for generation of unique token values.
4
+ # Can process custom payload and options
5
+ module UniqToken
6
+ # Generates unique token value
7
+ #
8
+ # @param _payload [Hash]
9
+ # payload
10
+ # @param options [Hash]
11
+ # options for generator
12
+ #
13
+ # @return [String]
14
+ # unique token value
15
+ def self.generate(_payload = {}, options = {})
16
+ SecureRandom.hex(options.delete(:size) || 32)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ module Simple
2
+ # Semantic versioning
3
+ module OAuth2
4
+ # Simple::OAuth2 version
5
+ # @return [Gem::Version] version of the gem
6
+ #
7
+ def self.gem_version
8
+ Gem::Version.new VERSION::STRING
9
+ end
10
+
11
+ # Simple::OAuth2 semantic versioning module
12
+ # Contains detailed info about gem version
13
+ module VERSION
14
+ # Level changes for implementation level detail changes, such as small bug fixes
15
+ PATCH = 0
16
+ # Level changes for any backwards compatible API changes, such as new functionality/features
17
+ MINOR = 0
18
+ # Level changes for backwards incompatible API changes,
19
+ # such as changes that will break existing users code if they update
20
+ MAJOR = 0
21
+
22
+ # Full gem version string
23
+ STRING = [MAJOR, MINOR, PATCH].join('.')
24
+ end
25
+ end
26
+ end