Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=deterministic-key
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=derivation-salt

EDITOR_ENCRYPTION_KEY=a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0

# The sandbox creds can be found in 1password under "Google Cloud Console: CEfE Sandbox"
GOOGLE_CLIENT_ID=changeme.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=changeme
45 changes: 45 additions & 0 deletions app/controllers/api/google_auth_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module Api
class GoogleAuthController < ApiController
TOKEN_EXCHANGE_URL = 'https://oauth2.googleapis.com/token'

before_action :authorize_user
authorize_resource :google_auth, class: false

def exchange_code
payload = google_token_params

request_body = {
code: payload[:code],
client_id: ENV.fetch('GOOGLE_CLIENT_ID'),
client_secret: ENV.fetch('GOOGLE_CLIENT_SECRET'),
redirect_uri: payload[:redirect_uri],
grant_type: 'authorization_code'
}

conn = Faraday.new do |f|
f.request :url_encoded
end

response = conn.post(TOKEN_EXCHANGE_URL, request_body)
@token_response = JSON.parse(response.body)

if response.success?
render :exchange_code, status: :ok
else
render json: { error: @token_response['error_description'] }, status: :unauthorized
end
rescue Faraday::Error => e
render json: { error: e.message }, status: :service_unavailable
end

private

def google_token_params
params.require(:google_auth).require(:code)
params.require(:google_auth).require(:redirect_uri)
params.require(:google_auth).permit(:code, :redirect_uri)
end
end
end
4 changes: 0 additions & 4 deletions app/controllers/auth_controller.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
# frozen_string_literal: true

class AuthController < ApplicationController
# def index

# end

def callback
Rails.logger.debug { "callback: #{omniauth_params}" }
# Prevent session fixation. If the session has been initialized before
Expand Down
2 changes: 2 additions & 0 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def define_school_owner_abilities(school:)
can(%i[read create create_batch update destroy], :school_student)
can(%i[create create_copy], Lesson, school_id: school.id)
can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public])
can(%i[exchange_code], :google_auth)
end

def define_school_teacher_abilities(user:, school:)
Expand Down Expand Up @@ -98,6 +99,7 @@ def define_school_teacher_abilities(user:, school:)
can(%i[read], Project, remixed_from_id: teacher_project_ids)
can(%i[show_status unsubmit return complete], SchoolProject, project: { remixed_from_id: teacher_project_ids })
can(%i[read create], Feedback, school_project: { project: { remixed_from_id: teacher_project_ids } })
can(%i[exchange_code], :google_auth)
end

def define_school_student_abilities(user:, school:)
Expand Down
10 changes: 10 additions & 0 deletions app/views/api/google_auth/exchange_code.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

json.call(
@token_response,
'access_token',
'expires_in',
'token_type',
'scope',
'id_token'
)
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
end

resources :user_jobs, only: %i[index show]

post '/google/auth/exchange-code', to: 'google_auth#exchange_code', defaults: { format: :json }
end

resource :github_webhooks, only: :create, defaults: { formats: :json }
Expand Down
47 changes: 47 additions & 0 deletions spec/models/ability_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -593,4 +593,51 @@
it { is_expected.not_to be_able_to(:destroy, other_school_class_saved) }
end
end

describe 'Google Auth' do
context 'with no user' do
let(:user) { nil }

it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
end

context 'with a standard user (no school)' do
let(:user) { build(:user) }

it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
end

context 'with a school teacher' do
let(:user) { create(:user) }
let(:school) { create(:school) }

before do
create(:teacher_role, user_id: user.id, school:)
end

it { is_expected.to be_able_to(:exchange_code, :google_auth) }
end

context 'with a school owner' do
let(:user) { create(:user) }
let(:school) { create(:school) }

before do
create(:owner_role, user_id: user.id, school:)
end

it { is_expected.to be_able_to(:exchange_code, :google_auth) }
end

context 'with a school student' do
let(:user) { create(:user) }
let(:school) { create(:school) }

before do
create(:student_role, user_id: user.id, school:)
end

it { is_expected.not_to be_able_to(:exchange_code, :google_auth) }
end
end
end
184 changes: 184 additions & 0 deletions spec/requests/api/google_auth_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Google Auth requests' do
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
let(:school) { create(:school) }
let(:owner) { create(:owner, school:) }

before do
authenticated_in_hydra_as(owner)
end

describe 'POST /api/google_auth/exchange_code' do
let(:params) do
{
google_auth: {
code: 'test-authorization-code',
redirect_uri: 'https://example.com/callback'
}
}
end

let(:google_token_response) do
{
'access_token' => 'test-access-token',
'expires_in' => 3599,
'token_type' => 'Bearer',
'scope' => 'openid email profile',
'id_token' => 'test-id-token'
}
end

around do |example|
ClimateControl.modify(
GOOGLE_CLIENT_ID: 'test-client-id',
GOOGLE_CLIENT_SECRET: 'test-client-secret'
) do
example.run
end
end

context 'when token exchange is successful' do
before do
stub_request(:post, Api::GoogleAuthController::TOKEN_EXCHANGE_URL)
.with(
body: {
code: 'test-authorization-code',
client_id: 'test-client-id',
client_secret: 'test-client-secret',
redirect_uri: 'https://example.com/callback',
grant_type: 'authorization_code'
}
)
.to_return(
status: 200,
body: google_token_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end

it 'returns success response' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response).to have_http_status(:ok)
end

it 'returns token response from Google' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response.parsed_body).to eq(google_token_response)
end

it 'includes access_token in response' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response.parsed_body['access_token']).to eq('test-access-token')
end
end

context 'when token exchange fails with error from Google' do
let(:error_response) do
{
'error' => 'invalid_grant',
'error_description' => 'Bad Request'
}
end

before do
stub_request(:post, Api::GoogleAuthController::TOKEN_EXCHANGE_URL)
.to_return(
status: 400,
body: error_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end

it 'returns unauthorized response' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response).to have_http_status(:unauthorized)
end

it 'returns error message' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response.parsed_body['error']).to eq('Bad Request')
end
end

context 'when network error occurs' do
before do
stub_request(:post, Api::GoogleAuthController::TOKEN_EXCHANGE_URL)
.to_raise(Faraday::ConnectionFailed.new('Connection failed'))
end

it 'returns service unavailable response' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response).to have_http_status(:service_unavailable)
end

it 'returns error message' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response.parsed_body['error']).to eq('Connection failed')
end
end

context 'when code parameter is missing' do
let(:params) do
{
google_auth: {
redirect_uri: 'https://example.com/callback'
}
}
end

it 'returns bad request response' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response).to have_http_status(:bad_request)
end
end

context 'when redirect_uri parameter is missing' do
let(:params) do
{
google_auth: {
code: 'test-authorization-code'
}
}
end

it 'returns bad request response' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response).to have_http_status(:bad_request)
end
end

context 'when google_auth params are missing' do
it 'returns bad request response' do
post('/api/google/auth/exchange-code', headers:)
expect(response).to have_http_status(:bad_request)
end
end

context 'when user is not authenticated' do
before do
unauthenticated_in_hydra
end

it 'returns unauthorized response' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response).to have_http_status(:unauthorized)
end
end

context 'when user is not authorized' do
let(:student) { create(:student, school:) }

before do
authenticated_in_hydra_as(student)
end

it 'returns forbidden response' do
post('/api/google/auth/exchange-code', params:, headers:)
expect(response).to have_http_status(:forbidden)
end
end
end
end