Skip to main content

splent_feature_identities

The identities feature of a SPLENT product line.

The accounts on sign-in providers (ORCID, GitHub...) that people link to their account, and the one OAuth flow every provider goes through. It keeps one row per linked provider account in external_identity, runs the round trip (sending people to the provider, the callback, signing in, linking from inside an account and opening a new one), lists the links at /account/identities and lets the owner disconnect one. It shows no button of its own: a provider feature (orcid, github) describes itself with an OAuthProvider and draws its buttons and rows. It needs auth, which owns the accounts and opens new ones, and profile, whose edit page is where the linked accounts are managed and whose form a new account is opened with.

A SPLENT feature is a Python package that a product composes with others. Its models, routes, services, templates and hooks are registered when the product starts. What this feature provides and requires is listed in the [tool.splent.contract] section of pyproject.toml, which splent feature:contract --write keeps in sync with the source.

The rules every provider follows

IdentitiesService and the flow (flow.py) hold them, so ORCID and GitHub behave the same way.

Rule How
A provider account signs in to the account it is linked to, and only when that account is active linked_user(provider, provider_id), then flow.sign_in, which checks active and login_user
A sign-in never joins an existing account because an email matches, since nothing proves the person owns the account here An address another account has opens nothing (EmailTaken)
Linking happens from inside the account, through the provider's connect link link(user, provider, account), only for the account that started the connect
Linking needs a recent sign-in, since it gives the account a new way in flow.begin_connect sends a sign-in older than auth's AUTH_REAUTH_SECONDS to /account/reauthenticate first, and comes back
Linking and unlinking wait until the address is confirmed, for an account carried over from an older site auth's User.may_act_sensitively(); the connect and the disconnect send such an account to confirm its address
The owner hears of every link and unlink A mail to the account's address, and the signals identity-linked and identity-disconnected
The last way an account signs in is never unlinked keeps_a_way_in(user, provider); disconnect raises LastWayIn for an account without a password of its own (auth's has_usable_password) and no other provider, and the page offers "Set a password" first
An account links one account of each provider, and a provider account is linked to one account UNIQUE(user_id, provider) and UNIQUE(provider, provider_id)
A new account is opened by auth's create_user, so every listener of user-registered hears of it open_account(provider, account, form, confirmed=False)
An address the person types is not verified until its holder says so Where the product confirms addresses, nothing is opened until the link mailed to it is followed and the same provider account signs in again (below)
Disconnecting is for the owner only disconnect(user, provider, provider_id) returns False for someone else's
A sign-in opens a new account only when the product installs signup registration_is_open(), and closed_registration_message(provider) for the refusal

After a link it sends identity-linked, and after a disconnect identity-disconnected, both with user, provider and provider_id. An account opened through a provider is announced by auth's user-registered instead. The provider features keep nothing of their own, so the disconnect alone is enough for them.

auth's /account/reauthenticate offers a button for each provider the account is connected to (its auth.reauthenticate.methods slot): it posts to /account/reauthenticate/<provider>, the person signs in with that provider again, and only the provider account linked to theirs proves who they are. An account a provider opened has no other way to.

Opening an account through a provider

A sign-in with a provider account linked to nobody opens nothing yet. The callback keeps who signed in, for a quarter of an hour, and sends the person to /account/complete-signup. The page is profile's edit form, with every field other features registered on it (academic_profile's affiliation), started with what the provider said, so a missing surname or a name too long for its column is fixed there instead of failing in the database.

The provider The page The account
Verified the address (GitHub's verified primary email) Says which address the account will use, and asks no other Active at once, signed in, and provider_email_verified keeps the address as its proof
Verified none (ORCID never does) Asks for one Not opened yet where the product confirms addresses: a link goes to the address (below). In a product that confirms none it is opened at once, with email_verified=False

The account is opened by AuthenticationService.create_user, through the locator, with the form's fields on the user-registered signal (as the sign-up does) plus provider and email_verified. auth records the provider as its account_origin, and as its email_verified_via when the provider verified the address (signup_link for one confirmed through the link below). Its password is random, nobody knows it and auth keeps it unusable (usable_password=False): the owner signs in through the provider, and sets one on My account, or with the reset, if they want.

Where the product confirms addresses (a confirmation feature is installed, as in uvlhub), a typed address opens nothing when the last step is sent:

  1. A link to /account/complete-signup/<token> goes to the address. The token is signed for this purpose alone, works for 24 hours (flow.SIGNUP_LINK_SECONDS), and carries who signed in, the address and the form as it was sent. The mail names the provider account that asked ("signed in with ORCID as 0000-0002-1825-0097").
  2. Following it shows a page with a button and does nothing else, so a mail scanner that opens every link confirms nothing. The button (a POST) confirms the address and sends the person to the provider once more. The page sends no Referer, is not cached and runs no script of another site, and neither does the provider's callback.
  3. Only when the same provider account comes back, within a quarter of an hour, is the account opened: active, with email_verified=True on the signal, linked to the provider account, and signed in.

So no account ever holds an address nobody confirmed together with a provider account someone else controls. Someone who types another person's address gets nothing linked to it, and the holder who follows the link signs in with their own provider account, which is not the one that asked, and opens nothing. A mail scanner that opens the link opens nothing either.

Nothing tells a stranger which accounts exist

Someone What they read
Types an address nobody has "We sent a link to that address", and the link goes there
Types an address an account has The same words. Nothing is opened or linked, and the holder gets a mail saying someone tried, and how to connect the provider if it was them
Signs in again with the same provider account The last step again, whichever address they typed, since nothing was opened or linked either way
Signs in in a closed product The same refusal whoever they are, naming no account and no address

The first three need a product that confirms addresses: without one a typed address opens the account at once, so the address that is taken gets "We could not open an account with these details" instead, as signup's own form would tell it. The mails go through the product's MailService.

Closed products

A product that does not install splent_feature_signup, the optional feature that opens registration, is closed, and an administrator opens its accounts. A provider must not be a way around that, so in a closed product the callback and /account/complete-signup refuse with closed_registration_message(provider).

Visitor Product with signup Product without signup
Signs in with a provider account already linked Signed in Signed in
Connects a provider from inside their account Linked Linked
Signs in with a provider account linked to nobody Asked for the details of a new account Refused, and told to ask an administrator for access or to connect the provider from their profile

The refusal is translated, and it reads "New accounts are opened by an administrator, so signing in with ORCID cannot create one. Ask an administrator for access. If you already have an account, sign in as usual and connect ORCID from your profile."

Adding a provider

A provider feature describes itself once and registers it from its init_feature. identities registers its client on the product's one Authlib registry, with the credentials the settings it names hold and OAUTH_TIMEOUT on every request.

# services.py of a provider feature
from splent_io.splent_feature_identities.oauth import (
    OAuthProvider,
    ProviderAccount,
    fetch_json,
    register_oauth_provider,
)


def fetch_account(client, token):
    data = fetch_json(client, PROVIDER, "me", token)  # ProviderError on failure
    return ProviderAccount(
        provider_id=str(data["id"]),
        username=data["login"],
        email=None,  # only an address the provider verified
        details={"name": data["name"]},  # by profile field
    )


PROVIDER = OAuthProvider(
    name="myprovider",
    label="My Provider",
    authorize_url="https://myprovider.example/oauth/authorize",
    access_token_url="https://myprovider.example/oauth/token",
    api_base_url="https://api.myprovider.example/",
    scope="read:user",
    callback_endpoint="myprovider.authorize",
    client_id_setting="MYPROVIDER_CLIENT_ID",
    client_secret_setting="MYPROVIDER_CLIENT_SECRET",
    fetch_account=fetch_account,
    account_url=lambda identity: f"https://myprovider.example/{identity.provider_username}",
)


def init_feature(app):
    register_oauth_provider(app, PROVIDER)

What a product may want to change (the base URL of a sandbox, the scope) belongs in the provider feature's settings, read when it builds its OAuthProvider: orcid has ORCID_BASE_URL and ORCID_SCOPE, github GITHUB_SCOPE.

Its routes mount the flow on its own addresses, the callback being the one registered with the provider:

from splent_io.splent_feature_identities.flow import begin_connect, begin_sign_in, finish


@myprovider_bp.route("/myprovider/login")
def login():
    return begin_sign_in("myprovider")


@myprovider_bp.route("/myprovider/authorize")
def authorize():
    return finish("myprovider")


@myprovider_bp.route("/account/connect/myprovider")
@login_required
def connect():
    return begin_connect("myprovider")

Without both credentials the provider is registered all the same, the app logs a warning at start, and whoever tries is told "Signing in with My Provider is not available here." fetch_json turns a provider that cannot be reached, rate limits or answers something unexpected into a ProviderError with a translated message, and logs what went wrong.

It then draws itself in the slots auth, signup and profile open. Each callback takes no arguments and returns HTML, or an empty string to add nothing.

Slot Opened by What a provider puts there
auth.login.providers auth Its sign-in button, under "or continue with"
signup.providers signup, when the product has it The same button, after the sign-up form
profile.edit.sections profile Its row, with the linked account and a disconnect button or a connect link

The disconnect button of a row opens this feature's modal, which is drawn once on the edit-profile page and posts to /account/disconnect/<provider>/<provider_id>.

<button type="button" data-bs-toggle="modal" data-bs-target="#disconnectModal"
        data-provider="myprovider" data-provider-id="{{ identity.provider_id }}">

GitHub takes order 10 and ORCID 20 in all three slots. /account/identities names every provider by its label and, when it gives an account_url, links the account on the provider's site.

Settings

Setting Default What it does
OAUTH_REDIRECT_BASE_URL empty The address people reach the site at (https://www.uvlhub.io). Every callback is built on it, so it matches the one registered with the provider whatever Host header a request carries. Empty builds it from the request, which behind a reverse proxy needs SPLENT_TRUSTED_PROXIES
OAUTH_TIMEOUT 10 Seconds to wait for a provider on every request of the sign-in before telling the person it could not be reached
EXTERNAL_LOGIN_PASSWORD_HINT false After a wrong email or password on the login page, names the providers the account signs in with

An account opened through a provider has no password its owner knows, so a wrong password is the expected outcome there. With the hint on, auth's "Invalid credentials" is followed by "This account signs in with ORCID. Continue with it below." It is off by default because it tells whoever types an address that an account exists for it and how it signs in. A product whose accounts are public anyway (a repository that lists its authors) turns it on.

[tool.splent.config]
OAUTH_REDIRECT_BASE_URL = "https://www.example.org"
EXTERNAL_LOGIN_PASSWORD_HINT = true

Only a wrong email or password gets the hint. auth renders its auth.login.error_hint slot for that case alone, never for a throttled attempt or an account that is not active yet.

The table and its migrations

external_identity is the one record of who signs in with which provider account. identities0002_one_per_provider brings a database from the first revision to it:

  • It adds UNIQUE(user_id, provider). It stops before changing anything when an account already links two accounts of one provider, and names the rows, so an operator decides which link stays.
  • It replaces email, a copy of an address nothing kept up to date, with provider_email_verified. A GitHub row keeps its address there when it is the account's own. The ORCID ones were typed by hand and verified by nobody, and go.

The downgrade puts email back with what provider_email_verified held and drops the constraint. The addresses the upgrade dropped do not come back: nothing read them, and they are the copy this revision removes. A database that may have to go back with them keeps a copy of external_identity(id, email) from before the upgrade. The orcid and github features fold their old tables into this one with migrations of their own (orcid0002, github0002).

identities0003_exact_ids puts provider and provider_id, which say who a provider account is, under utf8mb4_bin (DM-15). Under the table's collation, which ignores case and accents, the keys took two provider accounts whose ids differ only in case for one and a lookup by id found the other; now an id compares as the provider gave it, the way auth compares addresses. The downgrade puts the table's collation back, and stops, naming the identities, when two of them would be one row of either key under it. A migration that writes identities with SQL writes the ids already normalised (an ORCID iD with its capital X), since nothing folds them.

Translations

Every message of the flow, the last step of a new account, the mails it sends, the login hint and the closed registration message ship with a Spanish catalog.

Use it in a product

splent feature:add splent_feature_identities

Develop

splent feature:test splent_feature_identities --with orcid --with github
splent feature:contract splent_feature_identities

The tests sign in with a provider called acme that only exists in them (tests/conftest.py), whose OAuth client never leaves the process.

Release

splent feature:release splent_feature_identities --dry-run --bump patch
splent feature:release splent_feature_identities

The first command rehearses the release and changes nothing. The second publishes it to the git forge and to PyPI, and a PyPI version is permanent. orcid and github use the flow and the table this version brings, so the three are released together.

Metadata

Release files for splent-feature-identities 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for splent-feature-identities 0.5.0
File Size Uploaded Provenance
splent_feature_identities-0.5.0.tar.gz 55.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for splent-feature-identities 0.5.0
File Interpreter ABI Platform
splent_feature_identities-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 122.1 kB

Release files / splent_feature_identities-0.5.0.tar.gz

Download URL splent_feature_identities-0.5.0.tar.gz
Size 55.7 kB
Tags Source
SHA-256 checksum
How to use checksums
cd591512f1222861f7676e67def63390264594e6c3ca79d6519def4f7b53d177
BLAKE2b-256 checksum
How to use checksums
a3dfb4d3fff4e6dddbe380282c82b64c210eaa64bc3cb4850304ed54f404ae02
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.16

Release files / splent_feature_identities-0.5.0-py3-none-any.whl

Download URL splent_feature_identities-0.5.0-py3-none-any.whl
Size 66.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9765bf7096f35dba2ebaca50809568ba29727b31a8d170aa867688ec9b70e9a4
BLAKE2b-256 checksum
How to use checksums
80fbedd939bdeaa226f62105fe80c10e9dfba03fb08a023bc4e9dce75720fa51
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.16

Security for splent-feature-identities 0.5.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page