diff --git a/.env.example b/.env.example index 173e415e..14cf08ae 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/controllers/api/google_auth_controller.rb b/app/controllers/api/google_auth_controller.rb new file mode 100644 index 00000000..8771c662 --- /dev/null +++ b/app/controllers/api/google_auth_controller.rb @@ -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 diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb index c6efc85b..acf2cff4 100644 --- a/app/controllers/auth_controller.rb +++ b/app/controllers/auth_controller.rb @@ -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 diff --git a/app/models/ability.rb b/app/models/ability.rb index 5640518a..d695b8f3 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -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:) @@ -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:) diff --git a/app/views/api/google_auth/exchange_code.json.jbuilder b/app/views/api/google_auth/exchange_code.json.jbuilder new file mode 100644 index 00000000..92e9935b --- /dev/null +++ b/app/views/api/google_auth/exchange_code.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.call( + @token_response, + 'access_token', + 'expires_in', + 'token_type', + 'scope', + 'id_token' +) diff --git a/config/routes.rb b/config/routes.rb index affbe044..09b6b6a6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 } diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index adc35c3d..f7d2e09c 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -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 diff --git a/spec/requests/api/google_auth_controller_spec.rb b/spec/requests/api/google_auth_controller_spec.rb new file mode 100644 index 00000000..1ee45943 --- /dev/null +++ b/spec/requests/api/google_auth_controller_spec.rb @@ -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