songkick-oauth2-provider 0.10.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 +394 -0
- data/example/README.rdoc +11 -0
- data/example/application.rb +159 -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/error.erb +6 -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/songkick/oauth2/model.rb +20 -0
- data/lib/songkick/oauth2/model/authorization.rb +126 -0
- data/lib/songkick/oauth2/model/client.rb +61 -0
- data/lib/songkick/oauth2/model/client_owner.rb +15 -0
- data/lib/songkick/oauth2/model/hashing.rb +29 -0
- data/lib/songkick/oauth2/model/resource_owner.rb +54 -0
- data/lib/songkick/oauth2/provider.rb +122 -0
- data/lib/songkick/oauth2/provider/access_token.rb +68 -0
- data/lib/songkick/oauth2/provider/authorization.rb +190 -0
- data/lib/songkick/oauth2/provider/error.rb +22 -0
- data/lib/songkick/oauth2/provider/exchange.rb +227 -0
- data/lib/songkick/oauth2/router.rb +79 -0
- data/lib/songkick/oauth2/schema.rb +17 -0
- data/lib/songkick/oauth2/schema/20120828112156_songkick_oauth2_schema_original_schema.rb +36 -0
- data/spec/factories.rb +27 -0
- data/spec/request_helpers.rb +52 -0
- data/spec/songkick/oauth2/model/authorization_spec.rb +216 -0
- data/spec/songkick/oauth2/model/client_spec.rb +55 -0
- data/spec/songkick/oauth2/model/resource_owner_spec.rb +88 -0
- data/spec/songkick/oauth2/provider/access_token_spec.rb +125 -0
- data/spec/songkick/oauth2/provider/authorization_spec.rb +346 -0
- data/spec/songkick/oauth2/provider/exchange_spec.rb +353 -0
- data/spec/songkick/oauth2/provider_spec.rb +545 -0
- data/spec/spec_helper.rb +62 -0
- data/spec/test_app/helper.rb +33 -0
- data/spec/test_app/provider/application.rb +68 -0
- data/spec/test_app/provider/views/authorize.erb +19 -0
- metadata +273 -0
@@ -0,0 +1,216 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Songkick::OAuth2::Model::Authorization do
|
4
|
+
let(:client) { Factory :client }
|
5
|
+
let(:impostor) { Factory :client }
|
6
|
+
let(:owner) { Factory :owner }
|
7
|
+
let(:user) { Factory :owner }
|
8
|
+
|
9
|
+
let(:authorization) do
|
10
|
+
create_authorization(:owner => owner, :client => client)
|
11
|
+
end
|
12
|
+
|
13
|
+
it "is vaid" do
|
14
|
+
authorization.should be_valid
|
15
|
+
end
|
16
|
+
|
17
|
+
it "is not valid without a client" do
|
18
|
+
authorization.client = nil
|
19
|
+
authorization.should_not be_valid
|
20
|
+
end
|
21
|
+
|
22
|
+
it "is not valid without an owner" do
|
23
|
+
authorization.owner = nil
|
24
|
+
authorization.should_not be_valid
|
25
|
+
end
|
26
|
+
|
27
|
+
describe "when there are existing authorizations" do
|
28
|
+
before do
|
29
|
+
create_authorization(
|
30
|
+
:owner => user,
|
31
|
+
:client => impostor,
|
32
|
+
:access_token => 'existing_access_token')
|
33
|
+
|
34
|
+
create_authorization(
|
35
|
+
:owner => owner,
|
36
|
+
:client => client,
|
37
|
+
:code => 'existing_code')
|
38
|
+
|
39
|
+
create_authorization(
|
40
|
+
:owner => owner,
|
41
|
+
:client => client,
|
42
|
+
:refresh_token => 'existing_refresh_token')
|
43
|
+
end
|
44
|
+
|
45
|
+
it "is valid if its access_token is unique" do
|
46
|
+
authorization.should be_valid
|
47
|
+
end
|
48
|
+
|
49
|
+
it "is valid if both access_tokens are nil" do
|
50
|
+
Songkick::OAuth2::Model::Authorization.first.update_attribute(:access_token, nil)
|
51
|
+
authorization.access_token = nil
|
52
|
+
authorization.should be_valid
|
53
|
+
end
|
54
|
+
|
55
|
+
it "is not valid if its access_token is not unique" do
|
56
|
+
authorization.access_token = 'existing_access_token'
|
57
|
+
authorization.should_not be_valid
|
58
|
+
end
|
59
|
+
|
60
|
+
it "is valid if it has a unique code for its client" do
|
61
|
+
authorization.client = impostor
|
62
|
+
authorization.code = 'existing_code'
|
63
|
+
authorization.should be_valid
|
64
|
+
end
|
65
|
+
|
66
|
+
it "is not valid if it does not have a unique client and code" do
|
67
|
+
authorization.code = 'existing_code'
|
68
|
+
authorization.should_not be_valid
|
69
|
+
end
|
70
|
+
|
71
|
+
it "is valid if it has a unique refresh_token for its client" do
|
72
|
+
authorization.client = impostor
|
73
|
+
authorization.refresh_token = 'existing_refresh_token'
|
74
|
+
authorization.should be_valid
|
75
|
+
end
|
76
|
+
|
77
|
+
it "is not valid if it does not have a unique client and refresh_token" do
|
78
|
+
authorization.refresh_token = 'existing_refresh_token'
|
79
|
+
authorization.should_not be_valid
|
80
|
+
end
|
81
|
+
|
82
|
+
describe ".create_code" do
|
83
|
+
before { Songkick::OAuth2.stub(:random_string).and_return('existing_code', 'new_code') }
|
84
|
+
|
85
|
+
it "returns the first code the client has not used" do
|
86
|
+
Songkick::OAuth2::Model::Authorization.create_code(client).should == 'new_code'
|
87
|
+
end
|
88
|
+
|
89
|
+
it "returns the first code another client has not used" do
|
90
|
+
Songkick::OAuth2::Model::Authorization.create_code(impostor).should == 'existing_code'
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
describe ".create_access_token" do
|
95
|
+
before { Songkick::OAuth2.stub(:random_string).and_return('existing_access_token', 'new_access_token') }
|
96
|
+
|
97
|
+
it "returns the first unused token it can find" do
|
98
|
+
Songkick::OAuth2::Model::Authorization.create_access_token.should == 'new_access_token'
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
describe ".create_refresh_token" do
|
103
|
+
before { Songkick::OAuth2.stub(:random_string).and_return('existing_refresh_token', 'new_refresh_token') }
|
104
|
+
|
105
|
+
it "returns the first refresh_token the client has not used" do
|
106
|
+
Songkick::OAuth2::Model::Authorization.create_refresh_token(client).should == 'new_refresh_token'
|
107
|
+
end
|
108
|
+
|
109
|
+
it "returns the first refresh_token another client has not used" do
|
110
|
+
Songkick::OAuth2::Model::Authorization.create_refresh_token(impostor).should == 'existing_refresh_token'
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
describe "#exchange!" do
|
116
|
+
it "saves the record" do
|
117
|
+
authorization.should_receive(:save!)
|
118
|
+
authorization.exchange!
|
119
|
+
end
|
120
|
+
|
121
|
+
it "uses its helpers to find unique tokens" do
|
122
|
+
Songkick::OAuth2::Model::Authorization.should_receive(:create_access_token).and_return('access_token')
|
123
|
+
authorization.exchange!
|
124
|
+
authorization.access_token.should == 'access_token'
|
125
|
+
end
|
126
|
+
|
127
|
+
it "updates the tokens correctly" do
|
128
|
+
authorization.exchange!
|
129
|
+
authorization.should be_valid
|
130
|
+
authorization.code.should be_nil
|
131
|
+
authorization.refresh_token.should be_nil
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
describe "#expired?" do
|
136
|
+
it "returns false when not expiry is set" do
|
137
|
+
authorization.should_not be_expired
|
138
|
+
end
|
139
|
+
|
140
|
+
it "returns false when expiry is in the future" do
|
141
|
+
authorization.expires_at = 2.days.from_now
|
142
|
+
authorization.should_not be_expired
|
143
|
+
end
|
144
|
+
|
145
|
+
it "returns true when expiry is in the past" do
|
146
|
+
authorization.expires_at = 2.days.ago
|
147
|
+
authorization.should be_expired
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
describe "#grants_access?" do
|
152
|
+
it "returns true given the right user" do
|
153
|
+
authorization.grants_access?(owner).should be_true
|
154
|
+
end
|
155
|
+
|
156
|
+
it "returns false given the wrong user" do
|
157
|
+
authorization.grants_access?(user).should be_false
|
158
|
+
end
|
159
|
+
|
160
|
+
describe "when the authorization is expired" do
|
161
|
+
before { authorization.expires_at = 2.days.ago }
|
162
|
+
|
163
|
+
it "returns false in all cases" do
|
164
|
+
authorization.grants_access?(owner).should be_false
|
165
|
+
authorization.grants_access?(user).should be_false
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
describe "with a scope" do
|
171
|
+
before { authorization.scope = 'foo bar' }
|
172
|
+
|
173
|
+
describe "#in_scope?" do
|
174
|
+
it "returns true for authorized scopes" do
|
175
|
+
authorization.should be_in_scope('foo')
|
176
|
+
authorization.should be_in_scope('bar')
|
177
|
+
end
|
178
|
+
|
179
|
+
it "returns false for unauthorized scopes" do
|
180
|
+
authorization.should_not be_in_scope('qux')
|
181
|
+
authorization.should_not be_in_scope('fo')
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
describe "#grants_access?" do
|
186
|
+
it "returns true given the right user and all authorization scopes" do
|
187
|
+
authorization.grants_access?(owner, 'foo', 'bar').should be_true
|
188
|
+
end
|
189
|
+
|
190
|
+
it "returns true given the right user and some authorization scopes" do
|
191
|
+
authorization.grants_access?(owner, 'bar').should be_true
|
192
|
+
end
|
193
|
+
|
194
|
+
it "returns false given the right user and some unauthorization scopes" do
|
195
|
+
authorization.grants_access?(owner, 'foo', 'bar', 'qux').should be_false
|
196
|
+
end
|
197
|
+
|
198
|
+
it "returns false given an unauthorized scope" do
|
199
|
+
authorization.grants_access?(owner, 'qux').should be_false
|
200
|
+
end
|
201
|
+
|
202
|
+
it "returns true given the right user" do
|
203
|
+
authorization.grants_access?(owner).should be_true
|
204
|
+
end
|
205
|
+
|
206
|
+
it "returns false given the wrong user" do
|
207
|
+
authorization.grants_access?(user).should be_false
|
208
|
+
end
|
209
|
+
|
210
|
+
it "returns false given the wrong user and an authorized scope" do
|
211
|
+
authorization.grants_access?(user, 'foo').should be_false
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Songkick::OAuth2::Model::Client do
|
4
|
+
before do
|
5
|
+
@client = Songkick::OAuth2::Model::Client.create(:name => 'App', :redirect_uri => 'http://example.com/cb')
|
6
|
+
@owner = Factory(:owner)
|
7
|
+
Factory(:authorization, :client => @client, :owner => @owner)
|
8
|
+
end
|
9
|
+
|
10
|
+
it "is valid" do
|
11
|
+
@client.should be_valid
|
12
|
+
end
|
13
|
+
|
14
|
+
it "is invalid without a name" do
|
15
|
+
@client.name = nil
|
16
|
+
@client.should_not be_valid
|
17
|
+
end
|
18
|
+
|
19
|
+
it "is invalid without a redirect_uri" do
|
20
|
+
@client.redirect_uri = nil
|
21
|
+
@client.should_not be_valid
|
22
|
+
end
|
23
|
+
|
24
|
+
it "is invalid with a non-URI redirect_uri" do
|
25
|
+
@client.redirect_uri = 'foo'
|
26
|
+
@client.should_not be_valid
|
27
|
+
end
|
28
|
+
|
29
|
+
# http://en.wikipedia.org/wiki/HTTP_response_splitting
|
30
|
+
it "is invalid if the URI contains HTTP line breaks" do
|
31
|
+
@client.redirect_uri = "http://example.com/c\r\nb"
|
32
|
+
@client.should_not be_valid
|
33
|
+
end
|
34
|
+
|
35
|
+
it "cannot mass-assign client_id" do
|
36
|
+
@client.update_attributes(:client_id => 'foo')
|
37
|
+
@client.client_id.should_not == 'foo'
|
38
|
+
end
|
39
|
+
|
40
|
+
it "cannot mass-assign client_secret" do
|
41
|
+
@client.update_attributes(:client_secret => 'foo')
|
42
|
+
@client.client_secret.should_not == 'foo'
|
43
|
+
end
|
44
|
+
|
45
|
+
it "has client_id and client_secret filled in" do
|
46
|
+
@client.client_id.should_not be_nil
|
47
|
+
@client.client_secret.should_not be_nil
|
48
|
+
end
|
49
|
+
|
50
|
+
it "destroys its authorizations on destroy" do
|
51
|
+
@client.destroy
|
52
|
+
Songkick::OAuth2::Model::Authorization.count.should be_zero
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Songkick::OAuth2::Model::ResourceOwner do
|
4
|
+
before do
|
5
|
+
@owner = Factory(:owner)
|
6
|
+
@client = Factory(:client)
|
7
|
+
end
|
8
|
+
|
9
|
+
describe "#grant_access!" do
|
10
|
+
it "raises an error when passed an invalid client argument" do
|
11
|
+
lambda{ @owner.grant_access!('client') }.should raise_error(ArgumentError)
|
12
|
+
end
|
13
|
+
|
14
|
+
it "creates an authorization between the owner and the client" do
|
15
|
+
authorization = Songkick::OAuth2::Model::Authorization.new
|
16
|
+
Songkick::OAuth2::Model::Authorization.should_receive(:new).and_return(authorization)
|
17
|
+
@owner.grant_access!(@client)
|
18
|
+
end
|
19
|
+
|
20
|
+
# This is hacky, but mocking ActiveRecord turns out to get messy
|
21
|
+
it "creates an Authorization" do
|
22
|
+
Songkick::OAuth2::Model::Authorization.count.should == 0
|
23
|
+
@owner.grant_access!(@client)
|
24
|
+
Songkick::OAuth2::Model::Authorization.count.should == 1
|
25
|
+
end
|
26
|
+
|
27
|
+
it "returns the authorization" do
|
28
|
+
@owner.grant_access!(@client).should be_kind_of(Songkick::OAuth2::Model::Authorization)
|
29
|
+
end
|
30
|
+
|
31
|
+
# This method must return the same owner object, since the assertion
|
32
|
+
# handler may modify it -- either by changing its attributes or by extending
|
33
|
+
# it with new methods. These changes must be returned to the app calling the
|
34
|
+
# Provider interface.
|
35
|
+
it "sets the receiver as the authorization's owner" do
|
36
|
+
authorization = @owner.grant_access!(@client)
|
37
|
+
authorization.owner.should be_equal(@owner)
|
38
|
+
end
|
39
|
+
|
40
|
+
it "sets the duration of the authorization" do
|
41
|
+
authorization = @owner.grant_access!(@client, :duration => 5.hours)
|
42
|
+
authorization.expires_at.to_i.should == (Time.now + 5.hours.to_i).to_i
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe "when there is an existing authorization" do
|
47
|
+
before do
|
48
|
+
@authorization = Factory(:authorization, :owner => @owner, :client => @client)
|
49
|
+
end
|
50
|
+
|
51
|
+
it "does not create a new one" do
|
52
|
+
Songkick::OAuth2::Model::Authorization.should_not_receive(:new)
|
53
|
+
@owner.grant_access!(@client)
|
54
|
+
end
|
55
|
+
|
56
|
+
it "updates the authorization with scopes" do
|
57
|
+
@owner.grant_access!(@client, :scopes => ['foo', 'bar'])
|
58
|
+
@authorization.reload
|
59
|
+
@authorization.scopes.should == Set.new(['foo', 'bar'])
|
60
|
+
end
|
61
|
+
|
62
|
+
describe "with scopes" do
|
63
|
+
before do
|
64
|
+
@authorization.update_attribute(:scope, 'foo bar')
|
65
|
+
end
|
66
|
+
|
67
|
+
it "merges the new scopes with the existing ones" do
|
68
|
+
@owner.grant_access!(@client, :scopes => ['qux'])
|
69
|
+
@authorization.reload
|
70
|
+
@authorization.scopes.should == Set.new(['foo', 'bar', 'qux'])
|
71
|
+
end
|
72
|
+
|
73
|
+
it "does not add duplicate scopes to the list" do
|
74
|
+
@owner.grant_access!(@client, :scopes => ['qux'])
|
75
|
+
@owner.grant_access!(@client, :scopes => ['qux'])
|
76
|
+
@authorization.reload
|
77
|
+
@authorization.scopes.should == Set.new(['foo', 'bar', 'qux'])
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
it "destroys its authorizations on destroy" do
|
83
|
+
Factory(:authorization, :owner => @owner, :client => @client)
|
84
|
+
@owner.destroy
|
85
|
+
Songkick::OAuth2::Model::Authorization.count.should be_zero
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
@@ -0,0 +1,125 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Songkick::OAuth2::Provider::AccessToken do
|
4
|
+
before do
|
5
|
+
@alice = TestApp::User['Alice']
|
6
|
+
@bob = TestApp::User['Bob']
|
7
|
+
|
8
|
+
Factory(:authorization,
|
9
|
+
:owner => @alice,
|
10
|
+
:scope => 'profile',
|
11
|
+
:access_token => 'sesame')
|
12
|
+
|
13
|
+
@authorization = Factory(:authorization,
|
14
|
+
:owner => @bob,
|
15
|
+
:scope => 'profile',
|
16
|
+
:access_token => 'magic-key')
|
17
|
+
|
18
|
+
Songkick::OAuth2::Provider.realm = 'Demo App'
|
19
|
+
end
|
20
|
+
|
21
|
+
let :token do
|
22
|
+
Songkick::OAuth2::Provider::AccessToken.new(@bob, ['profile'], 'magic-key')
|
23
|
+
end
|
24
|
+
|
25
|
+
shared_examples_for "valid token" do
|
26
|
+
it "is valid" do
|
27
|
+
token.should be_valid
|
28
|
+
end
|
29
|
+
it "does not add headers" do
|
30
|
+
token.response_headers.should == {}
|
31
|
+
end
|
32
|
+
it "has an OK status code" do
|
33
|
+
token.response_status.should == 200
|
34
|
+
end
|
35
|
+
it "returns the owner who granted the authorization" do
|
36
|
+
token.owner.should == @bob
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
shared_examples_for "invalid token" do
|
41
|
+
it "is not valid" do
|
42
|
+
token.should_not be_valid
|
43
|
+
end
|
44
|
+
it "does not return the owner" do
|
45
|
+
token.owner.should be_nil
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
describe "with the right user, scope and token" do
|
50
|
+
it_should_behave_like "valid token"
|
51
|
+
end
|
52
|
+
|
53
|
+
describe "with no user" do
|
54
|
+
let :token do
|
55
|
+
Songkick::OAuth2::Provider::AccessToken.new(nil, ['profile'], 'magic-key')
|
56
|
+
end
|
57
|
+
it_should_behave_like "valid token"
|
58
|
+
end
|
59
|
+
|
60
|
+
describe "with less scope than was granted" do
|
61
|
+
let :token do
|
62
|
+
Songkick::OAuth2::Provider::AccessToken.new(@bob, [], 'magic-key')
|
63
|
+
end
|
64
|
+
it_should_behave_like "valid token"
|
65
|
+
end
|
66
|
+
|
67
|
+
describe "when the authorization has expired" do
|
68
|
+
before { @authorization.update_attribute(:expires_at, 1.hour.ago) }
|
69
|
+
it_should_behave_like "invalid token"
|
70
|
+
|
71
|
+
it "returns an error response" do
|
72
|
+
token.response_headers['WWW-Authenticate'].should == "OAuth realm='Demo App', error='expired_token'"
|
73
|
+
token.response_status.should == 401
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe "with a non-existent token" do
|
78
|
+
let :token do
|
79
|
+
Songkick::OAuth2::Provider::AccessToken.new(@bob, ['profile'], 'is-the-password-books')
|
80
|
+
end
|
81
|
+
it_should_behave_like "invalid token"
|
82
|
+
|
83
|
+
it "returns an error response" do
|
84
|
+
token.response_headers['WWW-Authenticate'].should == "OAuth realm='Demo App', error='invalid_token'"
|
85
|
+
token.response_status.should == 401
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe "with a token for the wrong user" do
|
90
|
+
let :token do
|
91
|
+
Songkick::OAuth2::Provider::AccessToken.new(@bob, ['profile'], 'sesame')
|
92
|
+
end
|
93
|
+
it_should_behave_like "invalid token"
|
94
|
+
|
95
|
+
it "returns an error response" do
|
96
|
+
token.response_headers['WWW-Authenticate'].should == "OAuth realm='Demo App', error='insufficient_scope'"
|
97
|
+
token.response_status.should == 403
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
describe "with a token for an ungranted scope" do
|
102
|
+
let :token do
|
103
|
+
Songkick::OAuth2::Provider::AccessToken.new(@bob, ['offline_access'], 'magic-key')
|
104
|
+
end
|
105
|
+
it_should_behave_like "invalid token"
|
106
|
+
|
107
|
+
it "returns an error response" do
|
108
|
+
token.response_headers['WWW-Authenticate'].should == "OAuth realm='Demo App', error='insufficient_scope'"
|
109
|
+
token.response_status.should == 403
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
describe "with no token string" do
|
114
|
+
let :token do
|
115
|
+
Songkick::OAuth2::Provider::AccessToken.new(@bob, ['profile'], nil)
|
116
|
+
end
|
117
|
+
it_should_behave_like "invalid token"
|
118
|
+
|
119
|
+
it "returns an error response" do
|
120
|
+
token.response_headers['WWW-Authenticate'].should == "OAuth realm='Demo App'"
|
121
|
+
token.response_status.should == 401
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|