Skip to content

Commit a3bed8f

Browse files
committed
1004: Add new endpoint for google token exchange
1 parent d2ebba4 commit a3bed8f

File tree

8 files changed

+294
-4
lines changed

8 files changed

+294
-4
lines changed

.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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
payload = google_token_params
12+
13+
request_body = {
14+
code: payload[:code],
15+
client_id: ENV.fetch('GOOGLE_CLIENT_ID'),
16+
client_secret: ENV.fetch('GOOGLE_CLIENT_SECRET'),
17+
redirect_uri: payload[:redirect_uri],
18+
grant_type: 'authorization_code'
19+
}
20+
21+
conn = Faraday.new do |f|
22+
f.request :url_encoded
23+
end
24+
25+
response = conn.post(TOKEN_EXCHANGE_URL, request_body)
26+
@token_response = JSON.parse(response.body)
27+
28+
if response.success?
29+
render :exchange_code, status: :ok
30+
else
31+
render json: { error: @token_response['error_description'] }, status: :unauthorized
32+
end
33+
rescue Faraday::Error => e
34+
render json: { error: e.message }, status: :service_unavailable
35+
end
36+
37+
private
38+
39+
def google_token_params
40+
params.require(:google_auth).require(:code)
41+
params.require(:google_auth).require(:redirect_uri)
42+
params.require(:google_auth).permit(:code, :redirect_uri)
43+
end
44+
end
45+
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:)
@@ -97,6 +98,7 @@ def define_school_teacher_abilities(user:, school:)
9798
).pluck(:id)
9899
can(%i[read], Project, remixed_from_id: teacher_project_ids)
99100
can(%i[read create], Feedback, school_project: { project: { remixed_from_id: teacher_project_ids } })
101+
can(%i[exchange_code], :google_auth)
100102
end
101103

102104
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
@@ -72,6 +72,8 @@
7272
end
7373

7474
resources :user_jobs, only: %i[index show]
75+
76+
post '/google/auth/exchange-code', to: 'google_auth#exchange_code', defaults: { format: :json }
7577
end
7678

7779
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
@@ -577,4 +577,51 @@
577577
it { is_expected.not_to be_able_to(:destroy, other_school_class_saved) }
578578
end
579579
end
580+
581+
describe 'Google Auth' do
582+
context 'with no user' do
583+
let(:user) { nil }
584+
585+
it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
586+
end
587+
588+
context 'with a standard user (no school)' do
589+
let(:user) { build(:user) }
590+
591+
it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
592+
end
593+
594+
context 'with a school teacher' do
595+
let(:user) { create(:user) }
596+
let(:school) { create(:school) }
597+
598+
before do
599+
create(:teacher_role, user_id: user.id, school:)
600+
end
601+
602+
it { is_expected.to be_able_to(:exchange_code, :google_auth) }
603+
end
604+
605+
context 'with a school owner' do
606+
let(:user) { create(:user) }
607+
let(:school) { create(:school) }
608+
609+
before do
610+
create(:owner_role, user_id: user.id, school:)
611+
end
612+
613+
it { is_expected.to be_able_to(:exchange_code, :google_auth) }
614+
end
615+
616+
context 'with a school student' do
617+
let(:user) { create(:user) }
618+
let(:school) { create(:school) }
619+
620+
before do
621+
create(:student_role, user_id: user.id, school:)
622+
end
623+
624+
it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
625+
end
626+
end
580627
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)