> built 2026-10-30 13:03 UTC from c1876cf (main) · enlace_auth 0.1.50. Details: build_info.json

# index.html.md

<!-- generated by epythet -->

# enlace_auth

Sign-in, sessions, CSRF protection, an admin dashboard and per-user data stores for the [enlace](https://github.com/i2mint/enlace) multi-app platform, added as a plugin.

Humans who still type their code by hand: [For the free-range, hand-coded developer]().

## For coding agents

Read [`CLAUDE.md`]() first: it has the architecture map, the test command and the invariants a change must keep. This package is the security boundary of the platform, so a behavior change here is a security change.

Two skills live in this repo:

| Skill               | For                                                         | Where                                                            |
|---------------------|-------------------------------------------------------------|------------------------------------------------------------------|
| `enlace-auth-usage` | using the plugin: wiring, access levels, CLI, the CSRF flow | `enlace_auth/data/skills/enlace-auth-usage` (ships in the wheel) |
| `enlace-auth-dev`   | working on this codebase: tests, invariants, the fix loop   | `skills/enlace-auth-dev`                                         |

Enable them with either route:

```bash
gh skill install i2mint/enlace_auth enlace-auth-usage --agent claude-code
gh skill install i2mint/enlace_auth enlace-auth-dev --agent claude-code

# or, from the installed package (needs `pip install skill`):
skill link-skills "$(python -c 'import enlace_auth, os; print(os.path.join(enlace_auth.__path__[0], "data", "skills"))')"
```

Inside a checkout, `.claude/skills/` already holds relative symlinks to both.

What an agent can do with it: put `public`, `protected:shared` and `protected:user` gates in front of any enlace app, manage accounts, grants and data shares with the `enlace-auth` CLI, and give each signed-in user a private `request.state.store`. This example builds a platform in a temp directory and checks the gates end to end:

<!-- readme-example -->
```python
import os, tempfile
from pathlib import Path

from enlace import PlatformConfig, build_backend
from enlace.discover import discover_apps
from starlette.testclient import TestClient

from enlace_auth import plugin as auth_plugin

os.environ["ENLACE_SIGNING_KEY"] = "a-demo-key-of-at-least-32-characters"
root = Path(tempfile.mkdtemp())

# Two apps: a public one and one that needs a signed-in user.
for name, access in [("hello", "public"), ("vault", "protected:user")]:
    (root / "apps" / name).mkdir(parents=True)
    (root / "apps" / name / "server.py").write_text(
        "from fastapi import FastAPI\n"
        "app = FastAPI()\n"
        "@app.get('/ping')\n"
        "def ping():\n"
        "    return {'ok': True}\n"
    )
    (root / "apps" / name / "app.toml").write_text(f'access = "{access}"\n')

config = PlatformConfig(
    apps_dir=root / "apps",
    auth={
        "enabled": True,
        "secure_cookies": False,  # plain http in this demo
        "registration_open": True,
        "stores": {"backend": "file", "path": str(root / "platform")},
    },
    stores={"user_data": {"backend": "file", "path": str(root / "user_data")}},
)
client = TestClient(build_backend(discover_apps(config), plugins=[auth_plugin]))

assert client.get("/api/hello/ping").status_code == 200  # public: open, no cookie set
assert "enlace_csrf" not in client.cookies
assert client.get("/api/vault/ping").status_code == 401  # protected: denied

token = client.get("/auth/csrf").json()["csrf"]  # every POST echoes this token
headers = {"X-CSRF-Token": token}
body = {"email": "ada@example.com", "password": "correct horse battery"}
assert client.post("/auth/register", json=body, headers=headers).status_code == 200
assert client.get("/api/vault/ping").status_code == 200  # now signed in
print(client.get("/auth/whoami").json())
```

## What it adds

- `/auth/login`, `/auth/logout`, `/auth/register`, `/auth/whoami`, `/auth/csrf`, `/auth/me/password`, `/auth/shared-login`
- `/auth/account`: the signed-in user changes their own password
- `/auth/forgot-password`: self-service reset by emailed link
- `/_admin/api/*`: list, create and delete users, mint a password-reset link, set a password directly, view app policy, and **grant or revoke per-app access at runtime** (optional expiry). Gated by an admin allowlist.
- per-user data injection via `request.state.store`
- `PlatformAuthMiddleware` and `CSRFMiddleware`
- optional OAuth2 / OIDC login via Authlib, and an OAuth 2.1 authorization server for MCP connectors

`enlace` itself is auth-agnostic: it composes apps and routes traffic. This package plugs in at compose time.

## Quick start

```python
from enlace import build_backend, PlatformConfig
from enlace_auth import plugin as auth_plugin

config = PlatformConfig.from_toml("platform.toml")
app = build_backend(config, plugins=[auth_plugin])
```

Or, if you serve via `uvicorn --factory enlace.compose:create_app`, set:

```bash
export ENLACE_PLUGINS=enlace_auth:plugin
```

## Configuration

In `platform.toml`:

```toml
[auth]
enabled = true
session_cookie_name = "enlace_session"
session_max_age_seconds = 86400
signing_key_env = "ENLACE_SIGNING_KEY"
secure_cookies = true

[auth.stores]
backend = "file"
path = "~/.enlace/platform_store"

[stores.user_data]
backend = "file"
path = "~/.enlace/user_data"
```

### OAuth 2.1 authorization server (MCP connectors)

`enlace_auth` can act as the authorization server that issues the JWTs an MCP
custom connector validates. **One** server serves *every* connector on the
platform, so anything a user reads during the connect flow must be keyed on the
connector — the `resource` the client asks for:

```toml
[auth.oauth_server]
enabled = true
issuer = "https://apps.example.com"

# How long a connector works before it must renew (default 3600); how long it
# may sit IDLE before its refresh token lapses (default 30 days); and the
# absolute ceiling on one authorization regardless of activity (default 90 days).
access_token_ttl_seconds = 3600
refresh_token_ttl_seconds = 2592000
refresh_family_max_lifetime_seconds = 7776000

# Who may authorize for each connector. Fails closed: a resource this table does
# not list is refused to everyone, so a new or renamed connector stays shut until
# it is added here. "*" opens a resource to every authenticated account, and has
# to be written out. It fails loudly too: an unknown key anywhere under [auth]
# (a misspelled or misnested table), a key that is not an http(s) URL, a value
# that is not a list of emails, or a pattern such as "*@example.com" stops the
# platform at startup, and `enlace doctor` names it. An enabled server with no
# allowlist refuses to start unless `allow_unlisted_resources = true` says an
# open server is intended.
[auth.oauth_server.resource_allowlist]
"https://apps.example.com/connector-a-mcp" = ["alice@example.com"]
"https://apps.example.com/connector-b-mcp" = ["*"]

# The name the consent screen shows, keyed by the SAME resource URL. A resource
# with no entry gets generic copy that names no product — never default to one
# connector's name, or every other connector's consent screen inherits it.
[auth.oauth_server.resource_display_names]
"https://apps.example.com/connector-a-mcp" = "Connector A"
"https://apps.example.com/connector-b-mcp" = "Connector B"
```

#### Keeping a connector alive

Access tokens are short-lived by design; **refresh tokens are what keep a
connector working**. With the refresh grant enabled (the default) a client
renews its own access token in the background and a person is involved only
once, at first connect. Set `refresh_token_ttl_seconds = 0` and you get the
opposite: every session dies `access_token_ttl_seconds` after it started, the
connector returns 401 forever, and the *only* way back is a human re-running the
browser authorization. Nothing errors when this happens — the connector process
stays healthy and expiry is logged at INFO on the resource server — so it
surfaces as a user saying the connector “has been down all day”. `enlace_auth`’s
doctor checks (`oauth_refresh` and the discovery-metadata HTTP check) fail on
exactly this, including the case where config enables refresh but the *deployed*
build is older than the config and cannot honour it. They run wherever
`enlace doctor` runs, via `enlace.doctor.discover_plugin_checks` — which needs
`enlace` new enough to have it (i2mint/enlace#34); on older `enlace` the checks
exist but nothing invokes them.

`refresh_token_ttl_seconds` is an **idle** timeout, not a session ceiling: every
rotation resets it, so a connector refreshing hourly would otherwise never face a
human again. `refresh_family_max_lifetime_seconds` is the absolute cap, set once
when the session is authorized and carried unchanged through every rotation.

Refresh tokens rotate: each use consumes the presented token and returns a
successor. Tokens are stored hashed, never verbatim.

Replaying a spent token is theft *unless* it looks like a retry. The server
consumes a token before its response is written, so a dropped response (proxy
502, TLS reset, client timeout) leaves an honest client holding a spent token —
and revoking there would strand the connector exactly as an absent refresh grant
does. Within `refresh_reuse_grace_seconds`, from the same client, with the
successor still unused and the subject still authorized, that is treated as a
retry and reissued. Anything else revokes the whole family. Spent tokens are
remembered for `refresh_reuse_detection_seconds` so that a replay is recognised
rather than merely unknown.

Because access tokens are self-contained JWTs with no denylist, **the allowlist
is re-evaluated on every refresh** — that is the only revocation path this server
has. Removing someone from `resource_allowlist` (and redeploying) stops them
within one access-token lifetime rather than one refresh-token lifetime. Keep
`access_token_ttl_seconds` short for that reason: with refresh in place, a short
access token costs nothing and is what bounds revocation lag.

**A credential change ends connector sessions too.** Deleting a user, an admin
password set, a self-service password change, a reset-link redemption and the
`set-password` CLI all revoke the account’s browser sessions *and* its refresh
families (plus any unredeemed authorization codes); the connector must be
re-authorized. Already-issued access tokens live out their TTL.

**Rotating a shared password ends its cookies.** A `shared_auth_<app>` cookie
carries a keyed fingerprint of the app’s shared-password hash at the time it was
minted, so after you change the hash (and restart), every cookie from the old
password is refused. Upgrading to this version signs everyone out of
shared-password apps once.

Plus environment variables:

- `ENLACE_SIGNING_KEY` — signing key (32+ chars). Generate with `python -c "import secrets; print(secrets.token_urlsafe(32))"`.
- `ENLACE_ADMIN_EMAILS` — comma-separated admin emails (gate `/_admin`). Admins can also be declared in `platform.toml` as `[auth] admin_emails = [...]`, which makes granting admin a reviewable config change; the two lists are unioned. A signed-in account that is not an admin gets a 403 page at `/_admin` (saying who is signed in, with a link to switch account), not a sign-in redirect. An admin address can never self-register, not even with `registration_open` or an allowlist entry, because `/auth/register` cannot prove who owns it. An admin account comes from `enlace-auth add-user <email>` (a password account), or from the admin’s first sign-in through a configured OAuth provider that itself verifies the address (an `email_verified` claim, or the `github` preset; `trust_unverified_email` does not count). Every account records how it was made (`created_via`). `enlace doctor` warns about an admin address with no account, and about an admin account that was self-registered.
- `ENLACE_REGISTRATION_ALLOWLIST` — comma-separated emails allowed to self-register while `registration_open` is false (the default).
- `ENLACE_ALLOW_UNSIGNED=1` — opt-out from fail-fast (diagnostics only).
- `ENLACE_SMTP_HOST` (+ `_PORT`, `_USER`, `_PASSWORD`, `_FROM`, `_TLS`) — mail
  sender for password-reset emails. Unset means **no email is sent**; see below.

## CSRF

Every `POST`, `PUT`, `PATCH` and `DELETE` must carry an `X-CSRF-Token` header that matches the signed `enlace_csrf` cookie. That covers `/auth`, `/_admin`, `/_apps/{name}/meta` and the per-user store `/api/{app}/store`. Exempt: `/auth/callback`, `/auth/login/<provider>`, `/auth/oauth/`, other sub-app APIs under `/api/`, and proxied `process` and `external` apps. A client gets the token from `GET /auth/csrf`, which also sets the cookie.

The cookie is set only on `/auth/*`, `/_admin` and `protected:*` apps. A `public` app’s pages set no cookie, so an app that promises “nothing is stored on your device” keeps that promise on this platform. A public page that has to `POST` to the auth routes fetches `/auth/csrf` first. Sub-app APIs under `/api/` are exempt from the check because the session cookie is `SameSite=Lax`, except the per-user store routes.

If you use `CSRFMiddleware` directly, `mint_prefixes` limits where it sets the cookie (default: every safe request); the check itself always runs.

## Passwords

Three ways a password gets set, for three different situations:

| Situation                                  | Path                                       |
|--------------------------------------------|--------------------------------------------|
| User knows their password, wants a new one | `/auth/account`                            |
| User is locked out, **SMTP configured**    | `/auth/forgot-password` → emailed link     |
| User is locked out, **no SMTP**            | admin mints a link and delivers it by hand |

That last one is what keeps the platform usable with no mail server:

```bash
enlace-auth reset-link someone@example.com          # prints a 72h, single-use link
enlace-auth reset-link someone@example.com --hours 4
```

or the **Reset link** button in `/_admin/`. Prefer it to setting a password for
someone: the admin never invents, learns, or transmits another person’s
credential, and the recipient chooses their own.

`enlace-auth set-password <email>` still exists for break-glass use.

Reset links are signed, not stored. Each carries the account’s current
password-hash fingerprint and its own absolute expiry, so a link dies on first
use — and any other password change invalidates every link outstanding for that
account. With no SMTP configured, `/auth/forgot-password` says so and points at
the admin rather than promising an email that only reaches the log.

## Per-app access & runtime grants

Each app declares an `access` level in its `app.toml`
(`public | protected:shared | protected:user`). A `protected:user` app may also
declare a static baseline allow-list:

```toml
access = "protected:user"
allowed_users = ["owner@example.com"]   # always allowed; edit-in-code baseline
```

`"@admins"` in that list stands for the platform admins (the emails in
`admin_emails_env`), so an owner-only tool can be declared without committing
an address: `allowed_users = ["@admins"]`. If no admins are configured, such an
app admits nobody. Because `allowed_users` is only enforced for
`protected:user`, an app using the alias is treated as `protected:user` even if
its `access` says otherwise (an error is logged) — it never falls open.

A signed-in user who is not on `allowed_users` (or an active grant) gets a **403** (a 403 page in a browser, JSON `{"auth": "forbidden"}` otherwise), not the sign-in redirect or a 401; a signed-out request still gets the redirect or 401. This is `AccessRule.deny_signed_in`, now `True` by default for every rule (it was admin-only). Upgrade note: a frontend that treats any 401 as “go sign in” will now see a 403 for a signed-in user who lacks access, and should show a “not allowed for this account” message instead.

On top of that baseline you can grant access **at runtime — no redeploy** — from
the admin dashboard or the CLI. Runtime grants are *additive* (effective access =
`allowed_users ∪ active grants`) and may carry an optional UTC expiry:

```bash
enlace-auth grant vault alice@example.com --expires 2026-12-31   # end of day UTC
enlace-auth list-grants --app vault
enlace-auth revoke-grant vault alice@example.com
```

Grants live in a `grants/` store alongside `sessions/` and `users/` under
`[auth.stores] path`, so they persist across restarts and redeploys. A grant on
an app with an *empty* `allowed_users` (open to any authenticated user) is
rejected — it would have no additive effect and would unintentionally restrict an
open app. To remove a user listed in `allowed_users`, edit `app.toml` (that layer
is intentionally code-managed); the admin panel manages the runtime layer.

## Owner-granted data shares

Grants say who may *open* an app. A **share** says who may act on *someone else’s* data in it: in app `A`, the owner’s per-user store is readable (and, by default, writable) by a named grantee, signed in as themselves. Use it for data that belongs to one person and is looked after by others — a child’s practice log kept by her parents.

```bash
enlace-auth share practice kid@example.com parent@example.com --label Kid   # admin, from the server
enlace-auth share practice kid@example.com teacher@example.com --access ro
enlace-auth list-shares
enlace-auth revoke-share practice kid@example.com parent@example.com
```

A signed-in owner manages their own shares at `/auth/shares/{app}` (`GET`, `PUT …/{grantee}`, `DELETE …/{grantee}`; a grantee leaves with `DELETE …/received/{owner}`); an admin uses `/_admin/api/shares`. A grantee then reads the owner’s data with `?owner=<email>` on the per-user store: `GET /api/{app}/store?prefix=…&owner=…` lists it, and every route takes `If-Match` so two devices cannot silently overwrite each other. A share names existing accounts only and is removed with either account.

Upgrading: store writes (`PUT`/`DELETE /api/{app}/store/...`) now require the `X-CSRF-Token` header (from `GET /auth/csrf`), and `/api/{app}/store` is reserved for the platform. A share does not bypass an app’s `allowed_users`.

An app gets a per-user store when its `access` is `protected:user`, or when its `app.toml` sets `user_store = true` — which lets a **public** app keep data for the visitors who sign in while staying open to everyone else. The design and its review are in [`misc/docs/decisions/0001-owner-granted-data-shares.md`]().

## Doctor checks

```python
from enlace.doctor import run_doctor
from enlace_auth.diagnostics import static_checks, http_checks

report = run_doctor(
    config,
    base_url="http://localhost:8000",
    extra_static_checks=static_checks,
    extra_http_checks=http_checks,
)
```

## For the free-range, hand-coded developer

Set up and run the tests:

```bash
git clone https://github.com/i2mint/enlace_auth && cd enlace_auth
uv venv -q .venv && . .venv/bin/activate
uv pip install -q -e ".[dev,oauth]" pip
python -m pytest -q --doctest-modules enlace_auth tests
```

- **Design.** Pure ASGI middleware, deny by default, storage behind `MutableMapping`s so tests use dicts or a temp directory. The reasoning for owner-granted shares is in [`misc/docs/decisions/0001-owner-granted-data-shares.md`]().
- **Status.** Extracted from `enlace` 0.0.11. The Python API is stable; an admin frontend ships separately as a normal enlaced app.
- **Contributing.** Open an issue first for anything larger than a focused fix. A fix comes with a test that fails without it. CI formats the code and bumps the version, so leave `version` alone.
- **Questions and bugs.** [GitHub issues](https://github.com/i2mint/enlace_auth/issues).
- **License.** MIT.

<p class="epythet-aggregates">This documentation as a single file: <a href="enlace_auth.md">enlace_auth.md</a> (Markdown, for agents).</p>


# _autosummary/enlace_auth.admin.html.md

# enlace_auth.admin

Admin subsystem for enlace_auth.

Exposes `/_admin/api/*` endpoints for managing users and viewing app access
policy. Gated by an admin email allowlist supplied at construction time.

The middleware (`PlatformAuthMiddleware`) is what enforces “admin only” — it
sees the `/_admin` access rule with `allowed_users=admin_emails` and rejects
everyone else. The router itself only needs to enforce the *narrower* “any
authenticated user” gate for self-service endpoints like `/me/password`.

### Functions

| [`make_admin_router`](_autosummary/enlace_auth.admin.html.md#enlace_auth.admin.make_admin_router)(\*, user_store, session_store)   | Build a FastAPI router exposing `/_admin/api/*` endpoints.   |
|-----------------------------------------------------------------------------------------------------|--------------------------------------------------------------|

### enlace_auth.admin.make_admin_router(, user_store, session_store, admin_emails=(), apps=(), grant_store=None, protected_user_apps=(), share_store=None, store_apps=(), signing_key=None, reset_link_ttl=259200, resource_allowlist=None, oauth_server_enabled=False, public_base_url=None, on_credentials_changed=None)

Build a FastAPI router exposing `/_admin/api/*` endpoints.

`admin_emails` is captured by closure so the router can enforce
last-admin protection on delete. The middleware handles the broader
“must be an admin to see /_admin” check.

`grant_store` (a [`GrantStore`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.GrantStore)) and
`protected_user_apps` enable the runtime grant endpoints. When
`grant_store` is None those endpoints return 503.

`signing_key` enables the reset-link endpoint (same key the auth router
signs with, so the link it mints verifies there). Omit it and that endpoint
returns 503, mirroring how `grant_store` gates the grant endpoints.
`public_base_url` is the platform’s public origin for minted reset links
(falls back to the request origin).

`reset_link_ttl` is how long a minted link stays usable — longer than an
emailed one, because delivery is a human round trip.

`resource_allowlist` is the OAuth server’s per-connector allow-list (see
`enlace_auth.auth.oauth_server`). It exists here purely so `/apps` can
tell the truth about connectors: an OAuth resource server is declared
`access = "public"` because enlace must NOT gate it at the session layer
— it authenticates its own bearer tokens instead. Reporting that as a bare
“public” badge invites the reader to conclude private data is world-open
(it isn’t) or, worse, to make some *other* app public by analogy (which
would be). Passing the allow-list lets the dashboard show who can actually
reach each one. `oauth_server_enabled` lets it also say what happens to a
resource that is NOT listed: refused to everyone while an allowlist is set,
open to every account when the server runs without one.

`on_credentials_changed` (`hook(email, *, keep=None)`) runs after a
user is deleted or has their password set. The default revokes the
account’s browser sessions; the plugin injects one that also revokes its
OAuth connector refresh families (`enlace_auth.auth.revocation`).

* **Return type:**
  `APIRouter`

### Modules

| [`routes`](_autosummary/enlace_auth.admin.routes.html.md#module-enlace_auth.admin.routes)   | Admin HTTP routes: user CRUD, password reset, app policy + grants.   |
|-------------------------------------------------------------------------------------------|----------------------------------------------------------------------|


# _autosummary/enlace_auth.admin.routes.html.md

# enlace_auth.admin.routes

Admin HTTP routes: user CRUD, password reset, app policy + grants.

Endpoints (all under `/_admin/api/`):

- `GET    /users`                — list registered users
- `POST   /users`                — create a user (admin-created)
- `DELETE /users/{email}`        — delete a user (refuses last admin)
- `POST   /users/{email}/password` — admin reset another user’s password
- `POST   /users/{email}/reset-link` — mint a one-time link the user follows
  to choose their *own* password. Preferred over the endpoint above: the admin
  never learns, transmits, or has to invent a credential. It is also the only
  recovery path that works when no SMTP sender is configured, since the admin
  delivers the link by hand.
- `GET    /apps`                 — list apps with their access policy +
  (for `protected:user` apps) their runtime grants
- `POST   /grants`               — grant a user runtime access to an app
- `DELETE /grants/{app_id}/{email}` — revoke a runtime grant

A minimal HTML dashboard is also mounted at `GET /_admin/` (and
`/_admin/index.html`); it consumes the JSON endpoints above. The HTML
lives next to this file at `static/index.html` and is served via
`importlib.resources` so it ships in the wheel without any build step.

Self-service `POST /auth/me/password` (change own password) lives in
`enlace_auth.auth.routes` so it’s reachable to any authenticated user — it
is NOT under `/_admin` because the admin access rule would block non-admins.

Access control:

- `/_admin/*` is gated by `PlatformAuthMiddleware` via the access rule the
  plugin installs (`allowed_users=admin_emails`). By the time a request
  reaches this router, the caller is an admin.

Runtime grants are ADDITIVE on top of each app’s static `app.toml`
`allowed_users` and carry an optional UTC expiry — see
`enlace_auth.auth.grants`.

### Functions

| [`make_admin_router`](_autosummary/enlace_auth.admin.routes.html.md#enlace_auth.admin.routes.make_admin_router)(\*, user_store, session_store)   | Build a FastAPI router exposing `/_admin/api/*` endpoints.     |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
| [`make_admin_ui_router`](_autosummary/enlace_auth.admin.routes.html.md#enlace_auth.admin.routes.make_admin_ui_router)()                             | Build a FastAPI router that serves the bundled HTML dashboard. |

### enlace_auth.admin.routes.make_admin_router(, user_store, session_store, admin_emails=(), apps=(), grant_store=None, protected_user_apps=(), share_store=None, store_apps=(), signing_key=None, reset_link_ttl=259200, resource_allowlist=None, oauth_server_enabled=False, public_base_url=None, on_credentials_changed=None)

Build a FastAPI router exposing `/_admin/api/*` endpoints.

`admin_emails` is captured by closure so the router can enforce
last-admin protection on delete. The middleware handles the broader
“must be an admin to see /_admin” check.

`grant_store` (a [`GrantStore`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.GrantStore)) and
`protected_user_apps` enable the runtime grant endpoints. When
`grant_store` is None those endpoints return 503.

`signing_key` enables the reset-link endpoint (same key the auth router
signs with, so the link it mints verifies there). Omit it and that endpoint
returns 503, mirroring how `grant_store` gates the grant endpoints.
`public_base_url` is the platform’s public origin for minted reset links
(falls back to the request origin).

`reset_link_ttl` is how long a minted link stays usable — longer than an
emailed one, because delivery is a human round trip.

`resource_allowlist` is the OAuth server’s per-connector allow-list (see
`enlace_auth.auth.oauth_server`). It exists here purely so `/apps` can
tell the truth about connectors: an OAuth resource server is declared
`access = "public"` because enlace must NOT gate it at the session layer
— it authenticates its own bearer tokens instead. Reporting that as a bare
“public” badge invites the reader to conclude private data is world-open
(it isn’t) or, worse, to make some *other* app public by analogy (which
would be). Passing the allow-list lets the dashboard show who can actually
reach each one. `oauth_server_enabled` lets it also say what happens to a
resource that is NOT listed: refused to everyone while an allowlist is set,
open to every account when the server runs without one.

`on_credentials_changed` (`hook(email, *, keep=None)`) runs after a
user is deleted or has their password set. The default revokes the
account’s browser sessions; the plugin injects one that also revokes its
OAuth connector refresh families (`enlace_auth.auth.revocation`).

* **Return type:**
  `APIRouter`

### enlace_auth.admin.routes.make_admin_ui_router()

Build a FastAPI router that serves the bundled HTML dashboard.

Mounted at `/_admin/`; gated by the same admin access rule as the API,
so unauthenticated browsers get redirected to login by the auth
middleware before they ever reach this handler.

* **Return type:**
  `APIRouter`


# _autosummary/enlace_auth.appmeta.html.md

# enlace_auth.appmeta

Editable app-metadata overlay: the write surface for the launcher.

enlace core owns the *read* model for launcher metadata (title / description /
keywords / icon) — it harvests app-declared values and serves `/_apps` and the
icon endpoint. But the *editable* layer — an owner-curated overlay of added
keywords and icon/title/description overrides, mutated live from the launcher UI
— needs authentication, CSRF, an admin allowlist, and durable per-app storage.
Those are exactly the concerns enlace core delegates to this plugin, so the
overlay lives here, mirroring the runtime-grants pattern
([`enlace_auth.auth.grants`](_autosummary/enlace_auth.auth.grants.html.md#module-enlace_auth.auth.grants)).

The contract with core is two dependency-injection slots on the parent app’s
`state` (read by core, written here):

- `app_meta_overlay`  — a `MutableMapping[str, dict]` of per-app overlay
  records; core reads it when resolving `/_apps` and the icon.
- `app_meta_can_edit` — `Callable[[Optional[str]], bool]` deciding whether
  an email may edit; core surfaces the result as `can_edit_meta`.

Without this plugin (or on an older enlace_auth), those slots default in core to
an empty overlay and “nobody can edit”, so the launcher still works read-only.

Overlay record schema (one JSON file per app under `<store>/app_meta/{name}`):

```default
{
  "keywords":     ["owner", "added", "tags"],  # additive; unioned by core
  "icon":         "emoji:🎸" | "assets/x.png" | null,
  "display_name": "Override Title" | null,
  "description":  "Override blurb" | null
}
```

Empty records are deleted, so “no overlay” and “empty overlay” coincide.

### Functions

| [`make_appmeta_can_edit`](_autosummary/enlace_auth.appmeta.html.md#enlace_auth.appmeta.make_appmeta_can_edit)(editors, \*[, widen])   | Build the `app_meta_can_edit` closure over a normalized editor set.                                                                          |
|------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------|
| [`make_appmeta_router`](_autosummary/enlace_auth.appmeta.html.md#enlace_auth.appmeta.make_appmeta_router)(\*, apps, config, ...)    | Build the `PATCH/DELETE /_apps/{name}/meta` router (CSRF-gated by the plugin's middleware, since `/_apps/*` is not in the CSRF-exempt list). |

### enlace_auth.appmeta.make_appmeta_can_edit(editors, , widen=True)

Build the `app_meta_can_edit` closure over a normalized editor set.

Case-insensitive, and with *widen* each editor admits both canonical
spellings of its address ([`account_spellings()`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.account_spellings)).
Pass `widen=False` for the admin list, which is compared as written
everywhere else. An empty editor set
means *nobody* can edit (safe default, so a misconfigured deploy fails closed
rather than open).

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]], [`bool`](https://docs.python.org/3/builtins/functions.html#bool)]

### enlace_auth.appmeta.make_appmeta_router(, apps, config, overlay_store, can_edit)

Build the `PATCH/DELETE /_apps/{name}/meta` router (CSRF-gated by the
plugin’s middleware, since `/_apps/*` is not in the CSRF-exempt list).

Editor authz is enforced per request via `can_edit`. The app name is
validated against the known-apps set (unknown ⇒ 404), which also keeps the
store key confined to a real app name — defense-in-depth over the `[^/]+`
path convertor that already blocks traversal.

* **Return type:**
  `APIRouter`


# _autosummary/enlace_auth.auth.accounts.html.md

# enlace_auth.auth.accounts

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

A user record is a plain dict in the user store. `disabled_at` (epoch
seconds) marks an account that exists, keeps its grants, shares and history,
but cannot sign in by any path; its connector grants are refused too (see
`enlace_auth.plugin._make_is_active`). Disabling also ends its sessions;
re-enabling only removes the mark.

The platform keeps that mark in its own mapping, not in the stored record
([`AccountRecords`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.AccountRecords), #71): several paths rewrite a whole record from a copy
read moments earlier (password change, reset, OAuth subject binding), and the
file store has no compare-and-set, so a mark inside the record could be undone
by any of them. Reads still show `disabled_at` on the record, so every
`is_disabled(record)` check is unchanged; only [`set_disabled()`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.set_disabled) writes it.

Every sign-in path opens its session through [`open_session()`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.open_session), which
re-checks the record after creating the session, so a sign-in racing an admin’s
disable cannot keep a session the revocation already missed.

The last sign-in time is NOT kept on the user record (a read-modify-write there
at every login could overwrite a concurrent disable); `SessionStore` records
it in its own mapping (`sign_ins`).

### Module Attributes

| [`DISABLED_DETAIL`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.DISABLED_DETAIL)   | What a disabled account is told when it tries to sign in.   |
|--------------------------------------------------------------------|-------------------------------------------------------------|

### Functions

| [`is_disabled`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.is_disabled)(record)                            | True when *record* is a user record marked disabled.                     |
|-------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| [`make_account_records`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.make_account_records)(store_factory)            | The platform's user records: `users` plus the `disabled_accounts` marks. |
| [`open_session`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.open_session)(session_store, user_store, email) | Create a session for *email* and return its id, unless it is disabled.   |
| [`set_disabled`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.set_disabled)(user_store, email, disabled, \*)  | Mark *email*'s record disabled (or not); return the new record.          |

### Classes

| [`AccountRecords`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.AccountRecords)(users, disabled)   | User records whose disabled mark lives in its own mapping.   |
|------------------------------------------------------------------------------------|--------------------------------------------------------------|

### *class* enlace_auth.auth.accounts.AccountRecords(users, disabled)

Bases: [`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)

User records whose disabled mark lives in its own mapping.

Reading a record adds `disabled_at` from *disabled*; writing one drops
that key, so a path that rewrites a record from a copy read before an
admin’s disable cannot undo it. Only [`set_disabled()`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.set_disabled) writes
*disabled*, one key at a time, with no read-modify-write.

```pycon
>>> users = AccountRecords({"a@x.io": {"password_hash": "old"}}, {})
>>> copy = users["a@x.io"]                   # a password change reads...
>>> _ = users.set_disabled("a@x.io", True, now=5)  # ...an admin disables...
>>> users["a@x.io"] = {**copy, "password_hash": "new"}  # ...it writes back
>>> is_disabled(users["a@x.io"]), users["a@x.io"]["password_hash"]
(True, 'new')
```

A mark that exists but cannot be read counts as disabled (fail closed). A
record written before this split (#70) may still carry the key itself:
[`make_account_records()`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.make_account_records) moves such marks out at startup, and until then
the record stays disabled (rewrites keep it) until an enable clears it.

#### migrate_legacy_marks()

Move marks written into records (before #71) to *disabled*; return how many.

```pycon
>>> records = AccountRecords({"a@x.io": {"disabled_at": 3.0}}, {})
>>> records.migrate_legacy_marks(), records.users, records.disabled
(1, {'a@x.io': {}}, {'a@x.io': 3.0})
```

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

#### set_disabled(email, disabled, , now=None)

Mark *email* disabled (or not); return its record as now read.

Raises `KeyError` when there is no such account.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### enlace_auth.auth.accounts.DISABLED_DETAIL *= 'This account is disabled. Contact the platform admin.'*

What a disabled account is told when it tries to sign in.

### enlace_auth.auth.accounts.is_disabled(record)

True when *record* is a user record marked disabled.

```pycon
>>> is_disabled({"disabled_at": 1.0}), is_disabled({}), is_disabled(None)
(True, False, False)
```

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### enlace_auth.auth.accounts.make_account_records(store_factory)

The platform’s user records: `users` plus the `disabled_accounts` marks.

Marks still inside records (written before #71) are moved out first.

```pycon
>>> stores = {}
>>> records = make_account_records(lambda name: stores.setdefault(name, {}))
>>> sorted(stores)
['disabled_accounts', 'users']
```

* **Return type:**
  [`AccountRecords`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.AccountRecords)

### enlace_auth.auth.accounts.open_session(session_store, user_store, email)

Create a session for *email* and return its id, unless it is disabled.

The disabled check runs AFTER the session exists: if an admin disabled the
account (and revoked its sessions) while this sign-in was in flight, the
new session is deleted here and the sign-in refused. Raises
`fastapi.HTTPException(403)` in that case.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.accounts.set_disabled(user_store, email, disabled, , now=None)

Mark *email*’s record disabled (or not); return the new record.

Raises `KeyError` when there is no such account. On
[`AccountRecords`](_autosummary/enlace_auth.auth.accounts.html.md#enlace_auth.auth.accounts.AccountRecords) (the platform’s store) the mark is written apart
from the record; on a plain mapping it is written into the record.

```pycon
>>> s = {"a@x.io": {"created_at": 0}}
>>> is_disabled(set_disabled(s, "a@x.io", True, now=5))
True
>>> is_disabled(set_disabled(s, "a@x.io", False))
False
```

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/enlace_auth.auth.cookies.html.md

# enlace_auth.auth.cookies

Signed cookie helpers built on itsdangerous.

We wrap `URLSafeTimedSerializer` so signing_key rotation and `max_age` checks
are centralised; callers never touch itsdangerous directly.

### Functions

| [`sign_cookie`](_autosummary/enlace_auth.auth.cookies.html.md#enlace_auth.auth.cookies.sign_cookie)(value, signing_key, \*[, salt])   | Return a signed, URL-safe token carrying `value`.               |
|------------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
| [`verify_cookie`](_autosummary/enlace_auth.auth.cookies.html.md#enlace_auth.auth.cookies.verify_cookie)(token, signing_key, \*[, ...])  | Return the original value iff the token is valid and unexpired. |

### enlace_auth.auth.cookies.sign_cookie(value, signing_key, , salt='enlace-cookie')

Return a signed, URL-safe token carrying `value`.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.cookies.verify_cookie(token, signing_key, , max_age=None, salt='enlace-cookie')

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

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]


# _autosummary/enlace_auth.auth.email.html.md

# enlace_auth.auth.email

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

Email is deliberately a thin, injectable seam. `make_auth_router` takes an
`EmailSender` callable; the platform wires a real one (SMTP) when configured
and otherwise falls back to the console sender, which *logs* the message
instead of sending it. That fallback means:

- local development needs no SMTP account — the reset link shows up in the log;
- a misconfigured production box degrades to “operator can recover the link
  from `journalctl`” instead of a hard failure.

A sender is `send(*, to: str, subject: str, body: str) -> None`. Senders
never raise into the request path: delivery failures are logged, because the
password-reset endpoint must return the same response whether or not the
address exists (so it can’t be used to enumerate accounts).

### Functions

| [`make_console_sender`](_autosummary/enlace_auth.auth.email.html.md#enlace_auth.auth.email.make_console_sender)()                             | Return a sender that logs the email instead of delivering it.   |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
| [`make_smtp_sender`](_autosummary/enlace_auth.auth.email.html.md#enlace_auth.auth.email.make_smtp_sender)(\*, host[, port, username, ...]) | Return a sender that delivers via SMTP.                         |

### Classes

| [`EmailSender`](_autosummary/enlace_auth.auth.email.html.md#enlace_auth.auth.email.EmailSender)(\*args, \*\*kwargs)   | Callable that delivers one plain-text email.   |
|------------------------------------------------------------------------------------|------------------------------------------------|

### *class* enlace_auth.auth.email.EmailSender(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

Callable that delivers one plain-text email. Must not raise.

### enlace_auth.auth.email.make_console_sender()

Return a sender that logs the email instead of delivering it.

The default when no SMTP config is present. The full body — including any
reset link — is logged at WARNING so it stands out in `journalctl`.

* **Return type:**
  [`EmailSender`](_autosummary/enlace_auth.auth.email.html.md#enlace_auth.auth.email.EmailSender)

### enlace_auth.auth.email.make_smtp_sender(, host, port=587, username=None, password=None, from_addr, use_tls=True, timeout=15.0)

Return a sender that delivers via SMTP.

* **Parameters:**
  * **host** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – SMTP server hostname.
  * **port** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – SMTP port (587 for STARTTLS, 465 for implicit TLS, 25 plain).
  * **password** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – SMTP auth credentials; omit for an open relay.
  * **from_addr** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the envelope/`From` address.
  * **use_tls** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – STARTTLS after connecting (ignored when `port == 465`,
    which uses implicit TLS).
  * **timeout** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – socket timeout in seconds.
* **Return type:**
  [`EmailSender`](_autosummary/enlace_auth.auth.email.html.md#enlace_auth.auth.email.EmailSender)

Delivery errors are caught and logged — the caller’s flow continues so
account existence never leaks through a differing response.


# _autosummary/enlace_auth.auth.grants.html.md

# enlace_auth.auth.grants

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

A *grant* gives a specific user (by email) access to a specific `protected:user`
app at runtime, **without a redeploy**. Grants are ADDITIVE on top of the static
`allowed_users` declared in an app’s `app.toml`: the effective allow-set for
an app is `config allowed_users ∪ {active grants}`. A grant is *active* when its
`expires_at` is `None` (never expires) or lies in the future (UTC).

Storage mirrors [`enlace_auth.auth.sessions.SessionStore`](_autosummary/enlace_auth.auth.sessions.html.md#enlace_auth.auth.sessions.SessionStore): a thin adapter
over a `MutableMapping` whose keys are `"{app_id}/{email}"` and whose values
are JSON grant records:

```default
{
    "app_id": str,
    "email": str,              # normalized to lowercase
    "expires_at": float | None,  # epoch seconds, UTC; None = never
    "granted_at": float,       # epoch seconds, UTC
    "granted_by": str | None,  # admin email who created it
    "note": str | None,
}
```

The composite key groups grants per app on disk (`grants/{app_id}/{email}`), so
the hot path — [`GrantStore.active_emails_for_app()`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.GrantStore.active_emails_for_app), consulted by the auth
middleware on every `protected:user` request — lists a single app’s
subdirectory rather than scanning every grant for every app.

### Functions

| [`parse_expires_at`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.parse_expires_at)(value, \*[, now])   | Coerce a user-supplied expiry into epoch seconds (UTC), or `None`.   |
|---------------------------------------------------------------------------------------|----------------------------------------------------------------------|

### Classes

| [`GrantStore`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.GrantStore)(backend, \*[, root])   | Thin adapter over a `MutableMapping` that speaks grant semantics.   |
|------------------------------------------------------------------------------------|---------------------------------------------------------------------|

### Exceptions

| [`GrantError`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.GrantError)   | Raised when a grant argument is invalid (bad email/app_id, past expiry).   |
|---------------------------------------------------------------|----------------------------------------------------------------------------|

### *exception* enlace_auth.auth.grants.GrantError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

Raised when a grant argument is invalid (bad email/app_id, past expiry).

### *class* enlace_auth.auth.grants.GrantStore(backend, , root=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Thin adapter over a `MutableMapping` that speaks grant semantics.

* **Parameters:**
  * **backend** ([`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)) – the per-name store (e.g. `factory("grants")`).
  * **root** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.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)

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`](https://docs.python.org/3/builtins/stdtypes.html#set)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

#### grant(app_id, email, , expires_at=None, granted_by=None, note=None, now=None)

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

Use [`parse_expires_at()`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.parse_expires_at) to turn a date/ISO string into `expires_at`
before calling this.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

#### list_all()

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

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### revoke(app_id, email)

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`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### enlace_auth.auth.grants.parse_expires_at(value, , now=None)

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`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.GrantError) for unparseable input or an already-past expiry.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]


# _autosummary/enlace_auth.auth.html.md

# 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`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.hash_password)(password)                         | Return an argon2id hash string for `password`.                     |
|--------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| [`make_auth_router`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.make_auth_router)(\*, session_store, ...[, ...]) | Build a FastAPI router exposing `/auth/*` endpoints.               |
| [`parse_expires_at`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.parse_expires_at)(value, \*[, now])              | Coerce a user-supplied expiry into epoch seconds (UTC), or `None`. |
| [`sign_cookie`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.sign_cookie)(value, signing_key, \*[, salt])     | Return a signed, URL-safe token carrying `value`.                  |
| [`verify_cookie`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.verify_cookie)(token, signing_key, \*[, ...])    | Return the original value iff the token is valid and unexpired.    |
| [`verify_password`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.verify_password)(hashed, password)               | Return True iff `password` matches the stored `hashed` value.      |

### Classes

| [`AccessRule`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.AccessRule)(prefix, level, app_id[, ...])           | Auth policy for a single mount prefix.                              |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------|
| [`CSRFMiddleware`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.CSRFMiddleware)(app, \*, signing_key[, ...])        | Signed double-submit CSRF for state-changing requests.              |
| [`GrantStore`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.GrantStore)(backend, \*[, root])                    | Thin adapter over a `MutableMapping` that speaks grant semantics.   |
| [`PlatformAuthMiddleware`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.PlatformAuthMiddleware)(app, \*, access_rules, ...) | Pure-ASGI auth middleware.                                          |
| [`SessionStore`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.SessionStore)(store, \*[, max_age, ...])            | Thin adapter around a MutableMapping that speaks session semantics. |
| [`ShareStore`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.ShareStore)(backend, \*[, root, account_exists])    | Share semantics over a `MutableMapping`.                            |

### Exceptions

| [`ShareError`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Thin adapter over a `MutableMapping` that speaks grant semantics.

* **Parameters:**
  * **backend** ([`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)) – the per-name store (e.g. `factory("grants")`).
  * **root** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.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)

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`](https://docs.python.org/3/builtins/stdtypes.html#set)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

#### grant(app_id, email, , expires_at=None, granted_by=None, note=None, now=None)

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

Use [`parse_expires_at()`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.parse_expires_at) to turn a date/ISO string into `expires_at`
before calling this.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

#### list_all()

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

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### revoke(app_id, email)

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`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Thin adapter around a MutableMapping that speaks session semantics.

#### last_sign_in(user)

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

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]

#### revoke_user(user, , keep=None)

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`](https://docs.python.org/3/builtins/functions.html#int)

#### sweep_expired(, now=None)

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`](https://docs.python.org/3/builtins/functions.html#int)

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

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

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

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

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Share semantics over a `MutableMapping`.

* **Parameters:**
  * **backend** ([`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)) – the per-name store (e.g. `factory("shares")`).
  * **root** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.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`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`bool`](https://docs.python.org/3/builtins/functions.html#bool)]]) – `email -> bool`. When given, [`share()`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.ShareStore.share) refuses an
    owner or grantee with no account. `None` skips the check (tests).

#### access(app_id, owner, grantee, , now=None)

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

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

#### granted(app_id, owner, , now=None)

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

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### list_all()

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`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### received(app_id, grantee, , now=None)

The **active** shares made to `grantee` in `app_id`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### remove_account(email)

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`](https://docs.python.org/3/builtins/functions.html#int)

#### revoke(app_id, owner, grantee, , widen_owner=False, widen_grantee=False, first_only=False)

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()`](_autosummary/enlace_auth.auth.html.md#enlace_auth.auth.ShareStore.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`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

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

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()`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.parse_expires_at) first.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### enlace_auth.auth.hash_password(password)

Return an argon2id hash string for `password`.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#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)

Build a FastAPI router exposing `/auth/*` endpoints.

* **Parameters:**
  * **user_store** (*session_store /*) – backing stores.
  * **signing_key** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – HMAC key for session, CSRF, shared, and reset tokens.
  * **secure_cookies** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – session-cookie policy.
  * **shared_password_for** ([`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]) – maps an app id to its shared-password hash.
  * **can_register** ([`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`bool`](https://docs.python.org/3/builtins/functions.html#bool)]) – predicate gating self-registration by email.
  * **send_email** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`EmailSender`](_autosummary/enlace_auth.auth.email.html.md#enlace_auth.auth.email.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`](https://docs.python.org/3/builtins/functions.html#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`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#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`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`CredentialsChanged`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.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)

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`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]

### enlace_auth.auth.sign_cookie(value, signing_key, , salt='enlace-cookie')

Return a signed, URL-safe token carrying `value`.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.verify_cookie(token, signing_key, , max_age=None, salt='enlace-cookie')

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

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### enlace_auth.auth.verify_password(hashed, password)

Return True iff `password` matches the stored `hashed` value.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### Modules

| [`accounts`](_autosummary/enlace_auth.auth.accounts.html.md#module-enlace_auth.auth.accounts)         | Disabled accounts, and the one way a sign-in opens a session.                       |
|----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
| [`cookies`](_autosummary/enlace_auth.auth.cookies.html.md#module-enlace_auth.auth.cookies)           | Signed cookie helpers built on itsdangerous.                                        |
| [`email`](_autosummary/enlace_auth.auth.email.html.md#module-enlace_auth.auth.email)               | Outbound email for enlace_auth — used by the password-recovery flow.                |
| [`grants`](_autosummary/enlace_auth.auth.grants.html.md#module-enlace_auth.auth.grants)             | Runtime per-app access grants, with optional UTC expiry.                            |
| [`middleware`](_autosummary/enlace_auth.auth.middleware.html.md#module-enlace_auth.auth.middleware)     | Platform auth middleware (pure ASGI).                                               |
| [`oauth`](_autosummary/enlace_auth.auth.oauth.html.md#module-enlace_auth.auth.oauth)               | OAuth2/OIDC login via Authlib.                                                      |
| [`oauth_server`](_autosummary/enlace_auth.auth.oauth_server.html.md#module-enlace_auth.auth.oauth_server) | OAuth 2.1 authorization server — issue tokens for MCP custom connectors.            |
| [`pages`](_autosummary/enlace_auth.auth.pages.html.md#module-enlace_auth.auth.pages)               | HTML pages for the enlace_auth browser-facing flows.                                |
| [`passwords`](_autosummary/enlace_auth.auth.passwords.html.md#module-enlace_auth.auth.passwords)       | Password hashing via argon2id.                                                      |
| [`reset_tokens`](_autosummary/enlace_auth.auth.reset_tokens.html.md#module-enlace_auth.auth.reset_tokens) | Password-reset tokens — minting, verification, and the link they live in.           |
| [`revocation`](_autosummary/enlace_auth.auth.revocation.html.md#module-enlace_auth.auth.revocation)     | Credential revocation: end everything an account holds when its credentials change. |
| [`routes`](_autosummary/enlace_auth.auth.routes.html.md#module-enlace_auth.auth.routes)             | Auth HTTP routes: register, login, logout, shared-login, csrf, recovery.            |
| [`sessions`](_autosummary/enlace_auth.auth.sessions.html.md#module-enlace_auth.auth.sessions)         | Session storage backed by a MutableMapping.                                         |
| [`share_routes`](_autosummary/enlace_auth.auth.share_routes.html.md#module-enlace_auth.auth.share_routes) | `/auth/shares/*` — the signed-in user manages the shares of their own data.         |
| [`shares`](_autosummary/enlace_auth.auth.shares.html.md#module-enlace_auth.auth.shares)             | Owner-granted data shares: let named users at one user's per-user data in one app.  |


# _autosummary/enlace_auth.auth.middleware.html.md

# enlace_auth.auth.middleware

Platform auth middleware (pure ASGI).

Runs before any mounted sub-app. Responsibilities:

1. Normalize the request path and reject known traversal bypasses.
2. Strip client-provided identity headers so apps can’t be fooled by spoofed
   `X-User-ID` / `X-Forwarded-User` / similar.
3. Resolve the access level for the request by longest-prefix match on the
   route prefix of each mounted app. Deny-by-default: an unmatched path is
   treated as `protected:user`.
4. For `public` / `local`, pass through with `user_id=None`.
5. For `protected:shared`, look for a per-app signed cookie; on failure
   return 401 (JSON) or redirect browsers to `login_redirect_path`.
6. For `protected:user`, look for the platform session cookie, load the
   session from `SessionStore`, set `user_id` / `user_email`; on
   failure return 401 (JSON) or redirect browsers to `login_redirect_path`.

Browser vs. API deny behavior: GET/HEAD navigations that look like a browser
(`Accept` header contains `text/html`) get a 303 redirect to the login
page with `?login_required=1&next=<original-path>`, so the user lands
somewhere meaningful instead of seeing a raw JSON error. Non-HTML requests
(XHR, fetch with `Accept: application/json`, curl, etc.) keep getting the
machine-readable 401.

Design notes:

- Pure ASGI three-callable pattern. Never `BaseHTTPMiddleware` (see
  CLAUDE.md).
- Exempts `/auth/*` from auth checks so login/register pages stay reachable.

### Classes

| [`AccessRule`](_autosummary/enlace_auth.auth.middleware.html.md#enlace_auth.auth.middleware.AccessRule)(prefix, level, app_id[, ...])           | Auth policy for a single mount prefix.                 |
|-----------------------------------------------------------------------------------------------------|--------------------------------------------------------|
| [`CSRFMiddleware`](_autosummary/enlace_auth.auth.middleware.html.md#enlace_auth.auth.middleware.CSRFMiddleware)(app, \*, signing_key[, ...])        | Signed double-submit CSRF for state-changing requests. |
| [`PlatformAuthMiddleware`](_autosummary/enlace_auth.auth.middleware.html.md#enlace_auth.auth.middleware.PlatformAuthMiddleware)(app, \*, access_rules, ...) | Pure-ASGI auth middleware.                             |

### *class* enlace_auth.auth.middleware.AccessRule(prefix, level, app_id, shared_password_hash=None, allowed_users=(), deny_signed_in=True)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Auth policy for a single mount prefix.

### *class* enlace_auth.auth.middleware.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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#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.middleware.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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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


# _autosummary/enlace_auth.auth.oauth.html.md

# enlace_auth.auth.oauth

OAuth2/OIDC login via Authlib.

Lazy import — `authlib` lives behind the `enlace[oauth]` extra. Providers
are configured in `platform.toml` under `[auth.oauth.{name}]` with
`client_id_env` / `client_secret_env` pointing at env vars (secrets never
in TOML). On callback we create a local session — the upstream tokens are
discarded because we use OAuth for identity only, not API access.

Two rules keep an OAuth login from being weaker than the account it opens:

- **The anti-CSRF state lives in a signed cookie** scoped to `/auth`
  (`_oauth_state_session()`). Authlib keeps the `state`, nonce and PKCE
  verifier in `request.session`; the plugin installs no Starlette
  `SessionMiddleware`, so this module supplies that session itself. A callback
  whose `state` was not issued to *this* browser is refused.
- **An identity is bound to the provider’s stable subject** (`sub`, or
  `tid`/`oid` for Microsoft, GitHub’s numeric `id`), recorded as
  `oauth_links[provider]` on the account. A later login must present the same
  subject. An existing *password* account, or one linked to another provider, is
  never taken over by an email match alone.

Residual limits, by design: the state cookie is signed but not bound to the
browser, so a script that can set cookies on the platform origin (any
co-hosted app) could plant its own state for a browser that has none and so
log that browser in as the attacker (two cookies of the name are refused). An
account created by a provider before links existed is bound to the first
subject that signs in to it after the upgrade. A password reset (admin, emailed
link, CLI) unlinks every external sign-in. The cookie path assumes the router
is mounted at `/auth` with no root path.

Built-in provider presets for Google and GitHub auto-fill the well-known
endpoints; other providers need explicit URLs in the config.

### Functions

| [`make_oauth_router`](_autosummary/enlace_auth.auth.oauth.html.md#enlace_auth.auth.oauth.make_oauth_router)(\*, providers, ...[, ...])   | Build an OAuth router or return None if no providers are configured.   |
|-------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|

### enlace_auth.auth.oauth.make_oauth_router(\*, providers, session_store, user_store, signing_key, cookie_name='enlace_session', session_max_age=86400, secure_cookies=True, can_register=<function <lambda>>, admin_emails=(), state_cookie_name='enlace_oauth_state', state_max_age=600)

Build an OAuth router or return None if no providers are configured.

*state_cookie_name* / *state_max_age* name and bound the signed cookie that
carries Authlib’s per-login state between `/auth/login/{provider}` and the
callback (see the module docstring). It is only used when no Starlette
`SessionMiddleware` already provides `request.session`.

*admin_emails* may create their account by a first sign-in, whatever
*can_register* says, but only when the provider itself verified the address
(`_email_verified_by_provider()`). That is the one self-service way to
set up an admin who never uses a password: `/auth/register` cannot prove
ownership and refuses admin addresses (i2mint/enlace_auth#38).

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[`APIRouter`]


# _autosummary/enlace_auth.auth.oauth_server.html.md

# enlace_auth.auth.oauth_server

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

The companion to [`enlace_auth.auth.oauth`](_autosummary/enlace_auth.auth.oauth.html.md#module-enlace_auth.auth.oauth) (which is a login *client*, “sign in
with Google”). This module makes `enlace_auth` an authorization **server**: it
issues and signs the bearer tokens a Claude.ai custom connector — an OAuth
*resource server* — validates. It reuses the platform’s existing user store and
session login, so a connector authenticates against the same accounts as the rest
of the platform.

The flow Claude.ai drives (OAuth 2.1 authorization-code + PKCE, RFC 7591 dynamic
client registration, RFC 8707 resource indicators):

1. The connector replies 401, pointing at its protected-resource metadata, which
   names this server as the authorization server.
2. The client discovers our endpoints via `/.well-known/oauth-authorization-server`.
3. It registers itself (DCR) at `/auth/oauth/register`.
4. `GET /auth/oauth/authorize` — the user signs in (reusing `/auth/login`) and
   consents; we issue a short-lived, PKCE-bound authorization code.
5. `POST /auth/oauth/token` — the code + PKCE verifier is exchanged for a signed
   JWT whose `aud` is the connector (the `resource` parameter).
6. The connector’s FastMCP `JWTVerifier` validates that JWT against
   `/auth/oauth/jwks`.
7. Before the access token expires, the client exchanges its **refresh token**
   (`grant_type=refresh_token`) for a fresh pair — no browser, no human. This is
   what keeps a connector alive: without it an access token’s expiry ends the
   session outright, and the only way back is for a person to re-run the whole
   browser authorization. See [`make_oauth_server_router()`](_autosummary/enlace_auth.auth.oauth_server.html.md#enlace_auth.auth.oauth_server.make_oauth_server_router) for the rotation and
   reuse-detection rules.

Endpoints (mounted on the platform root, so they sit at the issuer origin):

```default
GET  /.well-known/oauth-authorization-server   metadata (RFC 8414)
GET  /auth/oauth/jwks                            signing public keys
POST /auth/oauth/register                        dynamic client registration
GET  /auth/oauth/authorize                        sign-in + consent → code
POST /auth/oauth/authorize                        consent submit → code
POST /auth/oauth/token                            code + PKCE → JWT
                                                  refresh_token → JWT
```

### Functions

| [`make_oauth_server_router`](_autosummary/enlace_auth.auth.oauth_server.html.md#enlace_auth.auth.oauth_server.make_oauth_server_router)(\*, session_store, ...)   | Build the OAuth 2.1 authorization-server router (see the module docstring).   |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|

### Classes

| [`OAuthKeys`](_autosummary/enlace_auth.auth.oauth_server.html.md#enlace_auth.auth.oauth_server.OAuthKeys)(key_dir)   | The server's RSA signing key — persisted, exposed as a JWKS.   |
|-----------------------------------------------------------------------|----------------------------------------------------------------|

### *class* enlace_auth.auth.oauth_server.OAuthKeys(key_dir)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The server’s RSA signing key — persisted, exposed as a JWKS.

Generates a 2048-bit RSA key on first use under *key_dir* (`private_key.pem`,
mode 0600) and reuses it thereafter, so tokens stay verifiable across restarts.
Signs JWTs (RS256) and publishes the public half as a one-key JWKS for the
connector’s verifier to fetch.

### enlace_auth.auth.oauth_server.make_oauth_server_router(, session_store, signing_key, cookie_name, session_max_age, client_store, code_store, keys, issuer=None, refresh_store=None, claim_once=None, release_claim=None, consent_claim_once=None, is_active=None, access_token_ttl=3600, refresh_token_ttl=2592000, refresh_reuse_grace=60, refresh_reuse_detection=86400, refresh_family_max_lifetime=7776000, client_ttl=15552000, code_ttl=120, consent_ttl=600, scopes_supported=('mcp:read',), require_consent=True, resource_allowlist=None, allow_unlisted_resources=False, resource_display_names=None)

Build the OAuth 2.1 authorization-server router (see the module docstring).

*issuer* pins the token `iss` and the discovery URLs; when `None` it is
derived from each request’s base URL (so the same code serves any domain). The
consent step reuses the platform session — an unauthenticated `/authorize`
redirects to `/auth/login` and returns.

*refresh_store* backs the `refresh_token` grant. Supply it and clients renew
their own access tokens in the background, unattended; omit it and this server
issues bare access tokens whose expiry silently ends the session, recoverable
only by a human re-running the browser authorization. It is optional purely for
backwards compatibility — **always pass it in a deployment**, and see
`enlace_auth.diagnostics.check_oauth_server()` which reports its absence as
a fault. Tokens are stored hashed (`_hash_refresh()`), never verbatim.

Refresh tokens **rotate**: each use consumes the presented token and returns a
new one. Consumption goes through *claim_once*, which must be atomic across
PROCESSES — several workers share one store, and a plain read-then-write lets
two concurrent redemptions both mint a live successor, which is precisely the
double-spend rotation exists to detect.

A spent token presented again is theft *unless* it looks like a retry. The
token is consumed before the response is written, so a dropped response
leaves an honest client holding a spent token; revoking there would strand
the connector exactly as having no refresh grant does. Within
*refresh_reuse_grace*, from the same client, with the successor still unused
and the subject still authorized, it is reissued instead. Anything else
revokes the whole family (OAuth 2.1 §4.3.1). Spent tokens are remembered for
*refresh_reuse_detection* so a replay is recognised rather than merely
unknown.

*refresh_token_ttl* is an IDLE timeout that each rotation resets;
*refresh_family_max_lifetime* is the absolute ceiling, fixed when the session
is authorized. Both *resource_allowlist* and *is_active* are re-evaluated on
every refresh — with no denylist and no revocation endpoint, they are the
only way a session ends before its own expiry, which is why access tokens
should stay short.

*resource_allowlist* maps a connector resource URL to the emails permitted to
authorize for it — per-connector access control (e.g. restrict a private
connector to its own staff). Once it lists anything it **fails closed**: a
resource that is not in the map is refused to everyone, at authorization, code
exchange and every refresh, so a connector that is newly added, renamed or
mounted at a second path stays shut until someone lists it. Opening a resource
to every authenticated account is an explicit act: list it with
`[ANY_ACCOUNT]` (`["*"]`). An empty or absent map would leave every resource
open, so it raises `ValueError` unless *allow_unlisted_resources* asks for
exactly that (and is then logged as a warning); a malformed map raises too.
See [`enlace_auth.resource_policy`](_autosummary/enlace_auth.resource_policy.html.md#module-enlace_auth.resource_policy). Secure to key on the resource: the
connector only accepts tokens whose `aud` is that exact resource, so a denied
user can’t get a usable token another way. The `aud` is minted from the same
normalized resource the allowlist was checked against, so the two never
disagree about a trailing slash.

*require_consent* must stay `True`; `False` raises `ValueError`.
Registration is open to anyone, so a server that skipped the consent screen
would hand any website a code for a signed-in visitor on a cross-site GET
(see `enlace_auth.resource_policy.check_require_consent()`).

*consent_ttl* is how long, in seconds, a rendered consent form can be
submitted. Its CSRF token approves exactly one request — this session, this
client, redirect URI, PKCE challenge, state, scope and resource — and nothing
after it expires (see `_consent_binding()`). It must be positive. It is
also **one-shot**: its first submit (approve or deny) spends it, through
*consent_claim_once*, so a double-click or a replayed POST gets the stale-form
page instead of a second code (#55). Like *claim_once* it must be atomic across
processes. The key is the request’s binding, and a page reload re-mints a token
for the same binding, so it must remember a claim for as long as the session
can live (the platform uses `max(consent_ttl, session_max_age)`); when it is
`None` an in-process set is used, which is one-shot only within one worker.

*resource_display_names* maps the **same** resource URL to the human name the
consent screen shows. One authorization server serves every connector on the
platform, so this string must be per-connector: a name baked into the template
is correct for one connector and wrong — and disclosing — for every other. A
resource with no entry gets generic copy that names no product at all; never
default to a specific connector’s name, or the next connector added inherits
it.

* **Return type:**
  `APIRouter`


# _autosummary/enlace_auth.auth.pages.html.md

# enlace_auth.auth.pages

HTML pages for the enlace_auth browser-facing flows.

Single source of truth for the auth UI: the sign-in page, the account page
(change your own password), the password-recovery pages, and a generic notice
page. Pages are returned as plain HTML strings — the
same inline-HTML, no-build-step pattern used elsewhere in enlace
(`enlace.frontend._NOT_FOUND_PAGE`). All pages share one dark, minimal
stylesheet so the platform feels consistent from the very first unauthenticated
screen.

`PlatformAuthMiddleware` 303-redirects a blocked browser navigation to
`/auth/login?login_required=1&next=<path>`; [`render_login_page()`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_login_page) is the
page that redirect lands on, and it threads `next` back through after sign-in.

### Functions

| [`fill_template`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.fill_template)(template, /, \*\*values)             | Fill `{name}` placeholders in *template* with HTML-escaped *values*.   |
|-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|
| [`render_account_page`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_account_page)(\*, email[, error])            | Render the signed-in user's "change my password" form.                 |
| [`render_forbidden_page`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_forbidden_page)(\*, email, switch_url)       | The 403 a signed-in user sees for a page their account may not open.   |
| [`render_forgot_page`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_forgot_page)(\*[, error, ...])               | Render the "request a password-reset link" form.                       |
| [`render_login_page`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_login_page)(\*[, next_url, error, ...])      | Render the sign-in form.                                               |
| [`render_notice_page`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_notice_page)(\*, title, heading, ...[, ...]) | Render a centered notice card.                                         |
| [`render_reset_page`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_reset_page)(\*, token[, error])              | Render the "set a new password" form for a reset `token`.              |
| [`render_shared_login_page`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_shared_login_page)(\*, app[, next_url, ...]) | Render the shared-password form for a `protected:shared` app.          |
| [`safe_next`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.safe_next)(raw, \*[, default])                      | Return a same-origin path safe to redirect to, or `default`.           |

### enlace_auth.auth.pages.fill_template(template, , \*\*values)

Fill `{name}` placeholders in *template* with HTML-escaped *values*.

The escaping boundary for any page that interpolates request- or
user-supplied data into HTML. Escaping happens **here**, once, rather than
at each interpolation site: a value cannot reach the markup without passing
through [`html.escape()`](https://docs.python.org/3/library/html.html#html.escape), so a placeholder added later is safe by
construction instead of by the author remembering. Note that `_page()`
escapes only the page *title* — the body it receives is inserted verbatim,
so a body built by string interpolation must come through this function.

`quote=True` covers both sinks used here: element text, and attribute
values in either quote style.

*template* is trusted, static markup and must contain no literal braces
other than its placeholders ([`str.format()`](https://docs.python.org/3/builtins/stdtypes.html#str.format) rules apply). Compose a
page from several `fill_template` results rather than leaving an
un-escaped hole in one big template.

```pycon
>>> fill_template('<input value="{v}">', v='" onx=1')
'<input value="&quot; onx=1">'
```

Only *values* are escaped; the template’s own markup passes through as-is:

```pycon
>>> fill_template("<p>{who} &amp; {what}</p>", who="a<b", what="c>d")
'<p>a&lt;b &amp; c&gt;d</p>'
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.pages.render_account_page(, email, error=None)

Render the signed-in user’s “change my password” form.

Posts to `/auth/me/password`, which requires the *current* password —
so this page is safe to leave reachable on a shared machine: possession of
a live session alone doesn’t let someone change the password out from
under the owner.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.pages.render_forbidden_page(, email, switch_url)

The 403 a signed-in user sees for a page their account may not open.

Says who they are signed in as (a redirect to sign-in would read as “you
are not signed in”, which is false) and offers to switch account.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.pages.render_forgot_page(, error=None, email_delivery_configured=True)

Render the “request a password-reset link” form.

* **Parameters:**
  * **error** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – optional error banner.
  * **email_delivery_configured** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – whether the platform has a mail sender
    wired. When it does not, the page says so and points at the admin
    instead of inviting the user to watch an inbox nothing will arrive
    in. This is a property of the deployment, not of any account, so
    showing it leaks nothing about who is registered — the *submit*
    response stays identical either way.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.pages.render_login_page(, next_url='/', error=None, show_register_hint=True)

Render the sign-in form.

* **Parameters:**
  * **next_url** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – where to send the browser after a successful login. Already
    sanitized by the caller via [`safe_next()`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.safe_next).
  * **error** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – optional error banner (e.g. a stale-session note).
  * **show_register_hint** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – whether to show the “ask an admin” footnote.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.pages.render_notice_page(, title, heading, message, links, wide=False)

Render a centered notice card.

* **Parameters:**
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `<title>` text.
  * **heading** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the card’s `<h1>`.
  * **message** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – a sentence or two of explanation (HTML-escaped here).
  * **links** ([`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`bool`](https://docs.python.org/3/builtins/functions.html#bool)]]) – `(label, href, is_primary)` tuples rendered as a row of
    actions; primary links get the filled-button style.
  * **wide** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – use the wider card (for longer content).
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.pages.render_reset_page(, token, error=None)

Render the “set a new password” form for a reset `token`.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.pages.render_shared_login_page(, app, next_url='/', error=None)

Render the shared-password form for a `protected:shared` app.

Unlike [`render_login_page()`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.render_login_page) this asks for a single shared password
(no email) and posts to `/auth/shared-login` with the `app` id, so a
browser hitting a password-gated app with no frontend has something to
fill in.

* **Parameters:**
  * **app** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the app id whose shared password is being entered. Shown to the
    user and threaded into the POST body.
  * **next_url** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – where to send the browser after success. Already sanitized
    by the caller via [`safe_next()`](_autosummary/enlace_auth.auth.pages.html.md#enlace_auth.auth.pages.safe_next).
  * **error** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – optional error banner.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.pages.safe_next(raw, , default='/')

Return a same-origin path safe to redirect to, or `default`.

Guards against open-redirect: only local absolute paths are accepted —
no scheme, no `//host` form, no control characters. This is applied to
every `?next=` value before it reaches a page or a redirect.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)


# _autosummary/enlace_auth.auth.passwords.html.md

# enlace_auth.auth.passwords

Password hashing via argon2id.

argon2-cffi ships `PasswordHasher` which handles salting, tuning, and
constant-time verification. We surface only two helpers to keep callers away
from parameter tuning.

### Module Attributes

| [`MIN_PASSWORD_LEN`](_autosummary/enlace_auth.auth.passwords.html.md#enlace_auth.auth.passwords.MIN_PASSWORD_LEN)   | registration, the admin API, reset, change, and the `add-user` / `set-password` CLI.   |
|---------------------------------------------------------------------|----------------------------------------------------------------------------------------|

### Functions

| [`hash_password`](_autosummary/enlace_auth.auth.passwords.html.md#enlace_auth.auth.passwords.hash_password)(password)           | Return an argon2id hash string for `password`.                |
|------------------------------------------------------------------------------------|---------------------------------------------------------------|
| [`password_problem`](_autosummary/enlace_auth.auth.passwords.html.md#enlace_auth.auth.passwords.password_problem)(password)        | Why *password* may not be set, or `None` if it may.           |
| [`verify_password`](_autosummary/enlace_auth.auth.passwords.html.md#enlace_auth.auth.passwords.verify_password)(hashed, password) | Return True iff `password` matches the stored `hashed` value. |

### enlace_auth.auth.passwords.MIN_PASSWORD_LEN *= 8*

registration, the
admin API, reset, change, and the `add-user` / `set-password` CLI.

* **Type:**
  Minimum password length, for every way a password is set

### enlace_auth.auth.passwords.hash_password(password)

Return an argon2id hash string for `password`.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.passwords.password_problem(password)

Why *password* may not be set, or `None` if it may.

The one rule every way of setting a password applies (see
[`MIN_PASSWORD_LEN`](_autosummary/enlace_auth.auth.passwords.html.md#enlace_auth.auth.passwords.MIN_PASSWORD_LEN)), so they cannot drift apart.

```pycon
>>> password_problem("long-enough")
>>> password_problem("short")
'Password must be at least 8 characters.'
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### enlace_auth.auth.passwords.verify_password(hashed, password)

Return True iff `password` matches the stored `hashed` value.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)


# _autosummary/enlace_auth.auth.reset_tokens.html.md

# enlace_auth.auth.reset_tokens

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

Single source of truth for the reset-link format, shared by the three callers
that must agree on it byte-for-byte: the self-service
`/auth/password-reset/*` endpoints, the admin
`POST /_admin/api/users/{email}/reset-link` endpoint, and the
`enlace-auth reset-link` CLI.

The token is a *signed* (never encrypted) string carrying three fields:

```default
<password fingerprint> | <absolute expiry, epoch seconds> | <email>
```

Two properties fall out of that shape, and neither needs a server-side token
store:

- **Single use.** The fingerprint is a hash of the account’s *current* password
  hash. Consuming a link changes the password, which changes the hash, which
  invalidates that token — and every other link outstanding for the account.
- **Per-link lifetime.** The expiry lives *inside* the signed payload rather
  than being applied at verification time, so one verifier serves both the
  short-lived link sent by email and the longer-lived link an admin hands over
  out-of-band (chat, a phone call) when SMTP isn’t configured.

The signature covers the expiry, so a longer lifetime cannot be forged. A
second, independent ceiling ([`MAX_TTL_SECONDS`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.MAX_TTL_SECONDS)) is still applied at the
signature layer, so no token can outlive it even if the embedded expiry is
somehow mis-parsed.

### Module Attributes

| [`DEFAULT_EMAIL_TTL`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.DEFAULT_EMAIL_TTL)   | Lifetime of a link delivered by email — short, because the inbox is a hostile place to leave a credential lying around.             |
|----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| [`DEFAULT_HANDOFF_TTL`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.DEFAULT_HANDOFF_TTL) | Lifetime of a link an admin mints and delivers by hand.                                                                             |
| [`MAX_TTL_SECONDS`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.MAX_TTL_SECONDS)     | Absolute ceiling on any reset link, enforced at the signature layer as a second bound independent of the expiry inside the payload. |

### Functions

| [`mint_reset_token`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.mint_reset_token)(\*, record, email, signing_key)   | Return a signed reset token for `email`, valid for `ttl_seconds`.     |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
| [`password_fingerprint`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.password_fingerprint)(record)                       | Return a short, stable fingerprint of a user's current password hash. |
| [`reset_url`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.reset_url)(base_url, token)                         | Return the full `/auth/reset-password` link carrying `token`.         |
| [`verify_reset_token`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.verify_reset_token)(token, \*, signing_key, ...)    | Return `(email, record)` for a valid token, else `(None, None)`.      |

### enlace_auth.auth.reset_tokens.DEFAULT_EMAIL_TTL *= 1800*

Lifetime of a link delivered by email — short, because the inbox is a
hostile place to leave a credential lying around.

### enlace_auth.auth.reset_tokens.DEFAULT_HANDOFF_TTL *= 259200*

Lifetime of a link an admin mints and delivers by hand. Longer because the
round trip is human: the admin sends it, the recipient reads it later.

### enlace_auth.auth.reset_tokens.MAX_TTL_SECONDS *= 2592000*

Absolute ceiling on any reset link, enforced at the signature layer as a
second bound independent of the expiry inside the payload.

### enlace_auth.auth.reset_tokens.mint_reset_token(, record, email, signing_key, ttl_seconds=1800)

Return a signed reset token for `email`, valid for `ttl_seconds`.

* **Parameters:**
  * **record** ([`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]) – the user’s stored record (read for its `password_hash`).
  * **email** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the account the token authorizes a password change for.
  * **signing_key** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – platform HMAC key.
  * **ttl_seconds** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – how long the link stays usable. Clamped to
    [`MAX_TTL_SECONDS`](_autosummary/enlace_auth.auth.reset_tokens.html.md#enlace_auth.auth.reset_tokens.MAX_TTL_SECONDS).
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.reset_tokens.password_fingerprint(record)

Return a short, stable fingerprint of a user’s current password hash.

Embedded in reset tokens so a token stops working the moment the password
changes. That is what makes every link naturally single-use (using it
changes the hash) and what invalidates outstanding links after any other
password change.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.reset_tokens.reset_url(base_url, token)

Return the full `/auth/reset-password` link carrying `token`.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.reset_tokens.verify_reset_token(token, , signing_key, user_store)

Return `(email, record)` for a valid token, else `(None, None)`.

Four independent checks must all pass: the signature, the signature-layer
age ceiling, the expiry embedded in the payload, and that the password
fingerprint still matches the stored hash (the single-use property).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]]


# _autosummary/enlace_auth.auth.revocation.html.md

# enlace_auth.auth.revocation

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

An account’s access outlives a password change in three places, each with its own
lifetime: browser **sessions** (`session_max_age`), OAuth connector
**refresh-token families** (`refresh_family_max_lifetime`), and the per-app
**shared-password cookies** (which are not per account at all – see
[`shared_password_fingerprint()`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.shared_password_fingerprint)). This module is the one place that knows how
to end the first two, so every path that changes an account’s credentials
(admin delete, admin password set, self-service change, reset-link redemption,
the `set-password` CLI) calls a single [`make_on_credentials_changed()`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.make_on_credentials_changed) hook
instead of each remembering its own list.

Kept free of FastAPI/Authlib imports so the CLI can use it without the
`[oauth]` extra.

### Functions

| [`make_on_credentials_changed`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.make_on_credentials_changed)(session_store, \*)    | Return the hook every credential-changing path calls.                       |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|
| [`refresh_tombstone_ttl`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.refresh_tombstone_ttl)(\*, refresh_token_ttl, ...) | How long a family tombstone must live: past every token of the family.      |
| [`revoke_refresh_family`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.revoke_refresh_family)(refresh_store, family, ...) | Revoke one refresh-token family; return how many token records went.        |
| [`revoke_refresh_subject`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.revoke_refresh_subject)(refresh_store, email, ...) | Revoke every refresh family issued to *email*; return how many families.    |
| [`revoked_family_key`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.revoked_family_key)(family)                        | Store key of the tombstone that marks a whole refresh family revoked.       |
| [`subject_marker_key`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.subject_marker_key)(email)                         | Store key of the marker that says "nothing *email* authorized before T".    |
| [`subject_revoked_before`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.subject_revoked_before)(refresh_store, email)      | The time before which every authorization by *email* is revoked, if any.    |
| [`shared_cookie_valid`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.shared_cookie_valid)(value, password_hash, ...)    | True iff a verified shared-cookie *value* was minted under *password_hash*. |
| [`shared_password_fingerprint`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.shared_password_fingerprint)(password_hash, ...)   | A short keyed fingerprint of an app's CURRENT shared-password hash.         |

### Classes

| [`CredentialsChanged`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.CredentialsChanged)(\*args, \*\*kwargs)   | `on_credentials_changed(email, *, keep=None) -> None`.   |
|-------------------------------------------------------------------------------------------|----------------------------------------------------------|

### *class* enlace_auth.auth.revocation.CredentialsChanged(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

`on_credentials_changed(email, *, keep=None) -> None`.

*keep* names one browser session id to spare (the browser that just changed
its own password stays signed in).

### enlace_auth.auth.revocation.make_on_credentials_changed(session_store, , refresh_store=None, code_store=None, tombstone_ttl=0, marker_ttl=0, reason="the account's credentials changed")

Return the hook every credential-changing path calls.

Always revokes the account’s browser sessions (sparing *keep*); when a
*refresh_store* is given, also revokes the account’s connector refresh
families (and unredeemed codes in *code_store*). A failure to revoke
connector families is logged loudly but does not undo the password change,
which has already been written by the caller.

* **Return type:**
  [`CredentialsChanged`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.CredentialsChanged)

### enlace_auth.auth.revocation.refresh_tombstone_ttl(, refresh_token_ttl, refresh_reuse_detection)

How long a family tombstone must live: past every token of the family.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

### enlace_auth.auth.revocation.revoke_refresh_family(refresh_store, family, , reason, tombstone_ttl, now=None)

Revoke one refresh-token family; return how many token records went.

The tombstone is written FIRST: revocation expressed only as the absence of
records loses to a worker concurrently rotating the family (its successor is
written after our scan). A positive marker cannot be raced – the refresh
grant refuses any record whose family carries one.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

### enlace_auth.auth.revocation.revoke_refresh_subject(refresh_store, email, , reason, tombstone_ttl, code_store=None, marker_ttl=0, now=None)

Revoke every refresh family issued to *email*; return how many families.

Matches the subject case-insensitively. First writes a subject marker (see
[`subject_revoked_before()`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.subject_revoked_before)) that lives *marker_ttl* seconds – give it
the family max lifetime – so authorizations racing this call are refused
too. Then tombstones each existing family and drops the subject’s
unredeemed authorization codes from *code_store* when given. Access JWTs
already issued are self-contained and live out their (short) TTL.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

### enlace_auth.auth.revocation.revoked_family_key(family)

Store key of the tombstone that marks a whole refresh family revoked.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.revocation.shared_cookie_valid(value, password_hash, signing_key)

True iff a verified shared-cookie *value* was minted under *password_hash*.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### enlace_auth.auth.revocation.shared_password_fingerprint(password_hash, signing_key)

A short keyed fingerprint of an app’s CURRENT shared-password hash.

Signed into the `shared_auth_<app>` cookie and compared by the middleware,
so rotating the shared password invalidates every cookie minted under the
old one. Keyed with *signing_key* so the cookie (whose payload is readable,
only signed) reveals nothing about the hash.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.revocation.subject_marker_key(email)

Store key of the marker that says “nothing *email* authorized before T”.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.auth.revocation.subject_revoked_before(refresh_store, email)

The time before which every authorization by *email* is revoked, if any.

A scan-and-delete revocation cannot see a family that another worker is
creating at that very moment (its code already consumed, its first refresh
record not yet written), nor a code issued from a session read just before
the change. The marker closes both: the code grant refuses codes issued at
or before it, and the refresh grant refuses families authorized at or
before it.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`int`](https://docs.python.org/3/builtins/functions.html#int)]


# _autosummary/enlace_auth.auth.routes.html.md

# enlace_auth.auth.routes

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

This router serves both the JSON API (`POST /auth/login` etc., consumed by
app frontends) and the browser-facing HTML pages (`GET /auth/login`,
`GET /auth/account`, the password-recovery screens) so the platform is usable
from a bare URL with no app frontend in the way.

The three ways a password can be set, and who can use each:

- `GET /auth/account` — a signed-in user changing their own.
- `GET /auth/forgot-password` — a locked-out user, *if* SMTP is configured.
- `enlace-auth reset-link` — an admin, handing a link over out-of-band.

The last exists because the middle one needs a mail server. Without one the
forgot-password page says so plainly instead of promising an email that is only
written to the log.

OAuth routes live in `enlace.auth.oauth` and are attached separately so the
Authlib dependency stays lazy.

### Functions

| [`make_auth_router`](_autosummary/enlace_auth.auth.routes.html.md#enlace_auth.auth.routes.make_auth_router)(\*, session_store, ...[, ...])   | Build a FastAPI router exposing `/auth/*` endpoints.   |
|----------------------------------------------------------------------------------------------------|--------------------------------------------------------|

### enlace_auth.auth.routes.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)

Build a FastAPI router exposing `/auth/*` endpoints.

* **Parameters:**
  * **user_store** (*session_store /*) – backing stores.
  * **signing_key** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – HMAC key for session, CSRF, shared, and reset tokens.
  * **secure_cookies** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – session-cookie policy.
  * **shared_password_for** ([`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]) – maps an app id to its shared-password hash.
  * **can_register** ([`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`bool`](https://docs.python.org/3/builtins/functions.html#bool)]) – predicate gating self-registration by email.
  * **send_email** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`EmailSender`](_autosummary/enlace_auth.auth.email.html.md#enlace_auth.auth.email.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`](https://docs.python.org/3/builtins/functions.html#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`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#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`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`CredentialsChanged`](_autosummary/enlace_auth.auth.revocation.html.md#enlace_auth.auth.revocation.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`


# _autosummary/enlace_auth.auth.sessions.html.md

# enlace_auth.auth.sessions

Session storage backed by a MutableMapping.

A session is `{"user_id": str, "email": str | None, "created_at": float}`.
Session IDs are 32-byte urlsafe tokens. Revocation is a simple delete.

Records are otherwise only deleted by logout, so given a *max_age* the store
sweeps records older than it, a bounded batch at a time, when a session is
created (at most once per *sweep_interval* per process) – keeping the
store (and [`SessionStore.revoke_user()`](_autosummary/enlace_auth.auth.sessions.html.md#enlace_auth.auth.sessions.SessionStore.revoke_user)’s scan) from growing without bound.

### Classes

| [`SessionStore`](_autosummary/enlace_auth.auth.sessions.html.md#enlace_auth.auth.sessions.SessionStore)(store, \*[, max_age, ...])   | Thin adapter around a MutableMapping that speaks session semantics.   |
|--------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|

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

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Thin adapter around a MutableMapping that speaks session semantics.

#### last_sign_in(user)

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

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]

#### revoke_user(user, , keep=None)

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`](https://docs.python.org/3/builtins/functions.html#int)

#### sweep_expired(, now=None)

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`](https://docs.python.org/3/builtins/functions.html#int)


# _autosummary/enlace_auth.auth.share_routes.html.md

# enlace_auth.auth.share_routes

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

The owner side of a share is always the signed-in user: there is no route by which
a grantee can grant onward, or anyone can share someone else’s data (an admin does
that through `/_admin/api/shares` or the CLI). These routes live under `/auth/`,
so the platform’s double-submit CSRF check covers every write.

- `GET    /auth/shares/{app_id}` → `{"owner", "granted": [...], "received": [...]}`
- `PUT    /auth/shares/{app_id}/{grantee}`
  (body `{"access"?, "label"?, "expires_at"?}`)
- `DELETE /auth/shares/{app_id}/received/{owner}` — a grantee leaves a share
- `DELETE /auth/shares/{app_id}/{grantee}` — the owner revokes one

See `misc/docs/decisions/0001-owner-granted-data-shares.md`.

### Functions

| [`make_share_router`](_autosummary/enlace_auth.auth.share_routes.html.md#enlace_auth.auth.share_routes.make_share_router)(\*, share_store, store_apps)   | Build the `/auth/shares` router.   |
|---------------------------------------------------------------------------------------------------|------------------------------------|

### enlace_auth.auth.share_routes.make_share_router(, share_store, store_apps)

Build the `/auth/shares` router.

`store_apps` names the apps that have a per-user store (`protected:user`
apps and `user_store = true` apps); a share in any other app is refused,
since there would be nothing to share. A callable is re-read per request.

* **Return type:**
  `APIRouter`


# _autosummary/enlace_auth.auth.shares.html.md

# enlace_auth.auth.shares

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

Grants ([`enlace_auth.auth.grants`](_autosummary/enlace_auth.auth.grants.html.md#module-enlace_auth.auth.grants)) answer *may this user open this app?* A
**share** answers a different question: \*may this user act on that user’s data in
this app?\* A share is `(app_id, owner, grantee)` plus an access level, and the
per-user store ([`enlace_auth.stores.middleware`](_autosummary/enlace_auth.stores.middleware.html.md#module-enlace_auth.stores.middleware)) honours it when a request
names an owner with `?owner=`. The design, and why it is shaped this way, is
`misc/docs/decisions/0001-owner-granted-data-shares.md`.

Storage mirrors [`GrantStore`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.GrantStore): a thin adapter over a
`MutableMapping` whose keys are `"{app_id}/{owner}/{grantee}"` and whose values
are JSON records:

```default
{
    "app_id": str,
    "owner": str,              # normalized email
    "grantee": str,            # normalized email
    "access": "rw" | "ro",     # "ro" = read-only (GET)
    "label": str | None,       # how the grantee sees this space
    "granted_at": float,       # epoch seconds, UTC
    "granted_by": str | None,  # who created it (the owner, or an admin)
    "expires_at": float | None,  # epoch seconds, UTC; None = never
}
```

Two rules the store enforces, so no caller can forget them:

- **A share names existing accounts only** (when `account_exists` is given). A
  share to an address nobody holds would be claimed by whoever registers it next.
- **A share dies with either account**: [`ShareStore.remove_account()`](_autosummary/enlace_auth.auth.shares.html.md#enlace_auth.auth.shares.ShareStore.remove_account) deletes
  every share an email is part of, and account deletion calls it.

### Module Attributes

| [`ACCESS_LEVELS`](_autosummary/enlace_auth.auth.shares.html.md#enlace_auth.auth.shares.ACCESS_LEVELS)   | read-write, or read-only (GET).                                                  |
|------------------------------------------------------------------|----------------------------------------------------------------------------------|
| [`MAX_LABEL`](_autosummary/enlace_auth.auth.shares.html.md#enlace_auth.auth.shares.MAX_LABEL)       | The longest `label` a share may carry (the grantee sees it as the space's name). |

### Classes

| [`ShareStore`](_autosummary/enlace_auth.auth.shares.html.md#enlace_auth.auth.shares.ShareStore)(backend, \*[, root, account_exists])   | Share semantics over a `MutableMapping`.   |
|----------------------------------------------------------------------------------------------------|--------------------------------------------|

### Exceptions

| [`ShareError`](_autosummary/enlace_auth.auth.shares.html.md#enlace_auth.auth.shares.ShareError)(message, \*[, code])   | An invalid share.   |
|------------------------------------------------------------------------------------|---------------------|

### enlace_auth.auth.shares.ACCESS_LEVELS *= ('rw', 'ro')*

read-write, or read-only (GET).

* **Type:**
  The access levels a share may carry

### enlace_auth.auth.shares.MAX_LABEL *= 80*

The longest `label` a share may carry (the grantee sees it as the space’s name).

### *exception* enlace_auth.auth.shares.ShareError(message, , code='invalid')

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

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

### *class* enlace_auth.auth.shares.ShareStore(backend, , root=None, account_exists=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Share semantics over a `MutableMapping`.

* **Parameters:**
  * **backend** ([`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)) – the per-name store (e.g. `factory("shares")`).
  * **root** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.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`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`bool`](https://docs.python.org/3/builtins/functions.html#bool)]]) – `email -> bool`. When given, [`share()`](_autosummary/enlace_auth.auth.shares.html.md#enlace_auth.auth.shares.ShareStore.share) refuses an
    owner or grantee with no account. `None` skips the check (tests).

#### access(app_id, owner, grantee, , now=None)

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

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

#### granted(app_id, owner, , now=None)

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

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### list_all()

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`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### received(app_id, grantee, , now=None)

The **active** shares made to `grantee` in `app_id`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### remove_account(email)

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`](https://docs.python.org/3/builtins/functions.html#int)

#### revoke(app_id, owner, grantee, , widen_owner=False, widen_grantee=False, first_only=False)

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()`](_autosummary/enlace_auth.auth.shares.html.md#enlace_auth.auth.shares.ShareStore.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`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

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

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()`](_autosummary/enlace_auth.auth.grants.html.md#enlace_auth.auth.grants.parse_expires_at) first.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/enlace_auth.config.html.md

# enlace_auth.config

Auth-side configuration models.

These were originally in `enlace.base`. They have moved here because they
describe the auth subsystem’s behavior, not the platform’s composition. enlace
itself no longer imports anything from here; `enlace_auth.plugin` reads them
off `PlatformConfig` via `getattr(config, "auth", None)` and the loose
`stores` mapping.

The TOML loader in `enlace.base.PlatformConfig.from_toml` accepts
`[auth.*]` and `[stores.*]` tables and forwards them through the model;
we just deserialize them into typed objects here when the plugin runs.

### Functions

| [`coerce_auth_config`](_autosummary/enlace_auth.config.html.md#enlace_auth.config.coerce_auth_config)(raw)   | Accept dict / AuthConfig / None and return an AuthConfig.      |
|----------------------------------------------------------------------------|----------------------------------------------------------------|
| [`coerce_stores_map`](_autosummary/enlace_auth.config.html.md#enlace_auth.config.coerce_stores_map)(raw)    | Accept the loose `config.stores` dict and yield typed configs. |

### Classes

| [`AuthConfig`](_autosummary/enlace_auth.config.html.md#enlace_auth.config.AuthConfig)(\*[, enabled, ...])                     | Platform-wide authentication configuration.                   |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------|
| [`OAuthProviderConfig`](_autosummary/enlace_auth.config.html.md#enlace_auth.config.OAuthProviderConfig)(\*, client_id_env, ...[, ...]) | Configuration for a single OAuth2/OIDC provider.              |
| [`OAuthServerConfig`](_autosummary/enlace_auth.config.html.md#enlace_auth.config.OAuthServerConfig)(\*[, enabled, issuer, ...])      | OAuth 2.1 authorization-server settings (for MCP connectors). |
| [`StoreBackendConfig`](_autosummary/enlace_auth.config.html.md#enlace_auth.config.StoreBackendConfig)(\*[, backend, path])            | Backend configuration for a MutableMapping-backed store.      |

### *class* enlace_auth.config.AuthConfig(\*, enabled=False, session_cookie_name='enlace_session', session_max_age_seconds=86400, signing_key_env='ENLACE_SIGNING_KEY', secure_cookies=True, stores=<factory>, oauth=<factory>, oauth_server=<factory>, admin_emails=<factory>, admin_emails_env='ENLACE_ADMIN_EMAILS', registration_open=False, registration_allowlist_env='ENLACE_REGISTRATION_ALLOWLIST')

Bases: `_StrictModel`

Platform-wide authentication configuration.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* enlace_auth.config.OAuthProviderConfig(\*, client_id_env, client_secret_env, scopes=<factory>, authorize_url=None, token_url=None, userinfo_url=None, server_metadata_url=None, trust_unverified_email=False)

Bases: `_StrictModel`

Configuration for a single OAuth2/OIDC provider.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* enlace_auth.config.OAuthServerConfig(\*, enabled=False, issuer=None, key_dir='~/.enlace/oauth_keys', access_token_ttl_seconds=3600, refresh_token_ttl_seconds=2592000, refresh_reuse_grace_seconds=60, refresh_reuse_detection_seconds=86400, refresh_family_max_lifetime_seconds=7776000, client_ttl_seconds=15552000, code_ttl_seconds=120, consent_ttl_seconds=600, scopes_supported=<factory>, require_consent=True, resource_allowlist=<factory>, allow_unlisted_resources=False, resource_display_names=<factory>)

Bases: `_StrictModel`

OAuth 2.1 authorization-server settings (for MCP connectors).

When `enabled`, `enlace_auth` issues signed JWT access tokens that a
Claude.ai custom connector validates — reusing the platform user store and
session login. See [`enlace_auth.auth.oauth_server`](_autosummary/enlace_auth.auth.oauth_server.html.md#module-enlace_auth.auth.oauth_server).

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* enlace_auth.config.StoreBackendConfig(, backend='file', path='~/.enlace/platform_store')

Bases: `_StrictModel`

Backend configuration for a MutableMapping-backed store.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### enlace_auth.config.coerce_auth_config(raw)

Accept dict / AuthConfig / None and return an AuthConfig.

Used by the plugin so it can read whatever `PlatformConfig` happens to
hold without forcing enlace to know about `AuthConfig` itself.

* **Return type:**
  [`AuthConfig`](_autosummary/enlace_auth.config.html.md#enlace_auth.config.AuthConfig)

### enlace_auth.config.coerce_stores_map(raw)

Accept the loose `config.stores` dict and yield typed configs.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`StoreBackendConfig`](_autosummary/enlace_auth.config.html.md#enlace_auth.config.StoreBackendConfig)]


# _autosummary/enlace_auth.diagnostics.html.md

# enlace_auth.diagnostics

Auth-specific doctor checks that plug into `enlace.doctor.run_doctor`.

Usage:

```default
from enlace.doctor import run_doctor
from enlace_auth.diagnostics import static_checks, http_checks

report = run_doctor(
    config,
    base_url="http://localhost:8000",
    extra_static_checks=static_checks,
    extra_http_checks=http_checks,
)
```

These checks were previously hard-wired into `enlace.doctor` itself; they
moved here when auth was extracted from enlace.

### Functions

| [`check_admin_accounts`](_autosummary/enlace_auth.diagnostics.html.md#enlace_auth.diagnostics.check_admin_accounts)(config)               | Every configured admin address should already have an account.             |
|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| [`check_auth_config`](_autosummary/enlace_auth.diagnostics.html.md#enlace_auth.diagnostics.check_auth_config)(config)                  | Report an `[auth]` table the plugin would refuse at startup.               |
| [`check_connector_session_longevity`](_autosummary/enlace_auth.diagnostics.html.md#enlace_auth.diagnostics.check_connector_session_longevity)(config)  | Can a connector survive its own access-token expiry unattended?            |
| [`check_csrf`](_autosummary/enlace_auth.diagnostics.html.md#enlace_auth.diagnostics.check_csrf)(config, base_url, timeout)      | GET /auth/csrf must return JSON with a 'csrf' key.                         |
| `check_oauth_importable`(config)                                                            |                                                                            |
| [`check_oauth_server_advertises_refresh`](_autosummary/enlace_auth.diagnostics.html.md#enlace_auth.diagnostics.check_oauth_server_advertises_refresh)(...) | The live server must offer refresh_token in its discovery metadata.        |
| `check_shared_passwords`(config)                                                            |                                                                            |
| `check_signing_key`(config)                                                                 |                                                                            |
| [`check_widened_spellings`](_autosummary/enlace_auth.diagnostics.html.md#enlace_auth.diagnostics.check_widened_spellings)(config)            | Warn about accounts a written list admits only through the other spelling. |

### enlace_auth.diagnostics.check_admin_accounts(config)

Every configured admin address should already have an account.

Self-registration cannot prove who owns an address, so an admin address
with no account is one anyone could claim the moment registration admits it
(open registration, or an allowlist that lists it), and that account would
pass every `@admins` gate (i2mint/enlace_auth#35).

* **Return type:**
  [`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[`Check`]

### enlace_auth.diagnostics.check_auth_config(config)

Report an `[auth]` table the plugin would refuse at startup.

Unknown keys and a malformed or missing OAuth resource allowlist are errors
(see [`enlace_auth.resource_policy`](_autosummary/enlace_auth.resource_policy.html.md#module-enlace_auth.resource_policy)), so this is the one check that says
why; every other check skips a config that does not load.

* **Return type:**
  [`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[`Check`]

### enlace_auth.diagnostics.check_connector_session_longevity(config)

Can a connector survive its own access-token expiry unattended?

THE check for the failure this exists to prevent: an authorization server
that issues short access tokens and no refresh token strands every MCP
connector the moment the first token expires. Nothing errors — the connector
process stays healthy and the endpoint keeps answering — it just returns 401
forever until a human re-runs the browser authorization. In production that
read as “the connector has been down all day” and took a user complaint to
surface, because expiry is logged at INFO on the connector, not here.

* **Return type:**
  [`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[`Check`]

### enlace_auth.diagnostics.check_csrf(config, base_url, timeout)

GET /auth/csrf must return JSON with a ‘csrf’ key.

This is THE check that catches the i2mint/enlace#11 regression: when auth
is silently disabled, the SPA catch-all returns `<!doctype html>`
instead of JSON.

* **Return type:**
  [`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[`Check`]

### enlace_auth.diagnostics.check_oauth_server_advertises_refresh(config, base_url, timeout)

The live server must offer refresh_token in its discovery metadata.

Static config can be right while the deployed build is an older release that
has no refresh support at all — exactly the drift that caused the outage
(config and code were fine locally; the installed wheel was what served
requests). This asks the running server what it actually advertises.

* **Return type:**
  [`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[`Check`]

### enlace_auth.diagnostics.check_widened_spellings(config)

Warn about accounts a written list admits only through the other spelling.

Written lists (an app’s `allowed_users`, grants, explicit `app_meta`
editors, a connector’s `resource_allowlist`) admit both canonical spellings
of an entry (#45), but `/auth/register` proves nothing about who owns an
address. Two cases follow (#52):

- an entry whose other spelling has an account that was self-registered (or
  whose origin is not recorded), whether or not the written spelling has one
  too: possibly a squatter, registered before registration refused the other
  spelling of an existing account;
- with `registration_open`, an entry written in punycode with no account in
  either spelling: anyone can register its Unicode spelling (which is how
  registration keys it) and be admitted.

The admin list and the registration allowlist compare as written, so they
are not read here.

* **Return type:**
  [`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[`Check`]


# _autosummary/enlace_auth.emails.html.md

# enlace_auth.emails

One normal form for an email address, wherever one is compared.

Accounts are keyed by the address the HTTP routes accept (pydantic `EmailStr`,
then lowercased): `EmailStr` decodes a punycode domain to Unicode, so
`admin@xn--bcher-kva.example` is stored as `admin@bücher.example`. Every list
an address is compared against (the admin list, the registration allowlist, a
connector’s resource allowlist) must be put in the same form, or an entry
spelled the other way silently never matches.

One fold is refused rather than applied: `EmailStr` NFC-composes the local
part, but a mail system may deliver a composed and a decomposed local part to two
mailboxes, which one key would merge (#51). [`normalize_email()`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.normalize_email) refuses a
local part that composing would change. So is one that lowercasing would
change by more than case (`İ` -> `i̇`, `ẞ` -> `ß`, #57).

Two rules keep that safe. A written list entry is widened to both canonical
spellings of its address ([`account_spellings()`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.account_spellings)); the address an account is
keyed under, which may come from an untrusted party (an OAuth provider), is
compared exactly as stored and never normalized.

### Functions

| [`account_spellings`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.account_spellings)(email)       | The keys an account for a written *email* may be stored under, as written first.   |
|---------------------------------------------------------------------------------|------------------------------------------------------------------------------------|
| [`bare_address_problem`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.bare_address_problem)(email)    | Why *email* is not a bare `local@domain` address, or `None`.                       |
| [`local_part_problem`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.local_part_problem)(email)      | Why keying *email* would change its local part's characters, or `None`.            |
| [`lowercase_changes_letter`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.lowercase_changes_letter)(text) | The first character of *text* that lowercasing changes by more than case.          |
| [`normalize_email`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.normalize_email)(email)         | The key an account for *email* is stored under; `ValueError` if invalid.           |
| [`resolve_account`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.resolve_account)(email, exists) | The existing account key a written *email* names, or `None`.                       |

### enlace_auth.emails.account_spellings(email)

The keys an account for a written *email* may be stored under, as written first.

A punycode domain and its Unicode form are one DNS name, so they name one
mailbox, but an account is keyed in whichever spelling made it: `add-user`
and `/auth/register` store the Unicode form, an OAuth sign-in stores the
provider’s spelling, lowercased. A list entry written in either canonical
spelling therefore admits both.

```pycon
>>> account_spellings("Ann@XN--BCHER-KVA.example")
('ann@xn--bcher-kva.example', 'ann@bücher.example')
>>> account_spellings("ann@bücher.example")
('ann@bücher.example', 'ann@xn--bcher-kva.example')
>>> account_spellings("ann@corp.local")  # a domain EmailStr refuses
('ann@corp.local',)
```

Only an entry already in one of the two canonical spellings gets the other.
Normalizing also folds look-alikes (fullwidth letters, the Kelvin sign) onto
plain ASCII, so an entry that only becomes canonical by normalizing stays as
written: it must not name someone else’s account. That makes this safe to
apply to any written list, an untrusted user’s included. It is never applied
to the address an account is keyed under.

```pycon
>>> account_spellings("ann@ｂücher.example")  # fullwidth b
('ann@ｂücher.example',)
```

Lowercasing alone folds a few letters (U+212A KELVIN SIGN -> `k`); such an
entry is not widened either. Over-long strings are not looked at (nor cached).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### enlace_auth.emails.bare_address_problem(email)

Why *email* is not a bare `local@domain` address, or `None`.

Deliberately lenient about the domain (`.local`, `localhost`): it only
refuses what is not one plain address, such as the display-name form.

```pycon
>>> bare_address_problem("ann@corp.local") is None
True
>>> bare_address_problem("ann <mallory@evil.com>")
'not a bare email address'
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### enlace_auth.emails.local_part_problem(email)

Why keying *email* would change its local part’s characters, or `None`.

`EmailStr` NFC-composes the local part. Mail systems that compare local
parts as bytes (a Postfix/Dovecot host with `SMTPUTF8`, for one) deliver
`josé` composed and `josé` decomposed to two mailboxes, so composing
would map both onto one account (#51). A decomposed local part is refused,
not folded, as an OAuth callback’s address is never folded.

```pycon
>>> local_part_problem("josé@example.com") is None
True
>>> local_part_problem("josé@example.com")
'the part before @ is not in composed (NFC) form (U+0301); retype the address'
```

The two spellings look identical, so the message names the first character
to blame.

Keys are also lowercased, and for a few letters that changes the letter, not
only its case (#57): `İ` becomes `i` plus a combining dot, `ẞ` becomes
`ß`. A host that compares local parts as bytes reads another mailbox, and a
password reset is mailed to the key. Such a local part is refused too.

```pycon
>>> local_part_problem("Ann@example.com") is None
True
>>> local_part_problem("İstanbul@example.com")
'the part before @ changes letters when lowercased (U+0130); retype the address'
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### enlace_auth.emails.lowercase_changes_letter(text)

The first character of *text* that lowercasing changes by more than case.

A case map is one character for one, and goes back: `"A".lower().upper()`
is `"A"` (`.title()` for a titlecase digraph such as `ǅ`). `İ`
lowercases to two characters, `ẞ` to `ß` (which uppercases to `SS`),
the Kelvin sign to `k` (which uppercases to `K`).

```pycon
>>> lowercase_changes_letter("Ann-Marie") is None
True
>>> [hex(ord(lowercase_changes_letter(s))) for s in ("İ", "ẞ", "K")]
['0x130', '0x1e9e', '0x212a']
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### enlace_auth.emails.normalize_email(email)

The key an account for *email* is stored under; `ValueError` if invalid.

```pycon
>>> normalize_email("  Admin@XN--BCHER-KVA.example ")
'admin@bücher.example'
>>> normalize_email("A@Example.COM")
'a@example.com'
```

Only a bare address is accepted. `EmailStr` also parses the display-name
form, so `ann <mallory@evil.com>` would otherwise normalize to an address
the text does not appear to name; in a list that grants access, that is a
grant to someone other than the one written down.

```pycon
>>> normalize_email("ann <mallory@evil.com>")
Traceback (most recent call last):
...
ValueError: not a bare email address: 'ann <mallory@evil.com>'
```

Nor is a local part that composing would change (see
[`local_part_problem()`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.local_part_problem)): the key would name another mailbox’s spelling.

```pycon
>>> normalize_email("josé@example.com")
Traceback (most recent call last):
...
ValueError: the part before @ is not in composed (NFC) form...
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.emails.resolve_account(email, exists)

The existing account key a written *email* names, or `None`.

Tries [`account_spellings()`](_autosummary/enlace_auth.emails.html.md#enlace_auth.emails.account_spellings) in order, so an exact match wins over the
other spelling. *exists* is `key -> bool` (e.g. `users.__contains__`).

```pycon
>>> users = {"ann@bücher.example"}
>>> resolve_account("Ann@XN--BCHER-KVA.example", users.__contains__)
'ann@bücher.example'
>>> resolve_account("bob@example.com", set().__contains__) is None
True
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)


# _autosummary/enlace_auth.html.md

# enlace_auth

enlace_auth — authentication, sessions, admin dashboard, and per-user stores.

Plug into `enlace` at compose time:

```default
from enlace import build_backend, PlatformConfig
from enlace_auth import plugin as auth_plugin

config = PlatformConfig.from_toml()
app = build_backend(config, plugins=[auth_plugin])
```

When `config.auth.enabled` is True the plugin mounts:

- `/auth/*`      — login, logout, register, whoami, csrf, me/password,
  account (change your own), forgot-password / reset-password
- `/_admin/api/*` — admin user/app management (gated by admin allowlist)
- `/api/{app}/store/*` — per-user data store
- middleware: PlatformAuthMiddleware, CSRFMiddleware, StoreInjectionMiddleware

When it’s False the plugin is a no-op, so installing this package never
changes platform behavior unless the operator opts in.

### Functions

| [`plugin`](_autosummary/enlace_auth.html.md#enlace_auth.plugin)(parent, config)   | Mount /auth/ *, /_admin/*, store routes, and middleware on `parent`.   |
|---------------------------------------------------------------------------|------------------------------------------------------------------------|
| [`wire`](_autosummary/enlace_auth.html.md#enlace_auth.wire)(parent, config)     | Mount /auth/ *, /_admin/*, store routes, and middleware on `parent`.   |

### Classes

| [`AuthConfig`](_autosummary/enlace_auth.html.md#enlace_auth.AuthConfig)(\*[, enabled, ...])                     | Platform-wide authentication configuration.              |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------|
| [`OAuthProviderConfig`](_autosummary/enlace_auth.html.md#enlace_auth.OAuthProviderConfig)(\*, client_id_env, ...[, ...]) | Configuration for a single OAuth2/OIDC provider.         |
| [`StoreBackendConfig`](_autosummary/enlace_auth.html.md#enlace_auth.StoreBackendConfig)(\*[, backend, path])            | Backend configuration for a MutableMapping-backed store. |

### Exceptions

| [`EnlaceAuthConfigError`](_autosummary/enlace_auth.html.md#enlace_auth.EnlaceAuthConfigError)   | Raised when auth configuration is unusable at startup.   |
|--------------------------------------------------------------------------|----------------------------------------------------------|

### *class* enlace_auth.AuthConfig(\*, enabled=False, session_cookie_name='enlace_session', session_max_age_seconds=86400, signing_key_env='ENLACE_SIGNING_KEY', secure_cookies=True, stores=<factory>, oauth=<factory>, oauth_server=<factory>, admin_emails=<factory>, admin_emails_env='ENLACE_ADMIN_EMAILS', registration_open=False, registration_allowlist_env='ENLACE_REGISTRATION_ALLOWLIST')

Bases: `_StrictModel`

Platform-wide authentication configuration.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *exception* enlace_auth.EnlaceAuthConfigError

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

Raised when auth configuration is unusable at startup.

### *class* enlace_auth.OAuthProviderConfig(\*, client_id_env, client_secret_env, scopes=<factory>, authorize_url=None, token_url=None, userinfo_url=None, server_metadata_url=None, trust_unverified_email=False)

Bases: `_StrictModel`

Configuration for a single OAuth2/OIDC provider.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* enlace_auth.StoreBackendConfig(, backend='file', path='~/.enlace/platform_store')

Bases: `_StrictModel`

Backend configuration for a MutableMapping-backed store.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### enlace_auth.plugin(parent, config)

Mount /auth/ *, /_admin/*, store routes, and middleware on `parent`.

Plugin entry point. Safe to call when auth is disabled — does nothing.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### enlace_auth.wire(parent, config)

Mount /auth/ *, /_admin/*, store routes, and middleware on `parent`.

Plugin entry point. Safe to call when auth is disabled — does nothing.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### Modules

| [`admin`](_autosummary/enlace_auth.admin.html.md#module-enlace_auth.admin)                     | Admin subsystem for enlace_auth.                                            |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|
| [`appmeta`](_autosummary/enlace_auth.appmeta.html.md#module-enlace_auth.appmeta)                 | Editable app-metadata overlay: the write surface for the launcher.          |
| [`auth`](_autosummary/enlace_auth.auth.html.md#module-enlace_auth.auth)                       | Authentication subsystem for enlace.                                        |
| [`config`](_autosummary/enlace_auth.config.html.md#module-enlace_auth.config)                   | Auth-side configuration models.                                             |
| [`diagnostics`](_autosummary/enlace_auth.diagnostics.html.md#module-enlace_auth.diagnostics)         | Auth-specific doctor checks that plug into `enlace.doctor.run_doctor`.      |
| [`emails`](_autosummary/enlace_auth.emails.html.md#module-enlace_auth.emails)                   | One normal form for an email address, wherever one is compared.             |
| [`resource_policy`](_autosummary/enlace_auth.resource_policy.html.md#module-enlace_auth.resource_policy) | The OAuth per-connector allowlist: its shape, its parsing and its one rule. |
| [`stores`](_autosummary/enlace_auth.stores.html.md#module-enlace_auth.stores)                   | Per-user stores for enlace.                                                 |


# _autosummary/enlace_auth.resource_policy.html.md

# enlace_auth.resource_policy

The OAuth per-connector allowlist: its shape, its parsing and its one rule.

`[auth.oauth_server.resource_allowlist]` maps a connector’s resource URL to the
emails allowed to authorize for it. The authorization server enforces it
([`enlace_auth.auth.oauth_server`](_autosummary/enlace_auth.auth.oauth_server.html.md#module-enlace_auth.auth.oauth_server)), the config model validates it
([`enlace_auth.config`](_autosummary/enlace_auth.config.html.md#module-enlace_auth.config)), and the admin dashboard describes it
([`enlace_auth.admin.routes`](_autosummary/enlace_auth.admin.routes.html.md#module-enlace_auth.admin.routes)). All three go through [`parse_resource_allowlist()`](_autosummary/enlace_auth.resource_policy.html.md#enlace_auth.resource_policy.parse_resource_allowlist)
here, so they cannot disagree about what a key means or what a value may hold.

The rule is **fail closed, and fail loudly on anything malformed**:

- A listed resource admits exactly its listed emails; `["*"]`
  ([`ANY_ACCOUNT`](_autosummary/enlace_auth.resource_policy.html.md#enlace_auth.resource_policy.ANY_ACCOUNT)), on its own, admits every authenticated account.
- A key must be the resource URL exactly as a connector spells its audience
  (lowercase scheme and host, no default port, no `//`, `.` or `..`), since
  a key in any other spelling can never match one.
- An unlisted resource is refused to everyone.
- A server with no allowlist at all would admit everyone to every resource. That
  is never the result of a typo: it has to be asked for with
  `allow_unlisted_resources = true`, and an allowlist that is present but
  malformed (a key that is not a URL, a value that is not a list of emails, a
  wildcard pattern, two keys that differ only by a trailing slash) is an error,
  not a guess.

The authorization server’s other config-time refusal lives here for the same
reason: `check_require_consent()` refuses to skip the consent screen, because
client registration is open to anyone (i2mint/enlace_auth#46).

Pure functions, no web or crypto imports, so the config model can use them.

### Module Attributes

| [`ANY_ACCOUNT`](_autosummary/enlace_auth.resource_policy.html.md#enlace_auth.resource_policy.ANY_ACCOUNT)   | A `resource_allowlist` entry that opens a resource to every authenticated account.   |
|----------------------------------------------------------------|--------------------------------------------------------------------------------------|

### Functions

| [`normalize_resource`](_autosummary/enlace_auth.resource_policy.html.md#enlace_auth.resource_policy.normalize_resource)(resource)             | Canonical form of a resource URL: the key both maps use, and the token `aud`.   |
|-------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| [`parse_resource_allowlist`](_autosummary/enlace_auth.resource_policy.html.md#enlace_auth.resource_policy.parse_resource_allowlist)(raw, \*[, ...]) | Validate *raw* and return `{normalized resource: lowercased emails}`.           |
| [`parse_resource_display_names`](_autosummary/enlace_auth.resource_policy.html.md#enlace_auth.resource_policy.parse_resource_display_names)(raw)        | Validate *raw* and return `{normalized resource: display name}`.                |

### enlace_auth.resource_policy.ANY_ACCOUNT *= '\*'*

A `resource_allowlist` entry that opens a resource to every authenticated
account. Must be written out: an unlisted resource is refused (fail-closed).

### enlace_auth.resource_policy.normalize_resource(resource)

Canonical form of a resource URL: the key both maps use, and the token `aud`.

```pycon
>>> normalize_resource("https://apps.example.com/api/x_mcp/mcp/")
'https://apps.example.com/api/x_mcp/mcp'
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.resource_policy.parse_resource_allowlist(raw, , allow_unlisted_resources=False)

Validate *raw* and return `{normalized resource: lowercased emails}`.

Raises `ValueError` naming the offending entry for anything that is not
exactly the documented shape, and for an empty allowlist unless
*allow_unlisted_resources* says an open server is intended. Setting that flag
alongside a non-empty allowlist is also an error: the allowlist always refuses
unlisted resources, so the flag would promise something that does not happen.

```pycon
>>> allowlist = parse_resource_allowlist(
...     {"https://a.example/api/x/mcp/": ["Ann@Example.com", "bob@example.com"]}
... )
>>> {k: sorted(v) for k, v in allowlist.items()}
{'https://a.example/api/x/mcp': ['ann@example.com', 'bob@example.com']}
>>> parse_resource_allowlist({}, allow_unlisted_resources=True)
{}
```

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`frozenset`](https://docs.python.org/3/builtins/stdtypes.html#frozenset)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

### enlace_auth.resource_policy.parse_resource_display_names(raw)

Validate *raw* and return `{normalized resource: display name}`.

Keys follow the same rule as the allowlist’s, so a key that would never match
a resource is reported rather than silently showing generic consent copy.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]


# _autosummary/enlace_auth.stores.backends.html.md

# enlace_auth.stores.backends

MutableMapping-backed store factories for enlace.

The file backend is the MVP default: one directory per named store under a
platform root (`~/.enlace/platform_store/` by default). Values are JSON.

When `dol` is installed (via `enlace[auth]`), `make_file_store_factory`
uses `dol.Files` + a JSON codec. When it isn’t, we fall back to a tiny
stdlib implementation so the core package keeps working.

### Functions

| [`make_file_store_factory`](_autosummary/enlace_auth.stores.backends.html.md#enlace_auth.stores.backends.make_file_store_factory)(root, \*[, use_dol])   | Return a `StoreFactory` backed by JSON files under `root`.   |
|-------------------------------------------------------------------------------------------------|--------------------------------------------------------------|

### enlace_auth.stores.backends.make_file_store_factory(root, , use_dol=False)

Return a `StoreFactory` backed by JSON files under `root`.

`factory(name)` returns a `MutableMapping` rooted at `root/name/`.

Defaults to a small stdlib implementation that auto-creates parent
directories on write. Pass `use_dol=True` to use `dol.Files` instead
(pulls in the soft dep and expects flat keys).

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)]


# _autosummary/enlace_auth.stores.html.md

# enlace_auth.stores

Per-user stores for enlace.

Apps never import from this package. Apps read `request.state.store` — a
`MutableMapping` scoped to `{user_id}/{app_id}/` via `PrefixedStore`.

Public helpers:

- `PrefixedStore` — wraps any MutableMapping with a key prefix.
- `sanitize_key` — path-traversal guard for user-supplied keys.
- `make_file_store_factory` — file-backed MutableMapping factory.
- `StoreInjectionMiddleware` — pure-ASGI middleware that injects
  `scope["state"]["store"]` based on `user_id` and `app_id`.
- `make_store_router` — FastAPI router for `/api/{app_id}/store/{key}`.

### Functions

| [`make_file_store_factory`](_autosummary/enlace_auth.stores.html.md#enlace_auth.stores.make_file_store_factory)(root, \*[, use_dol])   | Return a `StoreFactory` backed by JSON files under `root`.            |
|-------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
| [`make_store_router`](_autosummary/enlace_auth.stores.html.md#enlace_auth.stores.make_store_router)(\*, base_store_getter, ...)  | Return a router exposing the per-user store of each app that has one. |
| [`sanitize_key`](_autosummary/enlace_auth.stores.html.md#enlace_auth.stores.sanitize_key)(key)                              | Return `key` unchanged if safe for use as a store path component.     |

### Classes

| [`PrefixedStore`](_autosummary/enlace_auth.stores.html.md#enlace_auth.stores.PrefixedStore)(base, prefix)                     | Transparently prepend a prefix to every key operation on a base store.   |
|--------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| [`StoreInjectionMiddleware`](_autosummary/enlace_auth.stores.html.md#enlace_auth.stores.StoreInjectionMiddleware)(app, \*[, base_store]) | Pure-ASGI middleware that injects `request.state.store`.                 |

### *class* enlace_auth.stores.PrefixedStore(base, prefix)

Bases: [`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)

Transparently prepend a prefix to every key operation on a base store.

#### keys_under(sub='')

Keys (without this store’s prefix) that start with `sub`.

Uses the base store’s own `keys_under` when it has one (the file backend
walks only the matching directory), else filters a full iteration.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Iterator)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### *class* enlace_auth.stores.StoreInjectionMiddleware(app, , base_store=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Pure-ASGI middleware that injects `request.state.store`.

### enlace_auth.stores.make_file_store_factory(root, , use_dol=False)

Return a `StoreFactory` backed by JSON files under `root`.

`factory(name)` returns a `MutableMapping` rooted at `root/name/`.

Defaults to a small stdlib implementation that auto-creates parent
directories on write. Pass `use_dol=True` to use `dol.Files` instead
(pulls in the soft dep and expects flat keys).

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)]

### enlace_auth.stores.make_store_router(, base_store_getter, protected_apps, share_access=None, max_items=5000)

Return a router exposing the per-user store of each app that has one.

Routes:

- `GET    /api/{app_id}/store?prefix=<p>`
  → `{"items": {key: {"value", "etag"}}, "truncated"}`
- `GET    /api/{app_id}/store/{key}` → `{"value": v}` with an `ETag` header
- `PUT    /api/{app_id}/store/{key}` — conditional with `If-Match: <etag>`
  or `If-None-Match: *`
- `DELETE /api/{app_id}/store/{key}` — `If-Match` likewise

A failed precondition is **412** with the current `{"value", "etag"}` (`value`
null when the key is absent), so the client can resolve and retry.

`protected_apps` names the apps with a per-user store: `protected:user`
apps and those whose `app.toml` sets `user_store = true` (a callable is
re-read per request). `?owner=<email>` on any route acts on that user’s
data instead of the caller’s, when `share_access(app_id, owner, caller)`
says so (`"rw"`, or `"ro"` for GET only); otherwise 404, as if the owner
had nothing. Without `share_access` every `?owner=` naming someone else
is 404. See `misc/docs/decisions/0001-owner-granted-data-shares.md`.

The router assumes `PlatformAuthMiddleware` has set `request.state.user_id`
(public apps get it too, when the visitor is signed in). CSRF on writes is the
`CSRFMiddleware`’s, through its `enforce_prefixes`.

* **Return type:**
  `APIRouter`

### enlace_auth.stores.sanitize_key(key)

Return `key` unchanged if safe for use as a store path component.

Raises `ValueError` with a specific reason if the key is unsafe. The goal
is fail-fast: we want the caller to see exactly why a key was rejected, not
a silently rewritten value.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### Modules

| [`backends`](_autosummary/enlace_auth.stores.backends.html.md#module-enlace_auth.stores.backends)     | MutableMapping-backed store factories for enlace.                       |
|--------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`middleware`](_autosummary/enlace_auth.stores.middleware.html.md#module-enlace_auth.stores.middleware) | Store injection middleware and per-app store router.                    |
| [`prefixed`](_autosummary/enlace_auth.stores.prefixed.html.md#module-enlace_auth.stores.prefixed)     | PrefixedStore — MutableMapping wrapper that scopes keys under a prefix. |
| [`validation`](_autosummary/enlace_auth.stores.validation.html.md#module-enlace_auth.stores.validation) | Key sanitization for user-supplied store keys.                          |


# _autosummary/enlace_auth.stores.middleware.html.md

# enlace_auth.stores.middleware

Store injection middleware and per-app store router.

`StoreInjectionMiddleware` runs after the auth middleware. It reads
`scope["state"]["user_id"]` and `scope["state"]["app_id"]` (the latter set
by the per-mount wrapper in `enlace.compose`) and attaches a
`PrefixedStore(base, f"{user_id}/{app_id}/")` to `scope["state"]["store"]`.

If either id is missing, `store` is `None` — apps are expected to handle
that case gracefully (they do so naturally by providing a dict fallback in
standalone mode).

### Module Attributes

| [`NO_ACCESS`](_autosummary/enlace_auth.stores.middleware.html.md#enlace_auth.stores.middleware.NO_ACCESS)         | no (more) share for `?owner=`, or no such key.                            |
|--------------------------------------------------------------------|---------------------------------------------------------------------------|
| [`DEFAULT_MAX_ITEMS`](_autosummary/enlace_auth.stores.middleware.html.md#enlace_auth.stores.middleware.DEFAULT_MAX_ITEMS) | How many items the list route returns before it says `"truncated": true`. |

### Functions

| [`etag_of`](_autosummary/enlace_auth.stores.middleware.html.md#enlace_auth.stores.middleware.etag_of)(value)                                | A strong ETag for a stored JSON value: a hash of its canonical serialisation.   |
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| [`make_store_router`](_autosummary/enlace_auth.stores.middleware.html.md#enlace_auth.stores.middleware.make_store_router)(\*, base_store_getter, ...) | Return a router exposing the per-user store of each app that has one.           |

### Classes

| [`StoreInjectionMiddleware`](_autosummary/enlace_auth.stores.middleware.html.md#enlace_auth.stores.middleware.StoreInjectionMiddleware)(app, \*[, base_store])   | Pure-ASGI middleware that injects `request.state.store`.   |
|----------------------------------------------------------------------------------------------------|------------------------------------------------------------|

### enlace_auth.stores.middleware.DEFAULT_MAX_ITEMS *= 5000*

How many items the list route returns before it says `"truncated": true`.

### enlace_auth.stores.middleware.NO_ACCESS *= 'no_access'*

no (more) share for `?owner=`, or no such key.

* **Type:**
  The 404 details of the store routes

### *class* enlace_auth.stores.middleware.StoreInjectionMiddleware(app, , base_store=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Pure-ASGI middleware that injects `request.state.store`.

### enlace_auth.stores.middleware.etag_of(value)

A strong ETag for a stored JSON value: a hash of its canonical serialisation.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.stores.middleware.make_store_router(, base_store_getter, protected_apps, share_access=None, max_items=5000)

Return a router exposing the per-user store of each app that has one.

Routes:

- `GET    /api/{app_id}/store?prefix=<p>`
  → `{"items": {key: {"value", "etag"}}, "truncated"}`
- `GET    /api/{app_id}/store/{key}` → `{"value": v}` with an `ETag` header
- `PUT    /api/{app_id}/store/{key}` — conditional with `If-Match: <etag>`
  or `If-None-Match: *`
- `DELETE /api/{app_id}/store/{key}` — `If-Match` likewise

A failed precondition is **412** with the current `{"value", "etag"}` (`value`
null when the key is absent), so the client can resolve and retry.

`protected_apps` names the apps with a per-user store: `protected:user`
apps and those whose `app.toml` sets `user_store = true` (a callable is
re-read per request). `?owner=<email>` on any route acts on that user’s
data instead of the caller’s, when `share_access(app_id, owner, caller)`
says so (`"rw"`, or `"ro"` for GET only); otherwise 404, as if the owner
had nothing. Without `share_access` every `?owner=` naming someone else
is 404. See `misc/docs/decisions/0001-owner-granted-data-shares.md`.

The router assumes `PlatformAuthMiddleware` has set `request.state.user_id`
(public apps get it too, when the visitor is signed in). CSRF on writes is the
`CSRFMiddleware`’s, through its `enforce_prefixes`.

* **Return type:**
  `APIRouter`


# _autosummary/enlace_auth.stores.prefixed.html.md

# enlace_auth.stores.prefixed

PrefixedStore — MutableMapping wrapper that scopes keys under a prefix.

The per-user injection pattern: a single base store is shared across all users
and apps, but each request sees a `PrefixedStore(base, f"{user_id}/{app_id}/")`
so keys can’t collide across tenants.

Keys the caller passes are validated via `sanitize_key`; the prefix itself is
sanitized at construction (each slash-separated segment).

### Classes

| [`PrefixedStore`](_autosummary/enlace_auth.stores.prefixed.html.md#enlace_auth.stores.prefixed.PrefixedStore)(base, prefix)   | Transparently prepend a prefix to every key operation on a base store.   |
|--------------------------------------------------------------------------------|--------------------------------------------------------------------------|

### *class* enlace_auth.stores.prefixed.PrefixedStore(base, prefix)

Bases: [`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)

Transparently prepend a prefix to every key operation on a base store.

#### keys_under(sub='')

Keys (without this store’s prefix) that start with `sub`.

Uses the base store’s own `keys_under` when it has one (the file backend
walks only the matching directory), else filters a full iteration.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Iterator)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]


# _autosummary/enlace_auth.stores.validation.html.md

# enlace_auth.stores.validation

Key sanitization for user-supplied store keys.

Blocks the standard path-traversal attack surface: `..` segments, backslashes,
null bytes, control characters, absolute paths, and URL-encoded variants. Keys
that survive sanitization are still filesystem-safe and can be composed into a
prefix without risk of escaping the tenant namespace.

### Functions

| [`sanitize_key`](_autosummary/enlace_auth.stores.validation.html.md#enlace_auth.stores.validation.sanitize_key)(key)   | Return `key` unchanged if safe for use as a store path component.   |
|----------------------------------------------------------------------|---------------------------------------------------------------------|

### enlace_auth.stores.validation.sanitize_key(key)

Return `key` unchanged if safe for use as a store path component.

Raises `ValueError` with a specific reason if the key is unsafe. The goal
is fail-fast: we want the caller to see exactly why a key was rejected, not
a silently rewritten value.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)


# about-this-build.html.md

<!-- generated by epythet -->

# About this build

This documentation was built on **2026-10-30 13:03 UTC** from commit <a href="https://github.com/i2mint/enlace_auth/commit/c1876cf81d25ac014e2288247132b42a09ce127e"><code>c1876cf</code></a> on branch <code>main</code>, for **enlace_auth 0.1.50** (from <code>pyproject.toml</code>).

#### WARNING
The documentation and the package may be misaligned:

- The documented version (0.1.50) is behind the latest release on PyPI (0.1.51): `pip install enlace_auth` gives newer code than these docs describe.

## Source

|                     |                                                                                                                                                           |
|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
| Commit              | <a href="https://github.com/i2mint/enlace_auth/commit/c1876cf81d25ac014e2288247132b42a09ce127e"><code>c1876cf81d25ac014e2288247132b42a09ce127e</code></a> |
| Branch              | <code>main</code>                                                                                                                                         |
| Tags at this commit | none                                                                                                                                                      |
| Working tree        | clean                                                                                                                                                     |
| Remote              | <code>https://github.com/i2mint/enlace_auth</code>                                                                                                        |

## Continuous integration

|              |                                                                                            |
|--------------|--------------------------------------------------------------------------------------------|
| Repository   | <code>i2mint/enlace_auth</code>                                                            |
| Run          | <a href="https://github.com/i2mint/enlace_auth/actions/runs/41059816268">41059816268</a>   |
| Ref          | <code>refs/heads/main</code>                                                               |
| Event commit | <code>c1876cf81d25ac014e2288247132b42a09ce127e</code> (in the history of the built commit) |

## Tools

|          |         |
|----------|---------|
| epythet  | 0.2.22  |
| Sphinx   | 9.1.1   |
| docutils | 0.22.4  |
| Python   | 3.12.15 |

## Configuration as resolved

|               |                                                                   |
|---------------|-------------------------------------------------------------------|
| theme         | <code>auto</code> (Sphinx theme <code>pydata_sphinx_theme</code>) |
| accent        | <code>#0e599d</code>                                              |
| api_generator | <code>autosummary</code>                                          |
| ignore        | <code>tests/</code>, <code>scrap/</code>, <code>examples/</code>  |
| agent_outputs | <code>true</code>                                                 |
| aggregates    | <code>md</code>                                                   |
| ai_artifacts  | <code>true</code>                                                 |

## Package on PyPI

Latest release: <a href="https://pypi.org/project/enlace_auth/0.1.51/">0.1.51</a>, newer than the documented version (0.1.50).

## Reproduce

```bash
git clone https://github.com/i2mint/enlace_auth && cd enlace_auth
git checkout c1876cf81d25ac014e2288247132b42a09ce127e
pip install "epythet==0.2.22"
epythet quickstart . --ignore tests/ scrap/ examples/
```

The same data, for machines: <a href="build_info.json"><code>build_info.json</code></a> (schema version 1).


# ai-agents.html.md

<!-- generated by epythet -->

# For AI agents

`enlace_auth` ships artifacts for coding agents alongside its code. This page lists
them, says where each lives in the repository, and points at the
machine-readable copies of this documentation.

## Skills

Skills are folders holding a `SKILL.md` (the [Agent Skills](https://agentskills.io) format): a description that tells an agent when to use it and a body with the procedure. Install one into your agent with `gh skill` (any host: `--agent claude-code`, `copilot`, `cursor`, `codex`, `gemini`), or use the copy bundled in the wheel.

### `enlace-auth-usage`

How to add sign-in, sessions, CSRF protection, admin accounts, per-app access levels, runtime grants and per-user storage to an enlace multi-app platform with the enlace_auth plugin. Use when the user mentions enlace_auth, the enlace-auth CLI, protected:user or protected:shared apps, ENLACE_SIGNING_KEY, ENLACE_ADMIN_EMAILS, X-CSRF-Token or /auth/csrf, request.state.store, add-user, reset-link, grant, or share; or wants to put login in front of apps served by enlace.

```bash
gh skill install i2mint/enlace_auth enlace-auth-usage --agent claude-code
```

Source: [`enlace_auth/data/skills/enlace-auth-usage`](https://github.com/i2mint/enlace_auth/tree/HEAD/enlace_auth/data/skills/enlace-auth-usage) (bundled with the pip package).

### `enlace-auth-dev`

How to work on the enlace_auth codebase itself: run and write tests, change the auth, CSRF, session, grant, share or admin code safely, and what a change must keep true. Use when editing files in enlace_auth/, fixing an enlace_auth issue, reviewing a pull request to it, or when asked how enlace_auth is organized or tested.

```bash
gh skill install i2mint/enlace_auth enlace-auth-dev --agent claude-code
```

Source: [`skills/enlace-auth-dev`](https://github.com/i2mint/enlace_auth/tree/HEAD/skills/enlace-auth-dev).

The bundled skills are also on disk after `pip install enlace_auth`, under the package’s `data/skills/` directory; link them into an agent without network access with `skill link-skills <that directory>`.

## Instruction files

Files agents read before working in this repository.

- [`CLAUDE.md`](https://github.com/i2mint/enlace_auth/tree/HEAD/CLAUDE.md): read by Claude Code

## Machine-readable documentation

This site publishes the same documentation in forms that fit an agent’s context window:

- [`llms.txt`](https://i2mint.github.io/enlace_auth/llms.txt): an index of every page with a one-line description ([llms.txt](https://llmstxt.org) format)
- [`enlace_auth.md`](https://i2mint.github.io/enlace_auth/enlace_auth.md): the whole documentation as one Markdown file
- `<page>.html.md`: a rendered Markdown twin of every page, advertised from each page’s `<head>` with `<link rel="alternate" type="text/markdown">`
- [`objects.inv`](https://i2mint.github.io/enlace_auth/objects.inv): the Sphinx inventory: a symbol-to-URL index (`sphobjinv convert plain objects.inv -`)


# api.html.md

# API reference

| [`enlace_auth`](_autosummary/enlace_auth.html.md#module-enlace_auth)   | enlace_auth — authentication, sessions, admin dashboard, and per-user stores.   |
|-----------------------------------------------------------------------------------|---------------------------------------------------------------------------------|


