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.
A typed address opens the account through a link
Where the product confirms addresses (a confirmation feature is installed, as in uvlhub), a typed address opens nothing when the last step is sent:
- 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"). - 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.
- Only when the same provider account comes back, within a quarter of an
hour, is the account opened: active, with
email_verified=Trueon 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, withprovider_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).
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.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| splent_feature_identities-0.3.0.tar.gz | 53.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| splent_feature_identities-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 116.4 kB
Release files / splent_feature_identities-0.3.0.tar.gz
| Download URL | splent_feature_identities-0.3.0.tar.gz |
|---|---|
| Size | 53.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7bf554ff0c793d7859aad5492de10f1c7dcec8a09130890aab2b10b28d45c188
|
|
BLAKE2b-256 checksum How to use checksums |
aa0db407ba2d6907be934634d902002eed764ebe6bfaea88df13d9dd6181a5de
|
| 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.3.0-py3-none-any.whl
| Download URL | splent_feature_identities-0.3.0-py3-none-any.whl |
|---|---|
| Size | 63.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
41e0c154d9969b93520060ab66166326815911fdd8178e744d3ee2305b46aff6
|
|
BLAKE2b-256 checksum How to use checksums |
da3ff5b6c50594bb2b978c9e78dc7a4a280fa4b4e6160af337ad3d37ff149c1d
|
| 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.3.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.