oauth2-provider-jonrowe 0.1.0
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.
- 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
|
+
|