-
-
Notifications
You must be signed in to change notification settings - Fork 10
Description
Situation: Django environment with REST and API authentication. For GUI interface, requiring SSO with our Google IdP. This is currently not possible out-of-the-box with django_pyoidc but managed to get it to work with some small changes. Sharing it here so others might benefit. This is just a rough list of notes, unchecked, as-is, without any support from my side.
All my authentication stuff is in the app accounts.
First of all, install django_pyoidc with pip install django_pyoidc and add it to INSTALLED_APPS in your settings, after rest_framework (if you have it), before your own apps.
No need to add/change AUTHENTICATION_BACKENDS. I have it set to just django.contrib.auth.backends.ModelBackend.
Create the table django_pyoidc needs with: python manage.py migrate django_pyoidc
Do configure some kind of caching.
The class OIDCLoginView only requests the openid scope while with Google we will need the email scope as well. Unfortunately we cannot modify the requested scopes without overriding the entire class. This was mentioned before by mathildepommier in issue 31. I created this new issue as I didn't want to hijack that one.
To resolve that issue, a pull request already got prepared by fkreiner.
Create accounts/google_provider.py with contents like:
from typing import Any
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django_pyoidc.client import OIDCClient
from django_pyoidc.exceptions import InvalidSIDException
from django_pyoidc.providers.provider import Provider
from django_pyoidc.views import OIDCView
class GoogleWorkspaceProvider(Provider):
"""
Minimal Provider subclass for Google
- Uses whatever provider_discovery_uri you pass via DJANGO_PYOIDC["sso"].
"""
def __init__(self, *args, op_name: str, **kwargs):
super().__init__(*args, op_name=op_name, **kwargs)
def get_default_config(self):
cfg = super().get_default_config()
# If you forget to set provider_discovery_uri in settings, fall back to Google.
if not cfg["provider_discovery_uri"]:
cfg["provider_discovery_uri"] = (
"https://accounts.google.com/.well-known/openid-configuration"
)
return cfg
class OIDCLoginView(OIDCView):
"""
The django_pyoidc version of this class lives as django_pyoidc.views.OIDCLoginView.
That version has the scope hard coded as just "openid". For Google authentication
we do need the "email" scope as well. That's the only change here.
"""
http_method_names = ["get"]
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
sid = request.session.get("oidc_sid")
if sid:
try:
client = OIDCClient(self.op_name, session_id=sid)
except InvalidSIDException:
# maybe a failed attempt trace in the session.
# we ignore the session sid and get back on the first steps.
client = OIDCClient(self.op_name)
else:
client = OIDCClient(self.op_name)
callback_path = str(self.get_setting("oidc_callback_path"))
if not callback_path.startswith("/"):
# issue in pyoidc when base_url ends with "/" and path does not start with "/" there's an extra "/" added (doubling)
# to prevent that we finally enforce the "/" prefix on the path
callback_path = f"/{callback_path}"
client.consumer.consumer_config["authz_page"] = callback_path
next_redirect_uri = self.get_next_url(request, "next")
if not next_redirect_uri:
next_redirect_uri = str(
self.get_setting(
"post_login_uri_success", request.build_absolute_uri("/")
)
)
request.session["oidc_login_next"] = next_redirect_uri
sid, location = client.consumer.begin(
scope=self.get_setting("scope", ["openid"]), # This is the only change here
response_type="code",
use_nonce=True,
path=self.request.build_absolute_uri("/"),
)
request.session["oidc_sid"] = sid
return redirect(location)In case the pull request got pulled into the main branch, just leave out the class OIDCLoginView part in accounts/google_provider.py.
Add DJANGO_PYOIDC setting to settings.py:
DJANGO_PYOIDC = {
"sso": {
"provider_class": "accounts.google_provider.GoogleWorkspaceProvider",
# Google OIDC discovery. Can also be used for Entra
"provider_discovery_uri": os.environ.get("OIDC_DISCOVERY_URL",),
"client_id": os.getenv("OIDC_CLIENT_ID"),
"client_secret": os.getenv("OIDC_CLIENT_SECRET"),
"oidc_callback_path": '/accounts/sso/sso-callback',
"scope": ["openid", "email"], # this is new
# Where to send the user after successful SSO login / logout
"post_login_uri_success": "/accounts/sso/post-login/",
"post_login_uri_failure": "/accounts/login/",
"post_logout_redirect_uri": "/",
"login_redirection_requires_https": not DEBUG,
# Cache provider metadata; safe and avoids hitting Google each time
"oidc_cache_provider_metadata": True,
"oidc_cache_provider_metadata_ttl": 300,
# Optional
# "hook_get_user": "accounts.oidc_hooks:get_user_from_tokens",
# "hook_user_login": "accounts.oidc_hooks:on_user_login",
}
}Now wiring everything together:
The project urls.py is just something like:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
...
path("accounts/", include(("accounts.urls", "accounts"), namespace="accounts")),
...
]As long as the aforementioned pull request isn't pulled, we have to shadow the sso-login generated by oidc_helper.get_urlpatterns() in accounts/urls.py. In case it is pulled, only use the oidc_helper.get_urlpatterns(). We need to use re_path with the optional trailing / as get_urlpatterns provides patterns without the ending / - we need to catch with and without.
from django.urls import path, include
from django_pyoidc.helper import OIDCHelper
from .google_provider import OIDCLoginView
app_name = "accounts"
oidc_helper = OIDCHelper(op_name="sso") # op_name must match the key in DJANGO_PYOIDC
urlpatterns = [
...
re_path(r'^sso/sso-login/?$', OIDCLoginView.as_view(op_name="sso"), name="sso-login"), # shadow django_pyoidc url
path("sso/", include(oidc_helper.get_urlpatterns())),
...
]Last but not least, generate the OIDC_CLIENT_ID and OIDC_CLIENT_SECRET with your Google environment.
Then login by visiting /sso/sso-login