Skip to main content

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.

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

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

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

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.

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 sends no signal for either, so signals.py listens on the database session for the two facts as auth's User records them, the way auth announces deletions: the password hash replaced on an account that had a password of its own, and disabled_at going from nothing to a time. Every way in counts (a reset link, My account, the accounts screen, the admin panel's generic CRUD, the terminal), and the tokens are revoked 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, 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.

auth has no notion of a service account, so the account a machine client uses (a marketplace, a CI) loses its tokens like any other when its password is reset or it is switched off. Coordinate either with whoever runs that client, or leave the fact out of APIKEYS_REVOKE_ON.

Expiry notices and the purge

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:

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.

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.

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

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for splent-feature-apikeys 0.7.0
File Size Uploaded Provenance
splent_feature_apikeys-0.7.0.tar.gz 71.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for splent-feature-apikeys 0.7.0
File Interpreter ABI Platform
splent_feature_apikeys-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 148.1 kB

Release files / splent_feature_apikeys-0.7.0.tar.gz

Download URL splent_feature_apikeys-0.7.0.tar.gz
Size 71.7 kB
Tags Source
SHA-256 checksum
How to use checksums
e0e323654e4a1955b3e623eb980dc7df22d55249790f50b3af0ba04e249d442a
BLAKE2b-256 checksum
How to use checksums
5b110528941ae26eda951b6c920d9a05c09fbf8b7eada5a97982b2e6cdd64bbb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.16

Release files / splent_feature_apikeys-0.7.0-py3-none-any.whl

Download URL splent_feature_apikeys-0.7.0-py3-none-any.whl
Size 76.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9e7281492321925fcdf4e998b3269a620e69ca7b8d89ff5aa3526b6d1bab0395
BLAKE2b-256 checksum
How to use checksums
9332e16309f597163d012a595f2ca53a7d22729bbf30f47f60591faf0b6ab509
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.16

Security for splent-feature-apikeys 0.7.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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page