Author SHA1 Message Date
lyra ffb824c652 Add the passkey listener at priority 70
Priority 70 sits after RejectListener (77) and before LoginListener (66), and
both bounds are load-bearing:

- After 77 so a rate-limited IP never reaches a ceremony. Passkeys cannot be
  used to sidestep a lockout (D3), which is the point of the reviewer's third
  clarification.
- Before 66 because LoginListener treats any POST to the auth subdomain as a
  login attempt. A ceremony finish body has no username/totp, so Payload::load()
  returns null and the request would be scored as a failed login, burning a
  rate-limit token for every legitimate passkey login.

Verified in the live container rather than assumed: debug:event-dispatcher
confirms 77 -> 70 -> 66.

Other properties asserted by tests: every header-bearing request gets a JSON
response so fetch() callers never receive HTML; registration identity comes from
the live session, never the request body; a failed ceremony is indistinguishable
from a wrong TOTP code and spends the same shared budget; and begin is bounded
by a separate resource guard that deliberately does not consume failure budget.

The listener also marks its responses so SecurityHeadersListener can apply
no-store: these are the only browser-facing 2xx this application produces, since
the auth subdomain has no forward_auth in front of it. CSP gains
publickey-credentials-get/-create only when passkeys are available, so the
unavailable case stays byte-identical to before.
2026-09-27 10:50:09 +00:00
lyra fed7b1b48c Extract session issuing so both login paths share it
SessionIssuer now owns 'grant access after authenticating', which LoginManager
previously did internally. The passkey ceremony needs the same behaviour, and
two implementations would inevitably drift — most likely in cookie attributes,
where a difference stays invisible until it breaks in a browser.

LoginManager keeps what is specific to code-based login: verifying the TOTP or
backup code and enforcing the single-use nonce. Its 18 existing tests pass
unchanged, which is the evidence that this is behaviour-preserving rather than a
rewrite.

Also folds the redundant early-return into a single guard in checkToken so the
success path reads straight through.
2026-09-27 10:45:58 +00:00
lyra 436450cdc2 Add passkey ceremony store and manager
Builds both WebAuthn ceremonies on top of the library, with real cryptography
proven in tests rather than stubbed:

- PasskeyCeremonyStore: server-authoritative, single-use challenge state in the
  nonceCache pool. The client's challenge copy is never trusted, and consume()
  deletes before verifying so a replay cannot retry the same challenge.
- PasskeyManager: registration and login ceremonies. Library types are confined
  to this class and PasskeyCeremonyFactory. Failures return null rather than
  distinguishing unknown-credential from bad-signature, so the endpoint is not
  an enumeration oracle.
- PasskeyTestHelper: builds genuinely valid ceremonies (real P-256 keypair,
  COSE key, signed authenticatorData, CBOR attestation object).
- PasskeyRealCryptoSpikeTest: proves registration and assertion verify, that
  http:// origins are refused (D4), that challenges and rpIdHash are bound, and
  that a synchronised passkey with a constant zero counter can log in repeatedly.
2026-09-27 10:42:59 +00:00
lyra 69609db8af Use a lenient signature-counter check for passkeys
The library default (ThrowExceptionIfInvalid) requires the reported counter to
be strictly greater than the stored one. Synchronised passkeys report a
constant 0 forever, so the default rejects a brand-new credential on its first
login — and only on real hardware, never in a unit test that increments the
counter.

The replacement still rejects a counter that moves backwards, which is the only
signal the counter can carry. Clone detection remains explicitly not a property
this feature claims; see SECURITY.md.
2026-09-27 10:38:02 +00:00
lyra c84cf8c308 Add passkey credential store
Persistence for registered passkeys, backed by sessionCache so credentials
survive a container restart the way sessions do.

The pool is wrapped in MonitorCacheKeys, matching LoginManager and
BackupCodeManager. Without that wrapper the credentials would live only in the
APCu-side pool and vanish on the next restart, because PersistCache::persist()
only flushes keys a monitor recorded. A test asserts visibility to the
persistent pool rather than trusting the wrapper.

Two storage hazards found while building this and covered by tests:

  - makeCacheKey() is not injective for base64url. It collapses the whole
    punctuation alphabet to "_", so "abc-def" and "abc_def" would share one
    cache slot and one credential would silently overwrite the other.
    Credential ids are therefore hashed, and a test uses precisely that pair.
  - A record's own credential id is authoritative. An index entry pointing at
    a record that disagrees with its key is rejected rather than trusted.

Unreadable or wrong-shaped entries degrade to "credential unavailable" so a
corrupt value cannot 500 the login page.

PasskeyCeremonyFactory is the single seam onto webauthn-lib: it builds the
serializer and pins attestation to `none` only, so a future version that moves
or renames library types touches one file.

Suite: 353 tests / 831 assertions, 100% coverage on all new files.
phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.
2026-09-27 02:40:31 +00:00
lyra 108e9623e6 Add passkey configuration and availability policy (inert)
Groundwork for passkey authentication, with the feature switched off by
default and no behaviour change when it is off.

Decision D1: passkeys require central authentication. A passkey is scoped to
a relying party spanning the base domain, which only exists when
SUBDOMAIN_REDIRECT is on and AUTH_SUBDOMAIN resolves to a base domain. The
RP ID is therefore always that base domain, never the request host.

Decision D4: HTTPS is required and is not exemptible. The allowed origin is
built as https://{authSubdomain} from configuration and never from the
request, so an http:// origin cannot be accepted, and isAvailableFor()
additionally refuses to offer the UI on a non-secure connection. The
deprecated setSecuredRelyingPartyId() escape hatch is not used and there is
deliberately no override that could reintroduce one.

Enabling PASSKEY_ENABLED without a usable configuration is a hard error via
a non-optional cache warmer, because entrypoint.sh runs cache:warmup on every
production boot: a misconfigured deployment fails to start instead of
offering a button that cannot work.

Also drops 12 obsolete phpstan-baseline entries for TotpTestHelper: adding
#[\Override] to its anonymous clock removed the rule violation at its source
rather than suppressing it.

Suite: 333 tests / 770 assertions (was 313 / 738), 100% coverage on new
files. phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.
2026-09-27 02:37:07 +00:00
lyra 0458d9b8d2 minor text formatting change
Sync GitHub / sync (push) Successful in 7s
Docker Publish / docker (push) Successful in 20s
Push Develop / docker (push) Successful in 20s
PHP Test / test (push) Successful in 50s
Tests / test (push) Successful in 50s
2026-09-25 19:22:33 -04:00
andrew 8cd06838d3 trailing comma for code standards
Sync GitHub / sync (push) Successful in 7s
PHP Test / test (push) Successful in 54s
Tests / test (push) Successful in 55s
Docker Publish / docker (push) Successful in 56s
Push Develop / docker (push) Successful in 56s
Docker Publish / docker (push) Successful in 23s
Push Docker / docker (push) Successful in 23s
2026-09-25 12:42:25 -04:00
andrew 54990dafc6 Using cache pool by variable name is deprecated,
Sync GitHub / sync (push) Successful in 7s
PHP Test / test (push) Failing after 54s
Tests / test (push) Failing after 54s
Docker Publish / docker (push) Successful in 58s
Push Develop / docker (push) Successful in 58s
so we've added target attributes.
2026-09-25 12:32:43 -04:00
andrew 06e2ca8b19 Merge pull request 'chore(ci): re-vendor conformance.sh so the audit/validate checks see the shared-workflow pin' (#19) from fix/conformance-revendor into main
Sync GitHub / sync (push) Successful in 7s
Docker Publish / docker (push) Successful in 21s
Push Develop / docker (push) Successful in 21s
PHP Test / test (push) Successful in 51s
Tests / test (push) Successful in 51s
Reviewed-on: #19
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-09-25 11:53:23 -04:00
lyra 4d5afa20e9 chore(ci): re-vendor conformance.sh (follow the shared-workflow pin)
PHP Test / test (pull_request) Successful in 54s
Tests / test (pull_request) Successful in 54s
private/ci gained the fix that makes ci-composer-audit / ci-composer-validate
follow a project's shared-workflow pin (`uses: private/ci/.../php-test.yaml@v1`)
instead of grepping for inline workflow text that an adopted repo no longer
contains.

That file is VENDORED into this repo at .ci/ (by design — §8.2, so CI never
fetches from the LAN-only private/ci at run time), so moving the v1 tag
updated the workflow but not this copy. Re-vendored with sync-configs.sh.

Before: 2 of 34 checks failed (the two false positives).
After:  All 35 runnable checks passed.

Also brings .ci/validate-bake.py, which the new checker's bake-target-exists
check needs; without it that check correctly reports SKIPPED rather than
silently passing.
2026-09-25 15:27:18 +00:00
andrew 74902c0fc7 adding intl
Sync GitHub / sync (push) Successful in 9s
PHP Test / test (push) Successful in 1m11s
Tests / test (push) Successful in 1m11s
Docker Publish / docker (push) Successful in 17m19s
Push Develop / docker (push) Successful in 17m19s
2026-09-25 10:22:08 -04:00
andrew bd2f5cbac3 removed problematic opcache line from dockerfile
Sync GitHub / sync (push) Successful in 7s
Docker Publish / docker (push) Successful in 7m6s
Push Develop / docker (push) Successful in 7m6s
PHP Test / test (push) Successful in 1m5s
Tests / test (push) Successful in 1m5s
2026-09-25 09:49:46 -04:00
andrew 51e921f54f now syncing github via shared workflow
Sync GitHub / sync (push) Successful in 7s
Docker Publish / docker (push) Failing after 49s
Push Develop / docker (push) Failing after 49s
PHP Test / test (push) Successful in 1m10s
Tests / test (push) Successful in 1m10s
2026-09-25 09:03:42 -04:00
andrew ba8924a244 Merge pull request 'docker to be more inline with other projects and best practices' (#18) from feat/refactoring into main
Sync GitHub / sync (push) Successful in 8s
Docker Publish / docker (push) Failing after 54s
Push Develop / docker (push) Failing after 54s
PHP Test / test (push) Successful in 57s
Tests / test (push) Successful in 57s
Reviewed-on: #18
2026-09-25 08:50:24 -04:00
lyra 538bd74100 docker to be more inline with other projects and best practices
PHP Test / test (pull_request) Successful in 52s
Tests / test (pull_request) Successful in 52s
2026-09-25 08:25:55 -04:00
andrew db0cf77049 normalizing workflow and build files
Sync GitHub / sync (push) Successful in 6s
Docker Publish / docker (push) Successful in 18s
Push Develop / docker (push) Successful in 18s
PHP Test / test (push) Successful in 1m5s
Tests / test (push) Successful in 1m5s
2026-09-24 16:41:42 -04:00
lyra ef924472a3 normalizing workflow content
Sync GitHub / sync (push) Successful in 7s
Docker Publish / docker (push) Successful in 22s
Push Develop / docker (push) Successful in 23s
Tests / test (push) Successful in 1m19s
2026-09-24 09:05:07 -04:00
lyra 539385c438 finish moving to central CI.
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m12s
Docker Publish / docker (push) Successful in 21s
Push Develop / docker (push) Successful in 22s
2026-09-23 17:37:49 -04:00
lyra eed6b4dbff moment of truth, trying to use central CI for docker image build, starting with just develop.
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m12s
Docker Publish / docker (push) Successful in 5m1s
Push Develop / docker (push) Successful in 5m1s
2026-09-23 17:34:15 -04:00
andrew 2b61a43f60 Merge pull request 'build: define the Docker build in docker-bake.hcl' (#17) from chore/docker-bake into main
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m19s
Push Develop / docker (push) Successful in 4m41s
Reviewed-on: #17
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-09-23 17:30:04 -04:00
lyra abe94c6238 build: define the Docker build in docker-bake.hcl
Tests / test (pull_request) Successful in 1m15s
The build was described in two places kept in step by hand: the Dockerfile, and
the `with:` block of each docker workflow file. This moves the parts CI actually
decides — Dockerfile target, platforms, tags, build args — into docker-bake.hcl,
so the build is version-controlled with the code and reviewable in a diff.

Tags are byte-identical to the current workflows, verified with
`buildx bake --print`:

  main push   -> digitaladapt/preauth:develop
  tag v1.2.0  -> digitaladapt/preauth:latest AND digitaladapt/preauth:1.2.0

Dockerfile: the final stage gains `AS app` so bake can target it. Naming the
last stage is a no-op for a plain `docker build` — it is still the default
target — so `docker build .` behaves exactly as before.

THREE THINGS FOUND WHILE DOING THIS
-----------------------------------

1. `buildx bake --print` does NOT read the Dockerfile, so it accepts a `target`
   naming no stage. preauth's final stage was unnamed, so `target = "app"` would
   have failed at build time in CI *after the push*, with --print reporting
   success. Fixed by naming the stage.

2. The `bake` backend in docker-publish.yaml sets no layer cache, while the
   `action` backend it replaces sets cache-from/cache-to: type=gha. Neither
   existing bake user in the portfolio sets one either, so every bake build in
   the portfolio runs cold. preauth compiles APCu from source (pecl) in both
   stages, so this would have been a real regression. The bake file now sets the
   gha cache, with the override for local builds documented.

3. `ARG MAX_REQUESTS` has been documented in the Dockerfile since the Symfony
   8.1 upgrade, but no workflow ever passed it, so CI silently built with the
   500 default. It is now an explicit named variable, overridable per build.

The workflow callers that select the bake backend are NOT in this commit:
.gitea/workflows/ is protected by a pre-receive hook and only changes via a
trusted ref. They are staged in the working tree.

Verified: Dockerfile stage/target contract holds; bake file parses and resolves
under buildx 0.37; 313 tests pass; conformance 31/34 with callers staged.
2026-09-23 21:01:53 +00:00
andrew 1e186c9354 Merge pull request 'chore: adopt shared Guiding Light configs, fix conformance gaps (18/34 → 30/34)' (#16) from chore/adopt-guiding-light into main
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 1m8s
Push Develop / docker (push) Successful in 6m53s
Reviewed-on: #16
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-09-23 16:40:51 -04:00
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
andrew 054b8ef48f Merge pull request 'fix: never cache the login flow (stale pre-auth responses in Safari)' (#12) from fix/login-flow-cache-control into main
Sync GitHub / sync (push) Successful in 10s
Tests / test (push) Successful in 1m35s
Push Develop / docker (push) Successful in 6m3s
Push Docker / docker (push) Successful in 8m45s
Reviewed-on: #12
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-09-14 21:56:59 -04: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
andrew 2f7ae31ba1 Merge pull request 'feat: upgrade Symfony 7.4 → 8.1, drop runtime/frankenphp-symfony' (#11) from feat/symfony-8.1-upgrade-plan into main
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 1m21s
Push Develop / docker (push) Successful in 4m49s
Reviewed-on: #11
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-09-12 16:51:29 -04:00
lyra baf976a8e6 feat: restore worker recycle limit via Caddyfile max_requests (default 500)
Tests / test (pull_request) Successful in 1m4s
The removed runtime/frankenphp-symfony package force-restarted the
worker after FRANKENPHP_LOOP_MAX requests (default 500) - memory-leak
paranoia the built-in 8.1 runner intentionally doesn't reimplement.

Restore the behavior natively so the upgrade is a no-op operationally:
- Caddyfile: global frankenphp block with max_requests {$MAX_REQUESTS}
  - per-thread graceful restarts, other threads keep serving
- Dockerfile: ARG/ENV MAX_REQUESTS=500 bakes the same default the old
  package had, overridable at docker build or runtime (-e, 0 disables)
- Stock FRANKENPHP_CONFIG env var remains the full-config escape hatch
- Documented in readme env table, docs/example.env, CHANGELOG, and the
  upgrade plan (phase-4 staging note now checks thread recycling)
2026-09-08 06:15:20 -04:00
lyra af4d2a4ac7 docs: record Symfony 8.1 upgrade results, update stack references
Tests / test (pull_request) Successful in 1m7s
- Plan doc: mark Phases 0-3 + worker-mode state audit complete with
  verified results; resolve open questions (all src/ services are final
  readonly - no ResetInterface needed under kernel-reusing runner)
- CHANGELOG: Unreleased entries for the 8.1 bump and the
  runtime/frankenphp-symfony removal (incl. FRANKENPHP_LOOP_MAX note)
- ROADMAP: current stack now Symfony 8.1; webauthn research note updated
- bin/dev.sh: correct stale PHP requirement comment
2026-09-07 22:46:22 -04:00
lyra c743a1baac feat: upgrade Symfony 7.4 -> 8.1, drop runtime/frankenphp-symfony
- All symfony/* constraints bumped 7.4.* -> 8.1.* (resolved to 8.1.2-8.1.6)
- Removed runtime/frankenphp-symfony: symfony/runtime 8.1 handles
  FrankenPHP worker mode natively via FrankenPhpWorkerRunner; deleted
  the extra.runtime class override so GenericRuntime auto-detects it
- phpunit 13.3.2, twig 3.28, otphp 11.5 ride along
- Deprecation sweep on 7.4 was clean (failOnDeprecation suite, zero hits)
- Verified: bin/console about boots on 8.1.6; 295 tests / 612 assertions
  green after the bump
2026-09-07 22:40:17 -04:00
lyra 3f1778cd6b docs: add Symfony 7.4 -> 8.1 upgrade plan
Covers deprecation sweep gate, composer.json constraint bumps to 8.1.*,
and removal of runtime/frankenphp-symfony (FrankenPHP worker mode is
handled natively by symfony/runtime in 8.x). Includes config recipe
re-sync, Docker/CI notes, phased rollout with rollback, and open
questions (kernel-reset audit for worker mode).
2026-09-07 22:26:00 -04:00
andrew 33181f11d8 testing branch is safe now that git hook protections have been setup
Sync GitHub / sync (push) Successful in 10s
Tests / test (push) Successful in 1m10s
Push Develop / docker (push) Successful in 4m44s
Push Docker / docker (push) Successful in 4m53s
2026-08-30 22:24:50 -04:00
andrew bb2cc3ce49 only test and push select branches
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 1m9s
Push Develop / docker (push) Successful in 4m48s
2026-08-27 21:55:07 -04:00
andrew e4f54769e6 use main branch workflow
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m1s
Push Develop / docker (push) Successful in 4m45s
Push Docker / docker (push) Successful in 10m28s
2026-08-25 20:22:12 -04:00
andrew b75a16a781 Merge remote-tracking branch 'origin/fix/ci-github-rate-limit' into develop
Sync GitHub / sync (push) Successful in 8s
Push Develop / docker (push) Successful in 4m55s
Tests / test (push) Successful in 59s
Push Docker / docker (push) Successful in 4m42s
2026-08-21 16:11:31 -04:00
andrew 9111958bcf Merge branch 'main' into develop 2026-08-21 16:10:30 -04:00
andrew 95dc6bf0ce Merge branch 'main' into fix/ci-github-rate-limit
Sync GitHub / sync (push) Successful in 6s
2026-08-20 16:27:52 -04:00
lyra 472abfdf89 fix(ci): cache composer deps and authenticate to GitHub
Tests / test (pull_request) Successful in 1m21s
Tests / test (push) Successful in 1m30s
Push Develop / docker (push) Successful in 5m12s
Sync GitHub / sync (push) Successful in 9s
The test workflow was hitting GitHub's unauthenticated API rate
limit (60 req/hour) when downloading 95 packages via composer
install --prefer-dist, causing 429 Too Many Requests errors.

Two fixes applied:
1. Cache Composer's download cache (~/.composer/cache) keyed on
   composer.lock hash, so repeated CI runs don't re-download
   packages at all.
2. Configure GitHub OAuth token via SYNC_GITHUB_TOKEN secret to
   raise the rate limit to 5,000 req/hour for cache misses.
2026-08-17 12:03:14 -04:00
lyra e2780ca5f6 fix: allow same-origin fetch in CSP when inline login script is used
Tests / test (pull_request) Successful in 49s
Sync GitHub / sync (push) Successful in 9s
Tests / test (push) Successful in 1m1s
Push Develop / docker (push) Successful in 4m47s
Push Docker / docker (push) Successful in 7m35s
When subdomain redirection is off, the login form is served inline on
the protected host and submission happens via a same-origin fetch() call
in _script.html.twig. The CSP default-src 'none' was blocking that
fetch (connect-src falls back to default-src).

Add connect-src 'self' to the CSP only when the request is not on the
auth subdomain (i.e. when the inline script is present). On the auth
subdomain the form POSTs normally with no inline script, so the stricter
policy still applies there.

This is the least-privilege relaxation: only same-origin connections,
only on pages that need them.
2026-08-17 11:27:46 -04:00
andrew 7a68c933ce Merge pull request 'fix: handle host-prefixed root path in PublicPathMatcher' (#8) from fix/public-path-host-root into main
Push Develop / docker (push) Successful in 4m44s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 45s
Push Docker / docker (push) Successful in 4m43s
Reviewed-on: #8
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-08-13 16:28:05 -04:00
andrew 55f8e9e84c Merge branch 'main' into fix/public-path-host-root
Sync GitHub / sync (push) Successful in 9s
Tests / test (pull_request) Successful in 1m16s
2026-08-13 15:43:54 -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 235a7866b3 fix: remove incorrect CSS escaping on color values
Tests / test (pull_request) Successful in 1m5s
Push Develop / docker (push) Successful in 6m43s
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 54s
The Twig |e('css') filter was escaping '#' (0x23) to '\23 ' in hex
color values (bg_color, fg_color, error_color), causing browsers to
not recognize them as valid CSS colors. These are admin-configured
environment variables, not user input, so CSS escaping is unnecessary.
2026-08-13 12:07:20 -04:00
lyra c7585e720a feat: add self-bootstrapping dev server script (bin/dev.sh)
Tests / test (pull_request) Successful in 50s
Push Develop / docker (push) Successful in 8m39s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 57s
Manages a local PHP dev server for end-to-end development and testing.
Binds to 0.0.0.0:8773, accessible via Caddy at
https://preauth.lyra-dev.devgnome.com.

Features:
- Self-bootstrapping: installs PHP 8.4 + extensions (including APCu,
  which is critical for nonce cache, rate limiter, and session storage),
  Composer, and project dependencies if missing. Survives terminal
  resets/reboots.
- Enables apc.enable_cli=1 for console commands (matches Dockerfile)
- Subcommands: start, stop, status, restart
- Sets APP_SHARE_DIR to var/share for filesystem session persistence
- Clears dev cache on start

No database needed — preauth uses APCu + filesystem cache exclusively.

Port assignment: P-R-E = 7-7-3 → 8773
2026-08-13 11:56:55 -04:00
andrew 66b960ccea Merge pull request 'feat: v1.1 — public rate-limited access' (#5) from feat/v1.1-public-access into main
Push Develop / docker (push) Successful in 4m47s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 55s
Push Docker / docker (push) Successful in 4m46s
Reviewed-on: #5
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-08-13 01:30:13 -04:00
andrew 17c2d525ff Merge branch 'main' into feat/v1.1-public-access
Sync GitHub / sync (push) Successful in 13s
Tests / test (pull_request) Successful in 54s
2026-08-13 01:19:08 -04:00
lyra 29e471c536 Merge branch 'fix/v1.0-must-fix' into feat/v1.1-public-access
Push Develop / docker (push) Successful in 6m1s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m3s
2026-08-12 11:21:42 -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
131 changed files with 13089 additions and 1814 deletions
+554
View File
@@ -0,0 +1,554 @@
#!/usr/bin/env bash
#
# conformance.sh — checks a Symfony project against the shared standard.
#
# Usage:
# conformance.sh --profile=web-app|auth-gateway|api-gateway [--json] [path]
#
# Design rules (GUIDING-LIGHT §8.2):
# 1. It only CHECKS. It never fixes anything. No remediation logic to maintain.
# 2. Checks are ADDED, never removed. The script can only get stricter. If a
# check is wrong, fix the check — don't delete it from a repo.
# 3. Every check names the document section it comes from, so a failure tells
# you WHY the rule exists, not just that you broke it.
#
# Exit codes: 0 = all passed, 1 = at least one failure.
#
# DEPENDENCIES: bash + coreutils + grep for everything except one check.
# `controls-16px-min-css` shells out to css-control-size.py because resolving
# rem/em/font-shorthand units correctly is not something grep can do — and
# getting it wrong silently misses the single most important regression in
# this codebase (vital-pulse's 0.95rem inputs). python3 is present on every
# GitHub/Gitea runner and in the setup-php images, so this is a safe
# dependency; if it is ever missing, that one check is SKIPPED with a warning
# rather than failed, so the rest of the suite still runs.
set -uo pipefail
PROFILE=""
OUTPUT_JSON=false
TARGET=""
# Directory this script lives in, so helper tools can be located regardless of
# the CWD the caller is in (CI runs it from the project root).
CONFORMANCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export CONFORMANCE_DIR
for arg in "$@"; do
case "$arg" in
--profile=*) PROFILE="${arg#*=}" ;;
--json) OUTPUT_JSON=true ;;
-h|--help)
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
-*) echo "Unknown flag: $arg" >&2; exit 2 ;;
*) TARGET="$arg" ;;
esac
done
PROFILE="${PROFILE:-web-app}"
TARGET="${TARGET:-.}"
cd "$TARGET" || { echo "Cannot cd to $TARGET" >&2; exit 2; }
case "$PROFILE" in
web-app|auth-gateway|api-gateway) ;;
*) echo "Invalid --profile: $PROFILE (expected web-app, auth-gateway, or api-gateway)" >&2; exit 2 ;;
esac
PASS=0
FAIL=0
SKIP=0
declare -a FAILURES=()
declare -a SKIPPED=()
# check <id> <description> <doc-section> <test-command...>
# The test command must exit 0 to pass.
check() {
local id="$1" desc="$2" section="$3"
shift 3
if "$@" >/dev/null 2>&1; then
PASS=$((PASS + 1))
$OUTPUT_JSON || printf ' \033[32m✓\033[0m %s\n' "$id"
else
FAIL=$((FAIL + 1))
FAILURES+=("$id|$desc|$section")
$OUTPUT_JSON || printf ' \033[31m✗\033[0m %s — %s [%s]\n' "$id" "$desc" "$section"
fi
}
# check_opt <id> <desc> <section> <prereq-cmd> <real-cmd...>
#
# For checks that depend on optional tooling. If the prerequisite is missing
# the check is reported as SKIPPED — its own state, never a green tick.
# A silently-passing check is the most dangerous outcome here: it makes a repo
# look compliant while the check never actually ran.
check_opt() {
local id="$1" desc="$2" section="$3" prereq="$4"
shift 4
# `prereq` is a SHELL EXPRESSION STRING, evaluated with eval.
#
# It must not be a multi-word command: `$prereq` is a single variable, so
# passing `bash -c '...'` would bind only `bash` and run it bare — and a
# bare `bash` reads stdin and BLOCKS FOREVER. That turns a broken check into
# a hung CI job (which only surfaces when the job timeout kills it).
if ! eval "$prereq" >/dev/null 2>&1; then
SKIP=$((SKIP + 1))
SKIPPED+=("$id|$desc|$section")
$OUTPUT_JSON || printf ' \033[33m–\033[0m %s — SKIPPED (prerequisite unavailable) [%s]\n' "$id" "$section"
return
fi
check "$id" "$desc" "$section" "$@"
}
# has_file <path>
has_file() { [ -f "$1" ]; }
# not_has_file <path>
not_has_file() { [ ! -f "$1" ]; }
# file_contains <path> <pattern> (silently false if path missing)
file_contains() { [ -f "$1" ] && grep -qE "$2" "$1"; }
# any_file_contains <pattern> <path...>
#
# NOTE: matches anywhere in the file, INCLUDING comments. That is correct for
# rules about the mere presence of a string, and WRONG for rules about a
# directive. When checking a directive ("CI calls X"), anchor the pattern to
# the line form that the directive actually takes — see ci-reusable-workflows
# below for why that matters.
any_file_contains() {
local pat="$1"; shift
grep -rlE "$pat" "$@" >/dev/null 2>&1
}
# caller_disables <knob> <value>
#
# True when one of the repo's own *php-test pins* sets a shared-workflow input
# to <value> — `run-composer-audit: 'false'`, `coverage: 'none'`. Accepts the
# bare YAML boolean spelling and both quote styles, ignores indentation and
# trailing comments, and only reads files that pin php-test.yaml (a
# docker-publish caller's knobs are unrelated).
#
# "Any pin disables it" is deliberate: one workflow that switches the step off
# is enough to make the check un-satisfied. Refusing to accept an opt-out
# would turn this fix into a false green, which is the one outcome worse than
# the false red it replaces.
caller_disables() {
local knob="$1" value="$2" got
local pins=()
mapfile -t pins < <(grep -rlE '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows/php-test\.yaml@' .gitea/workflows 2>/dev/null)
[ "${#pins[@]}" -eq 0 ] && return 1
got=$(grep -hE "^[[:space:]]*${knob}:" "${pins[@]}" 2>/dev/null \
| sed 's/#.*//' | sed 's/^[^:]*://' | tr -d "[:space:]\042\047" \
| tr '[:upper:]' '[:lower:]' || true)
printf '%s\n' "$got" | grep -qx "$value"
}
# ci_runs <inline-pattern> [<knob> <disabled-value>]
#
# "CI runs X" checks have TWO legitimate shapes, and only one existed when
# they were written — before the move to shared workflows (§8.2(1)):
#
# 1. INLINE — this repo's own workflow contains the command. Grep it.
# 2. DELEGATED — this repo pins private/ci's php-test.yaml, and the command
# runs there. The text is deliberately NOT in this repo any more; the
# caller is a ~15-line pin by design.
#
# Only shape 1 used to be accepted, which turned every adopted repo's audit
# and validate checks red while the steps genuinely ran — a false positive
# that reported a violation where there was none.
#
# For shape 2 the only local evidence is the knobs the caller passes, so that
# is what gets checked: an explicit opt-out (`run-composer-audit: 'false'`,
# `coverage: 'none'`) means the step does NOT run there, and the check must
# keep failing. A check that accepts a switched-off step is a false green.
#
# Deliberately NOT verified here: that the shared pipeline still contains the
# step. This script is vendored and stays offline (see the header), and
# private/ci is LAN-only, so fetching it at run time would reintroduce exactly
# the silent-no-op dependency that vendoring removed. That half of the
# contract is guarded where the file lives: validate-workflows.py fails
# private/ci's own CI if php-test.yaml loses a step the projects' checks take
# on faith, or if a gate's default flips to disabled.
ci_runs() {
local pattern="$1" knob="${2:-}" disabled="${3:-}"
if grep -rqE '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows/php-test\.yaml@' .gitea/workflows 2>/dev/null; then
# Delegated: the caller's knobs are the only local source of truth.
[ -n "$knob" ] && caller_disables "$knob" "$disabled" && return 1
return 0
fi
any_file_contains "$pattern" .gitea/workflows
}
# no_file_contains <ext-glob> <pattern> — searches source trees only
no_match_in_sources() {
local pattern="$1"; shift
grep -rEl "$pattern" "$@" >/dev/null 2>&1 && return 1 || return 0
}
# dir_exists
dir_exists() { [ -d "$1" ]; }
$OUTPUT_JSON || {
echo ""
echo "Conformance check — profile: $PROFILE — $(pwd)"
echo "Standard: GUIDING-LIGHT.md"
echo ""
}
# ─────────────────────────────────────────────────────────────────────────────
# UNIVERSAL — every repo, every archetype
# ─────────────────────────────────────────────────────────────────────────────
$OUTPUT_JSON || echo "PHP & framework baseline"
check "php-85" \
"composer.json requires PHP 8.5 (use ^8.5, not >=8.4)" \
"§1.1" \
file_contains composer.json '"php"[[:space:]]*:[[:space:]]*"\^8\.5'
check "php-not-open-ended" \
"PHP constraint is not open-ended (>=8.4 allows PHP 9)" \
"§1.1" \
bash -c '! grep -qE "\"php\"[[:space:]]*:[[:space:]]*\">=" composer.json'
check "platform-pinned" \
"config.platform is set in composer.json (prevents silent version drift)" \
"§1.2" \
file_contains composer.json '"platform"'
check "symfony-81" \
"Symfony pinned to 8.1" \
"§1" \
file_contains composer.json 'symfony/framework-bundle":[[:space:]]*"8\.1\.'
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "Toolchain"
check "phpstan-config" \
"phpstan.neon.dist present" \
"§2.2" \
has_file phpstan.neon.dist
check "phpstan-level" \
"PHPStan level >= 6 declared" \
"§2.2" \
bash -c 'grep -qE "level:[[:space:]]*[6-9]|level:[[:space:]]*max" phpstan.neon.dist 2>/dev/null'
check "cs-fixer-config" \
".php-cs-fixer.dist.php present" \
"§2.5" \
has_file .php-cs-fixer.dist.php
check "cs-fixer-pinned" \
"friendsofphp/php-cs-fixer pinned to ^3.95 (not \"*\")" \
"§2.5" \
bash -c '! grep -qE "php-cs-fixer\"[[:space:]]*:[[:space:]]*\"\*\"" composer.json'
check "phpunit-config-name" \
"PHPUnit config named phpunit.dist.xml" \
"§2.4" \
has_file phpunit.dist.xml
check "editorconfig" \
".editorconfig present" \
"§8.10" \
has_file .editorconfig
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "CI & supply chain"
# These three accept the command either inline or via the shared pipeline —
# see ci_runs above for why, and for what is still required of a delegating
# caller (the opt-out knobs must not be set).
check "ci-composer-audit" \
"CI runs 'composer audit'" \
"§8.1" \
ci_runs 'composer audit' run-composer-audit false
check "ci-coverage" \
"CI measures test coverage" \
"§2.3" \
ci_runs 'coverage' coverage none
check "ci-composer-validate" \
"CI runs 'composer validate --strict'" \
"§8.3" \
ci_runs 'composer validate'
# Anchored to a REAL `uses:` line, not the string anywhere in the file.
#
# The previous form matched the string anywhere, which gave it a false-positive
# mode that made it worse than useless: a repo that INLINED the shared workflow
# still carries a header comment saying it was "inlined from
# private/ci/.gitea/workflows/...", so the check went green in exactly the repos
# that had drifted. It reported compliance precisely where compliance was absent.
#
# `uses:` is what the description always claimed to test.
check "ci-reusable-workflows" \
"CI calls shared workflows from private/ci (not five drift surfaces)" \
"§8.2" \
any_file_contains '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows' .gitea/workflows
# A bake file describes WHICH Dockerfile stage to build. buildx does not check
# that the stage exists until build time, and `bake --print` — the obvious way
# to validate one — happily resolves a target that names no stage, because it
# never reads the Dockerfile. So a typo there reaches CI and fails after the
# push. This is the check that buildx is missing.
#
# SKIPPED when there is no bake file: most repos use the `action` backend and
# have none, and absence is not a violation.
#
# The helper belongs in the PREREQUISITE, not only in the command. It used to
# be `[ -f ... ] || exit 0` INSIDE the command, which is a different thing: a
# repo that has a bake file but no vendored helper reported a green tick for a
# check that never ran. That is the one outcome this script's header singles
# out as most dangerous, and it was live — preauth adopted a bake file before
# the `validate-bake.py` half of the re-vendor landed, so its next sync would
# have shown a green `bake-target-exists` no matter what the bake file said.
#
# With the helper in the prerequisite the same state reports SKIPPED — its own
# yellow state, explicitly not a pass. (`css-control-size.py`, the other
# helper, has been wired this way since it was added; see below.)
check_opt "bake-target-exists" \
"docker-bake.hcl targets a stage that exists in the Dockerfile" \
"§6.2" \
'command -v python3 >/dev/null 2>&1 && [ -f docker-bake.hcl ] && [ -f "$CONFORMANCE_DIR/validate-bake.py" ]' \
bash -c 'exec "$CONFORMANCE_DIR/validate-bake.py" docker-bake.hcl Dockerfile'
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "Hygiene & layout"
check "dockerignore-env" \
".env is excluded in .dockerignore (prevents secrets in images)" \
"§6.1" \
file_contains .dockerignore '^/?\.env$'
check "dockerignore-present" \
".dockerignore present" \
"§6" \
has_file .dockerignore
check "gitignore-var" \
"/var/ excluded in .gitignore" \
"§5.2" \
file_contains .gitignore '^/?var/?$'
check "dockerfile-nonroot" \
"Dockerfile drops privileges with USER" \
"§6.4" \
file_contains Dockerfile '^USER '
check "dockerfile-pinned-base" \
"Dockerfile base image is not :latest" \
"§6.4" \
bash -c '! grep -qE "^FROM[^ ]*:latest" Dockerfile'
check "docs-examples" \
"docs/examples/ present" \
"§4.4" \
dir_exists docs/examples
check "boilerplate-security" \
"SECURITY.md present" \
"§7.1" \
has_file SECURITY.md
check "license-file" \
"LICENSE file present" \
"§7.2" \
has_file LICENSE
check "license-mit" \
"LICENSE is MIT (uniform MIT decided §7.2)" \
"§7.2" \
bash -c 'head -1 LICENSE 2>/dev/null | grep -qi "^MIT License"'
# A composer.json license that contradicts the shipped LICENSE file is worse
# than declaring none: tooling trusts the metadata, humans read the file.
# task-weaver declared "proprietary" while shipping no file at all.
check "license-declared-matches" \
"composer.json declares MIT, matching the LICENSE file" \
"§7.2" \
file_contains composer.json '"license"[[:space:]]*:[[:space:]]*"MIT"'
check "no-cdn-references" \
"No CDN script/link references (zero-CDN goal)" \
"§3.5" \
bash -c '! grep -rElE "(cdn\.jsdelivr|cdnjs\.cloudflare|unpkg\.com|cdn\.tailwindcss)" templates public assets config 2>/dev/null | head -1 | grep -q .'
# ─────────────────────────────────────────────────────────────────────────────
# NOT API-GATEWAY — anything that renders HTML
# ─────────────────────────────────────────────────────────────────────────────
if [ "$PROFILE" != "api-gateway" ]; then
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "Accessibility (§3.3a) — the iOS zoom root cause"
check "viewport-not-zoom-locked" \
"Viewport does not disable pinch-zoom (WCAG 1.4.4)" \
"§3.3a" \
bash -c '! grep -rElE "(user-scalable=no|maximum-scale=1)" templates public 2>/dev/null | head -1 | grep -q .'
check "viewport-fit-cover" \
"Viewport declares viewport-fit=cover (safe areas)" \
"§3.3a" \
bash -c 'grep -rElE "viewport-fit=cover" templates public 2>/dev/null | head -1 | grep -q .'
# Two checks, because there are two ways to get this wrong and one grep
# cannot see both:
# (a) raw CSS with a small font-size — needs unit resolution (rem/em/shorthand)
# (b) Tailwind-style utility classes on the control — not CSS at all
# Requires python3 AND the vendored helper. If either is absent this is
# SKIPPED (reported as its own state), never a green tick.
check_opt "controls-16px-min-css" \
"No form control renders below 16px (root cause of iOS auto-zoom)" \
"§3.3a" \
'command -v python3 >/dev/null 2>&1 && [ -f "$CONFORMANCE_DIR/css-control-size.py" ]' \
bash -c '
mapfile -t files < <(find templates assets public -type f \( -name "*.css" -o -name "*.twig" -o -name "*.html" \) 2>/dev/null)
[ "${#files[@]}" -eq 0 ] && exit 0
exec "$CONFORMANCE_DIR/css-control-size.py" 16 "${files[@]}"
'
check "controls-16px-min-utility" \
"No small text utility class on a form control (Tailwind text-xs/text-sm)" \
"§3.3a" \
bash -c '
! grep -rPzoE "<(input|select|textarea)[^>]*class=\"[^\"]*(text-xs|text-sm)[^\"]*\"" templates 2>/dev/null | head -c1 | grep -q .
'
check "security-headers" \
"Security headers configured (Symfony listener or Caddy)" \
"§8.9" \
bash -c 'grep -rElE "X-Content-Type-Options" src config docker Caddyfile 2>/dev/null | head -1 | grep -q .'
check "no-deprecated-xss-header" \
"X-XSS-Protection not used (deprecated)" \
"§8.9" \
bash -c '! grep -rElE "X-XSS-Protection" src config docker Caddyfile 2>/dev/null | head -1 | grep -q .'
fi
# ─────────────────────────────────────────────────────────────────────────────
# WEB-APP ONLY — installable PWA surface
# ─────────────────────────────────────────────────────────────────────────────
if [ "$PROFILE" = "web-app" ]; then
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "PWA (§3.3b)"
check "webmanifest" \
"Web app manifest present" \
"§3.3b" \
bash -c 'ls public/*.webmanifest public/manifest.json 2>/dev/null | head -1 | grep -q .'
check "manifest-display-standalone" \
"Manifest declares display: standalone" \
"§3.3b" \
bash -c 'grep -qE "\"display\"[[:space:]]*:[[:space:]]*\"standalone\"" public/*.webmanifest public/manifest.json 2>/dev/null'
check "manifest-start-url" \
"Manifest declares start_url" \
"§3.3b" \
bash -c 'grep -qE "\"start_url\"" public/*.webmanifest public/manifest.json 2>/dev/null'
check "service-worker" \
"Service worker present (required for installability)" \
"§3.3b" \
bash -c 'ls public/sw.js public/service-worker.js 2>/dev/null | head -1 | grep -q .'
check "theme-color" \
"theme-color meta present (status bar theming)" \
"§3.3a" \
bash -c 'grep -rElE "theme-color" templates public 2>/dev/null | head -1 | grep -q .'
check "touch-action" \
"touch-action: manipulation used (removes 300ms tap delay)" \
"§3.3a" \
bash -c 'grep -rElE "touch-action" templates assets public 2>/dev/null | head -1 | grep -q .'
fi
# ─────────────────────────────────────────────────────────────────────────────
# AUTH-GATEWAY ONLY — preauth-specific security invariants
# ─────────────────────────────────────────────────────────────────────────────
if [ "$PROFILE" = "auth-gateway" ]; then
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "Auth gateway security (§3.3d)"
check "no-service-worker" \
"Auth gateway has NO service worker (must never replay a cached session)" \
"§3.3d" \
bash -c '! ls public/sw.js public/service-worker.js 2>/dev/null | head -1 | grep -q .'
check "no-store-present" \
"no-store cache directive present somewhere (login flow anti-cache guard)" \
"§3.3d" \
bash -c 'grep -rEli "no-store" src config 2>/dev/null | head -1 | grep -q .'
check "rate-limiter" \
"symfony/rate-limiter required" \
"§8.11" \
file_contains composer.json 'rate-limiter'
fi
# ─────────────────────────────────────────────────────────────────────────────
# REPORT
# ─────────────────────────────────────────────────────────────────────────────
TOTAL=$((PASS + FAIL + SKIP))
# JSON strings must be escaped. Without this, a description containing a double
# quote (e.g. 'pinned to ^3.95 (not "*")') produces invalid JSON and silently
# breaks every consumer of --json.
json_escape() {
local s="$1"
s="${s//\\/\\\\}" # backslash first
s="${s//\"/\\\"}" # then double quote
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/}"
printf '%s' "$s"
}
if $OUTPUT_JSON; then
printf '{"profile":"%s","passed":%d,"failed":%d,"skipped":%d,"total":%d,"failures":[' \
"$(json_escape "$PROFILE")" "$PASS" "$FAIL" "$SKIP" "$TOTAL"
first=true
for f in "${FAILURES[@]:-}"; do
[ -z "$f" ] && continue
IFS='|' read -r fid fdesc fsec <<< "$f"
$first || printf ','
printf '{"id":"%s","description":"%s","section":"%s"}' \
"$(json_escape "$fid")" "$(json_escape "$fdesc")" "$(json_escape "$fsec")"
first=false
done
printf '],"skipped_checks":['
first=true
for f in "${SKIPPED[@]:-}"; do
[ -z "$f" ] && continue
IFS='|' read -r fid fdesc fsec <<< "$f"
$first || printf ','
printf '{"id":"%s","description":"%s","section":"%s"}' \
"$(json_escape "$fid")" "$(json_escape "$fdesc")" "$(json_escape "$fsec")"
first=false
done
printf ']}\n'
else
echo ""
echo "─────────────────────────────────────────────"
if [ "$SKIP" -gt 0 ]; then
printf ' \033[33m%d of %d checks SKIPPED\033[0m (missing prerequisites — NOT a pass):\n' "$SKIP" "$TOTAL"
for f in "${SKIPPED[@]}"; do
IFS='|' read -r fid fdesc fsec <<< "$f"
printf ' · [%s] %s\n' "$fsec" "$fid"
done
echo ""
fi
if [ "$FAIL" -eq 0 ]; then
printf ' \033[32mAll %d runnable checks passed.\033[0m\n' "$((TOTAL - SKIP))"
else
printf ' \033[31m%d of %d checks failed.\033[0m\n\n' "$FAIL" "$TOTAL"
echo " Guidance:"
for f in "${FAILURES[@]}"; do
IFS='|' read -r fid fdesc fsec <<< "$f"
printf ' • [%s] %s\n %s\n' "$fsec" "$fdesc" "$fid"
done
fi
echo "─────────────────────────────────────────────"
echo ""
fi
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
css-control-size.py — finds form controls whose rendered font-size is below 16px.
This is the precise version of the check. A naive `grep font-size` on the source
produces both false negatives and false positives:
FALSE NEGATIVE: vital-pulse declares `font-size: 0.95rem` on its inputs.
0.95rem x 16px = 15.2px — under the iOS auto-zoom threshold
— but it never appears as a `px` literal.
FALSE POSITIVE: task-weaver has many `font-size: 11px` rules for table
headers and labels. Those are fine. It's *form controls*
that matter, so only rules whose selector targets a control
should be judged.
FALSE POSITIVE: preauth declares `button, input { font-size: 0.9em }`, which
looks small as an `em` value — but its root is
`html { font-size: 1.5em }` = 24px, so the real rendered
size is 21.6px. `em` must be resolved against the parsed root.
So this walks CSS rules, keeps only control-targeting selectors, resolves
px / rem / em to a pixel value, and reports anything under the threshold.
Usage: css-control-size.py <threshold-px> <file> [file...]
Output: one line per violation: "<file>:<line-ish>\t<selector>\t<raw>\t<computed-px>"
Exit: 0 = clean, 1 = violations found
"""
from __future__ import annotations
import re
import sys
# Selectors that style an actual interactive form control.
CONTROL_RE = re.compile(
r"""(?ix)
(?:^|[\s,>+~\[.(#]) # boundary
(?:
input | select | textarea | button
| \.input\b | \.form-control\b | \.btn\b
| \[type= | \.tag-input\b | \.schedule-number\b
)
""",
)
# font-size: 14px | font-size: 0.95rem | font-size: 0.9em
FONT_SIZE_RE = re.compile(
r"font-size\s*:\s*(?P<val>[0-9]*\.?[0-9]+)\s*(?P<unit>px|rem|em|pt)\b",
re.IGNORECASE,
)
# font: 14px/1.5 ... (shorthand — sets font-size implicitly)
FONT_SHORTHAND_RE = re.compile(
r"font\s*:\s*(?:[^;{}]*?\s)?(?P<val>[0-9]*\.?[0-9]+)\s*(?P<unit>px|rem|em|pt)\b",
re.IGNORECASE,
)
# html { font-size: 1.5em } — establishes the em base.
ROOT_SELECTOR_RE = re.compile(r"(?i)^\s*(?:html|:root)\s*$")
DEFAULT_ROOT_PX = 16.0
PT_TO_PX = 4.0 / 3.0
def strip_comments(css: str) -> str:
return re.sub(r"/\*.*?\*/", " ", css, flags=re.DOTALL)
def parse_rules(css: str):
"""Yield (selector, body, index) for every rule, including nested ones."""
css = strip_comments(css)
stack: list[str] = []
acc = ""
for i, ch in enumerate(css):
if ch == "{":
stack.append(acc.strip())
acc = ""
elif ch == "}":
selector = stack.pop() if stack else ""
if selector:
# Body is what accumulated inside this rule; recover it from
# the source between the opening brace and here.
yield selector, "", i
acc = ""
else:
acc += ch
def parse_rules_with_bodies(css: str):
"""Yield (selector, body). Handles nesting (@media) by tracking depth."""
css = strip_comments(css)
stack: list[str] = []
out: list[tuple[str, str]] = []
acc = ""
body_start: list[int] = []
for i, ch in enumerate(css):
if ch == "{":
stack.append(acc.strip())
body_start.append(i + 1)
acc = ""
elif ch == "}":
if stack:
selector = stack.pop()
start = body_start.pop() if body_start else 0
out.append((selector, css[start:i]))
acc = ""
else:
acc += ch
return out
def resolve_px(value: float, unit: str, root_px: float) -> float:
unit = unit.lower()
if unit == "px":
return value
if unit == "rem":
return value * DEFAULT_ROOT_PX
if unit == "em":
# Resolved against the root that we parsed. This is an approximation
# (true `em` is parent-relative) but it is correct for the real case
# that matters: a page-level `html { font-size: N }` scaling controls.
return value * root_px
if unit == "pt":
return value * PT_TO_PX
return value
def find_root_px(rules) -> float:
"""Find an explicit html/:root font-size to use as the em base."""
for selector, body in rules:
sel = selector.split(",")[0].strip()
if ROOT_SELECTOR_RE.match(sel):
m = FONT_SIZE_RE.search(body)
if m:
val = float(m.group("val"))
unit = m.group("unit")
# Root em is relative to the 16px default.
return resolve_px(val, unit, DEFAULT_ROOT_PX)
return DEFAULT_ROOT_PX
def check_file(path: str, threshold: float) -> list[tuple[str, str, str, float]]:
violations: list[tuple[str, str, str, float]] = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
css = fh.read()
except OSError:
return violations
# Only look at things that plausibly contain CSS.
if "{" not in css:
return violations
rules = parse_rules_with_bodies(css)
root_px = find_root_px(rules)
for selector, body in rules:
# Skip at-rule wrappers; their inner rules are yielded separately.
head = selector.strip()
if head.startswith("@"):
continue
# A rule may have several comma-separated selectors; judge each.
for one in head.split(","):
one = one.strip()
if not one:
continue
is_inherited_base = bool(re.match(r"(?i)^(?:html|body)$", one))
targets_control = bool(CONTROL_RE.search(one))
if not targets_control and not is_inherited_base:
continue
# Ignore rules that only set colours etc. — we want font-size.
m = FONT_SIZE_RE.search(body)
if m:
val = float(m.group("val"))
unit = m.group("unit")
raw = f"{m.group('val')}{unit}"
elif is_inherited_base:
# `body { font: 14px/1.5 }` sets the base every unstyled control
# inherits. Only the shorthand carries a size here.
m2 = FONT_SHORTHAND_RE.search(body)
if not m2:
continue
val = float(m2.group("val"))
unit = m2.group("unit")
raw = f"font-shorthand {m2.group('val')}{unit}"
else:
# A control-targeting rule with no size of its own inherits
# whatever body provides, which is reported separately.
continue
computed = resolve_px(val, unit, root_px)
if computed < threshold:
violations.append((path, one, raw, computed))
return violations
def main() -> int:
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
return 2
try:
threshold = float(sys.argv[1])
except ValueError:
print(f"threshold must be a number, got {sys.argv[1]!r}", file=sys.stderr)
return 2
total = 0
for path in sys.argv[2:]:
for vpath, selector, raw, computed in check_file(path, threshold):
print(f"{vpath}\t{selector}\t{raw}\t{computed:.1f}px")
total += 1
if total:
print(
f"\n{total} control style(s) render below {threshold:.0f}px. "
"iOS Safari auto-zooms any focused control under 16px — this is the "
"trigger that `user-scalable=no` was masking (GUIDING-LIGHT §3.3a).",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
validate-bake.py — check a docker-bake.hcl against its Dockerfile.
WHY THIS EXISTS
---------------
`docker buildx bake --print` resolves the bake file but does NOT read the
Dockerfile, so it happily accepts `target = "app"` when no stage is named
`app`. The failure only surfaces at build time, in CI, after the push:
ERROR: failed to solve: target stage "app" could not be found
That is exactly the failure preauth had: its final stage was unnamed
(`FROM dunglas/frankenphp:php8.5-trixie`), so `target = "app"` could never
have resolved. --print reported success.
This script checks the cross-file contract that buildx does not:
1. every `target = "..."` matches a named Dockerfile stage
2. the named stage is the LAST one, so a plain `docker build` still works
3. every variable the file references is declared
4. the `default` group only names targets that exist
Works without a Docker daemon, so it is usable in CI and on the workstation.
Usage: validate-bake.py [bake-file] [dockerfile]
Exit: 0 = clean, 1 = problems found
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
FROM_RE = re.compile(r"^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?\s*$", re.I)
TARGET_RE = re.compile(r'^\s*target\s*=\s*"([^"]+)"', re.M)
DECL_RE = re.compile(r'^\s*target\s+"([^"]+)"\s*\{', re.M)
VAR_DECL_RE = re.compile(r'^\s*variable\s+"([^"]+)"\s*\{', re.M)
VAR_USE_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
GROUP_RE = re.compile(r'^\s*group\s+"([^"]+)"\s*\{', re.M)
TARGETS_LIST_RE = re.compile(r"targets\s*=\s*\[([^\]]*)\]")
def stages(dockerfile: Path) -> list[tuple[int, str, str]]:
"""Return (line_no, image, stage_name_or_empty) for each FROM."""
out = []
for i, line in enumerate(dockerfile.read_text().splitlines(), 1):
if m := FROM_RE.match(line):
out.append((i, m.group(1), m.group(2) or ""))
return out
def main() -> int:
bake = Path(sys.argv[1] if len(sys.argv) > 1 else "docker-bake.hcl")
dockerfile = Path(sys.argv[2] if len(sys.argv) > 2 else "Dockerfile")
for f in (bake, dockerfile):
if not f.is_file():
print(f" ✗ missing file: {f}", file=sys.stderr)
return 1
text = bake.read_text()
found_stages = stages(dockerfile)
named = [(ln, n) for ln, _, n in found_stages if n]
names = [n for _, n in named]
problems: list[str] = []
notes: list[str] = []
# 1 + 2: target/stage contract
for t in TARGET_RE.findall(text):
if t not in names:
problems.append(
f"bake target '{t}' matches no named Dockerfile stage. "
f"Named stages: {names or '(none)'}. "
f"buildx --print does NOT catch this; the build fails."
)
if names:
# A plain `docker build` builds the LAST stage. If no bake target
# points at it, the two build paths produce different images — worth
# knowing, but legitimate for repos that publish one variant per
# target (task-weaver builds controller + worker and never uses the
# bare `docker build` path). So: note, not error.
bake_targets = TARGET_RE.findall(text)
last_name = named[-1][1]
if bake_targets and last_name not in bake_targets:
notes.append(
f"no bake target selects the LAST Dockerfile stage "
f"('{last_name}'), so a plain `docker build` and `bake` "
f"produce different images."
)
else:
problems.append("Dockerfile has no named stages; bake needs one.")
# 3: declared vs used variables
declared = set(VAR_DECL_RE.findall(text))
used = set(VAR_USE_RE.findall(text))
for u in sorted(used - declared):
problems.append(
f"${{{u}}} is used but never declared as a `variable` block; "
f"bake would error at load time."
)
for d in sorted(declared - used):
notes.append(f"variable '{d}' is declared but never referenced.")
# 4: default group names real targets
declared_targets = set(DECL_RE.findall(text))
for gname in GROUP_RE.findall(text):
block = text.split(f'group "{gname}"', 1)[1][:400]
if m := TARGETS_LIST_RE.search(block):
for t in re.findall(r'"([^"]+)"', m.group(1)):
if t not in declared_targets:
problems.append(
f"group '{gname}' references target '{t}', "
f"which is not declared."
)
name = bake.name
if problems:
print(f" FAIL {name}")
for p in problems:
print(f" ✗ {p}")
else:
print(f" OK {name} (targets: {sorted(declared_targets)} / "
f"stages: {names})")
for n in notes:
print(f" · {n}")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
+76 -4
View File
@@ -1,12 +1,84 @@
.git/
# .dockerignore — defines the build context used by `COPY . .`.
#
# Ignoring a path keeps EVERYTHING under it out of the context, which is what
# makes "copy the tree instead of listing files" safe: the allowlist that used
# to live in eight `COPY ./x /app/x` lines is now simply the absence of a
# pattern here. The failure mode is inverted — a missed entry fails the build
# loudly ("not found in build context") instead of silently baking a file in.
#
# Rules that matter (Guiding Light §6.3): `config/`, `bin/`, `public/` and the
# composer manifests are never ignored, and /Caddyfile is the inverse case —
# it is ignored, because the final stage copies it from `docker/Caddyfile`.
# ── VCS & CI ────────────────────────────────────────────────────────────────
.git
.gitignore
.editorconfig
.gitea
.ci
# ── Secrets: never bake these into a layer (§6.1, §8.12) ────────────────────
# Bare `.env` included deliberately: it is exactly the file a developer has
# locally and must not land in an image layer.
.env
.env.local
.env.local.php
.env.*.local
.env.dev
.env.test
host.env
config/secrets/prod/prod.decrypt.private.php
# ── Local runtime state (§5.2) ──────────────────────────────────────────────
# var/ holds the dev cache and logs; the prod cache is warmed inside the image.
var/
*.sqlite
*.sqlite3
*.db
# ── Rebuilt inside the image from composer.lock ─────────────────────────────
vendor/
# ── Dev / test artefacts not needed at runtime ──────────────────────────────
tests/
.phpunit.cache/
.phpunit.result.cache
phpunit.xml
phpunit.dist.xml
.php-cs-fixer.cache
.php-cs-fixer.dist.php
.php-cs-fixer.php
phpstan.neon.dist
phpstan-baseline.neon
# ── Build inputs that aren't payload ────────────────────────────────────────
Dockerfile
.dockerignore
docker-bake.hcl
compose*.yaml
docker-compose*.yaml
# ── Docs, examples and host-side tooling ────────────────────────────────────
# The root Caddyfile is the host-side example (docs/examples/ has a longer
# one); the image's config is docker/Caddyfile, copied explicitly below.
docs/
*.md
.env
.env.test
.env.local
license.txt
Caddyfile
Domainfile
run.sh
deploy.sh
bin/composer
bin/dev.sh
bin/franken.sh
bin/phpunit
composer.phar
# ── Generated config reference (regenerate with config:dump) ────────────────
config/reference.php
# ── Editor / OS noise ───────────────────────────────────────────────────────
.idea
.vscode
*.swp
.DS_Store
+41 -5
View File
@@ -1,20 +1,56 @@
# editorconfig.org
#
# Canonical shared .editorconfig. Copy verbatim into a project root.
# LEAF file: sync = overwrite, never merge (GUIDING-LIGHT §8.2).
#
# context-loom was missing this entirely in the 2026-09 audit; the other four
# had three subtly different versions.
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
# YAML is indentation-significant and the rest of the ecosystem uses 2 spaces.
[*.{yaml,yml}]
indent_size = 2
# Docker/compose files follow the same convention.
[{Dockerfile,*.dockerfile}]
indent_size = 4
[{compose.yaml,compose.*.yaml,compose.yml}]
indent_size = 2
[*.json]
indent_size = 2
# Markdown: trailing whitespace is a hard line break in some renderers, so
# stripping it silently changes formatting.
[*.md]
trim_trailing_whitespace = false
# Generated / vendored content: never touch, even accidentally on save.
[{vendor/**,var/**,node_modules/**,public/bundles/**}]
insert_final_newline = false
trim_trailing_whitespace = false
# Caddy's own formatter (caddy fmt) indents with tabs, and every Caddyfile in
# the portfolio already uses them — at the root (preauth, penny-track,
# vital-pulse), under docker/ (the FrankenPHP app config), and under
# docs/examples/ (the edge-proxy reference copied from). Without this rule the
# `[*]` block above silently tells editors to use spaces, so every save
# reindents the file and caddy fmt immediately undoes it.
[Caddyfile]
indent_style = tab
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[Makefile]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
[*.{sh,bash}]
indent_size = 4
+13
View File
@@ -12,6 +12,19 @@ BURST_COUNT=10
BURST_TIME=30
UPPER_COUNT=100
UPPER_TIME=3600
PASSKEY_ENABLED=0
PASSKEY_RP_NAME=''
PASSKEY_USER_VERIFICATION='required'
PASSKEY_TIMEOUT=60000
PASSKEY_BUTTON_NAME='Sign in with a passkey'
PASSKEY_REGISTER_NAME='Register this device as a passkey'
PASSKEY_BEGIN_BURST_COUNT=30
PASSKEY_BEGIN_BURST_TIME=60
PUBLIC_PATHS=''
PUBLIC_BURST_COUNT=100
PUBLIC_BURST_TIME=60
PUBLIC_UPPER_COUNT=500
PUBLIC_UPPER_TIME=3600
TITLE='Pre-Authentication System'
BG_COLOR='#029386'
FG_COLOR='#ffffff'
+14 -21
View File
@@ -1,33 +1,26 @@
# Push Develop - update the "develop" rolling docker image tag, via the shared workflow
#
# The build is defined in docker-bake.hcl.
name: Push Develop
on:
push:
branches:
- 'main'
- 'develop'
jobs:
docker:
runs-on: ubuntu-latest
uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
with:
mode: develop
steps:
- name: Checkout
uses: actions/checkout@v4
# Passed explicitly from repo vars
image-target: ${{ vars.DOCKERHUB_TARGET }}
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
# docker-bake.hcl controls building
build-backend: 'bake'
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:develop
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+14 -25
View File
@@ -1,3 +1,7 @@
# Push Docker - release new version to docker and update the "latest" rolling docker image tag, via the shared workflow
#
# The build is defined in docker-bake.hcl.
name: Push Docker
on:
@@ -7,31 +11,16 @@ on:
jobs:
docker:
runs-on: ubuntu-latest
uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
with:
mode: release
steps:
- name: Checkout
uses: actions/checkout@v4
# Passed explicitly from repo vars
image-target: ${{ vars.DOCKERHUB_TARGET }}
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
# docker-bake.hcl controls building
build-backend: 'bake'
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:latest
${{ vars.DOCKERHUB_TARGET }}:${{ steps.version.outputs.VERSION }}
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+14 -27
View File
@@ -1,37 +1,24 @@
# Sync GitHub - upload branch change to github, via the shared workflow
name: Sync GitHub
on:
push:
branches:
- '**'
- 'main'
- 'feat*'
- 'fix*'
- 'cleanup*'
- 'chore*'
jobs:
sync:
runs-on: ubuntu-latest
uses: private/ci/.gitea/workflows/sync-github.yaml@v1
with:
sync-target: ${{ vars.SYNC_GITHUB_TARGET }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config --global user.name "Andrew Sync"
git config --global user.email "sync@digitaladapt.com"
- name: Add GitHub Remote
env:
SYNC_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
SYNC_TARGET: ${{ vars.SYNC_GITHUB_TARGET }}
run: |
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET" 2>/dev/null || git remote set-url github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
- name: Push Current Branch
run: |
git push github HEAD:${GITHUB_REF_NAME}
- name: Push Tags
run: |
git push github --tags
# by default we do not alter existing github tags, but that can be changed here.
# force-tags: true
secrets:
SYNC_GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
+27 -20
View File
@@ -1,36 +1,43 @@
# Tests - ensure code quality, via the shared workflow.
#
# Checks include: PHPStan, PHPUnit, and PHP-CS-Fixer.
name: Tests
on:
push:
branches:
- 'main'
- 'develop'
- 'feat*'
- 'fix*'
- 'cleanup*'
- 'chore*'
pull_request:
branches:
- 'main'
- 'develop'
jobs:
test:
runs-on: ubuntu-latest
uses: private/ci/.gitea/workflows/php-test.yaml@v1
with:
php-version: '8.5'
steps:
- name: Checkout
uses: actions/checkout@v4
# profiles defines what to test:
# * web-app: full test suite (default)
# * auth-gateway: skip template check
# * api-gateway: skip interface checks
profile: auth-gateway
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
extensions: apcu, mbstring
coverage: xdebug
ini-values: apc.enable_cli=1
# coverage defines how to check test-coverage:
# * pcov: recommended (default)
# * xdebug
coverage: 'pcov'
# 0-100 percentage of test-coverage required
coverage-min: '75'
- name: Install dependencies
run: composer install --prefer-dist --no-progress
# does failing our "conformance" check make the test suite fail
conformance-blocking: false
- name: Run php-cs-fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff
- name: Run tests
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
secrets:
# github token so composer can download dependencies
SYNC_GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
+68 -13
View File
@@ -1,18 +1,73 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
->exclude('vendor')
->notPath([
'config/bundles.php',
'config/reference.php',
])
;
declare(strict_types=1);
return (new PhpCsFixer\Config())
/**
* .php-cs-fixer.dist.php — canonical shared config.
*
* Copy verbatim into a project root. LEAF file: sync = overwrite, never merge.
* Change it here and re-sync; do not hand-edit per repo (GUIDING-LIGHT §8.2).
*
* This replaces three divergent versions found in the 2026-09 audit:
* context-loom — had @Symfony + risky + declare_strict_types
* penny-track / preauth / vital-pulse — a second variant
* task-weaver — a third variant
*
* Pin friendsofphp/php-cs-fixer to ^3.95 in composer.json. preauth was on
* "*", which means its CI was not reproducible.
*/
$config = new PhpCsFixer\Config();
return $config
->setRiskyAllowed(true)
->setRules([
'@PSR12' => true,
'@Symfony' => true,
'@Symfony:risky' => true,
// Unambiguous wins.
'declare_strict_types' => true,
'no_unused_imports' => true,
'ordered_imports' => [
'sort_algorithm' => 'alpha',
'imports_order' => ['class', 'function', 'const'],
],
'php_unit_method_casing' => ['case' => 'snake_case'],
// Trailing commas in multiline constructs keep diffs to one line when
// a parameter is appended — reviewable, and no reformat noise.
'trailing_comma_in_multiline' => [
'elements' => ['arrays', 'arguments', 'parameters', 'match'],
],
// `array()` → `[]`, consistent with everything else in these repos.
'array_syntax' => ['syntax' => 'short'],
// Group imports so a file's dependency surface is scannable.
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => false,
'import_functions' => false,
],
// Keep `#[Attribute]`-style attributes on their own line for long ones.
'attribute_empty_parentheses' => true,
])
->setFinder($finder)
;
->setFinder(
(new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('vendor')
->exclude('var')
->exclude('node_modules')
// Migration classes are generated and version-stamped upstream;
// reformatting them makes diffs against the generator noisy.
->notPath('src/Migrations')
// Symfony's config reference is regenerated by `cache:clear`, which
// composer runs on every install — so it is present in CI even
// though it is gitignored. Formatting it makes the fixer report a
// file the author cannot commit, and a fresh `cache:clear`
// immediately undoes the fix, so CI can never go green.
->notPath('config/reference.php')
->ignoreDotFiles(true)
->ignoreVCS(true)
);
+119 -1
View File
@@ -5,7 +5,125 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [Unreleased] — v1.1
### Added
- **Public rate-limited access** — Select paths can now be made publicly
accessible without TOTP authentication, with separate per-IP rate limiting.
This is useful for exposing public content (e.g., public Gitea repositories)
while protecting server resources from bot traffic.
- New `PUBLIC_PATHS` env var: 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.
- New `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` env vars for burst rate
limiting (default: 100 requests per 60 seconds).
- New `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` env vars for sustained
rate limiting (default: 500 requests per 3600 seconds).
- Authenticated users bypass the public rate limiter entirely.
- Over-limit responses include a `Retry-After` header.
- New `PublicPathMatcher` service for path pattern matching.
- New `PublicAccessListener` (priority 84) in the request pipeline.
### Changed
- **Upgraded Symfony 7.4 → 8.1** — All `symfony/*` components bumped to
`8.1.*` (resolved to 8.1.2–8.1.6). The 7.4 deprecation sweep was clean
(test suite runs with `failOnDeprecation`), so the major-version jump
required no application code changes. See
`docs/symfony-8.1-upgrade-plan.md`.
### Removed
- **`runtime/frankenphp-symfony`** — No longer needed: `symfony/runtime`
8.1 handles FrankenPHP worker mode natively via its built-in
`FrankenPhpWorkerRunner`. The `extra.runtime` override in
`composer.json` was removed so the runtime auto-detects FrankenPHP.
The old package's `FRANKENPHP_LOOP_MAX` env var is no longer read;
an equivalent recycle limit is restored via the new `MAX_REQUESTS`
setting below.
### Added
- **`MAX_REQUESTS` worker-thread recycle limit** — The `Caddyfile` now
sets FrankenPHP's native `max_requests` from the `MAX_REQUESTS`
environment variable: each PHP worker thread is gracefully restarted
after N requests while others keep serving, containing slow memory
growth across long uptime. The image default is **500** (matching the
previous `runtime/frankenphp-symfony` default), baked in as a Docker
build arg and overridable at runtime (`MAX_REQUESTS=0` disables
restarts). Arbitrary `frankenphp`-block configuration is still
possible via the stock `FRANKENPHP_CONFIG` env var.
### Fixed
- **Login flow responses are no longer cacheable** — the login page,
failed logins, redirects, and rate-limit/error pages now send strict
anti-caching headers (`Cache-Control: no-store, no-cache,
must-revalidate, proxy-revalidate, max-age=0, s-maxage=0` plus
`Pragma`, `Expires`, `Surrogate-Control`, and `Vary: *`), the login
form's `fetch()` bypasses the HTTP cache, and the example Caddyfile
guards every `forward_auth` block with matching `header_down` rules.
This prevents browsers — notably older Safari — from replaying a stale
pre-auth response on refresh (previously: log in successfully, refresh,
and land back on the login page). Successful (2xx) responses are
deliberately excluded: they are consumed by the proxy's `forward_auth`
check and never reach the browser.
### Changed
- **Dockerfile rebuild — same layout as the rest of the portfolio** (Guiding
Light §6.4). The build now copies the tree (`COPY . .`) and lets
`.dockerignore` decide what reaches the context, instead of maintaining a
hand-written `COPY ./x /app/x` allowlist that had to be kept in step with
the project layout. `var/` — which the old file list never copied — now
simply stays out via the ignore file.
- **The image runs as a non-root `app` user** (uid/gid 1000, the same
convention as task-loom/context-shuttle). `/data` (cache pools) and
`/config` are created and owned by it. This resolves the last failing
conformance check (§6.4 `dockerfile-nonroot`).
- **`.dockerignore` rebuilt on the Guiding Light §6.2 baseline** — in
particular `.env` is now excluded explicitly (§6.1), so a developer's
local environment file can never be baked into a layer.
- **`docker/php.ini` and `docker/Caddyfile` added.** The PHP overrides
(`expose_php=Off`, error/log settings, OPcache timestamps off, APCu for
CLI) and the FrankenPHP app config now live in the repository instead of
being three heredocs inside the Dockerfile, so what the image runs is
reviewable in a diff.
- **Runtime base image pinned to `dunglas/frankenphp:1-php8.5-trixie` and
APCu installed via the base image's `install-php-extensions`** — the
versioned tag replaces the floating one, and the build no longer drags a
compiler toolchain into the runtime layer to build one extension.
- **`/app` is now the whole project.** The old image only shipped
`bin/console`, `config`, `public`, `src`, `templates` and the composer
manifests; `config/reference.php` and other loose files are now present.
No application path changes: `public/index.php` and `bin/console` resolve
through the same relative paths.
- `bin/franken.sh` mounts the share dir at its new default
(`/app/var/share`) instead of the old `/app/var/share` bind that no longer
matched the image.
### Fixed
- **`composer dump-env prod --empty` removed.** preauth does not depend on
`symfony/dotenv` (it is not in `composer.lock`), so nothing reads a `.env`
file in the container — the command only produced a dead
`.env.local.php` in the build stage. The Dockerfile comment that claimed
otherwise is gone with it.
- **`composer install` no longer ships a classmap missing `App\`.** The old
build ran `install --optimize-autoloader` before `src/` was copied, and the
final `--classmap-authoritative` dump happened before any `COPY . .`; the
classmap is now rebuilt after the application is in place.
- **The HEALTHCHECK can actually pass.** It probed `curl -f http://localhost/`,
and preauth answers every unauthenticated request to `/` with the login page
and a `401` — so the probe failed 100% of the time and the container was
permanently marked unhealthy. It now probes Caddy's loopback admin endpoint
(the base image's own default probe, restated explicitly), which is why the
Caddyfile deliberately does not disable the admin API.
- **`expose_php` is now genuinely off in the runtime image.** The base image
ships the `php.ini-production` *template* but no active `php.ini`, so the
previous `cp` of the template was the only thing setting it — and the
`docker/php.ini` overrides are loaded after it, so stating it here makes the
intent explicit; verified against a real boot that no `X-Powered-By` header
is emitted.
- `bin/franken.sh` no longer passes `DEFAULT_URI`, which the application does
not read (`config/packages/routing.yaml` sets the router's `default_uri`).
## [1.0.0] — v1.0 Release
### Security
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
+14
View File
@@ -1,3 +1,17 @@
{
frankenphp {
# Restart each PHP worker thread after this many requests, containing
# slow memory growth across long uptime. Preserves the 7.4-era default
# loop count of runtime/frankenphp-symfony (500) after the Symfony 8.1
# upgrade. Set MAX_REQUESTS=0 to disable restarts. The Dockerfile bakes
# in the default of 500 via build arg; override at runtime with:
# docker run -e MAX_REQUESTS=5000 ...
# For full control, the stock FRANKENPHP_CONFIG env var can inject any
# directive under this block instead.
max_requests {$MAX_REQUESTS}
}
}
http://
root public/
rewrite index.php
+110 -54
View File
@@ -1,72 +1,128 @@
# use build image, to simplify final image
# syntax=docker/dockerfile:1.7
#
# PreAuth — one app, one image.
#
# Multi-stage FrankenPHP build: dependencies in a builder, the runtime image
# only gets the finished tree. Runtime: FrankenPHP worker mode, non-root,
# state on /data. TLS is terminated upstream of the container; FrankenPHP
# serves :80.
#
# Build context: the whole tree (`COPY . .`), narrowed by .dockerignore. The
# allowlist that used to live in the eight `COPY ./x /app/x` below is in that
# file now — a directory that must ship is a directory it does not exclude.
#
# Secrets are injected at runtime as env vars, never baked in (§8.12).
# ── Stage: build — composer dependencies + prod app ────────────────────────
FROM php:8.5-trixie AS build
# install APCu and composer
RUN pecl install apcu && \
docker-php-ext-enable apcu
COPY --from=composer /usr/bin/composer /usr/bin/composer
RUN apt-get update && \
apt-get install -y unzip git
# Build-time set: git (composer resolves packages over VCS) and unzip (dist
# extraction). Neither reaches the runtime image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip \
&& rm -rf /var/lib/apt/lists/*
# symfony required environment variables
ENV APP_DEBUG=0
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
# APCu and Composer, both only needed to compile the application.
RUN pecl install apcu \
&& docker-php-ext-enable apcu
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# load application into build image
RUN mkdir -p /data/preauth
RUN mkdir -p /app/bin
WORKDIR /app
COPY ./bin/console /app/bin/console
COPY ./config /app/config
COPY ./public /app/public
COPY ./src /app/src
COPY ./templates /app/templates
COPY ./composer.json /app/composer.json
COPY ./composer.lock /app/composer.lock
COPY ./symfony.lock /app/symfony.lock
# install application dependencies
RUN composer install --no-dev --optimize-autoloader
RUN composer dump-env prod --empty
# Manifests first so the dependency layer only rebuilds when they change.
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist \
--optimize-autoloader --no-scripts
# start creating final image
FROM dunglas/frankenphp:php8.5-trixie
# Copy the application. .dockerignore keeps vendor/, var/, tests/ and the
# local env files out of the context; composer install has already run, so
# its vendor/ wins.
COPY . .
# install APCu and curl (needed for healthcheck)
RUN pecl install apcu && \
docker-php-ext-enable apcu
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# src/ was not in the context when composer install ran, so the authoritative
# classmap has to be rebuilt now that the application code is present.
#
# There is deliberately no `composer dump-env` step: preauth does not depend
# on symfony/dotenv (it is absent from composer.lock), so nothing reads a
# .env file at runtime and the dump would only add a dead file. Runtime
# configuration comes from the environment, with the defaults documented in
# config/services.yaml.
RUN composer dump-autoload --classmap-authoritative --no-dev
# symfony required environment variables
ENV APP_DEBUG=0
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
# Build-time smoke of the autoloader + config compile. No APP_SECRET is
# needed: %env(APP_SECRET)% is not resolved at compile time, and the cache is
# cleared afterwards anyway — the real warm-up runs at container start with
# the injected secrets (entrypoint; §8.12).
#
# The cache is written to the share dir, not var/cache: the runtime image
# ships without a warmed var/cache, so the first container start does the
# build for its own APP_SECRET (and the pages/ filesystem pool needs a
# writable dir owned by the app user).
RUN APP_ENV=prod APP_SHARE_DIR=/data/preauth bin/console cache:warmup \
&& rm -rf var/cache/* var/log/*
# ── Stage: app — the runtime image ─────────────────────────────────────────
FROM dunglas/frankenphp:1-php8.5-trixie AS app
# Runtime set: curl is the HEALTHCHECK's probe; APCu is the state store.
# The base image ships the install-php-extensions script, which builds the
# extension and removes its own build dependencies afterwards — so git,
# autoconf and gcc never reach this stage the way `pecl install` needed them.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& install-php-extensions apcu \
&& install-php-extensions intl \
&& rm -rf /var/lib/apt/lists/*
# PHP configuration. The packaged production baseline is copied in first
# (the base image ships the template, not an active php.ini), then the app's
# own overrides are layered on top of it — they restate the security-critical
# switches so the intent survives a base-image default changing underneath us.
COPY docker/php.ini $PHP_INI_DIR/conf.d/zz-preauth.ini
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
COPY docker/Caddyfile /etc/frankenphp/Caddyfile
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint
# load application into final image
WORKDIR /app
COPY --from=build /data/preauth /data/preauth
COPY --from=build /app /app
# configure container
COPY ./Caddyfile /etc/frankenphp/Caddyfile
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
RUN echo 'expose_php = off' > $PHP_INI_DIR/conf.d/restrict.ini
# console needs apc to manage cache
RUN echo 'apc.enable_cli = on' > $PHP_INI_DIR/conf.d/console.ini
# Non-root runtime user (Guiding Light §6.4). uid/gid 1000, same convention
# as task-loom/task-weaver/context-shuttle. /data holds the cache pools the
# app writes at runtime, /config is Caddy's own XDG dir.
RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app \
--home-dir /app --shell /usr/sbin/nologin app \
&& mkdir -p /data/preauth /config \
&& chown -R app:app /app /data
# app uses var folder for cache storage
USER app
# FrankenPHP listens on :80; TLS is terminated by the external proxy.
# APP_SHARE_DIR points the filesystem cache pools (sessions, rate limiter)
# at the volume. MAX_REQUESTS is a build arg so images can bake in a
# different worker-recycle default; the Caddyfile placeholder reads it.
ARG MAX_REQUESTS=500
ENV APP_ENV=prod \
APP_DEBUG=0 \
APP_SHARE_DIR=/data/preauth \
SERVER_NAME=:80 \
MAX_REQUESTS=$MAX_REQUESTS
# Persistent state: cache pools (sessions, backup codes, rate limits) and
# Caddy's data. Only /data is needed at runtime; /config is declared because
# the base image points XDG_CONFIG_HOME at it.
VOLUME ["/config", "/data"]
# runs http on standard port
EXPOSE 80
# healthcheck
HEALTHCHECK --interval=5m \
--retries=3 \
--start-interval=1s \
--start-period=10s \
--timeout=2s \
CMD curl http://localhost || exit 1
# Liveness: Caddy's own admin endpoint, bound to loopback inside the
# container, exactly as the base image declares it (restated here so the
# probe does not depend on the upstream default staying put). The app's own
# routes cannot serve this: an unauthenticated request gets the login page
# with a 401, so `curl -f` against HTTP would always report unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:2019/metrics || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint"]
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 digitaladapt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42 -49
View File
@@ -10,7 +10,7 @@ authentication — it's a gate that prevents outsiders from even seeing
what service is running.
- **Location:** `projects/preauth/`
- **Framework:** Symfony 7.4 (PHP ≥ 8.4)
- **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
@@ -38,12 +38,16 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
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. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
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`).
4. **LoginListener** (priority 66) — Detects login attempts via
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`.
5. **InterceptListener** (priority 55) — Fallback: if no listener has
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.
@@ -76,8 +80,8 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
| Metric | Value |
|--------------|--------------------------------|
| **Tests** | 222 |
| **Assertions** | 469 |
| **Tests** | 293 |
| **Assertions** | 605 |
| **Pass** | 222 (100%) |
| **Fail** | 0 |
| **Errors** | 0 |
@@ -109,12 +113,14 @@ Every class, method, and line in `src/` is covered.
| `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 |
@@ -122,6 +128,7 @@ Every class, method, and line in `src/` is covered.
| `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
@@ -157,7 +164,7 @@ Every class, method, and line in `src/` is covered.
## Roadmap
### Phase 1 — Public but Rate-Limited Access ✦
### 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
@@ -169,53 +176,39 @@ bandwidth, forcing it back to fully private. The solution isn't more
authentication — it's bandwidth/resource protection for public-facing
services.
**Design:**
**Implementation:**
- New config variables:
- `PUBLIC_MODE=false` — Enable public access for specific services
- `PUBLIC_RATE_LIMIT=10` — Max requests per minute from a single IP
on public paths
- `PUBLIC_RATE_WINDOW=60` — Sliding window in seconds
- `PUBLIC_BURST=20` — Allow short bursts above the sustained rate
- `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: **PublicListener** (priority 95, between AcceptListener
and AllowListener):
- Checks if the request matches a public path pattern (configured per
service via Caddy's `forward_auth` URI or a header like
`X-Preauth-Public: true`).
- If public mode is enabled for this request, applies aggressive
per-IP rate limiting (separate from the login rate limiter).
- If within rate limit → `200 OK` (no `Remote-User` header, or a
`Remote-User: public` marker).
- If over rate limit → `429 Too Many Requests` with `Retry-After`
header.
- 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).
- Caddy config would use different `forward_auth` snippets for public
vs. protected services:
```caddyfile
# Protected service — requires TOTP
bitwarden.example.com {
forward_auth preauth { copy_headers Remote-User }
reverse_proxy bitwarden:80
}
# Public but rate-limited service
git.example.com {
forward_auth preauth/public { copy_headers Remote-User }
reverse_proxy gitea:3000
}
```
- New service: **PublicPathMatcher** — Parses path patterns and matches
request paths with wildcard support.
- Consider integration with Caddy's own rate limiting as a second layer
of defense (rate limit at the reverse proxy before traffic even hits
preauth).
- Separate `public_limiter` compound rate limiter (independent from
the login attempt rate limiter).
- [ ] Design public path detection mechanism (URI-based or header-based)
- [ ] Implement `PublicListener` with separate rate limiter pool
- [ ] Add config variables and defaults
- [ ] Update Caddyfile example with public service snippet
- [ ] Tests for public mode (within limit, over limit, burst behavior)
- [ ] Documentation in README
- [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
@@ -336,7 +329,7 @@ struggle with TOTP apps.
command or initial-setup flow to register a passkey).
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
7.4 and FrankenPHP
8.1 and FrankenPHP
- [ ] Design passkey registration flow (console command? first-visit
setup? separate registration endpoint?)
- [ ] Implement challenge generation and storage (extend existing
+71
View File
@@ -0,0 +1,71 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| unreleased (v1 development) | ✅ |
## Reporting a Vulnerability
Report vulnerabilities privately to **security@digitaladapt.com** (or open a private
security advisory on the repository). Please include reproduction steps and affected
versions. You will receive an acknowledgement within 48 hours and a status update at
least weekly until resolution.
**Do not open a public issue for a suspected vulnerability.** preauth is an
authentication gateway — it sits in front of every protected service, so a
weakness here is a weakness everywhere behind it.
## Security model summary
preauth implements the auth half of the `forward_auth` pattern: a reverse proxy
calls it per request to decide whether a request may reach the upstream service.
- **Two outcomes per request: allow or intercept.** `AcceptListener` /
`RejectListener` / `InterceptListener` decide, and the decision is made on
every request rather than cached — a cached auth session is an anti-pattern
(GUIDING-LIGHT §3.3d), which is also why this project gets **no service
worker**.
- **The login flow is never cached.** The login page, failed logins, redirects
and rate-limit responses are sent with
`Cache-Control: no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0`.
An aggressive cache (notably older Safari) replaying a stale pre-auth response
presents to the user as being logged back out after a refresh.
- **Headers are set by the app, not left to the proxy.** `X-Content-Type-Options:
nosniff`, `X-Frame-Options: DENY`, a Content-Security-Policy, and
`Strict-Transport-Security: max-age=31536000`. Docs recommend mirroring the
caching headers at the edge as defence in depth, but the app does not depend
on it.
- **TOTP is required.** Secrets come from `TOTP_URI`; if it is unset the app
generates one and prints it for enrolment. Login state is carried in a signed
payload (`src/Data/Payload.php`) bound to a nonce and a scope, not in a
server-side session store.
- **Rate limiting is on by default**, with the block response configurable
(`TEAPOT=false` returns 429 rather than 418).
- **`REMOTE_USER` is trusted input, not a secret.** In `remote_user` modes the
gateway accepts an upstream-asserted identity, so the upstream must be the
only path to the app. Do not expose preauth directly to the internet for this
mode.
- **`.env` is never committed; secrets are env vars injected at runtime.** Real
secrets belong in `.env.local` or `bin/console secrets:set`, read via
`%env(...)%`. `.env.example` and `.env.test` are the committed env files.
## Scope
In scope: the application code in `src/`, the shipped `Caddyfile`, the
`Dockerfile`, and anything that affects the allow/intercept decision.
Out of scope: the `forward_auth` integration at the edge (a host-proxy
configuration concern, see `docs/examples/Caddyfile`) and the security of the
services preauth protects.
## Deployment note
preauth runs as a container and drops privileges via `USER` (Guiding Light
§6.4): the image runs as the non-root `app` user (uid/gid 1000) and owns the
state paths it needs. Only `/data` is written at runtime — the cache pools
behind sessions, backup codes and rate limiting — and `/config` is declared
because the base image points Caddy's XDG config dir there. If you pin a
different `user:` in your compose file, that user must be able to write to
both paths — otherwise login state and backup codes cannot be persisted.
Executable
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# PreAuth Dev Server Script
#
# Manages a local PHP dev server for end-to-end development and testing.
# Binds to 0.0.0.0 so the app is accessible via a reverse proxy (Caddy) for
# browser-based visual verification.
#
# PreAuth is a TOTP-based authentication gateway. It uses APCu for nonce/cache
# and filesystem for session persistence — no database needed. The dev server
# runs with APP_ENV=dev and APP_DEBUG=1 for live troubleshooting.
#
# Self-bootstrapping: the `start` command checks for required system packages
# (PHP, extensions, tools), Composer, and project dependencies — installing
# them automatically if missing. This means the script works even after a
# terminal reset/reboot, embracing the self-cleaning container design.
#
# Usage:
# bin/dev.sh start Start the dev server (auto-installs deps if needed)
# bin/dev.sh stop Stop the dev server
# bin/dev.sh status Check if the dev server is running
# bin/dev.sh restart Stop and start the dev server
#
# Port assignment (P-R-E = 7-7-3):
# 8773 → https://preauth.lyra-dev.devgnome.com
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# ── Configuration ───────────────────────────────────────────────────────────
PORT=8773
HOST="0.0.0.0"
ENV="dev"
DEV_SECRET="dev_secret_not_for_production_use_only"
PID_FILE="var/.dev-server.pid"
LOG_FILE="var/log/dev-server.log"
# Required PHP extensions (checked via php -m)
REQUIRED_PHP_EXTS=(
ctype
iconv
mbstring
apcu
dom
SimpleXML
xml
)
# Apt packages for PHP + extensions
# Note: preauth uses Symfony 8.1 which requires PHP >=8.4.
# We install PHP 8.4 (available in Debian 13/Trixie) for consistency.
PHP_APT_PACKAGES=(
php8.4-cli
php8.4-common # ctype, iconv
php8.4-mbstring
php8.4-xml # dom, SimpleXML, xml
php8.4-opcache
php8.4-readline
php8.4-apcu # APCu — critical for nonce cache, rate limiter, sessions
)
# System tools needed
SYSTEM_TOOLS=(
git
unzip
curl
)
# Resolve project root (script lives in bin/)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Ensure var directory structure exists
mkdir -p var/log var/share
# ── Helpers ─────────────────────────────────────────────────────────────────
is_running() {
if [[ ! -f "$PID_FILE" ]]; then
return 1
fi
local pid
pid="$(cat "$PID_FILE")"
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
return 0
}
print_status() {
if is_running; then
local pid
pid="$(cat "$PID_FILE")"
echo "✅ PreAuth dev server is RUNNING"
echo " PID: $pid"
echo " URL: http://localhost:${PORT}"
echo " Exposed: http://${HOST}:${PORT}"
echo " Dev URL: https://preauth.lyra-dev.devgnome.com"
echo " Logs: ${LOG_FILE}"
else
echo "⛔ PreAuth dev server is STOPPED"
fi
}
# ── Bootstrap ───────────────────────────────────────────────────────────────
# Ensures all system packages, Composer, and project dependencies are present.
# Idempotent — if everything is already installed, checks are fast no-ops.
# This is what makes the script survive terminal resets/reboots.
bootstrap() {
local needed_packages=()
# ── Check system tools ──
for tool in "${SYSTEM_TOOLS[@]}"; do
if ! command -v "$tool" &>/dev/null; then
needed_packages+=("$tool")
fi
done
# ── Check PHP and required extensions ──
local php_needs_install=false
if ! command -v php &>/dev/null; then
php_needs_install=true
else
for ext in "${REQUIRED_PHP_EXTS[@]}"; do
if ! php -m 2>/dev/null | grep -iq "^${ext}$"; then
php_needs_install=true
break
fi
done
fi
if [[ "$php_needs_install" == "true" ]]; then
needed_packages+=("${PHP_APT_PACKAGES[@]}")
fi
# ── Install missing packages ──
if [[ ${#needed_packages[@]} -gt 0 ]]; then
echo "→ Installing missing system packages: ${needed_packages[*]}…"
sudo apt-get update -qq
sudo apt-get install -y -qq "${needed_packages[@]}"
fi
# ── Ensure APCu is enabled for CLI ──
# PreAuth's console commands need APCu; the Dockerfile sets apc.enable_cli=1
local apcu_ini="/etc/php/8.4/mods-available/apcu.ini"
if [[ -f "$apcu_ini" ]] && ! grep -q 'apc.enable_cli' "$apcu_ini" 2>/dev/null; then
echo "→ Enabling APCu CLI support…"
echo 'apc.enable_cli=1' | sudo tee -a "$apcu_ini" >/dev/null
fi
# ── Ensure Composer is available ──
if ! command -v composer &>/dev/null; then
echo "→ Installing Composer…"
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composer
fi
# ── Ensure project dependencies are installed ──
if [[ ! -d "vendor/" ]]; then
echo "→ Installing Composer dependencies…"
APP_ENV=dev composer install --no-interaction
fi
}
# ── Commands ────────────────────────────────────────────────────────────────
start() {
if is_running; then
echo "⚠️ Dev server is already running (PID $(cat "$PID_FILE"))"
print_status
exit 0
fi
echo "→ Starting PreAuth dev server on ${HOST}:${PORT}…"
# Self-bootstrap: ensure all dependencies are present
bootstrap
echo "→ Clearing dev cache…"
APP_ENV="$ENV" \
APP_DEBUG=1 \
APP_SECRET="$DEV_SECRET" \
php bin/console cache:clear 2>&1 | tail -3
echo "→ Starting PHP dev server…"
APP_ENV="$ENV" \
APP_DEBUG=1 \
APP_SECRET="$DEV_SECRET" \
APP_SHARE_DIR="${PROJECT_ROOT}/var/share" \
nohup php -S "${HOST}:${PORT}" -t public/ > "$LOG_FILE" 2>&1 &
local pid=$!
echo "$pid" > "$PID_FILE"
# Give it a moment to boot
sleep 2
if is_running; then
echo ""
print_status
else
echo "❌ Failed to start dev server. Check logs:"
echo " ${LOG_FILE}"
tail -20 "$LOG_FILE" 2>/dev/null || true
rm -f "$PID_FILE"
exit 1
fi
}
stop() {
if ! is_running; then
echo "⚠️ Dev server is not running."
rm -f "$PID_FILE"
exit 0
fi
local pid
pid="$(cat "$PID_FILE")"
echo "→ Stopping dev server (PID ${pid})…"
kill "$pid" 2>/dev/null || true
# Wait for graceful shutdown
local count=0
while kill -0 "$pid" 2>/dev/null && [[ $count -lt 10 ]]; do
sleep 0.5
count=$((count + 1))
done
# Force kill if still alive
if kill -0 "$pid" 2>/dev/null; then
echo "→ Process didn't exit gracefully, sending SIGKILL…"
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$PID_FILE"
echo "✅ Dev server stopped."
}
restart() {
stop
sleep 1
start
}
# ── Main ────────────────────────────────────────────────────────────────────
usage() {
echo "Usage: bin/dev.sh {start|stop|status|restart}"
echo ""
echo "Commands:"
echo " start Start the dev server (auto-installs deps if needed)"
echo " stop Stop the dev server"
echo " status Check if the dev server is running"
echo " restart Restart the dev server"
exit 1
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) print_status ;;
restart) restart ;;
*) usage ;;
esac
+1 -3
View File
@@ -9,8 +9,6 @@ docker run --name preauth \
-e APP_ENV=dev \
-e APP_DEBUG=true \
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
-e APP_SHARE_DIR=var/share \
-e DEFAULT_URI=http://localhost \
-v ./var/share:/app/var/share \
-e APP_SHARE_DIR=/app/var/share \
-p 8000:80 \
digitaladapt/preauth:dev
+20 -19
View File
@@ -4,22 +4,22 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"php": "^8.5",
"ext-ctype": "*",
"ext-iconv": "*",
"bacon/bacon-qr-code": "^3.1.1",
"runtime/frankenphp-symfony": "^1.0.0",
"spomky-labs/otphp": "^11.4.2",
"symfony/cache": "7.4.*",
"symfony/console": "7.4.*",
"symfony/cache": "8.1.*",
"symfony/console": "8.1.*",
"symfony/flex": "^2.11",
"symfony/framework-bundle": "7.4.*",
"symfony/mime": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
"symfony/yaml": "7.4.*"
"symfony/framework-bundle": "8.1.*",
"symfony/mime": "8.1.*",
"symfony/rate-limiter": "8.1.*",
"symfony/runtime": "8.1.*",
"symfony/twig-bundle": "8.1.*",
"symfony/uid": "8.1.*",
"symfony/yaml": "8.1.*",
"web-auth/webauthn-lib": "^5.3"
},
"config": {
"allow-plugins": {
@@ -28,7 +28,10 @@
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
"sort-packages": true,
"platform": {
"php": "8.5.0"
}
},
"autoload": {
"psr-4": {
@@ -66,18 +69,16 @@
"symfony/symfony": "*"
},
"extra": {
"runtime": {
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
},
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
"require": "8.1.*"
}
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"friendsofphp/php-cs-fixer": "^3.95",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^13.2",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*"
"symfony/browser-kit": "8.1.*",
"symfony/css-selector": "8.1.*"
}
}
Generated
+1929 -784
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
+4
View File
@@ -10,6 +10,10 @@ framework:
adapters: cache.adapter.apcu
sessionStorage:
adapters: cache.adapter.filesystem
publicRateLimitCache:
adapters: cache.adapter.apcu
passkeyRateLimitCache:
adapters: cache.adapter.apcu
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: digitaladapt/preauth
+3
View File
@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true
+26
View File
@@ -13,3 +13,29 @@ framework:
login_limiter:
policy: compound
limiters: [burst, upper]
public_burst:
policy: 'sliding_window'
limit: '%env(int:PUBLIC_BURST_COUNT)%'
interval: '%env(int:PUBLIC_BURST_TIME)% seconds'
cache_pool: 'publicRateLimitCache'
public_upper:
policy: 'sliding_window'
limit: '%env(int:PUBLIC_UPPER_COUNT)%'
interval: '%env(int:PUBLIC_UPPER_TIME)% seconds'
cache_pool: 'publicRateLimitCache'
public_limiter:
policy: compound
limiters: [public_burst, public_upper]
# bounds how many ceremonies one caller can *start*.
#
# This is a resource guard, NOT part of the login budget: D3 makes the
# existing login_limiter the single shared budget for every login method,
# and a legitimate `begin` must not consume failure budget. Without this,
# an unauthenticated caller could fill the ceremony cache with records.
passkey_begin_burst:
policy: 'sliding_window'
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%'
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds'
cache_pool: 'passkeyRateLimitCache'
+4
View File
@@ -10,3 +10,7 @@ framework:
adapters: cache.adapter.array
sessionStorage:
adapters: cache.adapter.array
publicRateLimitCache:
adapters: cache.adapter.array
passkeyRateLimitCache:
adapters: cache.adapter.array
+6
View File
@@ -15,4 +15,10 @@ twig:
teapot_message: '%env(TEAPOT_MESSAGE)%'
too_many_title: '%env(TOO_MANY_TITLE)%'
too_many_message: '%env(TOO_MANY_MESSAGE)%'
passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
debug: '%env(SHELL_VERBOSITY)%'
# `passkeys` is computed per request by the controller-facing templates via
# PasskeyPolicyInterface, never from an env var, so that availability and the
# D1/D4 prerequisites cannot drift apart.
+4 -2
View File
@@ -1,8 +1,10 @@
<?php
if (file_exists(dirname(__DIR__) .
declare(strict_types=1);
if (file_exists(dirname(__DIR__).
'/var/cache/prod/App_KernelProdContainer.preload.php')
) {
require dirname(__DIR__) .
require dirname(__DIR__).
'/var/cache/prod/App_KernelProdContainer.preload.php';
}
+53
View File
@@ -45,6 +45,32 @@ parameters:
env(UPPER_COUNT): 10 # 10 per hour
env(UPPER_TIME): 3600 # seconds (1 hour)
# --- public access (rate-limited, no auth required) ---
# Comma-separated path patterns for public access. Wildcards: * (single
# segment), ** (cross segments). Optional host prefix: host.com/path/**
# When empty (default), the feature is fully disabled.
env(PUBLIC_PATHS): ''
env(PUBLIC_BURST_COUNT): 100 # max requests per burst window per IP
env(PUBLIC_BURST_TIME): 60 # burst window in seconds
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour)
# --- passkey authentication ---
# Requires central auth (SUBDOMAIN_REDIRECT=1 + AUTH_SUBDOMAIN) and HTTPS.
# Enabling this without central auth makes the container fail at cache warmup
# rather than offering a feature that cannot work.
env(PASSKEY_ENABLED): '0' # boolean, 1 to offer passkeys on the auth subdomain
env(PASSKEY_RP_NAME): '' # blank to use TITLE
env(PASSKEY_USER_VERIFICATION): 'required' # required|preferred|discouraged
env(PASSKEY_TIMEOUT): '60000' # milliseconds
# Extra options, custom labels
env(PASSKEY_BUTTON_NAME): 'Sign in with a passkey'
env(PASSKEY_REGISTER_NAME): 'Register this device as a passkey'
# bounds how many ceremonies one caller can start (resource guard, not the
# login budget — see config/packages/rate_limiter.yaml)
env(PASSKEY_BEGIN_BURST_COUNT): 30
env(PASSKEY_BEGIN_BURST_TIME): 60
# --- styling options ---
env(TITLE): 'Pre-Authentication System'
env(BG_COLOR): '#029386' # teal
@@ -77,9 +103,24 @@ parameters:
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
app.remote_user_map: '%env(REMOTE_USER_MAP)%'
app.public_paths: '%env(PUBLIC_PATHS)%'
app.public_burst_count: '%env(int:PUBLIC_BURST_COUNT)%'
app.public_burst_time: '%env(int:PUBLIC_BURST_TIME)%'
app.public_upper_count: '%env(int:PUBLIC_UPPER_COUNT)%'
app.public_upper_time: '%env(int:PUBLIC_UPPER_TIME)%'
app.error_message: '%env(ERROR_MESSAGE)%'
app.teapot_title: '%env(TEAPOT_TITLE)%'
app.too_many_title: '%env(TOO_MANY_TITLE)%'
app.title: '%env(TITLE)%'
app.passkey_enabled: '%env(bool:PASSKEY_ENABLED)%'
app.passkey_rp_name: '%env(PASSKEY_RP_NAME)%'
app.passkey_user_verification: '%env(PASSKEY_USER_VERIFICATION)%'
app.passkey_timeout: '%env(int:PASSKEY_TIMEOUT)%'
app.passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
app.passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
services:
# default configuration for services in *this* file
@@ -94,3 +135,15 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
# the boot-time passkey configuration check runs during `cache:warmup`, so a
# misconfigured deployment fails to start instead of failing in a browser
App\Service\PasskeyPolicyInterface: '@App\Service\PasskeyPolicy'
App\Service\PasskeyInterface: '@App\Service\PasskeyManager'
App\Service\PasskeyCeremonyStoreInterface: '@App\Service\PasskeyCeremonyStore'
App\Service\PasskeyCredentialStoreInterface: '@App\Service\PasskeyCredentialStore'
App\Service\SessionIssuerInterface: '@App\Service\SessionIssuer'
# the ceremony factory takes no constructor arguments and holds no state, so
# it is built once and shared rather than re-created per ceremony
App\Service\PasskeyCeremonyFactory: ~
+63
View File
@@ -0,0 +1,63 @@
# Preauth build config
#
# CI (develop.yaml / docker.yaml) invokes this with --file so the compose
# file that lives in the same directory is not merged in as extra targets.
#
# DOCKERHUB_TARGET is the org/repo (Gitea Settings → Variables)
# CI sets TAG=latest + VERSION=<v-stripped> for tag pushes,
# TAG=develop for pushes to main.
#
# MAX_REQUESTS=0 docker buildx bake # specify variables to override
variable "DOCKERHUB_TARGET" {
default = "digitaladapt/preauth"
description = "Docker Hub repo/org (Gitea repo variable DOCKERHUB_TARGET)."
}
variable "TAG" {
default = "latest"
description = "Base tag for this build: latest (release), develop (main push), or a version."
}
variable "VERSION" {
default = ""
description = "Optional full version (v stripped) to also tag with; empty for develop builds."
}
variable "MAX_REQUESTS" {
default = "500"
description = "Restart each FrankenPHP worker thread after N requests (0 disables). Baked in at build time; the same env var overrides it at runtime."
}
group "default" {
targets = ["app"]
}
target "app" {
dockerfile = "Dockerfile"
target = "app"
context = "."
platforms = ["linux/amd64", "linux/arm64"]
# Layer cache. The shared docker-publish.yaml sets cache-from/cache-to for
# its `action` backend but NOT for `bake`, so specifying it here is what keeps
# CI builds warm. preauth compiles APCu from source (pecl) in both stages, so
# a cold build is expensive.
#
# Local builds outside CI have no GHA cache service, so override:
# docker buildx bake --set 'app.cache-to=' --set 'app.cache-from='
cache-from = ["type=gha"]
cache-to = ["type=gha,mode=max"]
# The Dockerfile declares ARG MAX_REQUESTS=500 for plain `docker build`.
# It is repeated explicitly here so CI's value is visible and can be changed
# in this file instead of in a workflow. Keep the two defaults in sync.
args = {
MAX_REQUESTS = "${MAX_REQUESTS}"
}
tags = concat(
["${DOCKERHUB_TARGET}:${TAG}"],
VERSION != "" ? ["${DOCKERHUB_TARGET}:${VERSION}"] : [],
)
}
+36
View File
@@ -0,0 +1,36 @@
# PreAuth — Caddyfile / FrankenPHP app config.
#
# The container serves plain HTTP on :80; TLS is terminated by the upstream
# proxy. SERVER_NAME=:80 is set in the Dockerfile.
#
# This is the config the image ships (/etc/frankenphp/Caddyfile). The
# Caddyfile in the repository root is the example for host-side setups.
{
frankenphp {
# Restart each PHP worker thread after this many requests, containing
# slow memory growth across long uptime. Preserves the 7.4-era default
# loop count of runtime/frankenphp-symfony (500) after the Symfony 8.1
# upgrade. Set MAX_REQUESTS=0 to disable restarts. The Dockerfile bakes
# in the default of 500 via build arg; override at runtime with:
# docker run -e MAX_REQUESTS=5000 ...
# For full control, the stock FRANKENPHP_CONFIG env var can inject any
# directive under this block instead.
max_requests {$MAX_REQUESTS}
}
# The admin API is deliberately left at its default: bound to 127.0.0.1
# inside the container, where it is the target of the image's
# HEALTHCHECK. It is not reachable from outside the container. Do NOT set
# `admin off` here without also changing that probe — the app has no 2xx
# liveness route to fall back on, because every anonymous request is
# answered with the login page and a 401.
}
http:// {
root public/
rewrite index.php
php {
root /app/public
worker index.php
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
#
# PreAuth container entrypoint.
#
# Responsibilities:
# 1. Warm the prod cache with the injected secrets.
# 2. Hand off to CMD (FrankenPHP server, or a console override:
# `docker exec -it preauth bin/console app:generate-backup-codes`).
#
# Secrets are env vars injected at runtime, never baked into images (§8.12).
# The container has no shell to hand out otherwise — it runs as an unprivileged
# user with a nologin shell — so the real boot validation is
# `cache:warmup` failing here, which is also what makes it worth doing.
set -e
if [ "$APP_ENV" = "prod" ]; then
echo "Warming cache..."
php bin/console cache:warmup
fi
exec "$@"
+41
View File
@@ -0,0 +1,41 @@
; PreAuth php.ini overrides — merged on top of the FrankenPHP base image
; defaults.
;
; The base image ships no php.ini (only the php.ini-production template), so
; the production switches that matter are stated explicitly here rather than
; inherited — verified against a real boot: without them the response carries
; `X-Powered-By: PHP/8.5.10` and errors would render into the body.
;
; PreAuth keeps its session state in APCu plus a filesystem cache pool, so the
; settings that matter most are the cache ones.
; Never advertise the interpreter, never print errors to the client. This is
; an authentication gateway: a stack trace in a 500 body is an information
; leak. Errors go to stderr for the log collector.
expose_php = Off
display_errors = Off
log_errors = On
error_log = /proc/self/fd/2
memory_limit = 256M
upload_max_filesize = 2M
post_max_size = 8M
; OPcache for the FrankenPHP worker: the image is immutable, so timestamps
; never need revalidating. The CLI console also runs the app, hence
; enable_cli = 1.
opcache.enable = 1
opcache.enable_cli = 1
opcache.validate_timestamps = 0
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
; APCu — nonce cache, rate limiter and session cache all live in it, and the
; console needs it too (`bin/console` commands manage cache state).
apc.enabled = 1
apc.enable_cli = 1
apc.shm_size = 64M
apc.ttl = 0
date.timezone = UTC
-28
View File
@@ -1,28 +0,0 @@
# example of securing full service
# TODO replace domain and service name and port
service.example.com {
forward_auth preauth {
uri {uri}
copy_headers Remote-User
}
reverse_proxy service-container:80
}
# you can choose to only restrict select paths
# or any other Caddy match criteria, if desired
# IE: https://protected.example.com/secure/
protected.example.com {
# note any request that does not start with "/secure/" is NOT protected
forward_auth /secure/* preauth {
uri {uri}
copy_headers Remote-User
}
reverse_proxy protected-service:9000
}
# optionally, if you want to use a subdomain for central preauth
# set SUBDOMAIN_REDIRECT to true
# and AUTH_SUBDOMAIN to match the subdomain you use here
auth.example.com {
reverse_proxy preauth
}
@@ -15,6 +15,28 @@
#SUBDOMAIN_REDIRECT=false # default disabled, boolean
#AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page
# --- passkey authentication ---
# Passkeys (Touch ID / Windows Hello / security keys) as an alternative to TOTP.
#
# REQUIRES central authentication (SUBDOMAIN_REDIRECT=true plus AUTH_SUBDOMAIN)
# and HTTPS. Passkeys are bound to a relying party that spans the base domain,
# which only exists when central auth is configured; and browsers refuse to run
# a ceremony over plain HTTP.
#
# Enabling this without central auth is a hard error: the container fails at
# start-up (cache:warmup) rather than offering a passkey button that cannot work.
#
# There is deliberately no option to allow an http:// origin, and none to relax
# the requirement for local development. See the README for the local TLS setup.
#PASSKEY_ENABLED=false # default disabled, boolean
#PASSKEY_RP_NAME='' # blank to use TITLE
#PASSKEY_USER_VERIFICATION='required' # required | preferred | discouraged
#PASSKEY_TIMEOUT=60000 # ceremony timeout in milliseconds
#PASSKEY_BUTTON_NAME='Sign in with a passkey'
#PASSKEY_REGISTER_NAME='Register this device as a passkey'
#PASSKEY_BEGIN_BURST_COUNT=30 # ceremonies one caller may start per window
#PASSKEY_BEGIN_BURST_TIME=60 # window for the above, in seconds
# --- extra options ---
# how long do we allow *ALL* traffic from an ip address after successful login
@@ -24,6 +46,14 @@
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
# --- server / worker options ---
# (container/deployment only) restart each FrankenPHP worker thread after
# this many requests, containing memory growth across long uptime;
# matching the default from the old runtime/frankenphp-symfony package.
# 0 disables restarts. consumed by the Caddyfile, not the PHP app.
#MAX_REQUESTS=500 # default 500
# --- remote-user header ---
# Controls the value sent in the Remote-User header on successful auth.
# session: the session id (default, backward-compatible)
@@ -43,6 +73,18 @@
#UPPER_COUNT=10 # 10 per hour
#UPPER_TIME=3600 # seconds (1 hour)
# --- public access (rate-limited, no auth required) ---
# Comma-separated path patterns for public access. Wildcards:
# * matches any chars within one path segment (not crossing /)
# ** matches any chars including / (crosses path segments)
# Optional host prefix: host.example.com/path/**
# When empty (default), the feature is fully disabled.
#PUBLIC_PATHS=''
#PUBLIC_BURST_COUNT=100 # max requests per burst window per IP
#PUBLIC_BURST_TIME=60 # burst window in seconds
#PUBLIC_UPPER_COUNT=500 # max requests per sustained window per IP
#PUBLIC_UPPER_TIME=3600 # sustained window in seconds (1 hour)
# --- styling options ---
#TITLE='Pre-Authentication System'
+110
View File
@@ -0,0 +1,110 @@
# preauth example Caddyfile
# --- anti-caching guard for the login flow ---
# The login page, failed logins, redirects, and rate-limit pages must never
# be stored or replayed by a browser or intermediate cache. If they are,
# an aggressive cache (notably older Safari) can resurrect a stale pre-auth
# response — appearing to log a user back out after a refresh. preauth
# sends these headers itself; mirroring them here with `header_down` keeps
# the guarantee at the edge. Import this snippet inside every `forward_auth`
# block:
#
# forward_auth preauth { ...; import preauth_no_store }
#
# Note: 2xx auth responses are consumed by Caddy's forward_auth check and
# never reach the browser, and the protected service's own responses are
# not affected — so the cache headers of your services are left alone.
(preauth_no_store) {
header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0"
header_down Pragma "no-cache"
header_down Expires "0"
header_down Surrogate-Control "no-store"
header_down Vary "*"
}
# example of securing full service
# TODO replace domain and service name and port
service.example.com {
forward_auth preauth {
uri {uri}
copy_headers Remote-User
import preauth_no_store
}
reverse_proxy service-container:80
}
# you can choose to only restrict select paths
# or any other Caddy match criteria, if desired
# IE: https://protected.example.com/secure/
protected.example.com {
# note any request that does not start with "/secure/" is NOT protected
forward_auth /secure/* preauth {
uri {uri}
copy_headers Remote-User
import preauth_no_store
}
reverse_proxy protected-service:9000
}
# optionally, if you want to use a subdomain for central preauth
# set SUBDOMAIN_REDIRECT to true
# and AUTH_SUBDOMAIN to match the subdomain you use here
#
# Passkeys (PASSKEY_ENABLED) require this block AND HTTPS: the ceremony runs
# here and the credential is scoped to the base domain. Caddy provisions a
# certificate automatically for a real hostname, so nothing extra is needed in
# production. This block is also deliberately NOT behind forward_auth — the
# browser talks to it directly during a ceremony.
auth.example.com {
reverse_proxy preauth
}
# --- local development with passkeys ---
# Browsers only allow a WebAuthn ceremony over HTTPS, and preauth does not offer
# an exemption for http://localhost (that would be a way to run passkeys
# insecurely in production). So to exercise passkeys locally, give yourself a
# real hostname and a locally-trusted certificate:
#
# 1. Point the names at your machine:
# # /etc/hosts
# 127.0.0.1 auth.preauthtest.local app.preauthtest.local
# 2. Trust a certificate for them (mkcert installs a local CA):
# mkcert auth.preauthtest.local app.preauthtest.local
#
# 3. In preauth's .env:
# SUBDOMAIN_REDIRECT=true
# AUTH_SUBDOMAIN=auth.preauthtest.local
# PASSKEY_ENABLED=true
#
# 4. Terminate TLS here and proxy to the container:
#
# auth.preauthtest.local, "*.preauthtest.local" {
# tls /path/to/auth.preauthtest.local+1.pem /path/to/auth.preauthtest.local+1-key.pem
# reverse_proxy preauth
# }
#
# Note "localhost" itself cannot be used: it has no base domain, so central
# auth cannot be configured and passkeys stay disabled.
# --- public rate-limited access (v1.1) ---
# Configure PUBLIC_PATHS env var to specify which paths are public.
# Example: PUBLIC_PATHS=/public/**
# Unauthenticated visitors to public paths are rate-limited separately
# from login attempts. Authenticated users bypass the public rate limiter.
#
# This example protects all of Gitea except /public/** which is
# publicly accessible but rate-limited (e.g., 100 req/min, 500 req/hr).
git.example.com {
forward_auth preauth {
uri {uri}
copy_headers Remote-User
import preauth_no_store
}
reverse_proxy gitea:3000
}
# In preauth's .env:
# PUBLIC_PATHS=/public/**
# PUBLIC_BURST_COUNT=100
# PUBLIC_BURST_TIME=60
# PUBLIC_UPPER_COUNT=500
# PUBLIC_UPPER_TIME=3600
@@ -1,7 +1,8 @@
services:
preauth:
env_file:
# rename "example.env" to ".env", edit as needed
# copy ".env.example" to ".env", edit as needed, and put APP_SECRET
# in it (any long random string).
# strongly recommend setting TOTP_URI, if not provided the app
# will generate one for you, please copy it into your .env file
- .env
@@ -9,8 +10,10 @@ services:
- 80
image: digitaladapt/preauth:latest
restart: unless-stopped
# if you wish to set the user, you must make sure that the user
# can write to /config and /data within the container
# The image runs as the non-root `app` user (uid/gid 1000) and creates
# its state directories owned by that user, so a named volume inherits
# the right ownership on first start — no `user:` override is needed.
# If you pin one anyway, it must be able to write /config and /data.
#user: <uid>:<gid>
volumes:
- preauth-config:/config
@@ -19,4 +22,3 @@ services:
volumes:
preauth-config:
preauth-data:
+827
View File
@@ -0,0 +1,827 @@
# Plan — Passkey Authentication for the Dedicated Auth Subdomain
**Status:** 📋 Draft for review — no application code written yet
**Target:** next minor release (version to confirm — see Q1.1)
**Prepared:** 2026-09-26 against `main` @ `0458d9b`
**Revised:** 2026-09-27 — review round 2 (D4/D5, §2.3)
**Verified against:** `web-auth/webauthn-lib` 5.3.9 on PHP 8.5.11 / Symfony 8.1
---
## 0. Decisions locked in (from review feedback)
Five clarifications from the project owner reshape this plan. They are
**decisions**, not options, and everything below follows from them.
| # | Decision | Consequence |
|---|---|---|
| **D1** | **Central auth (dedicated auth subdomain) is a hard prerequisite** for passkeys. Without it, a passkey would collide with / confuse the passkey for the protected service itself. | Passkeys are simply **not offered** unless `SUBDOMAIN_REDIRECT=true` *and* `AUTH_SUBDOMAIN` is set. The RP ID is *always* `authBase()`. There is no single-host passkey mode, no per-service RP ID, and no ambiguity to document away. |
| **D2** | **Registration happens in the browser**, initiated by a simple "register passkey" checkbox on the login form — not a CLI command. | Registration reuses the existing login form, nonce/CSRF machinery and TOTP verification. This also **answers the identity question**: the identity is the `Session ID` field the user already types, exactly as with TOTP. |
| **D3** | **Rate limiting covers all forms of login.** If an IP is rate-limited, that includes passkeys. | Passkey ceremonies run **behind** the existing `RejectListener` gate and consume the **same** login limiter budget on failure. No way to sidestep a lockout by switching methods. |
| **D4** | **HTTPS is required — in development too.** No "secured relying party" exemption is supported, deprecated or otherwise. | The derived allowed-origin is *always* `https://…`, built from config and never from the request. The `PASSKEY_ALLOWED_ORIGINS` escape hatch from the first draft is **deleted**. Local development uses real TLS (§4.2). HTTPS becomes part of the boot-time assertion alongside D1 (Q3.1). |
| **D5** | **Attestation is `none`, deliberately.** The "set a real value instead" instinct was tested and is wrong *here* — every alternative is either broken or bypassable (§2.3). | Records are anonymous: zero AAGUID, `EmptyTrustPath`. No metadata service, no `web-token/jwt-library` dependency, no download of the FIDO BLOB. `SECURITY.md` states the reasoning and the conditions that would change it. |
Consequences worth stating plainly:
- The separate `passkey_limiter`, `PASSKEY_ENABLED=false` default, and the whole
"should we support single-host passkeys?" question from the first draft are
**gone**. D1 removes the configuration matrix; D3 removes the second limiter.
- The first draft's §7 (CLI registration, enrolment tokens, `--identity`) is
**deleted**. D2 replaces it with a checkbox.
- D4 keeps D1 exactly as strict — HTTPS is an **additional** requirement, never a
relaxation. D5 is the one place where "use the stricter-sounding option" loses,
and §2.3 shows the measurements behind that.
---
## 1. Goal
Let a user authenticate with a passkey (Touch ID, Windows Hello, Android
biometrics, hardware security key) instead of typing a 6-digit TOTP code —
served from the dedicated auth subdomain, where one passkey unlocks every
service on the base domain.
TOTP and backup codes remain and are never removed (Q2.1).
---
## 2. What the spike proved
The first draft contained claims that had not been executed. They now have been:
the library was installed, and a script performed a **complete registration and
assertion ceremony with real ES256 cryptography**, plus the negative cases.
**Environment:** PHP 8.5.11, Composer 2.10.3, `web-auth/webauthn-lib` **5.3.9**.
Baseline suite green before and after install: **313 tests / 738 assertions**.
`composer audit`: *"No security vulnerability advisories found."*
`.ci/conformance.sh --profile=auth-gateway`: **all 35 checks pass**.
### 2.1 Confirmed correct
| Claim | Result |
|---|---|
| Installs on PHP 8.5 + Symfony 8.1 with no conflicts | ✅ resolves to 5.3.9; `lint:container`, `lint:yaml`, `lint:twig` all pass |
| Only needs `ext-json` + `ext-openssl` | ✅ (`ext-openssl` is present in every official PHP image, so `Dockerfile` needs **no** extension work) |
| No Symfony Security bundle, no Doctrine, no bundler | ✅ `CeremonyStepManagerFactory` + `Authenticator*ResponseValidator::create()` are pure; the `webauthn-symfony-bundle` is unnecessary |
| rpId `example.com` admits an origin on `auth.example.com` | ✅ assertion ACCEPTED |
| …and also on `app.example.com` with the *same* credential | ✅ ACCEPTED — one passkey across all subdomains, as designed |
| Credential is cryptographically bound to the rpId | ✅ a forged `rpIdHash` is rejected: *"rpId hash mismatch"* |
| An origin outside the allow-list is rejected | ✅ *"Invalid origin. Not in the list of allowed origins."* |
| Wrong origin / wrong challenge rejected | ✅ `AuthenticatorResponseVerificationException` in both cases |
| CSP `publickey-credentials-*` do **not** inherit `default-src` | ✅ confirmed in the CSP3 spec (§6.8.3 fallback list omits WebAuthn directives) — the CSP change in §5.5 is required |
| base64url credential IDs survive `makeCacheKey()` without collision | ✅ 200 000 random 32-byte IDs, zero collisions |
| `attestation: 'none'` yields an anonymous record | ✅ `attestationType="none"`, zero AAGUID, `EmptyTrustPath` (§2.3, config A) |
| Origin scheme can never be inferred from the request | ✅ the scheme comes from the single allow-list string; an `https://` entry rejects an `http://` origin (§4.2) |
| `localhost` cannot accidentally enable passkeys | ✅ `baseDomain('localhost') === null` ⇒ `authBase() === null` ⇒ D1 unsatisfied (§4.2) |
| `auth.localhost` *does* satisfy D1 | ✅ `authBase() === 'auth.localhost'` (§4.2) |
| No MDS ⇒ no `web-token/jwt-library` needed | ✅ `FidoAllianceCompliantMetadataService` throws unless the JWT library is present; not installed, and MDS is not used (D5) |
### 2.2 Corrections to the first draft (things that would have bitten us)
| # | First draft said | Reality | Impact |
|---|---|---|---|
| **C1** | "`CredentialRecord` is JSON-serializable, so it fits the no-database constraint." | It is a **plain class**, not `JsonSerializable`. Persistence goes through `WebauthnSerializerFactory` (a Symfony Serializer with ~25 custom normalizers). | The store must use that factory. `symfony/serializer`, `property-info`, `property-access` arrive as transitive deps — no extra work, but the store can't just `json_encode()`. |
| **C2** | (unstated) treat option objects as plain JSON | `json_encode($creationOptions)` **throws** `JsonException: Malformed UTF-8` — the challenge is raw binary. Options **must** be serialized by the same factory, which base64url-encodes binary fields. | Both the `begin` payload and the stored record go through one `SerializerInterface`. Caught immediately by the spike; would otherwise have been a runtime 500 on first test. |
| **C3** | "the package carries 3 published advisories" | `composer audit` against 5.3.9 reports **none**. | No remediation work; record the clean audit in the CHANGELOG. |
| **C4** | counter handling not mentioned | Counter replay raises `CounterException`, which can **mask** the real reason a verification failed. | Test helper must use an incrementing counter per ceremony, or negative tests give false passes (this actually happened during the spike and had to be fixed). |
| **C5** | "keep the library default" for the counter | The default requires a strictly *increasing* counter. Measured: stored `0`, reported `0` → `CounterException`. A synchronised passkey reports `0` forever, so **every** such credential fails on its **first** login — and only on real hardware, since a test helper that increments never reproduces it. | `PasskeyCounterChecker` accepts `>=` and rejects strictly backwards. Pinned by `PasskeyCounterCheckerTest`, including a test asserting the library default still rejects `0`/`0` so this reasoning is re-checked if the dependency is upgraded. |
| **C5** | separate `passkey_limiter` + `publicRateLimitCache`-style pool | Decision D3 makes it redundant for the *login* budget. | Drop it. One small limiter remains, for a different purpose (§5.4). |
### 2.3 Attestation: why `none`, measured rather than assumed
The review asked the right question — *"is there any downside to `null`, and if it
needs a note in `SECURITY.md`, shouldn't we set a real value?"* — so it was
tested instead of argued. Seven configurations were run against 5.3.9
(`spike_attestation.php`, `spike_att2.php`). Results are summarised, not
predicted:
| # | Configuration | Outcome | What the server actually learns |
|---|---|---|---|
| **A** | `attestation=none`, `fmt=none` | ✅ accepted | `attestationType="none"`, aaguid all-zero, `EmptyTrustPath`. **Nothing.** |
| **B** | `attestation=direct`, `fmt=packed` **self**, no MDS | ✅ accepted | A real AAGUID string — but no metadata to interpret it against, so it is untrusted and uninterpretable. |
| **C** | `attestation=direct`, `fmt=packed` **basic** (`x5c` cert), no MDS | ❌ **rejected** | *"The Metadata Statement Repository is mandatory when requesting attestation objects."* |
| **C2** | …same, MDS enabled, metadata **empty** | ❌ **rejected** | *"The Metadata Statement for the AAGUID … is missing."* This is the real cost of MDS: **every** authenticator must be known in advance. |
| **C3** | MDS enabled, but the client sends a **zero** AAGUID | ✅ **accepted** | *"Null AAGUID detected. Skipping metadata verification."* — **MDS is bypassable by design.** |
| **C4** | MDS enabled, `fmt=packed` **self** attestation, AAGUID **unknown** to MDS | ✅ **accepted** | `processSelfAttestation()` returns early when the AAGUID has no metadata entry, so **self attestation is never refused by MDS** — even a *known-unknown* device passes. |
| **D** | `attestation=direct` requested, client sends `fmt=none` | ✅ **accepted** | Asking for `direct` does **not** compel compliance — conveyance is a *preference*, so the RP cannot force it. |
Three conclusions follow, and they are the reason D5 is `none`:
1. **Attestation cannot be *enforced*, only *requested*.** Configuration D shows a
client answering a `direct` request with `none` and being accepted regardless.
Any policy that depends on the client cooperating is not a security control.
2. **MDS is bypassable two different ways.** C3 is the decisive row: a zero AAGUID
short-circuits metadata verification *before* the repository is ever consulted.
Since passkeys from Apple/Google/Windows deliberately send zero AAGUIDs, an
attacker can present the same shape and skip MDS entirely — while legitimate
users are unaffected. C4 closes the remaining door on the same conclusion: with
`fmt=packed` **self** attestation (the format a software/platform authenticator
can produce without any vendor certificate), `processSelfAttestation()` returns
early when the AAGUID has no metadata entry, so even a device that is *unknown*
to MDS is accepted. Taken together: an MDS deployment refuses honest
certificate-bearing authenticators that postdate its cached BLOB (C2), while
still admitting the bypassable and self-attested cases. That is the worst
combination — friction for legitimate users, no assurance gained.
3. **`none` is not a weaker version of the same check — it is the honest
description of reality.** The property that actually protects users is that the
credential is cryptographically bound to the RP ID and origin (§2.1), which
holds identically in every row above. Attestation answers *"which device model
is this?"* — a question this project does not need to answer, because it does
not run a device-allow-list policy.
**What a real value would actually cost**, for the record: `direct` requires the
metadata repository (C) — verified as a hard failure, not a warning — which means
`web-token/jwt-library`, `symfony/http-client`, a periodic download of the FIDO
Alliance BLOB, certificate-chain validation on every registration, and a new
failure mode where a legitimate new phone is **rejected at enrolment** because its
AAGUID postdates the cached BLOB. All of that to gain a bypassable signal.
> **Where to revisit this.** D5 is the right call *for a self-hosted
gateway that does not distinguish devices*. It stops being the right call if the
project ever wants to (a) refuse specific authenticator models, or (b) prove
enrolment happened on hardware rather than a synced passkey. Both would require
MDS **plus** a decision to reject zero AAGUIDs — which is why the reasoning is
recorded in `SECURITY.md` rather than left implicit in a constant.
---
## 3. The flow, end to end
### 3.1 First-time setup (D2 — in the browser)
```
Browser → https://app.example.com/dashboard
forward_auth → preauth (host=app.example.com) → InterceptListener
matchesAuth() && host !== auth subdomain
⇒ 303 https://auth.example.com/?return=https%3A%2F%2Fapp.example.com%2Fdashboard
Browser → https://auth.example.com/?return=…
┌──────────────────────────────────────────────────────┐
│ Pre-Authentication System │
│ │
│ Session ID: [ lyra ] │
│ Authentication Token:[ 123456 ] │
│ [x] Register this device as a passkey ← new │
│ [ Submit ] │
└──────────────────────────────────────────────────────┘
```
The checkbox only appears when passkeys are available (D1 satisfied) — see §4.
**Submission with the box ticked** becomes a three-step ceremony:
```
1. POST / (auth host), form fields username+totp+nonce+register=passkey
LoginListener → LoginManager verifies TOTP/backup code + nonce [unchanged]
↳ instead of issuing a session, it starts a REGISTRATION ceremony:
stores passkey_reg_<cid> → { challenge, identity, userHandle } (TTL 300s)
⇐ 200 JSON { register: { publicKey: <options>, ceremonyId: <cid> } }
2. Browser: navigator.credentials.create({ publicKey: options })
→ user approves with Touch ID / Windows Hello / security key
3. POST / (auth host) X-Preauth-Passkey: register-finish
body: { ceremonyId, credential: <attestation JSON> }
PasskeyListener → PasskeyManager verifies attestation against the stored
challenge; stores the credential under the identity from the record
⇐ 303 Location: <return url> + Set-Cookie: __Http-Domain-Preauth=…
```
The TOTP check in step 1 is what authorises registration. There is no separate
enrolment token, no CLI, and **no way to create a credential without already
holding a valid TOTP code** — which is exactly the security property the CLI
design was reaching for.
> **Implementation note — where the hand-off actually goes.**
> `LoginListener::onKernelRequest()` is a straight chain: it builds a `Payload`,
> calls `$this->loginManager->checkToken(...)`, and on a non-null response it
> does `$event->setResponse($response); return;` — on `null` it immediately
> scores a failure and consumes a rate-limit token. There is no "authenticated
> but do not issue a session" branch to hook.
>
> So the clean split is: `LoginListener` detects `register=passkey` in the POST
> body and marks the **`Payload`** with the intent; `LoginManager::checkToken()`
> verifies TOTP/backup-code **and the nonce** exactly as it does today, and only
> then, if the intent is set, delegates to the registration ceremony instead of
> issuing a session. That keeps the nonce/CSRF guarantee in the one place that
> already enforces it — the alternative (starting a ceremony from the listener
> before `checkToken` runs) would move nonce validation and would need care to
> avoid double-spending it.
### 3.2 Everyday login (assertion)
```
Browser → https://auth.example.com/?return=…
[ 🔑 Sign in with a passkey ] ← button, one tap
─────────── or use a code ───────────
Session ID: [ … ] Token: [ … ] [ Submit ]
Passkey button:
1. POST / X-Preauth-Passkey: login-begin
⇐ 200 JSON { publicKey: { challenge, rpId, allowCredentials[], … },
ceremonyId }
2. navigator.credentials.get({ publicKey })
3. POST / X-Preauth-Passkey: login-finish body: { ceremonyId, credential }
PasskeyManager verifies the assertion against the stored record
⇐ 303 + cookie, or 401 JSON { message, nonce }
```
No username is typed: the credential carries its own identity (stored at
registration). `allowCredentials` lists all registered credentials, so the OS
picker decides which device to use.
### 3.3 Listener priority (D3)
```
Priority Listener Action
──────── ───────────────────── ─────────────────────────────────────────
99 AcceptListener Valid cookie → 200 OK
88 AllowListener Valid IP session → 200 OK
84 PublicAccessListener Public path + rate limit → 200/429
77 RejectListener LOGIN RATE-LIMIT GATE → 418/429
70 PasskeyListener (new) WebAuthn ceremony → JSON
66 LoginListener TOTP / backup-code login
55 InterceptListener Fallback → redirect or login page
```
**Why 70 — after `RejectListener` and before `LoginListener`:**
- **After 77 (D3):** a rate-limited IP is refused *before* any ceremony can
start. Passkeys cannot be used to sidestep a lockout. This is the whole point
of the reviewer's third clarification, and it reverses the first draft.
- **Before 66:** essential. `LoginListener` treats *any* POST to the auth
subdomain as a login attempt (`$domainManager->getAuthSubdomain() === $host`).
A ceremony `finish` POST has no `username`/`totp`, so `Payload::load()` returns
`null` and the request would be scored as a **failed login and burn a rate-limit
token**. `PasskeyListener` must claim the request first.
`PasskeyListener` sets a response for *every* request carrying its header —
including malformed ones — so control never falls through to
`InterceptListener`, which would render HTML to a `fetch()` caller. (Q3.2)
---
## 4. Availability rule (D1 + D4)
Passkeys are offered **only** when all of these hold:
```php
$passkeysAvailable =
$config->passkeyEnabled() // PASSKEY_ENABLED=1 (default 0)
&& null !== $domainManager->authBase() // SUBDOMAIN_REDIRECT=1 && AUTH_SUBDOMAIN set
&& $domainManager->getAuthSubdomain() === $request->getHost(); // we are ON the auth host
```
…and, separately, the **deployment** must satisfy HTTPS (D4). That is checked
once at boot rather than per request, because "is this request HTTPS" is not the
right question behind a TLS-terminating proxy — see §4.2.
Consequences:
- **RP ID is always `authBase()`** — never the request host, never configurable
per-service. `example.com` for `auth.example.com`.
- **Allowed origins is exactly one entry**: `https://{AUTH_SUBDOMAIN}`, built
from config. Because `InterceptListener` funnels every unauthenticated user to
the auth host, no other origin ever needs to run a ceremony. This is the
tightest configuration that still delivers "one passkey, every service" (§2.1).
- On a protected host, `InterceptListener` already redirects before rendering a
login page, so the checkbox is naturally absent there.
- If someone sets `PASSKEY_ENABLED=1` without central auth, the app must
**fail loudly at boot**, not silently ignore it (Q3.1). A silent ignore is how
you get "I enrolled a passkey and now I can't log in" support tickets.
`rpName` for the OS prompt defaults to `TITLE`.
### 4.1 Identity and userHandle
The identity is the `Session ID` the user typed — the same value TOTP uses, so
`Remote-User` modes (`session`/`static`/`mapped`) keep working unchanged.
- `userHandle` = `hash('sha256', $identity, true)` (32 raw bytes). Fixed length,
stable per identity, and does not leak the label into the authenticator.
- On assertion, the identity is read from the **stored credential record**, not
from the client-returned `userHandle`. The client's copy is never trusted.
- Because registration is gated behind a successful TOTP login, one identity
cannot be registered by someone who does not already hold the TOTP secret.
### 4.2 HTTPS (D4) — enforced, not exempted
D4 removes the exemption system entirely: **there is no code path that accepts an
`http://` origin for passkeys**, and no configuration that re-enables one. The
library's `setSecuredRelyingPartyId()` (deprecated since 5.2, confirmed in
`CeremonyStepManagerFactory`) is **never called**.
Measured behaviour of the origin check (`spike_origin.php`), all with rpId
`example.com`:
| Allowed origins | Client origin | Result |
|---|---|---|
| `https://auth.example.com` | `https://auth.example.com` | ✅ accepted |
| `http://localhost:8000` | `http://localhost:8000` | ✅ accepted — **only** because `http://` was explicitly allow-listed |
| `localhost:8000` (host-only) | `http://localhost:8000` | ❌ rejected |
| `https://auth.example.com` | `http://auth.example.com` | ❌ rejected |
| `https://example.com` +subdomains | `https://app.example.com` | ✅ accepted |
| `https://example.com` +subdomains | `http://app.example.com` | ❌ rejected |
| `https://example.com` (no subdomains) | `https://app.example.com` | ❌ rejected — *"Subdomains are not allowed."* |
The scheme is therefore never inferred from the request; it comes from the single
`https://{AUTH_SUBDOMAIN}` string. Note the second row — the library *will* accept
plain HTTP **if the operator writes it into the allow-list**, which is precisely
the hole D4 closes by deleting `PASSKEY_ALLOWED_ORIGINS`.
**Two gotchas this creates for local development**, both verified against
`DomainManager` (`spike_devhost.php`):
1. `baseDomain('localhost')` returns **`null`** by design, so `authBase()` is also
`null` and **`localhost` can never satisfy D1** — passkeys stay off there no
matter what. `auth.localhost`, by contrast, resolves to `authBase()` of
`auth.localhost` and *does* satisfy D1.
2. Because the origin must be `https://`, dev cannot simply point a browser at
`http://auth.localhost`. The supported dev workflow is therefore **a local TLS
certificate**, not an exemption:
```
# Development with real TLS — the only supported way to exercise passkeys
AUTH_SUBDOMAIN=auth.preauthtest.local
SUBDOMAIN_REDIRECT=true
PASSKEY_ENABLED=1
# /etc/hosts → 127.0.0.1 auth.preauthtest.local app.preauthtest.local
# mkcert auth.preauthtest.local app.preauthtest.local
# Caddy terminates TLS with the mkcert cert and reverse_proxies to :80
```
This is a **documentation and CI** change, not an application-code change: the app
already sits behind a TLS-terminating proxy in production (`docker/Caddyfile`
serves plain HTTP on `:80`, `trusted_headers` includes `x-forwarded-proto`), so
D4 adds no runtime branching. `docs/examples/Caddyfile` gains a TLS-enabled
development block, and the functional tests (§7.2) drive the HTTPS origin directly
because they build `clientDataJSON` by hand — no real TLS needed in the suite.
> **Not `localhost`.** Because D4 forbids `http://`, the classic
> `http://localhost` dev story simply does not apply to passkeys. `localhost` is
> treated as *"passkeys unavailable"*, which keeps D1 intact instead of carving
> out an exception that would then need its own tests.
---
## 5. Design detail
### 5.1 `PasskeyManager` (new service)
Owns both ceremonies. Library types stay inside this class so a future v6 rename
touches one file.
```php
final readonly class PasskeyManager implements PasskeyInterface
{
public function beginLogin(Request $request): array; // → options + ceremonyId
public function finishLogin(array $body, Request $request): ?Response;
public function beginRegistration(string $identity, Request $request): array;
public function finishRegistration(array $body, Request $request): ?Response;
}
```
Built on the verified recipe:
```php
$attestationManager = AttestationStatementSupportManager::create();
$attestationManager->add(NoneAttestationStatementSupport::create()); // D5 (§2.3)
$csm = new CeremonyStepManagerFactory();
$csm->setAllowedOrigins(["https://{$domainManager->getAuthSubdomain()}"]);
$csm->setAlgorithmManager(AlgorithmManager::create()->add(ES256::create()));
$csm->setAttestationStatementSupportManager($attestationManager);
$attestationValidator = AuthenticatorAttestationResponseValidator::create($csm->creationCeremony());
$assertionValidator = AuthenticatorAssertionResponseValidator::create($csm->requestCeremony());
$serializer = (new WebauthnSerializerFactory($attestationManager))->create();
```
- `setSecuredRelyingPartyId()` is **deprecated in 5.2** (confirmed in the source,
`@deprecated since 5.2.0 … Use setAllowedOrigins instead`) — **never called
(D4)**. Development uses real TLS, not an exemption (§4.2).
- `attestation: 'none'` for registration **(D5, §2.3)**; no metadata service, so
neither `web-token/jwt-library` nor `symfony/http-client` is needed — the
latter confirmed absent from the current install, so reaching for MDS would add
a second new dependency, not just code.
- Counter: **replaced the library default** — see §2.2 C5. `ThrowExceptionIfInvalid`
requires a *strictly increasing* counter, which rejects a synchronised passkey
on its first login; `PasskeyCounterChecker` accepts equal-or-greater and still
rejects moves backwards. Clone detection is not relied upon. Test helpers must
still increment (C4).
### 5.2 Ceremony state
Stored in the **`nonceCache`** pool (already APCu, already excluded from
`kernel.reset` in `TestKernel`, already short-lived, and — correctly — *not*
persisted to disk, so ceremonies do not survive a restart):
```
passkey_cer_<ceremonyId> → { type: 'login'|'register',
challenge: <base64url>,
identity?: string, // register only
userHandle?: string, // register only
returnUrl?: string,
createdAt: <iso8601> } TTL 300s
```
- `ceremonyId` is a fresh 15-byte base64url string, issued to the client. The
client's copy of the challenge is **never** trusted; the server-side record is
authoritative.
- **Single-use**: deleted on read at `finish`, before verification, so a failed
or replayed assertion cannot be retried against the same challenge.
- TTL 300 s (5 min) rather than the nonce's 120 s, because a user has to
interact with a biometric prompt.
### 5.3 Credential store (new service)
```php
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
{
public function all(): array; // for allowCredentials
public function find(string $credentialId): ?array; // record + metadata
public function save(CredentialRecord $record, string $identity, string $label): void;
public function updateCounter(CredentialRecord $record): void;
public function remove(string $credentialId): bool;
public function count(): int;
}
```
Cache layout in **`sessionCache`** (the persisted pool):
```
passkey_cred_<makeCacheKey(credentialId)> → { record: <serialized CredentialRecord>,
identity: string,
label: string,
createdAt: iso8601,
lastUsedAt: iso8601|null }
passkey_index → { <credentialId>: {identity, label, createdAt}, … }
```
> ⚠️ **Verified gotcha.** `PersistCache::persist()` only flushes keys recorded by
> a `MonitorCacheKeys` instance, and it watches `sessionCache`. `LoginManager`
> and `BackupCodeManager` therefore each wrap their injected pool:
> `$this->sessionCache = new MonitorCacheKeys($sessionCache);`.
> `PasskeyCredentialStore` **must do the same**, or credentials live only in APCu
> and vanish on the next container restart — a bug that would surface only after
> a redeploy. Add an explicit test asserting the write is visible in the
> underlying persistent pool.
`passkey_index` avoids scanning the whole key space for the login page's
`allowCredentials` list.
### 5.4 Rate limiting (D3)
**No new limiter for the login budget.** Instead:
| Event | Limiter behaviour |
|---|---|
| Any request to the auth host, incl. `*-begin` | `RejectListener` (77) gates first — a blocked IP never reaches `PasskeyListener` |
| `login-finish` **failure** | consumes `login_limiter` (1 token) — identical to a wrong TOTP code |
| `register-finish` **failure** | consumes `login_limiter` |
| successful ceremony | consumes nothing |
| `*-begin` | not consumed (a legitimate login must not burn failure budget) |
This satisfies "if the login attempt has been rate limited, that would include
all forms of login": after 10 failures the IP is blocked for *every* method, and
failures from any method count toward the same 10.
To stop `begin`-spam from filling the cache with ceremony records, add **one**
small limiter that bounds *starts* only — it is a resource guard, not the auth
budget:
```yaml
passkey_begin_burst:
policy: 'sliding_window'
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%' # default 30
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds' # default 60
cache_pool: 'passkeyRateLimitCache'
```
plus a `passkeyRateLimitCache` pool (APCu in prod, array in test) and an entry in
`tests/TestKernel`'s reset-exclusion list. On over-limit, `begin` answers
`429` with `Retry-After`, matching `PublicAccessListener`. (Q3.6 asks whether this
guard is wanted at all.)
### 5.5 Response caching and CSP
**Caching.** `SecurityHeadersListener` sets `no-store` only on *non-2xx*
responses, on the assumption that 2xx is consumed by `forward_auth`. That is
false here: `begin` returns a **`200` JSON body straight to the browser**, and
the auth subdomain is `reverse_proxy`-ed with no `forward_auth` in front of it at
all. Ceremony responses must therefore be no-store too. Proposed: `PasskeyListener`
marks them with an internal `X-Preauth-Ceremony` header, and
`SecurityHeadersListener` turns that into the full no-store set and strips the
marker — keeping the caching policy in the one place that owns it. (Q3.7)
**CSP.** `publickey-credentials-get` / `publickey-credentials-create` do **not**
fall back to `default-src` (confirmed, §2.1), and the current policy is
`default-src 'none'`. When passkeys are available the policy becomes:
```
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';
```
`connect-src 'self'` must be added **in both modes** — today it is added only for
the inline (non-auth-subdomain) case, but the passkey flow always uses `fetch()`.
When passkeys are unavailable the header is byte-identical to today.
### 5.6 Templates and script
| File | Purpose |
|---|---|
| `templates/_passkey.html.twig` | "Sign in with a passkey" button + `navigator.credentials.get()` handler |
| `templates/login.html.twig` | gains the checkbox (register) and includes the button, both only when available |
| `templates/_passkey_register.html.twig` | `navigator.credentials.create()` handler, driven by the JSON returned in step 1 of §3.1 |
`_script.html.twig` keeps its existing submit handler; ticking the checkbox
switches the submit into the registration branch. Kept as separate templates so
the "passkeys unavailable ⇒ byte-identical login page" property stays testable.
Base64url helpers must mirror the library's encoding exactly (no padding,
`-`/`_` alphabet); the spike's working script is the reference.
### 5.7 Configuration
| Variable | Default | Notes |
|---|---|---|
| `PASSKEY_ENABLED` | `0` | master switch; requires **D1 and D4** or the app fails at boot (Q3.1) |
| `PASSKEY_RP_NAME` | `%env(TITLE)%` | shown by the OS prompt |
| `PASSKEY_USER_VERIFICATION` | `required` | `required`/`preferred`/`discouraged` |
| `PASSKEY_TIMEOUT` | `60000` | ms, passed to the browser |
| `PASSKEY_BEGIN_BURST_COUNT` / `_TIME` | `30` / `60` | §5.4 resource guard |
| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | styling-option family |
| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | checkbox label |
> **Deleted by D4:** `PASSKEY_ALLOWED_ORIGINS`. The allowed origin is always
> derived as `https://{AUTH_SUBDOMAIN}` and there is no override — see §4.2.
Defaults preserve today's behaviour exactly.
---
## 6. Files
**New**
| File | Purpose |
|---|---|
| `src/Service/PasskeyManager.php` + `PasskeyInterface.php` | both ceremonies, library types contained |
| `src/Service/PasskeyCredentialStore.php` + interface | cache-backed records + index |
| `src/Listener/PasskeyListener.php` | priority 70, header dispatch, JSON |
| `templates/_passkey.html.twig` | login button + assertion script |
| `templates/_passkey_register.html.twig` | registration script |
| `tests/Support/PasskeyTestHelper.php` | ES256 generator, ceremony builder, incrementing counter (C4) |
| `tests/Unit/Service/PasskeyManagerTest.php` | ceremony control flow |
| `tests/Unit/Service/PasskeyCredentialStoreTest.php` | storage, index, persistence, key collisions |
| `tests/Unit/Listener/PasskeyListenerTest.php` | every branch |
| `tests/Functional/PasskeyFlowTest.php` | real crypto end-to-end (§7.2) |
**Changed**
| File | Change |
|---|---|
| `composer.json` / `composer.lock` / `symfony.lock` | `web-auth/webauthn-lib: ^5.3` (done on the spike branch) |
| `phpunit.dist.xml` | recipe-added `doctrine/deprecations` triggers (spike artefact — keep) |
| `config/packages/property_info.yaml` | recipe-added (spike artefact — keep) |
| `config/services.yaml` | `app.passkey_*` parameters |
| `config/packages/rate_limiter.yaml` | `passkey_begin_burst` |
| `config/packages/cache.yaml` + `test/cache.yaml` | `passkeyRateLimitCache` |
| `config/packages/twig.yaml` | passkey globals |
| `src/ConfigBag.php` | `passkeyEnabled()`, `rpName()`, `userVerification()`, `timeout()`, labels |
| `src/Kernel.php` or a compiler pass | boot-time check that **D1 and D4** hold when enabled (Q3.1, §4.2) |
| `src/Listener/SecurityHeadersListener.php` | CSP additions; ceremony no-store marker |
| `src/Listener/LoginListener.php` | detect `register=passkey` on the POST and mark the `Payload` with the intent (see §3.1 note) |
| `src/Service/LoginManager.php` | on success-with-intent, delegate to the registration ceremony instead of issuing a session; extract the session-issuing tail (Q3.8) |
| `templates/login.html.twig` | checkbox + button |
| `tests/TestKernel.php` | `passkeyRateLimitCache` in the reset-exclusion list |
| `tests/Support/ListenerTestHelper.php` | passkey limiter factory |
| `.env.test`, `docs/examples/.env.example`, `docs/examples/Caddyfile`, `docs/examples/compose.yaml` | config + docs; **TLS dev block** (§4.2) |
| `readme.md`, `CHANGELOG.md`, `ROADMAP.md`, `SECURITY.md`, `DESIGN_CONSIDERATIONS.md` | §9 |
---
## 7. Testing
### 7.1 Reusing the spike
`tests/Support/PasskeyTestHelper.php` is the spike's working code, refactored:
ES256 keypair → COSE key → `authenticatorData` → sign → JSON. Two rules learned
the hard way:
- **Increment the counter every ceremony** (C4), or a negative test can pass for
the wrong reason (`CounterException` masking the real failure).
- **Serialise options through `WebauthnSerializerFactory`**, never `json_encode` (C2).
### 7.2 Cases
| Case | Expected |
|---|---|
| Register on `auth.example.com` (rpId `example.com`), then assert from the same host | ✅ 303, `__Http-Domain-Preauth`, `Domain=example.com`, `Remote-User` |
| Assert the same credential from `app.example.com` | ✅ success by design — asserted explicitly so the scope is documented in code |
| Register while `PASSKEY_ENABLED=0` / without central auth | ❌ checkbox absent; `begin` inert; no cache writes |
| Registration submitted with a **bad TOTP** | ❌ 401, no ceremony started, login limiter consumed |
| Registration with a **spent nonce** | ❌ 401, no ceremony |
| `begin` from a rate-limited IP | ❌ 418/429 from `RejectListener`, never reaches passkey code |
| Failed assertion | ❌ 401, **login limiter consumed** (D3) |
| Failed assertion × N, then a correct TOTP | ❌ still blocked — shared budget |
| Assertion replayed with the same `ceremonyId` | ❌ 401 (record deleted on read) |
| Unknown `credentialId` | ❌ 401, same generic message as a bad TOTP (no enumeration) |
| Origin not in the allow-list | ❌ 401 (`Invalid origin…`) |
| **`http://` origin with the derived `https://` allow-list** | ❌ 401 — D4; asserted explicitly so the exemption cannot creep back |
| **`PASSKEY_ALLOWED_ORIGINS` is not consulted** | ❌ setting it has no effect (D4) |
| **`PASSKEY_ENABLED=1` with `AUTH_SUBDOMAIN=localhost`** | ❌ boot failure — D1 unsatisfiable (§4.2) |
| **`PASSKEY_ENABLED=1` on plain HTTP deployment** | ❌ boot failure — D4 (Q3.1) |
| Forged `rpIdHash` | ❌ 401 (`rpId hash mismatch`) |
| Zero AAGUID / self attestation payload | ✅ accepted exactly as a `none` record would be — documents D5's reasoning in code |
| Ceremony responses | ✅ full no-store header set |
| Login page when passkeys unavailable | ✅ byte-identical to today |
| Persistence | ✅ a saved credential is present in the **persistent** pool, not just APCu |
| `begin` spam | ✅ bounded by `passkey_begin_burst` |
### 7.3 Gates
Baseline to preserve: **313 tests / 738 assertions**, 100 % line/method/class
coverage, `phpstan` level 6 clean, `php-cs-fixer` clean, `composer audit` clean,
conformance 35/35. Note `phpunit.dist.xml` runs with `failOnDeprecation=true`, so
deprecations from the new dependency must be watched (the recipe already added
the `doctrine/deprecations` triggers).
---
## 8. Implementation order
Each step is independently committable and leaves the suite green.
1. **Dependency** *(done on the spike branch)* — `composer require
web-auth/webauthn-lib`; suite + lints + audit + conformance verified.
2. **Availability + config** — `ConfigBag` accessors, `services.yaml`, boot-time
**D1 + D4** assertion, Twig globals, test env. Feature fully inert; assert the
login page is unchanged. Includes the TLS development setup in
`docs/examples/` (§4.2), so contributors can exercise the feature locally.
3. **Credential store** — with `MonitorCacheKeys` wrapping and the persistence
test. No WebAuthn types needed yet (`CredentialRecord` can be stubbed).
4. **`PasskeyManager`** — both ceremonies, ceremony state, single-use deletion,
limiter consumption on failure. Unit-tested with a stubbed validator.
5. **`PasskeyListener`** — priority 70, header dispatch, always terminate,
no-store marker. Unit-test every branch incl. "post-shaped request must not
reach `LoginListener`".
6. **Extract session issuing** from `LoginManager` so both paths share it —
prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest`
before touching anything else (Q3.8).
7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent
hand-off described in §3.1, `_passkey_register.html.twig`.
8. **Login UI + CSP** — `_passkey.html.twig`, `SecurityHeadersListener`, extend
`CacheControlFlowTest` and `SecurityHeadersListenerTest`.
9. **Functional tests** with real crypto (§7.2).
10. **Docs** (§9) and **final gates**, then PR to `main`.
---
## 9. Documentation
| File | Update |
|---|---|
| `readme.md` | "Passkey Authentication" section: **the central-auth prerequisite**, **the HTTPS requirement (development included)**, enabling, the checkbox, the passkey button, RP ID, fallbacks |
| `CHANGELOG.md` | `[Unreleased]` `Added`/`Security`; record library 5.3.9 and the clean audit |
| `ROADMAP.md` | Phase 2c done, noting the deviations from the original sketch (browser registration, no bundle, D1/D3/D4/D5) |
| `SECURITY.md` | ceremony model, challenge TTL/one-shot, RP ID scope, **the D5 attestation rationale and the conditions that would reverse it (§2.3)**, HTTPS-only origins, counter caveat, shared rate-limit budget |
| `DESIGN_CONSIDERATIONS.md` | the 2xx-caching gap; `CredentialRecord` serialization; the shared-limiter decision; **why attestation was deliberately declined** |
| `docs/examples/.env.example` | new variables; **note that `PASSKEY_ALLOWED_ORIGINS` does not exist by design** |
| `docs/examples/Caddyfile` | auth-subdomain block already `reverse_proxy`-ed; **add a TLS-enabled development block (§4.2)** and note why the plain-HTTP shortcut is not offered |
---
## 10. Risks
| # | Risk | Mitigation |
|---|---|---|
| R1 | RP ID / origin misconfiguration | D1 removes the matrix: RP ID is always `authBase()`, origins is always the auth host. Asserted by tests. |
| R2 | Ceremony responses cached (first browser-facing 2xx) | §5.5 marker + `CacheControlFlowTest` cases |
| R3 | CSP blocks the ceremony | §5.5 directives; verify in a real browser during staging (Q3.9) |
| R4 | Library churn (v5 renamed types; `setSecuredRelyingPartyId` deprecated) | pin `^5.3`; library types contained in `PasskeyManager`; avoid deprecated calls |
| R5 | Credential loss on restart | `MonitorCacheKeys` wrap + explicit persistence test (§5.3) |
| R6 | Non-technical users lose their passkey device | TOTP/backup codes unchanged and always available; the checkbox is opt-in |
| R7 | `begin` cache-fill | §5.4 resource guard |
| R8 | New transitive deps (`symfony/serializer`, `property-info`) | already installed as part of the spike; container lint passes |
| R9 | **A deployment enables passkeys without TLS, and the feature silently half-works** | D4 + the extended boot assertion (§4.2, Q3.1): `PASSKEY_ENABLED=1` in a non-HTTPS configuration **fails at `cache:warmup`** instead of failing later in the browser |
| R10 | **"We should verify the device" creeps back in as a requirement** | §2.3 records the measurements and the two conditions that would justify revisiting; a functional test asserts a zero-AAGUID payload is handled deliberately, so any change is a visible, reviewed diff |
---
## 11. Remaining open questions
D1–D5 removed most of the first draft's 22 questions. These are what is left;
each has a proposal, so "yes" is a valid answer.
**Q1.1 — Version target.** `CHANGELOG.md`'s `[Unreleased]` heading still says
v1.1 while git tags reach `v1.3.0`. Target the next minor and repair the heading
in a separate labelled commit? *Proposal: yes.*
**Q1.2 — Where the checkbox appears.** *Proposal: always visible when passkeys
are available (same as the login button), since a user who has just landed on
the auth page is exactly the person most likely to be enrolling a new device.*
**Q1.3 — What if the same device registers twice** (same identity, second
passkey)? *Proposal: allow it — the OS may legitimately create a second
credential, and `excludeCredentials` will let the authenticator dedupe. `Q2.6`
of the first draft (a cap) becomes: cap at a configurable N (default 20).*
**Q2.1 — Re-confirm: TOTP stays?** *Proposal: yes, unchanged, and never
disabled by enabling passkeys.*
**Q3.1 — How to enforce the D1 prerequisite.** Boot-time hard failure when
`PASSKEY_ENABLED=1` without central auth, or log a warning and disable?
*Proposal: **hard failure** at container start (`cache:warmup`) — a silent
disable is how you get "my passkey stopped working" tickets.* **Extended by D4:**
the same boot check also asserts HTTPS, so "enabled but unusable" cannot ship.
The check is on **configuration**, not on the request, because behind a TLS
terminating proxy `isSecure()` is not authoritative (§4.2).
**Q3.2 — Always terminate a ceremony with JSON?** *Proposal: yes — any request
carrying `X-Preauth-Passkey` gets a JSON response, never the HTML login page.*
**Q3.3 — Attestation policy.** **RESOLVED — D5: `none`.** Measured, not assumed:
`direct` cannot be enforced (config D), MDS is bypassable by the zero AAGUIDs that
real passkeys send (config C3), and requiring MDS would reject legitimate new
authenticators (config C2) while adding two dependencies. Full evidence and the
conditions that would reverse it are in **§2.3**. *Set a real value instead*
was considered and rejected on the evidence.
**Q3.4 — Local development over HTTP.** **RESOLVED — D4: not supported.** No
`securedRelyingPartyId` exemption, deprecated or otherwise; local development uses
real TLS with a local certificate (§4.2). `PASSKEY_ALLOWED_ORIGINS` is deleted.
Note `localhost` deliberately cannot satisfy D1, so there is no half-configured
state to document away.
**Q3.5 — Counter checking.** **Resolved during implementation: the proposal was
wrong and was reversed.** The assumption "many passkeys always report 0" was
correct, but the conclusion "so the default is harmless" was not — the default
*rejects* a reported 0 against a stored 0, so the very case it was assumed to
tolerate is the case it fails. Replaced with `PasskeyCounterChecker` (accept
`>=`, reject strictly backwards). See §2.2 C5.
**Q3.6 — Keep the `begin` resource guard?** It is not part of the login budget
(D3 governs that) — it only bounds cache-fill. *Proposal: keep it; it is ~15
lines and mirrors the existing `public_limiter` pattern.*
**Q3.7 — Caching-policy mechanism.** `X-Preauth-Ceremony` marker header consumed
by `SecurityHeadersListener` (keeps cache policy in one place), or set headers
directly in `PasskeyListener`? *Proposal: the marker.*
**Q3.8 — Extract the session-issuing tail from `LoginManager`?** *Proposal: yes,
as its own commit — duplicating cookie/redirect/`Remote-User` logic is how the
two paths drift.*
**Q3.9 — Browser matrix.** Which browsers must be verified by hand on staging
(iOS Safari, Chrome, Firefox, and a hardware key) before release? *Proposal: all
four; note the CSP directive is the most likely divergence.*
---
### Resolved in this round
| Question | Resolution |
|---|---|
| Q3.3 — attestation value | **D5: `none`**, with measurements in §2.3 |
| Q3.4 — dev over HTTP | **D4: real TLS only**; `PASSKEY_ALLOWED_ORIGINS` deleted (§4.2) |
| Q3.1 — boot check scope | extended to assert **D1 *and* D4** |
---
## 12. Rollback
- `PASSKEY_ENABLED=0` (the default) makes the feature inert; reverting is
redeploying the previous image tag. No migrations.
- If passkeys were enabled and are rolled back, credential records remain in
`sessionCache`/filesystem under `passkey_*` keys, unread by the old code.
Sessions continue to work; nothing is invalidated.
- The dependency addition reverts with `composer.lock`.
---
## 13. Notes for the reviewer
- **Round 2 added D4 (HTTPS required, no exemptions) and D5 (attestation stays
`none`, on measured evidence).** D4 is covered in §4.2, D5 in §2.3; the two
questions that drove them are marked resolved in §11.
- Three claims in this revision were **measured, not reasoned**: the attestation
matrix (§2.3), the origin/HTTPS behaviour (§4.2), and the `localhost` × D1
interaction (§4.2). Scripts: `spike_attestation.php`, `spike_att2.php`,
`spike_origin.php`, `spike_devhost.php`.
- Incidentally confirmed while testing: `symfony/http-client` is **not** in the
current install, so MDS would have been a second new dependency, not a drop-in.
- The spike branch (`spike/passkey-deps`) currently carries `composer.json`,
`composer.lock`, `symfony.lock`, `phpunit.dist.xml` and
`config/packages/property_info.yaml` changes. Decide whether step 2 continues
on that branch or starts fresh from `main`.
- The spike scripts themselves were **removed** from the working tree (kept in
`/tmp/spike-backup/` for reference) so they never reach a PR; the reusable
parts are folded into `tests/Support/PasskeyTestHelper.php` in step 9.
- The environment details (PHP 8.5.11 via Sury, Composer, `pcov`) are local to
this container and are not a project change.
---
*End of plan.*
+229
View File
@@ -0,0 +1,229 @@
# Upgrade Plan: Symfony 7.4 → 8.1
**Status:** ✅ Implemented on branch `feat/symfony-8.1-upgrade-plan`
(Phases 0–3 & state audit complete; Phases 4–5 = staging + release)
**Target:** Symfony `8.1.*` (all symfony components)
**Was:** Symfony `7.4.*` → **resolved 8.1.2–8.1.6**
**Prepared:** 2026-09-07
---
## Implementation results
| Phase | Result |
|-------|--------|
| 0 Deprecation sweep | ✅ Clean — suite runs with `failOnDeprecation=true`, zero hits on 7.4; the 8.x jump needed **no app code changes**. |
| 1 Composer bump | ✅ `runtime/frankenphp-symfony` removed, `extra.runtime` deleted, all `symfony/*` at `8.1.*` (framework-bundle 8.1.6, twig-bundle 8.1.2); ride-alongs PHPUnit 13.3.2, Twig 3.28, otphp 11.5. Boots on **v8.1.6**. |
| 2 Config refresh | ✅ `config/reference.php` is gitignored, auto-regenerated by Flex. Prod `cache:clear`+`cache:warmup`, `lint:container`/`lint:yaml`/`lint:twig` all pass. |
| 3 Tests | ✅ **295 tests / 612 assertions green** on 8.1; php-cs-fixer 0 fixable files. |
| State audit | ✅ All `src/` services are `final readonly` with ctor-injected deps — no mutable state, kernel reuse under `FrankenPhpWorkerRunner` is safe. |
| Loop-max parity | ✅ `Caddyfile` sets `max_requests {$MAX_REQUESTS}`; default **500** baked into the image via Docker build arg (matches old package default), runtime-overridable. See §2 note. |
Phases 4–5 (staging smoke + release) are pending — everything else in
this document describes what was planned **and is now done**.
---
## 1. Why we can leapfrog 8.0
Symfony 7.4 and 8.0 were released simultaneously (Nov 2025) and are
feature-identical — 8.0 is simply 7.4 with the deprecated code removed.
Because preauth is **already on 7.4**, we are on the last LTS bridge
release. The only gating question for 8.x is whether we still trigger
any deprecations. If `composer test` runs clean under 7.4 with
`SYMFONY_DEPRECATIONS_HELPER` strict, upgrading straight to 8.1 is safe
and avoids a double-bump of `composer.json` / `composer.lock`.
Symfony 8.1 (May 2026 cycle) also brings a runtime improvement we
directly benefit from (see §3).
Prerequisites:
- ✅ PHP: Symfony 8.x requires PHP **>= 8.4**; composer.json already
requires `>= 8.4`, Docker and CI run 8.5. No PHP work needed.
- ⚠️ Deprecations: must be inventoried and fixed before the version bump
(see Phase 0).
## 2. The `runtime/frankenphp-symfony` removal
We currently use the community runtime package for FrankenPHP worker
mode, wired in two places in `composer.json`:
```json
"require": {
"runtime/frankenphp-symfony": "^1.0.0",
},
"extra": {
"runtime": {
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
},
}
```
As of Symfony 7.4+, `symfony/runtime` ships its own
`Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner`, and **in 8.1
the runtime handles FrankenPHP worker mode natively** (including new
8.1 support for returning a `Response` from worker mode). The
community package is redundant.
**Actions:**
1. `composer remove runtime/frankenphp-symfony` (as part of the 8.1 bump
in §4 — do it in the same `composer update` to keep one lockfile diff).
2. Delete the entire `extra.runtime` block from `composer.json` so the
default `Symfony\Component\Runtime\GenericRuntime` is used; the
built-in `FrankenPhpWorkerRunner` is auto-selected when
`frankenphp_handle_request()` exists (i.e. inside FrankenPHP worker
mode). Falling back to plain `APP_RUNTIME=Symfony\...\Runtime` env
override is possible but should not be needed.
3. Verify `symfony.lock` — Flex should drop the
`runtime/frankenphp-symfony` entry automatically on removal.
4. `public/index.php` needs **no change** — it already just returns the
Kernel closure via `autoload_runtime.php`.
**Note on loop_max:** the old package exposed
`FRANKENPHP_LOOP_MAX` (default 500). The built-in runner does not
read that env var. We never set it, so behavior is unchanged — but
check staging memory usage under worker mode and, if ever needed,
control restarts via FrankenPHP's own `worker ... num N` / max-requests
options in the Caddyfile instead.
## 3. composer.json changes
### `require`
| Package | From | To |
|--------------------------|------------|---------|
| `symfony/cache` | `7.4.*` | `8.1.*` |
| `symfony/console` | `7.4.*` | `8.1.*` |
| `symfony/framework-bundle`| `7.4.*` | `8.1.*` |
| `symfony/mime` | `7.4.*` | `8.1.*` |
| `symfony/rate-limiter` | `7.4.*` | `8.1.*` |
| `symfony/runtime` | `7.4.*` | `8.1.*` |
| `symfony/twig-bundle` | `7.4.*` | `8.1.*` |
| `symfony/uid` | `7.4.*` | `8.1.*` |
| `symfony/yaml` | `7.4.*` | `8.1.*` |
| ~~`runtime/frankenphp-symfony`~~ | `^1.0.0` | **removed** |
`symfony/flex` (`^2.11`), `bacon/bacon-qr-code` (^3) and
`spomky-labs/otphp` (^11) are compatible with 8.x — no change expected,
but let composer confirm during the update.
### `require-dev`
| Package | From | To |
|--------------------------|---------|---------|
| `symfony/browser-kit` | `7.4.*` | `8.1.*` |
| `symfony/css-selector` | `7.4.*` | `8.1.*` |
`phpunit/phpunit ^13.2` and `friendsofphp/php-cs-fixer` already support
PHP 8.5 / Symfony 8.
### `extra`
```diff
"extra": {
- "runtime": {
- "class": "Runtime\\FrankenPhpSymfony\\Runtime"
- },
"symfony": {
"allow-contrib": false,
- "require": "7.4.*"
+ "require": "8.1.*"
}
}
```
### One-shot command
```bash
composer update \
"symfony/*" \
--with-all-dependencies
# plus explicit remove of runtime/frankenphp-symfony beforehand
```
(Or edit composer.json, then `composer update` wholesale — the repo has
few non-Symfony deps, so a full update is low-risk.)
## 4. Config / recipes to re-sync
After the bump, run `composer recipes:update` (or
`symfony console recipes:update`) and review diffs for:
- `symfony/framework-bundle` — check `config/packages/framework.yaml`
for new/changed defaults (session, cache, http_method_override, etc.).
Our `config/reference.php` dump is generated from 7.4 config; it
**must be regenerated** after upgrade
(`bin/console config:dump-reference` equivalents) or it will document
stale defaults.
- `symfony/twig-bundle`, `symfony/rate-limiter` — verify
`config/packages/*.yaml` against new reference defaults.
- `symfony/runtime` — new recipe may update `public/index.php`; accept
only if it's a no-op for our shape.
Also review `bundles.php` (only Framework + Twig today — no removals
expected in 8.x) and `config/preload.php`.
## 5. Code-level risk review
Preauth deliberately avoids the Security component (custom listeners +
`ConfigBag`), which removes the biggest 8.0 BC-break surface
(`security.yaml` reshaping, authenticator changes). Remaining surface:
- **Listeners** (`src/Listener/*`): built on HttpKernel events — stable
API, but `KernelEvents` signatures gained native types in 8.0; our
listeners already declare types, verify covariance after upgrade.
- **`Kernel.php`**: confirm no overridden methods whose signatures
changed in 8.0 (MicroKernelTrait is stable; likely no-op).
- **`symfony/console`** (GenerateBackupCodesCommand): 8.0 removed
command `setName()`/aliases-in-constructor legacy paths — we use
`#[AsCommand]`, fine. `Command::execute()` must return `int` — verify.
- **`spomky-labs/otphp`** and **`bacon/bacon-qr-code`**: third-party;
confirm versions resolved are marked Symfony-8 compatible.
- **PHPUnit 13**: no changes needed, but watch for deprecations printed
after the Symfony bump (new `trigger_deprecation` calls in 8.1).
Canonical checklist: read `symfony/symfony` **UPGRADE-8.0.md** and
**UPGRADE-8.1.md** sections for the components we require
(cache, console, framework-bundle, mime, rate-limiter, runtime,
twig-bundle, uid, yaml) and tick each item against this codebase.
## 6. Docker / CI
- `Dockerfile`: no base-image change needed
(`dunglas/frankenphp:php8.5-trixie` + `php:8.5-trixie` builder).
Rebuild after composer.lock update; remove nothing — FrankenPHP itself
stays.
- `Caddyfile`: unchanged (worker mode config is FrankenPHP-side, not
runtime-package-side).
- `.gitea/workflows/tests.yaml`: PHP 8.5 already — unchanged.
- `composer dump-env prod --empty` step stays.
## 7. Rollout plan
| Phase | Step | Exit criteria |
|-------|------|---------------|
| 0 | **Deprecation sweep on 7.4**: run `SYMFONY_DEPRECATIONS_HELPER=max[total]=0 composer test` (or phpunit directly) + run the app in dev with the profiler/log; fix every direct deprecation. | Zero deprecations from `App\` code; only acceptable vendor ones documented. |
| 1 | **composer bump**: branch `feat/symfony-8.1`; edit composer.json per §3–§4; `composer remove runtime/frankenphp-symfony`; `composer update`; re-sync recipes. | Installs clean on PHP 8.5; `bin/console about` shows 8.1.x. |
| 2 | **Config refresh**: regenerate `config/reference.php`; review framework/twig/rate-limiter defaults; commit config changes. | `cache:clear` + warmup pass in dev & prod envs. |
| 3 | **Tests**: full phpunit suite + php-cs-fixer; fix failures (expected: minor — event/type related). | Suite green in CI. |
| 4 | **Staging smoke**: build image, run under FrankenPHP worker mode; verify TOTP login flow, backup codes, rate limiting (burst + teapot mode), public paths, central-auth subdomain flow; watch memory across >500 requests to confirm threads recycle via the Caddyfile `max_requests` setting (see §2 note). | No state leaks across worker requests; worker threads recycle at the configured request count; healthcheck passes. |
| 5 | **Docs + release**: update readme/DESIGN_CONSIDERATIONS ("symfony 8.1, built-in FrankenPHP runtime"); tag a minor release per CHANGELOG conventions. | Release published; image rebuilt & pushed. |
**Rollback:** the upgrade is a single composer.lock + config diff.
Rollback = `git revert` the bump commit + redeploy previous image tag.
No data/schema migrations are involved (no database).
## 8. Open questions — resolved during implementation
- [x] ~~Confirm none of our listeners/services relied on implicit behavior
of `Runtime\FrankenPhpSymfony\Runner`.~~ **Resolved:** audited every
class in `src/` — all are `final readonly` with constructor-injected
dependencies and no mutable state. No `ResetInterface` needed; kernel
reuse across worker requests is safe.
- [x] ~~Decide whether to pin `symfony/*` as `8.1.*` or `^8.1`.~~
**Resolved:** kept minor-pinned `8.1.*`, matching repo convention.
- [x] ~~Regenerate `config/reference.php` — scripted or manual dump?~~
**Resolved:** it's gitignored and auto-regenerated by Flex on
`composer update`; already refreshed for 8.1 during the bump.
+340
View File
@@ -0,0 +1,340 @@
# v1.1 Plan — Public Rate-Limited Access
## Goal
Allow preauth to provide rate-limited unauthenticated access to select
public paths. Authenticated users bypass the public rate limiter entirely.
Non-public paths continue to trigger the existing auth flow.
**Practical example:** Allow anyone to visit
`https://code.devgnome.com/public/*` in Gitea, but limit them to 100
requests/minute and 500 requests/hour per IP.
---
## How It Works
When a request arrives and the user is **not authenticated** (no valid
cookie or IP session), the new `PublicAccessListener` checks whether the
request path matches any configured public path pattern. If it does:
1. The public rate limiter is consulted (separate from the login limiter).
2. If within limits → `200 OK` (no `Remote-User` header). Caddy proxies
to the backend.
3. If over limits → `429 Too Many Requests` with a `Retry-After` header.
If the path does **not** match any public pattern, the request falls
through to the existing auth flow (RejectListener → LoginListener →
InterceptListener → login page or redirect).
**Authenticated users** never reach the `PublicAccessListener` because
`AcceptListener` (priority 99) or `AllowListener` (priority 88) will have
already set a `200` response before `PublicAccessListener` runs.
### Listener Priority Chain (updated)
```
Priority Listener Action
──────── ───────────────── ──────────────────────────────────────
99 AcceptListener Valid cookie → 200 OK
88 AllowListener Valid IP session → 200 OK
84 PublicAccessListener Public path + rate limit check → 200 or 429
77 RejectListener Login rate-limit gate → 418/429
66 LoginListener Login attempt handling
55 InterceptListener Fallback → redirect or login page
```
`PublicAccessListener` runs at priority 84 — after auth checks (so
authenticated users bypass it) but before `RejectListener` (so public
access is not subject to the login rate limiter).
---
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. Wildcard `*` supported. |
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP |
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds |
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP |
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour) |
**When `PUBLIC_PATHS` is empty (default), the feature is completely
disabled and has zero effect on existing behavior.**
### Path Pattern Syntax
- Patterns are matched against the request **path** only (query string
is ignored).
- Patterns must start with `/`.
- `*` matches any sequence of characters within a single path segment
(not crossing `/`).
- `**` matches any sequence of characters including `/` (crosses path
segments).
- No other regex or special characters are supported — patterns are
literal strings with `*` wildcards.
**Examples:**
| Pattern | Matches | Does NOT match |
|---------|---------|----------------|
| `/public` | `/public` | `/public/`, `/public/xyz` |
| `/public/*` | `/public/anything`, `/public/xyz` | `/public`, `/public/a/b` |
| `/public/**` | `/public/anything`, `/public/a/b/c` | `/public` |
| `/public` | `/public` | `/public/xyz` |
| `/api/*/status` | `/api/v1/status`, `/api/v2/status` | `/api/v1/v2/status` |
### Domain-Scoped Paths (when using auth subdomain)
When `SUBDOMAIN_REDIRECT=true` and `AUTH_SUBDOMAIN` is set, the user may
want public paths on specific subdomains only. In this case, `PUBLIC_PATHS`
can optionally include a domain prefix:
```
PUBLIC_PATHS='code.devgnome.com/public/**,auth.devgnome.com/health'
```
When no domain prefix is given, the path matches on **any** host. When a
domain prefix is given, it only matches on that specific host.
When **not** using an auth subdomain (the common case), paths without a
domain prefix match on all hosts. Domain-prefixed entries can still be
used to restrict to specific hosts.
### Rate Limiter
A new `public_limiter` compound rate limiter is added to
`rate_limiter.yaml`, following the same pattern as the existing
`login_limiter`. It uses a `publicRateLimitCache` pool (APCu in
production, array adapter in tests).
---
## New Files
| File | Purpose |
|------|---------|
| `src/Service/PublicPathMatcher.php` | Service that parses `PUBLIC_PATHS` and matches request paths against patterns |
| `src/Service/PublicPathMatcherInterface.php` | Interface for testability |
| `src/Listener/PublicAccessListener.php` | Listener that checks public paths and applies rate limiting |
## Modified Files
| File | Changes |
|------|---------|
| `config/services.yaml` | Add `PUBLIC_PATHS` and related env vars + parameters |
| `config/packages/rate_limiter.yaml` | Add `public_burst`, `public_upper`, `public_limiter` |
| `config/packages/cache.yaml` | Add `publicRateLimitCache` pool |
| `config/packages/test/cache.yaml` | Add `publicRateLimitCache` pool (array adapter) |
| `src/ConfigBag.php` | Add `publicPaths()` method returning parsed path patterns |
| `tests/TestKernel.php` | Add `publicRateLimitCache` to reset exclusion list |
| `tests/Support/ListenerTestHelper.php` | Add helper for public rate limiter factory |
| `docs/example.env` | Document new env vars |
| `.env.test` | Add test defaults for public paths vars |
| `.env` | Add dev defaults for public paths vars |
| `docs/Caddyfile` | Add example of public + protected service config |
| `CHANGELOG.md` | Add v1.1 section |
| `ROADMAP.md` | Mark Phase 1 as in-progress / completed |
| `readme.md` | Document public access feature |
## New Test Files
| File | Coverage |
|------|----------|
| `tests/Unit/Service/PublicPathMatcherTest.php` | Pattern parsing, matching, wildcards, domain scoping |
| `tests/Unit/Listener/PublicAccessListenerTest.php` | Listener logic: public path match → 200, non-public → pass through, rate limited → 429, authenticated → not reached |
| `tests/Functional/PublicAccessFlowTest.php` | End-to-end: public path accessible, rate limit enforced, non-public path shows login, authenticated user bypasses public rate limit |
---
## Implementation Order
1. **`PublicPathMatcher`** — Pure path matching logic, no dependencies.
Parse the `PUBLIC_PATHS` string into pattern entries (each with
optional host + path pattern). Convert `*`/`**` wildcards to regex.
Match a given (host, path) against all patterns.
2. **Config** — Add env vars to `services.yaml`, add rate limiter to
`rate_limiter.yaml`, add cache pool to `cache.yaml` + test cache.
3. **`ConfigBag`** — Add `publicPaths()` returning the raw string (the
`PublicPathMatcher` does the parsing). Or add the `PublicPathMatcher`
as a service that receives the raw string via autowiring.
4. **`PublicAccessListener`** — Inject `PublicPathMatcherInterface`,
`RateLimiterFactoryInterface` (target `public_limiter`), and
`ConfigBag`. On `RequestEvent`:
- If no public paths configured → return immediately.
- If request already has a response → return (auth listeners ran first).
- Check if (host, path) matches any public pattern.
- If no match → return (fall through to auth flow).
- If match → consume(1) from public rate limiter.
- If over limit → set 429 response with `Retry-After`.
- If within limit → set 200 response (plain text, no `Remote-User`).
5. **Tests** — Unit tests for `PublicPathMatcher` and
`PublicAccessListener`, functional tests for the full flow.
6. **Documentation** — Update all docs.
7. **Lint + Test** — Run php-cs-fixer + phpunit, fix any issues.
8. **Commit + Push + PR.**
---
## Key Design Decisions
### Why priority 84?
- Must be **after** `AcceptListener` (99) and `AllowListener` (88) so
authenticated users never hit the public rate limiter.
- Must be **before** `RejectListener` (77) so public access is not
blocked by the login attempt rate limiter.
- Must be **before** `LoginListener` (66) so login attempts on public
paths are still processed (though this is an edge case — a login
attempt on a public path would set a response in `PublicAccessListener`
before `LoginListener` runs, which is correct: you don't need to login
to access a public path).
**Wait — actually this is a problem.** If someone sends an `X-Preauth`
header on a public path, `PublicAccessListener` would return 200 before
`LoginListener` can process the login. But that's actually fine — if the
path is public, they don't need to log in. If they want to authenticate,
they can visit a non-public path.
**Revised approach:** `PublicAccessListener` should only return 200 for
**GET/HEAD** requests to public paths, or all methods? For a gate like
this, all methods should be allowed on public paths — the backend
service (e.g., Gitea) handles its own authorization for write
operations.
### Why a separate rate limiter?
The existing `login_limiter` rate limits **login attempts** (failures).
The public rate limiter rate limits **all requests** to public paths.
They serve different purposes and need independent counters. Using the
same limiter would mean public traffic could exhaust the login attempt
budget, or vice versa.
### Why `Retry-After` header?
It's a standard HTTP header (RFC 7231) that tells clients how long to
wait before retrying. Legitimate clients (browsers, API consumers) and
crawlers respect it.
### Why no `Remote-User` header on public responses?
The `Remote-User` header tells the backend who the authenticated user
is. For public access, there is no authenticated user. Sending
`Remote-User: public` or similar could confuse the backend. The backend
should treat requests without `Remote-User` as anonymous.
### Path matching: query strings
Query strings are **ignored** for path matching. `/public?foo=bar`
matches the pattern `/public`. This is implemented by using
`$request->getPathInfo()` which returns the path without query string.
---
## Edge Cases
1. **Empty `PUBLIC_PATHS`** → Feature disabled, zero impact on existing
behavior. All tests pass unchanged.
2. **Authenticated user visits a public path** → `AcceptListener` or
`AllowListener` returns 200 before `PublicAccessListener` runs. The
public rate limiter is never consulted.
3. **Public path rate limit exceeded** → 429 with `Retry-After` header.
The response uses the error template (same as login rate limit) but
always with 429 status (never teapot — teapot is for login failures).
4. **Non-public path on a host that has some public paths** → Falls
through to the normal auth flow. Login page or redirect.
5. **`PUBLIC_PATHS` with whitespace** → Trimmed during parsing.
`PUBLIC_PATHS='/public, /api'` is equivalent to `/public,/api`.
6. **Invalid patterns** (not starting with `/`) → Silently ignored
during parsing. Logged at debug level.
7. **Login attempt on a public path** → `PublicAccessListener` returns
200 before `LoginListener` runs. This is correct behavior — if the
path is public, no login is needed.
8. **Subdomain redirect mode + public paths** → If using an auth
subdomain, requests to the auth subdomain itself should never be
treated as public. The `PublicAccessListener` should skip requests
where `host === authSubdomain`.
---
## Test Strategy
### Unit Tests — `PublicPathMatcherTest`
- Empty string → no patterns → matches nothing
- Single path `/public` → matches exact, not `/public/`
- Wildcard `/public/*` → matches `/public/x`, not `/public`, not `/public/a/b`
- Double wildcard `/public/**` → matches `/public/a/b/c`
- Multiple patterns comma-separated
- Domain-prefixed pattern `host.example.com/public/**`
- Path without domain prefix matches any host
- Whitespace trimming
- Invalid patterns (no leading `/`) ignored
- Case sensitivity (paths are case-sensitive, hosts are case-insensitive)
### Unit Tests — `PublicAccessListenerTest`
- No public paths configured → returns without setting response
- Non-public path → returns without setting response
- Public path, within rate limit → sets 200 response
- Public path, rate limit exceeded → sets 429 response with Retry-After
- Public path, response already set by earlier listener → returns
- Auth subdomain request → skipped (even if path matches)
- Uses `ListenerTestHelper` for mock rate limiters and collaborators
### Functional Tests — `PublicAccessFlowTest`
- Public path accessible without authentication → 200
- Non-public path without auth → 401 (login page)
- Rate limit enforcement: multiple requests exceed burst → 429
- Authenticated user visits public path → 200 with Remote-User (bypasses public limiter)
- 429 response includes Retry-After header
- Query string ignored for path matching
- Wildcard matching works end-to-end
---
## Documentation Updates
### README
New section: **"Public Rate-Limited Access"** under Configuration.
- Explain the feature and use case
- Document all env vars
- Show path pattern syntax with examples
- Show Caddyfile configuration for public + protected services
- Note that authenticated users bypass the public rate limiter
### CHANGELOG
New `[Unreleased]` → v1.1 section with all new features.
### ROADMAP
Mark Phase 1 items as completed.
### docs/example.env
Add all new env vars with comments.
### docs/Caddyfile
Add example showing a service with both public and protected paths.
+949
View File
@@ -0,0 +1,949 @@
parameters:
ignoreErrors:
-
message: '#^Method App\\Clock\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Clock.php
-
message: '#^Method App\\Command\\GenerateBackupCodesCommand\:\:configure\(\) overrides method Symfony\\Component\\Console\\Command\\Command\:\:configure\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Command/GenerateBackupCodesCommand.php
-
message: '#^Method App\\Command\\GenerateBackupCodesCommand\:\:execute\(\) overrides method Symfony\\Component\\Console\\Command\\Command\:\:execute\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Command/GenerateBackupCodesCommand.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$id\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$json\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$nonce\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$scope\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$token\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Method App\\Data\\Payload\:\:load\(\) has parameter \$input with generic class Symfony\\Component\\HttpFoundation\\InputBag but does not specify its types\: TInput$#'
identifier: missingType.generics
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Kernel has an uninitialized property \$persistCache\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Kernel.php
-
message: '#^Method App\\Kernel\:\:boot\(\) overrides method Symfony\\Component\\HttpKernel\\Kernel\:\:boot\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Kernel.php
-
message: '#^Method App\\Kernel\:\:terminate\(\) overrides method Symfony\\Component\\HttpKernel\\Kernel\:\:terminate\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Kernel.php
-
message: '#^Class App\\Listener\\AcceptListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/AcceptListener.php
-
message: '#^Readonly property App\\Listener\\AcceptListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/AcceptListener.php
-
message: '#^Class App\\Listener\\AllowListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/AllowListener.php
-
message: '#^Readonly property App\\Listener\\AllowListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/AllowListener.php
-
message: '#^Class App\\Listener\\InterceptListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Class App\\Listener\\InterceptListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Property App\\Listener\\InterceptListener\:\:\$config is never read, only written\.$#'
identifier: property.onlyWritten
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Readonly property App\\Listener\\InterceptListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 2
path: src/Listener/InterceptListener.php
-
message: '#^Readonly property App\\Listener\\InterceptListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 2
path: src/Listener/PasskeyListener.php
-
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Readonly property App\\Listener\\LoginListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 2
path: src/Listener/LoginListener.php
-
message: '#^Readonly property App\\Listener\\LoginListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Using nullsafe property access "\?\-\>json" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Class App\\Listener\\PublicAccessListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/PublicAccessListener.php
-
message: '#^Readonly property App\\Listener\\PublicAccessListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/PublicAccessListener.php
-
message: '#^Using nullsafe method call on non\-nullable type DateTimeImmutable\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Listener/PublicAccessListener.php
-
message: '#^Class App\\Listener\\RejectListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/RejectListener.php
-
message: '#^Readonly property App\\Listener\\RejectListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/RejectListener.php
-
message: '#^Method App\\MonitorCacheKeys\:\:allValid\(\) has parameter \$keys with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:clear\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:commit\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:commit\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:deleteItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:deleteItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:getChanges\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:getItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:getItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:getKeys\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:hasItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:hasItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:save\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:save\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:saveDeferred\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:saveDeferred\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Class App\\Service\\BackupCodeManager has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Class App\\Service\\BackupCodeManager has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\BackupCodeManager\:\:expire\(\) overrides method App\\Service\\BackupCodeInterface\:\:expire\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\BackupCodeManager\:\:generate\(\) overrides method App\\Service\\BackupCodeInterface\:\:generate\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\BackupCodeManager\:\:saveCodes\(\) has parameter \$codes with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\BackupCodeManager\:\:verifyAndConsume\(\) overrides method App\\Service\\BackupCodeInterface\:\:verifyAndConsume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Readonly property App\\Service\\BackupCodeManager\:\:\$config is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Readonly property App\\Service\\BackupCodeManager\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\DomainManager\:\:authBase\(\) overrides method App\\Service\\DomainInterface\:\:authBase\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/DomainManager.php
-
message: '#^Method App\\Service\\DomainManager\:\:getAuthSubdomain\(\) overrides method App\\Service\\DomainInterface\:\:getAuthSubdomain\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/DomainManager.php
-
message: '#^Method App\\Service\\DomainManager\:\:matchesAuth\(\) overrides method App\\Service\\DomainInterface\:\:matchesAuth\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/DomainManager.php
-
message: '#^Method App\\Service\\DomainManager\:\:validReturn\(\) overrides method App\\Service\\DomainInterface\:\:validReturn\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/DomainManager.php
-
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
-
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
-
message: '#^Class App\\Service\\SessionIssuer has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/SessionIssuer.php
-
message: '#^Readonly property App\\Service\\SessionIssuer\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/SessionIssuer.php
-
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
-
message: '#^Readonly property App\\Service\\LoginManager\:\:\$config is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/LoginManager.php
-
message: '#^Readonly property App\\Service\\LoginManager\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/LoginManager.php
-
message: '#^Readonly property App\\Service\\LoginManager\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/LoginManager.php
-
message: '#^Method App\\Service\\PublicPathMatcher\:\:isEmpty\(\) overrides method App\\Service\\PublicPathMatcherInterface\:\:isEmpty\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/PublicPathMatcher.php
-
message: '#^Method App\\Service\\PublicPathMatcher\:\:matches\(\) overrides method App\\Service\\PublicPathMatcherInterface\:\:matches\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/PublicPathMatcher.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:loginPayload\(\) is unused\.$#'
identifier: method.unused
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/CacheControlFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/CacheControlFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Functional/CacheControlFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/CacheControlFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/PublicAccessFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/PublicAccessFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Functional/PublicAccessFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/PublicAccessFlowTest.php
-
message: '#^Method App\\Tests\\TestKernel\:\:build\(\) overrides method Symfony\\Component\\DependencyInjection\\Kernel\\AbstractKernel\:\:build\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/TestKernel.php
-
message: '#^Method Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface@anonymous/tests/TestKernel\.php\:31\:\:process\(\) overrides method Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface\:\:process\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/TestKernel.php
-
message: '#^Method App\\Tests\\Unit\\Command\\GenerateBackupCodesCommandTest\:\:makeManagerStub\(\) has parameter \$generatedCodes with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Unit/Command/GenerateBackupCodesCommandTest.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with null will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 2
path: tests/Unit/Enum/ScopeTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method App\\Tests\\Unit\\Listener\\LoginListenerTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 1
path: tests/Unit/Service/BackupCodeManagerTest.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 1
path: tests/Unit/Service/BackupCodeManagerTest.php
-
message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#'
identifier: method.notFound
count: 17
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$backupCodeManager\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$domainManager\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$pool\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22 has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/GetTotpTraitTest.php
-
message: '#^Readonly property class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22\:\:\$config is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: tests/Unit/Trait/GetTotpTraitTest.php
-
message: '#^Class App\\Tests\\Unit\\Trait\\HasLoggerTraitTest has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/HasLoggerTraitTest.php
-
message: '#^Readonly property App\\Tests\\Unit\\Trait\\HasLoggerTraitTest\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: tests/Unit/Trait/HasLoggerTraitTest.php
-
message: '#^Class class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25 has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Class class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25 has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:expiresAfter\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:expiresAfter\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:expiresAt\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:expiresAt\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:get\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:get\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:getKey\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:getKey\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:isHit\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:isHit\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:set\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:set\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:clear\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:commit\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:commit\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:deleteItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:deleteItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItems\(\) return type has no value type specified in iterable type iterable\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:hasItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:hasItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:save\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:save\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:saveDeferred\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:saveDeferred\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Readonly property class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Readonly property class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: tests/bootstrap.php
-
message: '#^Call to method bootEnv\(\) on an unknown class Symfony\\Component\\Dotenv\\Dotenv\.$#'
identifier: class.notFound
count: 1
path: tests/bootstrap.php
-
message: '#^Class Symfony\\Component\\Dotenv\\Dotenv not found\.$#'
identifier: class.notFound
count: 1
path: tests/bootstrap.php
-
message: '#^Instantiated class Symfony\\Component\\Dotenv\\Dotenv not found\.$#'
identifier: class.notFound
count: 1
path: tests/bootstrap.php
+116
View File
@@ -0,0 +1,116 @@
# phpstan.neon.dist — canonical shared PHPStan config.
#
# Copy verbatim into a project root. This is a LEAF file: it has no
# project-specific content, so "sync it" means "overwrite it", never merge.
# Do not hand-edit per repo — change it here and re-sync, or the five copies
# drift back apart (GUIDING-LIGHT §8.2).
#
# Baseline: level 6 for application code. Raise per project as it gets clean;
# the goal recorded in GUIDING-LIGHT §2.2 is level 6 minimum everywhere.
#
# Adopt incrementally:
# 1. vendor/bin/phpstan analyse --generate-baseline
# 2. Commit the result over the empty phpstan-baseline.neon that ships with this
# 3. Ratchet `level` up as the baseline shrinks
# Never replace a fix with an ignoreErrors entry — see reportIgnoresWithoutComments.
#
# IMPORTANT — every key below is VERIFIED against phpstan.org/config-reference.
# PHPStan 2.x errors on unknown keys, but a plausible-looking wrong key copied
# from a blog post is a common way to lose an afternoon. If you add a key,
# confirm it there first. Extension-specific keys (symfony.*, doctrine.*,
# phpstan-deprecation-rules, etc.) are deliberately NOT set here — see the
# commented block at the bottom for why and how to opt in per project.
parameters:
level: 6
paths:
- src
- tests
# ── High-signal checks (all verified key names) ──────────────────────────
# An `@var` that contradicts the assignment is almost always a real bug.
reportWrongPhpDocTypeInVarTag: true
# A `@var` that only widens the inferred type is usually an unnecessary cast.
reportAnyTypeWideningInVarTag: true
# `@param`/`@return` that contradict the native signature.
reportStaticMethodSignatures: true
# An ignoreErrors entry with no explanatory comment is a smell.
reportIgnoresWithoutComments: true
# Forces every ignore to still match something. Without this, ignores
# accumulate forever and nobody notices when the underlying bug is fixed.
reportUnmatchedIgnoredErrors: true
# Catch `Foo` vs `foo` in function names — matters for Windows devs and
# for correctness under strict autoloading.
checkFunctionNameCase: true
# Typed properties that are read before they are definitely initialised.
checkUninitializedProperties: true
# Respect #[Override] so refactors in parent classes cannot silently stop
# overriding a method that got renamed.
checkMissingOverrideMethodAttribute: true
checkMissingOverridePropertyAttribute: true
# Static analysis cannot see through sprintf, so mis-ordered placeholders
# are otherwise invisible until runtime.
checkStrictPrintfPlaceholderTypes: true
# Dynamic properties are deprecated in PHP 8.2+ and are a common source of
# typos that would otherwise fail silently at runtime.
checkDynamicProperties: true
# All five repos have this file (verified); the kernel boot lives here.
bootstrapFiles:
- tests/bootstrap.php
ignoreErrors:
# Symfony's createClient() returns KernelBrowser, but some test helpers
# are typed against the narrower legacy interface.
# reportUnmatched:false so this does not fail the build once the
# offending helper is typed properly.
#
# NOTE the layout: the dash sits alone and the keys are indented under
# it. This is the form used verbatim in PHPStan's own documentation.
# (The more compact `- message: ...` / continuation form is also valid
# NEON, but NOT every NEON parser in the wild handles it — the PHP
# parser PHPStan uses handles both, Python's neon-py handles neither
# reliably. Staying with the documented form avoids the argument.)
-
message: '#Call to an undefined method Symfony\\Component\\HttpFoundation\\Session\\SessionInterface::#'
reportUnmatched: false
includes:
# Ships EMPTY with this config. A repo overwrites it when it runs
# --generate-baseline. It must exist: a missing `includes` target is a hard
# error, not a silent skip, which is why the empty file is committed rather
# than the include being made conditional.
- phpstan-baseline.neon
# ─────────────────────────────────────────────────────────────────────────────
# OPTIONAL EXTENSIONS — commented out on purpose.
#
# These keys are owned by PHPStan *extensions*, not core. If the extension is
# not installed, or the key name drifts between extension majors, analysis
# fails outright. So they are opt-in per project rather than shared.
#
# Symfony — resolves service ids, autowiring, and container params from the
# compiled container. Requires phpstan/phpstan-symfony. Uncomment AND make sure
# the path exists (warm the dev cache first, or let the test bootstrap do it).
#
# symfony:
# containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
#
# Doctrine — validates DQL against the actual mapping. Requires
# phpstan/phpstan-doctrine. `repositoryClass` must name a class that EXISTS in
# the project; setting it to a class you do not have is an instant failure.
#
# doctrine:
# repositoryClass: App\Repository\YourBaseRepository
# ─────────────────────────────────────────────────────────────────────────────
+8
View File
@@ -22,6 +22,12 @@
<!-- high rate limits so functional tests don't get blocked -->
<server name="BURST_COUNT" value="10000" />
<server name="UPPER_COUNT" value="10000" />
<!-- public access: enable for functional tests with low limits -->
<server name="PUBLIC_PATHS" value="/public/**" />
<server name="PUBLIC_BURST_COUNT" value="3" />
<server name="PUBLIC_BURST_TIME" value="60" />
<server name="PUBLIC_UPPER_COUNT" value="10000" />
<server name="PUBLIC_UPPER_TIME" value="3600" />
</php>
<testsuites>
@@ -40,6 +46,8 @@
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
+1 -1
View File
@@ -6,6 +6,6 @@ use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return static function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
+84 -10
View File
@@ -17,6 +17,8 @@ For when you want a belt and suspenders.
- **Caddy native** — Designed for Caddy's `forward_auth` directive
- **Docker-first** — Single container, persistent volumes, no database
- **Rate limiting** — Per-IP burst and sustained limits (cannot be disabled)
- **Public rate-limited access** — Optional, allow unauthenticated access
to specific paths with separate rate limiting (e.g., public Gitea repos)
- **Central auth** — Optional subdomain-based SSO across multiple services
- **IP-based bypass** — Optional, for services that don't handle cookies
- **Customizable** — Colors, labels, messages, and error text via env vars
@@ -42,7 +44,7 @@ docker pull digitaladapt/preauth:latest
openssl rand -base64 30
```
Create a `.env` file (see `docs/example.env` for all options):
Create a `.env` file (see `docs/examples/.env.example` for all options):
```env
APP_SECRET=your-random-secret-here
@@ -59,7 +61,7 @@ COOKIE_TTL=2592000
docker compose up -d
```
See `docs/compose.yaml` for an example Docker Compose file.
See `docs/examples/compose.yaml` for an example Docker Compose file.
### 4. Configure Caddy
@@ -68,13 +70,24 @@ service.example.com {
forward_auth preauth {
uri {uri}
copy_headers Remote-User
# keep the login flow out of browser/proxy caches
header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0"
header_down Pragma "no-cache"
header_down Expires "0"
header_down Surrogate-Control "no-store"
header_down Vary "*"
}
reverse_proxy your-service:80
}
```
See `docs/Caddyfile` for more examples, including path-specific protection
and central auth subdomain configuration.
See `docs/examples/Caddyfile` for more examples, including path-specific protection
and central auth subdomain configuration. The `header_down` lines above are
optional — preauth already sends these headers itself — but they guarantee
at the edge that no part of the login flow is ever cached. (2xx auth
responses are consumed by `forward_auth` and never reach the browser, so
your service's own cache headers are unaffected.)
### 5. Generate backup codes (optional)
@@ -93,7 +106,7 @@ capabilities may work, but only Caddy is officially supported.
## Configuration
All configuration is via environment variables. See `docs/example.env`
All configuration is via environment variables. See `docs/examples/.env.example`
for the complete reference.
### Main Options
@@ -111,6 +124,7 @@ for the complete reference.
|----------|---------|-------------|
| `IP_TTL` | `0` | Seconds to allow all traffic from an IP after login (0 = disabled). |
| `TEAPOT` | `1` | Respond with 418 instead of 429 when rate-limited (boolean). |
| `MAX_REQUESTS` | `500` | Restart each FrankenPHP worker thread after this many requests to contain memory growth (`0` = unlimited). Maps to the Caddyfile `max_requests` directive. |
### Remote-User Header
@@ -138,6 +152,56 @@ Rate limiting **cannot be disabled**. It uses a compound sliding window:
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
### Public Rate-Limited Access
Preauth can provide rate-limited unauthenticated access to select public
paths. This is useful for exposing public content (e.g., public repositories
in Gitea) without requiring TOTP authentication, while protecting server
resources from bot traffic.
When `PUBLIC_PATHS` is configured, requests to matching paths from
unauthenticated users are allowed through with a separate rate limiter.
Authenticated users bypass the public rate limiter entirely.
| Variable | Default | Description |
|----------|---------|-------------|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. See below. |
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP. |
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds. |
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP. |
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour). |
**Path pattern syntax:**
- Patterns are matched against the request path only (query string ignored).
- Patterns must start with `/`.
- `*` matches one or more characters within a single path segment (not crossing `/`).
- `**` matches zero or more characters including `/` (crosses path segments).
- An optional host prefix can restrict a pattern to a specific host
(e.g., `code.example.com/public/**`).
| Pattern | Matches | Does NOT match |
|---------|---------|----------------|
| `/public` | `/public` | `/public/`, `/public/repo` |
| `/public/*` | `/public/repo` | `/public`, `/public/a/b` |
| `/public/**` | `/public/repo`, `/public/a/b/c` | `/public` |
| `host.com/api/**` | `host.com/api/v1/status` | `other.com/api/v1/status` |
**Example:** Allow public access to Gitea's `/public/` paths:
```env
PUBLIC_PATHS=/public/**
PUBLIC_BURST_COUNT=100
PUBLIC_BURST_TIME=60
PUBLIC_UPPER_COUNT=500
PUBLIC_UPPER_TIME=3600
```
When a visitor exceeds the rate limit, they receive a `429 Too Many Requests`
response with a `Retry-After` header. When within limits, they receive a
`200 OK` response (with no `Remote-User` header). Authenticated users receive
`200 OK` with their `Remote-User` header as normal.
### Styling
All UI text and colors are configurable:
@@ -168,10 +232,12 @@ passes through a priority-ordered chain of listeners:
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
2. **AllowListener** (priority 88) — Checks for valid IP-based session.
3. **RejectListener** (priority 77) — Rate-limiting gate.
4. **LoginListener** (priority 66) — Processes login attempts.
5. **InterceptListener** (priority 55) — Renders login page or redirects.
6. **SecurityHeadersListener** (response) — Adds security headers.
3. **PublicAccessListener** (priority 84) — If public paths are configured,
allows rate-limited unauthenticated access to matching paths.
4. **RejectListener** (priority 77) — Rate-limiting gate.
5. **LoginListener** (priority 66) — Processes login attempts.
6. **InterceptListener** (priority 55) — Renders login page or redirects.
7. **SecurityHeadersListener** (response) — Adds security headers.
### Security Model
@@ -182,6 +248,14 @@ passes through a priority-ordered chain of listeners:
- **Rate limiting**: Per-IP, compound sliding window, cannot be disabled
- **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, HSTS
- **No cacheable login flow**: The login page, failed logins, redirects,
and rate-limit pages are sent with strict anti-caching headers
(`no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0,
s-maxage=0` plus `Pragma`, `Expires`, `Surrogate-Control`, and
`Vary: *`), and the login form's `fetch()` opts out of the HTTP cache.
Successful (2xx) responses are deliberately excluded — they are
consumed by the proxy's `forward_auth` check and never reach the
browser, so a protected service's own caching is not affected.
### Cache
@@ -213,7 +287,7 @@ vendor/bin/php-cs-fixer fix
vendor/bin/phpunit
```
The test suite includes 222 tests with 100% code coverage (lines, methods,
The test suite includes 293 tests with 100% code coverage (lines, methods,
and classes). Both unit tests and functional tests (full HTTP kernel flow)
are included.
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\CacheWarmer;
use App\Exception\PasskeyConfigurationException;
use App\Service\PasskeyPolicyInterface;
use Override;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
/**
* Fails the build (or container start) when passkeys are enabled in a
* configuration that cannot support them.
*
* `docker/entrypoint.sh` runs `cache:warmup` on every production boot with the
* real environment already injected, so a misconfiguration is caught while the
* container is starting — the deployment aborts — rather than surfacing later as
* a passkey button that silently never works.
*
* The warmer is **not** optional: an optional warmer may be skipped, which would
* let a bad configuration through.
*/
final readonly class PasskeyConfigurationWarmer implements CacheWarmerInterface
{
public function __construct(
private PasskeyPolicyInterface $passkeyPolicy,
) {
}
/**
* @return string[]
*
* @throws PasskeyConfigurationException when passkeys are enabled but unusable
*/
#[Override]
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
$this->passkeyPolicy->assertConfigurationIsUsable();
return [];
}
/**
* Never optional: skipping this warmer would defeat its entire purpose.
*/
#[Override]
public function isOptional(): bool
{
return false;
}
}
+3 -2
View File
@@ -9,10 +9,10 @@ use App\Service\BackupCodeInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
/** simple console command to generate backup codes
* usage: php bin/console app:generate-backup-codes [count] */
@@ -21,7 +21,7 @@ final class GenerateBackupCodesCommand extends Command
{
public function __construct(
private readonly BackupCodeInterface $manager,
private readonly PersistCache $persistCache,
private readonly PersistCache $persistCache,
) {
parent::__construct();
}
@@ -46,6 +46,7 @@ final class GenerateBackupCodesCommand extends Command
$output->writeln($code);
}
$this->persistCache->persist();
return Command::SUCCESS;
}
}
+74 -19
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App;
use App\Enum\RemoteUserMode;
use App\Enum\UserVerification;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -23,34 +24,52 @@ final readonly class ConfigBag
private string $remoteUserStatic;
/** @var array<string,string> */
private array $remoteUserMap;
private string $title;
private bool $passkeyEnabled;
private string $passkeyRpName;
private UserVerification $passkeyUserVerification;
private int $passkeyTimeout;
/** Passkey ceremony timeout in milliseconds (WebAuthn default). */
private const int DEFAULT_PASSKEY_TIMEOUT = 60000;
/** @throws InvalidArgumentException */
public function __construct(
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
#[Autowire('%app.remote_user%')] string $remoteUserMode,
#[Autowire('%app.remote_user%')] string $remoteUserMode,
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
#[Autowire('%app.title%')] string $title = 'Pre-Authentication System',
#[Autowire('%app.passkey_enabled%')] bool $passkeyEnabled = false,
#[Autowire('%app.passkey_rp_name%')] string $passkeyRpName = '',
#[Autowire('%app.passkey_user_verification%')] string $passkeyUserVerification = 'required',
#[Autowire('%app.passkey_timeout%')] int $passkeyTimeout = self::DEFAULT_PASSKEY_TIMEOUT,
) {
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null;
$this->teapot = $teapot;
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null;
$this->teapot = $teapot;
$this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle;
$this->teapotTitle = $teapotTitle;
$this->tooManyTitle = $tooManyTitle;
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
$this->remoteUserStatic = $remoteUserStatic;
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
$this->title = $title;
$this->passkeyEnabled = $passkeyEnabled;
$this->passkeyRpName = $passkeyRpName;
$this->passkeyUserVerification = UserVerification::fromConfig($passkeyUserVerification);
$this->passkeyTimeout = $passkeyTimeout > 0 ? $passkeyTimeout : self::DEFAULT_PASSKEY_TIMEOUT;
}
/**
@@ -60,17 +79,18 @@ final readonly class ConfigBag
*/
private function parseUserMap(string $map): array
{
if ($map === '') {
if ('' === $map) {
return [];
}
$result = [];
foreach (explode(',', $map) as $pair) {
$parts = explode(':', trim($pair), 2);
if (count($parts) === 2) {
if (2 === \count($parts)) {
$result[trim($parts[0])] = trim($parts[1]);
}
}
return $result;
}
@@ -131,4 +151,39 @@ final readonly class ConfigBag
{
return $this->remoteUserMap;
}
public function title(): string
{
return $this->title;
}
/**
* Whether the passkey feature is switched on by configuration.
*
* This says nothing about whether the configuration is *usable* — that is
* {@see Service\PasskeyPolicyInterface::isEnabled()}, which also
* requires the central-auth prerequisite (D1).
*/
public function passkeyEnabled(): bool
{
return $this->passkeyEnabled;
}
/** Relying-party name shown in the authenticator prompt; blank falls back to the title. */
public function passkeyRpName(): string
{
return $this->passkeyRpName;
}
/** User-verification requirement; an unrecognised value falls back to `required`. */
public function passkeyUserVerification(): string
{
return $this->passkeyUserVerification->value;
}
/** Ceremony timeout in milliseconds. */
public function passkeyTimeout(): int
{
return $this->passkeyTimeout;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Data;
use DateTimeImmutable;
use Webauthn\CredentialRecord;
/**
* A stored passkey: the WebAuthn credential plus the application metadata that
* ties it to a visitor of this gateway.
*
* The credential itself carries the public key and signature counter; the
* metadata here records *whose* passkey it is and when it was used. The identity
* is deliberately read from this record on assertion rather than from the value
* the client returns, which is never trusted.
*/
final readonly class PasskeyCredential
{
public function __construct(
public CredentialRecord $record,
/** The session id this passkey authenticates, as typed at registration. */
public string $identity,
/** Operator-facing description, shown so a user can tell their keys apart. */
public string $label,
public DateTimeImmutable $createdAt,
public ?DateTimeImmutable $lastUsedAt = null,
) {
}
/**
* A copy of this credential with the signature counter and last-used time
* refreshed after a successful assertion.
*
* The counter is only ever observed, never enforced — many passkeys report a
* constant zero, so treating it as a clone signal would lock users out.
*/
public function withUsage(CredentialRecord $updated, DateTimeImmutable $usedAt): self
{
return new self(
$updated,
$this->identity,
$this->label,
$this->createdAt,
$usedAt,
);
}
}
+22 -20
View File
@@ -14,59 +14,61 @@ final class Payload
public string $id; /* session name, identifying who is logging in */
public string $token; /* TOTP, typically six digits */
public string $nonce; /* random unique string, to block duplicate submissions */
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
public static function decode(string $base64url): ?Payload
public static function decode(string $base64url): ?self
{
/* convert the base64url into json string */
$base64 = strtr($base64url, '-_', '+/');
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
$base64 .= str_repeat('=', (4 - \strlen($base64) % 4) % 4);
$json = base64_decode($base64, true);
if ($json) {
/* convert the json string into real data */
$data = json_decode($json);
if (is_object($data)) {
return Payload::create($data);
if (\is_object($data)) {
return self::create($data);
}
}
return null;
}
public static function load(InputBag $input): ?Payload
public static function load(InputBag $input): ?self
{
/* convert form data into real data */
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
return Payload::create((object)[
'id' => $input->get('username'),
return self::create((object) [
'id' => $input->get('username'),
'nonce' => $input->get('nonce'),
'token' => $input->get('totp'),
'json' => false,
'json' => false,
]);
}
return null;
}
public static function create(object $data): ?Payload
public static function create(object $data): ?self
{
/* if missing required fields id, nonce, or token */
if (strlen(trim($data->id ?? '')) < 1 ||
strlen(trim($data->nonce ?? '')) < 1 ||
strlen(trim($data->token ?? '')) < 1
if ('' === trim($data->id ?? '')
|| '' === trim($data->nonce ?? '')
|| '' === trim($data->token ?? '')
) {
/* returns null as the input is invalid */
return null;
}
/* all input is limited */
$payload = new Payload();
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
$payload = new self();
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
$payload->nonce = mb_substr(trim($data->nonce), 0, AppConstants::MAX_INPUT_LENGTH);
$payload->json = ($data->json ?? true);
$payload->json = ($data->json ?? true);
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
return Payload::constrict($payload);
return self::constrict($payload);
}
public function toString(): string
@@ -74,10 +76,10 @@ final class Payload
return json_encode($this);
}
private static function constrict(Payload $payload): Payload
private static function constrict(self $payload): self
{
/* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) {
if (Scope::None === $payload->scope) {
$payload->json = false;
}
+2 -2
View File
@@ -8,6 +8,6 @@ namespace App\Enum;
enum Scope: string
{
case Cookie = 'cookie';
case Ip = 'ip';
case None = 'none';
case Ip = 'ip';
case None = 'none';
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* User-verification requirement passed to the authenticator at passkey time.
*
* Mirrors the WebAuthn `userVerification` option without leaking the library's
* constants into application configuration.
*/
enum UserVerification: string
{
/** Require a biometric/PIN check (default). */
case Required = 'required';
/** Ask for it, but allow a plain user-presence tap to succeed. */
case Preferred = 'preferred';
/** Never prompt for verification; presence alone is enough. */
case Discouraged = 'discouraged';
/**
* Parse a configured value, falling back to the safest option.
*
* An unrecognised value must never silently weaken the requirement, so the
* fallback is the strictest case rather than the most permissive one.
*/
public static function fromConfig(string $value): self
{
return self::tryFrom(trim($value)) ?? self::Required;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
/**
* Thrown when passkeys are enabled in a configuration that cannot support them.
*
* This is deliberately fatal: the alternative is a feature that appears to be
* switched on but cannot complete a single ceremony, which surfaces to the user
* as "my passkey stopped working" rather than as a deployment error.
*
* Raised during cache warm-up so that a misconfigured container fails to start
* instead of failing later, in a browser.
*/
final class PasskeyConfigurationException extends RuntimeException
{
}
+7 -6
View File
@@ -11,6 +11,7 @@ use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -21,9 +22,9 @@ final readonly class AcceptListener
use StringTrait;
public function __construct(
private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
private ConfigBag $config,
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
private ConfigBag $config,
) {
}
@@ -32,7 +33,7 @@ final readonly class AcceptListener
{
/* check if they sent the correct preauth cookie */
$cookieName = $this->sessionCookieName($this->domainManager);
if (! $event->getRequest()->cookies->has($cookieName)) {
if (!$event->getRequest()->cookies->has($cookieName)) {
return;
}
@@ -40,13 +41,13 @@ final readonly class AcceptListener
$cookieKey = $this->makeCacheKey("cookie_$cookie");
try {
if (! $cookie || ! $this->sessionCache->hasItem($cookieKey)) {
if (!$cookie || !$this->sessionCache->hasItem($cookieKey)) {
return;
}
/* cookie sent corresponds to valid existing session */
$item = $this->sessionCache->getItem($cookieKey);
if (! $item->isHit()) {
if (!$item->isHit()) {
/* race condition: item was removed between hasItem and getItem */
return;
}
+5 -4
View File
@@ -9,6 +9,7 @@ use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -18,8 +19,8 @@ final readonly class AllowListener
use StringTrait;
public function __construct(
private CacheItemPoolInterface $sessionCache,
private ConfigBag $config,
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
private ConfigBag $config,
) {
}
@@ -33,13 +34,13 @@ final readonly class AllowListener
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
try {
if (! $this->sessionCache->hasItem($ipKey)) {
if (!$this->sessionCache->hasItem($ipKey)) {
return;
}
/* ip address corresponds to valid existing session */
$item = $this->sessionCache->getItem($ipKey);
if (! $item->isHit()) {
if (!$item->isHit()) {
/* race condition: item was removed between hasItem and getItem */
return;
}
+9 -9
View File
@@ -26,9 +26,9 @@ final readonly class InterceptListener
use MakeNonceTrait;
public function __construct(
private ConfigBag $config,
private ConfigBag $config,
private DomainInterface $domainManager,
private Environment $twig,
private Environment $twig,
) {
}
@@ -39,29 +39,29 @@ final readonly class InterceptListener
/* by this point, we know that the request we have is:
* not already authorized, nor already rate-limited,
* nor submitting login credentials; so redirect or present the login page now */
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() &&
$this->domainManager->matchesAuth($event->getRequest()->getHost())
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost()
&& $this->domainManager->matchesAuth($event->getRequest()->getHost())
) {
/* host matches base-domain of auth, but not on auth subdomain, redirect */
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
$event->setResponse(new Response(
'',
Response::HTTP_SEE_OTHER,
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"],
));
} else {
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
$content = $this->twig->render('login.html.twig', [
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
]);
$hasCookie = (bool) $event->getRequest()->cookies->get(
$this->sessionCookieName($this->domainManager)
$this->sessionCookieName($this->domainManager),
);
$event->setResponse($this->pruneInvalidCookie(new Response(
$content,
Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'text/html']
['Content-Type' => 'text/html'],
), $hasCookie, $event->getRequest()->getHost()));
}
}
@@ -75,7 +75,7 @@ final readonly class InterceptListener
$this->sessionCookieDomain($this->domainManager, $host),
true,
true,
Cookie::SAMESITE_STRICT
Cookie::SAMESITE_STRICT,
);
}
+18 -16
View File
@@ -43,28 +43,28 @@ final readonly class LoginListener
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private Environment $twig,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
) {
$this->rateLimiter = $rateLimiter;
$this->rateLimiter = $rateLimiter;
}
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
#[AsEventListener(priority: 66)]
public function onKernelRequest(RequestEvent $event): void
{
$payload = null;
$payload = null;
$response = null;
if ($event->getRequest()->headers->has($this->headerName())) {
/* if request contains our "X-Preauth" header */
$data = $event->getRequest()->headers->get($this->headerName());
$payload = Payload::decode($data);
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) &&
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST)
&& $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
) {
/* if request is a POST to the auth-subdomain */
$payload = Payload::load($event->getRequest()->getPayload());
@@ -80,6 +80,7 @@ final readonly class LoginListener
/* token or backup-code authentication was successful */
if ($response) {
$event->setResponse($response);
return;
}
}
@@ -92,14 +93,15 @@ final readonly class LoginListener
$limitReached,
$payload?->json ?? true,
$event->getRequest()->getHost(),
$this->makeCacheKey($payload?->id ?? '')
$this->makeCacheKey($payload?->id ?? ''),
));
}
private function logFailure(Request $request): bool
{
$limiter = $this->rateLimiter->create($request->getClientIp());
return ($limiter->consume(1)->getRemainingTokens() < 1);
return $limiter->consume(1)->getRemainingTokens() < 1;
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
@@ -111,24 +113,24 @@ final readonly class LoginListener
$message = $this->config->teapot() ? $this->config->teapotTitle()
: $this->config->tooManyTitle();
} else {
$status = Response::HTTP_UNAUTHORIZED;
$status = Response::HTTP_UNAUTHORIZED;
$message = $this->config->errorMessage();
}
$answer = [
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
];
if ($json) {
$contentType = 'application/json';
$content = json_encode($answer);
$content = json_encode($answer);
} else {
$contentType = 'text/html';
$content = $this->twig->render('login.html.twig', $answer);
$content = $this->twig->render('login.html.twig', $answer);
}
return new Response($content, $status, ["Content-Type" => $contentType]);
return new Response($content, $status, ['Content-Type' => $contentType]);
}
}
+316
View File
@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Enum\Scope;
use App\Service\DomainInterface;
use App\Service\PasskeyInterface;
use App\Service\PasskeyPolicyInterface;
use App\Service\SessionIssuerInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
/**
* Serves the passkey ceremonies.
*
* **Priority 70 — the whole design turns on this number.**
*
* - *After* `RejectListener` (77), so a rate-limited IP never reaches this code.
* Passkeys cannot be used to sidestep a lockout; that is decision D3.
* - *Before* `LoginListener` (66), which is essential rather than tidy:
* `LoginListener` treats **any** POST to the auth subdomain as a login attempt,
* and a ceremony `finish` body has no `username`/`totp`, so `Payload::load()`
* returns null and the request would be scored as a failed login — burning a
* rate-limit token for every legitimate passkey login.
*
* **Every** request carrying the dispatch header gets a response, including
* malformed ones. Falling through would let `InterceptListener` render HTML to a
* `fetch()` caller.
*/
final readonly class PasskeyListener
{
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
/**
* Marks a request as a ceremony call, and its value selects the operation.
*
* A distinct header rather than overloading `X-Preauth`: that one carries a
* base64url `Payload` and is parsed as such.
*/
public const string HEADER = 'X-Preauth-Passkey';
/** Marks a response as ceremony output, so the caching policy can see it. */
public const string CEREMONY_MARKER = 'X-Preauth-Ceremony';
private const string BEGIN_LOGIN = 'login-begin';
private const string FINISH_LOGIN = 'login-finish';
private const string BEGIN_REGISTER = 'register-begin';
private const string FINISH_REGISTER = 'register-finish';
private RateLimiterFactoryInterface $beginLimiter;
private RateLimiterFactoryInterface $loginLimiter;
public function __construct(
#[Target('passkey_begin_burst')] RateLimiterFactoryInterface $beginLimiter,
#[Target('login_limiter')] RateLimiterFactoryInterface $loginLimiter,
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
private PasskeyInterface $passkeys,
private PasskeyPolicyInterface $policy,
private SessionIssuerInterface $sessionIssuer,
private DomainInterface $domainManager,
private ConfigBag $config,
) {
$this->beginLimiter = $beginLimiter;
$this->loginLimiter = $loginLimiter;
}
/**
* @throws InvalidArgumentException
*/
#[AsEventListener(priority: 70)]
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$operation = $request->headers->get(self::HEADER);
if (null === $operation) {
return;
}
/* Ceremonies exist only on the auth subdomain. Elsewhere the header is
* ignored entirely, so this listener cannot be used to probe other hosts. */
if ($this->domainManager->getAuthSubdomain() !== $request->getHost()) {
return;
}
$event->setResponse($this->dispatch($operation, $request));
}
private function dispatch(string $operation, Request $request): Response
{
/* not available => behave as if the feature does not exist */
if (!$this->policy->isAvailableFor($request)) {
return $this->ceremonyResponse($this->error('Passkeys are not available.', $request));
}
return $this->ceremonyResponse(match ($operation) {
self::BEGIN_LOGIN => $this->beginLogin($request),
self::FINISH_LOGIN => $this->finishLogin($request),
self::BEGIN_REGISTER => $this->beginRegistration($request),
self::FINISH_REGISTER => $this->finishRegistration($request),
default => $this->error('Unknown passkey operation.', $request),
});
}
private function beginLogin(Request $request): Response
{
if ($limit = $this->beginBurstExceeded($request)) {
return $limit;
}
return $this->json($this->passkeys->beginLogin());
}
/**
* Registration is only offered to someone who already authenticated: the
* identity comes from the live session, never from the request body, so a
* caller cannot register a passkey for an identity it does not hold.
*/
private function beginRegistration(Request $request): Response
{
$identity = $this->identityFromRequest($request);
if (null === $identity) {
return $this->error('Registration requires a completed login.', $request);
}
if ($limit = $this->beginBurstExceeded($request)) {
return $limit;
}
return $this->json($this->passkeys->beginRegistration($identity));
}
private function finishLogin(Request $request): Response
{
$credential = $this->passkeys->finishLogin($this->body($request));
if (null === $credential) {
/* A failed ceremony consumes the same budget as a wrong TOTP code
* (D3), so passkey guesses cannot outpace code guesses. */
return $this->failure($request);
}
$this->logger->debug("passkey login succeeded for: {$credential->identity}");
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
}
private function finishRegistration(Request $request): Response
{
$credential = $this->passkeys->finishRegistration($this->body($request));
if (null === $credential) {
return $this->failure($request);
}
$this->logger->debug("passkey registered for: {$credential->identity}");
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
}
/**
* The identity of an already-authenticated caller, for registration.
*
* Read from the live session cookie, so `register-begin` is reachable only by
* someone who has just passed the TOTP check. Null when there is no session.
*
* @throws InvalidArgumentException
*/
private function identityFromRequest(Request $request): ?string
{
$cookie = $request->cookies->get($this->sessionCookieName($this->domainManager));
if (!\is_string($cookie) || '' === $cookie) {
return null;
}
$item = $this->sessionCache->getItem($this->makeCacheKey("cookie_$cookie"));
if (!$item->isHit()) {
return null;
}
$identity = $item->get();
return \is_string($identity) && '' !== $identity ? $identity : null;
}
/**
* Bounds how many ceremonies one caller can start.
*
* A resource guard, not the login budget: a legitimate `begin` must not spend
* failure budget, but an unbounded `begin` could fill the ceremony cache.
*/
private function beginBurstExceeded(Request $request): ?Response
{
$limit = $this->beginLimiter->create((string) $request->getClientIp())->consume(1);
if ($limit->isAccepted()) {
return null;
}
$retryAfter = max(1, $limit->getRetryAfter()->getTimestamp() - time());
$this->logger->debug("passkey begin rate-limited: {$request->getClientIp()}");
$response = $this->error('Too many passkey attempts, please slow down.', $request);
$response->setStatusCode(Response::HTTP_TOO_MANY_REQUESTS);
$response->headers->set('Retry-After', (string) $retryAfter);
return $response;
}
/**
* A failed ceremony is indistinguishable from a wrong TOTP code, and spends
* the same shared budget — including the same 418/429 outcome when exhausted.
*/
private function failure(Request $request): Response
{
$limited = $this->loginLimiter
->create((string) $request->getClientIp())
->consume(1)
->getRemainingTokens() < 1;
$this->logger->debug("passkey ceremony failed for: {$request->getClientIp()}");
if ($limited) {
$response = $this->error(
$this->config->teapot() ? $this->config->teapotTitle() : $this->config->tooManyTitle(),
$request,
);
$response->setStatusCode(
$this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
);
return $response;
}
$response = $this->error($this->config->errorMessage(), $request);
$response->setStatusCode(Response::HTTP_UNAUTHORIZED);
return $response;
}
/**
* Mark a reply as ceremony output so the no-store policy can find it.
*
* These are the only browser-facing 2xx responses this application produces,
* and `SecurityHeadersListener` otherwise assumes any 2xx is consumed by
* `forward_auth` and leaves it cacheable.
*/
private function ceremonyResponse(Response $response): Response
{
$response->headers->set(self::CEREMONY_MARKER, '1');
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* A JSON error carrying a fresh nonce and the same shape the login page
* expects, so the caller can fall back to the TOTP form without a reload.
*
* @throws InvalidArgumentException
*/
private function error(string $message, Request $request): Response
{
return new Response(
(string) json_encode([
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $request->getHost(),
'username' => '',
]),
Response::HTTP_UNAUTHORIZED,
);
}
/**
* @param array<string,mixed> $payload
*/
private function json(array $payload): Response
{
return new Response((string) json_encode($payload), Response::HTTP_OK);
}
/**
* @return array<string,mixed>
*/
private function body(Request $request): array
{
$raw = $request->getContent();
if ('' === $raw) {
return [];
}
$decoded = json_decode($raw, true);
return \is_array($decoded) ? $decoded : [];
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use App\Service\PublicPathMatcherInterface;
use App\Trait\HasLoggerTrait;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
/**
* Allows rate-limited unauthenticated access to configured public paths.
*
* Runs at priority 84 — after AcceptListener (99) and AllowListener (88)
* so authenticated users bypass this listener entirely, but before
* RejectListener (77) and LoginListener (66) so public traffic is not
* subject to the login rate limiter.
*
* When the request path matches a configured public path pattern:
* - If within rate limit → 200 OK (no Remote-User header)
* - If over rate limit → 429 Too Many Requests with Retry-After header
*
* Non-matching paths fall through to the normal auth flow.
*/
final readonly class PublicAccessListener
{
use HasLoggerTrait;
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private PublicPathMatcherInterface $pathMatcher,
private DomainInterface $domainManager,
private Environment $twig,
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
}
/** @throws SyntaxError|RuntimeError|LoaderError */
#[AsEventListener(priority: 84)]
public function onKernelRequest(RequestEvent $event): void
{
if ($this->pathMatcher->isEmpty()) {
return;
}
$request = $event->getRequest();
$host = $request->getHost();
$path = $request->getPathInfo();
// Never treat the auth subdomain itself as public
if ($this->domainManager->getAuthSubdomain() === $host) {
return;
}
if (!$this->pathMatcher->matches($host, $path)) {
return;
}
// Path is public — apply rate limiting
$limiter = $this->rateLimiter->create($request->getClientIp());
$limit = $limiter->consume(1);
if ($limit->isAccepted()) {
$this->logger->debug("public access granted: {$request->getClientIp()} -> $path");
$event->setResponse(new Response(
'',
Response::HTTP_OK,
[
'Content-Type' => 'text/plain',
'Retry-After' => (string) $limit->getRemainingTokens(),
],
));
} else {
$retryAfter = $limit->getRetryAfter()?->getTimestamp() - time();
$retryAfter = max(1, $retryAfter);
$this->logger->debug("public access rate-limited: {$request->getClientIp()} -> $path");
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response(
$html,
Response::HTTP_TOO_MANY_REQUESTS,
[
'Content-Type' => 'text/html',
'Retry-After' => (string) $retryAfter,
],
));
}
}
}
+6 -6
View File
@@ -9,8 +9,8 @@ use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
@@ -25,8 +25,8 @@ final readonly class RejectListener
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private ConfigBag $config,
private Environment $twig,
private ConfigBag $config,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
@@ -43,9 +43,9 @@ final readonly class RejectListener
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response(
$html,
($this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
['Content-Type' => 'text/html']
$this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
['Content-Type' => 'text/html'],
));
}
}
+74 -7
View File
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicyInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
@@ -15,15 +18,21 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent;
*/
final readonly class SecurityHeadersListener
{
public function __construct(
private DomainInterface $domainManager,
private PasskeyPolicyInterface $passkeyPolicy,
) {
}
#[AsEventListener(priority: 0)]
public function onKernelResponse(ResponseEvent $event): void
{
if (! $event->isMainRequest()) {
if (!$event->isMainRequest()) {
return;
}
$response = $event->getResponse();
$headers = $response->headers;
$headers = $response->headers;
/* prevent MIME-type sniffing */
$headers->set('X-Content-Type-Options', 'nosniff');
@@ -36,13 +45,71 @@ final readonly class SecurityHeadersListener
/* Content-Security-Policy — the login page uses inline styles
* and scripts (via Twig includes), so we allow 'unsafe-inline'
* for those. No external resources are loaded. */
$headers->set(
'Content-Security-Policy',
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
);
* for those. No external resources are loaded.
*
* When subdomain redirection is off (or the request is not on
* the auth subdomain), the login form is served inline on the
* protected host and submission is performed via a same-origin
* fetch() call in _script.html.twig. That fetch is blocked by
* the default 'none' policy, so we add connect-src 'self' only
* in that case — the least privilege needed to make the form
* work. On the auth subdomain the form POSTs normally and no
* inline script is included, so the stricter policy applies. */
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
/* `publickey-credentials-get`/`-create` do NOT fall back to default-src,
* so without these directives the browser refuses the ceremony even
* though the script itself is allowed to run. `connect-src 'self'` is
* needed in both modes here, because the passkey flow always talks to
* the server with fetch(). */
if ($this->passkeyPolicy->isAvailableFor($event->getRequest())) {
$csp .= " connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';";
} elseif ($inlineScript) {
$csp .= " connect-src 'self';";
}
$headers->set('Content-Security-Policy', $csp);
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
$headers->set('Strict-Transport-Security', 'max-age=31536000');
/* Prevent any part of the login flow from being cached: the login
* page, failed logins, redirects, and rate-limit/error pages must
* never be stored or replayed by the browser or an intermediate
* cache — older Safari builds in particular may otherwise resurrect
* a stale pre-auth response, appearing to log the user out after a
* refresh or showing a previous session after logging in again.
*
* Only non-2xx responses are touched: the 2xx responses that grant
* access ("already authenticated" or public) are consumed by the
* reverse proxy's forward_auth check before reaching the browser,
* and the protected service's own cache headers must remain
* untouched.
*
* A ceremony reply is the exception that proves the rule: it is a 2xx
* that goes straight to the browser, because the auth subdomain has no
* forward_auth in front of it. Left alone it would be cacheable, so a
* browser could replay a stale challenge. `PasskeyListener` marks those
* responses and the marker is stripped here. */
if (!$response->isSuccessful()) {
$this->applyNoStore($headers);
return;
}
if ($headers->has(PasskeyListener::CEREMONY_MARKER)) {
$headers->remove(PasskeyListener::CEREMONY_MARKER);
$this->applyNoStore($headers);
}
}
private function applyNoStore(ResponseHeaderBag $headers): void
{
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
$headers->set('Pragma', 'no-cache');
$headers->set('Expires', '0');
$headers->set('Surrogate-Control', 'no-store');
$headers->set('Vary', '*');
}
}
+16 -14
View File
@@ -26,7 +26,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
$this->cache = $cache;
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
foreach ($items as $item) {
if (! $item->isHit()) {
if (!$item->isHit()) {
$this->initialize();
break;
}
@@ -49,6 +49,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function getKeys(): array
{
$keyList = $this->cache->getItem(self::KEY_LIST);
return array_keys($keyList->get() ?? []);
}
@@ -56,6 +57,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function getChanges(): array
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
return $changeList->get() ?? [];
}
@@ -90,12 +92,14 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function clear(): bool
{
/* only bother clearing the pool if it is not empty */
if (! empty($this->getKeys())) {
if (!empty($this->getKeys())) {
$response = $this->cache->clear();
$this->initialize();
return $response;
}
return true;
}
@@ -109,7 +113,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
unset($keyValues[$key]);
$keyList->set($keyValues);
$this->cache->saveDeferred($keyList);
$this->logChange($key, MonitorCacheKeys::REMOVED);
$this->logChange($key, self::REMOVED);
$this->cache->commit();
}
@@ -125,7 +129,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
foreach ($keys as $key) {
if (isset($keyValues[$key])) {
unset($keyValues[$key]);
$this->logChange($key, MonitorCacheKeys::REMOVED);
$this->logChange($key, self::REMOVED);
}
}
$keyList->set($keyValues);
@@ -139,6 +143,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function save(CacheItemInterface $item): bool
{
$this->update($item);
return $this->cache->save($item);
}
@@ -146,6 +151,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function saveDeferred(CacheItemInterface $item): bool
{
$this->update($item);
return $this->cache->saveDeferred($item);
}
@@ -171,27 +177,23 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
/** @throws OutOfBoundsException */
private function isValid(string $key): void
{
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
throw new OutOfBoundsException(
'Can not modify the private key or change lists'
);
if (self::KEY_LIST === $key || self::CHANGE_LIST === $key) {
throw new OutOfBoundsException('Can not modify the private key or change lists');
}
}
/** @throws OutOfBoundsException */
private function allValid(array $keys): void
{
if (in_array(self::KEY_LIST, $keys, true) ||
in_array(self::CHANGE_LIST, $keys, true)
if (\in_array(self::KEY_LIST, $keys, true)
|| \in_array(self::CHANGE_LIST, $keys, true)
) {
throw new OutOfBoundsException(
'Can not modify the private key or change lists'
);
throw new OutOfBoundsException('Can not modify the private key or change lists');
}
}
/** @throws InvalidArgumentException */
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void
private function logChange(string $key, int $code = self::UPDATED): void
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$changeValues = $changeList->get();
+4 -3
View File
@@ -7,6 +7,7 @@ namespace App;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Target;
/* need autoconfigure so we get it from the service container in Kernel->boot() */
#[Autoconfigure(public: true)]
@@ -17,10 +18,10 @@ final readonly class PersistCache
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $sessionStorage,
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
#[Target('sessionStorage')] CacheItemPoolInterface $sessionStorage,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
}
+6 -2
View File
@@ -11,18 +11,22 @@ use Psr\Cache\InvalidArgumentException;
* they are single-use and marked as used after successful authentication */
interface BackupCodeInterface
{
/** generate a set of backup-codes and return them
/** generate a set of backup-codes and return them.
* @param int $count Number of codes to generate
*
* @return string[] Generated backup codes
*
* @throws InvalidArgumentException|Exception */
public function generate(int $count = 10): array;
/** @throws InvalidArgumentException */
public function expire(): void;
/** check if backup-code is valid and mark it as used
/** check if backup-code is valid and mark it as used.
* @param string $code Code supplied by the client
*
* @return bool true if the code is valid and unused
*
* @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool;
}
+22 -14
View File
@@ -6,13 +6,14 @@ namespace App\Service;
use App\AppConstants;
use App\MonitorCacheKeys;
use App\Trait\GetTotpTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Trait\GetTotpTrait;
use Symfony\Component\DependencyInjection\Attribute\Target;
/** backup-codes are case‑insensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */
@@ -29,27 +30,31 @@ final readonly class BackupCodeManager implements BackupCodeInterface
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $sessionCache)
{
public function __construct(
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/** generate a set of backup-codes and return them
/** generate a set of backup-codes and return them.
* @param int $count Number of codes to generate
*
* @return string[] Generated backup codes
*
* @throws InvalidArgumentException|Exception */
public function generate(int $count = self::DEFAULT_COUNT): array
{
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
$codes = [];
for ($i = 0; $i < $count; $i++) {
for ($i = 0; $i < $count; ++$i) {
/* output is alphanumeric string of given length */
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
random_bytes($length)
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT));
random_bytes($length),
), 16, 36), 0, $length), $length, '0', \STR_PAD_LEFT));
}
$this->saveCodes($codes);
$this->logger->info("generated {$count} backup codes");
return $codes;
}
@@ -62,35 +67,38 @@ final readonly class BackupCodeManager implements BackupCodeInterface
$itemsToRemove[] = $key;
}
}
if (count($itemsToRemove) > 0) {
if (\count($itemsToRemove) > 0) {
$this->sessionCache->deleteItems($itemsToRemove);
}
}
/** check if backup-code is valid and mark it as used
/** check if backup-code is valid and mark it as used.
* @param string $code Code supplied by the client
*
* @return bool true if the code is valid and unused
*
* @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool
{
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupKey = 'backup_'.preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
$this->logger->debug('checking backup code: ' . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
$this->logger->debug('checking backup code: '.($backupItem->isHit() ? 'HIT & ' : 'miss & ').($backupItem->get() ? 'VALID' : 'invalid'));
if ($backupItem->isHit() && $backupItem->get()) {
$this->logger->debug("valid backup code");
$this->logger->debug('valid backup code');
/* mark backup code as spent */
$backupItem->set(false); /* used */
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
AppConstants::FAR_FUTURE_DATE,
));
$this->sessionCache->save($backupItem);
return true;
}
return false;
}
@@ -105,7 +113,7 @@ final readonly class BackupCodeManager implements BackupCodeInterface
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
AppConstants::FAR_FUTURE_DATE,
));
$this->sessionCache->saveDeferred($backupItem);
}
+5 -5
View File
@@ -6,21 +6,21 @@ namespace App\Service;
interface DomainInterface
{
/** IE: "auth.example.com" or null if not using a separate subdomain
/** IE: "auth.example.com" or null if not using a separate subdomain.
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string;
/** check if given url is an acceptable url for redirection
/** check if given url is an acceptable url for redirection.
* @param string $url Where we are thinking of sending the user
*
* @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool;
/** check if host-base matches auth-base
* @param string $host
/** check if host-base matches auth-base.
* @return bool returns true if and only if host matches base domain of auth */
public function matchesAuth(string $host): bool;
/** IE: "example.com" if central auth is something like "auth.example.com"
/** IE: "example.com" if central auth is something like "auth.example.com".
* @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string;
}
+121 -112
View File
@@ -10,100 +10,100 @@ final readonly class DomainManager implements DomainInterface
{
/* top-level-domains which are known to have multiple parts */
private const array TLD = [
'ai' => ['com','net','off','org'],
'am' => ['radio'],
'at' => ['ac','co','gv','or'],
'au' => ['com','net','org','edu','gov','asn','id'],
'az' => ['com','net','org'],
'bd' => ['com','net','org','gov','mil','ac'],
'br' => ['com','net','org','gov','mil','eco','emp','g12','ind','inf','rec','tur','tv','edu','far','gov','gru','jor','leg','lec','med','nom','not','ppg','pro','psi','pub','slg','srv','tec','tmp','vip','vlog','wiki','zlg'],
'by' => ['com','net','org','gov','mil','of'],
'ca' => ['ab','bc','mb','nb','nf','nl','ns','nt','nu','on','pe','qc','sk','yk'],
'cc' => [],
'cn' => ['com','net','org','gov','edu','ac','bj','sh','tj','cq','he','sx','nm','ln','jl','hl','js','zj','ah','fj','jx','sd','ha','hb','hn','gd','gx','hi','sc','gz','yn','sn','gs','qh','nx','xj','tw','hk','mo'],
'co' => ['com','net','org','gov','mil','edu','arts','firm','info','int','nom','rec','web'],
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za','au','bh','bo','cn','ec','eg','gt','hk','hn','il','in','jp','kr','kw','lb','lv','my','mx','ng','ni','np','pe','pf','pg','ph','pk','pl','pr','py','sa','sg','sv','tr','tw','ua','uy','ve','vn','ye'],
'de' => ['com'],
'dk' => ['co'],
'ec' => ['com','net','org','gov','mil','edu','fin','med','pro'],
'ee' => ['com','org','pri'],
'eg' => ['com','net','org','gov','edu','mil'],
'es' => ['com','nom','org','edu','gob'],
'eu' => [],
'fi' => ['aland'],
'fm' => ['radio'],
'fr' => ['com','nom','tm','asso','gouv','pol'],
'ge' => ['com','net','org','edu','gov','mil'],
'gg' => ['co','net','org'],
'gr' => ['com','net','org','gov','edu','mil'],
'hk' => ['com','net','org','gov','edu','idv'],
'hu' => ['co','2000','privat','sport','tm','erotica','sex','video','info','org','net','gov','edu','mil','press','biz'],
'id' => ['ac','biz','co','desa','go','mil','my','net','or','sch','web'],
'ie' => ['gov'],
'il' => ['ac','co','gov','idf','k12','muni','net','org'],
'in' => ['co','firm','gen','ind','net','org','ac','edu','res','gov','mil'],
'iq' => ['com','net','org','gov','edu','mil'],
'ir' => ['ac','co','gov','id','net','org','sch'],
'is' => ['net','com','org','edu','gov','int'],
'it' => ['ab','ag','al','an','ao','ap','aq','ar','at','av','ba','bg','bi','bl','bn','bo','br','bs','bt','bz','ca','cb','ce','ch','cl','cn','co','cr','cs','ct','cz','en','fc','fe','fg','fi','fm','fr','ge','go','gr','im','is','kr','lc','le','li','lo','lt','lu','mb','mc','me','mi','mn','mo','ms','mt','na','no','nu','or','pa','pc','pd','pe','pg','pi','pn','po','pr','pt','pu','pv','pz','re','rg','ri','rm','rn','ro','sa','si','so','sp','sr','ss','su','sv','ta','te','tn','to','tp','tr','ts','tv','ud','va','vb','vc','ve','vi','vr','vt','vv','edu','gov','abruzzo','basilicata','calabria','campania','emilia-romagna','friuli-ve-giulia','lazio','liguria','lombardia','marche','molise','piemonte','puglia','sardegna','sicilia','toscana','trentino-a-adige','umbria','valle-aosta','veneto'],
'je' => ['co','net','org'],
'jo' => ['com','net','org','gov','edu','mil','sch'],
'jp' => ['ac','ad','co','ed','go','gr','lg','ne','or'],
'ke' => ['co','ne','or','ac','go','me','mobi','info','sc','pro'],
'kg' => ['com','net','org','gov','mil','edu'],
'kr' => ['ac','co','go','hs','kg','mil','ms','ne','or','pe','re','seoul','busan','daegu','incheon','gwangju','daejeon','ulsan','gyeonggi','gangwon','chungbuk','chungnam','jeonbuk','jeonnam','gyeongbuk','gyeongnam','jeju','sejong'],
'kz' => ['com','net','org','edu','gov','mil'],
'li' => [],
'lt' => ['gov'],
'lv' => ['com','net','org','edu','gov','mil','id','asn','conf'],
'ly' => ['com','net','org','gov','edu','sch','med','id'],
'ma' => ['co','net','org','gov','press','ac'],
'mk' => ['com','net','org','edu','gov','inf','name','pro'],
'mx' => ['com','net','org','gov','edu','mil'],
'my' => ['com','net','org','gov','edu','mil','name'],
'na' => ['com','net','org','alt','edu','gov','mil','pro'],
'net' => ['gb','hu','in','jp','se','uk','cn','nz'],
'ng' => ['com','net','org','gov','edu','mil','sch','name','gov'],
'ni' => ['ac','co','com','edu','gob','mil','net','nom','org'],
'nl' => ['bv','co'],
'no' => ['fhs','folkebibl','kommune','mil','stat','priv','vgs','dep','kommune'],
'nz' => ['co','net','org','ac','geek','gen','maori','school','parliament','govt','health','mil','crii','archie','geek','govt','health','maori','school'],
'om' => ['com','net','org','gov','edu','med','mil','sch'],
'org' => ['ae','us','lu'],
'pe' => ['com','net','org','gob','edu','mil','nom'],
'ph' => ['com','net','org','gov','edu','mil'],
'pk' => ['com','net','org','fam','biz','edu','gov','web'],
'pl' => ['com','net','org','aid','agro','atm','auto','biz','edu','gmina','gsm','info','mail','miasta','media','mil','ngo','nom','pc','powiat','priv','realestate','rel','sex','shop','sklep','sos','szkola','targi','tm','tourism','travel','turystyka','gov','ap','augov','bedzin','bialystok','bielawa','bierun','boleslawiec','bydgoszcz','bytom','cieszyn','czeladz','czest','dlugoleka','elblag','elk','glogow','gniezno','gorlice','gorzow','grodzisk','grudziadz','ilk','jaworzno','jelenia-gora','jgora','kalisz','kazimierz-dolny','karpacz','kartuzy','kaszuby','katowice','kepno','ketrzyn','klodzko','kobierzyce','kolobrzeg','konin','konskowola','krapkowice','krakow','krasnik','krasno','krosniewice','kutno','lapy','lebork','legnica','lezajsk','limanowa','lomza','lowicz','lubin','lukow','malbork','malopolska','mazowsze','mazury','mielec','milicz','mielno','mragowo','naklo','nowaruda','nysa','olawa','olecko','olkusz','olsztyn','opoczno','opole','ostrowiec','ostroleka','ostrowwlkp','pila','pisz','podhale','podlasie','polkowice','pomorze','pomorse','prochowice','pruszkow','przeworsk','pulawy','rabka','rawa-maz','rybnik','rzeszow','sanok','sejny','siedlce','slask','slupsk','sosnowiec','stalowa-wola','skoczow','starachowice','stargard','suwalki','swidnica','swiebodzin','swinoujscie','szczecin','szczytno','tarnobrzeg','tgory','turek','tychy','ustka','walbrzych','warmia','warszawa','waw','wegrow','wielun','wlocl','wloclawek','wodzislaw','wolomin','wroclaw','zachpomor','zagan','zarow','zgora','zgorzelec','plug'],
'pr' => ['ac','co','edu','gov','info','island','pro','net','org'],
'pt' => ['com','net','org','gov','edu','int','publ'],
'py' => ['com','net','org','gov','edu','mil','co'],
'qa' => ['com','net','org','gov','edu','mil','sch','name'],
'ro' => ['com','net','org','nom','rec','info','arts','com','firm','tm','www','store','nt','ngo','pro','tm','com','arts','rec','store','info','nom','nt','org','shop','firm','www','rest','travel','transport','tourism','press','media','medical','med','law','jobs','inst','individual','insinfo','guru','fit','engineering','expert','energy','economy','dot','dog','dev','design','dem','dental','craft','corp','consulting','construction','company','com','club','cloud','coach','city','cinema','church','chat','casino','cars','care','cards','broke','blog','bio','bid','band','auto','audio','attorney','apartments','app','art','archi','architects','arena','architects','associates','attorney','auction','auto','baby','band','bank','bar','bargains','beer','berlin','best','bet','bid','bike','bingo','bio','black','blog','blue','boats','bond','boo','book','boutique','build','builders','business','buzz','cab','cafe','call','cam','camp','capital','care','careers','cars','cash','casino','catering','center','ceo','ceramics','cfd','ch','chat','church','city','claims','cleaning','click','clinic','clothing','cloud','club','coach','codes','coffee','college','community','company','computer','condos','construction','consulting','contact','cooking','cool','country','courses','cpa','craft','credit','creditcard','cricket','cruise','cuisinella','cymru','dabur','dance','date','dating','deals','degree','delivery','democrat','dental','design','dev','diamonds','diet','digital','direct','directory','discount','dog','domains','doos','download','ec','edu','education','energy','engineering','enterprises','equipment','estate','events','exchange','expert','exposed','express','fail','faith','family','fan','farm','fashion','film','finance','financial','fish','fit','fitness','flights','florist','flowers','football','forex','forsale','foundation','fun','fund','furniture','futbol','fyi','gal','gallery','game','garden','gift','gifts','gives','glass','global','gold','golf','graphics','gratis','green','gripe','group','guru','health','healthcare','help','helsinki','here','hiphop','hiv','holdings','holiday','homes','horse','host','hosting','house','how','immo','immobilien','in','industries','info','ink','institute','insure','international','investments','irish','jewelry','kaufen','kids','kim','kitchen','kiwi','kred','land','law','lawyer','legal','lgbt','lifestyle','lighting','limited','limo','link','live','loan','loans','lol','london','love','ltd','ltda','luxury','maison','management','market','marketing','markets','media','memorial','men','menu','miami','mobi','moda','moe','mom','money','monster','mortgage','movie','nagoya','name','navy','net','network','news','ngo','ninja','nyc','observer','okinawa','one','ong','onl','online','ooo','org','organic','osaka','paris','partners','parts','party','photo','photography','photos','pics','pictures','pink','pizza','place','plumbing','plus','poker','porn','press','pro','productions','properties','property','pub','qpon','realtor','realty','recipes','red','rehab','reise','reisen','rent','rentals','repair','report','rest','restaurant','review','reviews','rich','rip','rocks','rodeo','run','saarland','sale','salon','sarl','save','saxo','school','schule','science','services','sex','sexy','sg','shop','shopping','show','singles','site','ski','soccer','social','software','solar','solutions','space','store','stream','studio','study','style','supplies','supply','support','surgery','systems','tax','taxi','team','tech','technology','tennis','thai','tips','tires','tirol','today','tokyo','tools','top','tour','tours','town','toys','trade','trading','training','travel','tube','university','uno','vacations','vegas','ventures','vet','viajes','video','villas','vin','vision','vlaanderen','vodka','vote','voting','voto','voyage','wales','watch','webcam','website','wedding','wien','wiki','win','wine','work','works','world','wtf','xxx','xyz','yoga','yokohama','zone'],
'ru' => ['ac','com','edu','int','net','org','pp','adygeya','altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey'],
'sa' => ['com','net','org','gov','med','pub','edu','sch'],
'sb' => ['com','net','org','edu','gov'],
'sc' => ['com','net','org','gov','edu'],
'se' => ['a','ac','b','bd','brand','c','d','e','f','fh','fhsk','fhv','g','h','i','k','komforb','kommunal','komvux','kunskapsforb','l','lanbib','m','n','naturbruksgymn','o','org','p','parti','pp','press','r','s','t','tm','u','v','w','x','y','z'],
'sg' => ['com','net','org','gov','edu','per'],
'sh' => ['com','net','org','gov','mil','edu'],
'sk' => ['co','com','edu','gov','mil','net','org','nfo'],
'st' => ['co','com','consulado','edu','embaixada','gov','mil','net','org','principe','saotome','store'],
'su' => ['abkhazia','adygeya','ak', 'altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey','com','net','org','gov','pp','edu'],
'sv' => ['com','edu','gob','org','red'],
'sy' => ['com','net','org','gov','edu','mil','name'],
'th' => ['ac','co','go','in','mi','net','or'],
'tj' => ['ac','biz','co','com','edu','gov','go','info','int','mil','name','net','nic','nom','org','pro','test','web'],
'tn' => ['agrinet','com','defense','edunet','ens','fin','gov','ind','info','intl','min','nat','net','org','perso','rnrt','rns','rnu','tourism','turen'],
'tr' => ['com','net','org','gov','biz','info','mil','edu','tv','bbs','k12','pol','bel','dr','gen','av','bbs','k12','name','tel','nc','web','tsk','bel','pol','edu'],
'tw' => ['com','net','org','edu','gov','mil','idv','game','ebiz','club','gnu'],
'ua' => ['com','net','org','edu','gov','in','at','cn','crimea','dn','dnepropetrovsk','donetsk','dp','if','ivano-frankivsk','kh','kharkov','kherson','khmelnitskiy','kiev','kirovograd','km','kr','ks','kv','lg','lt','lugansk','lutsk','lv','lviv','mk','mk.ua','mykolaiv','net','nikolaev','od','odessa','pl','poltava','rovno','rv','sebastopol','sm','sumy','te','ternopil','uz','uzhgorod','vinnica','vn','volyn','yalta','zaporizhzhe','zhitomir','zp','zt'],
'uk' => ['co','me','org','ltd','plc','net','sch','ac','gov','nhs','police','mod','nhs','parliament'],
'us' => ['ak','al','ar','as','az','ca','co','ct','dc','de','fl','ga','gu','hi','ia','id','il','in','ks','ky','la','ma','md','me','mi','mn','mo','ms','mt','nc','nd','ne','nh','nj','nm','nv','ny','oh','ok','or','pa','pr','ri','sc','sd','tn','tx','ut','vi','vt','va','wa','wi','wv','wy','dni','fed','isa','kids','nsn'],
'uy' => ['com','net','org','gub','mil','edu'],
've' => ['co','com','edu','gob','info','net','org','web'],
'vn' => ['com','net','org','edu','gov','int','ac','biz','info','name','pro','health'],
'yu' => ['ac','co','edu','gov','org'],
'za' => ['ac','alt','co','edu','gov','law','mil','net','ngo','nom','org','school','tm','web'],
'ai' => ['com', 'net', 'off', 'org'],
'am' => ['radio'],
'at' => ['ac', 'co', 'gv', 'or'],
'au' => ['com', 'net', 'org', 'edu', 'gov', 'asn', 'id'],
'az' => ['com', 'net', 'org'],
'bd' => ['com', 'net', 'org', 'gov', 'mil', 'ac'],
'br' => ['com', 'net', 'org', 'gov', 'mil', 'eco', 'emp', 'g12', 'ind', 'inf', 'rec', 'tur', 'tv', 'edu', 'far', 'gov', 'gru', 'jor', 'leg', 'lec', 'med', 'nom', 'not', 'ppg', 'pro', 'psi', 'pub', 'slg', 'srv', 'tec', 'tmp', 'vip', 'vlog', 'wiki', 'zlg'],
'by' => ['com', 'net', 'org', 'gov', 'mil', 'of'],
'ca' => ['ab', 'bc', 'mb', 'nb', 'nf', 'nl', 'ns', 'nt', 'nu', 'on', 'pe', 'qc', 'sk', 'yk'],
'cc' => [],
'cn' => ['com', 'net', 'org', 'gov', 'edu', 'ac', 'bj', 'sh', 'tj', 'cq', 'he', 'sx', 'nm', 'ln', 'jl', 'hl', 'js', 'zj', 'ah', 'fj', 'jx', 'sd', 'ha', 'hb', 'hn', 'gd', 'gx', 'hi', 'sc', 'gz', 'yn', 'sn', 'gs', 'qh', 'nx', 'xj', 'tw', 'hk', 'mo'],
'co' => ['com', 'net', 'org', 'gov', 'mil', 'edu', 'arts', 'firm', 'info', 'int', 'nom', 'rec', 'web'],
'com' => ['br', 'cn', 'co', 'de', 'eu', 'gr', 'it', 'jpn', 'mex', 'ru', 'sa', 'uk', 'us', 'za', 'au', 'bh', 'bo', 'cn', 'ec', 'eg', 'gt', 'hk', 'hn', 'il', 'in', 'jp', 'kr', 'kw', 'lb', 'lv', 'my', 'mx', 'ng', 'ni', 'np', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pr', 'py', 'sa', 'sg', 'sv', 'tr', 'tw', 'ua', 'uy', 've', 'vn', 'ye'],
'de' => ['com'],
'dk' => ['co'],
'ec' => ['com', 'net', 'org', 'gov', 'mil', 'edu', 'fin', 'med', 'pro'],
'ee' => ['com', 'org', 'pri'],
'eg' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'es' => ['com', 'nom', 'org', 'edu', 'gob'],
'eu' => [],
'fi' => ['aland'],
'fm' => ['radio'],
'fr' => ['com', 'nom', 'tm', 'asso', 'gouv', 'pol'],
'ge' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
'gg' => ['co', 'net', 'org'],
'gr' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'hk' => ['com', 'net', 'org', 'gov', 'edu', 'idv'],
'hu' => ['co', '2000', 'privat', 'sport', 'tm', 'erotica', 'sex', 'video', 'info', 'org', 'net', 'gov', 'edu', 'mil', 'press', 'biz'],
'id' => ['ac', 'biz', 'co', 'desa', 'go', 'mil', 'my', 'net', 'or', 'sch', 'web'],
'ie' => ['gov'],
'il' => ['ac', 'co', 'gov', 'idf', 'k12', 'muni', 'net', 'org'],
'in' => ['co', 'firm', 'gen', 'ind', 'net', 'org', 'ac', 'edu', 'res', 'gov', 'mil'],
'iq' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'ir' => ['ac', 'co', 'gov', 'id', 'net', 'org', 'sch'],
'is' => ['net', 'com', 'org', 'edu', 'gov', 'int'],
'it' => ['ab', 'ag', 'al', 'an', 'ao', 'ap', 'aq', 'ar', 'at', 'av', 'ba', 'bg', 'bi', 'bl', 'bn', 'bo', 'br', 'bs', 'bt', 'bz', 'ca', 'cb', 'ce', 'ch', 'cl', 'cn', 'co', 'cr', 'cs', 'ct', 'cz', 'en', 'fc', 'fe', 'fg', 'fi', 'fm', 'fr', 'ge', 'go', 'gr', 'im', 'is', 'kr', 'lc', 'le', 'li', 'lo', 'lt', 'lu', 'mb', 'mc', 'me', 'mi', 'mn', 'mo', 'ms', 'mt', 'na', 'no', 'nu', 'or', 'pa', 'pc', 'pd', 'pe', 'pg', 'pi', 'pn', 'po', 'pr', 'pt', 'pu', 'pv', 'pz', 're', 'rg', 'ri', 'rm', 'rn', 'ro', 'sa', 'si', 'so', 'sp', 'sr', 'ss', 'su', 'sv', 'ta', 'te', 'tn', 'to', 'tp', 'tr', 'ts', 'tv', 'ud', 'va', 'vb', 'vc', 've', 'vi', 'vr', 'vt', 'vv', 'edu', 'gov', 'abruzzo', 'basilicata', 'calabria', 'campania', 'emilia-romagna', 'friuli-ve-giulia', 'lazio', 'liguria', 'lombardia', 'marche', 'molise', 'piemonte', 'puglia', 'sardegna', 'sicilia', 'toscana', 'trentino-a-adige', 'umbria', 'valle-aosta', 'veneto'],
'je' => ['co', 'net', 'org'],
'jo' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch'],
'jp' => ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
'ke' => ['co', 'ne', 'or', 'ac', 'go', 'me', 'mobi', 'info', 'sc', 'pro'],
'kg' => ['com', 'net', 'org', 'gov', 'mil', 'edu'],
'kr' => ['ac', 'co', 'go', 'hs', 'kg', 'mil', 'ms', 'ne', 'or', 'pe', 're', 'seoul', 'busan', 'daegu', 'incheon', 'gwangju', 'daejeon', 'ulsan', 'gyeonggi', 'gangwon', 'chungbuk', 'chungnam', 'jeonbuk', 'jeonnam', 'gyeongbuk', 'gyeongnam', 'jeju', 'sejong'],
'kz' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
'li' => [],
'lt' => ['gov'],
'lv' => ['com', 'net', 'org', 'edu', 'gov', 'mil', 'id', 'asn', 'conf'],
'ly' => ['com', 'net', 'org', 'gov', 'edu', 'sch', 'med', 'id'],
'ma' => ['co', 'net', 'org', 'gov', 'press', 'ac'],
'mk' => ['com', 'net', 'org', 'edu', 'gov', 'inf', 'name', 'pro'],
'mx' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'my' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
'na' => ['com', 'net', 'org', 'alt', 'edu', 'gov', 'mil', 'pro'],
'net' => ['gb', 'hu', 'in', 'jp', 'se', 'uk', 'cn', 'nz'],
'ng' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch', 'name', 'gov'],
'ni' => ['ac', 'co', 'com', 'edu', 'gob', 'mil', 'net', 'nom', 'org'],
'nl' => ['bv', 'co'],
'no' => ['fhs', 'folkebibl', 'kommune', 'mil', 'stat', 'priv', 'vgs', 'dep', 'kommune'],
'nz' => ['co', 'net', 'org', 'ac', 'geek', 'gen', 'maori', 'school', 'parliament', 'govt', 'health', 'mil', 'crii', 'archie', 'geek', 'govt', 'health', 'maori', 'school'],
'om' => ['com', 'net', 'org', 'gov', 'edu', 'med', 'mil', 'sch'],
'org' => ['ae', 'us', 'lu'],
'pe' => ['com', 'net', 'org', 'gob', 'edu', 'mil', 'nom'],
'ph' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'pk' => ['com', 'net', 'org', 'fam', 'biz', 'edu', 'gov', 'web'],
'pl' => ['com', 'net', 'org', 'aid', 'agro', 'atm', 'auto', 'biz', 'edu', 'gmina', 'gsm', 'info', 'mail', 'miasta', 'media', 'mil', 'ngo', 'nom', 'pc', 'powiat', 'priv', 'realestate', 'rel', 'sex', 'shop', 'sklep', 'sos', 'szkola', 'targi', 'tm', 'tourism', 'travel', 'turystyka', 'gov', 'ap', 'augov', 'bedzin', 'bialystok', 'bielawa', 'bierun', 'boleslawiec', 'bydgoszcz', 'bytom', 'cieszyn', 'czeladz', 'czest', 'dlugoleka', 'elblag', 'elk', 'glogow', 'gniezno', 'gorlice', 'gorzow', 'grodzisk', 'grudziadz', 'ilk', 'jaworzno', 'jelenia-gora', 'jgora', 'kalisz', 'kazimierz-dolny', 'karpacz', 'kartuzy', 'kaszuby', 'katowice', 'kepno', 'ketrzyn', 'klodzko', 'kobierzyce', 'kolobrzeg', 'konin', 'konskowola', 'krapkowice', 'krakow', 'krasnik', 'krasno', 'krosniewice', 'kutno', 'lapy', 'lebork', 'legnica', 'lezajsk', 'limanowa', 'lomza', 'lowicz', 'lubin', 'lukow', 'malbork', 'malopolska', 'mazowsze', 'mazury', 'mielec', 'milicz', 'mielno', 'mragowo', 'naklo', 'nowaruda', 'nysa', 'olawa', 'olecko', 'olkusz', 'olsztyn', 'opoczno', 'opole', 'ostrowiec', 'ostroleka', 'ostrowwlkp', 'pila', 'pisz', 'podhale', 'podlasie', 'polkowice', 'pomorze', 'pomorse', 'prochowice', 'pruszkow', 'przeworsk', 'pulawy', 'rabka', 'rawa-maz', 'rybnik', 'rzeszow', 'sanok', 'sejny', 'siedlce', 'slask', 'slupsk', 'sosnowiec', 'stalowa-wola', 'skoczow', 'starachowice', 'stargard', 'suwalki', 'swidnica', 'swiebodzin', 'swinoujscie', 'szczecin', 'szczytno', 'tarnobrzeg', 'tgory', 'turek', 'tychy', 'ustka', 'walbrzych', 'warmia', 'warszawa', 'waw', 'wegrow', 'wielun', 'wlocl', 'wloclawek', 'wodzislaw', 'wolomin', 'wroclaw', 'zachpomor', 'zagan', 'zarow', 'zgora', 'zgorzelec', 'plug'],
'pr' => ['ac', 'co', 'edu', 'gov', 'info', 'island', 'pro', 'net', 'org'],
'pt' => ['com', 'net', 'org', 'gov', 'edu', 'int', 'publ'],
'py' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'co'],
'qa' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch', 'name'],
'ro' => ['com', 'net', 'org', 'nom', 'rec', 'info', 'arts', 'com', 'firm', 'tm', 'www', 'store', 'nt', 'ngo', 'pro', 'tm', 'com', 'arts', 'rec', 'store', 'info', 'nom', 'nt', 'org', 'shop', 'firm', 'www', 'rest', 'travel', 'transport', 'tourism', 'press', 'media', 'medical', 'med', 'law', 'jobs', 'inst', 'individual', 'insinfo', 'guru', 'fit', 'engineering', 'expert', 'energy', 'economy', 'dot', 'dog', 'dev', 'design', 'dem', 'dental', 'craft', 'corp', 'consulting', 'construction', 'company', 'com', 'club', 'cloud', 'coach', 'city', 'cinema', 'church', 'chat', 'casino', 'cars', 'care', 'cards', 'broke', 'blog', 'bio', 'bid', 'band', 'auto', 'audio', 'attorney', 'apartments', 'app', 'art', 'archi', 'architects', 'arena', 'architects', 'associates', 'attorney', 'auction', 'auto', 'baby', 'band', 'bank', 'bar', 'bargains', 'beer', 'berlin', 'best', 'bet', 'bid', 'bike', 'bingo', 'bio', 'black', 'blog', 'blue', 'boats', 'bond', 'boo', 'book', 'boutique', 'build', 'builders', 'business', 'buzz', 'cab', 'cafe', 'call', 'cam', 'camp', 'capital', 'care', 'careers', 'cars', 'cash', 'casino', 'catering', 'center', 'ceo', 'ceramics', 'cfd', 'ch', 'chat', 'church', 'city', 'claims', 'cleaning', 'click', 'clinic', 'clothing', 'cloud', 'club', 'coach', 'codes', 'coffee', 'college', 'community', 'company', 'computer', 'condos', 'construction', 'consulting', 'contact', 'cooking', 'cool', 'country', 'courses', 'cpa', 'craft', 'credit', 'creditcard', 'cricket', 'cruise', 'cuisinella', 'cymru', 'dabur', 'dance', 'date', 'dating', 'deals', 'degree', 'delivery', 'democrat', 'dental', 'design', 'dev', 'diamonds', 'diet', 'digital', 'direct', 'directory', 'discount', 'dog', 'domains', 'doos', 'download', 'ec', 'edu', 'education', 'energy', 'engineering', 'enterprises', 'equipment', 'estate', 'events', 'exchange', 'expert', 'exposed', 'express', 'fail', 'faith', 'family', 'fan', 'farm', 'fashion', 'film', 'finance', 'financial', 'fish', 'fit', 'fitness', 'flights', 'florist', 'flowers', 'football', 'forex', 'forsale', 'foundation', 'fun', 'fund', 'furniture', 'futbol', 'fyi', 'gal', 'gallery', 'game', 'garden', 'gift', 'gifts', 'gives', 'glass', 'global', 'gold', 'golf', 'graphics', 'gratis', 'green', 'gripe', 'group', 'guru', 'health', 'healthcare', 'help', 'helsinki', 'here', 'hiphop', 'hiv', 'holdings', 'holiday', 'homes', 'horse', 'host', 'hosting', 'house', 'how', 'immo', 'immobilien', 'in', 'industries', 'info', 'ink', 'institute', 'insure', 'international', 'investments', 'irish', 'jewelry', 'kaufen', 'kids', 'kim', 'kitchen', 'kiwi', 'kred', 'land', 'law', 'lawyer', 'legal', 'lgbt', 'lifestyle', 'lighting', 'limited', 'limo', 'link', 'live', 'loan', 'loans', 'lol', 'london', 'love', 'ltd', 'ltda', 'luxury', 'maison', 'management', 'market', 'marketing', 'markets', 'media', 'memorial', 'men', 'menu', 'miami', 'mobi', 'moda', 'moe', 'mom', 'money', 'monster', 'mortgage', 'movie', 'nagoya', 'name', 'navy', 'net', 'network', 'news', 'ngo', 'ninja', 'nyc', 'observer', 'okinawa', 'one', 'ong', 'onl', 'online', 'ooo', 'org', 'organic', 'osaka', 'paris', 'partners', 'parts', 'party', 'photo', 'photography', 'photos', 'pics', 'pictures', 'pink', 'pizza', 'place', 'plumbing', 'plus', 'poker', 'porn', 'press', 'pro', 'productions', 'properties', 'property', 'pub', 'qpon', 'realtor', 'realty', 'recipes', 'red', 'rehab', 'reise', 'reisen', 'rent', 'rentals', 'repair', 'report', 'rest', 'restaurant', 'review', 'reviews', 'rich', 'rip', 'rocks', 'rodeo', 'run', 'saarland', 'sale', 'salon', 'sarl', 'save', 'saxo', 'school', 'schule', 'science', 'services', 'sex', 'sexy', 'sg', 'shop', 'shopping', 'show', 'singles', 'site', 'ski', 'soccer', 'social', 'software', 'solar', 'solutions', 'space', 'store', 'stream', 'studio', 'study', 'style', 'supplies', 'supply', 'support', 'surgery', 'systems', 'tax', 'taxi', 'team', 'tech', 'technology', 'tennis', 'thai', 'tips', 'tires', 'tirol', 'today', 'tokyo', 'tools', 'top', 'tour', 'tours', 'town', 'toys', 'trade', 'trading', 'training', 'travel', 'tube', 'university', 'uno', 'vacations', 'vegas', 'ventures', 'vet', 'viajes', 'video', 'villas', 'vin', 'vision', 'vlaanderen', 'vodka', 'vote', 'voting', 'voto', 'voyage', 'wales', 'watch', 'webcam', 'website', 'wedding', 'wien', 'wiki', 'win', 'wine', 'work', 'works', 'world', 'wtf', 'xxx', 'xyz', 'yoga', 'yokohama', 'zone'],
'ru' => ['ac', 'com', 'edu', 'int', 'net', 'org', 'pp', 'adygeya', 'altai', 'amur', 'arkhangelsk', 'astrakhan', 'bashkiria', 'belgorod', 'bir', 'bryansk', 'buryatia', 'cbg', 'chel', 'chelyabinsk', 'chita', 'chukotka', 'chuvashia', 'dagestan', 'dudinka', 'e-burg', 'grozny', 'irkutsk', 'ivanovo', 'izhevsk', 'jar', 'joshkar-ola', 'kalmykia', 'kaluga', 'kamchatka', 'karelia', 'kazan', 'kchr', 'kemerovo', 'khabarovsk', 'khakassia', 'khv', 'kirov', 'koenigsberg', 'komi', 'kostroma', 'krasnodar', 'krasnoyarsk', 'kuban', 'kurgan', 'kursk', 'lipetsk', 'magadan', 'mari', 'mari-el', 'marine', 'mil', 'mordovia', 'mosreg', 'msk', 'murmansk', 'nalchik', 'nnov', 'nov', 'novosibirsk', 'nsk', 'omsk', 'orenburg', 'oryol', 'palana', 'penza', 'perm', 'ptz', 'rnd', 'ryazan', 'sakhalin', 'samara', 'saratov', 'simbirsk', 'smolensk', 'spb', 'stavropol', 'stv', 'surgut', 'tambov', 'tatarstan', 'tom', 'tomsk', 'tsaritsyn', 'tsk', 'tula', 'tuva', 'tver', 'tyumen', 'udm', 'udmurtia', 'ulan-ude', 'vladikavkaz', 'vladimir', 'vladivostok', 'volgograd', 'vologda', 'voronezh', 'vrn', 'vyatka', 'yakutia', 'yamal', 'yaroslavl', 'yevrey'],
'sa' => ['com', 'net', 'org', 'gov', 'med', 'pub', 'edu', 'sch'],
'sb' => ['com', 'net', 'org', 'edu', 'gov'],
'sc' => ['com', 'net', 'org', 'gov', 'edu'],
'se' => ['a', 'ac', 'b', 'bd', 'brand', 'c', 'd', 'e', 'f', 'fh', 'fhsk', 'fhv', 'g', 'h', 'i', 'k', 'komforb', 'kommunal', 'komvux', 'kunskapsforb', 'l', 'lanbib', 'm', 'n', 'naturbruksgymn', 'o', 'org', 'p', 'parti', 'pp', 'press', 'r', 's', 't', 'tm', 'u', 'v', 'w', 'x', 'y', 'z'],
'sg' => ['com', 'net', 'org', 'gov', 'edu', 'per'],
'sh' => ['com', 'net', 'org', 'gov', 'mil', 'edu'],
'sk' => ['co', 'com', 'edu', 'gov', 'mil', 'net', 'org', 'nfo'],
'st' => ['co', 'com', 'consulado', 'edu', 'embaixada', 'gov', 'mil', 'net', 'org', 'principe', 'saotome', 'store'],
'su' => ['abkhazia', 'adygeya', 'ak', 'altai', 'amur', 'arkhangelsk', 'astrakhan', 'bashkiria', 'belgorod', 'bir', 'bryansk', 'buryatia', 'cbg', 'chel', 'chelyabinsk', 'chita', 'chukotka', 'chuvashia', 'dagestan', 'dudinka', 'e-burg', 'grozny', 'irkutsk', 'ivanovo', 'izhevsk', 'jar', 'joshkar-ola', 'kalmykia', 'kaluga', 'kamchatka', 'karelia', 'kazan', 'kchr', 'kemerovo', 'khabarovsk', 'khakassia', 'khv', 'kirov', 'koenigsberg', 'komi', 'kostroma', 'krasnodar', 'krasnoyarsk', 'kuban', 'kurgan', 'kursk', 'lipetsk', 'magadan', 'mari', 'mari-el', 'marine', 'mil', 'mordovia', 'mosreg', 'msk', 'murmansk', 'nalchik', 'nnov', 'nov', 'novosibirsk', 'nsk', 'omsk', 'orenburg', 'oryol', 'palana', 'penza', 'perm', 'ptz', 'rnd', 'ryazan', 'sakhalin', 'samara', 'saratov', 'simbirsk', 'smolensk', 'spb', 'stavropol', 'stv', 'surgut', 'tambov', 'tatarstan', 'tom', 'tomsk', 'tsaritsyn', 'tsk', 'tula', 'tuva', 'tver', 'tyumen', 'udm', 'udmurtia', 'ulan-ude', 'vladikavkaz', 'vladimir', 'vladivostok', 'volgograd', 'vologda', 'voronezh', 'vrn', 'vyatka', 'yakutia', 'yamal', 'yaroslavl', 'yevrey', 'com', 'net', 'org', 'gov', 'pp', 'edu'],
'sv' => ['com', 'edu', 'gob', 'org', 'red'],
'sy' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
'th' => ['ac', 'co', 'go', 'in', 'mi', 'net', 'or'],
'tj' => ['ac', 'biz', 'co', 'com', 'edu', 'gov', 'go', 'info', 'int', 'mil', 'name', 'net', 'nic', 'nom', 'org', 'pro', 'test', 'web'],
'tn' => ['agrinet', 'com', 'defense', 'edunet', 'ens', 'fin', 'gov', 'ind', 'info', 'intl', 'min', 'nat', 'net', 'org', 'perso', 'rnrt', 'rns', 'rnu', 'tourism', 'turen'],
'tr' => ['com', 'net', 'org', 'gov', 'biz', 'info', 'mil', 'edu', 'tv', 'bbs', 'k12', 'pol', 'bel', 'dr', 'gen', 'av', 'bbs', 'k12', 'name', 'tel', 'nc', 'web', 'tsk', 'bel', 'pol', 'edu'],
'tw' => ['com', 'net', 'org', 'edu', 'gov', 'mil', 'idv', 'game', 'ebiz', 'club', 'gnu'],
'ua' => ['com', 'net', 'org', 'edu', 'gov', 'in', 'at', 'cn', 'crimea', 'dn', 'dnepropetrovsk', 'donetsk', 'dp', 'if', 'ivano-frankivsk', 'kh', 'kharkov', 'kherson', 'khmelnitskiy', 'kiev', 'kirovograd', 'km', 'kr', 'ks', 'kv', 'lg', 'lt', 'lugansk', 'lutsk', 'lv', 'lviv', 'mk', 'mk.ua', 'mykolaiv', 'net', 'nikolaev', 'od', 'odessa', 'pl', 'poltava', 'rovno', 'rv', 'sebastopol', 'sm', 'sumy', 'te', 'ternopil', 'uz', 'uzhgorod', 'vinnica', 'vn', 'volyn', 'yalta', 'zaporizhzhe', 'zhitomir', 'zp', 'zt'],
'uk' => ['co', 'me', 'org', 'ltd', 'plc', 'net', 'sch', 'ac', 'gov', 'nhs', 'police', 'mod', 'nhs', 'parliament'],
'us' => ['ak', 'al', 'ar', 'as', 'az', 'ca', 'co', 'ct', 'dc', 'de', 'fl', 'ga', 'gu', 'hi', 'ia', 'id', 'il', 'in', 'ks', 'ky', 'la', 'ma', 'md', 'me', 'mi', 'mn', 'mo', 'ms', 'mt', 'nc', 'nd', 'ne', 'nh', 'nj', 'nm', 'nv', 'ny', 'oh', 'ok', 'or', 'pa', 'pr', 'ri', 'sc', 'sd', 'tn', 'tx', 'ut', 'vi', 'vt', 'va', 'wa', 'wi', 'wv', 'wy', 'dni', 'fed', 'isa', 'kids', 'nsn'],
'uy' => ['com', 'net', 'org', 'gub', 'mil', 'edu'],
've' => ['co', 'com', 'edu', 'gob', 'info', 'net', 'org', 'web'],
'vn' => ['com', 'net', 'org', 'edu', 'gov', 'int', 'ac', 'biz', 'info', 'name', 'pro', 'health'],
'yu' => ['ac', 'co', 'edu', 'gov', 'org'],
'za' => ['ac', 'alt', 'co', 'edu', 'gov', 'law', 'mil', 'net', 'ngo', 'nom', 'org', 'school', 'tm', 'web'],
];
private bool $subdomainRedirect;
@@ -111,38 +111,41 @@ final readonly class DomainManager implements DomainInterface
public function __construct(
#[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect,
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
) {
$this->subdomainRedirect = $subdomainRedirect;
$this->authSubdomain = $authSubdomain;
}
/** IE: "auth.example.com" or null if not using a separate subdomain
/** IE: "auth.example.com" or null if not using a separate subdomain.
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string
{
if ($this->authBase()) {
return $this->authSubdomain;
}
return null;
}
/** check if given url is an acceptable url for redirection
/** check if given url is an acceptable url for redirection.
* @param string $url Where we are thinking of sending the user
*
* @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool
{
/* ensure url is valid and, when using an auth subdomain,
* that the url host matches the base domain */
if (!filter_var($url, FILTER_VALIDATE_URL)) {
if (!filter_var($url, \FILTER_VALIDATE_URL)) {
return false;
}
if ($this->authBase()) {
$host = parse_url($url, PHP_URL_HOST);
if ($host === null || $host === false || $host === '') {
$host = parse_url($url, \PHP_URL_HOST);
if (null === $host || false === $host || '' === $host) {
return false;
}
/* do not send the user to another domain */
return $this->matchesAuth($host);
}
@@ -150,58 +153,64 @@ final readonly class DomainManager implements DomainInterface
return true;
}
/** check if host-base matches auth-base
* @param string $host
/** check if host-base matches auth-base.
* @return bool returns true if and only if host matches base domain of auth */
public function matchesAuth(string $host): bool
{
$hostBase = $this->baseDomain($host);
$authBase = $this->baseDomain($this->authSubdomain);
return $this->subdomainRedirect && $this->authSubdomain &&
$authBase && $authBase === $hostBase;
return $this->subdomainRedirect && $this->authSubdomain
&& $authBase && $authBase === $hostBase;
}
/** IE: "example.com" if central auth is something like "auth.example.com"
/** IE: "example.com" if central auth is something like "auth.example.com".
* @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string
{
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
return $this->baseDomain($this->authSubdomain);
}
return null;
}
/** this lets us determine the base domain of the given ip, localhost, or domain
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
* things like "localhost" and "8.8.8.8" will return null
* things like "localhost" and "8.8.8.8" will return null.
*
* @param string $host ip, localhost, or domain with zero or more subdomains
*
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
private function baseDomain(string $host): ?string
{
/* if host is an ip address (or localhost), leave it as is */
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
if (filter_var($host, \FILTER_VALIDATE_IP) || 'localhost' === $host) {
return null;
}
$parts = explode('.', strtolower($host));
$keep = $this->baseLength($parts);
$parts = array_slice($parts, -$keep);
$parts = \array_slice($parts, -$keep);
return implode('.', $parts);
}
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"].
* @param string[] $parts pieces of a domain split by "." dot
*
* @return int typically 2 but sometimes 3 */
private function baseLength(array $parts): int
{
$length = count($parts);
$length = \count($parts);
$baseLength = min(2, $length);
/* check if host should retain 3 parts, due to TLD */
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) &&
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
if (\count($parts) > 2 && isset(self::TLD[$parts[$length - 1]])
&& \in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
) {
$baseLength = min(3, $length);
}
return $baseLength;
}
}
+35 -106
View File
@@ -6,141 +6,70 @@ namespace App\Service;
use App\Data\Payload;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Trait\CookieNameTrait;
use App\Trait\GetTotpTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Override;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
/**
* Authenticates a TOTP code (or backup code) and, on success, grants access.
*
* The "grant access" half now lives in {@see SessionIssuer} so the passkey
* ceremony produces an identical response. This class keeps the part that is
* genuinely specific to code-based login: verifying the code and enforcing the
* single-use nonce.
*/
final readonly class LoginManager implements LoginInterface
{
use CookieNameTrait;
use GetTotpTrait;
use MakeNonceTrait;
use StringTrait;
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $sessionCache,
private BackupCodeInterface $backupCodeManager,
private DomainInterface $domainManager,
private BackupCodeInterface $backupCodeManager,
private SessionIssuerInterface $sessionIssuer,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/** @throws InvalidArgumentException */
/**
* @throws InvalidArgumentException
*/
#[Override]
public function checkToken(Payload $payload, Request $request): ?Response
{
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
if (Scope::Ip === $payload->scope && !$this->config->ipTtl()) {
/* requested to grant ip access, but that is not enabled */
$payload->scope = Scope::Cookie;
}
if ($this->getTotp()->verify($payload->token, null, 1) ||
$this->backupCodeManager->verifyAndConsume($payload->token)
if (!$this->getTotp()->verify($payload->token, null, 1)
&& !$this->backupCodeManager->verifyAndConsume($payload->token)
) {
/* token is correct (TOTP or Backup) */
/* if server nonce is found and is valid */
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(LoginManager::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
/* token authentication successful, grant access and set response */
$cleanId = $this->makeCacheKey($payload->id);
/* if they just want this one page, return ok, to grant them access */
$response = $this->authSuccessResponse($cleanId, $this->config);
if ($payload->scope !== Scope::None) {
/* grant access based on the requested scope */
if ($payload->scope === Scope::Cookie) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} elseif ($payload->scope === Scope::Ip) {
$this->setIp($cleanId, $request->getClientIp());
}
if ($payload->json) {
$contentType = 'application/json';
$content = json_encode([
'message' => 'Login successful',
'nonce' => null,
]);
} else {
$contentType = 'text/html';
$content = "hi $cleanId, please reload";
}
$location = $request->query->has('return') &&
$this->domainManager->validReturn($request->query->get('return')) ?
"{$request->query->get('return')}" :
"{$request->getPathInfo()}{$request->getQueryString()}";
/* force redirect to use GET method (important when using central auth) */
$response->setContent($content)
->setStatusCode(Response::HTTP_SEE_OTHER)
->headers->set('Location', $location);
$response->headers->set('Content-Type', $contentType);
}
$this->logger->debug("successful login for: $cleanId");
return $response;
}
return null;
}
return null;
}
/** @throws InvalidArgumentException */
private function setCookie(string $id, string $host): Cookie
{
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid")
);
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$this->logger->error("aborting: ULID collision");
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
/* token is correct (TOTP or Backup) */
/* if server nonce is found and is valid */
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
if (!$nonceItem->isHit() || !$nonceItem->get()) {
return null;
}
$sessionCookie->set($id);
$sessionCookie->expiresAfter($this->config->cookieTtl());
$this->sessionCache->save($sessionCookie);
return Cookie::create(
name: $this->sessionCookieName($this->domainManager),
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
path: '/',
domain: $this->sessionCookieDomain($this->domainManager, $host),
secure: true,
httpOnly: true,
sameSite: Cookie::SAMESITE_STRICT,
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
return $this->sessionIssuer->issue(
$payload->id,
$payload->scope,
$request,
$payload->json,
);
}
/** @throws InvalidArgumentException */
private function setIp(string $id, string $ip): void
{
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
$sessionIp = $this->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionCache->save($sessionIp);
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\ECDSA\ES256;
use RuntimeException;
use Symfony\Component\Serializer\Serializer;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\CredentialRecord;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\Exception\InvalidDataException;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRequestOptions;
/**
* Builds the WebAuthn collaborators, and is the one place that knows how they
* are wired.
*
* Everything that touches webauthn-lib types goes through here, so a library
* major version that renames or moves those types is a single-file change
* instead of a hunt through the codebase.
*
* **Attestation is deliberately `none`.** The alternatives were measured and
* rejected: attestation conveyance is only a preference a client may ignore,
* and the FIDO metadata service is bypassed both by the zero AAGUID that
* privacy-preserving passkeys already send and by self attestation — while
* still refusing legitimate authenticators that postdate its cached blob. See
* `docs/passkey-auth-subdomain-plan.md` §2.3 for the evidence, and `SECURITY.md`
* for the conditions that would justify revisiting it.
*/
final readonly class PasskeyCeremonyFactory
{
private AttestationStatementSupportManager $attestationStatementSupportManager;
private PasskeyCounterChecker $counterChecker;
public function __construct()
{
$manager = AttestationStatementSupportManager::create();
$manager->add(NoneAttestationStatementSupport::create());
$this->attestationStatementSupportManager = $manager;
$this->counterChecker = new PasskeyCounterChecker();
}
/**
* The wire form of the ceremony options, ready to JSON-encode for the client.
*
* Goes through the serializer rather than `json_encode()`, because the
* challenge is raw binary: `json_encode()` rejects it outright, and the
* serializer base64url-encodes exactly the fields the browser expects. Using
* one path here and another at verification time is how a challenge silently
* stops matching, so both go through this factory.
*
* @return array<string,mixed>
*/
public function optionsAsArray(PublicKeyCredentialCreationOptions|PublicKeyCredentialRequestOptions $options): array
{
return $this->serializer()->normalize($options, 'json');
}
/**
* Validator for the registration ceremony.
*
* The origins are passed in rather than read from a request, so the scheme
* and host can only ever come from configuration. This is what makes D4
* enforceable: an `http://` origin is never presented to the library as
* acceptable, no matter how the request arrived at the container.
*
* @param string[] $allowedOrigins
*/
public function creationCeremonyValidator(array $allowedOrigins): AuthenticatorAttestationResponseValidator
{
return AuthenticatorAttestationResponseValidator::create(
$this->ceremonyStepManagerFactory($allowedOrigins)->creationCeremony(),
);
}
/**
* Validator for the login (assertion) ceremony.
*
* @param string[] $allowedOrigins
*/
public function requestCeremonyValidator(array $allowedOrigins): AuthenticatorAssertionResponseValidator
{
return AuthenticatorAssertionResponseValidator::create(
$this->ceremonyStepManagerFactory($allowedOrigins)->requestCeremony(),
);
}
public function counterChecker(): PasskeyCounterChecker
{
return $this->counterChecker;
}
/**
* The ceremony steps shared by both ceremonies.
*
* `setSecuredRelyingPartyId()` is deliberately never called: it is deprecated
* in 5.2 and, more importantly, it is the escape hatch that would let an
* `http://` origin through. Development uses real TLS instead (D4).
*
* @param string[] $allowedOrigins
*/
private function ceremonyStepManagerFactory(array $allowedOrigins): CeremonyStepManagerFactory
{
$factory = new CeremonyStepManagerFactory();
$factory->setAllowedOrigins($allowedOrigins);
$factory->setAlgorithmManager(Manager::create()->add(ES256::create()));
$factory->setAttestationStatementSupportManager($this->attestationStatementSupportManager);
/* replace the library default, which rejects the constant-zero counter
* that synchronised passkeys report — see PasskeyCounterChecker */
$factory->setCounterChecker($this->counterChecker);
return $factory;
}
/**
* The serializer for every WebAuthn value: credential records, ceremony
* options, and the client's response.
*
* This must be used instead of `json_encode()`. Options carry raw binary
* (the challenge) which `json_encode()` rejects outright, and
* `CredentialRecord` is not `JsonSerializable` at all — the serializer
* base64url-encodes those fields and is required for a correct round-trip.
*
* The concrete `Serializer` is returned because the library's own return
* type (`SerializerInterface`) only declares `serialize()`/`deserialize()`,
* while this class also needs `normalize()`/`denormalize()`.
*/
public function serializer(): Serializer
{
$serializer = (new WebauthnSerializerFactory($this->attestationStatementSupportManager))->create();
/* the library's declared return type is the narrower interface, so this
* narrows it back — failing loudly if a future version ever returns
* something else, rather than erroring at the first ceremony */
if (!$serializer instanceof Serializer) {
throw new RuntimeException('Expected the WebAuthn serializer to be a '.Serializer::class.'.');
}
return $serializer;
}
public function attestationStatementSupportManager(): AttestationStatementSupportManager
{
return $this->attestationStatementSupportManager;
}
/**
* Serialize a credential record for storage.
*
* @throws InvalidDataException
*/
public function serializeCredential(CredentialRecord $record): string
{
return $this->serializer()->serialize($record, 'json');
}
/**
* Rebuild a credential record previously written by {@see serializeCredential()}.
*
* Returns null rather than throwing when the stored payload is unusable: a
* corrupt entry must degrade to "this passkey is unavailable", never to a
* 500 on the login page.
*/
public function deserializeCredential(string $json): ?CredentialRecord
{
try {
return $this->serializer()->deserialize($json, CredentialRecord::class, 'json');
} catch (Throwable) {
return null;
}
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Exception;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
/**
* Short-lived server-side state for an in-flight WebAuthn ceremony.
*
* **The client's copy of the challenge is never trusted.** Starting a ceremony
* issues the challenge *and* a separate opaque `ceremonyId`; only the
* server-side record keyed by that id is authoritative at `finish`. A client
* that swaps in its own challenge is therefore comparing against a value the
* server never issued.
*
* **Single-use.** {@see consume()} deletes the record *before* the caller
* verifies anything, so a failed or replayed `finish` cannot be retried against
* the same challenge.
*
* **Deliberately not persisted.** This uses the `nonceCache` pool, which is APCu
* and stays in memory. A ceremony that does not complete within its TTL *should*
* evaporate — persisting it would only widen the replay window across restarts.
*/
final readonly class PasskeyCeremonyStore implements PasskeyCeremonyStoreInterface
{
use StringTrait;
private const string PREFIX = 'passkey_cer_';
/** Ceremonies live 5 minutes: a user has to interact with a biometric prompt. */
public const int CEREMONY_TTL = 300;
/** 15 bytes fits neatly into a base64url string without padding. */
private const int CEREMONY_ID_BYTES = 15;
/** 32-byte challenge, per WebAuthn's recommendation. */
private const int CHALLENGE_BYTES = 32;
public function __construct(
#[Target('nonceCache')] private CacheItemPoolInterface $nonceCache,
) {
}
/**
* @return array{ceremonyId: string, challenge: string}
*
* @throws Exception
* @throws InvalidArgumentException
*/
#[Override]
public function startLogin(): array
{
/* @var array{ceremonyId: string, challenge: string} */
return $this->store(PasskeyCeremonyStoreInterface::TYPE_LOGIN, [
'challenge' => random_bytes(self::CHALLENGE_BYTES),
]);
}
/**
* @return array{ceremonyId: string, challenge: string, userHandle: string}
*
* @throws Exception
* @throws InvalidArgumentException
*/
#[Override]
public function startRegistration(string $identity): array
{
/* @var array{ceremonyId: string, challenge: string, userHandle: string} */
return $this->store(PasskeyCeremonyStoreInterface::TYPE_REGISTER, [
'challenge' => random_bytes(self::CHALLENGE_BYTES),
'identity' => $identity,
/* Derived from the identity rather than taken from the client, so a
* caller cannot register a credential against an identity it did not
* authenticate as. Fixed length, stable per identity, and it does not
* leak the label into the authenticator. */
'userHandle' => hash('sha256', $identity, true),
]);
}
/**
* Read and destroy a ceremony, returning its record.
*
* Returns null when the id is unknown, expired, already consumed, or was
* started for the other ceremony type. The caller cannot distinguish these
* cases, and does not need to.
*
* @return array{challenge: string, identity?: string, userHandle?: string}|null
*
* @throws InvalidArgumentException
*/
#[Override]
public function consume(string $ceremonyId, string $expectedType): ?array
{
$key = $this->key($ceremonyId);
$item = $this->nonceCache->getItem($key);
if (!$item->isHit()) {
return null;
}
$payload = $item->get();
/* delete before validating: a malformed or replayed record must not be
* usable again even if the checks below reject it */
$this->nonceCache->deleteItem($key);
if (!\is_array($payload)
|| ($payload['type'] ?? null) !== $expectedType
|| !isset($payload['challenge'])
|| !\is_string($payload['challenge'])
) {
return null;
}
/** @var array{challenge: string, identity?: string, userHandle?: string} $payload */
$payload = array_filter(
$payload,
static fn (string $key): bool => \in_array($key, ['challenge', 'identity', 'userHandle'], true),
\ARRAY_FILTER_USE_KEY,
);
return $payload;
}
/**
* @param array<string,string> $payload
*
* @return array<string,string>
*
* @throws Exception
* @throws InvalidArgumentException
*/
private function store(string $type, array $payload): array
{
$ceremonyId = rtrim(strtr(base64_encode(random_bytes(self::CEREMONY_ID_BYTES)), '+/', '-_'), '=');
$item = $this->nonceCache->getItem($this->key($ceremonyId));
$item->set([
'type' => $type,
...$payload,
'createdAt' => new DateTimeImmutable()->format(\DATE_ATOM),
]);
$item->expiresAfter(self::CEREMONY_TTL);
$this->nonceCache->save($item);
return ['ceremonyId' => $ceremonyId, ...$payload];
}
/**
* The ceremony id is hashed rather than passed through `makeCacheKey()`,
* because that sanitises the base64url alphabet into `_` and is therefore
* not injective — two distinct ids could collide on one cache slot.
*
* @throws InvalidArgumentException
*/
private function key(string $ceremonyId): string
{
return $this->makeCacheKey(self::PREFIX.hash('sha256', $ceremonyId));
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Server-side storage for in-flight WebAuthn ceremonies.
*
* Kept separate from {@see PasskeyManager} so the security-critical properties —
* the challenge is server-authoritative and single-use — are testable without
* constructing a real authenticator response.
*/
interface PasskeyCeremonyStoreInterface
{
public const string TYPE_LOGIN = 'login';
public const string TYPE_REGISTER = 'register';
/**
* Start a login (assertion) ceremony.
*
* @return array{ceremonyId: string, challenge: string}
*/
public function startLogin(): array;
/**
* Start a registration (attestation) ceremony for an already-authenticated
* identity.
*
* @return array{ceremonyId: string, challenge: string, userHandle: string}
*/
public function startRegistration(string $identity): array;
/**
* Read and destroy a ceremony.
*
* Returns null when the id is unknown, expired, already consumed, or was
* started for the other ceremony type.
*
* @return array{challenge: string, identity?: string, userHandle?: string}|null
*/
public function consume(string $ceremonyId, string $expectedType): ?array;
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Override;
use Webauthn\Counter\CounterChecker;
use Webauthn\CredentialRecord;
use Webauthn\Exception\CounterException;
/**
* Signature-counter policy for passkey assertions.
*
* **Why this replaces the library default.** `ThrowExceptionIfInvalid` (the
* default wired up by `CeremonyStepManagerFactory`) requires the reported
* counter to be *strictly greater* than the stored one. That is wrong for the
* passkeys this feature targets: platform passkeys are synchronised through the
* OS keychain, and a synchronised authenticator reports a constant `0` forever
* (the spec permits it, and the multi-device design effectively requires it).
* Measured against the installed library:
*
* stored 0, reported 0 -> `ThrowExceptionIfInvalid` throws `CounterException`
*
* so with the default checker a brand-new synced passkey fails on its *first*
* login — and only on real hardware, never in a unit test that increments the
* counter. That is the worst possible failure shape, so the default is not used.
*
* **What is kept.** A counter that goes *backwards* still fails. That is the one
* signal the counter can carry (a cloned authenticator replaying an older
* assertion), and rejecting it costs nothing because a genuine synchronised
* passkey only ever reports the same value or a larger one.
*
* Note this is defence in depth and not relied upon for security: a
* synchronised passkey's counter carries no clone signal at all, which is why
* `SECURITY.md` records that clone detection is explicitly not a property this
* feature claims. The real protections are per-credential challenge binding,
* origin/RP-ID checks, and the user-verification requirement.
*/
final readonly class PasskeyCounterChecker implements CounterChecker
{
/**
* @throws CounterException when the reported counter moves backwards
*/
#[Override]
public function check(CredentialRecord $credentialRecord, int $currentCounter): void
{
if ($currentCounter < $credentialRecord->counter) {
throw CounterException::create($currentCounter, $credentialRecord->counter, 'The signature counter moved backwards, which can indicate a cloned authenticator.');
}
}
}
+266
View File
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\AppConstants;
use App\Data\PasskeyCredential;
use App\MonitorCacheKeys;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Webauthn\CredentialRecord;
/**
* @see PasskeyCredentialStoreInterface
*
* **Persistence.** The pool is wrapped in a {@see MonitorCacheKeys}, exactly
* as `LoginManager` and `BackupCodeManager` do. Without that wrapper the
* credentials would live only in the APCu-side pool and disappear on the next
* container restart, because `PersistCache::persist()` only flushes keys that a
* monitor recorded. `PasskeyCredentialStoreTest` asserts visibility in the
* underlying persistent pool, not just the wrapped one.
*
* **Layout.**
* passkey_cred_<key(credentialId)> -> serialized credential + metadata
* passkey_index -> credentialId => {identity, label, createdAt}
*
* The index exists so the login page can build `allowCredentials` without
* enumerating the whole key space, and so a corrupt credential cannot make the
* list disappear entirely.
*/
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
{
use StringTrait;
private const string PREFIX = 'passkey_cred_';
private const string INDEX_KEY = 'passkey_index';
private MonitorCacheKeys $sessionCache;
/**
* @throws InvalidArgumentException
*/
public function __construct(
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
private PasskeyCeremonyFactory $ceremonyFactory,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function all(): array
{
$credentials = [];
foreach ($this->credentialIds() as $credentialId) {
$credential = $this->find($credentialId);
if (null !== $credential) {
$credentials[] = $credential;
}
}
return $credentials;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function find(string $credentialId): ?PasskeyCredential
{
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
if (!$item->isHit()) {
return null;
}
$payload = $item->get();
if (!\is_array($payload)
|| !isset($payload['record'], $payload['identity'], $payload['label'], $payload['createdAt'])
) {
return null;
}
$record = $this->ceremonyFactory->deserializeCredential((string) $payload['record']);
if (null === $record) {
return null;
}
/* Guard against an index/record mismatch: the stored record decides which
* credential id it answers to. */
if (!hash_equals($record->publicKeyCredentialId, $credentialId)) {
return null;
}
return new PasskeyCredential(
$record,
(string) $payload['identity'],
(string) $payload['label'],
new DateTimeImmutable((string) $payload['createdAt']),
isset($payload['lastUsedAt']) ? new DateTimeImmutable((string) $payload['lastUsedAt']) : null,
);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function findByIdentity(string $identity): array
{
$matches = [];
foreach ($this->all() as $credential) {
if (hash_equals($credential->identity, $identity)) {
$matches[] = $credential;
}
}
return $matches;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function save(PasskeyCredential $credential): void
{
$credentialId = $credential->record->publicKeyCredentialId;
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
$item->set([
'record' => $this->ceremonyFactory->serializeCredential($credential->record),
'identity' => $credential->identity,
'label' => $credential->label,
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
'lastUsedAt' => $credential->lastUsedAt?->format(\DATE_ATOM),
]);
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$item->expiresAt($this->forever());
$this->sessionCache->save($item);
$this->writeIndexEntry($credentialId, [
'identity' => $credential->identity,
'label' => $credential->label,
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
]);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function updateUsage(CredentialRecord $record): void
{
$existing = $this->find($record->publicKeyCredentialId);
if (null === $existing) {
return;
}
$this->save($existing->withUsage($record, new DateTimeImmutable()));
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function remove(string $credentialId): bool
{
$index = $this->readIndex();
$existed = \array_key_exists($credentialId, $index);
unset($index[$credentialId]);
$this->writeIndex($index);
return $this->sessionCache->deleteItem($this->credentialKey($credentialId)) || $existed;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function count(): int
{
return \count($this->credentialIds());
}
/**
* Credential ids from the index only.
*
* Deliberately not `getKeys()`: keys are truncated to a fixed length by
* `makeCacheKey()`, so enumerating them cannot reliably recover a full
* credential id. The index stores the ids verbatim.
*
* @return string[]
*
* @throws InvalidArgumentException
*/
private function credentialIds(): array
{
return array_keys($this->readIndex());
}
/**
* @return array<string,array{identity: string, label: string, createdAt: string}>
*
* @throws InvalidArgumentException
*/
private function readIndex(): array
{
$item = $this->sessionCache->getItem(self::INDEX_KEY);
$index = $item->isHit() ? $item->get() : null;
return \is_array($index) ? $index : [];
}
/**
* @param array<string,array{identity: string, label: string, createdAt: string}> $index
*
* @throws InvalidArgumentException
*/
private function writeIndex(array $index): void
{
$item = $this->sessionCache->getItem(self::INDEX_KEY);
$item->set($index);
$item->expiresAt($this->forever());
$this->sessionCache->save($item);
}
/**
* @param array{identity: string, label: string, createdAt: string} $entry
*
* @throws InvalidArgumentException
*/
private function writeIndexEntry(string $credentialId, array $entry): void
{
$index = $this->readIndex();
$index[$credentialId] = $entry;
$this->writeIndex($index);
}
/** Credentials and the index are kept indefinitely; only removal clears them. */
private function forever(): DateTimeImmutable
{
return DateTimeImmutable::createFromFormat('Y-m-d', AppConstants::FAR_FUTURE_DATE);
}
/**
* Cache key for one credential id.
*
* The id is hashed rather than passed through `makeCacheKey()`, because that
* sanitises the base64url alphabet into a single `_` and is therefore not
* injective: "abc-def" and "abc_def" both become "abc_def", so two distinct
* credentials could share one cache slot and one of them would silently
* overwrite the other. A hash is injective for practical purposes and keeps
* the key within the allowed character set.
*/
private function credentialKey(string $credentialId): string
{
return self::PREFIX.hash('sha256', $credentialId);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\PasskeyCredential;
use Webauthn\CredentialRecord;
/**
* Persistence for registered passkeys, backed by the `sessionCache` pool so that
* credentials survive a container restart the same way sessions do.
*
* The identity is stored alongside the credential and is authoritative on
* assertion: the `userHandle` a client returns is attacker-controlled and is
* compared for consistency but never used to decide who is logging in.
*/
interface PasskeyCredentialStoreInterface
{
/**
* Every registered credential, for the login ceremony's `allowCredentials`.
*
* Credentials whose stored payload cannot be read are skipped rather than
* failing the ceremony, so one bad entry cannot lock everyone out.
*
* @return PasskeyCredential[]
*/
public function all(): array;
/**
* Look up a single credential by its raw credential id.
*/
public function find(string $credentialId): ?PasskeyCredential;
/**
* Every credential belonging to one identity.
*
* @return PasskeyCredential[]
*/
public function findByIdentity(string $identity): array;
/**
* Persist a newly registered credential.
*/
public function save(PasskeyCredential $credential): void;
/**
* Record that a credential was just used, refreshing its counter.
*/
public function updateUsage(CredentialRecord $record): void;
/**
* Forget a credential.
*
* @return bool true when a credential was removed
*/
public function remove(string $credentialId): bool;
public function count(): int;
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\PasskeyCredential;
use Throwable;
/**
* Both WebAuthn ceremonies, expressed without any library types in the
* signature so callers never depend on webauthn-lib directly.
*/
interface PasskeyInterface
{
/**
* Step 1 of the login ceremony: issue a challenge and the credential list.
*
* @return array{publicKey: mixed, ceremonyId: string}
*
* @throws Throwable
*/
public function beginLogin(): array;
/**
* Step 3 of the login ceremony: verify an assertion.
*
* Null means "not authenticated", and the caller must not distinguish
* between the possible causes.
*
* @param array<string,mixed> $body
*
* @throws Throwable
*/
public function finishLogin(array $body): ?PasskeyCredential;
/**
* Step 1 of the registration ceremony. Only reachable once the caller has
* already authenticated with a TOTP code.
*
* @return array{publicKey: mixed, ceremonyId: string}
*
* @throws Throwable
*/
public function beginRegistration(string $identity): array;
/**
* Step 3 of the registration ceremony: verify an attestation and store the
* credential.
*
* Null means "not registered".
*
* @param array<string,mixed> $body
*
* @throws Throwable
*/
public function finishRegistration(array $body): ?PasskeyCredential;
}
+301
View File
@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\PasskeyCredential;
use App\Service\PasskeyCeremonyStoreInterface as Ceremonies;
use DateTimeImmutable;
use Override;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Throwable;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\CredentialRecord;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
/**
* Owns both WebAuthn ceremonies.
*
* **Library containment.** Every `Webauthn\*` type used by the application is
* referenced in this file and {@see PasskeyCeremonyFactory}, so a v6 rename
* touches two files rather than the whole codebase.
*
* **What is never trusted.** The challenge (server-authoritative, single-use —
* see {@see PasskeyCeremonyStore}), the `userHandle` (read from the stored
* credential, never from the client), and the credential id (it selects which
* stored record to verify against). The library performs the cryptographic
* verification of origin, RP ID hash, challenge, user presence/verification and
* signature; this class supplies the inputs and interprets the outcome.
*/
final readonly class PasskeyManager implements PasskeyInterface
{
/** ES256 is mandatory for WebAuthn; other algorithms buy nothing here. */
private const int COSE_ALGORITHM_ES256 = -7;
public function __construct(
private PasskeyPolicyInterface $policy,
private Ceremonies $ceremonies,
private PasskeyCredentialStoreInterface $credentials,
private PasskeyCeremonyFactory $factory,
) {
}
/**
* Step 1 of the login ceremony.
*
* @return array{publicKey: mixed, ceremonyId: string}
*
* @throws Throwable
*/
#[Override]
public function beginLogin(): array
{
$ceremony = $this->ceremonies->startLogin();
$options = new PublicKeyCredentialRequestOptions(
$ceremony['challenge'],
$this->policy->rpId(),
$this->credentialDescriptors(),
$this->policy->userVerification(),
$this->policy->timeout(),
);
return [
'publicKey' => $this->factory->optionsAsArray($options),
'ceremonyId' => $ceremony['ceremonyId'],
];
}
/**
* Step 3 of the login ceremony: verify the assertion and report the identity.
*
* Returns null on any failure — the caller must not be able to distinguish
* "unknown credential" from "bad signature" from "wrong origin", or the
* endpoint becomes an oracle for credential enumeration.
*
* @param array<string,mixed> $body
*
* @throws Throwable
*/
#[Override]
public function finishLogin(array $body): ?PasskeyCredential
{
$ceremonyId = $this->stringOrNull($body['ceremonyId'] ?? null);
$credentialJson = $body['credential'] ?? null;
if (null === $ceremonyId || !\is_array($credentialJson)) {
return null;
}
$ceremony = $this->ceremonies->consume($ceremonyId, Ceremonies::TYPE_LOGIN);
if (null === $ceremony) {
return null;
}
$publicKeyCredential = $this->deserializeCredential($credentialJson);
if (!$publicKeyCredential instanceof PublicKeyCredential
|| !$publicKeyCredential->response instanceof AuthenticatorAssertionResponse
) {
return null;
}
/* the credential id selects the record: an attacker cannot nominate a
* different credential than the one they hold the key for */
$stored = $this->credentials->find($publicKeyCredential->rawId);
if (null === $stored) {
return null;
}
try {
$updated = $this->factory
->requestCeremonyValidator($this->policy->allowedOrigins())
->check(
$stored->record,
$publicKeyCredential->response,
$this->requestOptions($ceremony['challenge']),
$this->policy->authSubdomain(),
$stored->record->userHandle,
);
} catch (Throwable) {
/* deliberately not surfaced: see the note above */
return null;
}
$this->credentials->updateUsage($updated);
return $stored->withUsage($updated, new DateTimeImmutable());
}
/**
* Step 1 of the registration ceremony. Only reachable once the caller has
* already proven possession of the TOTP secret (see the flow in the plan).
*
* @return array{publicKey: mixed, ceremonyId: string}
*
* @throws Throwable
*/
#[Override]
public function beginRegistration(string $identity): array
{
$ceremony = $this->ceremonies->startRegistration($identity);
return [
'publicKey' => $this->factory->optionsAsArray($this->creationOptions(
$ceremony['challenge'],
$identity,
$ceremony['userHandle'],
)),
'ceremonyId' => $ceremony['ceremonyId'],
];
}
/**
* Step 3 of the registration ceremony: verify the attestation and persist
* the credential under the identity that was authenticated in step 1.
*
* @param array<string,mixed> $body
*
* @throws Throwable
*/
#[Override]
public function finishRegistration(array $body): ?PasskeyCredential
{
$ceremonyId = $this->stringOrNull($body['ceremonyId'] ?? null);
$credentialJson = $body['credential'] ?? null;
if (null === $ceremonyId || !\is_array($credentialJson)) {
return null;
}
$ceremony = $this->ceremonies->consume($ceremonyId, Ceremonies::TYPE_REGISTER);
if (null === $ceremony || !isset($ceremony['identity'], $ceremony['userHandle'])) {
return null;
}
$publicKeyCredential = $this->deserializeCredential($credentialJson);
if (!$publicKeyCredential instanceof PublicKeyCredential
|| !$publicKeyCredential->response instanceof AuthenticatorAttestationResponse
) {
return null;
}
try {
$record = $this->factory
->creationCeremonyValidator($this->policy->allowedOrigins())
->check(
$publicKeyCredential->response,
$this->creationOptions(
$ceremony['challenge'],
$ceremony['identity'],
$ceremony['userHandle'],
),
$this->policy->authSubdomain(),
);
} catch (Throwable) {
return null;
}
$credential = new PasskeyCredential(
$record,
$ceremony['identity'],
$this->labelFor($record),
new DateTimeImmutable(),
);
$this->credentials->save($credential);
return $credential;
}
private function requestOptions(string $challenge): PublicKeyCredentialRequestOptions
{
return new PublicKeyCredentialRequestOptions(
$challenge,
$this->policy->rpId(),
$this->credentialDescriptors(),
/* userVerification must be present for the library to enforce the
* user-verified flag; it is not re-read from the client request */
$this->policy->userVerification(),
$this->policy->timeout(),
);
}
private function creationOptions(string $challenge, string $identity, string $userHandle): PublicKeyCredentialCreationOptions
{
return new PublicKeyCredentialCreationOptions(
new PublicKeyCredentialRpEntity($this->policy->rpName(), $this->policy->rpId()),
new PublicKeyCredentialUserEntity($identity, $userHandle, $identity),
$challenge,
[PublicKeyCredentialParameters::create('public-key', self::COSE_ALGORITHM_ES256)],
new AuthenticatorSelectionCriteria(
/* a passkey for this device, which is what the "register this
* device" checkbox promises */
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
$this->policy->userVerification(),
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
),
/* D5: 'none' — see the plan §2.3 and SECURITY.md */
'none',
/* also discourage registering the same device twice */
$this->credentialDescriptors(),
$this->policy->timeout(),
);
}
/**
* Descriptors for every stored credential, used both as `allowCredentials`
* on login and as `excludeCredentials` on registration.
*
* @return PublicKeyCredentialDescriptor[]
*/
private function credentialDescriptors(): array
{
$descriptors = [];
foreach ($this->credentials->all() as $credential) {
$descriptors[] = PublicKeyCredentialDescriptor::create(
'public-key',
$credential->record->publicKeyCredentialId,
$credential->record->transports,
);
}
return $descriptors;
}
/**
* @param array<string,mixed> $data
*/
private function deserializeCredential(array $data): ?PublicKeyCredential
{
try {
return $this->factory->serializer()->denormalize($data, PublicKeyCredential::class, 'json');
} catch (Throwable) {
return null;
}
}
/**
* A short, stable description so a user can tell their keys apart.
*
* Derived from the credential id because the AAGUID is not a reliable
* identity without the FIDO metadata service, which D5 deliberately does
* not use.
*/
private function labelFor(CredentialRecord $record): string
{
return 'Passkey '.substr(Base64UrlSafe::encodeUnpadded($record->publicKeyCredentialId), 0, 8);
}
private function stringOrNull(mixed $value): ?string
{
return \is_string($value) && '' !== $value ? $value : null;
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ConfigBag;
use App\Exception\PasskeyConfigurationException;
use Override;
use Symfony\Component\HttpFoundation\Request;
/**
* @see PasskeyPolicyInterface for the decisions this class enforces
*
* **On scheme handling (D4).** The container serves plain HTTP and sits behind a
* TLS-terminating proxy, so the application never learns the public scheme from
* its own configuration — only from `X-Forwarded-Proto`, which Symfony resolves
* through `trusted_proxies` / `trusted_headers` in `framework.yaml`. The scheme
* is therefore enforced two independent ways rather than asserted at boot:
*
* 1. The allowed origin is hardcoded to `https://` here and is never taken from
* the request, so the library's origin check rejects an `http://` ceremony
* no matter how the request arrived.
* 2. {@see isAvailableFor()} additionally requires the request to be secure, so
* a visitor on a non-secure connection is never shown a passkey button that
* the browser would refuse to act on.
*/
final readonly class PasskeyPolicy implements PasskeyPolicyInterface
{
private const string SCHEME = 'https';
public function __construct(
private ConfigBag $config,
private DomainInterface $domainManager,
) {
}
#[Override]
public function isEnabled(): bool
{
return $this->config->passkeyEnabled() && null !== $this->domainManager->authBase();
}
#[Override]
public function isAvailableFor(Request $request): bool
{
return $this->isEnabled()
&& $request->isSecure()
&& $this->domainManager->getAuthSubdomain() === $request->getHost();
}
#[Override]
public function rpId(): string
{
return $this->domainManager->authBase() ?? throw $this->notConfigured();
}
#[Override]
public function allowedOrigins(): array
{
return [self::SCHEME.'://'.$this->authSubdomain()];
}
#[Override]
public function authSubdomain(): string
{
$subdomain = $this->domainManager->getAuthSubdomain();
return $subdomain ?? throw $this->notConfigured();
}
#[Override]
public function rpName(): string
{
return $this->config->passkeyRpName() ?: $this->config->title();
}
#[Override]
public function userVerification(): string
{
return $this->config->passkeyUserVerification();
}
#[Override]
public function timeout(): int
{
return $this->config->passkeyTimeout();
}
/**
* @throws PasskeyConfigurationException
*/
#[Override]
public function assertConfigurationIsUsable(): void
{
if (!$this->config->passkeyEnabled()) {
return;
}
/* D1: without a base domain there is no RP ID and no shared credential */
if (null === $this->domainManager->authBase()) {
throw new PasskeyConfigurationException('PASSKEY_ENABLED is on, but central authentication is not configured. Passkeys require SUBDOMAIN_REDIRECT=1 together with a valid AUTH_SUBDOMAIN whose base domain can be determined (a domain such as "auth.example.com" — not "localhost" and not an IP address). Either configure central authentication or set PASSKEY_ENABLED=0.');
}
/* D4: the subdomain must be a real domain able to present a TLS certificate.
* authBase() returning null already excludes localhost and bare IPs, so
* this guards against an auth subdomain that is a single label. */
if (!str_contains($this->authSubdomain(), '.')) {
throw new PasskeyConfigurationException('AUTH_SUBDOMAIN must be a fully qualified domain name (for example "auth.example.com") because passkeys require HTTPS and a certificate cannot be issued for a single-label host.');
}
}
private function notConfigured(): PasskeyConfigurationException
{
return new PasskeyConfigurationException(
'Passkeys are enabled but central authentication is not configured, '
.'so no relying party identity is available.',
);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Exception\PasskeyConfigurationException;
use Symfony\Component\HttpFoundation\Request;
/**
* Single source of truth for whether passkeys may be offered, and under which
* relying-party identity.
*
* Two project decisions are enforced here and nowhere else:
*
* - **D1 — central auth is a prerequisite.** A passkey is only meaningful when
* the whole base domain shares one relying party, so passkeys are unavailable
* unless `SUBDOMAIN_REDIRECT` is on and `AUTH_SUBDOMAIN` resolves to a base
* domain. The RP ID is therefore always that base domain.
*
* - **D4 — HTTPS is required, with no exemption.** The origin handed to the
* browser is built here as `https://{authSubdomain}` and is *never* derived
* from the incoming request, so an `http://` origin can never be accepted.
* The deprecated `setSecuredRelyingPartyId()` escape hatch is not used, and
* there is deliberately no configuration override that could reintroduce one.
*/
interface PasskeyPolicyInterface
{
/**
* True when passkeys are switched on by configuration AND that configuration
* satisfies D1. Availability to a particular visitor additionally requires
* {@see isAvailableFor()}.
*/
public function isEnabled(): bool;
/**
* Whether the passkey UI should be offered for this request.
*
* Requires the feature to be enabled, the request to be aimed at the auth
* subdomain (the only place a ceremony may run), and the visitor to actually
* be on HTTPS — see the note on scheme handling in {@see PasskeyPolicy}.
*/
public function isAvailableFor(Request $request): bool;
/**
* The relying party ID: always the base domain of the auth subdomain.
*
* @throws PasskeyConfigurationException when passkeys are enabled without D1
*/
public function rpId(): string;
/**
* Relying party origins allowed to complete a ceremony.
*
* Always exactly one entry, always `https://`, always derived from
* configuration rather than from the request (D4).
*
* @return string[]
*
* @throws PasskeyConfigurationException when passkeys are enabled without D1
*/
public function allowedOrigins(): array;
/** The auth subdomain that ceremonies must be served from. */
public function authSubdomain(): string;
/** Human-readable name shown in the authenticator prompt. */
public function rpName(): string;
/** `required`, `preferred` or `discouraged`. */
public function userVerification(): string;
/** Ceremony timeout in milliseconds, as passed to the browser. */
public function timeout(): int;
/**
* Fails hard when passkeys are enabled in a configuration that cannot work.
*
* Called during cache warm-up so a misconfigured deployment never reaches a
* browser: the container refuses to start instead.
*
* @throws PasskeyConfigurationException
*/
public function assertConfigurationIsUsable(): void;
}
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Matches request paths against configured public path patterns.
*
* Patterns are provided as a comma-separated string in the format:
* /path/pattern, host.example.com/path/pattern, or a mix.
*
* Wildcards:
* - * matches any characters within a single path segment (not crossing /)
* - ** matches any characters including / (crosses path segments)
*
* Query strings are not part of the pattern — matching is against the
* path only.
*/
final readonly class PublicPathMatcher implements PublicPathMatcherInterface
{
/** @var list<array{host: ?string, regex: string}> */
private array $patterns;
public function __construct(
#[Autowire('%app.public_paths%')] string $publicPaths,
) {
$this->patterns = $this->parse($publicPaths);
}
public function isEmpty(): bool
{
return [] === $this->patterns;
}
public function matches(string $host, string $path): bool
{
if ([] === $this->patterns) {
return false;
}
$host = strtolower($host);
foreach ($this->patterns as $entry) {
if (null !== $entry['host'] && $entry['host'] !== $host) {
continue;
}
if (1 === preg_match($entry['regex'], $path)) {
return true;
}
}
return false;
}
/**
* Parse the comma-separated PUBLIC_PATHS string into pattern entries.
*
* @return list<array{host: ?string, regex: string}>
*/
private function parse(string $publicPaths): array
{
if ('' === trim($publicPaths)) {
return [];
}
$patterns = [];
foreach (explode(',', $publicPaths) as $raw) {
$entry = trim($raw);
if ('' === $entry) {
continue;
}
// Check for a host prefix (anything before the first /)
$host = null;
$path = $entry;
if (preg_match('/^([a-z0-9.-]+)(\/.*)$/i', $entry, $m)) {
$host = strtolower($m[1]);
$path = $m[2];
}
// Validate path starts with /
if (!str_starts_with($path, '/')) {
continue;
}
$patterns[] = [
'host' => $host,
'regex' => $this->compilePattern($path),
];
}
return $patterns;
}
/**
* Convert a wildcard path pattern into a regex string.
*
* Star becomes a character class matching one or more non-slash chars.
* Double-star at end of pattern matches zero or more of any char.
* Double-star followed by slash matches zero or more path segments.
* Other characters are escaped as literal regex.
*/
private function compilePattern(string $pattern): string
{
$regex = '';
$length = \strlen($pattern);
$i = 0;
while ($i < $length) {
// Check for ** (must be at current position)
if ($i + 1 < $length && '*' === $pattern[$i] && '*' === $pattern[$i + 1]) {
$i += 2;
if ($i >= $length) {
// ** at end of pattern: zero or more chars including /
$regex .= '.*';
} elseif ('/' === $pattern[$i]) {
// /**/ in middle: zero or more intermediate segments
$regex .= '(?:.*/)?';
++$i; // skip the / after **
} else {
// ** not followed by / or end, treat as .*
$regex .= '.*';
}
} elseif ('*' === $pattern[$i]) {
$regex .= '[^/]+';
++$i;
} else {
$regex .= preg_quote($pattern[$i], '#');
++$i;
}
}
return '#^'.$regex.'$#';
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Matches request paths against configured public path patterns.
*
* Patterns support simple wildcards:
* - `*` matches any characters within a single path segment (not crossing `/`)
* - `**` matches any characters including `/` (crosses path segments)
*
* Patterns may optionally include a host prefix (e.g. `example.com/public/**`).
* When no host prefix is given, the pattern matches on any host.
*/
interface PublicPathMatcherInterface
{
/**
* Returns true if the given host and path match any configured public pattern.
*
* @param string $host The request host (e.g. "code.example.com")
* @param string $path The request path (e.g. "/public/repo/issues")
*/
public function matches(string $host, string $path): bool;
/**
* Returns true if no public paths are configured (feature is disabled).
*/
public function isEmpty(): bool;
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ConfigBag;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
/**
* Grants access once an identity has been authenticated, by whatever method.
*
* Extracted from `LoginManager` so the passkey ceremony and the TOTP form
* produce **byte-identical** outcomes. Two implementations would inevitably
* drift — most likely in cookie attributes, where a difference is invisible
* until it breaks in a browser.
*
* This class deliberately knows nothing about *how* authentication happened; it
* only records the result.
*
* @see SessionIssuerInterface
*/
final readonly class SessionIssuer implements SessionIssuerInterface
{
use CookieNameTrait;
use HasLoggerTrait;
use StringTrait;
private MonitorCacheKeys $sessionCache;
/**
* @throws InvalidArgumentException
*/
public function __construct(
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
private ConfigBag $config,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function issue(string $identity, Scope $scope, Request $request, bool $json): Response
{
/* the same normalisation LoginManager has always applied, so cache keys
* and Remote-User values stay identical between the two login paths */
$cleanId = $this->makeCacheKey($identity);
$response = $this->authSuccessResponse($cleanId, $this->config);
/* when the caller only wanted this one page, there is nothing to store */
if (Scope::None === $scope) {
$this->logger->debug("successful login for: $cleanId");
return $response;
}
if (Scope::Cookie === $scope) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} elseif (Scope::Ip === $scope) {
$this->setIp($cleanId, (string) $request->getClientIp());
}
if ($json) {
$contentType = 'application/json';
$content = (string) json_encode([
'message' => 'Login successful',
'nonce' => null,
]);
} else {
$contentType = 'text/html';
$content = "hi $cleanId, please reload";
}
$location = $request->query->has('return')
&& $this->domainManager->validReturn((string) $request->query->get('return')) ?
"{$request->query->get('return')}" :
"{$request->getPathInfo()}{$request->getQueryString()}";
/* force redirect to use GET method (important when using central auth) */
$response->setContent($content)
->setStatusCode(Response::HTTP_SEE_OTHER)
->headers->set('Location', $location);
$response->headers->set('Content-Type', $contentType);
$this->logger->debug("successful login for: $cleanId");
return $response;
}
/**
* @throws InvalidArgumentException
*/
private function setCookie(string $id, string $host): Cookie
{
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid"),
);
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$this->logger->error('aborting: ULID collision');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
$sessionCookie->set($id);
$sessionCookie->expiresAfter($this->config->cookieTtl());
$this->sessionCache->save($sessionCookie);
return Cookie::create(
name: $this->sessionCookieName($this->domainManager),
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
path: '/',
domain: $this->sessionCookieDomain($this->domainManager, $host),
secure: true,
httpOnly: true,
sameSite: Cookie::SAMESITE_STRICT,
);
}
/**
* @throws InvalidArgumentException
*/
private function setIp(string $id, string $ip): void
{
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
$sessionIp = $this->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionCache->save($sessionIp);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\Scope;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Grants access after a successful authentication, regardless of method.
*
* Exists so the TOTP form and the passkey ceremony cannot drift apart: they must
* produce identical cookies, headers and redirects, and the only reliable way to
* guarantee that is for both to call the same code.
*/
interface SessionIssuerInterface
{
/**
* Record the authenticated identity according to the requested scope and
* build the response the caller should return.
*
* @param string $identity the session id, as typed by the user
* @param Scope $scope whether to set a cookie, an IP session, or neither
* @param bool $json JSON for an AJAX caller, HTML for a form post
*/
public function issue(string $identity, Scope $scope, Request $request, bool $json): Response;
}
+1 -1
View File
@@ -25,7 +25,7 @@ trait GetTotpTrait
{
$otp = Factory::loadFromProvisioningUri(
$this->config->totpUri(),
$this->config->clock()
$this->config->clock(),
);
if ($otp instanceof TOTPInterface) {
return $otp;
+9 -8
View File
@@ -7,6 +7,7 @@ namespace App\Trait;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
@@ -23,8 +24,9 @@ trait MakeNonceTrait
protected readonly CacheItemPoolInterface $nonceCache;
#[Required]
public function setNonceCache(CacheItemPoolInterface $nonceCache): void
{
public function setNonceCache(
#[Target('nonceCache')] CacheItemPoolInterface $nonceCache,
): void {
$this->nonceCache = $nonceCache;
}
@@ -33,18 +35,16 @@ trait MakeNonceTrait
{
/* convert raw binary into base64url */
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
static::NONCE_LENGTH,
)), '+/', '-_'), '=');
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
if ($nonceItem->isHit()) {
if ($retries < 1) {
$this->logger->error("aborting: multiple nonce collisions");
throw new HttpException(
Response::HTTP_INTERNAL_SERVER_ERROR,
'Internal Server Error'
);
$this->logger->error('aborting: multiple nonce collisions');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
/* managed to have a collision, try again */
return $this->makeNonce($retries - 1);
}
@@ -53,6 +53,7 @@ trait MakeNonceTrait
$nonceItem->expiresAfter(static::NONCE_TTL);
$this->logger->debug("added nonce: $nonce");
$this->nonceCache->save($nonceItem);
return $nonce;
}
}
+4 -4
View File
@@ -30,7 +30,7 @@ trait StringTrait
$headers = ['Content-Type' => 'text/plain'];
$headerValue = $this->resolveRemoteUser($id, $config);
if ($headerValue !== null) {
if (null !== $headerValue) {
$headers['Remote-User'] = $headerValue;
}
@@ -45,9 +45,9 @@ trait StringTrait
{
return match ($config->remoteUserMode()) {
RemoteUserMode::Session => $id,
RemoteUserMode::Static => $config->remoteUserStatic(),
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
RemoteUserMode::None => null,
RemoteUserMode::Static => $config->remoteUserStatic(),
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
RemoteUserMode::None => null,
};
}
}
+5 -3
View File
@@ -15,7 +15,7 @@ use Psr\Clock\ClockInterface;
final readonly class Utilities
{
public function __construct(
private ClockInterface $clock,
private ClockInterface $clock,
private CacheItemPoolInterface $appPool,
) {
}
@@ -31,6 +31,7 @@ final readonly class Utilities
}
$this->showTotp($totp);
return $totp;
}
@@ -47,9 +48,10 @@ final readonly class Utilities
* we want this to keep forever, so a few hundred years should do it */
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
AppConstants::FAR_FUTURE_DATE,
));
$this->appPool->save($totpItem);
return $totp;
}
@@ -64,7 +66,7 @@ final readonly class Utilities
loading TOTP, because the env is not set, please copy above into TOTP_URI
RAW,
FILE_APPEND
\FILE_APPEND,
);
}
}
+24
View File
@@ -1,4 +1,13 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"friendsofphp/php-cs-fixer": {
"version": "3.95",
"recipe": {
@@ -11,6 +20,9 @@
".php-cs-fixer.dist.php"
]
},
"phpstan/phpstan": {
"version": "2.2.15"
},
"phpunit/phpunit": {
"version": "13.2",
"recipe": {
@@ -71,6 +83,18 @@
".editorconfig"
]
},
"symfony/property-info": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.4",
"recipe": {
+4 -1
View File
@@ -19,6 +19,8 @@ form.addEventListener('submit', (event) => {
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
// never serve this request from, or store it in, the HTTP cache
cache: 'no-store',
}).then((response) => {
{% if env.debug > 2 -%}
console.log(response);
@@ -28,7 +30,8 @@ form.addEventListener('submit', (event) => {
{% if env.debug > 2 -%}
console.log('got redirect response');
{% endif -%}
window.location.href = response.headers.get('Location');
// replace() keeps the login page out of history and the back-forward cache
window.location.replace(response.headers.get('Location'));
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
{# got json, update the page #}
{% if env.debug > 2 -%}
+2 -2
View File
@@ -1,10 +1,10 @@
<style id="preauth-style">
* { margin: 0; padding: 0.25em; }
html { background-color: {{ env.bg_color|e('css') }}; color: {{ env.fg_color|e('css') }}; display: table;
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
body { display: table-cell; vertical-align: middle; }
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
p { color: {{ env.error_color|e('css') }}; text-align: center; }
p { color: {{ env.error_color }}; text-align: center; }
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
form div { width: 45%; min-width: 300px; }
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<title>{{ env.title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
{{- include('_style.html.twig') -}}
</head>
<body id="preauth-body">
+46 -45
View File
@@ -42,7 +42,8 @@ final class AuthenticationFlowTest extends WebTestCase
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
$json = json_encode($data, \JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
@@ -53,16 +54,16 @@ final class AuthenticationFlowTest extends WebTestCase
bool $json = true,
): string {
return $this->encodePayload([
'id' => $id,
'id' => $id,
'token' => $token ?? $this->validTotpCode(),
'nonce' => $nonce,
'json' => $json,
'json' => $json,
]);
}
/* ── unauthenticated access ──────────────────────────────────────── */
public function testUnauthenticatedRequestShowsLoginPage(): void
public function test_unauthenticated_request_shows_login_page(): void
{
$client = static::createClient();
$client->request('GET', '/');
@@ -75,7 +76,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSelectorExists('input[name="totp"]');
}
public function testLoginPageContainsGeneratedNonce(): void
public function test_login_page_contains_generated_nonce(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -86,7 +87,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
}
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void
public function test_login_form_does_not_use_post_method_without_auth_subdomain(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -99,7 +100,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── successful TOTP login ────────────────────────────────────────── */
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void
public function test_successful_totp_login_via_header_sets_cookie_and_redirects(): void
{
$client = static::createClient();
@@ -111,10 +112,10 @@ final class AuthenticationFlowTest extends WebTestCase
// now submit a valid TOTP via the X-Preauth header
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
@@ -132,7 +133,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login');
}
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void
public function test_successful_login_returns_json_when_json_requested(): void
{
$client = static::createClient();
@@ -141,10 +142,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'bob',
'id' => 'bob',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
@@ -155,7 +156,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('Login successful', $body['message']);
}
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void
public function test_successful_login_returns_html_when_json_false(): void
{
$client = static::createClient();
@@ -164,10 +165,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'carol',
'id' => 'carol',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => false,
'json' => false,
]),
]);
@@ -176,7 +177,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
}
public function testAuthenticatedCookieAccessAfterLogin(): void
public function test_authenticated_cookie_access_after_login(): void
{
$client = static::createClient();
@@ -186,10 +187,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'dave',
'id' => 'dave',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
@@ -214,7 +215,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('dave', $response->headers->get('Remote-User'));
}
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void
public function test_scope_none_returns_plain_text_without_redirect(): void
{
$client = static::createClient();
@@ -223,7 +224,7 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'eve',
'id' => 'eve',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'scope' => 'none',
@@ -240,7 +241,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void
public function test_failed_login_returns_unauthorized_json_with_error(): void
{
$client = static::createClient();
@@ -249,10 +250,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => '000000', // wrong code
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
@@ -266,7 +267,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertNotEmpty($body['nonce']);
}
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void
public function test_failed_login_returns_html_when_json_false(): void
{
$client = static::createClient();
@@ -275,10 +276,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => 'wrong-code',
'nonce' => $nonce,
'json' => false,
'json' => false,
]),
]);
@@ -288,7 +289,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSelectorExists('form#preauth-form');
}
public function testFailedLoginWithSpentNonceIsRejected(): void
public function test_failed_login_with_spent_nonce_is_rejected(): void
{
$client = static::createClient();
@@ -298,10 +299,10 @@ final class AuthenticationFlowTest extends WebTestCase
// first: successful login consumes the nonce
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
@@ -314,26 +315,26 @@ final class AuthenticationFlowTest extends WebTestCase
// reuse the same nonce — should fail even with a valid token
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testFailedLoginWithInvalidNonceIsRejected(): void
public function test_failed_login_with_invalid_nonce_is_rejected(): void
{
$client = static::createClient();
// skip fetching a real nonce; use one that was never stored
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => 'never-issued-nonce',
'json' => true,
'json' => true,
]),
]);
@@ -342,7 +343,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── invalid payload ──────────────────────────────────────────────── */
public function testInvalidHeaderPayloadReturnsUnauthorized(): void
public function test_invalid_header_payload_returns_unauthorized(): void
{
$client = static::createClient();
@@ -354,7 +355,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void
public function test_payload_with_missing_fields_returns_unauthorized(): void
{
$client = static::createClient();
@@ -370,7 +371,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── invalid cookie ───────────────────────────────────────────────── */
public function testInvalidCookieIsClearedAndLoginPageShown(): void
public function test_invalid_cookie_is_cleared_and_login_page_shown(): void
{
$client = static::createClient();
@@ -388,7 +389,7 @@ final class AuthenticationFlowTest extends WebTestCase
true,
false,
'Strict',
)
),
);
$client->request('GET', 'https://localhost/');
@@ -399,7 +400,7 @@ final class AuthenticationFlowTest extends WebTestCase
// the stale cookie should be cleared
$cleared = false;
foreach ($response->headers->getCookies() as $cookie) {
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
$cleared = true;
}
}
@@ -408,7 +409,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── backup code authentication ───────────────────────────────────── */
public function testBackupCodeAuthenticationWorks(): void
public function test_backup_code_authentication_works(): void
{
$client = static::createClient();
$container = $client->getContainer();
@@ -423,17 +424,17 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'frank',
'id' => 'frank',
'token' => $codes[0],
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
}
public function testConsumedBackupCodeCannotBeReused(): void
public function test_consumed_backup_code_cannot_be_reused(): void
{
$client = static::createClient();
$container = $client->getContainer();
@@ -469,7 +470,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── return URL handling ──────────────────────────────────────────── */
public function testSuccessfulLoginWithValidReturnUrl(): void
public function test_successful_login_with_valid_return_url(): void
{
$client = static::createClient();
@@ -488,7 +489,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('https://example.com/app', $response->headers->get('Location'));
}
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
public function test_successful_login_with_invalid_return_falls_back_to_path(): void
{
$client = static::createClient();
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use OTPHP\TOTP;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* End-to-end checks that the login flow carries strict anti-caching headers
* on everything the browser can see, while 2xx grants ("already
* authenticated" / public access) — which the reverse proxy consumes in its
* forward_auth check and never forwards to the browser — are left untouched.
*/
final class CacheControlFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
protected static function createClient(array $options = [], array $server = []): KernelBrowser
{
$client = parent::createClient($options, $server);
$client->disableReboot();
return $client;
}
private function validTotpCode(): string
{
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
}
private function encodePayload(array $data): string
{
$json = json_encode($data, \JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
private function assertNotCacheable(Response $response): void
{
self::assertTrue($response->headers->hasCacheControlDirective('no-cache'));
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
self::assertTrue($response->headers->hasCacheControlDirective('must-revalidate'));
self::assertTrue($response->headers->hasCacheControlDirective('proxy-revalidate'));
self::assertSame('0', $response->headers->getCacheControlDirective('max-age'));
self::assertSame('0', $response->headers->getCacheControlDirective('s-maxage'));
self::assertSame('no-cache', $response->headers->get('Pragma'));
self::assertSame('0', $response->headers->get('Expires'));
self::assertSame('no-store', $response->headers->get('Surrogate-Control'));
self::assertSame('*', $response->headers->get('Vary'));
}
private function assertCacheable(Response $response): void
{
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
self::assertNull($response->headers->get('Pragma'));
self::assertNull($response->headers->get('Surrogate-Control'));
}
/* ── login flow: nothing may be cached ────────────────────────────── */
public function test_login_page_is_not_cacheable(): void
{
$client = static::createClient();
$client->request('GET', '/');
$response = $client->getResponse();
self::assertSame(401, $response->getStatusCode());
$this->assertNotCacheable($response);
}
public function test_login_page_fetch_bypasses_http_cache(): void
{
$client = static::createClient();
$client->request('GET', '/');
$content = $client->getResponse()->getContent();
// the inline login script must opt out of the HTTP cache and must
// not leave the login page in history / the back-forward cache
self::assertStringContainsString("cache: 'no-store'", $content);
self::assertStringContainsString('window.location.replace(', $content);
}
public function test_failed_login_is_not_cacheable(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'token' => '000000', 'nonce' => $nonce, 'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(401, $response->getStatusCode());
$this->assertNotCacheable($response);
}
public function test_successful_login_redirect_is_not_cacheable(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'token' => $this->validTotpCode(), 'nonce' => $nonce, 'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(303, $response->getStatusCode());
$this->assertNotCacheable($response);
// the redirect target must still be present
self::assertTrue($response->headers->has('Location'));
}
public function test_login_page_on_another_host_is_not_cacheable(): void
{
// the listener applies to every main response, not only the primary
// host; subdomain redirection itself is covered by InterceptListener
// unit tests
$client = static::createClient();
$client->request('GET', 'https://other.example.com/');
$response = $client->getResponse();
self::assertSame(401, $response->getStatusCode());
$this->assertNotCacheable($response);
}
public function test_rate_limited_response_is_not_cacheable(): void
{
$client = static::createClient();
// the login limiter is raised for tests, so exercise the public
// limiter instead (test config: PUBLIC_BURST_COUNT=3)
for ($i = 0; $i < 4; ++$i) {
$client->request('GET', '/public/repo');
}
$response = $client->getResponse();
self::assertSame(429, $response->getStatusCode());
$this->assertNotCacheable($response);
}
/* ── 2xx grants: must stay untouched ──────────────────────────────── */
public function test_authenticated_access_response_is_not_modified_by_anti_caching_headers(): void
{
$client = static::createClient();
// login and keep the cookie
$crawler = $client->request('GET', '/');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'dave', 'token' => $this->validTotpCode(), 'nonce' => $nonce, 'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
// subsequent authenticated requests return a 200 "grant" response
$client->request('GET', 'https://localhost/dashboard');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
self::assertSame('dave', $response->headers->get('Remote-User'));
// 2xx responses are consumed by forward_auth and never reach the
// browser — they must not carry the login-flow anti-caching headers
$this->assertCacheable($response);
}
public function test_public_access_response_is_not_modified_by_anti_caching_headers(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
$this->assertCacheable($response);
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use OTPHP\TOTP;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* End-to-end functional tests for the public rate-limited access feature.
*
* The test environment (phpunit.dist.xml) configures:
* PUBLIC_PATHS=/public/**
* PUBLIC_BURST_COUNT=3, PUBLIC_BURST_TIME=60
* PUBLIC_UPPER_COUNT=10000 (effectively unlimited for test purposes)
*
* @covers \App\Listener\PublicAccessListener
* @covers \App\Service\PublicPathMatcher
*/
final class PublicAccessFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
protected static function createClient(array $options = [], array $server = []): KernelBrowser
{
$client = parent::createClient($options, $server);
$client->disableReboot();
return $client;
}
private function validTotpCode(): string
{
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
}
private function encodePayload(array $data): string
{
$json = json_encode($data, \JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
/* ── public path accessible without auth ───────────────────────────── */
public function test_public_path_accessible_without_authentication(): void
{
$client = static::createClient();
$client->request('GET', '/public/some-repo');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
// No Remote-User header for public access
self::assertFalse($response->headers->has('Remote-User'));
}
public function test_public_path_with_querystring_accessible(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo?tab=issues&page=2');
self::assertSame(200, $client->getResponse()->getStatusCode());
}
public function test_deep_public_path_accessible(): void
{
$client = static::createClient();
$client->request('GET', '/public/org/repo/issues/42');
self::assertSame(200, $client->getResponse()->getStatusCode());
}
/* ── non-public path requires auth ─────────────────────────────────── */
public function test_non_public_path_shows_login_page(): void
{
$client = static::createClient();
$client->request('GET', '/private/settings');
self::assertSame(401, $client->getResponse()->getStatusCode());
self::assertSelectorExists('form#preauth-form');
}
public function test_root_path_shows_login_page(): void
{
$client = static::createClient();
$client->request('GET', '/');
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function test_exact_public_path_without_slash_not_matched(): void
{
// /public/** does NOT match /public (no trailing content)
$client = static::createClient();
$client->request('GET', '/public');
self::assertSame(401, $client->getResponse()->getStatusCode());
}
/* ── rate limiting ─────────────────────────────────────────────────── */
public function test_rate_limit_enforced_after_burst_exceeded(): void
{
$client = static::createClient();
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed
for ($i = 0; $i < 3; ++$i) {
$client->request('GET', '/public/repo');
self::assertSame(
200,
$client->getResponse()->getStatusCode(),
"Request $i should have been allowed",
);
}
// 4th request should be rate limited
$client->request('GET', '/public/repo');
$response = $client->getResponse();
self::assertSame(429, $response->getStatusCode());
self::assertTrue($response->headers->has('Retry-After'));
$retryAfter = (int) $response->headers->get('Retry-After');
self::assertGreaterThan(0, $retryAfter);
}
/* ── authenticated user bypasses public rate limiter ───────────────── */
public function test_authenticated_user_bypasses_public_rate_limit(): void
{
$client = static::createClient();
// First, exhaust the public rate limiter
for ($i = 0; $i < 4; ++$i) {
$client->request('GET', '/public/repo');
}
// Confirm rate limit is in effect
$client->request('GET', '/public/repo');
self::assertSame(429, $client->getResponse()->getStatusCode());
// Now log in — the cookie should let us bypass public rate limiting
$client->getCookieJar()->clear();
$crawler = $client->request('GET', '/private');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/private', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
// Now visit a public path while authenticated — should get 200
// (AcceptListener runs before PublicAccessListener, so the public
// rate limiter is never consulted)
$client->request('GET', '/public/repo');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
// Authenticated users get Remote-User header
self::assertSame('alice', $response->headers->get('Remote-User'));
}
/* ── 200 response has correct content type ─────────────────────────── */
public function test_public_access_response_is_plain_text(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type'));
}
/* ── 429 response renders error template ───────────────────────────── */
public function test_rate_limited_response_renders_error_template(): void
{
$client = static::createClient();
// Exhaust rate limit
for ($i = 0; $i < 4; ++$i) {
$client->request('GET', '/public/repo');
}
$response = $client->getResponse();
self::assertSame(429, $response->getStatusCode());
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
$content = $response->getContent();
// The error template renders either teapot or too-many-requests content
// Default test env has TEAPOT=true
self::assertNotEmpty($content);
}
/* ── security headers still applied to public responses ────────────── */
public function test_security_headers_on_public_access(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo');
$response = $client->getResponse();
// SecurityHeadersListener runs on all main-request responses
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
}
}
+37 -26
View File
@@ -4,13 +4,10 @@ declare(strict_types=1);
namespace App\Tests\Support;
use App\ConfigBag;
use App\Service\DomainManager;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use DateTimeImmutable;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\RateLimiter\LimiterInterface;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
@@ -25,26 +22,27 @@ trait ListenerTestHelper
/** Build a Twig Environment pointed at the project's real templates. */
private function makeTwig(): Environment
{
$loader = new FilesystemLoader(dirname(__DIR__, 2) . '/templates');
$loader = new FilesystemLoader(\dirname(__DIR__, 2).'/templates');
$twig = new Environment($loader, ['strict_variables' => true]);
// the templates reference a global `env` object; supply one with the
// keys used by base/login/error/_script/_style
$twig->addGlobal('env', (object)[
'title' => 'Pre-Authentication System',
'bg_color' => '#029386',
'fg_color' => '#ffffff',
'error_color' => '#ffb16d',
'id_name' => 'Session ID',
'token_name' => 'Authentication Token',
'submit_name' => 'Submit',
'error_message' => 'Unsuccessful login attempt',
'teapot' => true,
'teapot_title' => "I'm a teapot",
'teapot_message' => 'I refuse to brew coffee',
'too_many_title' => 'Too many requests',
$twig->addGlobal('env', (object) [
'title' => 'Pre-Authentication System',
'bg_color' => '#029386',
'fg_color' => '#ffffff',
'error_color' => '#ffb16d',
'id_name' => 'Session ID',
'token_name' => 'Authentication Token',
'submit_name' => 'Submit',
'error_message' => 'Unsuccessful login attempt',
'teapot' => true,
'teapot_title' => "I'm a teapot",
'teapot_message' => 'I refuse to brew coffee',
'too_many_title' => 'Too many requests',
'too_many_message' => 'Try again later',
'debug' => 0,
'debug' => 0,
]);
return $twig;
}
@@ -55,10 +53,12 @@ trait ListenerTestHelper
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
{
$limiter = $this->makeLimiter($remainingTokens);
return new class ($limiter) implements RateLimiterFactoryInterface {
return new class($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter)
{
}
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
@@ -70,22 +70,26 @@ trait ListenerTestHelper
{
$rateLimit = new RateLimit(
$remainingTokens,
new \DateTimeImmutable('+10 seconds'),
new DateTimeImmutable('+10 seconds'),
$remainingTokens > 0,
10,
);
return new class ($rateLimit) implements LimiterInterface {
return new class($rateLimit) implements LimiterInterface {
public function __construct(private RateLimit $rateLimit)
{
}
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
}
public function consume(int $tokens = 1): RateLimit
{
return $this->rateLimit;
}
public function reset(): void
{
}
@@ -98,35 +102,42 @@ trait ListenerTestHelper
*/
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
{
$limiter = new class ($threshold) implements LimiterInterface {
$limiter = new class($threshold) implements LimiterInterface {
private int $consumed = 0;
public function __construct(private int $threshold)
{
}
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
}
public function consume(int $tokens = 1): RateLimit
{
$this->consumed += $tokens;
$remaining = max(0, $this->threshold - $this->consumed);
return new RateLimit(
$remaining,
new \DateTimeImmutable('+10 seconds'),
new DateTimeImmutable('+10 seconds'),
$remaining > 0,
$this->threshold,
);
}
public function reset(): void
{
$this->consumed = 0;
}
};
return new class ($limiter) implements RateLimiterFactoryInterface {
return new class($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter)
{
}
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
+273
View File
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use CBOR\ByteStringObject;
use CBOR\Encoder;
use JsonException;
use OpenSSLAsymmetricKey;
use ParagonIE\ConstantTime\Base64UrlSafe;
use RuntimeException;
/**
* Builds real, cryptographically valid WebAuthn ceremonies for tests.
*
* Nothing here is mocked: the helper generates a P-256 keypair, builds a proper
* COSE public key, signs a correct `authenticatorData` with OpenSSL, and
* assembles the CBOR `attestationObject` a real authenticator would send. A
* passing test therefore proves the ceremony works, rather than proving that our
* code agrees with our own stubs.
*
* Two rules learned the hard way, both encoded below:
* - **The counter is controlled explicitly.** A stale counter raises
* `CounterException`, which can mask the real reason a verification failed and
* turn a negative test into a false pass.
* - **Options are serialised by the library**, never `json_encode()`d — the
* challenge is raw binary and `json_encode()` rejects it outright.
*/
final class PasskeyTestHelper
{
/** The `none` attestation format requires an all-zero AAGUID. */
private const string ZERO_AAGUID = '00000000000000000000000000000000';
private readonly OpenSSLAsymmetricKey $key;
/** @var array{x: string, y: string} */
private array $coordinates;
private int $counter = 0;
public function __construct()
{
$key = openssl_pkey_new([
'private_key_type' => \OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if (!$key instanceof OpenSSLAsymmetricKey) {
throw new RuntimeException('Unable to generate a P-256 keypair for tests.');
}
$details = openssl_pkey_get_details($key);
if (!\is_array($details) || !isset($details['ec']['x'], $details['ec']['y'])) {
throw new RuntimeException('Unable to read the generated P-256 keypair.');
}
$this->key = $key;
$this->coordinates = [
'x' => $details['ec']['x'],
'y' => $details['ec']['y'],
];
$this->counter = 0;
}
public function credentialId(): string
{
return random_bytes(16);
}
public function counter(): int
{
return $this->counter;
}
/**
* COSE-encoded public key, as carried in the attested credential data.
*
* Keys are the standard COSE labels: 1 = EC2, 3 = ES256, -1 = P-256,
* -2 = x, -3 = y. Binary coordinates must be byte strings, so they bypass
* the encoder's UTF-8 detection.
*/
public function cosePublicKey(): string
{
return (new Encoder())->encode([
1 => 2,
3 => -7,
-1 => 1,
-2 => ByteStringObject::create($this->coordinates['x']),
-3 => ByteStringObject::create($this->coordinates['y']),
]);
}
/**
* Authenticator data for a registration: rpIdHash, flags (UP|AT|UV), a
* counter, then the attested credential data.
*/
public function attestationAuthenticatorData(string $rpId, string $credentialId, bool $userVerified = true): string
{
$flags = 0x01 | 0x40; /* UP | AT */
if ($userVerified) {
$flags |= 0x04; /* UV */
}
return hash('sha256', $rpId, true)
.\chr($flags)
.pack('N', ++$this->counter)
.hex2bin(self::ZERO_AAGUID)
.pack('n', \strlen($credentialId))
.$credentialId
.$this->cosePublicKey();
}
/**
* Authenticator data for an assertion: rpIdHash, flags (UP|UV), a counter.
* The attested credential data lives in the stored record, not here.
*/
public function assertionAuthenticatorData(string $rpId, int $counter, bool $userVerified = true): string
{
$flags = 0x01; /* UP */
if ($userVerified) {
$flags |= 0x04; /* UV */
}
return hash('sha256', $rpId, true).\chr($flags).pack('N', $counter);
}
/**
* clientDataJSON with the challenge encoded exactly as a browser encodes it.
*
* @throws JsonException
*/
public function clientData(string $type, string $challenge, string $origin): string
{
return json_encode([
'type' => $type,
'challenge' => Base64UrlSafe::encodeUnpadded($challenge),
'origin' => $origin,
'crossOrigin' => false,
], \JSON_THROW_ON_ERROR);
}
/**
* The CBOR attestation object a browser sends, in the `none` format.
*/
public function attestationObject(string $authData): string
{
return (new Encoder())->encode([
'fmt' => 'none',
'attStmt' => [],
'authData' => ByteStringObject::create($authData),
]);
}
/**
* Sign `authData || sha256(clientDataJSON)` with the generated key.
*
* WebAuthn wants the raw 64-byte (r||s) form, but OpenSSL emits DER, so the
* result is converted. Getting this wrong produces a confusing "invalid
* signature" that looks like a logic bug rather than an encoding one.
*/
public function signature(string $authData, string $clientDataJson): string
{
$data = $authData.hash('sha256', $clientDataJson, true);
$der = '';
openssl_sign($data, $der, $this->key, \OPENSSL_ALGO_SHA256);
return self::derToRaw($der);
}
/**
* A ready-to-submit registration `credential`, matching what
* `navigator.credentials.create()` produces.
*
* @return array<string,mixed>
*
* @throws JsonException
*/
public function registrationCredential(
string $rpId,
string $challenge,
string $origin,
?string $credentialId = null,
): array {
$credentialId ??= $this->credentialId();
$clientDataJson = $this->clientData('webauthn.create', $challenge, $origin);
$authData = $this->attestationAuthenticatorData($rpId, $credentialId);
return [
'id' => Base64UrlSafe::encodeUnpadded($credentialId),
'rawId' => Base64UrlSafe::encodeUnpadded($credentialId),
'type' => 'public-key',
'response' => [
'clientDataJSON' => Base64UrlSafe::encodeUnpadded($clientDataJson),
'attestationObject' => Base64UrlSafe::encodeUnpadded($this->attestationObject($authData)),
'transports' => ['internal'],
],
];
}
/**
* A ready-to-submit assertion `credential`, matching what
* `navigator.credentials.get()` produces.
*
* @return array<string,mixed>
*
* @throws JsonException
*/
public function assertionCredential(
string $rpId,
string $challenge,
string $origin,
string $credentialId,
int $counter,
string $userHandle,
): array {
$clientDataJson = $this->clientData('webauthn.get', $challenge, $origin);
$authData = $this->assertionAuthenticatorData($rpId, $counter);
return [
'id' => Base64UrlSafe::encodeUnpadded($credentialId),
'rawId' => Base64UrlSafe::encodeUnpadded($credentialId),
'type' => 'public-key',
'response' => [
'clientDataJSON' => Base64UrlSafe::encodeUnpadded($clientDataJson),
'authenticatorData' => Base64UrlSafe::encodeUnpadded($authData),
'signature' => Base64UrlSafe::encodeUnpadded($this->signature($authData, $clientDataJson)),
'userHandle' => Base64UrlSafe::encodeUnpadded($userHandle),
],
];
}
/**
* Convert an OpenSSL DER signature to the fixed-length raw form WebAuthn
* mandates: 64 bytes for P-256, big-endian r||s.
*
* DER is `30 <len> 02 <lenR> R 02 <lenS> S`. Both integers are
* variable-length and may carry a leading zero byte, so each coordinate is
* right-aligned into exactly 32 bytes. The library rejects anything that is
* not exactly 64 bytes, and a malformed result looks like "invalid
* signature" rather than an encoding bug — hence the explicit round-trip
* check in the tests.
*
* @throws RuntimeException when the input is not a well-formed ECDSA-Sig-Value
*/
private static function derToRaw(string $der): string
{
$length = \strlen($der);
/* short-form SEQUENCE header: tag + one length byte */
if ($length < 8 || 0x30 !== \ord($der[0])) {
throw new RuntimeException('Expected a DER SEQUENCE.');
}
$offset = 2;
if (0x02 !== \ord($der[$offset])) {
throw new RuntimeException('Expected a DER INTEGER for r.');
}
$lengthR = \ord($der[$offset + 1]);
$r = substr($der, $offset + 2, $lengthR);
$offset += 2 + $lengthR;
if (0x02 !== \ord($der[$offset])) {
throw new RuntimeException('Expected a DER INTEGER for s.');
}
$lengthS = \ord($der[$offset + 1]);
$s = substr($der, $offset + 2, $lengthS);
return str_pad(substr($r, -32), 32, "\x00", \STR_PAD_LEFT)
.str_pad(substr($s, -32), 32, "\x00", \STR_PAD_LEFT);
}
}
+18 -3
View File
@@ -5,11 +5,10 @@ declare(strict_types=1);
namespace App\Tests\Support;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use App\Utilities;
use DateTimeImmutable;
use OTPHP\TOTP;
use PHPUnit\Framework\TestCase;
use Override;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface as PsrClockInterface;
@@ -31,10 +30,13 @@ trait TotpTestHelper
private function frozenClock(): PsrClockInterface
{
$time = self::FROZEN_TIME;
return new class ($time) implements PsrClockInterface {
return new class($time) implements PsrClockInterface {
public function __construct(private string $time)
{
}
#[Override]
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->time);
@@ -47,6 +49,7 @@ trait TotpTestHelper
{
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
$totp->setLabel('Test-TOTP');
return $totp->getProvisioningUri();
}
@@ -76,9 +79,15 @@ trait TotpTestHelper
string $remoteUserMode = 'session',
string $remoteUserStatic = 'authenticated',
string $remoteUserMap = '',
string $title = 'Pre-Authentication System',
bool $passkeyEnabled = false,
string $passkeyRpName = '',
string $passkeyUserVerification = 'required',
int $passkeyTimeout = 60000,
): ConfigBag {
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
return new ConfigBag(
$utilities,
$clock,
@@ -92,6 +101,11 @@ trait TotpTestHelper
$remoteUserMode,
$remoteUserStatic,
$remoteUserMap,
$title,
$passkeyEnabled,
$passkeyRpName,
$passkeyUserVerification,
$passkeyTimeout,
);
}
@@ -107,6 +121,7 @@ trait TotpTestHelper
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(false);
$cache->method('getItem')->willReturn($item);
return new Utilities($clock, $cache);
}
}
+2 -2
View File
@@ -28,10 +28,10 @@ class TestKernel extends AppKernel
{
parent::build($container);
$container->addCompilerPass(new class () implements CompilerPassInterface {
$container->addCompilerPass(new class implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) {
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache', 'passkeyRateLimitCache'] as $poolId) {
if ($container->hasDefinition($poolId)) {
$container->getDefinition($poolId)->clearTag('kernel.reset');
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\CacheWarmer;
use App\CacheWarmer\PasskeyConfigurationWarmer;
use App\Exception\PasskeyConfigurationException;
use App\Service\PasskeyPolicyInterface;
use PHPUnit\Framework\TestCase;
/**
* The warmer is the mechanism that turns a bad passkey configuration into a
* failed deployment rather than a broken feature (D1/D4, plan §4.2).
*/
final class PasskeyConfigurationWarmerTest extends TestCase
{
public function test_it_delegates_the_configuration_check(): void
{
$policy = $this->createMock(PasskeyPolicyInterface::class);
$policy->expects(self::once())->method('assertConfigurationIsUsable');
$warmer = new PasskeyConfigurationWarmer($policy);
self::assertSame([], $warmer->warmUp('/tmp/cache'));
}
public function test_it_is_not_optional(): void
{
/* an optional warmer can be skipped, which would defeat the check */
$warmer = new PasskeyConfigurationWarmer($this->createStub(PasskeyPolicyInterface::class));
self::assertFalse($warmer->isOptional());
}
public function test_it_propagates_a_configuration_failure(): void
{
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('assertConfigurationIsUsable')
->willThrowException(new PasskeyConfigurationException('nope'));
$this->expectException(PasskeyConfigurationException::class);
(new PasskeyConfigurationWarmer($policy))->warmUp('/tmp/cache');
}
}
+5 -4
View File
@@ -5,18 +5,19 @@ declare(strict_types=1);
namespace App\Tests\Unit;
use App\Clock;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
final class ClockTest extends TestCase
{
public function testNowReturnsDateTimeImmutable(): void
public function test_now_returns_date_time_immutable(): void
{
$clock = new Clock();
$before = new \DateTimeImmutable();
$before = new DateTimeImmutable();
$now = $clock->now();
$after = new \DateTimeImmutable();
$after = new DateTimeImmutable();
self::assertInstanceOf(\DateTimeImmutable::class, $now);
self::assertInstanceOf(DateTimeImmutable::class, $now);
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
}
@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Tests\Unit\Command;
use App\Command\GenerateBackupCodesCommand;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use App\PersistCache;
use App\Service\BackupCodeInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Tester\CommandTester;
final class GenerateBackupCodesCommandTest extends TestCase
@@ -25,16 +25,17 @@ final class GenerateBackupCodesCommandTest extends TestCase
{
$manager = $this->createStub(BackupCodeInterface::class);
$manager->method('generate')->willReturn($generatedCodes);
return $manager;
}
public function testGenerateDefaultCountOutputsCodes(): void
public function test_generate_default_count_outputs_codes(): void
{
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub($codes),
$this->makePersistCache()
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
@@ -48,7 +49,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
}
}
public function testGenerateSpecificCountPassesCountToManager(): void
public function test_generate_specific_count_passes_count_to_manager(): void
{
$manager = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once())
@@ -65,7 +66,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
self::assertSame(0, $exit);
}
public function testDefaultCountArgumentIsTen(): void
public function test_default_count_argument_is_ten(): void
{
// the configured default for the count argument should be 10
$manager = $this->createMock(BackupCodeInterface::class);
@@ -84,7 +85,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
$this->addToAssertionCount(1);
}
public function testBootsAndPersistsCache(): void
public function test_boots_and_persists_cache(): void
{
// PersistCache is final and can't be mocked, but we can verify the
// command runs end-to-end with a real instance; boot()/persist()
@@ -92,7 +93,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
// without throwing.
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['code1']),
$this->makePersistCache()
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
@@ -102,25 +103,25 @@ final class GenerateBackupCodesCommandTest extends TestCase
self::assertSame(0, $exit);
}
public function testZeroCodesThrowsException(): void
public function test_zero_codes_throws_exception(): void
{
// count must be a positive integer — zero is rejected
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]),
$this->makePersistCache()
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$this->expectException(\Symfony\Component\Console\Exception\InvalidArgumentException::class);
$this->expectException(InvalidArgumentException::class);
$tester->execute(['count' => 0]);
}
public function testCommandNameAndDescriptionAreConfigured(): void
public function test_command_name_and_description_are_configured(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['dummy']),
$this->makePersistCache()
$this->makePersistCache(),
);
// configuring via the Application runs the protected configure()
$app = new \Symfony\Component\Console\Application();

Some files were not shown because too many files have changed in this diff Show More