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
11 changes: 11 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ CANVAS_SERVER_URL=
CANVAS_CLIENT_ID=
CANVAS_CLIENT_SECRET=

# Google OAuth login (optional)
# Create an OAuth 2.0 Client ID in Google Cloud Console and paste the values
# below. Leave blank to disable the Google login option. See the README for
# the exact redirect URIs and authorized JavaScript origins to configure.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Comma-separated list of Google Workspace domains permitted to sign in
# (e.g. "berkeley.edu"). Leave blank to accept any Google account that
# matches an existing user's email.
GOOGLE_HOSTED_DOMAINS=

# gcp service account credentials
# available vaGCP_SA_CRED_TYPE values: env, file
# if GCP_SA_CRED_TYPE is env, put the encoded cred value in GCP_SA_CRED_VALUE
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,21 @@ The entire app is protected behind Canvas (bCourses) authentication and authoriz
- If you are an instructor or a course staff member who wants to use this app for your course, see [here](../../wiki/for-course-staff).
- If you are a developer or maintainer that works with this app, see [here](../../wiki/for-developers).
- If you are a student, see [here](../../wiki/for-students).

## Authentication

The app supports two login providers:

- **Canvas (bCourses)** — the primary provider. Required for first-time
sign-in; this is also what populates a user's email and course
enrollments.
- **Google** — an optional secondary provider. When configured, users
who have already signed in via Canvas at least once may sign in with
the Google account that matches their Canvas email. Google sign-in
will **never** create a new user.

See [`docs/google-oauth.md`](docs/google-oauth.md) for step-by-step
instructions on creating an OAuth client in Google Cloud Console, the
exact JavaScript origins and redirect URIs to register, and the
`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `GOOGLE_HOSTED_DOMAINS`
environment variables that enable the Google login button.
9 changes: 9 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ def getenv(key, default: Optional[str] = None, *, optional=False):
CANVAS_CLIENT_ID = getenv('CANVAS_CLIENT_ID')
CANVAS_CLIENT_SECRET = getenv('CANVAS_CLIENT_SECRET')

# Google OAuth setup (used as an alternate login provider).
# When unset, the Google login button is hidden. See the wiki/README for
# details on creating an OAuth client in Google Cloud Console.
GOOGLE_CLIENT_ID = getenv('GOOGLE_CLIENT_ID', optional=True)
GOOGLE_CLIENT_SECRET = getenv('GOOGLE_CLIENT_SECRET', optional=True)
# Comma-separated list of hosted domains to accept (e.g. "berkeley.edu").
# Leave empty to accept any Google account that matches an existing user.
GOOGLE_HOSTED_DOMAINS = getenv('GOOGLE_HOSTED_DOMAINS', '')

# Stub Setup, allow
MOCK_CANVAS = getenv('MOCK_CANVAS', 'false').lower() == 'true'
SEND_EMAIL = getenv('SEND_EMAIL', 'off').lower()
Expand Down
106 changes: 106 additions & 0 deletions docs/google-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Google OAuth Login Setup

The seating app supports Google OAuth as a **secondary** sign-in option
alongside Canvas. Google login is only used to authenticate **existing**
users — a Google sign-in will never create a new account. Users must
first sign in via Canvas at least once to establish their account, after
which they may sign in with the Google account that matches their
Canvas email.

This makes Google OAuth useful when:

- A user's Canvas access has lapsed (e.g. course ended) but they still
need to access historical seating data.
- Staff prefer a faster sign-in flow than the full Canvas OAuth
round-trip.

If Google credentials are not configured (i.e. `GOOGLE_CLIENT_ID` /
`GOOGLE_CLIENT_SECRET` are blank), the Google option is hidden and the
login flow falls back to Canvas-only — exactly the same as before.

## 1. Create the OAuth client in Google Cloud Console

1. Open the [Google Cloud Console](https://console.cloud.google.com/)
and select (or create) the project you want to host the OAuth
client in. The seating app already uses a Google Cloud project for
the Google Sheets service account (see `GCP_SA_CRED_*` settings);
you can reuse that project or create a new one.
2. In the left nav, go to **APIs & Services → OAuth consent screen**
and configure the consent screen if you have not already:
- User type: **Internal** if your organization uses Google
Workspace and you only want users on that domain (recommended for
`berkeley.edu`). Otherwise use **External**.
- App name, support email, developer email: required fields.
- Scopes: add `openid`, `.../auth/userinfo.email`, and
`.../auth/userinfo.profile`. No other scopes are required by the
seating app.
3. In the left nav, go to **APIs & Services → Credentials** and click
**Create Credentials → OAuth client ID**.
4. Application type: **Web application**.
5. Name: anything recognizable, e.g. *Seating App – Production*.
6. **Authorized JavaScript origins**: add the root URL of every
environment that needs to sign in. Examples:
- `https://seating.example.edu`
- `http://localhost:5000` (development only)
7. **Authorized redirect URIs**: add the full callback URL for every
environment. The path is fixed at `/authorized/google/`. Examples:
- `https://seating.example.edu/authorized/google/`
- `http://localhost:5000/authorized/google/` (development only)

The trailing slash matters — it must match the Flask route exactly.
8. Click **Create**. Google will show you a **Client ID** and **Client
secret** — copy both. (You can retrieve them again later from the
same Credentials page.)

## 2. Configure the seating app

Add the following variables to your environment (`.env` file in
development, your hosting platform's secret store in production). See
[`.env.sample`](../.env.sample) for the full list.

```bash
# OAuth Client ID issued by Google Cloud Console.
GOOGLE_CLIENT_ID=1234567890-abc...apps.googleusercontent.com

# OAuth Client secret issued by Google Cloud Console.
GOOGLE_CLIENT_SECRET=GOCSPX-...

# Optional: restrict sign-ins to one or more Google Workspace domains.
# Comma-separated; leave blank to accept any Google account whose email
# matches a known user. Highly recommended in production.
GOOGLE_HOSTED_DOMAINS=berkeley.edu
```

Restart the Flask app. The login page (`/login/`) will now show a
"Sign in with Google" button next to the existing "Sign in with
Canvas" option.

## 3. How the matching works

When a user signs in with Google, the app:

1. Verifies the Google token and reads the user's email and unique
Google account id (the OpenID `sub` claim).
2. Rejects unverified emails (`email_verified=false`).
3. If `GOOGLE_HOSTED_DOMAINS` is set, rejects any email whose Google
Workspace domain (`hd` claim or, as a fallback, the email's domain
suffix) is not in the allow-list.
4. Looks up an existing `User` row by Google account id first, then
falls back to a case-insensitive email match against the `email`
column populated during Canvas sign-in.
5. If no matching user is found, the sign-in is rejected with a
flashed message instructing the user to log in via Canvas first.
**No new user record is created.**
6. On the first successful match by email, the Google account id is
stored on the user so subsequent sign-ins are bound to the stable
identifier and survive email-address changes.

## 4. Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| The Google button does not appear on `/login/`. | `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is unset; the server was not restarted; or you are in `MOCK_CANVAS=true` mode, which redirects to the dev login screen. |
| "redirect_uri_mismatch" error from Google. | The redirect URI registered in Google Cloud Console must exactly match `${SERVER_BASE_URL}/authorized/google/` (including scheme, host, port, and trailing slash). |
| "No account is associated with this Google email." | The user has never signed in with Canvas (so the app does not know their email), or the Google email differs from what Canvas reported. Ask the user to sign in with Canvas first. |
| "Your Google domain is not permitted to sign in here." | The user's Google Workspace domain is not in `GOOGLE_HOSTED_DOMAINS`. Add it, or have them sign in with Canvas instead. |
| Domain restriction is bypassed for personal Gmail accounts. | Personal Gmail accounts do not have an `hd` claim. The fallback uses the email suffix, so `something@gmail.com` will only be allowed if `gmail.com` is explicitly listed in `GOOGLE_HOSTED_DOMAINS`. |
127 changes: 123 additions & 4 deletions server/controllers/auth_controllers.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,42 @@
from flask import redirect, request, session, url_for
from flask import flash, redirect, render_template, request, session, url_for
from flask_login import login_user, logout_user, login_required
import server.services.canvas as canvas_client

from server import app
from server.models import db, User

from server.controllers import auth_module
from server.services.auth import oauth_provider
from server.services.auth import (
google_oauth_provider,
is_google_login_enabled,
oauth_provider,
)


def _canvas_authorize():
return oauth_provider.authorize(
callback=url_for('auth.authorized', state=None, _external=True, _scheme="https"))


@auth_module.route('/login/')
def login():
if canvas_client.is_mock_canvas():
return redirect(url_for('dev_login.dev_login_page'))
return oauth_provider.authorize(
callback=url_for('auth.authorized', state=None, _external=True, _scheme="https"))
if is_google_login_enabled():
# Show a chooser so users can pick between Canvas and Google.
return render_template(
'login.html.j2',
title="Login",
google_enabled=True,
)
return _canvas_authorize()


@auth_module.route('/login/canvas/')
def login_canvas():
if canvas_client.is_mock_canvas():
return redirect(url_for('dev_login.dev_login_page'))
return _canvas_authorize()


@auth_module.route('/authorized/')
Expand All @@ -29,24 +52,120 @@ def authorized():
staff_offerings = [str(c.id) for c in staff_course_dics]
student_offerings = [str(c.id) for c in student_course_dics]

email = user_info.get('email') or getattr(user, 'email', None)

user_model = User.query.filter_by(canvas_id=str(user_info['id'])).one_or_none()
if not user_model:
user_model = User(
name=user_info['name'],
canvas_id=str(user_info['id']),
email=email,
staff_offerings=staff_offerings,
student_offerings=student_offerings)
db.session.add(user_model)
else:
user_model.staff_offerings = staff_offerings
user_model.student_offerings = student_offerings
if email:
user_model.email = email
db.session.commit()

login_user(user_model, remember=True)
after_login = session.pop('after_login', None) or url_for('index')
return redirect(after_login)


@auth_module.route('/login/google/')
def login_google():
if not is_google_login_enabled():
flash("Google login is not configured for this server.", "error")
return redirect(url_for('index'))
return google_oauth_provider.authorize(
callback=url_for('auth.authorized_google', _external=True, _scheme="https"))


def _google_profile_from_response(resp):
"""Validate the token response and fetch the user's Google profile."""
if resp is None or 'access_token' not in resp:
return None, 'Access denied: {}'.format(request.args.get('error', 'unknown error'))
session['google_access_token'] = resp['access_token']
profile_resp = google_oauth_provider.get('userinfo')
profile = profile_resp.data if profile_resp else None
if not profile or not isinstance(profile, dict):
flash("Could not read Google profile.", "error")
return None, redirect(url_for('index'))
if not profile.get('email_verified', True):
flash("Your Google email is not verified.", "error")
return None, redirect(url_for('index'))
return profile, None


def _google_domain_is_allowed(profile):
allowed_domains = [
d.strip().lower()
for d in (app.config.get('GOOGLE_HOSTED_DOMAINS') or '').split(',')
if d.strip()
]
if not allowed_domains:
return True
email = (profile.get('email') or '').lower().strip()
hd = (profile.get('hd') or email.rsplit('@', 1)[-1]).lower()
return hd in allowed_domains


def _find_existing_user_for_google(google_id, email):
user_model = User.query.filter_by(google_id=google_id).one_or_none()
if not user_model:
user_model = User.query.filter(db.func.lower(User.email) == email).one_or_none()
return user_model


@auth_module.route('/authorized/google/')
def authorized_google():
"""Authenticate an existing user via their Google account.

This handler intentionally does NOT create new users — Google OAuth is
only a sign-in alternative for users that already exist in the system
(typically created on first Canvas login). Unknown emails are rejected.
"""
if not is_google_login_enabled():
flash("Google login is not configured for this server.", "error")
return redirect(url_for('index'))

profile, early_response = _google_profile_from_response(
google_oauth_provider.authorized_response())
if profile is None:
return early_response

email = (profile.get('email') or '').lower().strip()
google_id = profile.get('sub')
if not email or not google_id:
flash("Google did not return an email address.", "error")
return redirect(url_for('index'))

if not _google_domain_is_allowed(profile):
flash("Your Google domain is not permitted to sign in here.", "error")
return redirect(url_for('index'))

user_model = _find_existing_user_for_google(google_id, email)
if not user_model:
flash(
"No account is associated with this Google email. "
"Please log in with Canvas at least once first.",
"error")
return redirect(url_for('index'))

# Link the Google identity on first successful match so subsequent
# logins are bound by the stable Google subject id, not just email.
if not user_model.google_id:
user_model.google_id = google_id
db.session.commit()

login_user(user_model, remember=True)
after_login = session.pop('after_login', None) or url_for('index')
return redirect(after_login)


@auth_module.route('/logout/')
@login_required
def logout():
Expand Down
2 changes: 2 additions & 0 deletions server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), index=True, nullable=False)
canvas_id = db.Column(db.String(255), nullable=False, index=True, unique=True)
email = db.Column(db.String(255), index=True, nullable=True)
google_id = db.Column(db.String(255), index=True, unique=True, nullable=True)
staff_offerings = db.Column(StringSet, nullable=False)
student_offerings = db.Column(StringSet, nullable=False)

Expand Down
29 changes: 29 additions & 0 deletions server/services/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ def get_access_token(token=None):
return session.get('access_token')


google_client_id = app.config.get('GOOGLE_CLIENT_ID')
google_client_secret = app.config.get('GOOGLE_CLIENT_SECRET')

google_oauth_provider = None

if google_client_id and google_client_secret:
google_oauth_provider = oauth.remote_app(
'google',
consumer_key=google_client_id,
consumer_secret=google_client_secret,
request_token_params={
'scope': 'openid email profile',
},
base_url='https://www.googleapis.com/oauth2/v3/',
request_token_url=None,
access_token_method='POST',
access_token_url='https://oauth2.googleapis.com/token',
authorize_url='https://accounts.google.com/o/oauth2/auth',
)

@google_oauth_provider.tokengetter
def get_google_access_token(token=None):
return session.get('google_access_token')


def is_google_login_enabled() -> bool:
return google_oauth_provider is not None


@login_manager.user_loader
def load_user(user_id):
from server.models import User
Expand Down
Loading
Loading