Files
preauth/ROADMAP.md
T
lyra 6bbfd44e7d Document passkey authentication
Covers the README (prerequisites, the two hard requirements, every new env var,
how registration and login work, and the counter caveat), SECURITY.md (the
ceremony model, single-use challenges, origin handling, the shared rate-limit
budget, and the attestation rationale with the conditions that would reverse
it), CHANGELOG (Added/Security/Changed), ROADMAP (Phase 2c complete, with the
deviations from the original sketch) and DESIGN_CONSIDERATIONS (the four
decisions whose reasoning is not visible in the code).

The docs lead with the two prerequisites because both are enforced rather than
advisory: enabling passkeys without central auth, or on a host that cannot
serve HTTPS, fails at container start. Neither is a runtime surprise, and a
reader needs to know that before they turn the feature on.

The plan document is updated to record that it is complete, and to note the two
places where implementation deviated from it — the listener/extraction order,
and register-begin not being a listener operation. The second was a design
error in the plan, not just an ordering change, so it is called out explicitly.
2026-09-27 11:37:02 +00:00

483 lines
22 KiB
Markdown

# Preauth — Project Roadmap
## Project Overview
Preauth is a pre-authentication gate for self-hosted services. It sits
between a reverse proxy (Caddy's `forward_auth`) and your web service,
requiring a TOTP code (or backup code) before traffic ever reaches the
protected application. It is **not** a replacement for the service's own
authentication — it's a gate that prevents outsiders from even seeing
what service is running.
- **Location:** `projects/preauth/`
- **Framework:** Symfony 8.1 (PHP ≥ 8.4)
- **Serving:** FrankenPHP (Docker image)
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
- **Auth:** TOTP (single secret) + single-use backup codes
- **Production status:** Running in production since June 2024
### Current Production Use
| Service | Purpose |
|-------------|--------------------------------------------------|
| Bitwarden | Password manager — always accessible, invisible to the world |
| Microbin | Sharing text blobs and small files across devices |
| Gitea | Code hosting — some DNS configs must be public |
---
## Architecture
### Request Flow
```
Client → Caddy → forward_auth → Preauth listeners (priority order) → 200/401/418
```
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
If found → `200 OK` + `Remote-User` header → Caddy proxies to backend.
2. **AllowListener** (priority 88) — If `IP_TTL` is enabled, checks for
valid IP-based session. If found → `200 OK` + `Remote-User`.
3. **PublicAccessListener** (priority 84) — If `PUBLIC_PATHS` is
configured and the request matches a public path pattern, applies
per-IP rate limiting. Within limit → `200 OK`. Over limit → `429`.
Authenticated users never reach this listener.
4. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
exceeded login attempt threshold → `418 I'm a Teapot` (or `429`).
5. **LoginListener** (priority 66) — Detects login attempts via
`X-Preauth` header (base64url JSON) or POST form on auth subdomain.
Validates TOTP/backup codes through `LoginManager`.
6. **InterceptListener** (priority 55) — Fallback: if no listener has
set a response, either redirects to auth subdomain (central auth) or
renders the Twig login page with a fresh nonce.
### Key Design Decisions
- **No controllers** — Entirely event-listener-driven. Clean separation
of concerns, each listener handles one stage of the auth flow.
- **Dual-layer cache** — APCu for fast in-memory lookups, file-based
storage for persistence across container restarts. `MonitorCacheKeys`
wraps the PSR-6 pool to track key changes for efficient persistence
(only write what changed).
- **`__Host-` prefixed cookies** — `SameSite=Strict`, `Secure`,
`HttpOnly`. Central auth mode uses a separate `__Http-Domain-Preauth`
cookie name (domain-scoped, no `__Host-` prefix).
- **Nonce system** — 15-byte random nonces, single-use, 120s TTL, with
retry-on-collision (up to 3 attempts).
- **TOTP with ±1 period leeway (±30 seconds)** — Accommodates clock drift.
- **Backup codes** — Case-insensitive alphanumeric, single-use, stored
in cache with year-2999 expiry. Generated via console command.
- **Domain awareness** — `DomainManager` handles multi-part TLDs
(`.co.uk`, `.com.au`, etc.) with a built-in TLD lookup table.
- **Interfaces** — `LoginInterface`, `DomainInterface`,
`BackupCodeInterface` extracted to support testing (mockable).
---
## Test Suite Status
### Current Results
| Metric | Value |
|--------------|--------------------------------|
| **Tests** | 293 |
| **Assertions** | 605 |
| **Pass** | 222 (100%) |
| **Fail** | 0 |
| **Errors** | 0 |
| **Warnings** | 0 |
| **Time** | ~0.56s (without coverage) |
| | ~1.31s (with coverage) |
### Code Coverage
| Metric | Percentage |
|----------|---------------------|
| **Lines** | **100.00%** (442/442) |
| **Methods** | **100.00%** (83/83) |
| **Classes** | **100.00%** (21/21) |
Every class, method, and line in `src/` is covered.
### Source → Test Mapping
| Source File | Test File | Type |
|------------------------------------------|----------------------------------------------------|----------|
| `Clock.php` | `Unit/ClockTest.php` | Unit |
| `ConfigBag.php` | `Unit/ConfigBagTest.php` | Unit |
| `Kernel.php` | (covered via functional tests) | Functional |
| `MonitorCacheKeys.php` | `Unit/MonitorCacheKeysTest.php` | Unit |
| `PersistCache.php` | `Unit/PersistCacheTest.php` | Unit |
| `Utilities.php` | `Unit/UtilitiesTest.php` | Unit |
| `Command/GenerateBackupCodesCommand.php` | `Unit/Command/GenerateBackupCodesCommandTest.php` | Unit |
| `Data/Payload.php` | `Unit/Data/PayloadTest.php` | Unit |
| `Enum/Scope.php` | `Unit/Enum/ScopeTest.php` | Unit |
| `Listener/AcceptListener.php` | `Unit/Listener/AcceptListenerTest.php` | Unit |
| `Listener/PublicAccessListener.php` | `Unit/Listener/PublicAccessListenerTest.php` | Unit |
| `Listener/AllowListener.php` | `Unit/Listener/AllowListenerTest.php` | Unit |
| `Listener/InterceptListener.php` | `Unit/Listener/InterceptListenerTest.php` | Unit |
| `Listener/LoginListener.php` | `Unit/Listener/LoginListenerTest.php` | Unit |
| `Listener/RejectListener.php` | `Unit/Listener/RejectListenerTest.php` | Unit |
| `Service/BackupCodeManager.php` | `Unit/Service/BackupCodeManagerTest.php` | Unit |
| `Service/DomainManager.php` | `Unit/Service/DomainManagerTest.php` | Unit |
| `Service/PublicPathMatcher.php` | `Unit/Service/PublicPathMatcherTest.php` | Unit |
| `Service/LoginManager.php` | `Unit/Service/LoginManagerTest.php` | Unit |
| `Trait/CookieNameTrait.php` | `Unit/Trait/CookieNameTraitTest.php` | Unit |
| `Trait/GetTotpTrait.php` | `Unit/Trait/GetTotpTraitTest.php` | Unit |
| `Trait/HasLoggerTrait.php` | `Unit/Trait/HasLoggerTraitTest.php` | Unit |
| `Trait/MakeNonceTrait.php` | `Unit/Trait/MakeNonceTraitTest.php` | Unit |
| `Trait/StringTrait.php` | `Unit/Trait/StringTraitTest.php` | Unit |
| *(All listeners + services)* | `Functional/AuthenticationFlowTest.php` | Functional |
| *(Public access flow)* | `Functional/PublicAccessFlowTest.php` | Functional |
### Test Quality Assessment
**Strengths:**
- **100% coverage** — every line, method, and class.
- **Well-structured test hierarchy** — Unit tests per class, functional
tests for the full HTTP kernel flow. Two support traits
(`TotpTestHelper`, `ListenerTestHelper`) provide reusable fixtures
(frozen clock, deterministic TOTP, Twig environment, mock rate
limiters).
- **Edge cases well-covered** — ULID collision handling, nonce collision
retries, spent nonces, invalid payloads (bad base64, non-object JSON,
arrays, null, booleans), empty/whitespace fields, field truncation,
multibyte characters in cache keys, multi-part TLD domain matching,
cookie pruning on invalid sessions.
- **Both positive and negative paths** — Every listener tests both
success and failure scenarios.
- **Security-conscious testing** — Backup code single-use enforcement,
case-insensitivity, character stripping, rate limit teapot vs.
too-many-requests, return URL validation (prevents open redirect),
cookie security attributes.
- **Realistic functional tests** — `AuthenticationFlowTest` goes through
the actual Symfony kernel: fetches nonces from rendered HTML, submits
TOTP codes, verifies cookies are set, tests the full login →
authenticated access cycle.
- **Smart test infrastructure** — `KernelBrowser::disableReboot()` used
in functional tests so nonces persist across requests (matching
production APCu behavior).
**Status: Test suite goal is met.** 222 tests, 100% coverage, all passing.
---
## Roadmap
### Phase 1 — Public but Rate-Limited Access ✅ Completed (v1.1)
**Goal:** Allow select services to be publicly accessible (no TOTP
required) but with aggressive per-IP rate limiting to prevent bot
traffic from overwhelming the server.
**Context:** The user previously made Gitea semi-public (view but no
login), but bot traffic slowed the server and consumed all household
bandwidth, forcing it back to fully private. The solution isn't more
authentication — it's bandwidth/resource protection for public-facing
services.
**Implementation:**
- New config variables:
- `PUBLIC_PATHS` — Comma-separated path patterns with `*` (single
segment) and `**` (cross-segment) wildcard support. Optional host
prefix (e.g., `code.example.com/public/**`). When empty (default),
the feature is fully disabled.
- `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` — Burst rate limiting
(default: 100 requests per 60 seconds).
- `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` — Sustained rate limiting
(default: 500 requests per 3600 seconds).
- New listener: **PublicAccessListener** (priority 84, after
AcceptListener and AllowListener, before RejectListener):
- Checks if the request path matches a configured public path pattern.
- If public and within rate limit → `200 OK` (no `Remote-User` header).
- If public and over rate limit → `429 Too Many Requests` with
`Retry-After` header.
- Authenticated users bypass this listener entirely (AcceptListener
or AllowListener returns 200 first).
- New service: **PublicPathMatcher** — Parses path patterns and matches
request paths with wildcard support.
- Separate `public_limiter` compound rate limiter (independent from
the login attempt rate limiter).
- [x] Design public path detection mechanism (path-based with wildcards)
- [x] Implement `PublicAccessListener` with separate rate limiter pool
- [x] Add config variables and defaults
- [x] Update Caddyfile example with public service snippet
- [x] Tests for public mode (within limit, over limit, burst behavior)
- [x] Documentation in README
### Phase 2 — Session Management & Audit
**Goal:** Give visibility into who has access and when it was granted.
- [ ] **Active sessions view** — Console command or simple API endpoint
to list active sessions (cookie-based and IP-based), showing:
- Session ID / username
- IP address
- First auth timestamp
- Last seen timestamp
- Scope (cookie vs. IP)
- [ ] **Session revocation** — Console command to revoke a specific
session by ID or revoke all sessions for an IP.
- [ ] **Audit log** — Log every successful and failed authentication
attempt to a persistent store (file-based JSONL, similar to the email
integration's audit log):
```json
{
"timestamp": "2025-01-15T14:23:01Z",
"ip": "192.168.1.50",
"action": "login_success",
"username": "mom",
"method": "totp"
}
```
- [ ] Tests for all new commands and endpoints
### Phase 2b — Backup Code System Completion
**Goal:** Finish the backup code system — the core logic is solid but
the management surface is incomplete.
**What already exists:**
- ✅ `BackupCodeManager::generate()` — Creates codes, saves to cache
with year-2999 expiry
- ✅ `BackupCodeManager::expire()` — Deletes all `backup_` prefixed
keys from cache
- ✅ `BackupCodeManager::verifyAndConsume()` — Validates and marks code
as used (sets value to `false`, keeps the key for audit trail)
- ✅ `app:generate-backup-codes [count]` console command
- ✅ Tests for all of the above (100% coverage)
**What's missing:**
- [ ] **`app:list-backup-codes` command** — Show backup code status:
- Total codes generated
- How many are still valid (unused)
- How many have been spent (and optionally when)
- Output format: table with status column (✅ valid / ⛔ used)
- Note: spent codes are kept in cache with value `false`, so we can
distinguish "used" from "never existed" — this is good design
- [ ] **`app:expire-backup-codes` command** — Wrap the existing
`BackupCodeManager::expire()` method in a console command. Should:
- Show how many codes are being expired before confirmation
- Support `--force` flag to skip confirmation prompt
- Call `persistCache->boot()` and `persistCache->persist()` like the
generate command does (since `Kernel::terminate()` doesn't run in
CLI)
- [ ] **Notification on backup code use** — When
`verifyAndConsume()` consumes a backup code, fire a notification
through configurable channels:
- Discord webhook (we already have the `discord.sh` infrastructure)
- ntfy
- Email (once email integration is available)
- Webhook (generic HTTP POST for future integrations)
- Config variables:
- `BACKUP_CODE_NOTIFY=discord,ntfy` — comma-separated channels
- `BACKUP_CODE_NOTIFY_WEBHOOK=''` — generic webhook URL
- Message should include: timestamp, IP address, username, and how
many valid codes remain
- Architecture: `BackupCodeManager` dispatches an event
(e.g. `BackupCodeUsedEvent`) after consuming a code. A listener
handles the notification dispatch. This keeps the notification
logic out of the backup code manager itself.
- [ ] **Low-codes warning** — If backup codes fall below a threshold
(e.g. 3 remaining), include a warning in the notification and/or
surface it in the `list-backup-codes` command output
- [ ] Tests for all new commands and notification dispatch
### Phase 2c — Passkey Authentication
**Status: complete.** See `docs/passkey-auth-subdomain-plan.md` for the full
plan, the evidence behind each decision, and the deviations noted below.
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
authentication method alongside TOTP and backup codes.
**Context:** Passkeys are the modern standard for passwordless auth.
They're phishing-resistant (domain-bound), use biometrics or device
PINs, and are significantly more user-friendly than typing 6-digit
codes. For a pre-auth gate that friends and family use, passkeys would
be a major UX improvement — especially for non-technical users who
struggle with TOTP apps.
**What was built**, and how it differs from the sketch above:
- **A Symfony bundle was not used**, only `web-auth/webauthn-lib`. The bundle
brings a database-backed credential repository and a controller setup that do
not fit a no-database, listener-only application; the library alone is a
clean fit and its types are confined to `PasskeyManager` and
`PasskeyCeremonyFactory` so a major-version rename touches two files.
- **Registration happens in the browser, not a console command.** The checkbox
on the login form is authorised by the TOTP code in the same submission, so
it needs no separate token and no CLI. This also settles the "how does the
identity get specified" question: it is the identity that just authenticated.
- **Central auth is a hard prerequisite.** A passkey is scoped to one relying
party, so passkeys require `SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`; the RP ID
is always that base domain. Without it the feature stays off, rather than
quietly scoping credentials to a single host.
- **HTTPS is required with no exemption**, development included, since an
`http://` escape hatch is how the same weakness reaches production.
- **Failed attempts share the TOTP rate-limit budget**, so passkeys cannot be
used to sidestep a lockout.
- **Attestation is `none`**, measured rather than assumed — see SECURITY.md and
plan §2.3.
**Remaining work:** none for the feature itself. Discoverable-credential
(usernameless) login is possible but not needed, since the login page already
lists registered credentials.
- [ ] Console command: `app:list-passkeys` — show registered devices
- [ ] Console command: `app:remove-passkey` — revoke a passkey
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
- [ ] Tests for registration, authentication, and revocation
- [ ] Consider: should passkeys be a *replacement* for TOTP or an
*alternative*? (Probably alternative — keep TOTP as fallback)
### Phase 3 — Multi-User Support
**Goal:** Support multiple TOTP users for household/family access.
*Note: This is a significant feature that changes the single-secret
model. It should only be pursued if the single-secret + backup codes
approach proves insufficient for the use case.*
- [ ] Multiple TOTP secrets, each with a label (e.g., "mom", "dad",
"friend")
- [ ] Per-user backup codes
- [ ] Per-user session tracking (the `username` field in Payload already
supports this — sessions are already tagged with an ID)
- [ ] Console command to add/remove/list users
- [ ] Consider: should the login page ask for a username, or should all
TOTP codes be tried against all secrets? (Username is better —
it's already in the payload.)
- [ ] Tests for multi-user scenarios
### Phase 4 — Polish & Hardening
**Goal:** Production hardening and quality-of-life improvements.
- [ ] **Docker image improvements:**
- Multi-arch builds (amd64 + arm64 for Raspberry Pi)
- Smaller image size (alpine-based if feasible)
- Better health check (actual endpoint, not just `curl localhost`)
- [ ] **GitHub/Gitea repository polish:**
- ✅ Comprehensive README with setup guide, architecture overview, and
configuration reference
- Contributing guidelines
- ✅ Changelog formalised (CHANGELOG.md)
- ✅ CI workflows (tests + php-cs-fixer on push/PR, Docker image on tag)
- [ ] **Security review:**
- ✅ CSRF protection on the POST form login — nonce system documented
- ✅ Security headers added (X-Content-Type-Options, X-Frame-Options, CSP, etc.)
- Review nonce entropy and cache key collision space
- Consider session fixation protections
- [ ] **Frontend improvements:**
- Mobile-responsive login page audit
- Accessibility audit (ARIA labels, keyboard navigation)
- Dark mode (if not already — the teal background suggests it might
already be dark-themed)
- [ ] **Logging improvements:**
- Structured logging (JSON format option) for easier parsing
- Log rotation configuration
- Debug mode documentation
---
## Feature Thoughts
Based on the review, here are features that might be missing or worth
considering, keeping in mind that preauth is a **gate**, not a full
identity provider:
### High Value
1. **Public but rate-limited mode** (Phase 1) — Directly solves the
Gitea bot traffic problem. This is the most impactful missing
feature.
2. **Passkey authentication** (Phase 2c) — Phishing-resistant,
passwordless auth that's far more user-friendly than TOTP for
non-technical family members. The modern standard for this kind
of gate.
3. **Backup code notifications** (Phase 2b) — When a backup code is
used, you should know about it immediately. This is a security-critical
event — it means someone lost their device or is locked out of their
TOTP app. Discord/ntfy/email notification should fire automatically.
4. **Backup code management commands** (Phase 2b) — The `generate`
command exists, but `list` and `expire` commands are missing despite
the underlying methods (`expire()`) already being implemented.
5. **Session visibility and revocation** (Phase 2) — Currently there's
no way to see who has access or revoke a session without clearing
the entire cache. For a security tool, this is important.
6. **Audit log** (Phase 2) — For a security gate, not having an audit
trail of logins (successful and failed) is a gap. The data is logged
at debug level, but not persisted in a queryable format.
### Medium Value
4. **Health check endpoint** — The Dockerfile has a `HEALTHCHECK` that
just `curl`s localhost, but a dedicated `/health` endpoint that
verifies cache connectivity would be more meaningful.
5. **Graceful degradation** — If the file-based cache is corrupted or
unavailable, does preauth fail open or closed? Should be documented
and tested. (Currently the `PersistCache` handles this in `boot()`,
but edge cases around partial corruption could be explored.)
6. **Rate limit headers** — Adding `X-RateLimit-Remaining` and
`Retry-After` headers to rate-limited responses would help legitimate
clients back off gracefully.
### Lower Value (Nice to Have)
7. **WebSocket support** — If protected services use WebSocket
connections, does `forward_auth` handle the upgrade handshake? This
is likely a Caddy configuration concern, but worth documenting.
8. **Theming presets** — Beyond the current env-var colour config,
preset themes or custom CSS upload could be nice for personalisation.
9. **TOTP secret rotation** — Console command to generate a new TOTP
secret and invalidate all existing sessions. Useful if a device is
lost or compromised.
10. **Per-service authentication policies** — Different services could
require different authentication strength (e.g., Bitwarden requires
TOTP + recent login, Microbin accepts any valid session). This would
need Caddy configuration support to pass the policy to preauth.
---
## Branch Status
| Branch | Status | Notes |
|--------|--------|-------|
| `main` (0.10.0) | Production | Current stable release |
All feature branches have been pruned. Development uses a feature-branch + PR workflow into `main`.
---
## Relationship to Other Projects
| Project | Integration |
|---------|-------------|
| MCP server | Preauth could be registered as an MCP command for session management ("revoke all sessions", "who's logged in?") |
| Email integration | Audit log entries could be included in morning summary ("2 failed login attempts from 203.0.113.50 overnight") |
| Discord/ntfy | Alert on backup code usage, suspicious activity (rate limit triggered, multiple failed attempts from new IP), low backup code count |
---
*Prepared by Lyra, your office-side assistant. ✨*