Metadata-Version: 2.4
Name: splent_feature_identities
Version: 0.4.1
License-Expression: LGPL-3.0-or-later
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: COPYING
License-File: COPYING.LESSER
Requires-Dist: Authlib==1.6.12
Dynamic: license-file

# splent_feature_identities

The `identities` feature of a [SPLENT](https://splent.io) 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:

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.

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

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

```html
<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.

```toml
[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`).

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

```bash
splent feature:add splent_feature_identities
```

## Develop

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

```bash
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.
