---
myst:
  html_meta:
    "description": "Write, register, and test a driver for a provider that pas.plugins.identity does not ship."
    "property=og:description": "Write, register, and test a driver for a provider that pas.plugins.identity does not ship."
    "property=og:title": "Write a driver"
---

(how-to-write-a-driver)=

# 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:

```python
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:

```python
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

```xml
<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:
{doc}`/reference/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 {doc}`/reference/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

- {doc}`/reference/driver-contract`—the ten rules, and what enforces each
- {doc}`/reference/claims`—the claim names to normalize to
- {doc}`/reference/shipped-drivers`—five worked examples
- {doc}`/how-to-guides/providers/configure-a-provider`—configuring a provider that uses your driver
