enlace_auth#
Sign-in, sessions, CSRF protection, an admin dashboard and per-user data stores for the 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 |
|---|---|---|
|
using the plugin: wiring, access levels, CLI, the CSRF flow |
|
|
working on this codebase: tests, invariants, the fix loop |
|
Enable them with either route:
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:
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.storePlatformAuthMiddlewareandCSRFMiddlewareoptional 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#
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:
export ENLACE_PLUGINS=enlace_auth:plugin
Configuration#
In platform.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"
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 |
|
User is locked out, SMTP configured |
|
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:
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:
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:
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.
Doctor checks#
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:
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
MutableMappings so tests use dicts or a temp directory. The reasoning for owner-granted shares is inmisc/docs/decisions/0001-owner-granted-data-shares.md.Status. Extracted from
enlace0.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
versionalone.Questions and bugs. GitHub issues.
License. MIT.
This documentation as a single file: enlace_auth.md (Markdown, for agents).
built 2026-10-30 13:03 UTC from c1876cf (main) · enlace_auth 0.1.50 · about this build