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 |
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.
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.
register_api_scope(name, label=None, order=100, *, default=True)
| 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 |
Registering a name again replaces its label, order and default.
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.
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.
# 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.
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)
api_token_service.header_aliases() # extra headers a token is read from
mint returns the plaintext once. Nothing else in the product can ever
produce it again.
Guarding a route
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.
[tool.splent.config]
APIKEYS_HEADER_ALIASES = ["X-API-Key"]
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 |
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/tokenslists the owner's tokens, by prefix, with a revoke button./developer/tokens/newcreates one and shows its plaintext once.
A link appears in the authenticated sidebar
(layout.authenticated_sidebar hook).
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.
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.
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 |
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.
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.
splent feature:install splent-io/splent_feature_apikeys
splent db:migrate splent_feature_apikeys
splent db:upgrade
Tests
splent feature:test splent_feature_apikeys
Metadata
Release files for splent-feature-apikeys 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_apikeys-0.3.0.tar.gz | 46.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| splent_feature_apikeys-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 101.3 kB
Release files / splent_feature_apikeys-0.3.0.tar.gz
| Download URL | splent_feature_apikeys-0.3.0.tar.gz |
|---|---|
| Size | 46.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f05d225d5cabc76577633284e257baab6e13b8a757dd512e5f6acfeb839dd1bf
|
|
BLAKE2b-256 checksum How to use checksums |
f6733359abf0d7453110e159c2225d2e01ceac26effb56d564f1180afb4dfd0e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.13
|
Release files / splent_feature_apikeys-0.3.0-py3-none-any.whl
| Download URL | splent_feature_apikeys-0.3.0-py3-none-any.whl |
|---|---|
| Size | 54.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
424a0c2593b61ee828a7ab0922fa9948f5a85ce459acaa6136eea4d22db992f8
|
|
BLAKE2b-256 checksum How to use checksums |
1d6f97f2dee630163dc11f8815a04ee070cc32ff4b83ac3dff2f72b8e7dfa5b9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.13
|
Security for splent-feature-apikeys 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.