Skip to content

Commit 033e133

Browse files
danhalsonCopilot
andauthored
1004: Add new endpoint for google token exchange (#609)
## Status - Related to RaspberryPiFoundation/digital-editor-issues#1004 ## What's changed? Adds a new endpoint to support google token exchange for the classroom importer ## Steps to perform after deploying to production N/A --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 2220231 commit 033e133

8 files changed

Lines changed: 303 additions & 4 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,7 @@ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=deterministic-key
4747
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=derivation-salt
4848

4949
EDITOR_ENCRYPTION_KEY=a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0
50+
51+
# The sandbox creds can be found in 1password under "Google Cloud Console: CEfE Sandbox"
52+
GOOGLE_CLIENT_ID=changeme.apps.googleusercontent.com
53+
GOOGLE_CLIENT_SECRET=changeme
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
class GoogleAuthController < ApiController
5+
TOKEN_EXCHANGE_URL = 'https://oauth2.googleapis.com/token'
6+
7+
before_action :authorize_user
8+
authorize_resource :google_auth, class: false
9+
10+
def exchange_code
11+
response = faraday.post(TOKEN_EXCHANGE_URL, token_exchange_payload)
12+
@token_response = JSON.parse(response.body)
13+
14+
return render(:exchange_code, status: :ok) if response.success?
15+
16+
render json: { error: response_error_message }, status: :unauthorized
17+
rescue JSON::ParserError => e
18+
render json: { error: e.message }, status: :bad_gateway
19+
rescue Faraday::Error => e
20+
render json: { error: e.message }, status: :service_unavailable
21+
end
22+
23+
private
24+
25+
def faraday
26+
Faraday.new do |f|
27+
f.request :url_encoded
28+
f.options.timeout = 10
29+
f.options.open_timeout = 5
30+
end
31+
end
32+
33+
def token_exchange_payload
34+
payload = google_token_params
35+
{
36+
code: payload[:code],
37+
client_id: ENV.fetch('GOOGLE_CLIENT_ID'),
38+
client_secret: ENV.fetch('GOOGLE_CLIENT_SECRET'),
39+
redirect_uri: payload[:redirect_uri],
40+
grant_type: 'authorization_code'
41+
}
42+
end
43+
44+
def response_error_message
45+
@token_response['error_description'] || @token_response['error'] || 'Unknown error'
46+
end
47+
48+
def google_token_params
49+
params.require(:google_auth).require(:code)
50+
params.require(:google_auth).require(:redirect_uri)
51+
params.require(:google_auth).permit(:code, :redirect_uri)
52+
end
53+
end
54+
end

app/controllers/auth_controller.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
# frozen_string_literal: true
22

33
class AuthController < ApplicationController
4-
# def index
5-
6-
# end
7-
84
def callback
95
Rails.logger.debug { "callback: #{omniauth_params}" }
106
# Prevent session fixation. If the session has been initialized before

app/models/ability.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def define_school_owner_abilities(school:)
6969
can(%i[read create create_batch update destroy], :school_student)
7070
can(%i[create create_copy], Lesson, school_id: school.id)
7171
can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public])
72+
can(%i[exchange_code], :google_auth)
7273
end
7374

7475
def define_school_teacher_abilities(user:, school:)
@@ -98,6 +99,7 @@ def define_school_teacher_abilities(user:, school:)
9899
can(%i[read], Project, remixed_from_id: teacher_project_ids)
99100
can(%i[show_status unsubmit return complete], SchoolProject, project: { remixed_from_id: teacher_project_ids })
100101
can(%i[read create], Feedback, school_project: { project: { remixed_from_id: teacher_project_ids } })
102+
can(%i[exchange_code], :google_auth)
101103
end
102104

103105
def define_school_student_abilities(user:, school:)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# frozen_string_literal: true
2+
3+
json.call(
4+
@token_response,
5+
'access_token',
6+
'expires_in',
7+
'token_type',
8+
'scope',
9+
'id_token'
10+
)

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
end
8080

8181
resources :user_jobs, only: %i[index show]
82+
83+
post '/google/auth/exchange-code', to: 'google_auth#exchange_code', defaults: { format: :json }
8284
end
8385

8486
resource :github_webhooks, only: :create, defaults: { formats: :json }

spec/models/ability_spec.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,4 +597,51 @@
597597
it { is_expected.not_to be_able_to(:destroy, other_school_class_saved) }
598598
end
599599
end
600+
601+
describe 'Google Auth' do
602+
context 'with no user' do
603+
let(:user) { nil }
604+
605+
it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
606+
end
607+
608+
context 'with a standard user (no school)' do
609+
let(:user) { build(:user) }
610+
611+
it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
612+
end
613+
614+
context 'with a school teacher' do
615+
let(:user) { create(:user) }
616+
let(:school) { create(:school) }
617+
618+
before do
619+
create(:teacher_role, user_id: user.id, school:)
620+
end
621+
622+
it { is_expected.to be_able_to(:exchange_code, :google_auth) }
623+
end
624+
625+
context 'with a school owner' do
626+
let(:user) { create(:user) }
627+
let(:school) { create(:school) }
628+
629+
before do
630+
create(:owner_role, user_id: user.id, school:)
631+
end
632+
633+
it { is_expected.to be_able_to(:exchange_code, :google_auth) }
634+
end
635+
636+
context 'with a school student' do
637+
let(:user) { create(:user) }
638+
let(:school) { create(:school) }
639+
640+
before do
641+
create(:student_role, user_id: user.id, school:)
642+
end
643+
644+
it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
645+
end
646+
end
600647
end
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Google Auth requests' do
6+
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
7+
let(:school) { create(:school) }
8+
let(:owner) { create(:owner, school:) }
9+
10+
before do
11+
authenticated_in_hydra_as(owner)
12+
end
13+
14+
describe 'POST /api/google_auth/exchange_code' do
15+
let(:params) do
16+
{
17+
google_auth: {
18+
code: 'test-authorization-code',
19+
redirect_uri: 'https://example.com/callback'
20+
}
21+
}
22+
end
23+
24+
let(:google_token_response) do
25+
{
26+
'access_token' => 'test-access-token',
27+
'expires_in' => 3599,
28+
'token_type' => 'Bearer',
29+
'scope' => 'openid email profile',
30+
'id_token' => 'test-id-token'
31+
}
32+
end
33+
34+
around do |example|
35+
ClimateControl.modify(
36+
GOOGLE_CLIENT_ID: 'test-client-id',
37+
GOOGLE_CLIENT_SECRET: 'test-client-secret'
38+
) do
39+
example.run
40+
end
41+
end
42+
43+
context 'when token exchange is successful' do
44+
before do
45+
stub_request(:post, Api::GoogleAuthController::TOKEN_EXCHANGE_URL)
46+
.with(
47+
body: {
48+
code: 'test-authorization-code',
49+
client_id: 'test-client-id',
50+
client_secret: 'test-client-secret',
51+
redirect_uri: 'https://example.com/callback',
52+
grant_type: 'authorization_code'
53+
}
54+
)
55+
.to_return(
56+
status: 200,
57+
body: google_token_response.to_json,
58+
headers: { 'Content-Type' => 'application/json' }
59+
)
60+
end
61+
62+
it 'returns success response' do
63+
post('/api/google/auth/exchange-code', params:, headers:)
64+
expect(response).to have_http_status(:ok)
65+
end
66+
67+
it 'returns token response from Google' do
68+
post('/api/google/auth/exchange-code', params:, headers:)
69+
expect(response.parsed_body).to eq(google_token_response)
70+
end
71+
72+
it 'includes access_token in response' do
73+
post('/api/google/auth/exchange-code', params:, headers:)
74+
expect(response.parsed_body['access_token']).to eq('test-access-token')
75+
end
76+
end
77+
78+
context 'when token exchange fails with error from Google' do
79+
let(:error_response) do
80+
{
81+
'error' => 'invalid_grant',
82+
'error_description' => 'Bad Request'
83+
}
84+
end
85+
86+
before do
87+
stub_request(:post, Api::GoogleAuthController::TOKEN_EXCHANGE_URL)
88+
.to_return(
89+
status: 400,
90+
body: error_response.to_json,
91+
headers: { 'Content-Type' => 'application/json' }
92+
)
93+
end
94+
95+
it 'returns unauthorized response' do
96+
post('/api/google/auth/exchange-code', params:, headers:)
97+
expect(response).to have_http_status(:unauthorized)
98+
end
99+
100+
it 'returns error message' do
101+
post('/api/google/auth/exchange-code', params:, headers:)
102+
expect(response.parsed_body['error']).to eq('Bad Request')
103+
end
104+
end
105+
106+
context 'when network error occurs' do
107+
before do
108+
stub_request(:post, Api::GoogleAuthController::TOKEN_EXCHANGE_URL)
109+
.to_raise(Faraday::ConnectionFailed.new('Connection failed'))
110+
end
111+
112+
it 'returns service unavailable response' do
113+
post('/api/google/auth/exchange-code', params:, headers:)
114+
expect(response).to have_http_status(:service_unavailable)
115+
end
116+
117+
it 'returns error message' do
118+
post('/api/google/auth/exchange-code', params:, headers:)
119+
expect(response.parsed_body['error']).to eq('Connection failed')
120+
end
121+
end
122+
123+
context 'when code parameter is missing' do
124+
let(:params) do
125+
{
126+
google_auth: {
127+
redirect_uri: 'https://example.com/callback'
128+
}
129+
}
130+
end
131+
132+
it 'returns bad request response' do
133+
post('/api/google/auth/exchange-code', params:, headers:)
134+
expect(response).to have_http_status(:bad_request)
135+
end
136+
end
137+
138+
context 'when redirect_uri parameter is missing' do
139+
let(:params) do
140+
{
141+
google_auth: {
142+
code: 'test-authorization-code'
143+
}
144+
}
145+
end
146+
147+
it 'returns bad request response' do
148+
post('/api/google/auth/exchange-code', params:, headers:)
149+
expect(response).to have_http_status(:bad_request)
150+
end
151+
end
152+
153+
context 'when google_auth params are missing' do
154+
it 'returns bad request response' do
155+
post('/api/google/auth/exchange-code', headers:)
156+
expect(response).to have_http_status(:bad_request)
157+
end
158+
end
159+
160+
context 'when user is not authenticated' do
161+
before do
162+
unauthenticated_in_hydra
163+
end
164+
165+
it 'returns unauthorized response' do
166+
post('/api/google/auth/exchange-code', params:, headers:)
167+
expect(response).to have_http_status(:unauthorized)
168+
end
169+
end
170+
171+
context 'when user is not authorized' do
172+
let(:student) { create(:student, school:) }
173+
174+
before do
175+
authenticated_in_hydra_as(student)
176+
end
177+
178+
it 'returns forbidden response' do
179+
post('/api/google/auth/exchange-code', params:, headers:)
180+
expect(response).to have_http_status(:forbidden)
181+
end
182+
end
183+
end
184+
end

0 commit comments

Comments
 (0)