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
|
Return an argon2id hash string for |
|
Build a FastAPI router exposing |
|
Coerce a user-supplied expiry into epoch seconds (UTC), or |
|
Return a signed, URL-safe token carrying |
|
Return the original value iff the token is valid and unexpired. |
|
Return True iff |
Classes
|
Auth policy for a single mount prefix. |
|
Signed double-submit CSRF for state-changing requests. |
|
Thin adapter over a |
|
Pure-ASGI auth middleware. |
|
Thin adapter around a MutableMapping that speaks session semantics. |
|
Share semantics over a |
Exceptions
|
An invalid share. |
- class enlace_auth.auth.AccessRule(prefix, level, app_id, shared_password_hash=None, allowed_users=(), deny_signed_in=True)[source]#
Bases:
objectAuth 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:
objectSigned double-submit CSRF for state-changing requests.
On safe-method requests, sets an
enlace_csrfcookie if one isn’t present. On state-changing requests, requires the cookie and anX-CSRF-Tokenheader to match after signature verification.mint_prefixesnarrows 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 apublicapp’s pages set no cookie (i2mint/enlace_auth#31).Exempt paths skip the check entirely. Defaults exempt sub-app APIs under
/api/because theenlace_sessioncookie isSameSite=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/csrfdouble-submit flow. The auth endpoints themselves (/auth/login,/auth/register,/auth/logout) stay protected.
- class enlace_auth.auth.GrantStore(backend, *, root=None)[source]#
Bases:
objectThin adapter over a
MutableMappingthat speaks grant semantics.- Parameters:
backend (
MutableMapping) – the per-name store (e.g.factory("grants")).root (
Optional[Path]) – the resolved filesystem directory backingbackend(.../grants). Used ONLY for efficient per-app listing (root/{app_id}/*). All reads/writes go throughbackendso the storage codec stays in one place. WhenNone(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.
- grant(app_id, email, *, expires_at=None, granted_by=None, note=None, now=None)[source]#
Create or replace a grant.
expires_atis epoch seconds UTC or None.Use
parse_expires_at()to turn a date/ISO string intoexpires_atbefore calling this.- Return type:
- 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:
objectPure-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:
objectThin adapter around a MutableMapping that speaks session semantics.
- revoke_user(user, *, keep=None)[source]#
Delete every session belonging to user; return how many went.
A session is matched on its
user_idor itsemail, 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:
- 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_atis left alone.- Return type:
Bases:
ValueErrorAn invalid share.
codesays which:"no_account"or"invalid".
Bases:
objectShare semantics over a
MutableMapping.- Parameters:
backend (
MutableMapping) – the per-name store (e.g.factory("shares")).root (
Optional[Path]) – the filesystem directory backingbackend, used only to list one app’s shares without scanning every key (asGrantStoredoes).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.Noneskips the check (tests).
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 indexapp_id,ownerandgranteeon what they get.
Delete every share
emailis part of, either side. Returns how many.Anything stored under a key
{app_id}/{owner}/{grantee}that namesemailgoes, whatever the record holds (a damaged or null record included), and so does a complete record whose fields nameemail. Account deletion never raises on a damaged record.- Return type:
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.
Create or replace the share
owner→granteeinapp_id.expires_atis epoch seconds UTC orNone; turn a date string into it withenlace_auth.auth.grants.parse_expires_at()first.- Return type:
- enlace_auth.auth.hash_password(password)[source]#
Return an argon2id hash string for
password.- Return type:
- 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.Nonemeans 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 — seeenlace_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’sHostheader, which the requester controls – a forgedHostwould 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 (seeenlace_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 trailingZis honored (Python 3.10’sfromisoformatcan’t) and a naive timestamp is assumed to be UTC.
Raises
GrantErrorfor unparseable input or an already-past expiry.
- enlace_auth.auth.sign_cookie(value, signing_key, *, salt='enlace-cookie')[source]#
Return a signed, URL-safe token carrying
value.- Return type:
- enlace_auth.auth.verify_cookie(token, signing_key, *, max_age=None, salt='enlace-cookie')[source]#
Return the original value iff the token is valid and unexpired.
- enlace_auth.auth.verify_password(hashed, password)[source]#
Return True iff
passwordmatches the storedhashedvalue.- Return type:
Modules
Disabled accounts, and the one way a sign-in opens a session. |
|
Signed cookie helpers built on itsdangerous. |
|
Outbound email for enlace_auth — used by the password-recovery flow. |
|
Runtime per-app access grants, with optional UTC expiry. |
|
Platform auth middleware (pure ASGI). |
|
OAuth2/OIDC login via Authlib. |
|
OAuth 2.1 authorization server — issue tokens for MCP custom connectors. |
|
HTML pages for the enlace_auth browser-facing flows. |
|
Password hashing via argon2id. |
|
Password-reset tokens — minting, verification, and the link they live in. |
|
Credential revocation: end everything an account holds when its credentials change. |
|
Auth HTTP routes: register, login, logout, shared-login, csrf, recovery. |
|
Session storage backed by a MutableMapping. |
|
|
|
Owner-granted data shares: let named users at one user's per-user data in one app. |