Skip to content

Commit 40d8721

Browse files
authored
Allow customising of the logout returnTo URL (#56)
Closes #46 ## What's changed * A returnTo parameter can be added to the logout path to redirect users to a different place having logged out. * Altered how we handle returnTo parameters in general, as Rails 6 doesn't check for open redirects
1 parent b5e6ab3 commit 40d8721

File tree

4 files changed

+91
-14
lines changed

4 files changed

+91
-14
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Allow for customisation of returnTo param on log out (#56)
13+
1014
## [v3.1.0]
1115

1216
### Changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,16 @@ meaning (most) users will end up back on the page where they started the auth fl
139139

140140
Finally, if none of these things are set, we end up back at the application root.
141141

142+
#### Redirecting when logging out
143+
144+
It is also possible to send users to pages within your app when logging out. Just set the `returnTo` parameter again.
145+
146+
```ruby
147+
link_to 'Log out', rpi_auth_logout_path, params: { returnTo: '/thanks-dude' }
148+
```
149+
150+
This has to be a relative URL, i.e. it has to start with a slash. This is to ensure there's no open redirect.
151+
142152
### Globbed/catch-all routes
143153

144154
If your app has a catch-all route at the end of the routing table, you must

app/controllers/rpi_auth/auth_controller.rb

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,18 @@ def callback
1717
self.current_user = RpiAuth.user_model.from_omniauth(auth)
1818

1919
redirect_to RpiAuth.configuration.success_redirect.presence ||
20-
request.env.fetch('omniauth.origin', nil).presence ||
21-
'/'
20+
ensure_relative_url(request.env['omniauth.origin'])
2221
end
2322

2423
def destroy
2524
reset_session
2625

27-
# Prevent redirect loops etc.
28-
if RpiAuth.configuration.bypass_auth == true
29-
redirect_to '/'
30-
return
31-
end
26+
# Any redirect must be within our app, so it should start with a slash.
27+
return_to = ensure_relative_url(params[:returnTo])
3228

33-
redirect_to "#{RpiAuth.configuration.identity_url}/logout?returnTo=#{RpiAuth.configuration.host_url}",
29+
return redirect_to return_to if RpiAuth.configuration.bypass_auth == true
30+
31+
redirect_to "#{RpiAuth.configuration.identity_url}/logout?returnTo=#{RpiAuth.configuration.host_url}#{return_to}",
3432
allow_other_host: true
3533
end
3634

@@ -42,5 +40,22 @@ def failure
4240
end
4341
redirect_to '/'
4442
end
43+
44+
private
45+
46+
def ensure_relative_url(url)
47+
url = URI.parse(url)
48+
49+
# Bail out early if the URL doesn't look local. This condition is taken
50+
# from ActionController::Redirecting#_url_host_allowed? in Rails 7.0
51+
raise ArgumentError unless url.host == request.host || (url.host.nil? && url.to_s.start_with?('/'))
52+
53+
# This is a bit of an odd way to do things, but it means that this method
54+
# works with both URI::Generic and URI::HTTP
55+
relative_url = [url.path, url.query].compact.join('?')
56+
[relative_url, url.fragment].compact.join('#')
57+
rescue ArgumentError, URI::Error
58+
'/'
59+
end
4560
end
4661
end

spec/dummy/spec/requests/auth_request_spec.rb

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717

1818
let(:bypass_auth) { false }
1919
let(:identity_url) { 'https://my.example.com' }
20-
let(:host_url) { 'https://example.com' }
20+
# We need to make sure we match the hostname Rails uses in test requests
21+
# here, otherwise `returnTo` redirects will fail after login/logout.
22+
let(:host_url) { 'https://www.example.com' }
2123

2224
before do
2325
RpiAuth.configuration.user_model = 'User'
@@ -38,11 +40,35 @@
3840

3941
get '/rpi_auth/logout'
4042

41-
expect(response).to redirect_to("#{identity_url}/logout?returnTo=#{host_url}")
43+
expect(response).to redirect_to("#{identity_url}/logout?returnTo=#{host_url}/")
4244
expect(session.id).not_to eq previous_id
4345
expect(session['current_user']).to be_nil
4446
end
4547

48+
context 'when a returnTo param is given' do
49+
let(:return_to) { '/next/page' }
50+
51+
it 'redirects to the correct URL' do
52+
sign_in(user)
53+
54+
get '/rpi_auth/logout', params: { returnTo: return_to }
55+
56+
expect(response).to redirect_to("#{identity_url}/logout?returnTo=#{URI.join(host_url, return_to)}")
57+
end
58+
59+
context 'when the returnTo param is on another host' do
60+
let(:return_to) { 'https://a.bad.actor.com/bad/page' }
61+
62+
it 'redirects to the correct URL' do
63+
sign_in(user)
64+
65+
get '/rpi_auth/logout', params: { returnTo: return_to }
66+
67+
expect(response).to redirect_to("#{identity_url}/logout?returnTo=#{host_url}/")
68+
end
69+
end
70+
end
71+
4672
context 'when bypass_auth is set' do
4773
let(:bypass_auth) { true }
4874

@@ -59,6 +85,18 @@
5985
expect(session.id).not_to eq previous_id
6086
expect(session['current_user']).to be_nil
6187
end
88+
89+
context 'when a returnTo param is given' do
90+
let(:return_to) { '/next/page' }
91+
92+
it 'redirects to the correct URL' do
93+
sign_in(user)
94+
95+
get '/rpi_auth/logout', params: { returnTo: return_to }
96+
97+
expect(response).to redirect_to(return_to)
98+
end
99+
end
62100
end
63101
end
64102

@@ -144,21 +182,31 @@
144182

145183
context 'when having visited a page first' do
146184
it 'redirects back to the original page' do
147-
post '/auth/rpi', headers: { Referer: 'http://www.example.com/foo' }
185+
post '/auth/rpi', headers: { Referer: 'http://www.example.com/foo#foo' }
148186
expect(response).to redirect_to('/rpi_auth/auth/callback')
149187
follow_redirect!
150188

151-
expect(response).to redirect_to('/foo')
189+
expect(response).to redirect_to('/foo#foo')
152190
end
153191
end
154192

155193
context 'when returnTo param is set' do
156194
it 'redirects back to the original page' do
157-
post '/auth/rpi', params: { returnTo: 'http://www.example.com/bar' }
195+
post '/auth/rpi', params: { returnTo: 'http://www.example.com/bar?q=p#r' }
196+
expect(response).to redirect_to('/rpi_auth/auth/callback')
197+
follow_redirect!
198+
199+
expect(response).to redirect_to('/bar?q=p#r')
200+
end
201+
end
202+
203+
context 'when the returnTo param is on another host' do
204+
it 'redirects to the root URL' do
205+
post '/auth/rpi', params: { returnTo: 'https://a.bad.actor.com/bad/page' }
158206
expect(response).to redirect_to('/rpi_auth/auth/callback')
159207
follow_redirect!
160208

161-
expect(response).to redirect_to('/bar')
209+
expect(response).to redirect_to('/')
162210
end
163211
end
164212

0 commit comments

Comments
 (0)