Repository: cal-itp/benefits

Issue:
Enable Google SSO login for Admin
The basic Django admin manages local user accounts for login and access control.

As we grow usage of the tool, we'd like to not have to manage specific user accounts, and would rather login via existing accounts, e.g. Google Workspace.

## Acceptance Criteria

<!-- Remember to consider edge cases -->

- [ ] Engineers can login to the Admin using `@compiler.la` Google Workspace accounts

## Additional context

Potential options from a quick search (haven't evaluated any of these yet):

- https://github.com/megalus/django-google-sso
- https://github.com/matthiask/django-authlib
- https://github.com/pennersr/django-allauth
- https://github.com/SAML-Toolkits/python3-saml
- https://github.com/AngellusMortis/django_microsoft_auth (we might want this later)

We could also likely roll something with our existing [`Authlib`](https://docs.authlib.org/en/latest/client/django.html) package.


Discussion:

Resolving patch:
diff --git a/benefits/core/admin.py b/benefits/core/admin.py
--- a/benefits/core/admin.py
+++ b/benefits/core/admin.py
@@ -2,8 +2,9 @@
 The core application: Admin interface configuration.
 """
 
-from django.conf import settings
+import requests
 
+from django.conf import settings
 
 if settings.ADMIN:
     import logging
@@ -21,3 +22,23 @@
     ]:
         logger.debug(f"Register {model.__name__}")
         admin.site.register(model)
+
+    def pre_login_user(user, request):
+        logger.debug(f"Running pre-login callback for user: {user.username}")
+        token = request.session.get("google_sso_access_token")
+        if token:
+            headers = {
+                "Authorization": f"Bearer {token}",
+            }
+
+            # Request Google user info to get name and email
+            url = "https://www.googleapis.com/oauth2/v3/userinfo"
+            response = requests.get(url, headers=headers, timeout=settings.REQUESTS_TIMEOUT)
+            user_data = response.json()
+            logger.debug(f"Updating admin user data from Google for user with email: {user_data['email']}")
+
+            user.first_name = user_data["given_name"]
+            user.last_name = user_data["family_name"]
+            user.username = user_data["email"]
+            user.email = user_data["email"]
+            user.save()
diff --git a/benefits/settings.py b/benefits/settings.py
--- a/benefits/settings.py
+++ b/benefits/settings.py
@@ -22,7 +22,7 @@ def _filter_empty(ls):
 
 ADMIN = os.environ.get("DJANGO_ADMIN", "False").lower() == "true"
 
-ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(","))
+ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(","))
 
 # Application definition
 
@@ -37,11 +37,27 @@ def _filter_empty(ls):
 ]
 
 if ADMIN:
+    GOOGLE_SSO_CLIENT_ID = os.environ.get("GOOGLE_SSO_CLIENT_ID", "secret")
+    GOOGLE_SSO_PROJECT_ID = os.environ.get("GOOGLE_SSO_PROJECT_ID", "benefits-admin")
+    GOOGLE_SSO_CLIENT_SECRET = os.environ.get("GOOGLE_SSO_CLIENT_SECRET", "secret")
+    GOOGLE_SSO_ALLOWABLE_DOMAINS = _filter_empty(os.environ.get("GOOGLE_SSO_ALLOWABLE_DOMAINS", "compiler.la").split(","))
+    GOOGLE_SSO_STAFF_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_STAFF_LIST", "").split(","))
+    GOOGLE_SSO_SUPERUSER_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_SUPERUSER_LIST", "").split(","))
+    GOOGLE_SSO_LOGO_URL = "/static/img/icon/google_sso_logo.svg"
+    GOOGLE_SSO_SAVE_ACCESS_TOKEN = True
+    GOOGLE_SSO_PRE_LOGIN_CALLBACK = "benefits.core.admin.pre_login_user"
+    GOOGLE_SSO_SCOPES = [
+        "openid",
+        "https://www.googleapis.com/auth/userinfo.email",
+        "https://www.googleapis.com/auth/userinfo.profile",
+    ]
+
     INSTALLED_APPS.extend(
         [
             "django.contrib.admin",
             "django.contrib.auth",
             "django.contrib.contenttypes",
+            "django_google_sso",  # Add django_google_sso
         ]
     )
 
@@ -282,7 +298,11 @@ def _filter_empty(ls):
 if len(env_frame_src) > 0:
     CSP_FRAME_SRC = env_frame_src
 
-CSP_IMG_SRC = ["'self'", "data:"]
+CSP_IMG_SRC = [
+    "'self'",
+    "data:",
+    "*.googleusercontent.com",
+]
 
 # Configuring strict Content Security Policy
 # https://django-csp.readthedocs.io/en/latest/nonce.html
@@ -294,9 +314,11 @@ def _filter_empty(ls):
     CSP_REPORT_URI = [sentry.SENTRY_CSP_REPORT_URI]
 
 CSP_SCRIPT_SRC = [
+    "'self'",
     "https://cdn.amplitude.com/libs/",
     "https://cdn.jsdelivr.net/",
     "*.littlepay.com",
+    "https://code.jquery.com/jquery-3.6.0.min.js",
 ]
 env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(","))
 CSP_SCRIPT_SRC.extend(env_script_src)
diff --git a/benefits/urls.py b/benefits/urls.py
--- a/benefits/urls.py
+++ b/benefits/urls.py
@@ -39,5 +39,6 @@ def trigger_error(request):
 
     logger.debug("Register admin urls")
     urlpatterns.append(path("admin/", admin.site.urls))
+    urlpatterns.append(path("google_sso/", include("django_google_sso.urls", namespace="django_google_sso")))
 else:
     logger.debug("Skip url registrations for admin")


Test patch: