-
Notifications
You must be signed in to change notification settings - Fork 78
fix(sign_out): invalidate cookie server-side on user sign out #8572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # frozen_string_literal: true | ||
| # Expires the +access_token+ cookie that ApplicationUserConcern writes. | ||
| # | ||
| # The cookie is httponly, so the client cannot clear it on its own: js-cookie deletes by writing | ||
| # through +document.cookie+, which an httponly cookie is invisible to by definition. Signing out | ||
| # therefore has to ask the server to expire it, or it outlives the sign out as a usable credential | ||
| # for browser-issued requests, which fall back to the cookie when no bearer token is present. | ||
| # | ||
| # A caller can only ever clear their own cookie. This action takes no parameters and identifies no | ||
| # user - it expires the cookie on the response to this very request, so the only browser it can | ||
| # affect is the one that made it. | ||
| class AccessTokenController < ApplicationController | ||
| # +refresh_token_cookie+ would otherwise mint the cookie from this request's own bearer token | ||
| # moments before the action deletes it. The net result is the same, but this endpoint should only | ||
| # ever be capable of clearing a credential, never of issuing one. | ||
| skip_before_action :refresh_token_cookie | ||
|
|
||
| def destroy | ||
| cookies.delete(:access_token) | ||
| head :no_content | ||
| end | ||
|
|
||
| protected | ||
|
|
||
| # Signing out has to work with a credential that has already lapsed, which is exactly when a | ||
| # stale cookie is most likely to still be sitting in the browser. | ||
| def publicly_accessible? | ||
| true | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # frozen_string_literal: true | ||
| require 'rails_helper' | ||
|
|
||
| # The cookie under test is httponly, so only a server response can expire it. That makes this a | ||
| # request spec: what matters is the Set-Cookie the browser actually receives. | ||
| RSpec.describe 'Access token cookie' do | ||
| let(:instance) { Instance.default } | ||
|
|
||
| with_tenant(:instance) do | ||
| let(:administrator) { create(:administrator) } | ||
|
|
||
| before { host! instance.host } | ||
|
|
||
| # Mirrors the controller specs, which sidestep Keycloak the same way. | ||
| before do | ||
| allow(Authentication::AuthenticationService).to receive(:validate_token) do |access_token, _method| | ||
| if access_token == 'a-valid-token' | ||
| Authentication::VerificationService::Response.new( | ||
| { email: administrator.email, session_state: 'a-session' }, nil | ||
| ) | ||
| else | ||
| error = Authentication::VerificationService::Error.new('Invalid token', :unauthorized) | ||
| Authentication::VerificationService::Response.new(nil, error) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def deleted_in_response? | ||
| # Rails expires a cookie by sending it back empty, so the header names it with a nil value. | ||
| response.cookies.key?('access_token') && response.cookies['access_token'].nil? | ||
| end | ||
|
|
||
| it 'expires the cookie the request arrived with' do | ||
| delete '/access_token', headers: access_token_cookie('a-previously-issued-token') | ||
|
|
||
| expect(response).to have_http_status(:no_content) | ||
| expect(deleted_in_response?).to be(true) | ||
| end | ||
|
|
||
| # Signing out is exactly when the credential is most likely to be unusable already, so this | ||
| # must not require one. | ||
| it 'succeeds without any credential' do | ||
| delete '/access_token' | ||
|
|
||
| expect(response).to have_http_status(:no_content) | ||
| end | ||
|
|
||
| # ApplicationUserConcern#refresh_token_cookie mints the cookie on every publicly accessible | ||
| # action, and this is one. Skipping it here keeps the endpoint incapable of issuing a | ||
| # credential, so a bearer token cannot leave a freshly minted cookie behind. | ||
| it 'does not mint a cookie when a bearer token is presented' do | ||
| expect_any_instance_of(AccessTokenController).not_to receive(:refresh_token_cookie) | ||
| delete '/access_token', headers: { 'Authorization' => 'Bearer a-valid-token' } | ||
|
|
||
| expect(response).to have_http_status(:no_content) | ||
| expect(response.cookies['access_token']).to be_nil | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # frozen_string_literal: true | ||
| # Builds the access_token cookie that ApplicationUserConcern#add_token_to_cookie writes, for specs | ||
| # that need a request to arrive carrying one. | ||
| # | ||
| # It cannot be seeded through an integration session's own jar: that is a Rack::Test::CookieJar, | ||
| # which has no #encrypted. So the ciphertext is produced from a real CookieJar and handed over as a | ||
| # raw request header. | ||
| module AccessTokenCookieHelpers | ||
| # @param [String] token The access token to encrypt into the cookie. | ||
| # @return [Hash] Headers to merge into a request, carrying the cookie as the browser would. | ||
| def access_token_cookie(token) | ||
| env = Rack::MockRequest.env_for('/', 'HTTP_HOST' => 'test.host').merge(Rails.application.env_config) | ||
| jar = ActionDispatch::Cookies::CookieJar.build(ActionDispatch::Request.new(env), {}) | ||
| jar.encrypted[:access_token] = token | ||
|
|
||
| # The ciphertext has to be escaped the way Rails escapes it on the way out, otherwise any '+' it | ||
| # happens to contain is read back as a space and decryption fails for roughly half of all | ||
| # generated tokens. | ||
| { 'HTTP_COOKIE' => "access_token=#{Rack::Utils.escape(jar[:access_token])}" } | ||
| end | ||
| end | ||
|
|
||
| RSpec.configure do |config| | ||
| config.include AccessTokenCookieHelpers, type: :request | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.