Metadata-Version: 2.4
Name: splent_feature_apikeys
Version: 0.9.0
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: pytz>=2024.1
Requires-Dist: splent_framework>=1.20.0
Dynamic: license-file

# splent_feature_apikeys

Developer API tokens for a SPLENT product, and the only place in a product
where a password is exchanged for a credential a machine can carry.

It owns tokens and nothing else. It does not know what the tokens are used
for, since the features that guard routes with a token register the scopes
they check. It never touches the `auth` feature either, because credentials
are checked through the `AuthenticationService` that auth already registers.

## The model

`ApiToken` belongs to one user and keeps:

| Column         | Meaning                                                    |
|----------------|------------------------------------------------------------|
| `user_id`      | Owner, foreign key to auth's `user.id`                     |
| `name`         | Human label chosen by the owner                            |
| `token_hash`   | sha256 of the plaintext, hex. The plaintext is never stored |
| `prefix`       | First 15 characters of the plaintext, indexed, safe to show |
| `scopes`       | Comma separated scope names                                |
| `created_at`, `expires_at`, `revoked_at`, `last_used_at` | lifecycle |
| `revoked_reason` | Who or what revoked it (`models.REVOKED_*`), or NULL     |
| `expiry_notice_days` | The expiry notice threshold its owner was last mailed |

A plaintext token looks like `splent_<64 hex characters>`. The `splent_`
label makes a leaked token greppable; the prefix makes verification a single
indexed lookup instead of a table scan.

## Scopes

A scope belongs to the feature that checks it. That feature registers it
from its own `init_feature`, with a label the token form shows beside the
checkbox.

```python
from flask_babel import lazy_gettext as _l
from splent_io.splent_feature_apikeys.scopes import register_api_scope

def init_feature(app):
    register_api_scope("read_reports", _l("Read the published reports"))
    register_api_scope(
        "write_reports", _l("Publish and withdraw reports"), default=False
    )
```

This feature registers `read_spl` and `write_spl` itself, the scopes of the
SPLENT command line.

```python
register_api_scope(name, label=None, order=100, *, default=True, writes=None)
```

| Argument  | What it does |
|-----------|--------------|
| `label`   | What the token form shows beside the checkbox. Pass a lazy string. Without one the form shows the name |
| `order`   | Lower orders are listed first. Equal orders keep the order of registration |
| `default` | `False` keeps the scope out of what a caller gets without choosing, while it is still offered |
| `writes`  | Whether it lets a token change things. `None` takes it from `default`: a scope that is no default writes. `write_spl` stays a default and says `writes=True` |

Registering a name again replaces its label, order, default and writes.

A scope that lets a token change things (upload, publish, delete) is
registered with `default=False`. Then a token gets it only when its creator
ticks it on the form or names it in the request, never because nobody chose.

The registry and `default=` arrive in the same release, so an apikeys that
has `register_api_scope` takes the argument. A feature that also has to run
with an older apikeys only guards the import, catching `ImportError` and
`AttributeError`, and registers nothing when the registry is missing.

```python
def init_feature(app):
    try:
        from splent_io.splent_feature_apikeys.scopes import register_api_scope
    except (ImportError, AttributeError):
        # An apikeys older than its scope registry: say so and register
        # nothing, so the product still starts.
        logger.warning("apikeys has no scope registry")
        return
    register_api_scope(
        "write_reports", _l("Publish and withdraw reports"), default=False
    )
```

What a product hands out is decided at request time by two settings.

| Setting                  | Empty means            | A list means                               |
|--------------------------|------------------------|--------------------------------------------|
| `APIKEYS_SCOPES`         | every registered scope | only the registered scopes it names        |
| `APIKEYS_DEFAULT_SCOPES` | every offered scope registered as a default | what a caller gets when it does not choose |

A product that offers write scopes should set `APIKEYS_DEFAULT_SCOPES` to
the read scopes its clients need. Left empty, the default comes from how
each feature registered its scopes, and a write scope registered without
`default=False` is a default like any other.
A scope the setting names is a default even when it was registered with
`default=False`, so the product has the last word.

```toml
# A product whose tokens are for its own API, not for the command line
[tool.splent.config]
APIKEYS_SCOPES = ["read_reports", "write_reports"]
APIKEYS_DEFAULT_SCOPES = ["read_reports"]
```

When no offered scope is a default, a caller that does not choose is
refused with `400` and asked to name the scopes the token needs.

The token form, `POST /api/v1/auth/tokens` and `ApiTokenService.mint` all
read them, so they always agree. A scope that is not offered is dropped from
a request, a default that is not offered is never granted, and a request
that asks for nothing that is offered is refused rather than silently
producing a token that can do nothing. Narrowing the offer later never
breaks a token already issued, because a token keeps the scopes it was
minted with.

## The service

`ApiTokenService`, registered under that name in the service locator.

```python
api_token_service = service_proxy("ApiTokenService")

row, plaintext = api_token_service.mint(user, "laptop", ["write_spl"], ttl_days=90)
api_token_service.verify(plaintext)       # row, or None if unknown/revoked/expired
api_token_service.find(plaintext)         # row whatever its state, to explain a refusal
api_token_service.revoke(row.id, user)    # only the owner can revoke
api_token_service.revoke_all(user)        # every token of the account, how many
api_token_service.count_usable(user)      # neither revoked nor expired
api_token_service.touch(row)              # last_used_at, at most once every 5 minutes
api_token_service.authenticate(email, password)  # (user, None) or (None, reason)
api_token_service.header_aliases()        # extra headers a token is read from
api_token_service.notify_expiring()       # the expiry notices (commands.py)
api_token_service.purge()                 # the dead tokens past retention
api_token_service.import_token(user_id, key, name=..., scopes=[...])  # a key from elsewhere
```

`mint` returns the plaintext once. Nothing else in the product can ever
produce it again.

`ttl_days` left out gives `APIKEYS_DEFAULT_TTL_DAYS`, and a life is at
most `APIKEYS_MAX_TTL_DAYS`. `None`, said explicitly, is a token that
never expires, which only a product with `APIKEYS_ALLOW_NO_EXPIRY` issues;
anywhere else `mint` raises `ValueError`. An account that already holds
`APIKEYS_MAX_ACTIVE_TOKENS` usable tokens gets `TooManyTokens`, a
`ValueError` with the `limit`.

A scope that writes (`scopes.scope_writes`, and a name no feature registered
counts as one) is only granted to an account that may act sensitively
(auth's `User.may_act_sensitively`): one carried over from an older site
confirms its address first. `mint` raises `EmailUnconfirmed`, a
`ValueError` naming the scopes, for anyone else. The token endpoint answers
it with `403` and `{"code": "email_unconfirmed", "scopes": [...],
"confirm_url": ...}`, and the token form starts such an account from the
defaults that only read, marks the scopes that wait, and sends it to
confirm its address if it ticks one. Tokens the account already holds keep
working whatever they may do: `require_token` checks a token's scopes, not
its owner's address.

The page that shows a new token sends no Referer, is not cached and runs no
script of another site (auth's `keep_token_private`).

## Guarding a route

```python
from splent_io.splent_feature_apikeys.decorators import require_token

@some_bp.route("/api/v1/spls", methods=["POST"])
@require_token("write_spl")
def publish():
    g.api_user   # the owner
    g.api_token  # the presenting token
```

`require_token()` with no scope accepts any usable token. For routes where a
token is one of several ways in, call `resolve_token(scope)` directly: it
returns `(api_token, None)` or `(None, (response, status))`. `bearer_token()`
tells whether the request presents a token at all, without checking it.

A token travels in `Authorization: Bearer <token>`. Clients written against
another convention can send it raw in a header the product names in
`APIKEYS_HEADER_ALIASES`, and every guarded route accepts it there too. When
both are present the Bearer header wins.

```toml
[tool.splent.config]
APIKEYS_HEADER_ALIASES = ["X-API-Key"]
```

```bash
curl https://<product>/api/v1/auth/whoami -H "X-API-Key: splent_..."
```

## Endpoints

| Method | Path                            | Auth              |
|--------|---------------------------------|-------------------|
| POST   | `/api/v1/auth/tokens`           | email + password  |
| GET    | `/api/v1/auth/whoami`           | Bearer token      |
| DELETE | `/api/v1/auth/tokens/current`   | Bearer token      |

```bash
curl -X POST https://<product>/api/v1/auth/tokens \
     -H "Content-Type: application/json" \
     -d '{"email": "dev@example.com", "password": "...", "name": "laptop"}'

curl https://<product>/api/v1/auth/whoami \
     -H "Authorization: Bearer splent_..."
```

Omitting `ttl_days` gives the product default. Sending it as `null` gives a
token that never expires where the product allows it
(`APIKEYS_ALLOW_NO_EXPIRY`), and `400 invalid_request` anywhere else, as
does a life longer than `APIKEYS_MAX_TTL_DAYS`. An account that already
holds `APIKEYS_MAX_ACTIVE_TOKENS` usable tokens is answered `409` with
`{"code": "too_many_tokens", "limit": ..., "tokens_url": ...}`.

A revoked token is refused with `403`, the code `token_revoked` and a
`reason`: `owner`, `admin`, `password_changed`, `account_disabled`, one of
the `import_*` codes for a key carried over already revoked, or `null` for
one revoked before reasons were kept.

Failed credential attempts are throttled, and a refusal is a `429` with a
`Retry-After` header and the code `rate_limited`. Three budgets are counted.
One address against every account and one address against one account are
checked before the password is, and one account from every address together
only after wrong credentials, so the right password is never refused because
a stranger made noise. The counts
live in a table of the product's database that auth owns
(`auth_throttle_failure`), so every gunicorn worker and every container
shares them. Until auth's migration has created that table each worker
counts on its own and says so in the log. An account is counted however its
address is spelled. The database matches addresses under its collation, so
`dëveloper@example.com` or a full-width spelling reaches the same account, and both
budgets are keyed on the account it resolves to, never on the typed text.
The budget of one address against every account stops a caller spraying one
password over many accounts. A token does not clear it, and it sits far
above what an office or a campus behind one address gets wrong honestly.

Users are created inactive and an administrator activates them, so an
account still waiting is answered with `403` and a message that says exactly
that, never with "wrong password".

## Pages

- `/developer/tokens` lists the owner's tokens, by prefix, with a revoke
  button, why each revoked one was revoked, and "Revoke all", a POST to
  `/developer/tokens/revoke-all` with the form's CSRF token that asks
  first.
- `/developer/tokens/new` creates one and shows its plaintext once. The
  life is required, from 1 to `APIKEYS_MAX_TTL_DAYS` days, and may be left
  empty only where the product issues tokens that never expire.
- `/admin/tokens`, for administrators (`role_required("admin")`), finds an
  account by its address, lists its tokens and revokes them all
  (`/admin/tokens/<user_id>/revoke-all`, CSRF and a confirmation too).

The pages extend `shell('app')`, so they follow `SPLENT_SHELL_APP`.

## Menus

The feature declares two entries in the framework's menu registry, and an
application shell that draws `menu_sections()` shows them to every
signed-in account.

| Key | Section | Order | Opens | Current on |
|---|---|---|---|---|
| `apikeys` | `developer` | 20 | `apikeys.index` | Every screen of the blueprint (`apikeys.`) |
| `apikeys_new` | `developer` | 30 | `apikeys.new` | The new token screen, the longer match |
| `apikeys_admin` | `admin` | 40 | `apikeys.admin_tokens` | The administrators' page (`apikeys.admin_`), shown to administrators only |

A product hides or reorders them by key in `[tool.splent.layout.menu]`.
Another feature puts its developer link in the same section with its own
`register_menu_item(..., section="developer")`.

For one minor, a link also appears in the authenticated sidebar
(`layout.authenticated_sidebar` hook), for the shells that draw that slot.
A shell draws the registry or the slots, never both. The next minor drops
the hook and the slot below.

## Developer links

Right after that link the sidebar entry opens the slot
`apikeys.sidebar.developer`, so other features can put their developer
links next to the tokens. Each one renders its own sidebar item, and an
empty slot renders nothing. A shell that draws the menu registry does not
draw this slot.

```python
from flask import render_template
from splent_framework.hooks.template_hooks import register_template_hook

def api_docs_link():
    return render_template("hooks/sidebar_api_docs.html")

register_template_hook("apikeys.sidebar.developer", api_docs_link, order=10)
```

## When an account is deleted

A token acts as its owner, so it goes with the owner. auth sends
`user-deleting` from the database session just before an account is
deleted, whoever deletes it (the accounts screen, the admin panel's generic
CRUD, the terminal), and this feature deletes that account's tokens through
the same session, in the same transaction. See `signals.py`.

## Lifecycle

### A new password, an account switched off

A token outlives neither the password it was issued under nor the account
being switched off. auth (1.25.0 or later) announces both from the database
session, `user-password-changed` and `user-disabled`, whichever way the
change came (a reset link, My account, the accounts screen, the admin
panel's generic CRUD, the terminal), and `signals.py` revokes the account's
tokens in the same transaction, with the reason. Switching the account on
again brings none back. A first password set by an account a provider
opened, the same password written again, a new address and "sign out
everywhere" revoke nothing. `APIKEYS_REVOKE_ON` says which of the two facts
the product acts on. A bulk `UPDATE` past the session is not seen.

A service account keeps its tokens on both: the account a machine client
uses (a marketplace, a CI) should be marked so, by an administrator on the
accounts screen or from the terminal:

```bash
splent feature:auth set-service-account marketplace@example.org
```

### Expiry notices and the purge

```bash
splent feature:apikeys notify-expiring   # mail the owners of tokens about to expire
splent feature:apikeys purge             # delete what is dead past retention
splent feature:apikeys revoke-all EMAIL  # every token of an account, for an operator
```

`notify-expiring` mails each owner one message listing the tokens that
reached a threshold of `APIKEYS_EXPIRY_NOTICE_DAYS` (30 and 7 days before
the expiry), through the product's `MailService`, with a link to the token
page. A token is told about once per threshold, and only about the most
urgent one it has reached, so a missed run never sends two notices at once.
Each notice is claimed in the database before the mail goes, so two runs at
once never send it twice, and given back when the mail could not go, for
the next run. An account that is not active is not told. Without a
`MailService` nothing is sent or claimed. Outside a request the link is
built from `SERVER_NAME` and `PREFERRED_URL_SCHEME` in the environment.

`purge` deletes the tokens revoked, or expired, more than
`APIKEYS_PURGE_AFTER_DAYS` ago. Until then a client presenting one is told
it was revoked or expired; afterwards it is an unknown token.

Neither runs by itself. Schedule them on the host, as the user the app runs
as: the notices once a day, so no threshold is missed by more than a day,
and the purge once a week:

```
40 3 * * * docker exec -u splent <product>_web_deploy splent feature:apikeys notify-expiring
50 3 * * 0 docker exec -u splent <product>_web_deploy splent feature:apikeys purge
```

Both exit with 0 when there is nothing to do.

### Keys carried over from another system

`import_token` stores a key another system issued so it keeps working: its
sha256 and its first 15 characters, whatever it looks like, so uvlhub 2's
64 hexadecimal keys are found like a `splent_` token, Bearer or a header
alias. It keeps the id (`token_id`) and the scopes as given, falls back from
`created_at` to `last_used_at` and then to `now` (the migration's T0), and
expires `ttl_days` after `now` (or at `expires_at`). It stores the key
already revoked, with its reason, when it has no scope, when it was never
used and was created more than `never_used_grace_days` before `now`, and
when it was last used more than `unused_days` before `now`:

```python
api_token_service.import_token(
    key.user_id, key.key,
    name="uvlhub 2 key", scopes=translated, token_id=key.id,
    created_at=key.created_at, last_used_at=key.last_used_at,
    ttl_days=90 if writes else 365, now=T0,
    never_used_grace_days=30, unused_days=365, commit=False,
)
```

A row written past the service the way the migration plan's SQL writes it
(`SHA2(key, 256)`, `LEFT(key, 15)`) is found all the same.

A migration that writes with SQLAlchemy Core, with no app and no session,
computes the row with `services.imported_token_fields`, the function
`import_token` stores the result of. It takes the same arguments, with `now`
required and `ttl_days` a life already chosen (1 to 365): the product's
default and its `APIKEYS_MAX_TTL_DAYS` are settings, which `import_token`
reads and the function does not. Its dates are aware, in UTC (a naive one is
read as UTC), and the row inserts as it is:

```python
from splent_io.splent_feature_apikeys.services import imported_token_fields

fields = imported_token_fields(
    key.user_id, key.key,
    name="uvlhub 2 key", scopes=translated, token_id=key.id,
    created_at=key.created_at, last_used_at=key.last_used_at,
    ttl_days=90 if writes else 365, now=T0,
    never_used_grace_days=30, unused_days=365,
)
connection.execute(sa.insert(api_token), [fields])
```

It leaves `expiry_notice_days` to the column's default, as `import_token`
does.

## Configuration

| Variable                       | Default | Meaning                          |
|--------------------------------|---------|----------------------------------|
| `APIKEYS_DEFAULT_TTL_DAYS`     | 90      | Life of a token when none is asked |
| `APIKEYS_MAX_TTL_DAYS`         | 365     | Longest life a token may be given, not below the default |
| `APIKEYS_ALLOW_NO_EXPIRY`      | false   | Whether a token may be issued that never expires |
| `APIKEYS_EXPIRY_NOTICE_DAYS`   | `[30, 7]` | Days before the expiry the owner is mailed, once each |
| `APIKEYS_MAX_ACTIVE_TOKENS`    | 50      | Usable tokens per account, 0 for no cap |
| `APIKEYS_PURGE_AFTER_DAYS`     | 90      | Days a revoked or expired token is kept |
| `APIKEYS_REVOKE_ON`            | `["password_changed", "account_disabled"]` | What happening to an account revokes its tokens |
| `APIKEYS_MAX_LOGIN_ATTEMPTS`   | 5       | Failed attempts per address and account in a window |
| `APIKEYS_MAX_ACCOUNT_ATTEMPTS` | 20      | Failed attempts per account from every address in a window |
| `APIKEYS_MAX_ADDRESS_ATTEMPTS` | 200     | Failed attempts per address against every account in a window |
| `APIKEYS_LOGIN_WINDOW_SECONDS` | 300     | Length of that window            |
| `APIKEYS_THROTTLE_MAX_KEYS`    | 10000   | Counters one worker keeps while auth's table is missing |
| `APIKEYS_MAX_BODY_BYTES`       | 8192    | Largest body the token endpoints read |
| `APIKEYS_SCOPES`               | `[]`    | Registered scopes the product offers, empty for all |
| `APIKEYS_DEFAULT_SCOPES`       | `[]`    | Scopes granted when none are asked for, empty for every offered default |
| `APIKEYS_HEADER_ALIASES`       | `[]`    | Extra headers that carry the raw token |

All of them have working defaults, so the feature needs no `.env` entry.
A default life longer than the longest one stops the start.

## Install

This feature counts failed logins through auth's shared throttle
(`splent_io.splent_feature_auth.throttle` and its migration
`auth0003_throttle`), which auth v1.18.0 and earlier do not have. Pin it with
an auth release that ships them, and release that auth first. With an older
auth the app stops at startup on an `ImportError`.

```bash
splent feature:install splent-io/splent_feature_apikeys
splent db:migrate splent_feature_apikeys
splent db:upgrade
```

## Tests

```bash
splent feature:test splent_feature_apikeys
```
