diff --git a/.env.example b/.env.example
index 0fe7a7ee..fdcb63b6 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,11 @@ CANVAS_APP_KEY=
CANVAS_CLIENT_ID=
CANVAS_REDIRECT_URI='https://flextensions.eecs.cloud'
CANVAS_URL='https://ucberkeleysandbox.instructure.com'
+# Google OAuth Configuration
+# Login with Google is only enabled when both variables are set.
+# Existing users only — Google login will not create new accounts.
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
## Gradescope Configuration
# We use a single username/password for Gradescope
# This should be a "service account" that can be used to set course settings
diff --git a/Gemfile b/Gemfile
index 9f7eed60..52202e52 100644
--- a/Gemfile
+++ b/Gemfile
@@ -52,6 +52,7 @@ gem 'ostruct'
# used to authenticate with the LMS
gem 'omniauth'
gem 'omniauth-canvas'
+gem 'omniauth-google-oauth2'
gem 'omniauth-oauth2'
# Audit for potentially unsafe database migrations
diff --git a/Gemfile.lock b/Gemfile.lock
index 4d116cbc..c5bc4ee7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -358,6 +358,11 @@ GEM
omniauth-canvas (2.0.0)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
+ omniauth-google-oauth2 (1.2.2)
+ jwt (>= 2.9.2)
+ oauth2 (~> 2.0)
+ omniauth (~> 2.0)
+ omniauth-oauth2 (~> 1.8)
omniauth-oauth2 (1.9.0)
oauth2 (>= 2.0.2, < 3)
omniauth (~> 2.0)
@@ -644,6 +649,7 @@ DEPENDENCIES
lograge
omniauth
omniauth-canvas
+ omniauth-google-oauth2
omniauth-oauth2
ostruct
pg
diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb
index eeac56a9..dcde47ab 100644
--- a/app/controllers/session_controller.rb
+++ b/app/controllers/session_controller.rb
@@ -46,6 +46,11 @@ def omniauth_callback
return
end
+ if auth.provider == 'google_oauth2'
+ handle_google_login(auth)
+ return
+ end
+
user_data = {
'id' => auth.uid,
'name' => auth.info.name,
@@ -89,6 +94,25 @@ def destroy
private
+ # Google OAuth only authenticates users who already have an account.
+ # No new accounts are created via Google login.
+ def handle_google_login(auth)
+ email = auth.info.email
+ user = User.find_by(email: email) if email.present?
+
+ unless user
+ Rails.logger.warn("Google login rejected: no user with email #{email.inspect}")
+ redirect_to root_path,
+ alert: 'No account found for that Google email. Please log in with Canvas first.'
+ return
+ end
+
+ session[:username] = user.name
+ session[:user_id] = user.canvas_uid
+
+ redirect_to courses_path, notice: "Logged in! Welcome, #{user.name}!"
+ end
+
def ensure_developer_test_enrollments(user)
# Find the test course
test_course = Course.find_by(course_code: 'DEV101')
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index b83449b1..b0659462 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,4 +1,8 @@
module ApplicationHelper
+ def google_oauth_enabled?
+ ENV['GOOGLE_CLIENT_ID'].present? && ENV['GOOGLE_CLIENT_SECRET'].present?
+ end
+
def assignment_link_for(assignment, course)
case assignment.course_to_lms.lms_id
when 1
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb
index f356c5f5..186d3328 100644
--- a/app/views/home/index.html.erb
+++ b/app/views/home/index.html.erb
@@ -23,6 +23,9 @@
<%= link_to 'Login', '/auth/canvas', class: 'btn btn-primary', id: 'login-button-index' %>
+ <% if google_oauth_enabled? %>
+ <%= link_to 'Login with Google', '/auth/google_oauth2', class: 'btn btn-outline-primary ms-2', id: 'google-login-button' %>
+ <% end %>
<% if Rails.env.development? %>
<%= link_to 'Developer Login', '/auth/developer', class: 'btn btn-secondary ms-2', id: 'dev-login-button' %>
<% end %>
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index bfffa61e..3aa8becd 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -28,6 +28,14 @@ def build_access_token
authorize_url: "/login/oauth2/auth?scope=#{encoded_scopes}"
},
redirect_uri: "#{ENV['CANVAS_REDIRECT_URI']}/auth/canvas/callback"
+
+ if ENV['GOOGLE_CLIENT_ID'].present? && ENV['GOOGLE_CLIENT_SECRET'].present?
+ provider :google_oauth2,
+ ENV['GOOGLE_CLIENT_ID'],
+ ENV['GOOGLE_CLIENT_SECRET'],
+ scope: 'email,profile',
+ prompt: 'select_account'
+ end
end
# OmniAuth.config.before_request_phase do |env|
diff --git a/spec/Helpers/application_helper_spec.rb b/spec/Helpers/application_helper_spec.rb
new file mode 100644
index 00000000..da0fc5ae
--- /dev/null
+++ b/spec/Helpers/application_helper_spec.rb
@@ -0,0 +1,37 @@
+require 'rails_helper'
+
+RSpec.describe ApplicationHelper, type: :helper do
+ describe '#google_oauth_enabled?' do
+ around do |example|
+ original_id = ENV['GOOGLE_CLIENT_ID']
+ original_secret = ENV['GOOGLE_CLIENT_SECRET']
+ example.run
+ ENV['GOOGLE_CLIENT_ID'] = original_id
+ ENV['GOOGLE_CLIENT_SECRET'] = original_secret
+ end
+
+ it 'returns true when both Google OAuth env vars are set' do
+ ENV['GOOGLE_CLIENT_ID'] = 'fake-id'
+ ENV['GOOGLE_CLIENT_SECRET'] = 'fake-secret'
+ expect(helper.google_oauth_enabled?).to be(true)
+ end
+
+ it 'returns false when GOOGLE_CLIENT_ID is missing' do
+ ENV['GOOGLE_CLIENT_ID'] = nil
+ ENV['GOOGLE_CLIENT_SECRET'] = 'fake-secret'
+ expect(helper.google_oauth_enabled?).to be(false)
+ end
+
+ it 'returns false when GOOGLE_CLIENT_SECRET is missing' do
+ ENV['GOOGLE_CLIENT_ID'] = 'fake-id'
+ ENV['GOOGLE_CLIENT_SECRET'] = nil
+ expect(helper.google_oauth_enabled?).to be(false)
+ end
+
+ it 'returns false when both env vars are blank' do
+ ENV['GOOGLE_CLIENT_ID'] = ''
+ ENV['GOOGLE_CLIENT_SECRET'] = ''
+ expect(helper.google_oauth_enabled?).to be(false)
+ end
+ end
+end
diff --git a/spec/controllers/home_controller_spec.rb b/spec/controllers/home_controller_spec.rb
index 1a57eb82..e0379ea9 100644
--- a/spec/controllers/home_controller_spec.rb
+++ b/spec/controllers/home_controller_spec.rb
@@ -18,5 +18,32 @@
expect(response).to redirect_to(courses_path)
end
end
+
+ context 'Google login button visibility' do
+ render_views
+
+ around do |example|
+ original_id = ENV['GOOGLE_CLIENT_ID']
+ original_secret = ENV['GOOGLE_CLIENT_SECRET']
+ example.run
+ ENV['GOOGLE_CLIENT_ID'] = original_id
+ ENV['GOOGLE_CLIENT_SECRET'] = original_secret
+ end
+
+ it 'shows the Login with Google button when Google OAuth is configured' do
+ ENV['GOOGLE_CLIENT_ID'] = 'fake-id'
+ ENV['GOOGLE_CLIENT_SECRET'] = 'fake-secret'
+ get :index
+ expect(response.body).to include('Login with Google')
+ expect(response.body).to include('/auth/google_oauth2')
+ end
+
+ it 'hides the Login with Google button when env vars are not set' do
+ ENV['GOOGLE_CLIENT_ID'] = nil
+ ENV['GOOGLE_CLIENT_SECRET'] = nil
+ get :index
+ expect(response.body).not_to include('Login with Google')
+ end
+ end
end
end
diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb
index 6acae4d2..31c6b6a3 100644
--- a/spec/controllers/session_controller_spec.rb
+++ b/spec/controllers/session_controller_spec.rb
@@ -166,6 +166,77 @@
end
end
+ describe 'GET #omniauth_callback (google_oauth2 provider)' do
+ let(:google_auth_hash) do
+ OmniAuth::AuthHash.new(
+ provider: 'google_oauth2',
+ uid: 'google-uid-1',
+ info: OpenStruct.new(name: 'Google User', email: 'existing@example.com'),
+ credentials: {
+ token: 'google-token',
+ refresh_token: 'google-refresh',
+ expires_at: 1.hour.from_now.to_i
+ }
+ )
+ end
+
+ before do
+ request.env['omniauth.auth'] = google_auth_hash
+ end
+
+ context 'when a user with the given email already exists' do
+ let!(:existing_user) do
+ User.create!(email: 'existing@example.com', canvas_uid: 'canvas-99', name: 'Existing User')
+ end
+
+ it 'logs the user in and redirects to courses' do
+ get :omniauth_callback, params: { provider: 'google_oauth2' }
+
+ expect(session[:user_id]).to eq('canvas-99')
+ expect(session[:username]).to eq('Existing User')
+ expect(response).to redirect_to(courses_path)
+ expect(flash[:notice]).to include('Logged in!')
+ end
+
+ it 'does not create a new user' do
+ expect do
+ get :omniauth_callback, params: { provider: 'google_oauth2' }
+ end.not_to change(User, :count)
+ end
+
+ it 'does not create LMS credentials for the user' do
+ expect do
+ get :omniauth_callback, params: { provider: 'google_oauth2' }
+ end.not_to change { existing_user.reload.lms_credentials.count }
+ end
+ end
+
+ context 'when no user with the given email exists' do
+ it 'does not create a user and redirects to root with an alert' do
+ expect do
+ get :omniauth_callback, params: { provider: 'google_oauth2' }
+ end.not_to change(User, :count)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to include('No account found')
+ expect(session[:user_id]).to be_nil
+ end
+ end
+
+ context 'when the auth hash has no email' do
+ before do
+ google_auth_hash.info.email = nil
+ end
+
+ it 'rejects the login with an alert' do
+ get :omniauth_callback, params: { provider: 'google_oauth2' }
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to include('No account found')
+ end
+ end
+ end
+
describe 'GET #logout' do
before do
session[:user_id] = 'test_user_id'