oauth2-provider-jonrowe 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +314 -0
- data/example/README.rdoc +11 -0
- data/example/application.rb +151 -0
- data/example/config.ru +3 -0
- data/example/environment.rb +11 -0
- data/example/models/connection.rb +9 -0
- data/example/models/note.rb +4 -0
- data/example/models/user.rb +6 -0
- data/example/public/style.css +78 -0
- data/example/schema.rb +27 -0
- data/example/views/authorize.erb +28 -0
- data/example/views/create_user.erb +3 -0
- data/example/views/home.erb +25 -0
- data/example/views/layout.erb +25 -0
- data/example/views/login.erb +20 -0
- data/example/views/new_client.erb +25 -0
- data/example/views/new_user.erb +22 -0
- data/example/views/show_client.erb +15 -0
- data/lib/oauth2/model.rb +17 -0
- data/lib/oauth2/model/authorization.rb +113 -0
- data/lib/oauth2/model/client.rb +55 -0
- data/lib/oauth2/model/client_owner.rb +13 -0
- data/lib/oauth2/model/hashing.rb +27 -0
- data/lib/oauth2/model/resource_owner.rb +26 -0
- data/lib/oauth2/model/schema.rb +42 -0
- data/lib/oauth2/provider.rb +117 -0
- data/lib/oauth2/provider/access_token.rb +66 -0
- data/lib/oauth2/provider/authorization.rb +168 -0
- data/lib/oauth2/provider/error.rb +29 -0
- data/lib/oauth2/provider/exchange.rb +212 -0
- data/lib/oauth2/router.rb +60 -0
- data/spec/factories.rb +27 -0
- data/spec/oauth2/model/authorization_spec.rb +216 -0
- data/spec/oauth2/model/client_spec.rb +55 -0
- data/spec/oauth2/model/resource_owner_spec.rb +55 -0
- data/spec/oauth2/provider/access_token_spec.rb +125 -0
- data/spec/oauth2/provider/authorization_spec.rb +323 -0
- data/spec/oauth2/provider/exchange_spec.rb +330 -0
- data/spec/oauth2/provider_spec.rb +531 -0
- data/spec/request_helpers.rb +46 -0
- data/spec/spec_helper.rb +44 -0
- data/spec/test_app/helper.rb +33 -0
- data/spec/test_app/provider/application.rb +61 -0
- data/spec/test_app/provider/views/authorize.erb +19 -0
- metadata +220 -0
@@ -0,0 +1,55 @@
|
|
1
|
+
module OAuth2
|
2
|
+
module Model
|
3
|
+
|
4
|
+
class Client < ActiveRecord::Base
|
5
|
+
set_table_name :oauth2_clients
|
6
|
+
|
7
|
+
belongs_to :oauth2_client_owner, :polymorphic => true
|
8
|
+
alias :owner :oauth2_client_owner
|
9
|
+
alias :owner= :oauth2_client_owner=
|
10
|
+
|
11
|
+
has_many :authorizations, :class_name => 'OAuth2::Model::Authorization', :dependent => :destroy
|
12
|
+
|
13
|
+
validates_uniqueness_of :client_id
|
14
|
+
validates_presence_of :name, :redirect_uri
|
15
|
+
validate :check_format_of_redirect_uri
|
16
|
+
|
17
|
+
attr_accessible :name, :redirect_uri
|
18
|
+
|
19
|
+
before_create :generate_credentials
|
20
|
+
|
21
|
+
def self.create_client_id
|
22
|
+
OAuth2.generate_id do |client_id|
|
23
|
+
count(:conditions => {:client_id => client_id}).zero?
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
attr_reader :client_secret
|
28
|
+
|
29
|
+
def client_secret=(secret)
|
30
|
+
@client_secret = secret
|
31
|
+
self.client_secret_hash = BCrypt::Password.create(secret)
|
32
|
+
end
|
33
|
+
|
34
|
+
def valid_client_secret?(secret)
|
35
|
+
BCrypt::Password.new(client_secret_hash) == secret
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def check_format_of_redirect_uri
|
41
|
+
uri = URI.parse(redirect_uri)
|
42
|
+
errors.add(:redirect_uri, 'must be an absolute URI') unless uri.absolute?
|
43
|
+
rescue
|
44
|
+
errors.add(:redirect_uri, 'must be a URI')
|
45
|
+
end
|
46
|
+
|
47
|
+
def generate_credentials
|
48
|
+
self.client_id = self.class.create_client_id
|
49
|
+
self.client_secret = OAuth2.random_string
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module OAuth2
|
2
|
+
module Model
|
3
|
+
|
4
|
+
module Hashing
|
5
|
+
def hashes_attributes(*attributes)
|
6
|
+
attributes.each do |attribute|
|
7
|
+
define_method("#{attribute}=") do |value|
|
8
|
+
instance_variable_set("@#{attribute}", value)
|
9
|
+
__send__("#{attribute}_hash=", value && OAuth2.hashify(value))
|
10
|
+
end
|
11
|
+
attr_reader attribute
|
12
|
+
end
|
13
|
+
|
14
|
+
class_eval <<-RUBY
|
15
|
+
def reload(*args)
|
16
|
+
super
|
17
|
+
#{ attributes.inspect }.each do |attribute|
|
18
|
+
instance_variable_set('@' + attribute.to_s, nil)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
RUBY
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module OAuth2
|
2
|
+
module Model
|
3
|
+
|
4
|
+
module ResourceOwner
|
5
|
+
def self.included(klass)
|
6
|
+
klass.has_many :oauth2_authorizations,
|
7
|
+
:class_name => 'OAuth2::Model::Authorization',
|
8
|
+
:as => :oauth2_resource_owner,
|
9
|
+
:dependent => :destroy
|
10
|
+
end
|
11
|
+
|
12
|
+
def grant_access!(client, options = {})
|
13
|
+
authorization = oauth2_authorizations.find_by_client_id(client.id) ||
|
14
|
+
Model::Authorization.create(:owner => self, :client => client)
|
15
|
+
|
16
|
+
if scopes = options[:scopes]
|
17
|
+
scopes = authorization.scopes + scopes
|
18
|
+
authorization.update_attribute(:scope, scopes.join(' '))
|
19
|
+
end
|
20
|
+
|
21
|
+
authorization
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module OAuth2
|
2
|
+
module Model
|
3
|
+
|
4
|
+
class Schema < ActiveRecord::Migration
|
5
|
+
def self.up
|
6
|
+
create_table :oauth2_clients, :force => true do |t|
|
7
|
+
t.timestamps
|
8
|
+
t.string :oauth2_client_owner_type
|
9
|
+
t.integer :oauth2_client_owner_id
|
10
|
+
t.string :name
|
11
|
+
t.string :client_id
|
12
|
+
t.string :client_secret_hash
|
13
|
+
t.string :redirect_uri
|
14
|
+
end
|
15
|
+
add_index :oauth2_clients, :client_id
|
16
|
+
|
17
|
+
create_table :oauth2_authorizations, :force => true do |t|
|
18
|
+
t.timestamps
|
19
|
+
t.string :oauth2_resource_owner_type
|
20
|
+
t.integer :oauth2_resource_owner_id
|
21
|
+
t.belongs_to :client
|
22
|
+
t.string :scope
|
23
|
+
t.string :code, :limit => 40
|
24
|
+
t.string :access_token_hash, :limit => 40
|
25
|
+
t.string :refresh_token_hash, :limit => 40
|
26
|
+
t.datetime :expires_at
|
27
|
+
end
|
28
|
+
add_index :oauth2_authorizations, [:client_id, :code]
|
29
|
+
add_index :oauth2_authorizations, [:access_token_hash]
|
30
|
+
add_index :oauth2_authorizations, [:client_id, :access_token_hash]
|
31
|
+
add_index :oauth2_authorizations, [:client_id, :refresh_token_hash]
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.down
|
35
|
+
drop_table :oauth2_clients
|
36
|
+
drop_table :oauth2_authorizations
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
@@ -0,0 +1,117 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
require 'digest/sha1'
|
3
|
+
require 'bcrypt'
|
4
|
+
require 'json'
|
5
|
+
require 'active_record'
|
6
|
+
|
7
|
+
module OAuth2
|
8
|
+
ROOT = File.expand_path(File.dirname(__FILE__) + '/..')
|
9
|
+
TOKEN_SIZE = 128
|
10
|
+
|
11
|
+
autoload :Model, ROOT + '/oauth2/model'
|
12
|
+
autoload :Router, ROOT + '/oauth2/router'
|
13
|
+
|
14
|
+
def self.random_string
|
15
|
+
rand(2 ** TOKEN_SIZE).to_s(36)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.generate_id(&predicate)
|
19
|
+
id = random_string
|
20
|
+
id = random_string until predicate.call(id)
|
21
|
+
id
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.hashify(token)
|
25
|
+
return nil unless String === token
|
26
|
+
Digest::SHA1.hexdigest(token)
|
27
|
+
end
|
28
|
+
|
29
|
+
ACCESS_TOKEN = 'access_token'
|
30
|
+
ASSERTION = 'assertion'
|
31
|
+
ASSERTION_TYPE = 'assertion_type'
|
32
|
+
AUTHORIZATION_CODE = 'authorization_code'
|
33
|
+
CLIENT_ID = 'client_id'
|
34
|
+
CLIENT_SECRET = 'client_secret'
|
35
|
+
CODE = 'code'
|
36
|
+
CODE_AND_TOKEN = 'code_and_token'
|
37
|
+
DURATION = 'duration'
|
38
|
+
ERROR = 'error'
|
39
|
+
ERROR_DESCRIPTION = 'error_description'
|
40
|
+
EXPIRES_IN = 'expires_in'
|
41
|
+
GRANT_TYPE = 'grant_type'
|
42
|
+
OAUTH_TOKEN = 'oauth_token'
|
43
|
+
PASSWORD = 'password'
|
44
|
+
REDIRECT_URI = 'redirect_uri'
|
45
|
+
REFRESH_TOKEN = 'refresh_token'
|
46
|
+
RESPONSE_TYPE = 'response_type'
|
47
|
+
SCOPE = 'scope'
|
48
|
+
STATE = 'state'
|
49
|
+
TOKEN = 'token'
|
50
|
+
USERNAME = 'username'
|
51
|
+
|
52
|
+
INVALID_REQUEST = 'invalid_request'
|
53
|
+
UNSUPPORTED_RESPONSE = 'unsupported_response_type'
|
54
|
+
REDIRECT_MISMATCH = 'redirect_uri_mismatch'
|
55
|
+
UNSUPPORTED_GRANT_TYPE = 'unsupported_grant_type'
|
56
|
+
INVALID_GRANT = 'invalid_grant'
|
57
|
+
INVALID_CLIENT = 'invalid_client'
|
58
|
+
UNAUTHORIZED_CLIENT = 'unauthorized_client'
|
59
|
+
INVALID_SCOPE = 'invalid_scope'
|
60
|
+
INVALID_TOKEN = 'invalid_token'
|
61
|
+
EXPIRED_TOKEN = 'expired_token'
|
62
|
+
INSUFFICIENT_SCOPE = 'insufficient_scope'
|
63
|
+
ACCESS_DENIED = 'access_denied'
|
64
|
+
|
65
|
+
class Provider
|
66
|
+
class << self
|
67
|
+
attr_accessor :realm, :enforce_ssl
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.clear_assertion_handlers!
|
71
|
+
@password_handler = nil
|
72
|
+
@assertion_handlers = {}
|
73
|
+
@assertion_filters = []
|
74
|
+
end
|
75
|
+
|
76
|
+
clear_assertion_handlers!
|
77
|
+
|
78
|
+
def self.handle_passwords(&block)
|
79
|
+
@password_handler = block
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.handle_password(client, username, password)
|
83
|
+
return nil unless @password_handler
|
84
|
+
@password_handler.call(client, username, password)
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.filter_assertions(&filter)
|
88
|
+
@assertion_filters.push(filter)
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.handle_assertions(assertion_type, &handler)
|
92
|
+
@assertion_handlers[assertion_type] = handler
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.handle_assertion(client, assertion)
|
96
|
+
return nil unless @assertion_filters.all? { |f| f.call(client) }
|
97
|
+
handler = @assertion_handlers[assertion.type]
|
98
|
+
handler ? handler.call(client, assertion.value) : nil
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.parse(*args)
|
102
|
+
Router.parse(*args)
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.access_token(*args)
|
106
|
+
Router.access_token(*args)
|
107
|
+
end
|
108
|
+
|
109
|
+
EXPIRY_TIME = 3600
|
110
|
+
|
111
|
+
autoload :Authorization, ROOT + '/oauth2/provider/authorization'
|
112
|
+
autoload :Exchange, ROOT + '/oauth2/provider/exchange'
|
113
|
+
autoload :AccessToken, ROOT + '/oauth2/provider/access_token'
|
114
|
+
autoload :Error, ROOT + '/oauth2/provider/error'
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module OAuth2
|
2
|
+
class Provider
|
3
|
+
|
4
|
+
class AccessToken
|
5
|
+
attr_reader :authorization
|
6
|
+
|
7
|
+
def initialize(resource_owner = nil, scopes = [], access_token = nil, error = nil)
|
8
|
+
@resource_owner = resource_owner
|
9
|
+
@scopes = scopes
|
10
|
+
@access_token = access_token
|
11
|
+
@error = error && INVALID_REQUEST
|
12
|
+
|
13
|
+
authorize!(access_token, error)
|
14
|
+
validate!
|
15
|
+
end
|
16
|
+
|
17
|
+
def client
|
18
|
+
valid? ? @authorization.client : nil
|
19
|
+
end
|
20
|
+
|
21
|
+
def owner
|
22
|
+
valid? ? @authorization.owner : nil
|
23
|
+
end
|
24
|
+
|
25
|
+
def response_headers
|
26
|
+
return {} if valid?
|
27
|
+
error_message = "OAuth realm='#{ Provider.realm }'"
|
28
|
+
error_message << ", error='#{ @error }'" unless @error == ''
|
29
|
+
{'WWW-Authenticate' => error_message}
|
30
|
+
end
|
31
|
+
|
32
|
+
def response_status
|
33
|
+
case @error
|
34
|
+
when INVALID_REQUEST, INVALID_TOKEN, EXPIRED_TOKEN then 401
|
35
|
+
when INSUFFICIENT_SCOPE then 403
|
36
|
+
when '' then 401
|
37
|
+
else 200
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def valid?
|
42
|
+
@error.nil?
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def authorize!(access_token, error)
|
48
|
+
return unless @authorization = Model.find_access_token(access_token)
|
49
|
+
@authorization.update_attribute(:access_token, nil) if error
|
50
|
+
end
|
51
|
+
|
52
|
+
def validate!
|
53
|
+
return @error = '' unless @access_token
|
54
|
+
return @error = INVALID_TOKEN unless @authorization
|
55
|
+
return @error = EXPIRED_TOKEN if @authorization.expired?
|
56
|
+
return @error = INSUFFICIENT_SCOPE unless @authorization.in_scope?(@scopes)
|
57
|
+
|
58
|
+
if @resource_owner and @authorization.owner != @resource_owner
|
59
|
+
@error = INSUFFICIENT_SCOPE
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
@@ -0,0 +1,168 @@
|
|
1
|
+
module OAuth2
|
2
|
+
class Provider
|
3
|
+
|
4
|
+
class Authorization
|
5
|
+
attr_reader :owner, :client,
|
6
|
+
:code, :access_token,
|
7
|
+
:expires_in, :refresh_token,
|
8
|
+
:error, :error_description
|
9
|
+
|
10
|
+
REQUIRED_PARAMS = [RESPONSE_TYPE, CLIENT_ID, REDIRECT_URI]
|
11
|
+
VALID_PARAMS = REQUIRED_PARAMS + [SCOPE, STATE]
|
12
|
+
VALID_RESPONSES = [CODE, TOKEN, CODE_AND_TOKEN]
|
13
|
+
|
14
|
+
def initialize(resource_owner, params)
|
15
|
+
@owner = resource_owner
|
16
|
+
@params = params
|
17
|
+
@scope = params[SCOPE]
|
18
|
+
@state = params[STATE]
|
19
|
+
|
20
|
+
validate!
|
21
|
+
return unless @owner and not @error
|
22
|
+
|
23
|
+
@model = Model::Authorization.for(@owner, @client)
|
24
|
+
return unless @model and @model.in_scope?(scopes) and not @model.expired?
|
25
|
+
|
26
|
+
@authorized = true
|
27
|
+
@code = @model.generate_code
|
28
|
+
end
|
29
|
+
|
30
|
+
def scopes
|
31
|
+
@scope ? @scope.split(/\s+/).delete_if { |s| s.empty? } : []
|
32
|
+
end
|
33
|
+
|
34
|
+
def unauthorized_scopes
|
35
|
+
@model ? scopes.select { |s| not @model.in_scope?(s) } : scopes
|
36
|
+
end
|
37
|
+
|
38
|
+
def grant_access!(options = {})
|
39
|
+
@model = Model::Authorization.for_response_type(@params[RESPONSE_TYPE],
|
40
|
+
:owner => @owner,
|
41
|
+
:client => @client,
|
42
|
+
:scope => @scope,
|
43
|
+
:duration => options[:duration])
|
44
|
+
|
45
|
+
@code = @model.code
|
46
|
+
@access_token = @model.access_token
|
47
|
+
@refresh_token = @model.refresh_token
|
48
|
+
@expires_in = @model.expires_in
|
49
|
+
|
50
|
+
unless @params[RESPONSE_TYPE] == CODE
|
51
|
+
@expires_in = @model.expires_in
|
52
|
+
end
|
53
|
+
|
54
|
+
@authorized = true
|
55
|
+
end
|
56
|
+
|
57
|
+
def deny_access!
|
58
|
+
@code = @access_token = @refresh_token = nil
|
59
|
+
@error = ACCESS_DENIED
|
60
|
+
@error_description = "The user denied you access"
|
61
|
+
end
|
62
|
+
|
63
|
+
def params
|
64
|
+
params = {}
|
65
|
+
VALID_PARAMS.each { |key| params[key] = @params[key] if @params.has_key?(key) }
|
66
|
+
params
|
67
|
+
end
|
68
|
+
|
69
|
+
def redirect?
|
70
|
+
@client and (@authorized or not valid?)
|
71
|
+
end
|
72
|
+
|
73
|
+
def redirect_uri
|
74
|
+
return nil unless @client
|
75
|
+
base_redirect_uri = @client.redirect_uri
|
76
|
+
|
77
|
+
if not valid?
|
78
|
+
query = to_query_string(ERROR, ERROR_DESCRIPTION, STATE)
|
79
|
+
"#{ base_redirect_uri }?#{ query }"
|
80
|
+
|
81
|
+
elsif @params[RESPONSE_TYPE] == CODE_AND_TOKEN
|
82
|
+
query = to_query_string(CODE, STATE)
|
83
|
+
fragment = to_query_string(ACCESS_TOKEN, EXPIRES_IN, SCOPE)
|
84
|
+
"#{ base_redirect_uri }#{ query.empty? ? '' : '?' + query }##{ fragment }"
|
85
|
+
|
86
|
+
elsif @params[RESPONSE_TYPE] == 'token'
|
87
|
+
fragment = to_query_string(ACCESS_TOKEN, EXPIRES_IN, SCOPE, STATE)
|
88
|
+
"#{ base_redirect_uri }##{ fragment }"
|
89
|
+
|
90
|
+
else
|
91
|
+
query = to_query_string(CODE, SCOPE, STATE)
|
92
|
+
"#{ base_redirect_uri }?#{ query }"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def response_body
|
97
|
+
return nil if @client and valid?
|
98
|
+
JSON.unparse(
|
99
|
+
ERROR => INVALID_REQUEST,
|
100
|
+
ERROR_DESCRIPTION => 'This is not a valid OAuth request')
|
101
|
+
end
|
102
|
+
|
103
|
+
def response_headers
|
104
|
+
valid? ? {} : Exchange::RESPONSE_HEADERS
|
105
|
+
end
|
106
|
+
|
107
|
+
def response_status
|
108
|
+
return 200 if valid?
|
109
|
+
@client ? 302 : 400
|
110
|
+
end
|
111
|
+
|
112
|
+
def valid?
|
113
|
+
@error.nil?
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
def validate!
|
119
|
+
@client = @params[CLIENT_ID] && Model::Client.find_by_client_id(@params[CLIENT_ID])
|
120
|
+
unless @client
|
121
|
+
@error = INVALID_CLIENT
|
122
|
+
@error_description = "Unknown client ID #{@params[CLIENT_ID]}"
|
123
|
+
end
|
124
|
+
|
125
|
+
REQUIRED_PARAMS.each do |param|
|
126
|
+
next if @params.has_key?(param)
|
127
|
+
@error = INVALID_REQUEST
|
128
|
+
@error_description = "Missing required parameter #{param}"
|
129
|
+
end
|
130
|
+
return if @error
|
131
|
+
|
132
|
+
[SCOPE, STATE].each do |param|
|
133
|
+
next unless @params.has_key?(param)
|
134
|
+
if @params[param] =~ /\r\n/
|
135
|
+
@error = INVALID_REQUEST
|
136
|
+
@error_description = "Illegal value for #{param} parameter"
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
unless VALID_RESPONSES.include?(@params[RESPONSE_TYPE])
|
141
|
+
@error = UNSUPPORTED_RESPONSE
|
142
|
+
@error_description = "Response type #{@params[RESPONSE_TYPE]} is not supported"
|
143
|
+
end
|
144
|
+
|
145
|
+
@client = Model::Client.find_by_client_id(@params[CLIENT_ID])
|
146
|
+
unless @client
|
147
|
+
@error = INVALID_CLIENT
|
148
|
+
@error_description = "Unknown client ID #{@params[CLIENT_ID]}"
|
149
|
+
end
|
150
|
+
|
151
|
+
if @client and @client.redirect_uri and @client.redirect_uri != @params[REDIRECT_URI]
|
152
|
+
@error = REDIRECT_MISMATCH
|
153
|
+
@error_description = "Parameter redirect_uri does not match registered URI"
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def to_query_string(*ivars)
|
158
|
+
ivars.map { |key|
|
159
|
+
value = instance_variable_get("@#{key}")
|
160
|
+
value = value.join(' ') if Array === value
|
161
|
+
value ? "#{ key }=#{ CGI.escape(value.to_s) }" : nil
|
162
|
+
}.compact.join('&')
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|