Metadata-Version: 2.4
Name: splent_feature_apikeys
Version: 0.1.2
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 per caller and account. 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).

## 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 window       |
| `APIKEYS_LOGIN_WINDOW_SECONDS` | 300     | Length of that window            |

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

## Install

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