Skip to content

Platform auth + OAuth + per-user stores (closes #4)#6

Merged
thorwhalen merged 1 commit intomainfrom
feat/auth-and-stores
Apr 20, 2026
Merged

Platform auth + OAuth + per-user stores (closes #4)#6
thorwhalen merged 1 commit intomainfrom
feat/auth-and-stores

Conversation

@thorwhalen
Copy link
Copy Markdown
Member

Summary

Implements issue #4 — the auth and stores plan from misc/docs/plan__auth_and_stores_phase_2_3.md. All three phases ship together.

Phase 2a — Auth foundation

  • enlace/auth/ with PlatformAuthMiddleware (pure ASGI, path normalization + identity-header stripping + deny-by-default), CSRFMiddleware (signed double-submit), SessionStore, argon2id passwords, signed cookies via itsdangerous, and /auth/{register,login,logout,shared-login,whoami} routes.
  • AccessLevel on AppConfig; AuthConfig / StoreBackendConfig / OAuthProviderConfig on PlatformConfig with TOML parsing.
  • CLI: enlace auth-init | auth-generate-signing-key | auth-hash-password | auth-list-sessions | auth-revoke-session. enlace check now validates signing-key and shared-password env vars.

Phase 2b — OAuth

  • enlace/auth/oauth.py with lazy Authlib import. Google and GitHub presets. Callback creates a local session and discards upstream tokens.

Phase 3 — Per-user stores

  • enlace/stores/ with PrefixedStore, key sanitization (path-traversal guard incl. URL-encoded variants), a file-backed MutableMapping factory, StoreInjectionMiddleware, and a /api/{app_id}/store/{key} router.

Diagnostics — seven new checks: SUBAPP_AUTH_MIDDLEWARE, CLIENT_IDENTITY_HEADER, HARDCODED_USER_ID, SESSION_COOKIE_IN_SUBAPP, STORE_IMPORT_IN_APP, UNSAFE_KEY_IN_STORE, MISSING_SIGNING_KEY. Each has a standalone-preserving fix suggestion.

Design invariants honored

  • Apps never import enlace. Contract: request.state.user_id + request.state.store.
  • Enlaced apps still run standalone — test_standalone_preservation.py proves ENLACE_MANAGED unset keeps the happy path working.
  • All new middleware is pure ASGI (three-callable pattern) — no BaseHTTPMiddleware.
  • CORS stays on parent; auth/store middleware also only on parent.
  • Conflict detection remains fail-fast and collects all errors, not just the first.

Open decisions (§13 of the plan)

Resolved with plan defaults:

  1. Hand-rolled CSRF (~60 lines, no dep).
  2. dol as soft dep in enlace[auth]; stdlib fallback ships in enlace/stores/backends.py so core install still works.
  3. Separate session / user-data store factories.
  4. No CAPTCHA / rate limit on /auth/register.
  5. Skills (enlace-auth-setup, enlace-user-stores) deferred to a follow-up PR once the auth flow is proven on a real app.

Tests

  • Full suite: 152 passed (68 pre-existing + 84 new).
    • Unit: test_auth_middleware (19), test_csrf (5), test_sessions (5), test_passwords (5), test_prefixed_store (23), test_store_middleware (5), test_oauth (3), test_base_auth_config (3), test_diagnose_auth (7).
    • Integration: test_auth_e2e (6 — register/login/protected access/store round-trip/logout, shared-password flow, identity-header stripping), test_standalone_preservation (3).
  • ruff check / ruff format: clean.

Test plan

  • pip install -e '.[auth,oauth,dev]'
  • pytest enlace/tests/ tests/ — 152 pass
  • enlace auth-generate-signing-key prints a key
  • enlace auth-hash-password prompts and prints an argon2id hash
  • enlace check surfaces missing env vars when auth.enabled=true
  • Manual: register + login + protected endpoint + store round-trip against a local platform

Follow-ups

  • Skills (enlace-auth-setup, enlace-user-stores)
  • OAuth end-to-end tests against real Google/GitHub (current tests use a mocked Authlib registry)
  • Documentation: README section for the auth + stores setup path

Implements the auth + stores plan documented in
misc/docs/plan__auth_and_stores_phase_2_3.md. Apps remain auth- and
storage-unaware; the contract is just request.state.user_id and
request.state.store.

Phase 2a — Auth foundation:
- enlace/auth/ package with PlatformAuthMiddleware (pure ASGI, path
  normalization, identity-header stripping, deny-by-default), CSRF
  middleware (signed double-submit), SessionStore, argon2id passwords,
  signed cookies via itsdangerous, and /auth/register|login|logout|
  shared-login|whoami routes.
- AccessLevel on AppConfig ("public" | "protected:shared" |
  "protected:user" | "local"); AuthConfig / StoreBackendConfig /
  OAuthProviderConfig on PlatformConfig with TOML parsing.
- CLI: enlace auth-init, auth-generate-signing-key, auth-hash-password,
  auth-list-sessions, auth-revoke-session. enlace check validates the
  signing-key and shared-password env vars.

Phase 2b — OAuth:
- enlace/auth/oauth.py with lazy Authlib import. Google + GitHub presets;
  on callback we create a local session and discard upstream tokens.

Phase 3 — Per-user stores:
- enlace/stores/ with PrefixedStore, key sanitization (path-traversal
  guard), a file-backed MutableMapping factory, StoreInjectionMiddleware,
  and a /api/{app_id}/store/{key} router.

Diagnostics: new categories SUBAPP_AUTH_MIDDLEWARE, CLIENT_IDENTITY_HEADER,
HARDCODED_USER_ID, SESSION_COOKIE_IN_SUBAPP, STORE_IMPORT_IN_APP,
UNSAFE_KEY_IN_STORE, MISSING_SIGNING_KEY.

Tests: 84 new tests (75 unit + 9 integration), including an e2e that
registers, logs in, hits a protected endpoint, round-trips the store,
and logs out, plus a standalone-preservation test proving apps still
work with ENLACE_MANAGED unset. Full suite: 152 passed.

Skills (enlace-auth-setup, enlace-user-stores) are deferred to a
follow-up PR per §13 open decisions. Open decisions resolved with plan
defaults: hand-rolled CSRF, dol as enlace[auth] soft dep (with stdlib
fallback), separate session/user-data store factories, no CAPTCHA, in-
repo skills.
@thorwhalen thorwhalen merged commit e673542 into main Apr 20, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant