enlace_auth.auth#

Authentication subsystem for enlace.

Apps never import from here. The contract exposed to mounted apps is just request.state.user_id and (optionally) request.state.user_email.

Public helpers:

  • PlatformAuthMiddleware — pure-ASGI auth middleware.

  • CSRFMiddleware — signed double-submit CSRF.

  • SessionStore — MutableMapping-backed session storage.

  • GrantStore — MutableMapping-backed runtime per-app access grants.

  • ShareStore — owner-granted data shares (who may act on whose per-user data).

  • hash_password / verify_password — argon2id helpers.

  • make_auth_router — FastAPI router for /auth/* endpoints.

Functions

hash_password(password)

Return an argon2id hash string for password.

make_auth_router(*, session_store, ...[, ...])

Build a FastAPI router exposing /auth/* endpoints.

parse_expires_at(value, *[, now])

Coerce a user-supplied expiry into epoch seconds (UTC), or None.

sign_cookie(value, signing_key, *[, salt])

Return a signed, URL-safe token carrying value.

verify_cookie(token, signing_key, *[, ...])

Return the original value iff the token is valid and unexpired.

verify_password(hashed, password)

Return True iff password matches the stored hashed value.

Classes

AccessRule(prefix, level, app_id[, ...])

Auth policy for a single mount prefix.

CSRFMiddleware(app, *, signing_key[, ...])

Signed double-submit CSRF for state-changing requests.

GrantStore(backend, *[, root])

Thin adapter over a MutableMapping that speaks grant semantics.

PlatformAuthMiddleware(app, *, access_rules, ...)

Pure-ASGI auth middleware.

SessionStore(store, *[, max_age, ...])

Thin adapter around a MutableMapping that speaks session semantics.

ShareStore(backend, *[, root, account_exists])

Share semantics over a MutableMapping.

Exceptions

ShareError(message, *[, code])

An invalid share.

class enlace_auth.auth.AccessRule(prefix, level, app_id, shared_password_hash=None, allowed_users=(), deny_signed_in=True)[source]#

Bases: object

Auth policy for a single mount prefix.

class enlace_auth.auth.CSRFMiddleware(app, *, signing_key, cookie_name='enlace_csrf', header_name='X-CSRF-Token', exempt_prefixes=('/auth/callback', '/auth/login/', '/api/'), enforce_prefixes=(), mint_prefixes=None)[source]#

Bases: object

Signed double-submit CSRF for state-changing requests.

On safe-method requests, sets an enlace_csrf cookie if one isn’t present. On state-changing requests, requires the cookie and an X-CSRF-Token header to match after signature verification.

mint_prefixes narrows where the cookie is set (not where the check runs): a request outside every prefix never receives one. The default, None, mints on every safe request. The platform plugin passes the places a state-changing request can follow, so a public app’s pages set no cookie (i2mint/enlace_auth#31).

Exempt paths skip the check entirely. Defaults exempt sub-app APIs under /api/ because the enlace_session cookie is SameSite=Lax: a cross-site POST from an attacker site arrives without credentials and is rejected by PlatformAuthMiddleware regardless of CSRF. This keeps pre-enlace apps working out of the box without each having to implement the /auth/csrf double-submit flow. The auth endpoints themselves (/auth/login, /auth/register, /auth/logout) stay protected.

class enlace_auth.auth.GrantStore(backend, *, root=None)[source]#

Bases: object

Thin adapter over a MutableMapping that speaks grant semantics.

Parameters:
  • backend (MutableMapping) – the per-name store (e.g. factory("grants")).

  • root (Optional[Path]) – the resolved filesystem directory backing backend (.../grants). Used ONLY for efficient per-app listing (root/{app_id}/*). All reads/writes go through backend so the storage codec stays in one place. When None (e.g. a dict backend in tests), listing falls back to filtering the backend’s keys.

active_emails_for_app(app_id, *, now=None)[source]#

The currently-active granted emails for app_id (hot path).

Each in both canonical spellings, so a grant written with a punycode domain admits the account keyed in Unicode, and vice versa.

Return type:

set[str]

grant(app_id, email, *, expires_at=None, granted_by=None, note=None, now=None)[source]#

Create or replace a grant. expires_at is epoch seconds UTC or None.

Use parse_expires_at() to turn a date/ISO string into expires_at before calling this.

Return type:

dict

list_all()[source]#

All grant records across all apps. Admin-only / infrequent.

Return type:

list[dict]

revoke(app_id, email)[source]#

Remove the grant to email in both spellings; return the emails removed.

Empty (falsy) when there was none. Two entries may be two distinct accounts (an OAuth account keyed in punycode, a registered one keyed in Unicode), which is why the caller is told which.

Return type:

list[str]

class enlace_auth.auth.PlatformAuthMiddleware(app, *, access_rules, session_store, signing_key, cookie_name='enlace_session', max_age=86400, auth_path_prefix='/auth', login_redirect_path='/', dynamic_allowed_users=None)[source]#

Bases: object

Pure-ASGI auth middleware. See module docstring for behavior.

class enlace_auth.auth.SessionStore(store, *, max_age=None, sweep_batch=100, sweep_interval=3600.0, sign_ins=None)[source]#

Bases: object

Thin adapter around a MutableMapping that speaks session semantics.

last_sign_in(user)[source]#

When user last opened a session (epoch seconds), if recorded.

Return type:

Optional[float]

revoke_user(user, *, keep=None)[source]#

Delete every session belonging to user; return how many went.

A session is matched on its user_id or its email, case-insensitively (emails are the platform’s user ids, and a record written before lower-casing was consistent must still be caught). keep names one session id to spare – the browser that just changed its own password stays signed in while every other copy of the account is logged out.

Call this whenever an account’s credentials change hands: deletion, an admin password reset, a self-service change, a reset-link redemption. Without it a session outlives the change for the full cookie lifetime.

Return type:

int

sweep_expired(*, now=None)[source]#

Delete up to sweep_batch records older than max_age; return count.

No-op without a max_age. A cursor carries the position across calls (wrapping at the end) so successive sweeps walk the whole store. A record without a numeric created_at is left alone.

Return type:

int

exception enlace_auth.auth.ShareError(message, *, code='invalid')[source]#

Bases: ValueError

An invalid share. code says which: "no_account" or "invalid".

class enlace_auth.auth.ShareStore(backend, *, root=None, account_exists=None)[source]#

Bases: object

Share semantics over a MutableMapping.

Parameters:
  • backend (MutableMapping) – the per-name store (e.g. factory("shares")).

  • root (Optional[Path]) – the filesystem directory backing backend, used only to list one app’s shares without scanning every key (as GrantStore does). None (a dict backend in tests) falls back to filtering all keys.

  • account_exists (Optional[Callable[[str], bool]]) – email -> bool. When given, share() refuses an owner or grantee with no account. None skips the check (tests).

access(app_id, owner, grantee, *, now=None)[source]#

"rw", "ro" or None: what grantee may do with the data.

Return type:

Optional[str]

granted(app_id, owner, *, now=None)[source]#

Every share owner made in app_id, each with "active".

Return type:

list[dict]

list_all()[source]#

Every share in every app. Admin-only, infrequent.

Only genuine records are listed (see _is_genuine()): a record that is damaged, or disagrees with its key, grants nothing, and callers can index app_id, owner and grantee on what they get.

Return type:

list[dict]

received(app_id, grantee, *, now=None)[source]#

The active shares made to grantee in app_id.

Return type:

list[dict]

remove_account(email)[source]#

Delete every share email is part of, either side. Returns how many.

Anything stored under a key {app_id}/{owner}/{grantee} that names email goes, whatever the record holds (a damaged or null record included), and so does a complete record whose fields name email. Account deletion never raises on a damaged record.

Return type:

int

revoke(app_id, owner, grantee, *, widen_owner=False, widen_grantee=False, first_only=False)[source]#

Delete the share owner -> grantee; return the (owner, grantee) pairs removed (empty, so falsy, when there was none).

A side that was typed (an admin’s pick, a URL segment) may be widened to both canonical spellings of its address, as share() resolved it when the share was made. Never widen a side that is the caller’s own session key: its other spelling may be someone else’s account.

With first_only, stop at the first share found, trying the spellings as written first: a user naming one owner or grantee means one account, though the two spellings may be two accounts. An admin revokes both.

Return type:

list[tuple[str, str]]

share(app_id, owner, grantee, *, access='rw', label=None, expires_at=None, granted_by=None, now=None)[source]#

Create or replace the share owner → grantee in app_id.

expires_at is epoch seconds UTC or None; turn a date string into it with enlace_auth.auth.grants.parse_expires_at() first.

Return type:

dict

enlace_auth.auth.hash_password(password)[source]#

Return an argon2id hash string for password.

Return type:

str

enlace_auth.auth.make_auth_router(*, session_store, user_store, signing_key, cookie_name='enlace_session', session_max_age=86400, secure_cookies=True, shared_password_for=<function <lambda>>, can_register=<function <lambda>>, send_email=None, reset_token_max_age=1800, public_base_url=None, on_credentials_changed=None)[source]#

Build a FastAPI router exposing /auth/* endpoints.

Parameters:
  • user_store (session_store /) – backing stores.

  • signing_key (str) – HMAC key for session, CSRF, shared, and reset tokens.

  • secure_cookies (bool) – session-cookie policy.

  • shared_password_for (Callable[[str], Optional[str]]) – maps an app id to its shared-password hash.

  • can_register (Callable[[str], bool]) – predicate gating self-registration by email.

  • send_email (Optional[EmailSender]) – delivers password-reset emails. None means no delivery channel is configured: the flow falls back to the console sender (which logs the link) and the forgot-password page says so, rather than telling the user to check an inbox nothing was sent to.

  • reset_token_max_age (int) – lifetime of an emailed password-reset link, in seconds (default 30 minutes). Links an admin mints by hand carry their own, longer lifetime — see enlace_auth.auth.reset_tokens.

  • public_base_url (Optional[str]) – the platform’s public origin (https://example.com), used to build the link in a password-reset email. Set it in any deployment: without it the link is built from the request’s Host header, which the requester controls – a forged Host would mail the victim a link that hands their reset token to another site, unless a proxy in front only forwards known hosts.

  • on_credentials_changed (Optional[CredentialsChanged]) – hook(email, *, keep=None) called after a password change or reset. Defaults to revoking the account’s browser sessions only; the plugin injects one that also revokes the account’s OAuth connector refresh families (see enlace_auth.auth.revocation).

Return type:

APIRouter

enlace_auth.auth.parse_expires_at(value, *, now=None)[source]#

Coerce a user-supplied expiry into epoch seconds (UTC), or None.

Accepts:

  • None / empty string → None (never expires).

  • a number → treated as epoch seconds, returned as a float.

  • "YYYY-MM-DD" → end of that day UTC (23:59:59), so the grant is valid through the whole named day (least-surprising “expires on this date”).

  • a full ISO-8601 timestamp (...THH:MM[:SS][±TZ|Z]) → that instant. A trailing Z is honored (Python 3.10’s fromisoformat can’t) and a naive timestamp is assumed to be UTC.

Raises GrantError for unparseable input or an already-past expiry.

Return type:

Optional[float]

Return a signed, URL-safe token carrying value.

Return type:

str

Return the original value iff the token is valid and unexpired.

Return type:

Optional[str]

enlace_auth.auth.verify_password(hashed, password)[source]#

Return True iff password matches the stored hashed value.

Return type:

bool

Modules

accounts

Disabled accounts, and the one way a sign-in opens a session.

cookies

Signed cookie helpers built on itsdangerous.

email

Outbound email for enlace_auth — used by the password-recovery flow.

grants

Runtime per-app access grants, with optional UTC expiry.

middleware

Platform auth middleware (pure ASGI).

oauth

OAuth2/OIDC login via Authlib.

oauth_server

OAuth 2.1 authorization server — issue tokens for MCP custom connectors.

pages

HTML pages for the enlace_auth browser-facing flows.

passwords

Password hashing via argon2id.

reset_tokens

Password-reset tokens — minting, verification, and the link they live in.

revocation

Credential revocation: end everything an account holds when its credentials change.

routes

Auth HTTP routes: register, login, logout, shared-login, csrf, recovery.

sessions

Session storage backed by a MutableMapping.

share_routes

/auth/shares/* — the signed-in user manages the shares of their own data.

shares

Owner-granted data shares: let named users at one user's per-user data in one app.