Skip to content

Commit 1af3494

Browse files
authored
Merge pull request #11717 from neinteractiveliterature/fix/become-user-pkce-auth
Fix become/revert_become after PKCE auth switch
2 parents cefb75a + d54abc3 commit 1af3494

5 files changed

Lines changed: 137 additions & 12 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
# Shared logic for managing the SPA's OAuth session cookie across controllers
4+
# that need to swap the active user (e.g. the "become user" feature).
5+
module OAuthSessionManagement
6+
REFRESH_COOKIE_NAME = "__Host-intercode_refresh"
7+
8+
private
9+
10+
def issue_oauth_session_for_user(user)
11+
frontend_app = OAuthApplication.find_by(is_intercode_frontend: true)
12+
return unless frontend_app
13+
14+
revoke_current_oauth_session
15+
16+
new_token = create_frontend_access_token(user, frontend_app)
17+
cookies[REFRESH_COOKIE_NAME] = {
18+
value: new_token.plaintext_refresh_token,
19+
httponly: true,
20+
secure: true,
21+
same_site: :strict,
22+
path: "/",
23+
max_age: 90.days.to_i
24+
}
25+
end
26+
27+
def create_frontend_access_token(user, frontend_app)
28+
scopes = Doorkeeper.config.scopes.to_s
29+
# Mirror the custom_access_token_expires_in block in config/initializers/doorkeeper.rb
30+
expires_in =
31+
Doorkeeper::OAuth::Scopes.from_string(scopes).any? { |s| s.start_with?("manage_") } ? 30.minutes : 2.weeks
32+
Doorkeeper::AccessToken.create!(
33+
application: frontend_app,
34+
resource_owner_id: user.id,
35+
scopes: scopes,
36+
expires_in: expires_in,
37+
use_refresh_token: true
38+
)
39+
end
40+
41+
def revoke_current_oauth_session
42+
refresh_value = cookies[REFRESH_COOKIE_NAME]
43+
return if refresh_value.blank?
44+
45+
Doorkeeper::AccessToken.by_refresh_token(refresh_value)&.revoke
46+
end
47+
end

app/controllers/user_con_profiles_controller.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22
class UserConProfilesController < ApplicationController
33
include SendCsv
4+
include OAuthSessionManagement
45

56
# Normally we'd just use the name of the resource as the instance variable name. Here that'd be
67
# @user_con_profile, which is unsafe for us to use because ApplicationController uses it to mean
@@ -12,15 +13,15 @@ class UserConProfilesController < ApplicationController
1213

1314
def become
1415
identity_assumer = assumed_identity_from_profile || user_con_profile
15-
subject_profile = convention.user_con_profiles.find(params[:id])
16+
subject_profile = convention.user_con_profiles.find(params.expect(:id))
1617
authorize subject_profile, :become?
1718

1819
new_session =
1920
AssumedIdentitySession.new(
2021
assumed_profile: subject_profile,
2122
assumer_profile: identity_assumer,
2223
justification: params[:justification],
23-
started_at: Time.now
24+
started_at: Time.current
2425
)
2526

2627
unless new_session.save
@@ -31,6 +32,7 @@ def become
3132
sign_in subject_profile.user
3233
session[:assumed_identity_from_profile_id] = identity_assumer.id
3334
session[:assumed_identity_session_id] = new_session.id
35+
issue_oauth_session_for_user(subject_profile.user)
3436
redirect_to root_url
3537
end
3638

@@ -40,8 +42,10 @@ def revert_become
4042
redirect_to(
4143
root_url,
4244
alert:
45+
# rubocop:disable Rails/I18nLocaleTexts
4346
"You haven't assumed an identity, so you can't revert \
4447
back to your normal identity (since you already are your normal identity)."
48+
# rubocop:enable Rails/I18nLocaleTexts
4549
)
4650
)
4751
end
@@ -52,7 +56,8 @@ def revert_become
5256
sign_in regular_user_con_profile.user
5357
session.delete(:assumed_identity_from_profile_id)
5458
session.delete(:assumed_identity_session_id)
55-
current_session&.update!(finished_at: Time.now) if current_session
59+
current_session&.update!(finished_at: Time.current)
60+
issue_oauth_session_for_user(regular_user_con_profile.user)
5661
redirect_to root_url
5762
end
5863

app/javascript/NavigationBar/UserNavigationSection.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,17 @@ const LoggedInDropdownTarget = forwardRef<HTMLButtonElement, LoggedInDropdownTar
111111

112112
function RevertAssumedIdentityButton() {
113113
const { assumedIdentityFromProfile } = useContext(AppRootContext);
114+
const authenticationManager = useContext(AuthenticationManagerContext);
114115

115116
const revertAssumedIdentity = async () => {
117+
const token = await authenticationManager.ensureFreshAccessToken();
118+
const headers: Record<string, string> = {};
119+
if (token) {
120+
headers['Authorization'] = `Bearer ${token}`;
121+
}
116122
const response = await htmlFetch('/user_con_profiles/revert_become', {
117123
method: 'POST',
124+
headers,
118125
});
119126

120127
window.location.href = response.url;

app/javascript/UserConProfiles/UserConProfileAdminDisplay.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Suspense, useMemo, useState } from 'react';
1+
import { Suspense, useContext, useMemo, useState } from 'react';
22
import {
33
ActionFunction,
44
Link,
@@ -34,6 +34,7 @@ import { DeleteUserConProfileDocument } from './mutations.generated';
3434
import invariant from 'tiny-invariant';
3535
import { UserConProfile } from 'graphqlTypes.generated';
3636
import { apolloClientContext } from 'AppContexts';
37+
import { AuthenticationManagerContext } from '../Authentication/authenticationManager';
3738

3839
export const action: ActionFunction<RouterContextProvider> = async ({ context, request, params: { id } }) => {
3940
const client = context.get(apolloClientContext);
@@ -59,16 +60,19 @@ export const action: ActionFunction<RouterContextProvider> = async ({ context, r
5960
}
6061
};
6162

62-
async function becomeUser(userConProfileId: string, justification: string) {
63+
async function becomeUser(userConProfileId: string, justification: string, token?: string) {
6364
const formData = new FormData();
6465
formData.append('justification', justification);
6566

67+
const headers: Record<string, string> = { Accept: 'application/json' };
68+
if (token) {
69+
headers['Authorization'] = `Bearer ${token}`;
70+
}
71+
6672
const response = await fetch(`/user_con_profiles/${userConProfileId}/become`, {
6773
method: 'POST',
6874
credentials: 'include',
69-
headers: {
70-
Accept: 'application/json',
71-
},
75+
headers,
7276
body: formData,
7377
});
7478

@@ -98,6 +102,7 @@ function BecomeUserModal({
98102
close,
99103
}: BecomeUserModalProps): React.JSX.Element {
100104
const { t } = useTranslation();
105+
const authenticationManager = useContext(AuthenticationManagerContext);
101106
const [justification, setJustification] = useState('');
102107
const [becomeAsync, error, inProgress] = useAsyncFunction(becomeUser);
103108

@@ -106,7 +111,8 @@ function BecomeUserModal({
106111
return;
107112
}
108113

109-
await becomeAsync(userConProfileId, justification);
114+
const token = await authenticationManager.ensureFreshAccessToken();
115+
await becomeAsync(userConProfileId, justification, token);
110116
close();
111117
};
112118

test/controllers/user_con_profiles_controller_test.rb

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
require 'test_helper'
1+
# frozen_string_literal: true
2+
# rubocop:disable Metrics/BlockLength
3+
require "test_helper"
24

35
describe UserConProfilesController do
46
let(:user_con_profile) { create(:user_con_profile) }
@@ -10,13 +12,71 @@
1012
profile
1113
end
1214
let(:con_admin) { con_admin_profile.user }
15+
let(:frontend_app) { create(:oauth_application, is_intercode_frontend: true) }
1316

1417
setup do
1518
set_convention convention
1619
sign_in con_admin
17-
1820
user_con_profile
21+
frontend_app
1922
end
2023

21-
# TODO write tests for become/revert_become
24+
describe "POST become" do
25+
it "creates an assumed identity session and issues an OAuth session for the assumed user" do
26+
OAuthApplication.stub(:find_by, frontend_app) do
27+
assert_difference("AssumedIdentitySession.count", 1) do
28+
assert_difference("Doorkeeper::AccessToken.count", 1) do
29+
post :become, params: { id: user_con_profile.id, justification: "testing become" }
30+
end
31+
end
32+
end
33+
34+
assert_redirected_to root_url
35+
36+
new_token = Doorkeeper::AccessToken.last
37+
assert_equal user_con_profile.user.id, new_token.resource_owner_id
38+
assert_equal frontend_app.id, new_token.application_id
39+
end
40+
41+
it "revokes the admin's previous OAuth session cookie when one exists" do
42+
admin_token =
43+
Doorkeeper::AccessToken.create!(
44+
application: frontend_app,
45+
resource_owner_id: con_admin.id,
46+
scopes: "public",
47+
expires_in: 2.weeks,
48+
use_refresh_token: true
49+
)
50+
@request.cookies[OAuthSessionManagement::REFRESH_COOKIE_NAME] = admin_token.plaintext_refresh_token
51+
52+
OAuthApplication.stub(:find_by, frontend_app) do
53+
post :become, params: { id: user_con_profile.id, justification: "testing cookie revocation" }
54+
end
55+
56+
assert admin_token.reload.revoked?, "Expected the admin's previous OAuth token to be revoked"
57+
end
58+
end
59+
60+
describe "POST revert_become" do
61+
setup do
62+
OAuthApplication.stub(:find_by, frontend_app) do
63+
post :become, params: { id: user_con_profile.id, justification: "setup for revert test" }
64+
end
65+
end
66+
67+
it "reverts to the original admin user and issues a new OAuth session for them" do
68+
assumed_user_token = Doorkeeper::AccessToken.last
69+
70+
OAuthApplication.stub(:find_by, frontend_app) do
71+
assert_difference("Doorkeeper::AccessToken.count", 1) { post :revert_become }
72+
end
73+
74+
assert_redirected_to root_url
75+
76+
new_token = Doorkeeper::AccessToken.last
77+
assert_equal con_admin.id, new_token.resource_owner_id
78+
assert assumed_user_token.reload.revoked?, "Expected the assumed user's OAuth token to be revoked after revert"
79+
end
80+
end
2281
end
82+
# rubocop:enable Metrics/BlockLength

0 commit comments

Comments
 (0)