10 Commits
Author SHA1 Message Date
lyra 2064153cd3 chore: adopt shared Guiding Light configs and fix conformance gaps
Tests / test (pull_request) Successful in 1m10s
Brings preauth from 18/34 to 30/34 conformance (auth-gateway profile). The
remaining four checks all depend on files this branch cannot change (see below).

PHP toolchain (§1)
  - require.php  >=8.4 -> ^8.5, and pin config.platform to 8.5.0. The old
    constraint also permitted PHP 9, which is not a promise we can keep.
    composer.lock regenerated with --lock: content-hash + platform-overrides
    only, zero dependency version movement.
  - friendsofphp/php-cs-fixer  * -> ^3.95. A wildcard meant CI was not
    reproducible.

PHPStan (§2.2)
  - vendor the shared phpstan.neon.dist (level 6) + a generated baseline.
    187 errors are captured rather than fixed; the baseline should only shrink
    from here.
  - add phpstan/phpstan:^2.1 to require-dev.

Code style (§8.2)
  - vendor the shared .php-cs-fixer.dist.php (@Symfony + @Symfony:risky +
    declare_strict_types) and apply it: 59 of 67 files reformatted.
  - Verified this is a formatting change, not a behaviour change: all 313 tests
    pass after the reformat, all in_array() calls already passed strict=true,
    and the remaining edits are @Symfony:risky idiom (yoda conditions, \count(),
    self:: over the class name).

Repository layout (§4.4)
  - docs/{Caddyfile,compose.yaml,example.env} -> docs/examples/, with
    example.env becoming the conventional .env.example. This is the layout
    GUIDING-LIGHT already cites preauth as doing correctly — it just needed
    renaming.
  - update the four readme.md references and a stale compose.yaml comment.
  - docs/v1.1-plan.md references are left alone deliberately: it is a historical
    plan recording what was done at the time, not live documentation.

Licence and security policy (§7)
  - add LICENSE (uniform MIT, matching composer.json).
  - add SECURITY.md describing the actual threat model: per-request
    allow/intercept, no caching of the login flow, app-set security headers,
    TOTP, and the fact that REMOTE_USER is trusted input.

Mobile accessibility (§3.3a)
  - templates/base.html.twig: drop maximum-scale=1 and add viewport-fit=cover.
    preauth was the one app already past the font-size precondition (controls
    render at 21.6px = 0.9em x 24px), so removing the lock is safe here and
    restores pinch-zoom for Android users.

Conformance tooling (§8.2)
  - vendor .ci/conformance.sh and .ci/css-control-size.py so the check runs
    from a checkout rather than fetching from the LAN-only private/ci.
  - .editorconfig synced from the version that keeps the Caddyfile tab rule.

Not included (blocked by the .gitea/workflows pre-receive hook):
  - ci-composer-audit, ci-composer-validate, ci-reusable-workflows.
    Workflow files may only change via a trusted ref, so the caller files are
    staged but not committed.

Also not included: dockerfile-nonroot (§6.4). Adding USER to an image with
VOLUME [/config, /data] changes volume ownership and needs an actual container
build/run to verify, so it goes in its own change.
2026-09-23 20:13:45 +00:00
lyra 5258e175a1 fix: never cache the login flow (stale pre-auth responses in Safari)
Tests / test (pull_request) Successful in 1m8s
The login page, failed logins, redirects, and rate-limit/error pages could
be stored by the browser (Symfony's default 'no-cache, private' still
permits storage — it only requires revalidation). Older Safari builds may
then replay a stale pre-auth response on refresh, appearing to log the
user back out, or show a previous session after logging in again.

- SecurityHeadersListener: send strict anti-caching headers on non-2xx
  responses only (no-store/no-cache/must-revalidate/proxy-revalidate,
  max-age=0, s-maxage=0 + Pragma, Expires, Surrogate-Control, Vary: *).
  2xx grants (already-authenticated / public access) are consumed by
  Caddy's forward_auth check and never reach the browser, and protected
  services' own cache headers must stay untouched.
- templates/_script.html.twig: fetch() with cache: 'no-store'; follow
  redirects with location.replace() to keep the login page out of history
  and the back-forward cache.
- docs/Caddyfile: reusable (preauth_no_store) snippet imported into every
  forward_auth block, using header_down so the guarantee holds at the edge
  (verified: replaces conflicting upstream values, leaves service
  responses alone).
- tests: unit coverage for the listener and functional coverage for the
  full HTTP kernel (login/failure/redirect/rate-limit not cacheable;
  200 grants untouched); asserts the rendered page carries the JS changes.
- readme/CHANGELOG updates.
2026-09-14 19:31:44 -04:00
lyra e3cd8c6739 fix: handle host-prefixed root path in PublicPathMatcher
Sync GitHub / sync (push) Successful in 6s
Tests / test (pull_request) Successful in 1m3s
The host-prefix regex required at least one character after the slash
(/\+.+/), so a pattern like 'code.example.com/' was silently dropped
instead of matching the root path '/'. Changed \+.+ to \+.* so the
trailing slash alone is accepted as the path '/'.

Added tests covering the exact bug scenario from PUBLIC_PATHS
config: 'code.digitaladapt.com/,code.digitaladapt.com/public/**'
2026-08-13 14:30:52 -04:00
lyra 5563999525 feat: public rate-limited access for v1.1
Sync GitHub / sync (push) Successful in 8s
Add PublicAccessListener (priority 84) that allows rate-limited
unauthenticated access to configured public paths. Authenticated users
bypass this listener entirely via AcceptListener/AllowListener.

New components:
- PublicPathMatcher service with wildcard path matching (* and **)
  and optional host-prefix scoping
- PublicAccessListener applying per-IP rate limiting to public paths
- Separate public_limiter compound rate limiter (burst + sustained)
- publicRateLimitCache pool (APCu in prod, array in tests)

New env vars:
- PUBLIC_PATHS (comma-separated path patterns, empty = disabled)
- PUBLIC_BURST_COUNT/PUBLIC_BURST_TIME (default 100/60s)
- PUBLIC_UPPER_COUNT/PUBLIC_UPPER_TIME (default 500/3600s)

Tests: 52 new tests (29 unit for PublicPathMatcher, 12 unit for
PublicAccessListener, 11 functional for PublicAccessFlowTest).
Total: 293 tests, 605 assertions, all passing.
PHP CS Fixer: 0 of 63 files need fixing.

Documentation: README, CHANGELOG, ROADMAP, Caddyfile, example.env
all updated with public access configuration and examples.
2026-08-12 09:26:50 -04:00
lyra 9ad54f8e2a feat: configurable Remote-User header (design consideration 1.2)
Sync GitHub / sync (push) Successful in 7s
Tests / test (pull_request) Successful in 58s
Add REMOTE_USER env var with four modes:
- session (default): sends session id, backward-compatible
- static: sends a fixed string (REMOTE_USER_STATIC)
- mapped: looks up session id in REMOTE_USER_MAP
- none: omits the header entirely

New RemoteUserMode enum, ConfigBag parsing/validation, and
StringTrait::authSuccessResponse resolves the header value based
on the configured mode. AcceptListener now receives ConfigBag as
a constructor dependency.

Addresses design consideration 1.2 (Remote-User header value is
user-controlled) from DESIGN_CONSIDERATIONS.md.

241 tests pass, 0 cs-fixer violations.
2026-08-11 22:48:52 -04:00
lyra d2eb914637 fix: must-fix items for v1.0 release
Security:
- Add SecurityHeadersListener (X-Content-Type-Options, X-Frame-Options,
  CSP, Referrer-Policy, HSTS)
- Replace document.write() with document.documentElement.innerHTML
  in login JS to avoid CSP violations
- Add CSS escaping (|e('css')) to env color values in _style.html.twig
- Document CSRF protection model: nonce serves as CSRF token for POST
  form path (single-use, server-generated, 120s TTL)
- Reduce TOTP verification window from 10 periods (±5 min) to 1 (±30s)
- Remove hardcoded APP_SECRET from bin/franken.sh (now uses env or
  generates random)
- Remove backup code values from debug log output
- Add .env to .gitignore

Bug fixes:
- Fix ->json access on possibly-null  in LoginListener
  (uses null-safe operator ?->)
- Fix validReturn() not checking false from parse_url (could cause
  TypeError on malformed URLs)
- Add isHit() race condition check in AcceptListener and AllowListener
- Add try/finally in Kernel::terminate() so parent::terminate() always
  runs even if persist() throws
- Add input validation to GenerateBackupCodesCommand (reject count < 1)
- Use Response::HTTP_INTERNAL_SERVER_ERROR constant in GetTotpTrait
  instead of literal 500

Docker/CI:
- Explicitly install curl in Docker final image (needed for healthcheck)
- Update workflow tag pattern to v*.*.* (standardize on v-prefix)
- Extract version without v-prefix for Docker image tag
- Remove stale develop branch from CI triggers
- Fix publish.yaml git remote add to use set-url on re-runs

Code quality:
- Add declare(strict_types=1) to all interface files
- Add #[AsCommand] attribute to GenerateBackupCodesCommand
- Fix BackupCodeInterface default count to match implementation (10)
- Lowercase host before TLD lookup in DomainManager
- Expand TLD list with many missing multi-part TLDs (.com.au, .co.jp,
  .com.br, .co.kr, .com.tw, .co.za, etc.) to prevent open redirect
  vulnerabilities
- Disable unused Symfony sessions in framework.yaml

Tests:
- Update DomainManagerTest for corrected TLD parsing (.com.au, .co.jp,
  .com.br now correctly recognized as multi-part)
- Update GetTotpTraitTest for corrected error message
- Update GenerateBackupCodesCommandTest: zero count now throws exception
2026-08-11 16:33:00 -04:00
lyra cb378e20bc chore: add php-cs-fixer with PSR-12 config and CI check
Sync GitHub / sync (push) Failing after 5s
Tests / test (pull_request) Successful in 49s
- Add friendsofphp/php-cs-fixer to require-dev
- Create .php-cs-fixer.dist.php configured for @PSR12 ruleset
- Add php-cs-fixer dry-run step to CI pipeline
- Auto-fix existing PSR-12 violations
- Document code style tooling in readme.md
2026-08-11 08:30:05 -04:00
lyra 12ba6cde7b Add unit tests for untested edge cases and code paths
Sync GitHub / sync (push) Successful in 9s
PayloadTest:
- decode() with invalid JSON, JSON array, null, boolean, number
- load() with all fields present but empty strings
- create() with invalid scope string (falls back to cookie)
- create() with missing json property (defaults to true)

DomainManagerTest:
- TLD table coverage: com.au, co.jp, com.br, co.nz, com.mx, co.in, br.com
- Single-label host matching behavior
- Empty string host
- validReturn with URL containing port, without path, different domain with port

MakeNonceTraitTest:
- Retry-then-succeed path (first attempt collides, second succeeds)
- Zero retries throws immediately on collision

StringTraitTest:
- Only invalid characters collapses to single underscore
- Exactly 128 characters boundary
- Multibyte characters (é, ö) replaced with underscore
- Emoji replaced with underscore

GetTotpTraitTest:
- HOTP URI triggers HttpException(500) via instanceof check

LoginManagerTest:
- ULID collision throws HttpException(500)
- Central auth cookie sets domain on matching host (uses auth cookie name)
- Central auth cookie uses null domain on non-matching host
- Empty return parameter falls back to request path

BackupCodeManagerTest:
- Empty string code returns false
- Code with value false (consumed) verified via cache state
- Generated codes are unique (50 codes)
- Code length equals TOTP digits + 2

MonitorCacheKeysTest:
- saveDeferred on __key_list and __chg_list throws OutOfBoundsException
- getKeys returns empty when key list is lost
- deleteItem/deleteItems return true assertions

PersistCacheTest:
- Mixed updates and removals in same persist cycle
- Multiple boot-modify-persist cycles with interleaved modifications
2026-08-06 06:10:31 -04:00
lyra 4d314bcb28 Add unit tests for all listeners, the backup-codes command, and test support helpers
- Add ListenerTestHelper providing a real Twig Environment pointed at the
  project templates and configurable RateLimiterFactoryInterface doubles
  (fixed-remaining and counting variants)
- Add AcceptListenerTest: valid cookie session (host & auth-subdomain cookie
  names), missing/empty cookie, no session
- Add AllowListenerTest: valid IP session, missing session, IP access disabled
- Add RejectListenerTest: blocked -> teapot / too-many-requests, unblocked,
  error template content
- Add InterceptListenerTest: central-auth redirect, login page rendering,
  post form on auth subdomain, invalid-cookie pruning (host & auth cookies)
- Add LoginListenerTest: no-login early return, successful login via header
  and via POST to auth subdomain, failed login (json/html), rate-limited
  (teapot/too-many), invalid payload handling
- Add GenerateBackupCodesCommandTest: default/specific count, default argument
  is 10, boot+persist invocation, zero codes, name & description configuration
- Fix MakeNonceTraitTest nonce cache lookups to use makeCacheKey() (was flaky
  when random nonce contained '-')
2026-08-05 16:55:28 -04:00
lyra 890cc225ef Add PHPUnit test suite and supporting infrastructure
- Add phpunit/phpunit ^13.2, symfony/browser-kit and symfony/css-selector
  to require-dev, plus the autoload-dev mapping for App\Tests- Add phpunit.dist.xml (strict deprecation/notice/warning failures,
  APP_ENV=test forced) and .env.test / bin/phpunit / tests/bootstrap.php
  from the PHPUnit recipe
- Add tests/Support/TotpTestHelper providing a deterministic TOTP
  fixture, frozen clock and ConfigBag/cache-pool helpers
- Add 121 unit tests covering Clock, ConfigBag, Data/Payload, Enum/Scope,
  MonitorCacheKeys, PersistCache, Utilities, all five Traits and the
  three Service managers (BackupCode, Domain, Login)
- Fix LoginManagerTest nonce lookups to use makeCacheKey() so the cache
  key matches the one the manager actually reads/writes
- Gitignore bin/.phpunit.result.cache
2026-08-05 16:52:44 -04:00