Skip to content

Using Google IdP #55

@roldenboom

Description

@roldenboom

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions