Metadata-Version: 2.4
Name: splent_feature_apikeys
Version: 0.2.0
Requires-Python: >=3.13
Description-Content-Type: text/markdown
Requires-Dist: pytz>=2024.1

# splent_feature_apikeys

Developer API tokens for a SPLENT product. A port of the UVLHub `apikeys`
feature to SPLENT conventions, 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, and it never touches the `auth` feature: 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, as in UVLHub                              |
| `created_at`, `expires_at`, `revoked_at`, `last_used_at` | lifecycle |

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

`read_spl` and `write_spl`. Unknown names are dropped, and a request that
asks for nothing but unknown names is refused rather than silently producing
a token that can do nothing.

## 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.touch(row)              # last_used_at, at most once every 5 minutes
api_token_service.authenticate(email, password)  # (user, None) or (None, reason)
```

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

## 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))`.

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

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` — the owner's tokens, by prefix, with a revoke button.
- `/developer/tokens/new` — create one; the plaintext is shown once.

A link appears in the authenticated sidebar
(`layout.authenticated_sidebar` hook).

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

## Configuration

| Variable                       | Default | Meaning                          |
|--------------------------------|---------|----------------------------------|
| `APIKEYS_DEFAULT_TTL_DAYS`     | 90      | Life of a token when none is asked |
| `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 |

All of them have working defaults, so the feature needs no `.env` entry.

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