Write a driver#

This guide shows you how to add support for a provider the package does not ship.

A driver is two things: static metadata describing what a kind of provider needs, and a function that turns that provider's answer into the normalized claims schema. It holds no state and it makes no decisions about accounts.

Drivers are registered as named ZCA utilities, and the utility name is the driver id. This package keeps one module per driver, and doing the same in your own add-on keeps a driver easy to find.

Where the endpoints come from#

Everything else follows from it, and there are three answers.

Your provider

What the driver declares

Shipped example

Publishes an OpenID Connect discovery document at an issuer the operator runs

nothing—extend IOIDCSettings, which asks for the issuer

oidc-generic

Publishes one at an issuer you know in advance

issuer = "https://…"

google

Publishes no discovery document at all

static_metadata = {…}, with at least an authorization_endpoint and a token_endpoint

github

A driver that declares none of the three is refused rather than sent to discovery with an empty URL, so a login against it fails with has no authorization endpoints.

This is the trap worth naming: the first field most people reach for is the provider's URL, and for a self-hosted provider it already exists. It is issuer, IOIDCSettings declares it, and adding a base_url of your own means the discovery machinery never sees it.

Subclass BaseDriver#

BaseDriver gives you the OAuth configuration fields, the subject extraction, and the shared claim normalization. A driver is usually a few class attributes and one override.

A self-hosted GitLab is the first row of that table—it is a conforming OpenID Connect provider—so its settings are IOIDCSettings unchanged, and there is no schema to write at all:

from pas.plugins.identity import api


class GitLabDriver(api.BaseDriver):
    """Sign in with a self-hosted GitLab."""

    driver_id = "gitlab"
    title = "GitLab"
    settings_schema = api.IOIDCSettings
    default_scope = ("openid", "email", "profile")
    subject_keys = ("sub", "id")

    #: GitLab advertises both `groups` and `groups_direct`; the second is
    #: memberships held directly rather than inherited from a parent group,
    #: which is the one an access decision usually means. An operator can
    #: still override it per provider.
    default_group_claim = "groups_direct"

    def normalize_claims(self, payload: api.JSONDict) -> api.Claims:
        """Turn GitLab's answer into the documented schema.

        :param payload: The provider's userinfo response.
        :returns: Normalized claims.
        """
        claims = super().normalize_claims(payload)
        claims["picture_url"] = payload.get("avatar_url", "")
        return claims

When your provider genuinely does need a field nothing else has, declare it as an ordinary zope.schema interface extending the one you would otherwise use, and say where it sits:

from pas.plugins.identity import _
from pas.plugins.identity import api
from plone.autoform import directives
from zope import schema


class IGitLabSettings(api.IOIDCSettings):
    """What a GitLab needs beyond the OpenID Connect fields."""

    project_path = schema.TextLine(
        title=_("Project path"),
        description=_("Sign in only members of this project."),
        required=False,
    )
    # Position is declared, never spaced out by hand.
    directives.order_before(project_path="client_id")

@identity-drivers serializes that interface with plone.restapi, so the field appears in the control panel with no frontend change, in the site's language, and validated.

Tip

Put translatable strings through _(), and let the field type carry the meaning: a Password is masked everywhere without a flag, a Choice over a vocabulary becomes a select, and a Tuple becomes a list. Defaults that differ per driver—the scope GitLab needs, the userid source—stay on the driver class as default_scope and friends, because a subinterface redeclaring a field to change its default gives it a fresh creation order and the field jumps to the end of the form.

If you would rather implement IDriver from scratch, the interface asks for driver_id, title, settings_schema, subject(), and normalize_claims().

Register it#

<utility
    factory=".gitlab.GitLabDriver"
    provides="pas.plugins.identity.core.interfaces.IDriver"
    name="gitlab"
    />

Follow the rules#

There are ten, and they are a checklist rather than a narrative: Driver contract lists each one with what enforces it, and explains the four that surprise people—why order is a number, why only a literal True counts as verified, why an empty default_group_claim is not a neutral default, and why the endpoints belong to the driver.

Three of them are worth stating here, because they are about code you are about to write rather than values you are about to declare.

Report every address the provider knows, not the first one. normalize_claims fills emails with one entry per address -- address, verified, primary -- and the base class does that for you from the single address most providers send. Override it only where the provider offers more, and put them in the order they should be offered: primary first, verified before unverified. All of them go onto the person's profile, and email is the head of the list.

Never construct protocol messages by hand. Authorization URLs and token requests go through authlib's OAuth2Session; every JWT is read by joserfc. The suite checks this: tests/test_protocol_libraries.py fails when either library is imported outside the five modules that own a protocol boundary. It exists because hand-rolled OAuth is how this goes wrong.

Put your own data in raw and nowhere else. Put the untouched payload in raw and read it in your own consumers if you must. Anything else you invent at the top level is ignored.

For the schema your driver must produce, see Events.

Test it#

Normalize against recorded payload fixtures rather than live calls.

The drivers shipped here are tested that way. A real captured response is checked into the test suite, normalized, and asserted field by field. It costs nothing to run, it does not need credentials, and it still catches the case that matters, which is a provider changing the shape of its answer.

Next steps#