Apache Session Auth — Design

As-built (2026-08-04) — supersedes the plan below

The private notes HTML store shipped 2026-08-04, but not via the mod_auth_form/mod_session design below — those modules were never enabled. What actually shipped: the private gdata-server runs with NOTES_WEB=true, NOTES_WEB_PREFIX=/private, NOTES_WEB_AUTH=true, 1h configurable session TTL. notes_web.py gained an opt-in gate that accepts a session cookie or Authorization: Bearer, both validated against the shared gdata_oauth token store. GET /private/login (Apache-guarded by AuthType Basic + .htpasswd-webdav) mints a short-lived token and sets an HttpOnly; Secure; SameSite=Lax; Path=/private cookie, with open-redirect-safe next=. Apache exposes it via a ProxyPassMatch that excludes the reserved /private/{mcp,oauth,authorize,login,.well-known} paths (a broad <Location /private/> shadowed them — reverted). No mod_session/mod_auth_form/mod_lua. Caveat: the cookie token is a full private-store bearer credential for its 1h life (shared store). Details in gdata-server/todo (2026-08-04 entries); code in the private-notes commit. The Phase-2 MCP-bearer plan below remains just a plan.

Replace per-store Python OAuth with Apache-managed session auth backed by the WebDAV htpasswd file. One set of credentials, no separate per-store passwords. Phase 1: private notes HTML renderer. Phase 2: MCP token auth.

Credential Source

/etc/apache2/.htpasswd-webdav -- same file used by webdav.critchley.biz. One username/password to manage. Validated by Apache mod_authn_file, no Python involved.

Key Insight: No New Python Needed

The HTML renderer already exists in gdata-server (the NOTES_WEB=true flag enables it). It is currently disabled on the private store (NOTES_WEB=false). All that is needed:
1. Enable NOTES_WEB=true on the private notes server instance
2. Apache proxies /private/html/ to localhost:8120/, gated behind session auth

Port 8120 is not externally accessible (only via stunnel on 18121 and Apache proxy), so enabling NOTES_WEB on it does not expose the renderer publicly.

Two Token Levels

Level 1 -- Session cookie (browser, 1 week)
Used by the HTML renderer in a browser.
Issued by Apache after a form login (mod_auth_form).
Stored as an encrypted, signed cookie (mod_session_cookie + mod_session_crypto).
SessionMaxAge 604800 (1 week).
Automatically sent by browser on every request -- no JS needed.

Level 2 -- Bearer token (API/scripts/MCP, longer-lived)
Opaque token issued manually (or via a token endpoint).
Validated by Apache (mod_lua) before proxying to the backend.
For curl/scripts and eventually MCP clients.

Phase 1: HTML Renderer — Apache Config

Additions to /etc/apache2/sites-enabled/www.critchley.biz-ssl.conf on gravlax.
All required modules already installed (session, session_cookie, session_crypto, auth_form).

# Enable modules (a2enmod)
# session session_cookie session_crypto auth_form

# Session config -- add near top of VirtualHost
Session On
SessionCookieName notes_session path=/private;HttpOnly;Secure;SameSite=Lax
SessionMaxAge 604800
SessionCryptoPassphrase "<random-secret-generate-with-openssl-rand-hex-32>"

# Login handler
<Location /private/login>
    SetHandler form-login-handler
    AuthType form
    AuthName "Private Notes"
    AuthFormProvider file
    AuthUserFile /etc/apache2/.htpasswd-webdav
    AuthFormLoginSuccessLocation /private/html/CONTENTS
    Require valid-user
</Location>

# Logout
<Location /private/logout>
    SetHandler form-logout-handler
    AuthFormLogoutLocation /private/login
</Location>

# Protected HTML renderer -- proxies to private gdata-server
<Location /private/html>
    AuthType form
    AuthName "Private Notes"
    AuthFormProvider file
    AuthUserFile /etc/apache2/.htpasswd-webdav
    AuthFormLoginRequiredLocation /private/login
    Require valid-user
    ProxyPass http://localhost:8120/
    ProxyPassReverse http://localhost:8120/
</Location>

Login Form

A simple static HTML file served at GET /private/login. mod_auth_form requires specific field names for the POST:

<form method="POST" action="/private/login">
  <input type="text" name="httpd_username" placeholder="Username">
  <input type="password" name="httpd_password" placeholder="Password">
  <input type="hidden" name="httpd_location" value="">
  <button type="submit">Login</button>
</form>

Serve as a static file via Apache (Alias /private/login/form /var/www/html/private-login.html) or inline via the SetHandler. Note: the GET (show form) and POST (handle login) on the same Location both work with mod_auth_form.

Private Notes Server Change

In /etc/systemd/system/gdata-mcp-server-private.service, change:
NOTES_WEB=false --> NOTES_WEB=true
Then: sudo systemctl daemon-reload && sudo systemctl restart gdata-mcp-server-private

Phase 2: MCP Bearer Token

Currently MCP uses Python OAuth (PKCE) with a per-store password. Replacement plan:

1. Token endpoint: POST /private/token
Accepts Basic auth against .htpasswd-webdav.
Issues a signed opaque token stored server-side (flat file or SQLite).
Token lifetime: configurable (30 days suggested).

2. Apache mod_lua validates Bearer token on MCP requests:
Reads Authorization: Bearer header, looks up in token store, checks expiry.
If valid: proxies to backend. If invalid: 401.

3. Python MCP server drops its own OAuth entirely.
Apache is the auth layer. Python just serves the MCP protocol.

MCP clients get a token from /private/token once and store it in connector settings.

Implementation Order

1. Enable NOTES_WEB=true on private notes server (one line change + restart)
2. Add Apache mod_session + mod_auth_form config for /private/html
3. Serve the login form
4. Generate SessionCryptoPassphrase and add to config
5. Enable the modules (a2enmod) and restart Apache
6. Test: browser login -> session cookie -> /private/html/CONTENTS
7. (Later) Bearer token endpoint + mod_lua for MCP
8. (Later) Migrate MCP connectors from OAuth to Bearer token

updated 2026-08-04