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 |
|
Publishes one at an issuer you know in advance |
|
|
Publishes no discovery document at all |
|
|
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#
Driver contract—the ten rules, and what enforces each
Claims released by the server layer—the claim names to normalize to
Shipped drivers—five worked examples
Configure a provider—configuring a provider that uses your driver