motionauth-oauth2 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +53 -0
- data/lib/oauth2-cocoa/connection.rb +158 -0
- data/lib/oauth2-cocoa/mac_token.rb +58 -0
- data/lib/oauth2-cocoa/response.rb +34 -0
- data/lib/oauth2-cocoa/strategy/assertion.rb +37 -0
- data/lib/oauth2-cocoa/strategy/client_credentials.rb +18 -0
- data/lib/oauth2-cocoa/utils.rb +63 -0
- data/lib/oauth2.rb +23 -0
- data/lib/oauth2/access_token.rb +177 -0
- data/lib/oauth2/client.rb +163 -0
- data/lib/oauth2/connection.rb +35 -0
- data/lib/oauth2/error.rb +24 -0
- data/lib/oauth2/mac_token.rb +74 -0
- data/lib/oauth2/response.rb +58 -0
- data/lib/oauth2/strategy/assertion.rb +59 -0
- data/lib/oauth2/strategy/auth_code.rb +33 -0
- data/lib/oauth2/strategy/base.rb +16 -0
- data/lib/oauth2/strategy/client_credentials.rb +30 -0
- data/lib/oauth2/strategy/implicit.rb +29 -0
- data/lib/oauth2/strategy/password.rb +29 -0
- data/lib/oauth2/version.rb +15 -0
- metadata +197 -0
@@ -0,0 +1,177 @@
|
|
1
|
+
module OAuth2
|
2
|
+
class AccessToken
|
3
|
+
attr_reader :client, :token, :expires_in, :expires_at, :params
|
4
|
+
attr_accessor :options, :refresh_token
|
5
|
+
|
6
|
+
class << self
|
7
|
+
# Initializes an AccessToken from a Hash
|
8
|
+
#
|
9
|
+
# @param [Client] the OAuth2::Client instance
|
10
|
+
# @param [Hash] a hash of AccessToken property values
|
11
|
+
# @return [AccessToken] the initalized AccessToken
|
12
|
+
def from_hash(client, hash)
|
13
|
+
new(client, hash.delete("access_token") || hash.delete(:access_token), hash)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Initializes an AccessToken from a key/value application/x-www-form-urlencoded string
|
17
|
+
#
|
18
|
+
# @param [Client] client the OAuth2::Client instance
|
19
|
+
# @param [String] kvform the application/x-www-form-urlencoded string
|
20
|
+
# @return [AccessToken] the initalized AccessToken
|
21
|
+
def from_kvform(client, kvform)
|
22
|
+
from_hash(client, Utils.params_from_query(kvform))
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Initalize an AccessToken
|
27
|
+
#
|
28
|
+
# @param [Client] client the OAuth2::Client instance
|
29
|
+
# @param [String] token the Access Token value
|
30
|
+
# @param [Hash] opts the options to create the Access Token with
|
31
|
+
# @option opts [String] :refresh_token (nil) the refresh_token value
|
32
|
+
# @option opts [FixNum, String] :expires_in (nil) the number of seconds in which the AccessToken will expire
|
33
|
+
# @option opts [FixNum, String] :expires_at (nil) the epoch time in seconds in which AccessToken will expire
|
34
|
+
# @option opts [Symbol] :mode (:header) the transmission mode of the Access Token parameter value
|
35
|
+
# one of :header, :body or :query
|
36
|
+
# @option opts [String] :header_format ("Bearer %s") the string format to use for the Authorization header
|
37
|
+
# @option opts [String] :param_name ("access_token") the parameter name to use for transmission of the
|
38
|
+
# Access Token value in :body or :query transmission mode
|
39
|
+
def initialize(client, token, opts = {})
|
40
|
+
@client = client
|
41
|
+
@token = token.to_s
|
42
|
+
[:refresh_token, :expires_in, :expires_at].each do |arg|
|
43
|
+
instance_variable_set("@#{arg}", opts.delete(arg) || opts.delete(arg.to_s))
|
44
|
+
end
|
45
|
+
@expires_in ||= opts.delete("expires")
|
46
|
+
@expires_in &&= @expires_in.to_i
|
47
|
+
@expires_at &&= @expires_at.to_i
|
48
|
+
@expires_at ||= Time.now.to_i + @expires_in if @expires_in
|
49
|
+
@options = {
|
50
|
+
mode: opts.delete(:mode) || :header,
|
51
|
+
header_format: opts.delete(:header_format) || "Bearer %s",
|
52
|
+
param_name: opts.delete(:param_name) || "access_token"
|
53
|
+
}
|
54
|
+
@params = opts
|
55
|
+
end
|
56
|
+
|
57
|
+
# Indexer to additional params present in token response
|
58
|
+
#
|
59
|
+
# @param [String] key entry key to Hash
|
60
|
+
def [](key)
|
61
|
+
@params[key]
|
62
|
+
end
|
63
|
+
|
64
|
+
# Whether or not the token expires
|
65
|
+
#
|
66
|
+
# @return [Boolean]
|
67
|
+
def expires?
|
68
|
+
!!@expires_at # rubocop:disable DoubleNegation
|
69
|
+
end
|
70
|
+
|
71
|
+
# Whether or not the token is expired
|
72
|
+
#
|
73
|
+
# @return [Boolean]
|
74
|
+
def expired?
|
75
|
+
expires? && (expires_at < Time.now.to_i)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Refreshes the current Access Token
|
79
|
+
#
|
80
|
+
# @return [AccessToken] a new AccessToken
|
81
|
+
# @note options should be carried over to the new AccessToken
|
82
|
+
def refresh!(params = {})
|
83
|
+
fail("A refresh_token is not available") unless refresh_token
|
84
|
+
params.merge!(
|
85
|
+
client_id: @client.id,
|
86
|
+
client_secret: @client.secret,
|
87
|
+
grant_type: "refresh_token",
|
88
|
+
refresh_token: refresh_token
|
89
|
+
)
|
90
|
+
new_token = @client.get_token(params)
|
91
|
+
new_token.options = options
|
92
|
+
new_token.refresh_token = refresh_token unless new_token.refresh_token
|
93
|
+
new_token
|
94
|
+
end
|
95
|
+
|
96
|
+
# Convert AccessToken to a hash which can be used to rebuild itself with AccessToken.from_hash
|
97
|
+
#
|
98
|
+
# @return [Hash] a hash of AccessToken property values
|
99
|
+
def to_hash
|
100
|
+
params.merge(access_token: token, refresh_token: refresh_token, expires_at: expires_at)
|
101
|
+
end
|
102
|
+
|
103
|
+
# Make a request with the Access Token
|
104
|
+
#
|
105
|
+
# @param [Symbol] verb the HTTP request method
|
106
|
+
# @param [String] path the HTTP URL path of the request
|
107
|
+
# @param [Hash] opts the options to make the request with
|
108
|
+
# @see Client#request
|
109
|
+
def request(verb, path, opts = {}, &block)
|
110
|
+
self.token = opts
|
111
|
+
@client.request(verb, path, opts, &block)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Make a GET request with the Access Token
|
115
|
+
#
|
116
|
+
# @see AccessToken#request
|
117
|
+
def get(path, opts = {}, &block)
|
118
|
+
request(:get, path, opts, &block)
|
119
|
+
end
|
120
|
+
|
121
|
+
# Make a POST request with the Access Token
|
122
|
+
#
|
123
|
+
# @see AccessToken#request
|
124
|
+
def post(path, opts = {}, &block)
|
125
|
+
request(:post, path, opts, &block)
|
126
|
+
end
|
127
|
+
|
128
|
+
# Make a PUT request with the Access Token
|
129
|
+
#
|
130
|
+
# @see AccessToken#request
|
131
|
+
def put(path, opts = {}, &block)
|
132
|
+
request(:put, path, opts, &block)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Make a PATCH request with the Access Token
|
136
|
+
#
|
137
|
+
# @see AccessToken#request
|
138
|
+
def patch(path, opts = {}, &block)
|
139
|
+
request(:patch, path, opts, &block)
|
140
|
+
end
|
141
|
+
|
142
|
+
# Make a DELETE request with the Access Token
|
143
|
+
#
|
144
|
+
# @see AccessToken#request
|
145
|
+
def delete(path, opts = {}, &block)
|
146
|
+
request(:delete, path, opts, &block)
|
147
|
+
end
|
148
|
+
|
149
|
+
# Get the headers hash (includes Authorization token)
|
150
|
+
def headers
|
151
|
+
{ "Authorization" => options[:header_format] % token }
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
def token=(opts) # rubocop:disable MethodLength
|
157
|
+
case options[:mode]
|
158
|
+
when :header
|
159
|
+
opts[:headers] ||= {}
|
160
|
+
opts[:headers].merge!(headers)
|
161
|
+
when :query
|
162
|
+
opts[:params] ||= {}
|
163
|
+
opts[:params][options[:param_name]] = token
|
164
|
+
when :body
|
165
|
+
opts[:body] ||= {}
|
166
|
+
if opts[:body].is_a?(Hash)
|
167
|
+
opts[:body][options[:param_name]] = token
|
168
|
+
else
|
169
|
+
opts[:body] << "&#{options[:param_name]}=#{token}"
|
170
|
+
end
|
171
|
+
# @todo support for multi-part (file uploads)
|
172
|
+
else
|
173
|
+
fail("invalid :mode option of #{options[:mode]}")
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
module OAuth2
|
2
|
+
# The OAuth2::Client class
|
3
|
+
class Client
|
4
|
+
attr_reader :id, :secret, :site
|
5
|
+
attr_accessor :options
|
6
|
+
attr_writer :connection
|
7
|
+
|
8
|
+
# Instantiate a new OAuth 2.0 client using the
|
9
|
+
# Client ID and Client Secret registered to your
|
10
|
+
# application.
|
11
|
+
#
|
12
|
+
# @param [String] client_id the client_id value
|
13
|
+
# @param [String] client_secret the client_secret value
|
14
|
+
# @param [Hash] opts the options to create the client with
|
15
|
+
# @option opts [String] :site the OAuth2 provider site host
|
16
|
+
# @option opts [String] :authorize_url ("/oauth/authorize") absolute or relative URL path to the Authorization endpoint
|
17
|
+
# @option opts [String] :token_url ("/oauth/token") absolute or relative URL path to the Token endpoint
|
18
|
+
# @option opts [Symbol] :token_method (:post) HTTP method to use to request token (:get or :post)
|
19
|
+
# @option opts [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday with
|
20
|
+
# @option opts [FixNum] :max_redirects (5) maximum number of redirects to follow
|
21
|
+
# @option opts [Boolean] :raise_errors (true) whether or not to raise an OAuth2::Error
|
22
|
+
# on responses with 400+ status codes
|
23
|
+
def initialize(client_id, client_secret, options = {})
|
24
|
+
opts = options.dup
|
25
|
+
@id = client_id
|
26
|
+
@secret = client_secret
|
27
|
+
@site = opts.delete(:site)
|
28
|
+
ssl = opts.delete(:ssl)
|
29
|
+
@options = {
|
30
|
+
authorize_url: "/oauth/authorize",
|
31
|
+
token_url: "/oauth/token",
|
32
|
+
token_method: :post,
|
33
|
+
connection_opts: {},
|
34
|
+
max_redirects: 5,
|
35
|
+
raise_errors: true
|
36
|
+
}.merge(opts)
|
37
|
+
@options[:connection_opts][:ssl] = ssl if ssl
|
38
|
+
end
|
39
|
+
|
40
|
+
# Set the site host
|
41
|
+
#
|
42
|
+
# @param [String] the OAuth2 provider site host
|
43
|
+
def site=(value)
|
44
|
+
@connection = nil
|
45
|
+
@site = value
|
46
|
+
end
|
47
|
+
|
48
|
+
# The OAuth2::Connection object
|
49
|
+
def connection
|
50
|
+
@connection ||= Connection.new(site, options[:connection_opts])
|
51
|
+
end
|
52
|
+
|
53
|
+
# The authorize endpoint URL of the OAuth2 provider
|
54
|
+
#
|
55
|
+
# @param [Hash] params additional query parameters
|
56
|
+
def authorize_url(params = nil)
|
57
|
+
connection.build_url(options[:authorize_url], params).to_s
|
58
|
+
end
|
59
|
+
|
60
|
+
# The token endpoint URL of the OAuth2 provider
|
61
|
+
#
|
62
|
+
# @param [Hash] params additional query parameters
|
63
|
+
def token_url(params = nil)
|
64
|
+
connection.build_url(options[:token_url], params).to_s
|
65
|
+
end
|
66
|
+
|
67
|
+
# Makes a request relative to the specified site root.
|
68
|
+
#
|
69
|
+
# @param [Symbol] verb one of :get, :post, :put, :delete
|
70
|
+
# @param [String] url URL path of request
|
71
|
+
# @param [Hash] opts the options to make the request with
|
72
|
+
# @option opts [Hash] :params additional query parameters for the URL of the request
|
73
|
+
# @option opts [Hash, String] :body the body of the request
|
74
|
+
# @option opts [Hash] :headers http request headers
|
75
|
+
# @option opts [Boolean] :raise_errors whether or not to raise an OAuth2::Error on 400+ status
|
76
|
+
# code response for this request. Will default to client option
|
77
|
+
# @option opts [Symbol] :parse @see Response::initialize
|
78
|
+
# @yield [req] The OAuth2::Request
|
79
|
+
def request(verb, url, opts = {}) # rubocop:disable CyclomaticComplexity, MethodLength
|
80
|
+
# connection.response :logger, ::Logger.new($stdout) if ENV["OAUTH_DEBUG"] == "true"
|
81
|
+
|
82
|
+
url = connection.build_url(url, opts[:params]).to_s
|
83
|
+
response = connection.run_request(verb, url, opts[:body], opts[:headers], opts[:parse])
|
84
|
+
|
85
|
+
case response.status
|
86
|
+
when 301, 302, 303, 307
|
87
|
+
opts[:redirect_count] ||= 0
|
88
|
+
opts[:redirect_count] += 1
|
89
|
+
return response if opts[:redirect_count] > options[:max_redirects]
|
90
|
+
if response.status == 303
|
91
|
+
verb = :get
|
92
|
+
opts.delete(:body)
|
93
|
+
end
|
94
|
+
request(verb, response.headers["location"], opts)
|
95
|
+
when 200..299, 300..399
|
96
|
+
# on non-redirecting 3xx statuses, just return the response
|
97
|
+
response
|
98
|
+
when 400..599
|
99
|
+
error = Error.new(response)
|
100
|
+
fail(error) if opts.fetch(:raise_errors, options[:raise_errors])
|
101
|
+
response.error = error
|
102
|
+
response
|
103
|
+
else
|
104
|
+
error = Error.new(response)
|
105
|
+
fail(error, "Unhandled status code value of #{response.status}")
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Initializes an AccessToken by making a request to the token endpoint
|
110
|
+
#
|
111
|
+
# @param [Hash] params a Hash of params for the token endpoint
|
112
|
+
# @param [Hash] access token options, to pass to the AccessToken object
|
113
|
+
# @param [Class] class of access token for easier subclassing OAuth2::AccessToken
|
114
|
+
# @return [AccessToken] the initalized AccessToken
|
115
|
+
def get_token(params, access_token_opts = {}, access_token_class = AccessToken)
|
116
|
+
opts = { raise_errors: options[:raise_errors], parse: params.delete(:parse) }
|
117
|
+
if options[:token_method] == :post
|
118
|
+
headers = params.delete(:headers)
|
119
|
+
opts[:body] = params
|
120
|
+
opts[:headers] = { "Content-Type" => "application/x-www-form-urlencoded" }
|
121
|
+
opts[:headers].merge!(headers) if headers
|
122
|
+
else
|
123
|
+
opts[:params] = params
|
124
|
+
end
|
125
|
+
response = request(options[:token_method], token_url, opts)
|
126
|
+
error = Error.new(response)
|
127
|
+
fail(error) if options[:raise_errors] && !(response.parsed.is_a?(Hash) && response.parsed["access_token"])
|
128
|
+
access_token_class.from_hash(self, response.parsed.merge(access_token_opts))
|
129
|
+
end
|
130
|
+
|
131
|
+
# The Authorization Code strategy
|
132
|
+
#
|
133
|
+
# @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.1
|
134
|
+
def auth_code
|
135
|
+
@auth_code ||= OAuth2::Strategy::AuthCode.new(self)
|
136
|
+
end
|
137
|
+
|
138
|
+
# The Implicit strategy
|
139
|
+
#
|
140
|
+
# @see http://tools.ietf.org/html/draft-ietf-oauth-v2-26#section-4.2
|
141
|
+
def implicit
|
142
|
+
@implicit ||= OAuth2::Strategy::Implicit.new(self)
|
143
|
+
end
|
144
|
+
|
145
|
+
# The Resource Owner Password Credentials strategy
|
146
|
+
#
|
147
|
+
# @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.3
|
148
|
+
def password
|
149
|
+
@password ||= OAuth2::Strategy::Password.new(self)
|
150
|
+
end
|
151
|
+
|
152
|
+
# The Client Credentials strategy
|
153
|
+
#
|
154
|
+
# @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.4
|
155
|
+
def client_credentials
|
156
|
+
@client_credentials ||= OAuth2::Strategy::ClientCredentials.new(self)
|
157
|
+
end
|
158
|
+
|
159
|
+
def assertion
|
160
|
+
@assertion ||= OAuth2::Strategy::Assertion.new(self)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module OAuth2
|
2
|
+
class Connection
|
3
|
+
# A Set of allowed HTTP verbs.
|
4
|
+
METHODS = [:get, :post, :put, :delete, :head, :patch, :options]
|
5
|
+
|
6
|
+
# Public: Returns a Hash of URI query unencoded key/value pairs.
|
7
|
+
attr_reader :params
|
8
|
+
|
9
|
+
# Public: Returns a Hash of unencoded HTTP header key/value pairs.
|
10
|
+
attr_reader :headers
|
11
|
+
|
12
|
+
# Public: Returns a URI with the prefix used for all requests from this
|
13
|
+
# Connection. This includes a default host name, scheme, port, and path.
|
14
|
+
attr_reader :url_prefix
|
15
|
+
|
16
|
+
# Public: Returns the Faraday::Builder for this Connection.
|
17
|
+
attr_reader :builder
|
18
|
+
|
19
|
+
# Public: Returns a Hash of the request options.
|
20
|
+
attr_reader :options
|
21
|
+
|
22
|
+
# Public: Returns a Hash of the SSL options.
|
23
|
+
attr_reader :ssl
|
24
|
+
|
25
|
+
# Public: Sets the Hash of unencoded HTTP header key/value pairs.
|
26
|
+
def headers=(hash)
|
27
|
+
@headers.replace(hash)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Public: Sets the Hash of URI query unencoded key/value pairs.
|
31
|
+
def params=(hash)
|
32
|
+
@params.replace(hash)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/oauth2/error.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
module OAuth2
|
2
|
+
class Error < StandardError
|
3
|
+
attr_reader :response, :code, :description
|
4
|
+
|
5
|
+
# standard error values include:
|
6
|
+
# :invalid_request, :invalid_client, :invalid_token, :invalid_grant, :unsupported_grant_type, :invalid_scope
|
7
|
+
def initialize(response)
|
8
|
+
response.error = self
|
9
|
+
@response = response
|
10
|
+
|
11
|
+
message = []
|
12
|
+
|
13
|
+
if response.parsed.is_a?(Hash)
|
14
|
+
@code = response.parsed["error"]
|
15
|
+
@description = response.parsed["error_description"]
|
16
|
+
message << "#{@code}: #{@description}"
|
17
|
+
end
|
18
|
+
|
19
|
+
message << response.body
|
20
|
+
|
21
|
+
super(message.join("\n"))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module OAuth2
|
2
|
+
class MACToken < AccessToken
|
3
|
+
# Generates a MACToken from an AccessToken and secret
|
4
|
+
#
|
5
|
+
# @param [AccessToken] token the OAuth2::Token instance
|
6
|
+
# @option [String] secret the secret key value
|
7
|
+
# @param [Hash] opts the options to create the Access Token with
|
8
|
+
# @see MACToken#initialize
|
9
|
+
def self.from_access_token(token, secret, options = {})
|
10
|
+
new(token.client, token.token, secret, token.params.merge(
|
11
|
+
refresh_token: token.refresh_token,
|
12
|
+
expires_in: token.expires_in,
|
13
|
+
expires_at: token.expires_at
|
14
|
+
).merge(options))
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :algorithm, :secret
|
18
|
+
|
19
|
+
# Initalize a MACToken
|
20
|
+
#
|
21
|
+
# @param [Client] client the OAuth2::Client instance
|
22
|
+
# @param [String] token the Access Token value
|
23
|
+
# @option [String] secret the secret key value
|
24
|
+
# @param [Hash] opts the options to create the Access Token with
|
25
|
+
# @option opts [String] :refresh_token (nil) the refresh_token value
|
26
|
+
# @option opts [FixNum, String] :expires_in (nil) the number of seconds in which the AccessToken will expire
|
27
|
+
# @option opts [FixNum, String] :expires_at (nil) the epoch time in seconds in which AccessToken will expire
|
28
|
+
# @option opts [FixNum, String] :algorithm (hmac-sha-256) the algorithm to use for the HMAC digest (one of 'hmac-sha-256', 'hmac-sha-1')
|
29
|
+
def initialize(client, token, secret, opts = {})
|
30
|
+
@secret = secret
|
31
|
+
self.algorithm = opts.delete(:algorithm) || "hmac-sha-256"
|
32
|
+
|
33
|
+
super(client, token, opts)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Make a request with the MAC Token
|
37
|
+
#
|
38
|
+
# @param [Symbol] verb the HTTP request method
|
39
|
+
# @param [String] path the HTTP URL path of the request
|
40
|
+
# @param [Hash] opts the options to make the request with
|
41
|
+
# @see Client#request
|
42
|
+
def request(verb, path, opts = {}, &block)
|
43
|
+
url = client.connection.build_url(path, opts[:params]).to_s
|
44
|
+
|
45
|
+
opts[:headers] ||= {}
|
46
|
+
opts[:headers].merge!("Authorization" => header(verb, url))
|
47
|
+
|
48
|
+
@client.request(verb, path, opts, &block)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Get the headers hash (always an empty hash)
|
52
|
+
def headers
|
53
|
+
{}
|
54
|
+
end
|
55
|
+
|
56
|
+
# Generate the MAC header
|
57
|
+
#
|
58
|
+
# @param [Symbol] verb the HTTP request method
|
59
|
+
# @param [String] url the HTTP URL path of the request
|
60
|
+
def header(verb, url)
|
61
|
+
timestamp = Time.now.utc.to_i
|
62
|
+
nonce = generate_nonce
|
63
|
+
mac = signature(timestamp, nonce, verb, url)
|
64
|
+
"MAC id=\"#{token}\", ts=\"#{timestamp}\", nonce=\"#{nonce}\", mac=\"#{mac}\""
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
# No-op since we need the verb and path
|
70
|
+
# and the MAC always goes in a header
|
71
|
+
def token=(_)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|